From ae5676cd3057dcfcc14c1c3a79432883e592e920 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sat, 4 Jul 2026 22:33:00 +0800 Subject: [PATCH] feat(ui): add assistant status avatar --- modules/personalization_manager.py | 7 + static/src/App.vue | 3 +- static/src/app/bootstrap.ts | 5 +- static/src/app/components.ts | 2 + static/src/app/computed.ts | 156 +++++ static/src/app/methods/history.ts | 10 +- static/src/app/methods/ui/scroll.ts | 38 ++ static/src/components/avatar/StatusAvatar.vue | 616 ++++++++++++++++++ static/src/components/chat/ChatArea.vue | 137 ++-- .../personalization/PersonalizationDrawer.vue | 22 + static/src/stores/personalization.ts | 3 + .../styles/components/chat/_chat-area.scss | 93 +++ .../styles/components/input/_composer.scss | 6 +- static/src/utils/avatarFace.ts | 44 ++ 14 files changed, 1091 insertions(+), 51 deletions(-) create mode 100644 static/src/components/avatar/StatusAvatar.vue create mode 100644 static/src/utils/avatarFace.ts diff --git a/modules/personalization_manager.py b/modules/personalization_manager.py index 62e04b4..3d336d7 100644 --- a/modules/personalization_manager.py +++ b/modules/personalization_manager.py @@ -94,6 +94,7 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = { "silent_tool_disable": True, # 禁用工具时不向模型插入提示(默认开启) "enhanced_tool_display": True, # 增强工具显示 "compact_message_display": "full", # 简略消息显示:full-完整原始内容 / brief-一行概要 + "show_status_avatar": True, # 是否显示助手状态形象 "stacked_hide_borders": False, # 堆叠块隐藏边线 "show_git_status_bar": True, # 是否显示输入栏上方 Git 状态栏 "versioning_restore_mode": "overwrite", # 版本回溯模式固定为 overwrite @@ -433,6 +434,12 @@ def sanitize_personalization_payload( else: base["stacked_hide_borders"] = bool(base.get("stacked_hide_borders", False)) + # 助手状态形象显示开关 + if "show_status_avatar" in data: + base["show_status_avatar"] = bool(data.get("show_status_avatar")) + else: + base["show_status_avatar"] = bool(base.get("show_status_avatar", True)) + # Git 状态栏显示开关 if "show_git_status_bar" in data: base["show_git_status_bar"] = bool(data.get("show_git_status_bar")) diff --git a/static/src/App.vue b/static/src/App.vue index 46de6d3..3ae5834 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -222,6 +222,7 @@ v-show="chatDisplayMode === 'chat'" ref="messagesArea" :messages="messages" + :avatar-status="avatarStatus" :icon-style="iconStyle" :expanded-blocks="expandedBlocks" :render-markdown="renderMarkdown" @@ -242,7 +243,7 @@
- +

{{ blankWelcomeText }}

diff --git a/static/src/app/bootstrap.ts b/static/src/app/bootstrap.ts index 98dcfb5..5d06b72 100644 --- a/static/src/app/bootstrap.ts +++ b/static/src/app/bootstrap.ts @@ -1134,7 +1134,10 @@ function setupLayoutDebugObservers() { layoutDebugObserver = new MutationObserver((mutations) => { const interesting = mutations.filter((m) => { if (!(m.target instanceof Element)) return false; - const className = (m.target as HTMLElement).className || ''; + const className = + typeof (m.target as HTMLElement).className === 'string' + ? (m.target as HTMLElement).className + : m.target.getAttribute('class') || ''; return ( m.type === 'attributes' && (tracked.some((el) => el === m.target) || diff --git a/static/src/app/components.ts b/static/src/app/components.ts index f34ecef..20ac0c4 100644 --- a/static/src/app/components.ts +++ b/static/src/app/components.ts @@ -9,6 +9,7 @@ import TokenDrawer from '../components/token/TokenDrawer.vue'; import QuickMenu from '../components/input/QuickMenu.vue'; import InputComposer from '../components/input/InputComposer.vue'; import AppShell from '../components/shell/AppShell.vue'; +import StatusAvatar from '../components/avatar/StatusAvatar.vue'; const PersonalizationDrawer = defineAsyncComponent( () => import('../components/personalization/PersonalizationDrawer.vue') @@ -57,6 +58,7 @@ export const appComponents = { QuickMenu, InputComposer, AppShell, + StatusAvatar, ImagePicker, ConversationReviewDialog, SubAgentActivityDialog, diff --git a/static/src/app/computed.ts b/static/src/app/computed.ts index 45cdce0..8f54107 100644 --- a/static/src/app/computed.ts +++ b/static/src/app/computed.ts @@ -13,6 +13,45 @@ import { useFocusStore } from '../stores/focus'; import { useUploadStore } from '../stores/upload'; import { useMonitorStore } from '../stores/monitor'; import { usePolicyStore } from '../stores/policy'; +import { useSubAgentStore } from '../stores/subAgent'; +import { useBackgroundCommandStore } from '../stores/backgroundCommand'; +import { usePersonalizationStore } from '../stores/personalization'; +import { getToolStatusText, getToolDescription } from '../utils/chatDisplay'; +import { toolFaceKey } from '../utils/avatarFace'; + +// 取最后一段连续的 tool actions(对应模型一次并行调用的一批工具) +function getLatestToolSegment(actions) { + if (!Array.isArray(actions)) return []; + let last = -1; + for (let i = actions.length - 1; i >= 0; i--) { + if (actions[i]?.type === 'tool') { + last = i; + break; + } + if (actions[i]?.type === 'thinking') break; + } + if (last < 0) return []; + let start = last; + while (start > 0 && actions[start - 1]?.type === 'tool') start--; + return actions.slice(start, last + 1); +} + +const AVATAR_RUNNING_TOOL_STATUS = new Set([ + 'preparing', + 'running', + 'pending', + 'queued', + 'awaiting_approval', + 'pending_approval', + 'awaiting_user_answer' +]); +const AVATAR_TERMINAL_STATUS = new Set([ + 'completed', + 'failed', + 'timeout', + 'terminated', + 'cancelled' +]); export const computed = { ...mapWritableState(useConnectionStore, [ @@ -211,6 +250,9 @@ export const computed = { composerHeroActive() { return (this.blankHeroActive || this.blankHeroExiting) && !this.composerInteractionActive; }, + showStatusAvatar() { + return usePersonalizationStore().form?.show_status_avatar !== false; + }, composerInteractionActive() { const hasText = !!(this.inputMessage && this.inputMessage.trim().length > 0); const hasImages = Array.isArray(this.selectedImages) && this.selectedImages.length > 0; @@ -234,5 +276,119 @@ export const computed = { width: w + 'px', gridTemplateRows: rows }; + }, + // 状态形象:驱动 StatusAvatar(欢迎页 + 对话末尾指示器) + avatarStatus() { + const messages = Array.isArray(this.messages) ? this.messages : []; + const lastAssistant = (() => { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i]?.role === 'assistant') return messages[i]; + } + return null; + })(); + + // 后台运行计数(用于 work 文案) + const subAgentStore = useSubAgentStore(); + const bgStore = useBackgroundCommandStore(); + const countRunning = (arr) => + (Array.isArray(arr) ? arr : []).filter((item) => { + if (!item) return false; + const status = String(item?.status || '').toLowerCase(); + return status ? !AVATAR_TERMINAL_STATUS.has(status) : true; + }).length; + const agentCount = countRunning(subAgentStore.subAgents); + const cmdCount = countRunning(bgStore.commands); + const bgText = (() => { + const parts = []; + if (cmdCount > 0) parts.push(`${cmdCount}个后台指令`); + if (agentCount > 0) parts.push(`${agentCount}个后台智能体`); + return parts.length ? `${parts.join(',')}运行中...` : ''; + })(); + + const intentEnabled = !!usePersonalizationStore().form?.tool_intent_enabled; + + // 1) 思考中 + const isThinking = + !!lastAssistant && + (lastAssistant.currentStreamingType === 'thinking' || lastAssistant.activeThinkingId != null); + + // 2) 工具执行中(取最后一段并行工具,过滤未完成的) + let runningTools = []; + if (lastAssistant) { + const segment = getLatestToolSegment(lastAssistant.actions || []); + runningTools = segment.filter((a) => { + const s = String(a?.tool?.status || '').toLowerCase(); + return a?.tool?.awaiting_content || !s || AVATAR_RUNNING_TOOL_STATUS.has(s); + }); + } + if (runningTools.length === 0) { + const mapActions = [ + ...Array.from(this.preparingTools?.values?.() || []), + ...Array.from(this.activeTools?.values?.() || []) + ]; + runningTools = mapActions + .map((item) => (item?.type === 'tool' ? item : { type: 'tool', tool: item })) + .filter((a) => { + const tool = a?.tool; + if (!tool) return false; + const s = String(tool?.status || '').toLowerCase(); + return tool?.awaiting_content || !s || AVATAR_RUNNING_TOOL_STATUS.has(s); + }); + } + const hasMapTools = + (this.preparingTools && this.preparingTools.size > 0) || + (this.activeTools && this.activeTools.size > 0); + + // 3) 运行/等待态标志 + const running = + this.streamingMessage || + this.taskInProgress || + this.waitingForSubAgent || + this.waitingForBackgroundCommand || + this.stopRequested || + hasMapTools || + runningTools.length > 0 || + agentCount > 0 || + cmdCount > 0; + + // ---- 决策 ---- + if (isThinking) { + return { mode: 'think', toolKeys: [], toolTexts: [], text: '思考中...', tracking: false }; + } + if (runningTools.length > 0) { + const keys = runningTools.map((a) => toolFaceKey(a?.tool?.name)); + const toolTexts = runningTools.map((a) => { + const tool = a?.tool; + if (!tool) return ''; + const status = getToolStatusText(tool, { intentEnabled }); + const desc = getToolDescription(tool); + return desc ? `${status} · ${desc}` : status; + }); + let text = toolTexts[0] || ''; + if (runningTools.length > 1) { + text = toolTexts[0] || `并行调用 ${runningTools.length} 个工具...`; + } else if (!text) { + const first = runningTools[0]?.tool; + const status = getToolStatusText(first, { intentEnabled }); + const desc = getToolDescription(first); + text = desc ? `${status} · ${desc}` : status; + } + return { mode: 'tool', toolKeys: keys, toolTexts, text, tracking: false }; + } + if (running) { + // 工作态:等 API / 后台运行 / 流式输出正文 + let text = bgText; + if (!text) { + const awaitingMsg = lastAssistant && lastAssistant.awaitingFirstContent ? lastAssistant : null; + if (awaitingMsg) { + text = + (typeof awaitingMsg.generatingLabel === 'string' && awaitingMsg.generatingLabel.trim()) || + ''; + } + } + return { mode: 'work', toolKeys: [], toolTexts: [], text, tracking: false }; + } + // 空闲 + return { mode: 'idle', toolKeys: [], toolTexts: [], text: '', tracking: true }; } }; diff --git a/static/src/app/methods/history.ts b/static/src/app/methods/history.ts index e94f68b..9ab7d3b 100644 --- a/static/src/app/methods/history.ts +++ b/static/src/app/methods/history.ts @@ -197,10 +197,14 @@ export const historyMethods = { lastRole: this.messages?.[this.messages.length - 1]?.role || null }); - // 滚动到底部 - this.$nextTick(() => { + // 在 historyLoading 隐藏期内等待 Markdown/表格/卡片等动态高度稳定,再滚到底部。 + // 注意:不要在显示后用多个 timeout 追底,否则会和滚动锚点/异步内容高度变化互相抢导致上下抖动。 + if (typeof this.settleHistoryRenderAndScroll === 'function') { + await this.settleHistoryRenderAndScroll(); + } else { + await this.$nextTick(); this.scrollHistoryToBottomInstant(); - }); + } debugLog('历史对话内容显示完成'); } else { diff --git a/static/src/app/methods/ui/scroll.ts b/static/src/app/methods/ui/scroll.ts index b33c4fa..cfcc9be 100644 --- a/static/src/app/methods/ui/scroll.ts +++ b/static/src/app/methods/ui/scroll.ts @@ -168,6 +168,44 @@ export const scrollMethods = { }); this.chatSetScrollState({ userScrolling: false }); }, + async settleHistoryRenderAndScroll() { + this._escapedByUserScroll = false; + const chatArea = this.getChatAreaController(); + if (chatArea && typeof chatArea.stopStickScroll === 'function') { + chatArea.stopStickScroll(); + } + const area = this.getMessagesAreaElement(); + if (!area) { + return; + } + const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve)); + const sleep = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms)); + const jump = () => { + area.scrollTop = area.scrollHeight; + }; + + let lastHeight = -1; + let stableFrames = 0; + const startedAt = Date.now(); + while (Date.now() - startedAt < 900 && stableFrames < 4) { + await nextFrame(); + jump(); + const height = area.scrollHeight; + if (Math.abs(height - lastHeight) <= 1) { + stableFrames += 1; + } else { + stableFrames = 0; + lastHeight = height; + } + } + + // 给 show_html / 表格滚动壳等异步同步 DOM 一次短暂稳定窗口;仍在 historyLoading 隐藏期内完成。 + await sleep(80); + jump(); + await nextFrame(); + jump(); + this.chatSetScrollState({ userScrolling: false }); + }, scrollToBottom() { uiBounceTrace( 'ui.scrollToBottom:called', diff --git a/static/src/components/avatar/StatusAvatar.vue b/static/src/components/avatar/StatusAvatar.vue new file mode 100644 index 0000000..27bea0b --- /dev/null +++ b/static/src/components/avatar/StatusAvatar.vue @@ -0,0 +1,616 @@ + + + + + diff --git a/static/src/components/chat/ChatArea.vue b/static/src/components/chat/ChatArea.vue index acc332a..fdf408e 100644 --- a/static/src/components/chat/ChatArea.vue +++ b/static/src/components/chat/ChatArea.vue @@ -1,14 +1,19 @@