Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/client/src/api/hermes/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface DisplayConfig {
inline_diffs?: boolean
show_cost?: boolean
skin?: string
thinking_video_url?: string
}

export interface AgentConfig {
Expand Down
22 changes: 21 additions & 1 deletion packages/client/src/components/hermes/chat/MessageList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,25 @@ import { useChatStore } from "@/stores/hermes/chat";
import thinkingVideoLight from "@/assets/thinking-light.mp4";
import thinkingVideoDark from "@/assets/thinking-dark.mp4";
import { useTheme } from "@/composables/useTheme";
import { useSettingsStore } from "@/stores/hermes/settings";
import { useToolTraceVisibility } from "@/composables/useToolTraceVisibility";

const chatStore = useChatStore();
const { t } = useI18n();
const { isDark } = useTheme();
const settingsStore = useSettingsStore();
const { toolTraceVisible } = useToolTraceVisibility();

const thinkingIsGif = computed(() => {
const url = settingsStore.display?.thinking_video_url?.trim()
return !!url && /\.gif(\?.*)?$/i.test(url)
})

const thinkingVideoSrc = computed(() => {
const custom = settingsStore.display.thinking_video_url;
if (custom && custom.trim()) return custom.trim();
return isDark.value ? thinkingVideoDark : thinkingVideoLight;
});
const listRef = ref<HTMLElement>();

function formatTokens(n: number): string {
Expand Down Expand Up @@ -172,8 +185,15 @@ watch(currentToolCalls, () => {
/>
<Transition name="fade">
<div v-if="chatStore.isRunActive || chatStore.abortState" class="streaming-indicator">
<img
v-if="thinkingIsGif"
:src="thinkingVideoSrc"
class="thinking-video"
alt="thinking"
/>
<video
:src="isDark ? thinkingVideoDark : thinkingVideoLight"
v-else
:src="thinkingVideoSrc"
autoplay
loop
muted
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { NSwitch, NSelect, useMessage } from 'naive-ui'
import { ref } from 'vue'
import { NSwitch, NSelect, NButton, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/hermes/settings'
import { useTheme, type BrightnessMode } from '@/composables/useTheme'
Expand Down Expand Up @@ -30,6 +31,35 @@ function handleThemeChange(val: string) {
setBrightness(m)
save({ skin: m })
}

const animationUploading = ref(false)
const animationFileInput = ref<HTMLInputElement | null>(null)

async function handleAnimationFile(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
animationUploading.value = true
try {
const fd = new FormData()
fd.append('file', file)
const res = await fetch('/api/hermes/upload/thinking-animation', { method: 'POST', body: fd })
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || res.statusText)
}
const { url } = await res.json()
await save({ thinking_video_url: url })
} catch (err: any) {
message.error(err.message || t('settings.saveFailed'))
} finally {
animationUploading.value = false
if (animationFileInput.value) animationFileInput.value.value = ''
}
}

async function clearAnimation() {
await save({ thinking_video_url: undefined })
}
</script>

<template>
Expand Down Expand Up @@ -58,6 +88,26 @@ function handleThemeChange(val: string) {
<SettingRow :label="t('settings.display.busyInputMode')" :hint="t('settings.display.busyInputModeHint')">
<NSwitch :value="settingsStore.display.busy_input_mode === 'interrupt'" @update:value="v => save({ busy_input_mode: v ? 'interrupt' : 'off' })" />
</SettingRow>
<SettingRow :label="t('settings.display.thinkingVideoUrl')" :hint="t('settings.display.thinkingVideoUrlHint')">
<div class="animation-upload">
<span v-if="settingsStore.display.thinking_video_url" class="animation-filename">
{{ settingsStore.display.thinking_video_url.split('/').pop() }}
</span>
<NButton size="small" :loading="animationUploading" @click="animationFileInput?.click()">
{{ settingsStore.display.thinking_video_url ? t('settings.display.thinkingVideoUrlReplace') : t('settings.display.thinkingVideoUrlUpload') }}
</NButton>
<NButton v-if="settingsStore.display.thinking_video_url" size="small" @click="clearAnimation">
{{ t('settings.display.thinkingVideoUrlClear') }}
</NButton>
<input
ref="animationFileInput"
type="file"
accept=".gif,.mp4,.webm,.png,.jpg,.jpeg,.webp"
style="display:none"
@change="handleAnimationFile"
/>
</div>
</SettingRow>
</section>
</template>

Expand All @@ -67,4 +117,19 @@ function handleThemeChange(val: string) {
.settings-section {
margin-top: 16px;
}

.animation-upload {
display: flex;
align-items: center;
gap: 8px;
}

.animation-filename {
font-size: 12px;
opacity: 0.7;
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
5 changes: 5 additions & 0 deletions packages/client/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,11 @@ export default {
bellOnCompleteHint: 'Play sound when AI finishes',
busyInputMode: 'Busy Input Mode',
busyInputModeHint: 'Allow input while AI is processing',
thinkingVideoUrl: 'Thinking Animation',
thinkingVideoUrlHint: 'Upload a GIF or video to replace the default thinking animation.',
thinkingVideoUrlUpload: 'Upload',
thinkingVideoUrlReplace: 'Replace',
thinkingVideoUrlClear: 'Clear',
theme: 'Theme',
themeHint: 'Choose light, dark, or follow system preference',
themeLight: 'Light',
Expand Down
5 changes: 5 additions & 0 deletions packages/client/src/i18n/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -726,6 +726,11 @@ export default {
bellOnCompleteHint: 'AI 回复完成时播放提示音',
busyInputMode: '忙碌输入模式',
busyInputModeHint: 'AI 处理中仍可输入',
thinkingVideoUrl: '思考动画',
thinkingVideoUrlHint: '上传 GIF 或视频,替换默认的思考动画。',
thinkingVideoUrlUpload: '上传',
thinkingVideoUrlReplace: '替换',
thinkingVideoUrlClear: '清除',
theme: '主题',
themeHint: '选择浅色、暗色或跟随系统',
themeLight: '浅色',
Expand Down
48 changes: 48 additions & 0 deletions packages/server/src/controllers/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,54 @@ export async function handleUpload(ctx: any) {
ctx.body = { files: results }
}

const ALLOWED_ANIMATION_EXTS = new Set(['.gif', '.mp4', '.webm', '.png', '.jpg', '.jpeg', '.webp'])

export async function handleThinkingAnimationUpload(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 || boundary === '--undefined') {
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_UPLOAD_SIZE) {
ctx.status = 413; ctx.body = { error: `File too large (max ${MAX_UPLOAD_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.includes('.') ? '.' + filename.split('.').pop()! : '').toLowerCase()
if (!ALLOWED_ANIMATION_EXTS.has(ext)) {
ctx.status = 400; ctx.body = { error: `Unsupported file type: ${ext}. Allowed: gif, mp4, webm, png, jpg, webp` }; return
}
const savedName = randomBytes(8).toString('hex') + ext
await writeFile(join(config.uploadDir, savedName), data)
ctx.body = { url: `/user-uploads/${savedName}` }
return
}
ctx.status = 400; ctx.body = { error: 'No file found in request' }
}

function splitMultipart(raw: Buffer, boundary: Buffer): Buffer[] {
const parts: Buffer[] = []
let start = 0
Expand Down
10 changes: 10 additions & 0 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,16 @@ export async function bootstrap() {
logger.info('Auth enabled — token: %s', authToken)
}

// Serve user-uploaded files (thinking animation, etc.)
app.use(async (ctx, next) => {
if (ctx.path.startsWith('/user-uploads/')) {
ctx.path = ctx.path.slice('/user-uploads'.length)
await serve(config.uploadDir)(ctx, next)
} else {
await next()
}
})

// SPA fallback
const distDir = resolve(__dirname, '..', 'client')
app.use(serve(distDir))
Expand Down
1 change: 1 addition & 0 deletions packages/server/src/routes/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ import * as ctrl from '../controllers/upload'
export const uploadRoutes = new Router()

uploadRoutes.post('/upload', ctrl.handleUpload)
uploadRoutes.post('/api/hermes/upload/thinking-animation', ctrl.handleThinkingAnimationUpload)