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) {
save({ busy_input_mode: v ? 'interrupt' : 'off' })" />
+
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 @@
+
+
+
+
+
+
+
![Custom thinking animation]()
+
+
+ {{ t('settings.display.thinkingAnimationReset') }}
+
+
+
+
+ {{ uploading ? t('settings.display.thinkingAnimationUploading') : t('settings.display.thinkingAnimationUpload') }}
+
+
+
+
+
+
+
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
/