Compare commits

...

5 Commits

18 changed files with 3121 additions and 999 deletions

View File

@ -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"))

View File

@ -686,6 +686,7 @@ def _collect_pending_completion_notices(*, web_terminal, conversation_id: str) -
})
notices.sort(key=lambda item: item.get("sort_key") or 0)
return notices
def _has_pending_completion_work(*, web_terminal, conversation_id: str) -> bool:

View File

@ -1,10 +1,11 @@
"""文件与GUI文件管理相关路由。"""
from __future__ import annotations
import os
import re
import zipfile
from io import BytesIO
from pathlib import Path
from typing import Dict, Any
from typing import Dict, Any, List, Optional, Tuple
from flask import Blueprint, jsonify, request, send_file
from werkzeug.utils import secure_filename
@ -311,4 +312,145 @@ def gui_text_entry(terminal, workspace, username):
return jsonify({"success": False, "error": str(exc)}), 400
def _score_file_match(path: str, query: str) -> Optional[Tuple[int, ...]]:
"""为 @文件 搜索计算匹配分数分数越低越靠前None 表示不匹配。"""
path_lower = path.lower()
query_lower = query.lower()
if path_lower.startswith(query_lower):
return (0, len(path))
query_parts = query_lower.split('/')
path_parts = path_lower.split('/')
if len(query_parts) > 1 and len(path_parts) >= len(query_parts):
matched = True
for i, q in enumerate(query_parts):
p = path_parts[i]
if not p.startswith(q) and q not in p:
matched = False
break
if matched:
return (1, len(path))
name = path_parts[-1] if path_parts else path_lower
if name.startswith(query_lower):
return (2, len(path))
if query_lower in name:
return (3, len(path))
if query_lower in path_lower:
return (4, len(path))
return None
def _scan_project_entries(project_path: Path, max_depth: int = 6) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
"""扫描项目目录,返回文件和文件夹列表(包含隐藏目录)。"""
files: List[Dict[str, Any]] = []
folders: List[Dict[str, Any]] = []
for root, dirs, filenames in os.walk(project_path):
rel_root = Path(root).relative_to(project_path)
level = len(rel_root.parts) if str(rel_root) != '.' else 0
if level > max_depth:
del dirs[:]
continue
for d in list(dirs):
dir_path = rel_root / d if str(rel_root) != '.' else Path(d)
folders.append({
"name": d,
"path": str(dir_path).replace('\\', '/'),
"type": "directory"
})
for f in filenames:
file_path = rel_root / f if str(rel_root) != '.' else Path(f)
ext = Path(f).suffix.lower()
files.append({
"name": f,
"path": str(file_path).replace('\\', '/'),
"type": "file",
"extension": ext
})
return files, folders
def _scan_root_entries(project_path: Path) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
"""只扫描项目根目录的直接子项(包含隐藏目录)。"""
files: List[Dict[str, Any]] = []
folders: List[Dict[str, Any]] = []
if not project_path.exists() or not project_path.is_dir():
return files, folders
for entry in sorted(project_path.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):
if entry.is_dir():
folders.append({
"name": entry.name,
"path": entry.name,
"type": "directory"
})
elif entry.is_file():
files.append({
"name": entry.name,
"path": entry.name,
"type": "file",
"extension": entry.suffix.lower()
})
return files, folders
@files_bp.route('/api/project/files/search', methods=['GET'])
@api_login_required
@with_terminal
def search_project_files(terminal, workspace, username):
"""为 @文件 功能提供项目内文件/文件夹搜索(包含隐藏目录)。"""
policy = resolve_admin_policy(get_current_user_record())
if policy.get("ui_blocks", {}).get("collapse_workspace") or policy.get("ui_blocks", {}).get("block_file_manager"):
return jsonify({"success": False, "error": "文件浏览已被管理员禁用"}), 403
query = str(request.args.get('q') or '').strip()
try:
project_path = Path(getattr(workspace, 'project_path', '') or '').expanduser().resolve()
if not project_path.exists():
return jsonify({"success": False, "error": "项目路径不存在"}), 400
except Exception as exc:
return jsonify({"success": False, "error": f"项目路径无效: {exc}"}), 400
try:
files, folders = _scan_project_entries(project_path, max_depth=6)
except Exception as exc:
return jsonify({"success": False, "error": f"扫描项目失败: {exc}"}), 500
if not query:
root_files, root_folders = _scan_root_entries(project_path)
root_items = root_folders + root_files
return jsonify({
"success": True,
"data": {
"items": root_items[:50],
"total": len(root_items)
}
})
scored: List[Tuple[Tuple[int, ...], Dict[str, Any]]] = []
for entry in files + folders:
score = _score_file_match(entry["path"], query)
if score is not None:
scored.append((score, entry))
scored.sort(key=lambda x: (x[0], x[1]["path"].lower()))
limit = max(10, min(100, int(request.args.get('limit') or 50)))
return jsonify({
"success": True,
"data": {
"items": [entry for _, entry in scored[:limit]],
"total": len(scored)
}
})
__all__ = ["files_bp"]

View File

@ -242,7 +242,7 @@
<VirtualMonitorSurface v-if="chatDisplayMode === 'monitor'" />
<div v-if="blankHeroActive" class="blank-hero-overlay">
<span class="icon icon-lg" :style="iconStyle('bot')" aria-hidden="true"></span>
<StatusAvatar v-if="showStatusAvatar" mode="idle" :size="58" tracking />
<p class="blank-hero-text">{{ blankWelcomeText }}</p>
</div>
@ -294,6 +294,7 @@
:block-tool-toggle="policyUiBlocks.block_tool_toggle"
:block-realtime-terminal="policyUiBlocks.block_realtime_terminal"
:terminal-count="terminalSessions ? Object.keys(terminalSessions).length : 0"
:host-mode="versioningHostMode"
:block-focus-panel="policyUiBlocks.block_focus_panel"
:block-token-panel="policyUiBlocks.block_token_panel"
:block-compress-conversation="policyUiBlocks.block_compress_conversation"
@ -317,6 +318,7 @@
:goal-mode-armed="goalModeArmed"
:goal-running="goalRunning"
:goal-progress="goalProgress"
:avatar-status="showStatusAvatar && messages.length > 0 ? avatarStatus : null"
@restore-user-question="restoreUserQuestionDialog"
@update:input-message="inputSetMessage"
@input-change="handleInputChange"

View File

@ -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) ||

View File

@ -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,

View File

@ -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 getFinalIntentText = (tool: any) => {
if (!tool) return '';
const full = tool.intent_full || '';
const rendered = tool.intent_rendered || '';
// 只在 intent 打字效果完成后才显示完整文案,避免头像上出现逐字动画
return full && rendered === full ? full : '';
};
const toolTexts = runningTools.map((a) => {
return intentEnabled ? getFinalIntentText(a?.tool) : '';
});
let text = toolTexts[0] || '';
if (runningTools.length > 1) {
text = toolTexts[0] || '';
} else if (!text) {
text = intentEnabled ? getFinalIntentText(runningTools[0]?.tool) : '';
}
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 };
}
};

View File

@ -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 {

View File

@ -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',

View File

@ -0,0 +1,775 @@
<template>
<svg
ref="svgRef"
class="status-avatar"
:class="`status-avatar--${props.mode || 'idle'}`"
:style="{ width: size + 'px', height: size + 'px' }"
viewBox="0 0 200 200"
aria-hidden="true"
@click="handleAvatarClick"
>
<!-- flat-top 圆角正六边形背景与对话区背景同色 -->
<path
v-if="props.hexBackground"
class="sa-bg"
d="M 175.959,107.000 Q 180.000,100.000 175.959,93.000 L 144.041,37.718 Q 140.000,30.718 131.917,30.718 L 68.083,30.718 Q 60.000,30.718 55.959,37.718 L 24.041,93.000 Q 20.000,100.000 24.041,107.000 L 55.959,162.282 Q 60.000,169.282 68.083,169.282 L 131.917,169.282 Q 140.000,169.282 144.041,162.282 Z"
/>
<!-- flat-top 圆角正六边形外框 -->
<path
class="sa-frame"
d="M 175.959,107.000 Q 180.000,100.000 175.959,93.000 L 144.041,37.718 Q 140.000,30.718 131.917,30.718 L 68.083,30.718 Q 60.000,30.718 55.959,37.718 L 24.041,93.000 Q 20.000,100.000 24.041,107.000 L 55.959,162.282 Q 60.000,169.282 68.083,169.282 L 131.917,169.282 Q 140.000,169.282 144.041,162.282 Z"
/>
<defs>
<clipPath :id="clipId">
<path d="M 180,100 L 140,30.718 L 60,30.718 L 20,100 L 60,169.282 L 140,169.282 Z" />
</clipPath>
</defs>
<g class="sa-face" ref="faceRef">
<!-- 眼睛组idle 眼睛 work 提示符 互相 morph -->
<g class="sa-fc sa-shared" :class="{ hidden: !sharedVisible }">
<path
class="sa-eye"
ref="eyeLeftRef"
d="M 0,-11 Q 0,-5.5 0,0 Q 0,5.5 0,11 Q 0,5.5 0,0 Q 0,-5.5 0,-11"
transform="translate(84, 96)"
/>
<path
class="sa-eye"
ref="eyeRightRef"
d="M 0,-11 Q 0,-5.5 0,0 Q 0,5.5 0,11 Q 0,5.5 0,0 Q 0,-5.5 0,-11"
transform="translate(116, 96)"
/>
</g>
<!-- 思考三个大点 -->
<g class="sa-fc sa-icon" :class="{ active: activeFaceKey === 'think' }">
<circle class="sa-think-dot" cx="80" cy="100" r="7" />
<circle class="sa-think-dot" cx="100" cy="100" r="7" />
<circle class="sa-think-dot" cx="120" cy="100" r="7" />
</g>
<!-- 子智能体六边形分裂-聚合 -->
<g class="sa-fc sa-icon" :class="{ active: activeFaceKey === 'subagent' }" :clip-path="`url(#${clipId})`">
<g class="sa-sub-scene">
<line class="sa-sub-ray" x1="180" y1="100" x2="140" y2="100" />
<line class="sa-sub-ray" x1="140" y1="30.718" x2="120" y2="65.359" />
<line class="sa-sub-ray" x1="60" y1="30.718" x2="80" y2="65.359" />
<line class="sa-sub-ray" x1="20" y1="100" x2="60" y2="100" />
<line class="sa-sub-ray" x1="60" y1="169.282" x2="80" y2="134.641" />
<line class="sa-sub-ray" x1="140" y1="169.282" x2="120" y2="134.641" />
<path class="sa-sub-hex" d="M 137.9795,103.5 Q 140,100 137.9795,96.5 L 122.0205,68.859 Q 120,65.359 115.9585,65.359 L 84.0415,65.359 Q 80,65.359 77.9795,68.859 L 62.0205,96.5 Q 60,100 62.0205,103.5 L 77.9795,131.141 Q 80,134.641 84.0415,134.641 L 115.9585,134.641 Q 120,134.641 122.0205,131.141 Z" />
<g class="sa-sub-eyes">
<line class="sa-sub-eye" x1="0" y1="0" x2="11" y2="0" style="transform: translate(92px, 92px) rotate(90deg);" />
<line class="sa-sub-eye" x1="0" y1="0" x2="11" y2="0" style="transform: translate(108px, 92px) rotate(90deg);" />
</g>
</g>
</g>
<!-- 注册表工具 face动态渲染 -->
<g
v-for="tool in TOOLS"
:key="tool.key"
class="sa-fc sa-icon"
:class="{ active: activeFaceKey === tool.key }"
v-html="buildFaceInner(tool)"
></g>
</g>
</svg>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
// mode: 'idle' | 'work' | 'think' | 'tool'
const props = withDefaults(
defineProps<{
mode?: string;
toolKeys?: string[];
tracking?: boolean;
size?: number;
hexBackground?: boolean;
}>(),
{
mode: 'idle',
toolKeys: () => [],
tracking: false,
size: 30,
hexBackground: false
}
);
const emit = defineEmits<{
(event: 'active-index', index: number): void;
(event: 'poke'): void;
}>();
// clipPath id
const clipId = `sa-hex-clip-${Math.random().toString(36).slice(2, 9)}`;
const svgRef = ref<SVGSVGElement | null>(null);
const faceRef = ref<SVGGElement | null>(null);
const eyeLeftRef = ref<SVGPathElement | null>(null);
const eyeRightRef = ref<SVGPathElement | null>(null);
// facethink / key null sharedidle/work
const activeFaceKey = ref<string | null>(null);
const sharedVisible = ref(true);
// ---- face demo ----
interface ToolDef {
key: string;
motion: string;
scale?: number;
svg?: string;
raw?: string;
}
const TOOLS: ToolDef[] = [
{ key: 'search', motion: 'wiggle', svg: '<path class="sa-inner" d="m21 21-4.34-4.34"/><circle class="sa-inner" cx="11" cy="11" r="8"/>' },
{ key: 'webpage', motion: 'bob', svg: '<circle class="sa-inner" cx="12" cy="12" r="10"/><path class="sa-inner" d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><line class="sa-inner" x1="2" x2="22" y1="12" y2="12"/>' },
{ key: 'write', motion: 'wiggle', svg: '<path class="sa-inner" d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path class="sa-inner" d="m15 5 4 4"/>' },
{ key: 'read', motion: 'bob', svg: '<path class="sa-inner" d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20"/>' },
{ key: 'save', motion: 'bob', svg: '<path class="sa-inner" d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path class="sa-inner" d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path class="sa-inner" d="M7 3v4a1 1 0 0 0 1 1h7"/>' },
{ key: 'camera', motion: 'bob', svg: '<path class="sa-inner" d="M13.997 4a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 1.759-1.048l.489-.904A2 2 0 0 1 10.004 4z"/><circle class="sa-inner" cx="12" cy="13" r="3"/>' },
{ key: 'monitor', motion: 'bob', svg: '<rect class="sa-inner" width="20" height="14" x="2" y="3" rx="2"/><line class="sa-inner" x1="8" x2="16" y1="21" y2="21"/><line class="sa-inner" x1="12" x2="12" y1="17" y2="21"/>' },
{ key: 'keyboard', motion: 'bob', svg: '<path class="sa-inner" d="M10 8h.01M12 12h.01M14 8h.01M16 12h.01M18 8h.01M6 8h.01M7 16h10m-9-4h.01"/><rect class="sa-inner" width="20" height="16" x="2" y="4" rx="2"/>' },
{ key: 'clipboard', motion: 'bob', svg: '<rect class="sa-inner" width="8" height="4" x="8" y="2" rx="1" ry="1"/><path class="sa-inner" d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>' },
{ key: 'command', motion: 'bob', svg: '<path class="sa-inner" d="M12 19h8"/><path class="sa-inner" d="m4 17 6-6-6-6"/>' },
{ key: 'brain', motion: 'bob', svg: '<path class="sa-inner" d="M12 18V5"/><path class="sa-inner" d="M15 13a4.17 4.17 0 0 1-3-4 4.17 4.17 0 0 1-3 4"/><path class="sa-inner" d="M17.598 6.5A3 3 0 1 0 12 5a3 3 0 1 0-5.598 1.5"/><path class="sa-inner" d="M17.997 5.125a4 4 0 0 1 2.526 5.77"/><path class="sa-inner" d="M18 18a4 4 0 0 0 2-7.464"/><path class="sa-inner" d="M19.967 17.483A4 4 0 1 1 12 18a4 4 0 1 1-7.967-.517"/><path class="sa-inner" d="M6 18a4 4 0 0 1-2-7.464"/><path class="sa-inner" d="M6.003 5.125a4 4 0 0 0-2.526 5.77"/>' },
{ key: 'notebook', motion: 'bob', svg: '<path class="sa-inner" d="M2 6h4m-4 4h4m-4 4h4m-4 4h4"/><rect class="sa-inner" width="16" height="20" x="4" y="2" rx="2"/><path class="sa-inner" d="M16 2v20"/>' },
{ key: 'note', motion: 'bob', svg: '<path class="sa-inner" d="M21 9a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 15 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2z"/><path class="sa-inner" d="M15 3v5a1 1 0 0 0 1 1h5"/>' },
{ key: 'check', motion: 'bob', svg: '<path class="sa-inner" d="M20 6 9 17l-5-5"/>' },
{ key: 'skill', motion: 'bob', svg: '<path class="sa-inner" d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/><path class="sa-inner" d="M20 2v4"/><path class="sa-inner" d="M22 4h-4"/><circle class="sa-inner" cx="4" cy="20" r="2"/>' },
{ key: 'persona', motion: 'bob', svg: '<path class="sa-inner" d="M11.5 15H7a4 4 0 0 0-4 4v2m18.378-4.374a1 1 0 0 0-3.004-3.004l-4.01 4.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z"/><circle class="sa-inner" cx="10" cy="7" r="4"/>' },
{ key: 'mcp', motion: 'bob', raw: '<g transform="translate(100,100) scale(0.30) translate(-90,-90)"><path class="sa-inner" style="stroke-width:16.8" d="M18 84.8528L85.8822 16.9706C95.2548 7.59798 110.451 7.59798 119.823 16.9706C129.196 26.3431 129.196 41.5391 119.823 50.9117L68.5581 102.177"/><path class="sa-inner" style="stroke-width:16.8" d="M69.2652 101.47L119.823 50.9117C129.196 41.5391 144.392 41.5391 153.765 50.9117L154.118 51.2652C163.491 60.6378 163.491 75.8338 154.118 85.2063L92.7248 146.6C89.6006 149.724 89.6006 154.789 92.7248 157.913L105.331 170.52"/><path class="sa-inner" style="stroke-width:16.8" d="M102.853 33.9411L52.6482 84.1457C43.2756 93.5183 43.2756 108.714 52.6482 118.087C62.0208 127.459 77.2167 127.459 86.5893 118.087L136.794 67.8822"/></g>' },
{ key: 'ask', motion: 'bob', scale: 3.7, svg: '<path class="sa-inner" style="stroke-width:1.6" d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path class="sa-inner" style="stroke-width:1.6" d="M12 17h.01"/>' },
{ key: 'sleep', motion: 'special', raw: '<g class="sa-z z3"><text x="130" y="94" text-anchor="middle" font-size="34" font-weight="600">Z</text></g><g class="sa-z z2"><text x="100" y="109" text-anchor="middle" font-size="26" font-weight="600">Z</text></g><g class="sa-z z1"><text x="70" y="123" text-anchor="middle" font-size="18" font-weight="600">Z</text></g>' }
];
function buildFaceInner(tool: ToolDef): string {
if (tool.raw) {
return tool.motion === 'bob' ? `<g class="sa-bob">${tool.raw}</g>` : tool.raw;
}
const s = tool.scale || 2.17;
const scaled = `<g transform="translate(100, 100) scale(${s}) translate(-12, -12)">${tool.svg}</g>`;
const motionClass = tool.motion === 'wiggle' ? 'sa-wiggle' : 'sa-bob';
return `<g class="${motionClass}">${scaled}</g>`;
}
// ---- morphidle 线 work demo ----
const EYE_SHAPES: Record<string, string> = {
idle: 'M 0,-11 Q 0,-5.5 0,0 Q 0,5.5 0,11 Q 0,5.5 0,0 Q 0,-5.5 0,-11',
blink: 'M 0,0 Q 0,0 0,0 Q 0,0 0,0 Q 0,0 0,0 Q 0,0 0,0',
nervousLeft: 'M -10,-12 Q -2,-6 6,0 Q -2,6 -10,12 Q -2,6 6,0 Q -2,-6 -10,-12',
nervousRight: 'M 10,-12 Q 2,-6 -6,0 Q 2,6 10,12 Q 2,6 -6,0 Q 2,-6 10,-12',
work: 'M 0,0 Q 8.5,0 17,0 Q 25.5,0 34,0 Q 25.5,0 17,0 Q 8.5,0 0,0'
};
const EYE_LEFT_ORIGIN = { x: 84, y: 96 };
const EYE_RIGHT_ORIGIN = { x: 116, y: 96 };
const NERVOUS_LEFT_ORIGIN = { x: 82, y: 96 };
const NERVOUS_RIGHT_ORIGIN = { x: 118, y: 96 };
const WORK_ORIGIN = { x: 117, y: 100 };
const WORK_ROTATION_LEFT = 150;
const WORK_ROTATION_RIGHT = 210;
const DEFAULT_EYE_TRANSITION = 'transform 0.55s cubic-bezier(0.22, 1, 0.36, 1)';
const NERVOUS_EYE_TRANSITION = 'transform 0.22s cubic-bezier(0.65, 0, 0.35, 1)';
let currentLeftEyeShape = 'idle';
let currentRightEyeShape = 'idle';
function cubicBezier(p1x: number, p1y: number, p2x: number, p2y: number) {
const cx = 3 * p1x;
const bx = 3 * (p2x - p1x) - cx;
const ax = 1 - cx - bx;
const cy = 3 * p1y;
const by = 3 * (p2y - p1y) - cy;
const ay = 1 - cy - by;
const sampleCurveX = (t: number) => ((ax * t + bx) * t + cx) * t;
return (t: number) => {
let x = t;
for (let i = 0; i < 8; i++) {
const x2 = sampleCurveX(x) - t;
if (Math.abs(x2) < 1e-6) return ((ay * x + by) * x + cy) * x;
const d2 = (3 * ax * x + 2 * bx) * x + cx;
if (Math.abs(d2) < 1e-6) break;
x -= x2 / d2;
}
return ((ay * x + by) * x + cy) * x;
};
}
const easeOut = cubicBezier(0.22, 1, 0.36, 1);
const easeInOut = cubicBezier(0.65, 0, 0.35, 1);
function parsePathNumbers(d: string): number[] {
return (d.match(/[-\d.]+/g) || []).map(Number);
}
function buildPath(n: number[]): string {
return `M ${n[0]},${n[1]} Q ${n[2]},${n[3]} ${n[4]},${n[5]} Q ${n[6]},${n[7]} ${n[8]},${n[9]} Q ${n[10]},${n[11]} ${n[12]},${n[13]} Q ${n[14]},${n[15]} ${n[16]},${n[17]}`;
}
const morphRaf = new Map<SVGPathElement, number>();
function morphShape(
eye: SVGPathElement | null,
fromShape: string,
toShape: string,
duration = 550,
easing = easeOut
) {
if (!eye) return;
eye.setAttribute('d', EYE_SHAPES[fromShape]);
const fromNums = parsePathNumbers(EYE_SHAPES[fromShape]);
const toNums = parsePathNumbers(EYE_SHAPES[toShape]);
const startTime = performance.now();
const prev = morphRaf.get(eye);
if (prev) cancelAnimationFrame(prev);
const step = (now: number) => {
const progress = Math.min((now - startTime) / duration, 1);
const eased = easing(progress);
const cur = fromNums.map((v, i) => v + (toNums[i] - v) * eased);
eye.setAttribute('d', buildPath(cur));
if (progress < 1) {
morphRaf.set(eye, requestAnimationFrame(step));
} else {
morphRaf.delete(eye);
}
};
morphRaf.set(eye, requestAnimationFrame(step));
}
function applyWorkTransform() {
if (eyeLeftRef.value) {
eyeLeftRef.value.style.transition = DEFAULT_EYE_TRANSITION;
eyeLeftRef.value.style.transform = `translate(${WORK_ORIGIN.x}px, ${WORK_ORIGIN.y}px) rotate(${WORK_ROTATION_LEFT}deg)`;
}
if (eyeRightRef.value) {
eyeRightRef.value.style.transition = DEFAULT_EYE_TRANSITION;
eyeRightRef.value.style.transform = `translate(${WORK_ORIGIN.x}px, ${WORK_ORIGIN.y}px) rotate(${WORK_ROTATION_RIGHT}deg)`;
}
}
function applyIdleTransform(animate = true) {
if (eyeLeftRef.value) {
if (!animate) eyeLeftRef.value.style.transition = 'none';
eyeLeftRef.value.style.transform = `translate(${EYE_LEFT_ORIGIN.x}px, ${EYE_LEFT_ORIGIN.y}px)`;
if (!animate) {
void eyeLeftRef.value.offsetWidth;
eyeLeftRef.value.style.transition = '';
}
}
if (eyeRightRef.value) {
if (!animate) eyeRightRef.value.style.transition = 'none';
eyeRightRef.value.style.transform = `translate(${EYE_RIGHT_ORIGIN.x}px, ${EYE_RIGHT_ORIGIN.y}px)`;
if (!animate) {
void eyeRightRef.value.offsetWidth;
eyeRightRef.value.style.transition = '';
}
}
}
function applyNervousTransform() {
if (eyeLeftRef.value) {
eyeLeftRef.value.style.transition = NERVOUS_EYE_TRANSITION;
eyeLeftRef.value.style.transform = `translate(${NERVOUS_LEFT_ORIGIN.x}px, ${NERVOUS_LEFT_ORIGIN.y}px)`;
}
if (eyeRightRef.value) {
eyeRightRef.value.style.transition = NERVOUS_EYE_TRANSITION;
eyeRightRef.value.style.transform = `translate(${NERVOUS_RIGHT_ORIGIN.x}px, ${NERVOUS_RIGHT_ORIGIN.y}px)`;
}
}
// ---- ----
let mouseX = 0;
let mouseY = 0;
let eyeOffsetX = 0;
let eyeOffsetY = 0;
let trackRaf: number | null = null;
function onMouseMove(e: MouseEvent) {
mouseX = e.clientX;
mouseY = e.clientY;
}
function stopTracking() {
if (trackRaf) cancelAnimationFrame(trackRaf);
trackRaf = null;
if (faceRef.value) faceRef.value.style.transform = 'translate(0px, 0px)';
}
function trackEyes() {
const mode = internalMode.value || props.mode;
if (!(props.tracking && (mode === 'idle' || mode === 'nervous')) || !svgRef.value) {
stopTracking();
return;
}
const rect = svgRef.value.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const dx = mouseX - centerX;
const dy = mouseY - centerY;
const distance = Math.sqrt(dx * dx + dy * dy);
const angle = Math.atan2(dy, dx);
const factor = Math.min(distance / 180, 1);
const targetX = Math.cos(angle) * 10 * factor;
const targetY = Math.sin(angle) * 10 * factor;
eyeOffsetX += (targetX - eyeOffsetX) * 0.15;
eyeOffsetY += (targetY - eyeOffsetY) * 0.15;
if (faceRef.value) faceRef.value.style.transform = `translate(${eyeOffsetX}px, ${eyeOffsetY}px)`;
trackRaf = requestAnimationFrame(trackEyes);
}
// ---- ----
let carouselTimer: number | null = null;
let faceSwitchTimer: number | null = null;
let blinkTimer: number | null = null;
let nervousTimer: number | null = null;
let isBlinking = false;
let carouselIdx = 0;
const internalMode = ref<string | null>(null);
function stopCarousel() {
if (carouselTimer) clearInterval(carouselTimer);
carouselTimer = null;
}
function stopFaceSwitchTimer() {
if (faceSwitchTimer) clearTimeout(faceSwitchTimer);
faceSwitchTimer = null;
}
function stopAutoBlink() {
if (blinkTimer) clearTimeout(blinkTimer);
blinkTimer = null;
}
function stopNervousTimer() {
if (nervousTimer) clearTimeout(nervousTimer);
nervousTimer = null;
}
function wait(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function scheduleAutoBlink() {
stopAutoBlink();
if (props.mode !== 'idle' || internalMode.value) return;
const delay = 2800 + Math.random() * 3600;
blinkTimer = window.setTimeout(async () => {
await blinkEyes();
scheduleAutoBlink();
}, delay);
}
async function blinkEyes() {
if (props.mode !== 'idle' || internalMode.value || isBlinking) return;
isBlinking = true;
const blinkCount = Math.random() < 0.5 ? 1 : 2;
for (let i = 0; i < blinkCount; i++) {
morphShape(eyeLeftRef.value, 'idle', 'blink', 150, easeInOut);
morphShape(eyeRightRef.value, 'idle', 'blink', 150, easeInOut);
await wait(120);
if (props.mode !== 'idle' || internalMode.value) {
isBlinking = false;
return;
}
await wait(45);
morphShape(eyeLeftRef.value, 'blink', 'idle', 210, easeInOut);
morphShape(eyeRightRef.value, 'blink', 'idle', 210, easeInOut);
await wait(i === blinkCount - 1 ? 200 : 250);
}
isBlinking = false;
}
function triggerNervous() {
if (props.mode !== 'idle') return;
internalMode.value = 'nervous';
stopAutoBlink();
stopNervousTimer();
stopTracking();
stopCarousel();
stopFaceSwitchTimer();
activeFaceKey.value = null;
sharedVisible.value = true;
applyNervousTransform();
morphShape(eyeLeftRef.value, currentLeftEyeShape, 'nervousLeft', 220, easeInOut);
morphShape(eyeRightRef.value, currentRightEyeShape, 'nervousRight', 220, easeInOut);
currentLeftEyeShape = 'nervousLeft';
currentRightEyeShape = 'nervousRight';
if (props.tracking) trackEyes();
nervousTimer = window.setTimeout(() => {
if (internalMode.value === 'nervous') {
internalMode.value = null;
// watcher applyState
}
}, 500);
}
function handleAvatarClick() {
if (props.mode !== 'idle') return;
emit('poke');
triggerNervous();
}
function setIconFace(key: string, animated = true) {
const fromShared = sharedVisible.value && !activeFaceKey.value;
sharedVisible.value = false;
if (!animated || activeFaceKey.value === key) {
activeFaceKey.value = key;
return;
}
if (fromShared) {
activeFaceKey.value = null;
stopFaceSwitchTimer();
faceSwitchTimer = window.setTimeout(() => {
activeFaceKey.value = key;
faceSwitchTimer = null;
}, 160);
return;
}
if (!activeFaceKey.value) {
activeFaceKey.value = key;
return;
}
activeFaceKey.value = null;
stopFaceSwitchTimer();
faceSwitchTimer = window.setTimeout(() => {
activeFaceKey.value = key;
faceSwitchTimer = null;
}, 160);
}
function startCarousel(keys: string[]) {
stopCarousel();
stopFaceSwitchTimer();
carouselIdx = 0;
setIconFace(keys[0]);
emit('active-index', carouselIdx);
if (keys.length < 2) return;
carouselTimer = window.setInterval(() => {
carouselIdx = (carouselIdx + 1) % keys.length;
setIconFace(keys[carouselIdx]);
emit('active-index', carouselIdx);
}, 1450);
}
// ---- ----
function applyState() {
if (props.mode !== 'idle' && internalMode.value) {
internalMode.value = null;
stopNervousTimer();
}
const mode = internalMode.value || props.mode;
if (mode !== 'idle') stopAutoBlink();
if (mode === 'tool') {
stopTracking();
const keys = (props.toolKeys || []).filter(Boolean);
startCarousel(keys.length ? keys : ['command']);
return;
}
stopCarousel();
stopFaceSwitchTimer();
if (mode === 'think') {
stopTracking();
setIconFace('think');
return;
}
if (mode === 'nervous') {
return;
}
// idle / work shared morph
const target = mode === 'work' ? 'work' : 'idle';
const fromIcon = !!activeFaceKey.value;
activeFaceKey.value = null;
const revealShared = () => {
sharedVisible.value = true;
morphShape(eyeLeftRef.value, currentLeftEyeShape, target);
morphShape(eyeRightRef.value, currentRightEyeShape, target);
if (target === 'work') applyWorkTransform();
else applyIdleTransform();
currentLeftEyeShape = target;
currentRightEyeShape = target;
};
if (fromIcon) {
faceSwitchTimer = window.setTimeout(() => {
revealShared();
faceSwitchTimer = null;
}, 160);
} else {
revealShared();
}
if (target === 'idle' && props.tracking) {
stopTracking();
trackEyes();
scheduleAutoBlink();
} else {
stopTracking();
stopAutoBlink();
}
}
watch(() => [props.mode, props.toolKeys, props.tracking, internalMode.value], () => applyState(), { deep: true });
onMounted(() => {
// SVG
applyIdleTransform(false);
document.addEventListener('mousemove', onMouseMove);
applyState();
});
onBeforeUnmount(() => {
document.removeEventListener('mousemove', onMouseMove);
stopTracking();
stopCarousel();
stopFaceSwitchTimer();
stopAutoBlink();
stopNervousTimer();
morphRaf.forEach((id) => cancelAnimationFrame(id));
morphRaf.clear();
});
</script>
<style scoped lang="scss">
.status-avatar {
display: block;
cursor: pointer;
overflow: visible;
color: currentColor;
flex-shrink: 0;
}
.sa-bg {
fill: var(--chat-surface-color);
stroke: none;
}
.sa-frame {
fill: none;
stroke: currentColor;
stroke-width: 10;
stroke-linecap: round;
stroke-linejoin: round;
transform-origin: 100px 100px;
}
.status-avatar--idle .sa-frame {
stroke-width: 12;
}
.sa-face {
transform-origin: 100px 100px;
transition: transform 0.08s linear;
}
.sa-fc {
transform-origin: 100px 100px;
pointer-events: none;
}
.sa-shared {
opacity: 1;
transition: opacity 0.35s cubic-bezier(0.22, 1, 0.36, 1);
}
.sa-shared.hidden {
opacity: 0;
}
.status-avatar--work .sa-shared {
animation: sa-bob 2.4s ease-in-out infinite;
}
.sa-icon {
opacity: 0;
transform: scale(0.92);
transition:
opacity 0.35s cubic-bezier(0.22, 1, 0.36, 1),
transform 0.35s cubic-bezier(0.22, 1, 0.36, 1);
}
.sa-icon.active {
opacity: 1;
transform: scale(1);
}
// 线 :deep v-html scoped
:deep(.sa-inner) {
fill: none;
stroke: currentColor;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
}
.sa-eye {
fill: none;
stroke: currentColor;
stroke-width: 8;
stroke-linecap: round;
stroke-linejoin: round;
transition: transform 0.55s cubic-bezier(0.22, 1, 0.36, 1);
}
.status-avatar--idle .sa-eye {
stroke-width: 10;
}
.sa-think-dot {
fill: currentColor;
animation: sa-think-wave 1.6s ease-in-out infinite;
}
.sa-think-dot:nth-child(1) {
animation-delay: -0.48s;
}
.sa-think-dot:nth-child(2) {
animation-delay: -0.32s;
}
.sa-think-dot:nth-child(3) {
animation-delay: -0.16s;
}
@keyframes sa-think-wave {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-12px);
}
}
:deep(.sa-wiggle) {
animation: sa-wiggle 1.8s ease-in-out infinite;
transform-origin: 100px 100px;
}
@keyframes sa-wiggle {
0%,
100% {
transform: translateX(-4px) rotate(-6deg);
}
50% {
transform: translateX(4px) rotate(6deg);
}
}
:deep(.sa-bob) {
animation: sa-bob 2.4s ease-in-out infinite;
transform-origin: 100px 100px;
}
@keyframes sa-bob {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-9px);
}
}
// sleep Z
:deep(.sa-z) {
animation: sa-z-wiggle 1.8s ease-in-out infinite;
}
:deep(.sa-z.z1) {
transform-origin: 70px 117px;
animation-delay: -0.9s;
}
:deep(.sa-z.z2) {
transform-origin: 100px 100px;
animation-delay: -0.45s;
}
:deep(.sa-z.z3) {
transform-origin: 130px 83px;
animation-delay: 0s;
}
:deep(.sa-z text) {
fill: currentColor;
}
@keyframes sa-z-wiggle {
0%,
100% {
transform: rotate(-9deg);
}
50% {
transform: rotate(9deg);
}
}
//
.sa-sub-scene {
transform-origin: 100px 100px;
animation: sa-sub-zoom 4s ease-in-out infinite;
}
.sa-sub-ray,
.sa-sub-hex,
.sa-sub-eye {
fill: none;
stroke: currentColor;
stroke-width: 5;
stroke-linecap: round;
stroke-linejoin: round;
}
.sa-sub-ray {
stroke-dasharray: 40;
stroke-dashoffset: 40;
opacity: 1;
animation: sa-sub-ray 4s ease-in-out infinite;
}
.sa-sub-hex {
opacity: 0;
animation: sa-sub-hex 4s ease-in-out infinite;
}
.sa-sub-eyes {
opacity: 0;
transform-origin: 100px 100px;
animation: sa-sub-eyes 4s ease-in-out infinite;
}
@keyframes sa-sub-ray {
0% {
stroke-dashoffset: 40;
opacity: 1;
}
15% {
stroke-dashoffset: 0;
opacity: 1;
}
30% {
stroke-dashoffset: 0;
opacity: 1;
}
40%,
100% {
opacity: 0;
}
}
@keyframes sa-sub-hex {
0%,
15% {
opacity: 0;
}
30% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes sa-sub-eyes {
0%,
40% {
opacity: 0;
transform: scale(0.9);
}
50% {
opacity: 1;
transform: scale(1);
}
70% {
opacity: 1;
transform: scale(1);
}
80%,
100% {
opacity: 0;
transform: scale(1);
}
}
@keyframes sa-sub-zoom {
0%,
50% {
transform: scale(1);
opacity: 1;
}
70% {
transform: scale(2);
opacity: 1;
}
99.5% {
transform: scale(2);
opacity: 1;
}
100% {
transform: scale(2);
opacity: 0;
}
}
</style>

View File

@ -1,14 +1,19 @@
<template>
<div class="messages-area messages-area--stick" ref="scrollRef">
<div
class="messages-area messages-area--stick"
:class="{ 'messages-area--render-pending': renderPending }"
ref="scrollRef"
>
<div class="messages-flow" ref="contentRef">
<div
v-for="(msg, index) in filteredMessages"
v-for="(msg, index) in filteredMessages || []"
:key="index"
class="message-block"
:class="{
'message-block--compact-user': getMessageVisibility(msg) === 'compact',
'message-block--sub-agent-notice': isSubAgentNoticeOnlyMessage(msg),
'message-block--before-sub-agent-notice': isFollowedBySubAgentNotice(index)
'message-block--before-sub-agent-notice': isFollowedBySubAgentNotice(index),
'message-block--last': index === (filteredMessages || []).length - 1
}"
>
<div v-if="msg.role === 'user'" class="user-message" :class="{ 'user-message--compact': getMessageVisibility(msg) === 'compact', 'user-message--brief': isBriefCompactMessage(msg) }">
@ -102,7 +107,6 @@
"
class="message-header icon-label"
>
<span class="icon icon-sm" :style="iconStyleSafe('bot')" aria-hidden="true"></span>
<span>{{ aiAssistantName }}</span>
<span v-if="assistantWorkLabel(index)" class="assistant-work-status">{{
assistantWorkLabel(index)
@ -678,6 +682,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';
});
@ -714,6 +724,7 @@ interface UserBubbleFoldState {
}
const USER_BUBBLE_FOLD_LINES = 10;
const DEFAULT_GENERATING_TEXT = '生成中…';
const userBubbleRefs = new Map<string, HTMLElement>();
const userBubbleFoldStates = ref<Record<string, UserBubbleFoldState>>({});
const copiedBubbleKeys = ref<Set<string>>(new Set());
@ -748,7 +759,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;
@ -903,16 +914,35 @@ const escapeUserHtml = (value: string): string =>
.replace(/'/g, '&#39;');
const USER_SKILL_LINK_RE = /\[\$([^\]\n]+)\]\(([^)\n]*\/\.agents\/skills\/[^)\n]+\/SKILL\.md)\)/g;
const USER_FILE_LINK_RE = /\[([^\]\n]+)\]\(file:\/\/([^)\n]+)\)/g;
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*完成(?:\])?$/;
const RENDERABLE_ACTION_TYPES = new Set([
'thinking',
'text',
'tool',
'append',
'append_payload',
'system'
]);
function renderUserMessageContent(content: string): string {
const source = String(content || '');
USER_SKILL_LINK_RE.lastIndex = 0;
const combinedRe = new RegExp(
`(?:${USER_SKILL_LINK_RE.source})|(?:${USER_FILE_LINK_RE.source})`,
'g'
);
let html = '';
let cursor = 0;
let match: RegExpExecArray | null;
while ((match = USER_SKILL_LINK_RE.exec(source))) {
while ((match = combinedRe.exec(source))) {
html += escapeUserHtml(source.slice(cursor, match.index));
html += ` <span class="user-skill-link">${escapeUserHtml(match[1] || '')}</span> `;
if (match[1] !== undefined) {
html += ` <span class="user-skill-link">${escapeUserHtml(match[1] || '')}</span> `;
} else if (match[3] !== undefined) {
html += ` <span class="user-file-link">${escapeUserHtml(match[3] || '')}</span> `;
}
cursor = match.index + match[0].length;
}
html += escapeUserHtml(source.slice(cursor));
@ -978,18 +1008,9 @@ const filteredMessages = computed(() => {
}
return result;
});
const latestMessageIndex = computed(() => filteredMessages.value.length - 1);
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*完成(?:\])?$/;
const RENDERABLE_ACTION_TYPES = new Set([
'thinking',
'text',
'tool',
'append',
'append_payload',
'system'
]);
const getFilteredMessagesSafe = () =>
Array.isArray(filteredMessages.value) ? filteredMessages.value : [];
const latestMessageIndex = computed(() => getFilteredMessagesSafe().length - 1);
const nowMs = ref(Date.now());
let timerHandle: number | null = null;
const debugLoggedSystemActionKeys = new Set<string>();
@ -1001,7 +1022,6 @@ const userMDebug = (...args: any[]) => {
}
console.log('[USERMDEBUG]', ...args);
};
const DEFAULT_GENERATING_TEXT = '生成中…';
const {
scrollRef,
contentRef,
@ -1669,12 +1689,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 +1739,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 +1748,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 +1842,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;
//

View File

@ -0,0 +1,234 @@
<template>
<transition name="file-at-menu-motion">
<div
v-if="visible"
class="file-at-menu-wrapper"
role="listbox"
aria-label="文件引用"
:style="menuStyle"
@click.stop
>
<div ref="fileAtList" class="file-at-menu">
<button
v-if="hostMode"
type="button"
class="file-at-item file-at-item--picker"
:class="{ 'file-at-item--active': activeIndex === 0 }"
role="option"
:aria-selected="activeIndex === 0"
@mouseenter="$emit('hover', 0)"
@mousedown.prevent="$emit('select', 0)"
>
<span class="file-at-item__name">在文件管理器中选中</span>
<span class="file-at-item__description">选择本地文件</span>
</button>
<button
v-for="(item, index) in fileItems"
:key="item.path"
type="button"
class="file-at-item"
:class="{ 'file-at-item--active': displayIndex(index) === activeIndex }"
role="option"
:aria-selected="displayIndex(index) === activeIndex"
@mouseenter="$emit('hover', displayIndex(index))"
@mousedown.prevent="$emit('select', displayIndex(index))"
>
<span class="file-at-item__name">{{ item.name }}</span>
<span class="file-at-item__description">{{ item.path }}</span>
</button>
<div v-if="!hostMode && !fileItems.length && !loading" class="file-at-empty">
没有匹配的文件
</div>
<div v-if="loading" class="file-at-empty">搜索中...</div>
</div>
</div>
</transition>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue';
export interface FileAtItem {
name: string;
path: string;
type: 'file' | 'directory';
extension?: string;
}
const props = defineProps<{
visible: boolean;
items: FileAtItem[];
activeIndex: number;
loading: boolean;
hostMode: boolean;
menuStyle: Record<string, string>;
}>();
defineEmits<{
(e: 'select', index: number): void;
(e: 'hover', index: number): void;
}>();
const fileAtList = ref<HTMLElement | null>(null);
const hasPicker = computed(() => props.hostMode);
const fileItems = computed(() => props.items || []);
const displayIndex = (fileIndex: number) => (hasPicker.value ? fileIndex + 1 : fileIndex);
watch(
() => props.visible,
() => {
if (props.visible) {
nextTick(() => {
const list = fileAtList.value;
if (list) list.scrollTop = 0;
});
}
}
);
const scrollActiveIntoView = (index: number) => {
const list = fileAtList.value;
if (!list) return;
const rowHeight = 31;
const targetTop = index * rowHeight;
const targetBottom = targetTop + rowHeight;
if (targetTop < list.scrollTop) {
list.scrollTop = targetTop;
} else if (targetBottom > list.scrollTop + list.clientHeight) {
list.scrollTop = targetBottom - list.clientHeight;
}
};
const focusActive = () => {
nextTick(() => {
scrollActiveIntoView(props.activeIndex);
});
};
const publicApi = {
focusActive,
setManualScroll: () => {}
};
defineExpose(publicApi);
</script>
<style scoped>
.file-at-menu-wrapper {
--file-at-row-height: 28px;
--file-at-gap: 3px;
--file-at-radius: 9px;
--file-at-pad-x: 4px;
--file-at-pad-y: 4px;
position: fixed;
z-index: 1000;
min-width: 240px;
max-width: min(840px, calc(100vw - 24px));
max-height: calc(
((var(--file-at-row-height) * 7) + (var(--file-at-gap) * 8) + (var(--file-at-pad-y) * 2)) * 1.5
);
border: 1px solid var(--claude-border);
border-radius: var(--file-at-radius);
background: var(--surface-soft);
box-shadow: 0 8px 24px var(--shadow-color);
pointer-events: auto;
overflow: hidden;
}
.file-at-menu {
height: 100%;
max-height: inherit;
overflow-y: auto;
scrollbar-width: none;
padding: var(--file-at-pad-y) var(--file-at-pad-x) 6px;
display: flex;
flex-direction: column;
gap: var(--file-at-gap);
}
.file-at-menu::-webkit-scrollbar {
width: 0;
height: 0;
}
.file-at-item {
width: 100%;
height: var(--file-at-row-height);
min-height: var(--file-at-row-height);
flex: 0 0 var(--file-at-row-height);
display: flex;
align-items: center;
gap: 10px;
border: none;
border-radius: 7px;
padding: 3px 9px;
background: transparent;
color: var(--claude-text);
text-align: left;
cursor: pointer;
}
.file-at-item:first-child {
border-top-left-radius: calc(var(--file-at-radius) - var(--file-at-gap));
border-top-right-radius: calc(var(--file-at-radius) - var(--file-at-gap));
}
.file-at-item:hover,
.file-at-item--active {
background: var(--hover-bg);
box-shadow: 0 1px 2px var(--shadow-color);
}
.file-at-item__name {
flex: 0 1 auto;
min-width: 0;
color: var(--claude-text);
font-size: 12px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-at-item__description {
flex: 1 1 auto;
min-width: 0;
color: var(--claude-text-secondary);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-at-empty {
height: var(--file-at-row-height);
min-height: var(--file-at-row-height);
flex: 0 0 var(--file-at-row-height);
display: flex;
align-items: center;
padding: 3px 9px;
color: var(--claude-text-secondary);
font-size: 11px;
}
body[data-theme='dark'] .file-at-menu-wrapper {
background: var(--badge-bg);
border-color: var(--claude-border);
}
.file-at-menu-motion-enter-active,
.file-at-menu-motion-leave-active {
transition:
opacity 0.16s ease,
transform 0.16s ease;
transform-origin: bottom left;
}
.file-at-menu-motion-enter-from,
.file-at-menu-motion-leave-to {
opacity: 0;
transform: translateY(6px) scale(0.98);
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -693,6 +693,28 @@
class="fancy-path"
></path></svg></span
></label>
<label class="settings-toggle-row"
><span class="settings-row-copy"
><span class="settings-row-title">显示助手状态形象</span
><span class="settings-row-desc"
>在欢迎页和对话末尾显示空闲思考工具调用等状态动画</span
></span
><input
type="checkbox"
:checked="form.show_status_avatar"
@change="
personalization.updateField({
key: 'show_status_avatar',
value: $event.target.checked
})
" /><span class="fancy-check" aria-hidden="true"
><svg viewBox="0 0 64 64">
<path
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
pathLength="575.0541381835938"
class="fancy-path"
></path></svg></span
></label>
<label class="settings-toggle-row"
><span class="settings-row-copy"
><span class="settings-row-title">显示 Git 状态栏</span

View File

@ -25,6 +25,7 @@ interface PersonalForm {
silent_tool_disable: boolean;
enhanced_tool_display: boolean;
compact_message_display: CompactMessageDisplay;
show_status_avatar: boolean;
show_git_status_bar: boolean;
auto_open_terminal_panel: boolean;
stacked_hide_borders: boolean;
@ -142,6 +143,7 @@ const defaultForm = (): PersonalForm => ({
silent_tool_disable: false,
enhanced_tool_display: true,
compact_message_display: 'full',
show_status_avatar: true,
show_git_status_bar: true,
auto_open_terminal_panel: true,
stacked_hide_borders: loadCachedStackedHideBorders(),
@ -333,6 +335,7 @@ export const usePersonalizationStore = defineStore('personalization', {
silent_tool_disable: !!data.silent_tool_disable,
enhanced_tool_display: data.enhanced_tool_display !== false,
compact_message_display: data.compact_message_display === 'brief' ? 'brief' : 'full',
show_status_avatar: data.show_status_avatar !== false,
show_git_status_bar: data.show_git_status_bar !== false,
auto_open_terminal_panel: data.auto_open_terminal_panel !== false,
stacked_hide_borders: !!data.stacked_hide_borders,

View File

@ -107,6 +107,11 @@
overflow-anchor: none;
}
.messages-area.messages-area--render-pending .messages-flow,
.messages-area.messages-area--render-pending .messages-bottom-spacer {
visibility: hidden;
}
.messages-flow {
position: relative;
left: calc(var(--chat-scrollbar-width) / 2);
@ -117,6 +122,10 @@
contain: layout style;
}
.messages-flow > .message-block.message-block--last {
margin-bottom: 0;
}
.messages-bottom-spacer {
width: 1px;
height: var(--composer-growth-height, 0px);
@ -483,6 +492,35 @@
align-items: flex-end; /* 靠右对齐 */
}
.message-block--last .user-message:not(.user-message--compact):not(.user-message--brief) {
position: relative;
}
.message-block--last .user-message:not(.user-message--compact):not(.user-message--brief) .user-bubble-actions {
position: absolute;
right: 0;
top: 100%;
margin-top: 4px;
z-index: 1;
}
.message-block--last .assistant-message .text-output {
margin-top: 0;
margin-bottom: 0;
}
.message-block--last .assistant-message .text-content > :last-child,
.message-block--last .assistant-message .markdown-renderer > :last-child,
.message-block--last .assistant-message .markdown-text-segment > :last-child,
.message-block--last .assistant-message .md-table-scroll:last-child,
.message-block--last .assistant-message [data-md-table-scroll='1']:last-child,
.message-block--last .assistant-message .chat-inline-card:last-child,
.message-block--last .assistant-message .chat-inline-card--image:last-child,
.message-block--last .assistant-message .show-file-card:last-child,
.message-block--last .assistant-message .show-file-card-root:last-child {
margin-bottom: 0 !important;
}
.user-message.user-message--compact {
align-items: center;
}
@ -503,6 +541,38 @@
color: var(--claude-text-secondary);
}
.assistant-generating-placeholder {
display: inline-flex;
align-items: center;
color: var(--claude-text-secondary);
font-size: 14px;
font-weight: 500;
letter-spacing: 0.05em;
}
.assistant-generating-letter {
display: inline-block;
opacity: 0.42;
transform: translateY(0);
animation: assistant-generating-letter-wave 1.6s ease-in-out infinite;
}
@keyframes assistant-generating-letter-wave {
0%,
100% {
opacity: 0.42;
transform: translateY(0);
}
20% {
opacity: 1;
transform: translateY(-3px);
}
42% {
opacity: 0.68;
transform: translateY(0);
}
}
.user-message .message-text,
.assistant-message .message-text {
padding: 16px 20px;
@ -569,7 +639,8 @@
opacity: 0.9;
}
.user-message .message-text .user-skill-link {
.user-message .message-text .user-skill-link,
.user-message .message-text .user-file-link {
color: var(--state-info);
font-weight: 600;
display: inline-block;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,44 @@
// 工具名 → StatusAvatar face key 映射
// 未命中的工具回退到 'command'(终端 > 提示符),保证始终有形象
const TOOL_FACE_MAP: Record<string, string> = {
web_search: 'search',
conversation_search: 'search',
extract_webpage: 'webpage',
save_webpage: 'save',
write_file: 'write',
edit_file: 'write',
read_file: 'read',
read_skill: 'read',
conversation_review: 'read',
vlm_analyze: 'camera',
ocr_image: 'camera',
view_image: 'camera',
view_video: 'camera',
create_skill: 'skill',
trigger_easter_egg: 'skill',
terminal_session: 'monitor',
terminal_input: 'keyboard',
terminal_snapshot: 'clipboard',
sleep: 'sleep',
run_command: 'command',
update_memory: 'brain',
recall_project_memory: 'notebook',
update_project_memory: 'notebook',
todo_create: 'note',
todo_update_task: 'check',
create_sub_agent: 'subagent',
close_sub_agent: 'subagent',
terminate_sub_agent: 'subagent',
get_sub_agent_status: 'subagent',
manage_personalization: 'persona',
ask_user: 'ask',
list_mcp_servers: 'mcp'
};
export function toolFaceKey(toolName: string | undefined | null): string {
const name = String(toolName || '');
if (name.startsWith('mcp__')) {
return 'mcp';
}
return TOOL_FACE_MAP[name] || 'command';
}