Compare commits
5 Commits
dd5e94b294
...
7863e497d8
| Author | SHA1 | Date | |
|---|---|---|---|
| 7863e497d8 | |||
| 01dfec4c72 | |||
| 4a54d6fbb7 | |||
| 359caca672 | |||
| ae5676cd30 |
@ -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"))
|
||||
|
||||
@ -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:
|
||||
|
||||
144
server/files.py
144
server/files.py
@ -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"]
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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) ||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
};
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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',
|
||||
|
||||
775
static/src/components/avatar/StatusAvatar.vue
Normal file
775
static/src/components/avatar/StatusAvatar.vue
Normal 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);
|
||||
|
||||
// 当前激活的独立 face(think / 工具 key);为 null 表示显示 shared(idle/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>`;
|
||||
}
|
||||
|
||||
// ---- 眼睛 morph(idle 竖线 ↔ 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>
|
||||
@ -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, ''');
|
||||
|
||||
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;
|
||||
// 先同步估算折叠状态,再异步精确测量,避免长气泡先展开后收起
|
||||
|
||||
234
static/src/components/input/FileAtMenu.vue
Normal file
234
static/src/components/input/FileAtMenu.vue
Normal 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
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
44
static/src/utils/avatarFace.ts
Normal file
44
static/src/utils/avatarFace.ts
Normal 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';
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user