diff --git a/packages/client/src/components/hermes/settings/DisplaySettings.vue b/packages/client/src/components/hermes/settings/DisplaySettings.vue index 35491ad2..0c050990 100644 --- a/packages/client/src/components/hermes/settings/DisplaySettings.vue +++ b/packages/client/src/components/hermes/settings/DisplaySettings.vue @@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n' import { useSettingsStore } from '@/stores/hermes/settings' import { useTheme, type ThemeMode } from '@/composables/useTheme' import SettingRow from './SettingRow.vue' +import ThinkingAnimationPicker from './ThinkingAnimationPicker.vue' const settingsStore = useSettingsStore() const message = useMessage() @@ -58,13 +59,17 @@ function handleThemeChange(val: string) { + diff --git a/packages/client/src/components/hermes/settings/ThinkingAnimationPicker.vue b/packages/client/src/components/hermes/settings/ThinkingAnimationPicker.vue new file mode 100644 index 00000000..8f21c5b0 --- /dev/null +++ b/packages/client/src/components/hermes/settings/ThinkingAnimationPicker.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/packages/client/src/i18n/locales/de.ts b/packages/client/src/i18n/locales/de.ts index 2bd880d4..a1cf37f8 100644 --- a/packages/client/src/i18n/locales/de.ts +++ b/packages/client/src/i18n/locales/de.ts @@ -394,6 +394,16 @@ export default { themeLight: 'Hell', themeDark: 'Dunkel', themeSystem: 'System', + thinkingAnimation: 'Denk-Animation', + thinkingAnimationHint: 'Laden Sie eine benutzerdefinierte Animation (GIF, MP4, WebM, MOV, AVI, MKV) hoch, um die Standard-AI-Denk-Animation zu ersetzen', + thinkingAnimationUpload: 'Animation hochladen', + thinkingAnimationUploading: 'Hochladen...', + thinkingAnimationUploaded: 'Animation erfolgreich hochgeladen', + thinkingAnimationReset: 'Auf Standard zurücksetzen', + thinkingAnimationResetDone: 'Animation auf Standard zurückgesetzt', + thinkingAnimationFailed: 'Upload fehlgeschlagen', + thinkingAnimationUnsupported: 'Nicht unterstützter Dateityp (verwenden Sie GIF, MP4, WebM, MOV, AVI oder MKV)', + thinkingAnimationTooLarge: 'Datei zu groß (max. 100MB)', }, agent: { maxTurns: 'Maximale Runden', diff --git a/packages/client/src/i18n/locales/en.ts b/packages/client/src/i18n/locales/en.ts index 992ea4ae..d9c4a08c 100644 --- a/packages/client/src/i18n/locales/en.ts +++ b/packages/client/src/i18n/locales/en.ts @@ -435,6 +435,16 @@ export default { themeLight: 'Light', themeDark: 'Dark', themeSystem: 'System', + thinkingAnimation: 'Thinking Animation', + thinkingAnimationHint: 'Upload a custom animation (GIF, MP4, WebM, MOV, AVI, MKV) to replace the default AI thinking animation', + thinkingAnimationUpload: 'Upload Animation', + thinkingAnimationUploading: 'Uploading...', + thinkingAnimationUploaded: 'Animation uploaded successfully', + thinkingAnimationReset: 'Reset to Default', + thinkingAnimationResetDone: 'Animation reset to default', + thinkingAnimationFailed: 'Upload failed', + thinkingAnimationUnsupported: 'Unsupported file type (use GIF, MP4, WebM, MOV, AVI, or MKV)', + thinkingAnimationTooLarge: 'File too large (max 100MB)', }, agent: { maxTurns: 'Max Turns', diff --git a/packages/client/src/i18n/locales/es.ts b/packages/client/src/i18n/locales/es.ts index df18059d..d24fba4a 100644 --- a/packages/client/src/i18n/locales/es.ts +++ b/packages/client/src/i18n/locales/es.ts @@ -394,6 +394,16 @@ export default { themeLight: 'Claro', themeDark: 'Oscuro', themeSystem: 'Sistema', + thinkingAnimation: 'Animación de pensamiento', + thinkingAnimationHint: 'Sube una animación personalizada (GIF, MP4, WebM, MOV, AVI, MKV) para reemplazar la animación de pensamiento de IA predeterminada', + thinkingAnimationUpload: 'Subir animación', + thinkingAnimationUploading: 'Subiendo...', + thinkingAnimationUploaded: 'Animación subida exitosamente', + thinkingAnimationReset: 'Restablecer predeterminado', + thinkingAnimationResetDone: 'Animación restablecida a predeterminada', + thinkingAnimationFailed: 'Error al subir', + thinkingAnimationUnsupported: 'Tipo de archivo no compatible (usa GIF, MP4, WebM, MOV, AVI o MKV)', + thinkingAnimationTooLarge: 'Archivo demasiado grande (máx 100MB)', }, agent: { maxTurns: 'Turnos maximos', diff --git a/packages/client/src/i18n/locales/fr.ts b/packages/client/src/i18n/locales/fr.ts index acc2bdcf..09c9b07d 100644 --- a/packages/client/src/i18n/locales/fr.ts +++ b/packages/client/src/i18n/locales/fr.ts @@ -394,6 +394,16 @@ export default { themeLight: 'Clair', themeDark: 'Sombre', themeSystem: 'Systeme', + thinkingAnimation: 'Animation de réflexion', + thinkingAnimationHint: 'Téléchargez une animation personnalisée (GIF, MP4, WebM, MOV, AVI, MKV) pour remplacer l\'animation de réflexion IA par défaut', + thinkingAnimationUpload: 'Télécharger l\'animation', + thinkingAnimationUploading: 'Téléchargement...', + thinkingAnimationUploaded: 'Animation téléchargée avec succès', + thinkingAnimationReset: 'Rétablir par défaut', + thinkingAnimationResetDone: 'Animation rétablie par défaut', + thinkingAnimationFailed: 'Échec du téléchargement', + thinkingAnimationUnsupported: 'Type de fichier non pris en charge (utilisez GIF, MP4, WebM, MOV, AVI ou MKV)', + thinkingAnimationTooLarge: 'Fichier trop volumineux (max 100 Mo)', }, agent: { maxTurns: 'Tours maximum', diff --git a/packages/client/src/i18n/locales/ja.ts b/packages/client/src/i18n/locales/ja.ts index c55e1ae0..eed30e04 100644 --- a/packages/client/src/i18n/locales/ja.ts +++ b/packages/client/src/i18n/locales/ja.ts @@ -394,6 +394,16 @@ export default { themeLight: 'ライト', themeDark: 'ダーク', themeSystem: 'システム', + thinkingAnimation: '思考アニメーション', + thinkingAnimationHint: 'カスタムアニメーション(GIF、MP4、WebM、MOV、AVI、MKV)をアップロードして、デフォルトのAI思考アニメーションを置き換えます', + thinkingAnimationUpload: 'アニメーションをアップロード', + thinkingAnimationUploading: 'アップロード中...', + thinkingAnimationUploaded: 'アニメーションのアップロードが成功しました', + thinkingAnimationReset: 'デフォルトに戻す', + thinkingAnimationResetDone: 'デフォルトアニメーションに戻しました', + thinkingAnimationFailed: 'アップロードに失敗しました', + thinkingAnimationUnsupported: 'サポートされていないファイル形式(GIF、MP4、WebM、MOV、AVI、MKVを使用)', + thinkingAnimationTooLarge: 'ファイルが大きすぎます(最大100MB)', }, agent: { maxTurns: '最大ターン数', diff --git a/packages/client/src/i18n/locales/ko.ts b/packages/client/src/i18n/locales/ko.ts index aa09ccdb..11dba9eb 100644 --- a/packages/client/src/i18n/locales/ko.ts +++ b/packages/client/src/i18n/locales/ko.ts @@ -394,6 +394,16 @@ export default { themeLight: '라이트', themeDark: '다크', themeSystem: '시스템', + thinkingAnimation: '사고 애니메이션', + thinkingAnimationHint: '커스텀 애니메이션(GIF, MP4, WebM, MOV, AVI, MKV)을 업로드하여 기본 AI 사고 애니메이션을 대체합니다', + thinkingAnimationUpload: '애니메이션 업로드', + thinkingAnimationUploading: '업로드 중...', + thinkingAnimationUploaded: '애니메이션이 성공적으로 업로드되었습니다', + thinkingAnimationReset: '기본값으로 복원', + thinkingAnimationResetDone: '기본 애니메이션으로 복원되었습니다', + thinkingAnimationFailed: '업로드에 실패했습니다', + thinkingAnimationUnsupported: '지원되지 않는 파일 형식(GIF, MP4, WebM, MOV, AVI, MKV 사용)', + thinkingAnimationTooLarge: '파일이 너무 큽니다(최대 100MB)', }, agent: { maxTurns: '최대 턴 수', diff --git a/packages/client/src/i18n/locales/pt.ts b/packages/client/src/i18n/locales/pt.ts index 02183f33..01ef1210 100644 --- a/packages/client/src/i18n/locales/pt.ts +++ b/packages/client/src/i18n/locales/pt.ts @@ -394,6 +394,16 @@ export default { themeLight: 'Claro', themeDark: 'Escuro', themeSystem: 'Sistema', + thinkingAnimation: 'Animação de pensamento', + thinkingAnimationHint: 'Envie uma animação personalizada (GIF, MP4, WebM, MOV, AVI, MKV) para substituir a animação de pensamento de IA padrão', + thinkingAnimationUpload: 'Enviar animação', + thinkingAnimationUploading: 'Enviando...', + thinkingAnimationUploaded: 'Animação enviada com sucesso', + thinkingAnimationReset: 'Redefinir para padrão', + thinkingAnimationResetDone: 'Animação redefinida para o padrão', + thinkingAnimationFailed: 'Falha no envio', + thinkingAnimationUnsupported: 'Tipo de arquivo não suportado (use GIF, MP4, WebM, MOV, AVI ou MKV)', + thinkingAnimationTooLarge: 'Arquivo muito grande (máx 100MB)', }, agent: { maxTurns: 'Maximo de turnos', diff --git a/packages/client/src/i18n/locales/zh.ts b/packages/client/src/i18n/locales/zh.ts index a274fb22..d6e2efb9 100644 --- a/packages/client/src/i18n/locales/zh.ts +++ b/packages/client/src/i18n/locales/zh.ts @@ -427,6 +427,16 @@ export default { themeLight: '浅色', themeDark: '暗色', themeSystem: '跟随系统', + thinkingAnimation: '思考动画', + thinkingAnimationHint: '上传自定义动画(GIF、MP4、WebM、MOV、AVI、MKV)替换默认 AI 思考动画', + thinkingAnimationUpload: '上传动画', + thinkingAnimationUploading: '上传中...', + thinkingAnimationUploaded: '动画上传成功', + thinkingAnimationReset: '恢复默认', + thinkingAnimationResetDone: '已恢复默认动画', + thinkingAnimationFailed: '上传失败', + thinkingAnimationUnsupported: '不支持的文件类型(请使用 GIF、MP4、WebM、MOV、AVI 或 MKV)', + thinkingAnimationTooLarge: '文件过大(最大 100MB)', }, agent: { maxTurns: '最大轮次', diff --git a/packages/server/src/controllers/thinking-animation.ts b/packages/server/src/controllers/thinking-animation.ts new file mode 100644 index 00000000..4ce169c9 --- /dev/null +++ b/packages/server/src/controllers/thinking-animation.ts @@ -0,0 +1,162 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'fs' +import { resolve, join, basename } from 'path' +import { randomBytes } from 'crypto' +import { config } from '../config' + +const DATA_DIR = join(config.dataDir, 'thinking-animation') +const ALLOWED_EXTS = ['.gif', '.mp4', '.webm', '.mov', '.avi', '.mkv'] +const MAX_SIZE = 100 * 1024 * 1024 // 100MB + +// Ensure data directory exists +if (!existsSync(DATA_DIR)) { + mkdirSync(DATA_DIR, { recursive: true }) +} + +/** Safe path resolution to prevent traversal */ +function safePath(filename: string): string | null { + const cleaned = basename(filename) + if (cleaned !== filename || cleaned === '.' || cleaned === '..') return null + const ext = '.' + cleaned.split('.').pop()?.toLowerCase() + if (!ALLOWED_EXTS.includes(ext)) return null + return join(DATA_DIR, cleaned) +} + +/** Split multipart body into parts */ +function splitMultipart(raw: Buffer, boundary: Buffer): Buffer[] { + const parts: Buffer[] = [] + let start = 0 + while (true) { + const idx = raw.indexOf(boundary, start) + if (idx === -1) break + if (start > 0) { + const end = idx - 2 // before \r\n + if (end > start) parts.push(raw.subarray(start, end)) + } + start = idx + boundary.length + 2 // skip \r\n after boundary + } + return parts +} + +/** GET /api/thinking-animation/status — public */ +export async function getStatus(ctx: any) { + if (!existsSync(DATA_DIR)) { + ctx.body = { hasCustom: false }; return + } + const { readdirSync } = await import('fs') + const files = readdirSync(DATA_DIR).filter(f => { + const ext = '.' + f.split('.').pop()?.toLowerCase() + return ALLOWED_EXTS.includes(ext) + }) + if (files.length === 0) { + ctx.body = { hasCustom: false }; return + } + const file = files[0] + const ext = '.' + file.split('.').pop()?.toLowerCase() + ctx.body = { + hasCustom: true, + filename: file, + type: ext === '.gif' ? 'gif' : 'video', + url: `/api/thinking-animation/file/${encodeURIComponent(file)}` + } +} + +/** GET /api/thinking-animation/file/:filename — public */ +export async function getFile(ctx: any) { + const filename = ctx.params.filename + const filePath = safePath(filename) + if (!filePath || !existsSync(filePath)) { + ctx.status = 404; ctx.body = { error: 'Not found' }; return + } + const ext = '.' + filename.split('.').pop()?.toLowerCase() + const mimeMap: Record = { + '.gif': 'image/gif', '.mp4': 'video/mp4', '.webm': 'video/webm', + '.mov': 'video/quicktime', '.avi': 'video/x-msvideo', '.mkv': 'video/x-matroska' + } + ctx.set('Content-Type', mimeMap[ext] || 'application/octet-stream') + ctx.set('Cache-Control', 'public, max-age=86400') + ctx.body = readFileSync(filePath) +} + +/** POST /api/thinking-animation/upload — protected */ +export async function upload(ctx: any) { + const contentType = ctx.get('content-type') || '' + if (!contentType.startsWith('multipart/form-data')) { + ctx.status = 400; ctx.body = { error: 'Expected multipart/form-data' }; return + } + const boundary = '--' + contentType.split('boundary=')[1] + if (!boundary) { + ctx.status = 400; ctx.body = { error: 'Missing boundary' }; return + } + + const chunks: Buffer[] = [] + let totalSize = 0 + for await (const chunk of ctx.req) { + totalSize += chunk.length + if (totalSize > MAX_SIZE) { + ctx.status = 413; ctx.body = { error: `File too large (max ${MAX_SIZE / 1024 / 1024}MB)` }; return + } + chunks.push(chunk) + } + const raw = Buffer.concat(chunks) + const boundaryBuf = Buffer.from(boundary) + const parts = splitMultipart(raw, boundaryBuf) + + for (const part of parts) { + const headerEnd = part.indexOf(Buffer.from('\r\n\r\n')) + if (headerEnd === -1) continue + const header = part.subarray(0, headerEnd).toString('utf-8') + const data = part.subarray(headerEnd + 4, part.length - 2) + + let filename = '' + const filenameStarMatch = header.match(/filename\*=UTF-8''(.+)/i) + if (filenameStarMatch) { + filename = decodeURIComponent(filenameStarMatch[1]) + } else { + const filenameMatch = header.match(/filename="([^"]+)"/) + if (!filenameMatch) continue + filename = filenameMatch[1] + } + + const ext = '.' + filename.split('.').pop()?.toLowerCase() + if (!ALLOWED_EXTS.includes(ext)) { + ctx.status = 400; ctx.body = { error: `Unsupported file type: ${ext}` }; return + } + + // Delete old animation files + const { readdirSync } = await import('fs') + if (existsSync(DATA_DIR)) { + for (const old of readdirSync(DATA_DIR)) { + try { unlinkSync(join(DATA_DIR, old)) } catch {} + } + } + + // Save new file with random name + const savedName = randomBytes(8).toString('hex') + ext + writeFileSync(join(DATA_DIR, savedName), data) + + ctx.body = { + success: true, + filename: savedName, + originalName: filename, + size: data.length, + type: ext === '.gif' ? 'gif' : 'video', + url: `/api/thinking-animation/file/${encodeURIComponent(savedName)}` + } + return + } + + ctx.status = 400; ctx.body = { error: 'No file found in upload' } +} + +/** DELETE /api/thinking-animation — protected */ +export async function resetAnimation(ctx: any) { + if (!existsSync(DATA_DIR)) { + ctx.body = { success: true }; return + } + const { readdirSync } = await import('fs') + const files = readdirSync(DATA_DIR) + for (const f of files) { + try { unlinkSync(join(DATA_DIR, f)) } catch {} + } + ctx.body = { success: true } +} diff --git a/packages/server/src/routes/index.ts b/packages/server/src/routes/index.ts index f1294b54..d2585047 100644 --- a/packages/server/src/routes/index.ts +++ b/packages/server/src/routes/index.ts @@ -27,6 +27,9 @@ import { jobRoutes } from './hermes/jobs' import { proxyRoutes, proxyMiddleware } from './hermes/proxy' import { groupChatRoutes, setGroupChatServer } from './hermes/group-chat' +// Thinking animation routes +import { thinkingAnimationPublicRoutes, thinkingAnimationProtectedRoutes } from './thinking-animation' + /** * Register all routes on the Koa app. * Public routes are registered first, then auth middleware, @@ -37,6 +40,7 @@ export function registerRoutes(app: any, requireAuth: (ctx: Context, next: Next) app.use(healthRoutes.routes()) app.use(webhookRoutes.routes()) app.use(authPublicRoutes.routes()) + app.use(thinkingAnimationPublicRoutes.routes()) // --- Auth middleware: all routes below require authentication --- app.use(requireAuth) @@ -62,6 +66,7 @@ export function registerRoutes(app: any, requireAuth: (ctx: Context, next: Next) app.use(fileRoutes.routes()) // Must be before proxy (proxy catch-all matches everything) app.use(downloadRoutes.routes()) // Must be before proxy app.use(jobRoutes.routes()) // Must be before proxy + app.use(thinkingAnimationProtectedRoutes.routes()) app.use(proxyRoutes.routes()) // Proxy catch-all middleware (must be last) diff --git a/packages/server/src/routes/thinking-animation.ts b/packages/server/src/routes/thinking-animation.ts new file mode 100644 index 00000000..2f464b75 --- /dev/null +++ b/packages/server/src/routes/thinking-animation.ts @@ -0,0 +1,12 @@ +import Router from '@koa/router' +import * as ctrl from '../controllers/thinking-animation' + +// Public routes (no auth required) — needed for /