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 @@
-
+
@@ -102,33 +107,11 @@
"
class="message-header icon-label"
>
-
{{ aiAssistantName }}
{{
assistantWorkLabel(index)
}}
-
+
+
+
+
+
+ {{ letter }}
+
+
+ {{ statusAvatarText }}
+
+
@@ -649,11 +664,19 @@ import ToolAction from '@/components/chat/actions/ToolAction.vue';
import StackedBlocks from './StackedBlocks.vue';
import MinimalBlocks from './MinimalBlocks.vue';
import MarkdownRenderer from './MarkdownRenderer.vue';
+import StatusAvatar from '@/components/avatar/StatusAvatar.vue';
import { usePersonalizationStore } from '@/stores/personalization';
import { getMessageVisibility, messageStartsWork } from '@/utils/messageVisibility';
const props = defineProps<{
messages: Array;
+ avatarStatus?: {
+ mode?: string;
+ toolKeys?: string[];
+ toolTexts?: string[];
+ text?: string;
+ tracking?: boolean;
+ };
iconStyle: (key: string, size?: string) => Record;
expandedBlocks: Set;
streamingMessage: boolean;
@@ -678,6 +701,12 @@ const emit = defineEmits<{
}>();
const personalization = usePersonalizationStore();
+const personalizationReady = computed(() => {
+ return !!personalization.loaded || !!personalization.error;
+});
+const renderPending = computed(() => {
+ return !!props.historyLoading || !personalizationReady.value;
+});
const blockDisplayMode = computed(() => {
return personalization.experiments.blockDisplayMode || 'stacked';
});
@@ -692,6 +721,7 @@ const stackedBlocksEnabled = computed(() => {
// 堆叠模式和极简模式都使用堆叠布局
return blockDisplayMode.value === 'stacked' || blockDisplayMode.value === 'minimal';
});
+const showStatusAvatar = computed(() => personalization.form.show_status_avatar !== false);
const aiAssistantName = computed(() => {
if (!personalization.form.use_custom_names) {
return 'AI Assistant';
@@ -748,7 +778,7 @@ const preMeasureUserBubbles = () => {
// 在 DOM 测量前,先根据文本内容同步估算是否需要折叠,
// 避免长气泡先完整渲染再被收起。
// 只给尚未有状态记录的气泡做首次估算,避免切换对话时旧消息反复重算导致动画。
- filteredMessages.value.forEach((msg, index) => {
+ (Array.isArray(filteredMessages.value) ? filteredMessages.value : []).forEach((msg, index) => {
if (!msg || msg.role !== 'user') return;
const key = getUserBubbleKey(msg, index);
if (userBubbleFoldStates.value[key]) return;
@@ -978,7 +1008,38 @@ const filteredMessages = computed(() => {
}
return result;
});
-const latestMessageIndex = computed(() => filteredMessages.value.length - 1);
+const getFilteredMessagesSafe = () =>
+ Array.isArray(filteredMessages.value) ? filteredMessages.value : [];
+const latestMessageIndex = computed(() => getFilteredMessagesSafe().length - 1);
+const statusAvatarActiveIndex = ref(0);
+const statusAvatarRef = ref(null);
+const avatarStatusSafe = computed(() => ({
+ mode: props.avatarStatus?.mode || 'idle',
+ toolKeys: Array.isArray(props.avatarStatus?.toolKeys) ? props.avatarStatus!.toolKeys! : [],
+ toolTexts: Array.isArray(props.avatarStatus?.toolTexts) ? props.avatarStatus!.toolTexts! : [],
+ text: typeof props.avatarStatus?.text === 'string' ? props.avatarStatus.text : '',
+ tracking: props.avatarStatus?.tracking !== false
+}));
+const statusAvatarText = computed(() => {
+ if (avatarStatusSafe.value.mode === 'tool' && avatarStatusSafe.value.toolTexts.length > 1) {
+ const idx = statusAvatarActiveIndex.value % avatarStatusSafe.value.toolTexts.length;
+ return avatarStatusSafe.value.toolTexts[idx] || avatarStatusSafe.value.text;
+ }
+ return avatarStatusSafe.value.text;
+});
+const statusAvatarTextAnimated = computed(() => {
+ return avatarStatusSafe.value.mode === 'work' && !!statusAvatarText.value;
+});
+const statusAvatarLetters = computed(() => Array.from(statusAvatarText.value || ''));
+const handleStatusAvatarActiveIndex = (index: number) => {
+ statusAvatarActiveIndex.value = Number.isFinite(index) ? Math.max(0, index) : 0;
+};
+watch(
+ () => [props.avatarStatus?.mode, props.avatarStatus?.toolKeys?.join('|')],
+ () => {
+ statusAvatarActiveIndex.value = 0;
+ }
+);
const SUB_AGENT_DONE_LABEL_RE = /^子智能体\d+\s*任务完成$/;
const SUB_AGENT_DONE_PREFIX_RE = /^(?:✅\s*)?子智能体\s*#?\s*(\d+)\s*任务摘要[::]/;
const BG_RUN_COMMAND_DONE_LABEL_RE = /^(?:\[)?后台\s*run_command\s*完成(?:\])?$/;
@@ -1001,7 +1062,6 @@ const userMDebug = (...args: any[]) => {
}
console.log('[USERMDEBUG]', ...args);
};
-const DEFAULT_GENERATING_TEXT = '生成中…';
const {
scrollRef,
contentRef,
@@ -1669,12 +1729,13 @@ function isSubAgentNoticeOnlyMessage(msg: any): boolean {
}
function isFollowedBySubAgentNotice(index: number): boolean {
- const next = filteredMessages.value[index + 1];
+ const messages = getFilteredMessagesSafe();
+ const next = messages[index + 1];
const yes = !!next && isSubAgentNoticeOnlyMessage(next);
if (yes) {
userMDebug('ChatArea.isFollowedBySubAgentNotice:hit', {
index,
- currentRole: filteredMessages.value[index]?.role,
+ currentRole: messages[index]?.role,
nextRole: next?.role
});
}
@@ -1718,7 +1779,8 @@ function findAssistantHeaderAnchorUser(index: number): any | null {
if (index <= 0) {
return null;
}
- const prev = filteredMessages.value[index - 1];
+ const messages = getFilteredMessagesSafe();
+ const prev = messages[index - 1];
if (!prev || prev.role !== 'user') {
return null;
}
@@ -1726,7 +1788,7 @@ function findAssistantHeaderAnchorUser(index: number): any | null {
// 只有 starts_work=true 的消息才会开启新 work segment;运行期 guidance
// 只是当前 segment 内的补充,不会重置头部与计时器。
for (let i = index - 1; i >= 0; i -= 1) {
- const item = filteredMessages.value[i];
+ const item = messages[i];
if (!item || item.role !== 'user') {
break;
}
@@ -1820,7 +1882,8 @@ function compactBriefLabel(msg: any): string {
}
watch(
- () => filteredMessages.value.map((m) => m?.id ?? m?.content?.slice(0, 80)),
+ () =>
+ getFilteredMessagesSafe().map((m) => m?.id ?? m?.content?.slice(0, 80)),
() => {
userBubbleTransitionSuppressed.value = true;
// 先同步估算折叠状态,再异步精确测量,避免长气泡先展开后收起
@@ -1962,14 +2025,6 @@ const splitActionGroups = (actions: any[] = [], messageIndex = 0) => {
return result;
};
-function getGeneratingLetters(message: any) {
- const label =
- typeof message?.generatingLabel === 'string' && message.generatingLabel.trim()
- ? message.generatingLabel.trim()
- : DEFAULT_GENERATING_TEXT;
- return Array.from(label);
-}
-
defineExpose({
rootEl,
getThinkingRef,
diff --git a/static/src/components/personalization/PersonalizationDrawer.vue b/static/src/components/personalization/PersonalizationDrawer.vue
index 69d6d7b..9e53c64 100644
--- a/static/src/components/personalization/PersonalizationDrawer.vue
+++ b/static/src/components/personalization/PersonalizationDrawer.vue
@@ -693,6 +693,28 @@
class="fancy-path"
>
+