优化: 聊天体验批与Web规范合规

This commit is contained in:
lxy
2026-10-05 23:41:32 +08:00
parent 9e81182b4f
commit 28e6acbf3f
41 changed files with 1518 additions and 570 deletions
+154 -2
View File
@@ -25,6 +25,8 @@
@close-panel="store.togglePanel()"
@dock="store.dockDetached()"
@toggle-always-on-top="toggleAlwaysOnTop"
@reattach="onReattachToMain"
@show-shortcuts="showShortcuts"
@close-detached="store.closeDetachedWindow()"
@provider-switched="(name: string) => showToast(t('aiChat.providerSwitched', { model: name }), 'info')"
/>
@@ -46,6 +48,8 @@
<!-- Input -->
<div class="ai-input-area">
<!-- B4: 自动压缩完成提示(一次性 4s 淡出;治「上下文何时被压缩」零感知,不弹 toast 不打断流) -->
<div v-if="autoCompressedFlash" class="ai-autocompressed-hint">{{ t('aiChat.autoCompressed') }}</div>
<!-- 无人值守边界配置卡(默认收起=现状零变化):发起时一次授权,生成中锁编辑 -->
<TaskBoundaryCard @toast="(p) => showToast(p.msg, p.type)" />
<!-- AE-2025-07: Agentic 循环进度条(C 状态语义化)
@@ -131,6 +135,24 @@
<Transition name="ai-toast">
<div v-if="toast.visible" class="ai-toast" :class="'ai-toast-' + toast.type">{{ toast.msg }}</div>
</Transition>
<!-- I12(2026-10-05):键盘快捷键速查。Teleport 到 body 防 .ai-panel overflow 裁剪;
主窗/分离窗各自实例独立渲染,入口在 TopBar ... 菜单(emit show-shortcuts) -->
<Teleport to="body">
<div v-if="shortcutsVisible" class="ai-shortcuts-mask" @click.self="shortcutsVisible = false">
<div class="ai-shortcuts-panel" role="dialog" :aria-label="t('aiChat.shortcutsTitle')">
<div class="ai-shortcuts-head">
<span class="ai-shortcuts-title">{{ t('aiChat.shortcutsTitle') }}</span>
<button class="ai-shortcuts-close" :aria-label="t('aiChat.scCloseOverlay')" @click="shortcutsVisible = false">✕</button>
</div>
<ul class="ai-shortcuts-list">
<li v-for="s in shortcuts" :key="s.key" class="ai-shortcuts-row">
<span class="ai-shortcuts-key">{{ s.key }}</span>
<span class="ai-shortcuts-act">{{ s.label }}</span>
</li>
</ul>
</div>
</div>
</Teleport>
</div>
</template>
@@ -154,6 +176,8 @@ import { useConfirm } from '../composables/useConfirm'
import { useToast } from '../composables/useToast'
import { useProjectStore } from '../stores/project'
import { collectTurnStats, turnMessages } from '../composables/ai/useTurnTrace'
import { useAiContext } from '../composables/ai/useAiContext'
import { emitReattachToMain, registerMainReattachListener } from '@/composables/ai/useAiWindow'
import type { AiMessage } from '../api/types'
const props = defineProps<{
@@ -164,6 +188,8 @@ const props = defineProps<{
const store = useAiStore()
const { t } = useI18n()
const router = useRouter()
// B4: 自动压缩完成一次性可见信号(输入区上方 hint 行短暂显示,4s 自灭)
const { autoCompressedFlash } = useAiContext()
// path_auth 审批链阶段3b(统一审批模型):df-ai-unified-approval 开关响应式 ref。
// 开(true,默认):path 挂起归一进 ToolCard 内联审批(once/always/deny),不挂 DirAuthDialog。
@@ -278,7 +304,9 @@ async function confirmNewConversation() {
await store.newConversation()
} catch (e) {
console.error('[AI] 新建对话失败:', e)
// 用错误气泡反馈用户,不静默吞掉
// #16(2026-10-05 注释纠偏):原注释称「用错误气泡反馈用户」,实际 catch 内仅 console.error,
// UI 无反馈;失败兜底由 store.newConversation 内部 withConvOp toast 承接,此处不再重复提示,
// 保留 console.error 留痕。注释与实现对齐,避免后续维护误导。
}
}
@@ -338,9 +366,19 @@ function onGlobalKeydown(e: KeyboardEvent) {
const el = e.target as HTMLElement | null
if (ctrl && !!el?.closest?.('input, textarea, [contenteditable]')) return
// Ctrl+N: 新建对话(Tauri 下 preventDefault 可靠,无浏览器新窗口冲突)
// V10(2026-10-05):撤 streaming 守卫,对齐按钮侧 handleNewConversation 的 F-09 真并发
// 语义——新建不杀旧 loop,旧会话后台继续,无需「生成中禁新建」;原守卫静默吞键(无反馈)
// 与按钮可点自相矛盾。真并发下新旧会话事件按 conversation_id 路由,无串台风险。
if (ctrl && (e.key === 'n' || e.key === 'N')) {
e.preventDefault()
if (!store.state.streaming) store.newConversation()
store.newConversation()
return
}
// I13(2026-10-05):Ctrl+I 聚焦输入框(输入焦点守卫已让路——焦点已在输入控件内不重复抢);
// Ctrl+K 归会话搜索,聚焦输入框选 I(无浏览器默认冲突,IME 安全)。
if (ctrl && (e.key === 'i' || e.key === 'I')) {
e.preventDefault()
chatInputRef.value?.focus()
return
}
// Ctrl+R: 重新生成末条回复(替代浏览器刷新,Tauri 下 preventDefault 可靠)
@@ -386,6 +424,12 @@ function onGlobalKeydown(e: KeyboardEvent) {
answerConfirm(false) // 视为取消,resolve Promise 打断 confirmClearChat/confirmDelete
return
}
// I12:快捷键速查面板开时 Esc 仅关面板(优先级高于主面板/分离窗关闭,防误关整窗)
if (shortcutsVisible.value) {
e.preventDefault()
shortcutsVisible.value = false
return
}
// P1-2 Esc 仲裁:事件源落在内层 Esc 消费者(输入框/侧栏重命名框/队列编辑框等输入控件,
// 或技能/@联想浮层与技能 chip)时,Esc 语义归内层(关浮层/取消编辑/取消输入法组词),
// 不关分离窗——否则输入框按 Esc 关技能浮层的同时窗口也进入关闭流程,一次按键双重效果。
@@ -612,6 +656,9 @@ onMounted(async () => {
_unlistenAutoApproved = await listen<{ tool: string; dir: string }>('ai-tool-auto-approved-toast', (e) => {
showToast(t('aiChat.autoApprovedToast', { tool: e.payload.tool, dir: e.payload.dir }), 'info')
})
// V11b: 主窗口注册跨窗收回分离窗监听(label 判定在内部,分离窗调用为 no-op);
// reattachPanel 由主窗 store 执行(关全部 ai-detached-* 窗 + 恢复主面板)。
registerMainReattachListener(() => store.reattachPanel())
// P1-e-d: 审批失败 toast(useAiApproval.approveToolCall 审批 IPC 未送达经事件总线广播,
// 本组件 listen 弹本地 toast。主窗口/分离窗口各挂一份 AiChat,各自消费)
_unlistenAiToast = await listen<{ msg: string; type: 'info' | 'error' | 'warning' | 'success' }>('ai-toast', (e) => {
@@ -674,6 +721,30 @@ async function toggleAlwaysOnTop() {
appSettingsStore.set('df-ai-always-on-top', alwaysOnTop.value)
}
// ── V11b 收回分离窗 + I12 快捷键速查面板 ──
// V11b(2026-10-05):分离窗 TopBar「收回」按钮 emit 链——分离窗与主窗是独立 JS context,
// 不能直调主窗 store.reattachPanel;走 Tauri 全局事件广播(emitReattachToMain),主窗 AiChat
// 挂载时注册的 registerMainReattachListener(useAiWindow.ts,按 label==='main' 判定)消费执行。
// I12(2026-10-05):顶部 ... 菜单入口(TopBar emit show-shortcuts)→ 此处 Teleport 展示速查浮层。
// 键位表 shortcuts 与 onGlobalKeydown 同文件同居——键区调整时表随手同步,防速查与真实键位漂移。
const shortcutsVisible = ref(false)
// computed:随 locale 即时翻译(仅面板打开时消费,开销可忽略);t 已在本组件上方 useI18n 解构
const shortcuts = computed(() => [
{ key: 'Ctrl+N', label: t('aiChat.scNewConv') },
{ key: 'Ctrl+I', label: t('aiChat.scFocusInput') },
{ key: 'Ctrl+R', label: t('aiChat.scRegenerate') },
{ key: 'Ctrl+L', label: t('aiChat.scClearChat') },
{ key: 'Ctrl+K', label: t('aiChat.scSearch') },
{ key: 'Ctrl+B', label: t('aiChat.scToggleSidebar') },
{ key: 'Esc', label: t('aiChat.scCloseOverlay') },
])
function showShortcuts() {
shortcutsVisible.value = true
}
async function onReattachToMain() {
await emitReattachToMain()
}
// ── F-15 阶段2: 手动上下文管理(清空 / 压缩) ──
// ChatMessage.status 渲染语义(types.ts AiMessage.status 字面量联合定义):
// null | undefined | 'active' → 正常消息(现状)
@@ -833,6 +904,21 @@ async function initContextEventListeners(): Promise<void> {
flex-shrink: 0;
animation: ai-agentic-pulse 1.2s ease-in-out infinite;
}
/* B4: 自动压缩完成提示(一次性 4s;视觉克制度对齐 agentic 进度条,弱存在感) */
.ai-autocompressed-hint {
margin-bottom: 6px;
padding: 3px 8px;
background: color-mix(in srgb, var(--df-accent) 8%, transparent);
border-radius: var(--df-radius);
font-size: 11px;
color: var(--df-text-dim);
user-select: none;
animation: ai-autocompressed-fade 4s ease-in-out forwards;
}
@keyframes ai-autocompressed-fade {
0%, 70% { opacity: 1; }
100% { opacity: 0.35; }
}
.ai-agentic-progress-text {
font-family: var(--df-font-mono);
white-space: nowrap;
@@ -930,4 +1016,70 @@ async function initContextEventListeners(): Promise<void> {
.ai-toast-enter-active, .ai-toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
.ai-toast-enter-from, .ai-toast-leave-to { opacity: 0; transform: translate(-50%, -8px); }
/* ═══ I12 快捷键速查面板(Teleport 到 body,scoped 对 Teleport 内容仍生效——
Vue scoped 把 data 属性打在编译期节点上,与挂载位置无关) ═══ */
.ai-shortcuts-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.ai-shortcuts-panel {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg, 8px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
width: 320px;
max-width: calc(100vw - 48px);
padding: 14px 16px;
}
.ai-shortcuts-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.ai-shortcuts-title {
font-size: 13px;
font-weight: 600;
color: var(--df-text);
}
.ai-shortcuts-close {
background: transparent;
border: none;
color: var(--df-text-secondary);
cursor: pointer;
font-size: 12px;
padding: 2px 4px;
border-radius: 4px;
}
.ai-shortcuts-close:hover { color: var(--df-text); background: var(--df-bg-secondary); }
.ai-shortcuts-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.ai-shortcuts-row {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
}
.ai-shortcuts-key {
font-family: var(--df-mono, monospace);
font-size: 11px;
background: var(--df-bg-secondary);
border: 0.5px solid var(--df-border);
border-radius: 4px;
padding: 2px 8px;
color: var(--df-text);
}
.ai-shortcuts-act { color: var(--df-text-secondary); }
</style>
+6 -6
View File
@@ -84,28 +84,28 @@
:disabled="approving"
@click="onAuthorizeOnce">
<span v-if="approving" class="ai-tool-btn-spinner" />
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
<svg aria-hidden="true" v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{{ $t('aiChat.dirAuthOnce') }}
</button>
<button class="ai-tool-btn ai-tool-btn--session"
:disabled="approving"
@click="onAuthorizeSession">
<span v-if="approving" class="ai-tool-btn-spinner" />
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
<svg aria-hidden="true" v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{{ $t('aiChat.dirAuthSession') }}
</button>
<button class="ai-tool-btn ai-tool-btn--always"
:disabled="approving"
@click="onAuthorizeAlways">
<span v-if="approving" class="ai-tool-btn-spinner" />
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
<svg aria-hidden="true" v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{{ $t('aiChat.dirAuthAlways') }}
</button>
<button class="ai-tool-btn ai-tool-btn--reject"
:disabled="approving"
@click="onDeny">
<span v-if="approving" class="ai-tool-btn-spinner" />
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
<svg aria-hidden="true" v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
{{ $t('aiChat.dirAuthDeny') }}
</button>
</div>
@@ -114,14 +114,14 @@
:disabled="approving"
@click="onApprove">
<span v-if="approving" class="ai-tool-btn-spinner" />
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
<svg aria-hidden="true" v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{{ $t('aiTool.approve') }}
</button>
<button class="ai-tool-btn ai-tool-btn--reject"
:disabled="approving"
@click="onReject">
<span v-if="approving" class="ai-tool-btn-spinner" />
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
<svg aria-hidden="true" v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
{{ $t('aiTool.reject') }}
</button>
</div>
+29 -6
View File
@@ -26,6 +26,8 @@
<div v-if="group.calls.length >= 2" class="ai-tool-group-header" @click="toggleGroupCollapse(gi)">
<span class="ai-tool-group-icon" v-html="groupIcon(group.name)"></span>
<span class="ai-tool-group-name">{{ groupDisplayName(group) }}</span>
<!-- R7 组头失败徽标:折叠组内失败卡不展开也能从组头发现(红字计数) -->
<span v-if="groupFailCounts[gi] > 0" class="ai-tool-group-fail">{{ $t('aiTool.groupFailCount', { n: groupFailCounts[gi] }) }}</span>
<span class="ai-tool-group-chevron" :class="{ 'is-open': !isGroupCollapsed(gi) }">▸</span>
</div>
<ToolCard
@@ -61,6 +63,7 @@ import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import type { AiToolCallInfo } from '@/api/types'
import { useConfirm } from '@/composables/useConfirm'
import { toolRiskLevel } from '@/composables/ai/useToolApproval'
import { isToolFailure } from '@/composables/ai/useToolCard'
// useI18n needed for $t() in template (collapseAll / expandAll) + groupDisplayName(i18n key)
const { t } = useI18n()
@@ -110,6 +113,16 @@ const collapsedGroups = ref<Set<number>>(new Set())
// 用户手动展开过的组不再被自动收回——名字比索引稳(分组只追加不重排,但语义上名字更直白)。
const userTouchedGroups = ref(new Set<string>())
// ── R7 组头失败徽标:折叠组内失败卡扫组头即可发现 ──
// 病灶(R7 单行折叠信息量缺口):工具失败信号散在单卡红边,同名工具组默认折叠后
// 失败卡被 display:none 压住,10+ 卡需逐组展开逐行扫。轨迹卡聚合行/执行中步骤行
// 已有「失败 N」计数,唯一盲区是组头。此处补:组内失败卡 ≥1 时组头显红徽标
// 「{n} 失败」,折叠展开都可见。失败判定复用 useToolCard.isToolFailure 单点(与
// 单卡红边/轨迹卡统计同源,防双写判定漂移)。
const groupFailCounts = computed<number[]>(() =>
groupedToolCalls.value.map(g => g.calls.reduce((n, tc) => n + (isToolFailure(tc) ? 1 : 0), 0)),
)
interface ToolCallGroup {
name: string
calls: AiToolCallInfo[]
@@ -415,12 +428,12 @@ defineExpose({ collapseInactive, scrollToFirstPending })
/* -- 分组图标(复用 ToolCard 的 toolCategory 逻辑) -- */
function groupIcon(name: string): string {
if (name === 'read_file') return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>'
if (name === 'write_file') return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>'
if (name === 'list_directory') return '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>'
if (name.includes('create')) return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>'
if (name.includes('delete')) return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>'
return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>'
if (name === 'read_file') return '<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>'
if (name === 'write_file') return '<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>'
if (name === 'list_directory') return '<svg aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>'
if (name.includes('create')) return '<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>'
if (name.includes('delete')) return '<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>'
return '<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>'
}
</script>
@@ -501,6 +514,16 @@ function groupIcon(name: string): string {
.ai-tool-group-header:hover {
background: rgba(255,255,255,0.03);
}
/* R7 组头失败徽标:失败计数红字,margin-left auto 推到 chevron 之前右对齐 */
.ai-tool-group-fail {
margin-left: auto;
margin-right: 2px;
font-size: 10px;
font-weight: 600;
color: var(--df-danger);
opacity: 0.9;
white-space: nowrap;
}
.ai-tool-group-icon {
display: flex;
align-items: center;
+34 -7
View File
@@ -113,11 +113,16 @@
<!-- write_file 结果 -->
<div v-else-if="tc.name === 'write_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-write-result">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--df-success)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--df-success)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
<span class="ai-tool-write-path" :title="parsed?.path">{{ shortPath(parsed?.path || '') }}</span>
<span class="ai-tool-write-meta">{{ formatBytes(parsed?.bytes_written) }} {{ $t('aiTool.written') }}</span>
<span v-if="typeof parsed?.old_size === 'number' && parsed.old_size > 0" class="ai-tool-write-meta">{{ $t('aiTool.writeOverwrite', { old: formatBytes(parsed.old_size), neww: formatBytes(parsed?.bytes_written) }) }}</span>
<span v-if="typeof parsed?.encoding === 'string' && parsed.encoding !== 'utf-8'" class="ai-tool-write-meta">{{ $t('aiTool.encodingLabel', { enc: parsed.encoding }) }}</span>
<!-- R12(2026-10-05):覆盖写入的行级 diff 回看(后端 diff 字段,新建/base64 为 null 不渲染) -->
<div v-if="resultDiffLines.length" class="ai-tool-write-diff">
<pre class="ai-tool-diff-pre"><code><span v-for="(ln, idx) in resultDiffLines" :key="idx" class="ai-tool-diff-line" :class="'ai-tool-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
<button v-if="diffTotal > DIFF_PREVIEW_LINES" type="button" class="ai-tool-diff-toggle" @click="diffExpanded = !diffExpanded">{{ diffExpanded ? $t('aiTool.diffCollapse') : $t('aiTool.diffExpand', { n: diffTotal }) }}</button>
</div>
</div>
<!-- patch_file 结果:文件路径/字节增减 + unified diff 红删绿增行级预览(复用 ai-tool-diff-pre/--add/--del/--ctx 既有样式)
@@ -132,6 +137,8 @@
<div v-if="parsed?.warning" class="ai-tool-approval-reason">⚠ {{ parsed.warning }}</div>
<div v-if="resultDiffLines.length" class="ai-tool-approval-diff">
<pre class="ai-tool-diff-pre"><code><span v-for="(ln, idx) in resultDiffLines" :key="idx" class="ai-tool-diff-line" :class="'ai-tool-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
<!-- R12a:长 diff 展开入口(默认预览 120 行,点开全量) -->
<button v-if="diffTotal > DIFF_PREVIEW_LINES" type="button" class="ai-tool-diff-toggle" @click="diffExpanded = !diffExpanded">{{ diffExpanded ? $t('aiTool.diffCollapse') : $t('aiTool.diffExpand', { n: diffTotal }) }}</button>
</div>
</div>
@@ -202,7 +209,7 @@
<div v-if="obscuraMissing" class="ai-tool-obscura-hint">
<div class="ai-tool-obscura-hint-text">⚠ {{ parsed?.hint || $t('aiTool.fetchUrlFallbackHint') }}</div>
<button class="ai-tool-btn ai-tool-btn--install" @click="onInstallObscura">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
{{ $t('aiTool.installObscura') }}
</button>
</div>
@@ -331,10 +338,15 @@ const cmdOutput = computed(() => {
})
/**
* patch_file 结果 diff 行拆解(复用 parseDiffLines +add/-del/ ctx 着色)。
* 数据源:parsed.diff(patch_file 工具执行结果字段)。长 diff 截断到前 120 行防撑爆卡片。
* patch_file / write_file 结果 diff 行拆解(复用 parseDiffLines +add/-del/ ctx 着色)。
* 数据源:parsed.diff(工具执行结果字段,write_file 覆盖写入时后端同款生成)。
* R12a:默认截断前 120 行防撑爆卡片,expanded 后全量渲染(toggle 入口在 diff 块尾部)。
*/
const resultDiffLines = computed(() => parseDiffLines(parsed.value?.diff, 120))
const DIFF_PREVIEW_LINES = 120
const diffExpanded = ref(false)
const resultDiffLines = computed(() => parseDiffLines(parsed.value?.diff, diffExpanded.value ? undefined : DIFF_PREVIEW_LINES))
/** diff 全量行数(不受预览截断影响),驱动展开按钮显隐与行数文案 */
const diffTotal = computed(() => parseDiffLines(parsed.value?.diff).length)
/**
* grep 命中计数标签G-1 复用 useToolCard.formatGrep 单一函数,
@@ -864,5 +876,20 @@ watch(() => props.tc.status, (s) => {
font-family: var(--df-font-sans);
transition: filter 0.15s var(--df-ease);
}
.ai-tool-btn:hover { filter: brightness(1.1); }
</style>
.ai-tool-btn:hover { filter: brightness(1.1); }
/* R12: write_file diff 回看 + 长 diff 展开按钮 */
.ai-tool-write-diff { width: 100%; margin-top: 6px; }
.ai-tool-diff-toggle {
display: block;
margin: 4px auto 0;
background: transparent;
border: 0.5px dashed var(--df-border);
border-radius: 4px;
color: var(--df-text-secondary);
font-size: 11px;
padding: 3px 12px;
cursor: pointer;
}
.ai-tool-diff-toggle:hover { color: var(--df-text); background: var(--df-bg-secondary); }
</style>
+2 -2
View File
@@ -16,7 +16,7 @@
@click="onActivate"
>
<div class="approval-overlay-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 11l3 3L22 4" />
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
</svg>
@@ -31,7 +31,7 @@
:title="$t('common.close')"
@click.stop="onDismiss"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
+83 -8
View File
@@ -53,7 +53,18 @@
:aria-label="$t('aiChat.pickImage')"
@click="onPickImage"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
</button>
<!-- I9(2026-10-05):文本/代码文件附件入口——选文件读内容以 ```<file> 块插入草稿,
走既有文本发送链,零协议改动;大小/行数上限防刷屏 -->
<button
type="button"
class="ai-img-pick-btn"
:title="$t('aiChat.pickFileTitle')"
:aria-label="$t('aiChat.pickFileTitle')"
@click="onPickTextFile"
>
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
</button>
<!-- L2 状态机停止按钮三态(批2 1c):conv_state 派生 generating/stopping/error 三态,
idle(或 conv_state 未收到回退旧 streaming=false)显发送按钮。双轨过渡不强制全替旧 bool。 -->
@@ -63,7 +74,7 @@
:title="$t('aiChat.stopping')"
disabled
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
</button>
<button
v-else-if="stopBtnState === 'retry'"
@@ -71,7 +82,7 @@
:title="$t('aiChat.stopFailedRetry')"
@click="store.stopChat"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
</button>
<button
v-else-if="stopBtnState === 'stop'"
@@ -79,7 +90,7 @@
:title="$t('aiChat.stopGenerating')"
@click="store.stopChat"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
</button>
<button
v-else
@@ -88,7 +99,7 @@
:disabled="!canSend"
@click="handleSend"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
</div>
<!-- ⑥.4 Phase 4: @项目展开摘要(提取至 EnrichmentPanel.vue) -->
@@ -205,6 +216,66 @@ async function addImageFile(file: File): Promise<void> {
}
}
/** I9(2026-10-05):「📄 文件」按钮——选文本/代码文件读内容,以代码块形式插入输入框草稿。
* 病灶:附件通道仅图片,文本/代码文件无入口(粘贴/拖入均仅认 image/)。用户要给 AI 看一段
* 代码/配置只能手动开文件复制。本方案走纯前端:读文件内容插入草稿,复用既有文本发送链,
* 零协议/后端改动。多选逐个插入;单文件 256KB/5000 行上限(超出截断并提示,防撑爆上下文)。
* 扩展名白名单取常见文本/代码/配置格式,二进制格式(png/pdf/zip 等)不在名单直接拒绝。 */
const TEXT_FILE_EXTENSIONS = [
'txt', 'md', 'markdown', 'json', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', 'env', 'csv', 'tsv',
'js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx', 'vue', 'svelte', 'css', 'scss', 'less', 'html', 'htm', 'xml', 'svg',
'py', 'rs', 'go', 'java', 'kt', 'kts', 'c', 'h', 'cpp', 'hpp', 'cc', 'hh', 'cs', 'swift', 'm', 'mm',
'rb', 'php', 'pl', 'pm', 'lua', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'psm1', 'bat', 'cmd', 'sql', 'r',
'dart', 'gradle', 'cmake', 'dockerfile', 'makefile', 'gitignore', 'editorconfig', 'properties', 'proto', 'graphql', 'prisma',
]
const MAX_TEXT_FILE_BYTES = 256 * 1024
const MAX_TEXT_FILE_LINES = 5000
async function onPickTextFile(): Promise<void> {
const selected = await open({
multiple: true,
title: t('aiChat.pickFileTitle'),
filters: [{ name: t('aiChat.textFileFilter'), extensions: TEXT_FILE_EXTENSIONS }],
})
if (!selected) return
const files = Array.isArray(selected) ? selected : [selected]
for (const path of files) await insertTextFileDraft(path)
}
/** 读单个文本文件 → 截断(超限提示)→ 以 fenced code block 插入草稿(光标回到块尾) */
async function insertTextFileDraft(path: string): Promise<void> {
try {
// 复用图片链的 blobFromPath(convertFileSrc→fetch),项目未装 plugin-fs 零新依赖
const fileObj = await blobFromPath(path)
if (!fileObj) throw new Error('blobFromPath returned null')
const content = await fileObj.text()
const fileName = path.split(/[\\/]/).pop() || path
let text = content
let truncated = false
if (content.length > MAX_TEXT_FILE_BYTES) {
text = content.slice(0, MAX_TEXT_FILE_BYTES)
truncated = true
}
const lines = text.split('\n')
if (lines.length > MAX_TEXT_FILE_LINES) {
text = lines.slice(0, MAX_TEXT_FILE_LINES).join('\n')
truncated = true
}
// 文件名后缀作语言提示(无扩展名/特殊名不给语言,裸 ``` 也够用)
const ext = fileName.includes('.') ? (fileName.split('.').pop() || '').toLowerCase() : ''
const lang = TEXT_FILE_EXTENSIONS.includes(ext) ? ext : ''
const block = '\n\n```' + lang + ' <!-- ' + fileName + ' -->\n' + text + (truncated ? '\n… ' + t('aiChat.fileTooLargeTruncated') : '') + '\n```\n'
inputText.value += block
await nextTick()
inputEl.value?.focus()
const el = inputEl.value
if (el) el.selectionStart = el.selectionEnd = el.value.length
} catch (e) {
// 用户取消读取/二进制解码失败/权限拒绝:提示后继续(多选时不阻断后续文件)
emit('error', { msg: t('aiChat.fileReadFailed'), type: 'warning' })
console.error('[AI] 文本文件读取失败:', path, e)
}
}
/** "📎 图片"按钮:tauri 原生文件对话框选图(多选,仅图片)→ 逐个 addImageFile。 */
async function onPickImage(): Promise<void> {
const selected = await open({
@@ -727,7 +798,7 @@ function selectMention(item: MentionItem) {
el.focus()
el.setSelectionRange(pos, pos)
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
el.style.height = Math.min(el.scrollHeight, INPUT_MAX_HEIGHT) + 'px'
}
})
}
@@ -830,11 +901,15 @@ function handleKeydown(e: KeyboardEvent) {
}
}
/** 输入框最大高度(CSS .ai-input max-height 同源;I11:原 JS 120 vs CSS 200 不一致,
* 用户手工拉伸到 200px 后任意按键触发 autoResize 被压回 120px,违反 resize:vertical 预期) */
const INPUT_MAX_HEIGHT = 200
function autoResize() {
const el = inputEl.value
if (el) {
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
el.style.height = Math.min(el.scrollHeight, INPUT_MAX_HEIGHT) + 'px'
}
}
@@ -1022,7 +1097,7 @@ function fillDraft(text: string, skill: SkillInfo | null = null): void {
const len = el.value.length
el.setSelectionRange(len, len)
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
el.style.height = Math.min(el.scrollHeight, INPUT_MAX_HEIGHT) + 'px'
}
})
}
+36 -21
View File
@@ -15,12 +15,12 @@
<div class="ai-conv-sidebar-header">
<span class="ai-conv-sidebar-title">{{ t('aiChat.sidebarTitle') }}</span>
<button class="ai-btn-icon" @click="emit('confirm-new-conversation')" :title="t('aiChat.newConversation')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
<svg aria-hidden="true" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
</div>
<!-- UX-06 §3.1: 对话搜索框(v-model 双绑 store.searchQuery;Ctrl+K 聚焦) -->
<div class="ai-conv-search">
<svg class="ai-conv-search-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<svg aria-hidden="true" class="ai-conv-search-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input
ref="searchInputRef"
v-model="store.state.searchQuery"
@@ -73,7 +73,7 @@
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
<!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 -->
<span v-if="hasBoundary(conv.id)" class="ai-conv-boundary-badge" :title="t('ai.taskBoundary.badge')">
<svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg>
<svg aria-hidden="true" width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg>
</span>
</span>
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
@@ -81,23 +81,23 @@
<div class="ai-conv-item-actions">
<div class="ai-conv-item-more">
<button class="ai-conv-item-act" @click.stop="toggleActionsMenu(conv.id)" :title="t('aiChat.moreActions')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
</button>
<div v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu">
<button class="ai-conv-actions-opt" @click.stop="startRename(conv); closeActionsMenu()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
{{ t('aiChat.renameConversation') }}
</button>
<button class="ai-conv-actions-opt" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned); closeActionsMenu()">
<svg width="12" height="12" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
{{ conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation') }}
</button>
<button class="ai-conv-actions-opt" @click.stop="store.archiveConversation(conv.id, !conv.archived); closeActionsMenu()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 002 2h12a2 2 0 002-2V9"/><line x1="10" y1="13" x2="14" y2="13"/></svg>
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 002 2h12a2 2 0 002-2V9"/><line x1="10" y1="13" x2="14" y2="13"/></svg>
{{ conv.archived ? t('aiChat.unarchive') : t('aiChat.archive') }}
</button>
<button class="ai-conv-actions-opt ai-conv-actions-opt--danger" @click.stop="emit('confirm-delete', conv.id); closeActionsMenu()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
{{ t('common.delete') }}
</button>
</div>
@@ -156,7 +156,7 @@
>
{{ conv.title || t('aiChat.newConversation') }}
<!-- UX-2025-20 §7.4:AI 生成标题标识小图标(标题非空非占位时显示) -->
<svg
<svg aria-hidden="true"
v-if="conv.title && titleFlash && conv.id === store.state.activeConversationId"
class="ai-conv-item-title-badge"
width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"
@@ -167,7 +167,7 @@
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
<!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 -->
<span v-if="hasBoundary(conv.id)" class="ai-conv-boundary-badge" :title="t('ai.taskBoundary.badge')">
<svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg>
<svg aria-hidden="true" width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg>
</span>
</span>
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
@@ -175,23 +175,23 @@
<div class="ai-conv-item-actions">
<div class="ai-conv-item-more">
<button class="ai-conv-item-act" @click.stop="toggleActionsMenu(conv.id)" :title="t('aiChat.moreActions')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
</button>
<div v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu">
<button class="ai-conv-actions-opt" @click.stop="startRename(conv); closeActionsMenu()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
{{ t('aiChat.renameConversation') }}
</button>
<button class="ai-conv-actions-opt" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned); closeActionsMenu()">
<svg width="12" height="12" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
{{ conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation') }}
</button>
<button class="ai-conv-actions-opt" @click.stop="store.archiveConversation(conv.id, true); closeActionsMenu()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 002 2h12a2 2 0 002-2V9"/><line x1="10" y1="13" x2="14" y2="13"/></svg>
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 002 2h12a2 2 0 002-2V9"/><line x1="10" y1="13" x2="14" y2="13"/></svg>
{{ t('aiChat.archive') }}
</button>
<button class="ai-conv-actions-opt ai-conv-actions-opt--danger" @click.stop="emit('confirm-delete', conv.id); closeActionsMenu()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
{{ t('common.delete') }}
</button>
</div>
@@ -225,7 +225,7 @@
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
<!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 -->
<span v-if="hasBoundary(conv.id)" class="ai-conv-boundary-badge" :title="t('ai.taskBoundary.badge')">
<svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg>
<svg aria-hidden="true" width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg>
</span>
</span>
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
@@ -233,19 +233,19 @@
<div class="ai-conv-item-actions">
<div class="ai-conv-item-more">
<button class="ai-conv-item-act" @click.stop="toggleActionsMenu(conv.id)" :title="t('aiChat.moreActions')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
</button>
<div v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu">
<button class="ai-conv-actions-opt" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned); closeActionsMenu()">
<svg width="12" height="12" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
{{ conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation') }}
</button>
<button class="ai-conv-actions-opt" @click.stop="store.archiveConversation(conv.id, false); closeActionsMenu()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 002 2h12a2 2 0 002-2V9"/><line x1="12" y1="13" x2="12" y2="17"/><polyline points="10 15 12 13 14 15"/></svg>
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 002 2h12a2 2 0 002-2V9"/><line x1="12" y1="13" x2="12" y2="17"/><polyline points="10 15 12 13 14 15"/></svg>
{{ t('aiChat.unarchive') }}
</button>
<button class="ai-conv-actions-opt ai-conv-actions-opt--danger" @click.stop="emit('confirm-delete', conv.id); closeActionsMenu()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
{{ t('common.delete') }}
</button>
</div>
@@ -293,7 +293,7 @@ import { useAiStore } from '../../stores/ai'
import { convPendingApprovals } from '../../composables/ai/aiShared'
// 无人值守边界徽标只读消费:getConvBoundary 读镜像(reactive Map,模板内读取可追踪;
// 镜像由边界配置卡在切会话/写入时渐进填充,无批量 IPC 属契约限制)
import { getConvBoundary } from '../../composables/ai/useTaskBoundary'
import { getConvBoundary, fetchConvBoundary } from '../../composables/ai/useTaskBoundary'
// UX走查 P2-7 只读订阅:rename/archive/pin 失败通知由 useAiConversations 发布,本组件弹就近 toast
import { convOpFailNotice } from '../../composables/ai/useAiConversations'
import { useToast } from '../../composables/useToast'
@@ -609,8 +609,23 @@ onMounted(() => {
document.addEventListener('keydown', onActionsEscClose)
// V1:后台会话待审批变化兜底轮询(见 pendingCountByConv 注)
pendingPollTimer = setInterval(() => { pendingTick.value++ }, 2500)
// A10 边界盾标跨窗/重启空洞补拉:镜像 Map 是本窗口 JS context 内存态,分离窗口授权
// 或重启后(boundary_json 在库)侧栏盾标全消失。挂载时对列表会话补拉一轮填充镜像
// (fetch 静默降级:失败仅缺徽标不报错;已入镜像的会话跳过,不重复打 IPC)。
void hydrateBoundaryBadges()
})
/** A10:对侧栏全部会话补拉边界镜像(盾标徽标数据源)。
* 逐条并发 fire-and-forget;镜像已有条目跳过(配置卡/前次补拉已填,省 IPC)。
* 列表为空或全命中时零 IPC 开销。 */
async function hydrateBoundaryBadges(): Promise<void> {
const ids = store.state.conversations.map(c => c.id)
await Promise.allSettled(ids.map(id => {
if (getConvBoundary(id) !== null) return Promise.resolve()
return fetchConvBoundary(id)
}))
}
onBeforeUnmount(() => {
document.removeEventListener('click', onActionsOutsideClick)
document.removeEventListener('keydown', onActionsEscClose)
+3 -3
View File
@@ -34,19 +34,19 @@ const examplePrompts = [
<div class="ai-empty">
<template v-if="!hasProvider">
<div class="ai-empty-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--df-warning)" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<svg aria-hidden="true" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--df-warning)" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
</div>
<p class="ai-empty-text">{{ $t('aiChat.emptyNoProvider') }}</p>
<p class="ai-empty-sub">{{ $t('aiChat.emptyNoProviderHint') }}</p>
<!-- 跳 Settings:嵌入模式用 router.push;分离窗口无 Settings 路由,降级 toast 提示去主窗口 -->
<button class="ai-empty-action" @click="emit('go-to-settings')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
<span>{{ $t('aiChat.goToSettings') }}</span>
</button>
</template>
<template v-else>
<div class="ai-empty-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--df-text-dim)" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/><line x1="9" y1="21" x2="15" y2="21"/></svg>
<svg aria-hidden="true" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--df-text-dim)" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/><line x1="9" y1="21" x2="15" y2="21"/></svg>
</div>
<p class="ai-empty-text">{{ $t('aiChat.emptyTitle') }}</p>
<p class="ai-empty-sub">{{ $t('aiChat.emptyHint') }}</p>
+10 -19
View File
@@ -34,14 +34,11 @@ defineProps<{
msg: AiMessage
/** 是否显示编辑按钮(父合成:末条 user + 非生成中 + 非编辑中) */
canEdit?: boolean
/** I6:是否显示重新生成按钮(父合成同 canEdit 时机;作用于末条 user 触发的回复) */
canRegenerate?: boolean
}>()
const emit = defineEmits<{
(e: 'copy', msg: AiMessage): void
(e: 'start-edit', msg: AiMessage): void
(e: 'regenerate', msg: AiMessage): void
}>()
// ── Input Augmentation: 按 mention 区间切段 ──
@@ -106,7 +103,7 @@ function imgAlt(img: ImageContentPart): string {
<!-- 用户消息(本组件唯一渲染角色;assistant/system/error 由 MessageList 内联渲染) -->
<div class="ai-msg-user">
<div class="ai-msg-avatar ai-msg-avatar--user">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
<svg aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</div>
<div class="ai-msg-bubble ai-msg-bubble--user">
<!-- 问题3 技能调用留痕:前导 /技能名 命中已安装技能 → 紫色 chip 标识,替代原文前缀显示 -->
@@ -125,16 +122,12 @@ function imgAlt(img: ImageContentPart): string {
<img v-for="(img, idx) in msgImageParts(msg)" :key="msg.id + '-img-' + idx" :src="imageSrc(img)" :alt="imgAlt(img)" class="ai-msg-image" loading="lazy" />
</div>
</template>
<!-- I6:重新生成按钮(hover,编辑按钮左侧);复用后端 regenerate 链路(弹末尾 AI 回复取触发 user 消息重跑) -->
<button v-if="canRegenerate" class="ai-msg-regen-btn" :title="t('aiChat.regenerate')" @click.stop="emit('regenerate', msg)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
</button>
<!-- UX-09:末条 user 消息编辑按钮(hover 显示),点击经 start-edit 链回填输入框编辑重发 -->
<button v-if="canEdit" class="ai-msg-edit-btn" :title="t('aiChat.editMessage')" @click.stop="emit('start-edit', msg)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
<button v-if="canEdit" class="ai-msg-edit-btn" :title="t('aiChat.editMessage')" :aria-label="t('aiChat.editMessage')" @click.stop="emit('start-edit', msg)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<button class="ai-copy-btn" :title="t('aiChat.copyMsg')" @click.stop="emit('copy', msg)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
<button class="ai-copy-btn" :title="t('aiChat.copyMsg')" :aria-label="t('aiChat.copyMsg')" @click.stop="emit('copy', msg)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</button>
</div>
</div>
@@ -202,9 +195,8 @@ function imgAlt(img: ImageContentPart): string {
max-height: 200px;
border-radius: 6px;
}
/* 复制/编辑/重新生成按钮(hover 显示,同栏同视觉;编辑/重新生成仅各自 can* 时渲染) */
/* 复制/编辑按钮(hover 显示,同栏同视觉;编辑仅 canEdit 时渲染) */
.ai-copy-btn,
.ai-msg-regen-btn,
.ai-msg-edit-btn {
opacity: 0;
transition: opacity 0.15s;
@@ -215,11 +207,10 @@ function imgAlt(img: ImageContentPart): string {
color: var(--df-text-dim);
}
.ai-msg-user:hover .ai-copy-btn,
.ai-msg-user:hover .ai-msg-regen-btn,
.ai-msg-user:hover .ai-msg-edit-btn {
.ai-msg-user:hover .ai-msg-edit-btn,
/* UX第五轮 P2-M1:键盘聚焦可达(hover-only 修复)——Tab 聚到按钮或按钮内聚焦时显形 */
.ai-msg-user:focus-within .ai-copy-btn,
.ai-msg-user:focus-within .ai-msg-edit-btn {
opacity: 1;
}
.ai-msg-regen-btn:hover {
color: var(--df-accent);
}
</style>
+146 -65
View File
@@ -12,9 +12,9 @@
//! - emit('start-edit', msg):UX-09 编辑末条 user,父置 editingMsgId + 委托 ChatInput.startEditFocus
//! - emit('send-example-prompt', i18nKey):空状态示例问题,父委托 ChatInput.sendExamplePrompt
//! - emit('go-to-settings'):无 provider 引导 / 错误气泡「去设置」,父走 router/toast(detached 降级)
//! - emit('toast', {msg, type}):copy/regenerate/retry 等的 toast 转发(父持单一 toast 源)
//! - emit('toast', {msg, type}):copy/retry 等的 toast 转发(父持单一 toast 源)
//! - expose(scrollToBottom):父 ChatInput @sent 后调,滚动到底
import { ref, computed, nextTick, onBeforeUnmount, watch } from 'vue'
import { ref, computed, nextTick, onBeforeUnmount, watch, type Ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { useMarkdown } from '../../composables/useMarkdown'
@@ -206,7 +206,12 @@ const LOAD_MORE_TOP_PX = 60
async function handleMessagesScroll(): Promise<void> {
// 原 @scroll 职责(回底按钮显隐 + 底部边沿检测回调收起旧工具卡)保持
onMessagesScroll(() => collapseAllToolLists(buildActiveToolIds(store.state.messages)))
// R10(2026-10-05):收起旧卡加 isFollowingBottom 门——上滚读历史时新轮次/新卡到达
// 不再强制收起正浏览的工具卡(跟随意图锁存语义对齐 useMessageScroll.isFollowingBottom);
// 用户在底部跟随态时行为不变(自动收起聚焦新生成)。
onMessagesScroll(() => {
if (isFollowingBottom.value) collapseAllToolLists(buildActiveToolIds(store.state.messages))
})
// G3.5:滚顶阈值触发加载更早历史
const cur = loadMoreCursor
if (!cur.hasMore || cur.loading || cur.earliestSeq == null) return
@@ -373,7 +378,8 @@ watch(
const isFirst = lastMsgSnapshot === ''
lastMsgSnapshot = snap
if (isFirst) return
collapseAllToolLists(buildActiveToolIds(store.state.messages))
// R10 同门:非跟随态(上滚阅读中)不强制收起正浏览的工具卡
if (isFollowingBottom.value) collapseAllToolLists(buildActiveToolIds(store.state.messages))
},
)
@@ -383,7 +389,8 @@ watch(
() => store.state.agentRound,
(cur, prev) => {
if (cur <= (prev ?? 0)) return
collapseAllToolLists(buildActiveToolIds(store.state.messages))
// R10 同门:非跟随态(上滚阅读中)新轮次到达不强制收起正浏览的工具卡
if (isFollowingBottom.value) collapseAllToolLists(buildActiveToolIds(store.state.messages))
},
)
@@ -724,15 +731,6 @@ async function handleRetry(): Promise<void> {
}
}
/**
* I6 重新生成按钮:与错误气泡「重试」同链路(store.regenerate,后端弹末尾 AI 回复取触发
* user 消息重跑),入口场景不同——用户对已有回复不满意主动重问,挂在末条 user 气泡 hover。
* 与隐藏快捷键 Ctrl+R(AiChat.vue)等价,补齐 UI 入口(i18n aiChat.regenerate 文案已备)。
*/
async function handleRegenerate(): Promise<void> {
await handleRetry()
}
/**
* fetch_url obscura 未装时"AI 帮装"按钮:自动触发 AI 对话让 AI run_command 装流式。
* 提示语足够具体(给出 cargo install + GitHub releases 两种装法 + 装完告知用户重试),
@@ -791,28 +789,34 @@ const messageSegments = computed<MessageSegment[]>(() => {
return segments
})
/// 展开的折叠段 key 集合(点击分隔条切换);默认折叠
const expandedSegmentIds = ref<Set<string>>(new Set())
function toggleSegment(key: string): void {
const next = new Set(expandedSegmentIds.value)
if (next.has(key)) next.delete(key)
else next.add(key)
expandedSegmentIds.value = next
// ── 展开/切换态集合工厂(DRY 2026-10-05) ──
// 五组「Set 集合 + 同构 toggle」(折叠段/长消息/JSON 块/轨迹卡/思考 pill)此前五连逐字重复,
// 收敛本工厂。ids 返回 Ref<Set> 本体:script 侧 .value.has / 整体重置赋值 / 模板侧自动解包
// .has() 三种既有用法全部零改动,仅收敛重复的 toggle 实现。
// 不抽独立 composable 文件:五处仅本组件消费,文件内 DRY 即可,跨文件反增跳转成本。
function useToggleSet(): { ids: Ref<Set<string>>; toggle: (id: string) => void } {
const s = ref<Set<string>>(new Set())
return {
ids: s,
toggle: (id) => {
const next = new Set(s.value)
if (next.has(id)) next.delete(id)
else next.add(id)
s.value = next
},
}
}
/// 展开的折叠段 key 集合(点击分隔条切换);默认折叠
const { ids: expandedSegmentIds, toggle: toggleSegment } = useToggleSet()
// M1:assistant 纯文本气泡超长折叠,避免单条 AI 回复撑爆对话区。
// 仅对「已完成(非流式)」assistant 文本生效;流式中的消息不折叠(delta 重排抖动)。
// 阈值 1500 字符:约 markdown 渲染后一屏(400px@13px/1.6≈35行)的源文本量,超此即判长。
// 折叠 max-height 同步 400px(见 CSS .ai-msg-bubble--collapsed,两处一致)。
const LONG_MSG_THRESHOLD = 1500
/// 展开的长消息 id 集合(点击底部按钮切换);默认折叠
const expandedLongIds = ref<Set<string>>(new Set())
function toggleLong(id: string): void {
const next = new Set(expandedLongIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
expandedLongIds.value = next
}
const { ids: expandedLongIds, toggle: toggleLong } = useToggleSet()
function isLongMsg(msg: AiMessage): boolean {
return msg.role === 'assistant' && !!msg.content && msg.content.length > LONG_MSG_THRESHOLD
}
@@ -825,6 +829,15 @@ function shouldCollapseLong(msg: AiMessage): boolean {
return true
}
/// 折叠态是否跳过 markdown 渲染(B5 惰性渲染):折叠气泡视觉上只有 400px 高的 CSS 裁剪,
/// 但 v-html 仍全量渲染整段 markdown(150+ msg 长会话切换/挂载成本随消息数线性涨的主因)。
/// 跳过渲染后折叠态只显示首屏预览(前 800 字符)——展开瞬间才付全量渲染成本(一次性,可接受)。
/// 注意:预览截断可能断在 markdown 语法中间(半代码栅栏/半表格),渲染器按普通文本处理无副作用,
/// 仅视觉上「预览尾部可能带半个语法符号」;展开即见完整内容,不误导。
function collapsedPreviewHtml(msg: AiMessage): string {
return renderMdDecorated(msg.content.slice(0, 800))
}
// R3:折叠条带量 —— 折叠态按钮显示「已折叠 N 字符 / 约 M tokens,点击展开」。
// 折叠量按 M1 的标定估算(400px 视口 ≈ 阈值字符量,见 LONG_MSG_THRESHOLD 注),
// 即 content.length - 阈值;token 为粗估(中英混合 1 token ≈ 2.5 字符),只供量级感知。
@@ -856,34 +869,16 @@ function isToolResultJson(content: string | undefined | null): boolean {
}
}
/// 展开的 JSON 折叠块 id 集合(点击切换);默认折叠(max-height 80px 裁剪,可滚动)
const expandedJsonIds = ref<Set<string>>(new Set())
function toggleJson(id: string): void {
const next = new Set(expandedJsonIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
expandedJsonIds.value = next
}
const { ids: expandedJsonIds, toggle: toggleJson } = useToggleSet()
// A 过程压缩:执行轨迹展开态
// 完成轮次的过程文本+工具卡默认折叠为一张轨迹卡;用户手动展开后记入此集合,
// 不再被自动收回(换轮次/切会话回来都保持,会话切换时整体重置)。
const expandedTraceIds = ref<Set<string>>(new Set())
function toggleTrace(key: string): void {
const next = new Set(expandedTraceIds.value)
if (next.has(key)) next.delete(key)
else next.add(key)
expandedTraceIds.value = next
}
const { ids: expandedTraceIds, toggle: toggleTrace } = useToggleSet()
// UX 密度改造:轨迹内每轮思考单行 pill(灯泡+单行截断+右端 token 小字),点击展开完整思考段落。
// 展开态按 msg.id 记忆,与轨迹卡展开态(expandedTraceIds)互不影响。
const expandedThinkIds = ref<Set<string>>(new Set())
function toggleThink(id: string): void {
const next = new Set(expandedThinkIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
expandedThinkIds.value = next
}
const { ids: expandedThinkIds, toggle: toggleThink } = useToggleSet()
/** 轮次思考纯文本:reasoning_content 优先(DeepSeek/Anthropic thinking 同通道),content 兜底;
* 压平 markdown 换行;两者皆空回退「过程记录」占位(纯工具轮也有锚点) */
@@ -1126,6 +1121,8 @@ function agentFinTone(status: AgentFinishedCard['status']): 'ok' | 'warn' | 'err
// 未知值兜底中性 completed 词不误报。无字段(旧后端)回退 summary 启发式:用户停止
// 级联取消子代理时 stop_reason=「已被取消(...)」并经 summary 终止说明透出(后端两处
// 取消检查点均写此中文字面量),按该特征分流为「已取消」;无此特征才是系统截断。
// A-3(2026-10-05):并行多子代理卡序号身份——从 agentId 尾段提 agent-{n} 序号,
// 并行交错完成时卡面可区分「哪张卡是哪个代理」,防「3 轮/2 轮」乱序观感误读。
function agentFinStatusText(card: AgentFinishedCard): string {
const k = card.stop_kind
if (k) {
@@ -1145,6 +1142,13 @@ function agentFinStatusText(card: AgentFinishedCard): string {
if (card.status === 'failed') return t('ai.agentFinished.failed')
return t('ai.agentFinished.completed')
}
// A-3(2026-10-05):并行多子代理卡序号身份——从 agentId 尾段提取 agent-{n} 的 n,
// 并行交错完成时卡面可区分「哪张卡是哪个代理」,防多卡「N 轮」乱序观感误读;
// 解析失败(形态不符)返回 null,模板不渲染序号徽标。
function agentFinSeq(card: AgentFinishedCard): number | null {
const m = /::agent-(\d+)$/.exec(card.agentId || '')
return m ? Number(m[1]) : null
}
// 启动失败型子代理(并发占满/父会话已删/provider 缺失)在进循环前就返回,子会话未落库,
// 点卡 peek 必报「对话不存在」死胡同;rounds=0 是前端可得的最稳特征(进循环后的失败
// 也至少有一轮落库记录,可正常 peek)。
@@ -1311,7 +1315,19 @@ function jumpToTrace(key: string): void {
}
// 切换对话/新对话时由父取消编辑态;滚动边沿/跟随意图重置在此自管。
watch(() => store.state.activeConversationId, () => {
// V12 per-conv 滚动位置记忆:换出会话存 scrollTop,换入会话有存档则恢复(不再一律回底)。
// 容量收敛:仅记最近 20 个会话(Map 超限删最早),防长会话列表无限增长。
const _scrollTopMemo = new Map<string, number>()
const _SCROLL_MEMO_LIMIT = 20
watch(() => store.state.activeConversationId, (newId, oldId) => {
// 换出:记旧会话滚动位置(容器就绪且旧 id 有效才记)
if (oldId && messagesContainer.value) {
if (_scrollTopMemo.size >= _SCROLL_MEMO_LIMIT) {
_scrollTopMemo.delete(_scrollTopMemo.keys().next().value!)
}
_scrollTopMemo.set(oldId, messagesContainer.value.scrollTop)
}
// 切会话重置滚动边沿检测 + 跟随意图(新会话默认跟随底部)
setFollowing(true)
// 切会话重置执行轨迹展开态(展开记忆按会话隔离,防跨会话 key 撞车误展开)
@@ -1334,7 +1350,18 @@ watch(() => store.state.activeConversationId, () => {
// UX 走查-对话域 B2:切会话显式回底。仅 watch messages.length 时,两个等长消息数的会话
// 互切不触发任何滚动,scrollTop 残留旧会话位置(可能停在中段)。setFollowing(true)已声明
// 跟随意图,此处补上实际滚动;nextTick 等新会话消息渲染出真实 scrollHeight 再滚。
nextTick(() => scrollToBottom())
// V12(2026-10-05):有滚动位置存档的会话恢复存档位置(回读不被打断),无存档(首次/新会话)
// 保持回底。跟随意图仅在回底路径置位;恢复路径置 setFollowing(false) 尊重用户阅读位置。
nextTick(() => {
const saved = newId ? _scrollTopMemo.get(newId) : undefined
const el = messagesContainer.value
if (saved != null && el) {
el.scrollTop = Math.min(saved, el.scrollHeight)
setFollowing(false)
} else {
scrollToBottom()
}
})
})
// 流式 rAF 清理:组件卸载时若仍有 pending rAF(streaming 中途切走/关窗),取消防泄漏。
@@ -1369,7 +1396,8 @@ defineExpose({
<template>
<!-- Messages -->
<div class="ai-messages" ref="messagesContainer" @scroll="handleMessagesScroll" @click="onMdCodeCopyClick">
<!-- UX第五轮 P2-G1:role="log"+aria-live=polite,读屏用户可感知流式新消息与回复更新 -->
<div class="ai-messages" ref="messagesContainer" role="log" aria-live="polite" @scroll="handleMessagesScroll" @click="onMdCodeCopyClick">
<!-- 空状态 (UX-2025-20: 示例问题卡片 + 无 provider 引导跳 Settings) — 提取至 EmptyState.vue -->
<EmptyState
v-if="store.state.messages.length === 0 && !store.state.streaming"
@@ -1421,7 +1449,7 @@ defineExpose({
@click="toggleThink(m.id)"
>
<span class="ai-think-ic">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a7 7 0 00-4 12.7c.6.5 1 1.4 1 2.3h6c0-.9.4-1.8 1-2.3A7 7 0 0012 2z"/></svg>
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a7 7 0 00-4 12.7c.6.5 1 1.4 1 2.3h6c0-.9.4-1.8 1-2.3A7 7 0 0012 2z"/></svg>
</span>
<span class="ai-think-text">{{ thinkSnippet(m) }}</span>
<span v-if="roundTokenText(m)" class="ai-think-tokens">{{ roundTokenText(m) }}</span>
@@ -1465,6 +1493,8 @@ defineExpose({
@click="openAgentTrace(item.card)"
>
<span class="ai-agent-fin-dot"></span>
<!-- A-3 并行多子代理卡序号身份:agent-{n} 解析成功才渲染,防多卡轮次乱序误读 -->
<span v-if="agentFinSeq(item.card) !== null" class="ai-agent-fin-seq">#{{ agentFinSeq(item.card) }}</span>
<span class="ai-agent-fin-label">{{ $t('ai.agentFinished.title') }}</span>
<span class="ai-agent-fin-status">{{ agentFinStatusText(item.card) }}</span>
<!-- 被拒工具 chip(P2-1):非空才渲染,琥珀警示层与 truncated 同色系;
@@ -1509,7 +1539,7 @@ defineExpose({
:class="'ai-msg-segment--' + item.seg.kind"
@click="toggleSegment(item.seg.key)"
>
<svg class="ai-msg-segment-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<svg aria-hidden="true" class="ai-msg-segment-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<template v-if="item.seg.kind === 'archived'">
<polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/>
</template>
@@ -1545,16 +1575,14 @@ defineExpose({
v-else-if="item.msg.role === 'user'"
:msg="item.msg"
:can-edit="isLastUser(item.msg) && !store.state.streaming && item.msg.id !== editingMsgId"
:can-regenerate="isLastUser(item.msg) && !store.state.streaming"
@copy="copyMsgContent"
@start-edit="(m) => emit('start-edit', m)"
@regenerate="handleRegenerate"
/>
<!-- AI 消息 -->
<div v-else class="ai-msg-ai">
<div class="ai-msg-avatar ai-msg-avatar--ai">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/></svg>
<svg aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/></svg>
</div>
<div class="ai-msg-content">
<!-- 文本内容(流式或固定,Markdown 渲染) -->
@@ -1583,7 +1611,10 @@ defineExpose({
<div v-for="block in decoratedStreamingBlocks" :key="block.key" v-html="block.html"></div>
<span v-if="isViewingGenerating" class="ai-cursor"></span>
</template>
<!-- 完成/历史:整段v-html(不变,经装饰层注入代码块复制按钮) -->
<!-- 完成/历史:整段v-html(不变,经装饰层注入代码块复制按钮)。
B5 惰性渲染:折叠态仅渲染前 800 字符预览(全量渲染延迟到展开瞬间),
长(>1500字)历史消息的 markdown 渲染成本不再随会话长度线性累积。 -->
<div v-else-if="shouldCollapseLong(item.msg)" v-html="collapsedPreviewHtml(item.msg)"></div>
<div v-else v-html="renderMdDecorated(item.msg.content)"></div>
<!-- M1/R3:超长 assistant 文本折叠条(折叠态带量:已折叠 N 字符/约 M tokens) -->
<button
@@ -1597,7 +1628,7 @@ defineExpose({
:title="$t('aiChat.copyMsg')"
@click.stop="copyMsgContent(item.msg)"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</button>
<button
v-if="item.msg.content"
@@ -1605,7 +1636,7 @@ defineExpose({
:title="$t('aiChat.copyAsImage')"
@click.stop="copyMsgAsImage(item.msg)"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
</button>
</div>
<!-- 流式开始但尚无文本时显示光标 -->
@@ -1622,7 +1653,7 @@ defineExpose({
:disabled="store.state.streaming"
@click.stop="handleRetry"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
<svg aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
<span>{{ $t('aiChat.retry') }}</span>
</button>
<button
@@ -1630,7 +1661,7 @@ defineExpose({
class="ai-msg-act"
@click.stop="emit('go-to-settings')"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
<svg aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
<span>{{ $t('aiChat.goToSettingsAction') }}</span>
</button>
</div>
@@ -1687,6 +1718,23 @@ defineExpose({
<span class="ai-token-popover-label">{{ $t('aiChat.tokenReasonLabel') }}</span>
<span class="ai-token-popover-val">{{ formatTokensK(tokenReasonOf(item.msg)) }} ({{ tokenReasonOf(item.msg) }})</span>
</div>
<!-- #12(2026-10-05):会话累计(convTokenTotal)入面板——数据只写不读的闲置数据源接上消费;
累计为前端内存态(切换会话由 switchConversation 重算),会话末条 AI 回复的 popover 展示 -->
<template v-if="isLastAi(item.msg) && store.state.convTokenTotal">
<div class="ai-token-popover-sep"></div>
<div class="ai-token-popover-row ai-token-popover-row--total">
<span class="ai-token-popover-label">{{ $t('aiChat.tokenConvTotal') }}</span>
<span class="ai-token-popover-val">{{ formatTokensK(store.state.convTokenTotal.total) }} ({{ store.state.convTokenTotal.total }})</span>
</div>
<div class="ai-token-popover-row">
<span class="ai-token-popover-label">{{ $t('aiChat.tokenConvTotalIn') }}</span>
<span class="ai-token-popover-val">{{ formatTokensK(store.state.convTokenTotal.prompt) }} ({{ store.state.convTokenTotal.prompt }})</span>
</div>
<div class="ai-token-popover-row">
<span class="ai-token-popover-label">{{ $t('aiChat.tokenConvTotalOut') }}</span>
<span class="ai-token-popover-val">{{ formatTokensK(store.state.convTokenTotal.completion) }} ({{ store.state.convTokenTotal.completion }})</span>
</div>
</template>
<!-- 命中率与 cache 段同闸:无缓存口径的 provider 不显 0%,防「无数据」误读成「全未命中」 -->
<div v-if="tokenCacheVisible(item.msg) && cacheHitRate(item.msg) != null" class="ai-token-popover-row">
<span class="ai-token-popover-label">{{ $t('aiChat.tokenCacheRateLabel') }}</span>
@@ -1723,9 +1771,11 @@ defineExpose({
</div>
<!-- 回到底部(上滑看历史 / 有新内容时显示) -->
<!-- R11(2026-10-05):生成中且非跟随态 → 按钮加脉冲点,提示「下面有新内容」而非仅导航 -->
<Transition name="back-to-bottom">
<button v-if="showBackToBottom" class="ai-back-to-bottom" @click="() => scrollToBottom()" :title="$t('aiChat.backToBottom')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>
<button v-if="showBackToBottom" class="ai-back-to-bottom" :class="{ 'has-new': store.state.streaming && !isFollowingBottom }" @click="() => scrollToBottom()" :title="$t('aiChat.backToBottom')">
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>
<span v-if="store.state.streaming && !isFollowingBottom" class="ai-btb-new-dot"></span>
</button>
</Transition>
@@ -1744,7 +1794,7 @@ defineExpose({
</div>
</div>
<button class="ai-agent-peek-close" :title="$t('common.close')" @click="closeAgentTrace">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="ai-agent-peek-body">
@@ -1809,6 +1859,21 @@ defineExpose({
.back-to-bottom-leave-active { transition: opacity 0.15s, transform 0.15s; }
.back-to-bottom-enter-from,
.back-to-bottom-leave-to { opacity: 0; transform: translateY(8px); }
/* R11 新内容脉冲点:生成中非跟随态提示下方有新内容(amber 脉冲,2s 循环) */
.ai-btb-new-dot {
position: absolute;
top: -2px;
right: -2px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--df-warning, #e8a33d);
animation: ai-btb-pulse 2s ease-in-out infinite;
}
@keyframes ai-btb-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.45; transform: scale(1.25); }
}
/* -- Empty State 样式已随 EmptyState.vue 提取 -- */
@@ -2285,6 +2350,15 @@ defineExpose({
gap: 12px;
padding: 2px 0;
}
/* #12 会话累计段分隔线 + 总计行加重 */
.ai-token-popover-sep {
height: 0.5px;
background: var(--df-border);
margin: 4px 0;
}
.ai-token-popover-row--total .ai-token-popover-label {
font-weight: 600;
}
.ai-token-popover-label {
color: var(--df-text-dim);
}
@@ -2615,6 +2689,13 @@ defineExpose({
flex-shrink: 0;
font-weight: 600;
}
/* A-3 并行多子代理卡序号徽标:等宽小号弱化展示,解析失败不渲染(v-if 已守) */
.ai-agent-fin-seq {
flex-shrink: 0;
font-family: var(--df-font-mono, monospace);
font-size: 11px;
opacity: 0.65;
}
.ai-agent-fin-status {
flex-shrink: 0;
}
+72 -25
View File
@@ -5,14 +5,16 @@
(中途改边界=边跑边扩权,设计明令禁止)。不引入对话框,最小折叠卡。 -->
<div class="ai-boundary">
<button type="button" class="ai-boundary-toggle" @click="toggleOpen">
<svg class="ai-boundary-chevron" :class="{ 'is-open': open }" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
<svg aria-hidden="true" class="ai-boundary-chevron" :class="{ 'is-open': open }" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
<svg aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
<span class="ai-boundary-toggle-label">{{ t('ai.taskBoundary.toggle') }}</span>
<span v-if="current" class="ai-boundary-echo">{{ echoSummary }}</span>
</button>
<div v-if="open" class="ai-boundary-body">
<div v-if="locked" class="ai-boundary-locked">{{ t('ai.taskBoundary.lockedGenerating') }}</div>
<!-- A11: 卡展开切会话保留未提交编辑时的可见提示(编辑不静默蒸发,但目标会话已变) -->
<div v-if="open && hasDirtyEdits" class="ai-boundary-dirty">{{ t('ai.taskBoundary.dirtyKept') }}</div>
<!-- 拉取失败 ≠ 无边界:失败态禁「一次授权」(防默认空表单覆盖原授权),保留表单现状供重试 -->
<div v-if="loadFailed" class="ai-boundary-loadfail">
@@ -61,29 +63,30 @@
</div>
<button type="button" class="ai-boundary-budget-toggle" @click="budgetOpen = !budgetOpen">
<svg class="ai-boundary-chevron" :class="{ 'is-open': budgetOpen }" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
<svg aria-hidden="true" class="ai-boundary-chevron" :class="{ 'is-open': budgetOpen }" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
{{ t('ai.taskBoundary.budgetToggle') }}
</button>
<div v-if="budgetOpen" class="ai-boundary-budget">
<label class="ai-boundary-field">
<label class="ai-boundary-field" :class="{ 'is-error': budgetErrField === 'rounds' }">
<span>{{ t('ai.taskBoundary.budgetRounds') }}</span>
<input v-model="rounds" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :disabled="locked" />
<input v-model="rounds" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :disabled="locked" @input="budgetErrField = ''" />
</label>
<label class="ai-boundary-field">
<label class="ai-boundary-field" :class="{ 'is-error': budgetErrField === 'tokens' }">
<span>{{ t('ai.taskBoundary.budgetTokens') }}</span>
<input v-model="tokens" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :disabled="locked" />
<input v-model="tokens" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :disabled="locked" @input="budgetErrField = ''" />
</label>
<label class="ai-boundary-field">
<label class="ai-boundary-field" :class="{ 'is-error': budgetErrField === 'wallSecs' }">
<span>{{ t('ai.taskBoundary.budgetWallSecs') }}</span>
<input v-model="wallSecs" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :disabled="locked" />
<input v-model="wallSecs" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :disabled="locked" @input="budgetErrField = ''" />
</label>
<span class="ai-boundary-dim">{{ t('ai.taskBoundary.budgetHint') }}</span>
</div>
<div class="ai-boundary-actions">
<button type="button" class="ai-boundary-confirm" :disabled="locked || loadFailed" @click="confirmSet">{{ t('ai.taskBoundary.confirmSet') }}</button>
<!-- A12: confirm/clear in-flight 防重(busy 禁用,防双击双 IPC 后到覆盖先到) -->
<button type="button" class="ai-boundary-confirm" :disabled="locked || loadFailed || confirming" @click="confirmSet">{{ t('ai.taskBoundary.confirmSet') }}</button>
<!-- A3: 清除=收权(安全向),generating 中保持可用,与字段编辑锁分离;有 confirm 二次确认 -->
<button v-if="current" type="button" class="ai-boundary-clear" @click="clearBoundary">{{ t('ai.taskBoundary.clearBoundary') }}</button>
<button v-if="current" type="button" class="ai-boundary-clear" :disabled="confirming" @click="clearBoundary">{{ t('ai.taskBoundary.clearBoundary') }}</button>
</div>
</div>
@@ -125,6 +128,24 @@ const budgetOpen = ref(false)
const rounds = ref('')
const tokens = ref('')
const wallSecs = ref('')
// A12: in-flight 防重(双击双 IPC 后到覆盖先到)+ 出错字段定位
const confirming = ref(false)
const budgetErrField = ref<'' | 'rounds' | 'tokens' | 'wallSecs'>('')
// A11: 未提交脏编辑保护(切会话不静默蒸发正在输入的目录/预算)
const hasDirtyEdits = computed(() => {
if (!current.value) {
return dirs.value.length > 0 || dirInput.value.trim() !== ''
|| rounds.value.trim() !== '' || tokens.value.trim() !== '' || wallSecs.value.trim() !== ''
}
const b = current.value
const dirsDirty = JSON.stringify(dirs.value) !== JSON.stringify([...b.dir_allowlist])
const budgetDirty = (b.budget
? rounds.value.trim() !== String(b.budget.max_rounds)
|| tokens.value.trim() !== String(b.budget.max_tokens)
|| wallSecs.value.trim() !== String(b.budget.wall_secs)
: rounds.value.trim() !== '' || tokens.value.trim() !== '' || wallSecs.value.trim() !== '')
return dirsDirty || budgetDirty
})
const activeId = computed(() => store.state.activeConversationId)
// 生成中锁编辑:防「边跑边扩权」。isGenerating 派生自 convStates reactive Map,computed 内可追踪
@@ -183,11 +204,12 @@ function toggleOpen(): void {
if (open.value) void hydrate()
}
// 切会话:预拉边界喂侧栏徽标镜像;展开态走 hydrate(内含拉取)重填表单,防上一会话表单串台
// 切会话:预拉边界喂侧栏徽标镜像;展开态走 hydrate(内含拉取)重填表单,防上一会话表单串台。
// A11: 卡展开且有未提交脏编辑时不静默重填(保留编辑至用户手动收起/确认),仅静默拉取喂镜像。
watch(activeId, (id) => {
if (!id) return
if (open.value) void hydrate()
else void fetchConvBoundary(id)
void fetchConvBoundary(id)
if (open.value && !hasDirtyEdits.value) void hydrate()
})
onMounted(() => {
if (activeId.value) void fetchConvBoundary(activeId.value)
@@ -235,12 +257,12 @@ function removeDir(d: string): void {
dirs.value = dirs.value.filter(x => x !== d)
}
/** 预算三项解析:全空=undefined(不限);任一非空但非正整数视为无效返回 null */
/** 预算三项解析:全空=undefined(不限);任一非空但非正整数视为无效返回 null(带字段键定位) */
function parseBudget(): { max_rounds: number; max_tokens: number; wall_secs: number } | undefined | null {
const fields = [
{ v: rounds.value.trim(), min: 1 },
{ v: tokens.value.trim(), min: 1 },
{ v: wallSecs.value.trim(), min: 1 },
{ v: rounds.value.trim(), key: 'rounds' as const },
{ v: tokens.value.trim(), key: 'tokens' as const },
{ v: wallSecs.value.trim(), key: 'wallSecs' as const },
]
if (fields.every(f => !f.v)) return undefined
const nums: number[] = []
@@ -250,7 +272,11 @@ function parseBudget(): { max_rounds: number; max_tokens: number; wall_secs: num
continue
}
const n = Number(f.v)
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) return null
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) {
// A12: 记录出错字段,toast 定位提示(替代笼统「预算需为正整数」)
budgetErrField.value = f.key
return null
}
nums.push(n)
}
return { max_rounds: nums[0], max_tokens: nums[1], wall_secs: nums[2] }
@@ -259,10 +285,16 @@ function parseBudget(): { max_rounds: number; max_tokens: number; wall_secs: num
/** 一次授权:set 当前活跃会话边界并收卡。未落库会话后端内存即生效,首条消息落库随行 */
async function confirmSet(): Promise<void> {
const id = activeId.value
if (!id || locked.value || loadFailed.value) return
if (!id || locked.value || loadFailed.value || confirming.value) return
const budget = parseBudget()
if (budget === null) {
emit('toast', { msg: t('ai.taskBoundary.budgetInvalid'), type: 'warning' })
// A12: 出错字段定位(红字标到具体输入框 + toast 指名)
const fieldLabel = budgetErrField.value === 'rounds'
? t('ai.taskBoundary.budgetRounds')
: budgetErrField.value === 'tokens'
? t('ai.taskBoundary.budgetTokens')
: t('ai.taskBoundary.budgetWallSecs')
emit('toast', { msg: t('ai.taskBoundary.budgetInvalidField', { field: fieldLabel }), type: 'warning' })
return
}
const boundary: TaskBoundary = {
@@ -271,6 +303,7 @@ async function confirmSet(): Promise<void> {
forbid_destructive: true,
budget: budget ?? null,
}
confirming.value = true
try {
await saveConvBoundary(id, boundary)
open.value = false
@@ -278,6 +311,9 @@ async function confirmSet(): Promise<void> {
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
emit('toast', { msg: t('ai.taskBoundary.setFail', { msg }), type: 'error' })
} finally {
// A12: 防重守卫复位(成功收卡/失败停留都复位;3s 兜底防 IPC 悬挂卡死)
confirming.value = false
}
}
@@ -286,8 +322,9 @@ async function confirmSet(): Promise<void> {
* 不该是停掉整个任务);因运行中收权影响后续工具审批方式,加 confirm 二次确认 */
async function clearBoundary(): Promise<void> {
const id = activeId.value
if (!id) return
if (!id || confirming.value) return
if (!await confirmDialog(t('ai.taskBoundary.clearConfirm'))) return
confirming.value = true
try {
await saveConvBoundary(id, null)
resetForm()
@@ -295,6 +332,8 @@ async function clearBoundary(): Promise<void> {
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
emit('toast', { msg: t('ai.taskBoundary.setFail', { msg }), type: 'error' })
} finally {
confirming.value = false
}
}
@@ -525,6 +564,14 @@ const echoSummary = computed(() => {
cursor: pointer;
border-radius: calc(var(--df-radius) - 2px);
}
.ai-boundary-clear:hover:not(:disabled) { color: var(--df-danger); border-color: var(--df-danger); }
.ai-boundary-clear:disabled { cursor: not-allowed; opacity: 0.5; }
</style>
.ai-boundary-clear:hover:not(:disabled) { color: var(--df-danger); border-color: var(--df-danger); }
.ai-boundary-clear:disabled { cursor: not-allowed; opacity: 0.5; }
/* A12: 预算字段校验错误着色(定位到具体输入框,替代笼统 toast) */
.ai-boundary-field.is-error .ai-boundary-input { border-color: var(--df-danger); }
.ai-boundary-field.is-error span { color: var(--df-danger); }
/* A11: 脏编辑提示条(切会话保留编辑时的可见说明) */
.ai-boundary-dirty {
font-size: 10.5px;
color: var(--df-warning);
}
</style>
+87 -34
View File
@@ -5,24 +5,26 @@
<div class="ai-header">
<div class="ai-header-left">
<button class="ai-btn-icon" @click="emit('toggle-sidebar')" :title="$t('aiChat.conversationList')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
<svg aria-hidden="true" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<span class="ai-header-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/><line x1="9" y1="21" x2="15" y2="21"/></svg>
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/><line x1="9" y1="21" x2="15" y2="21"/></svg>
</span>
<span class="ai-header-title">{{ $t('ai.assistant') }}</span>
<div
<button
v-if="pendingApprovalCount > 0"
type="button"
class="ai-approval-badge"
:title="$t('aiChat.pendingApprovalHint')"
@click="emit('scroll-to-pending')"
>
⏳ {{ $t('aiChat.pendingApprovalCount', { n: pendingApprovalCount }) }}
</div>
</button>
</div>
<!-- 提供商状态 + 模型选择器 -->
<div class="ai-header-center">
<div
<button
type="button"
class="ai-provider-bar"
:class="{ 'ai-provider-bar--switchable': store.state.providers.length > 1 }"
v-if="store.state.providers.length > 0"
@@ -31,7 +33,7 @@
>
<span class="provider-dot" :class="'provider-dot--' + providerStatus"></span>
<span class="provider-name">{{ activeProviderName }}</span>
</div>
</button>
<div class="ai-provider-bar ai-provider-empty" v-else>
<span class="provider-hint">{{ $t('aiChat.providerNotConfigured') }}</span>
</div>
@@ -60,7 +62,7 @@
:title="$t('aiChat.moreActions')"
@click="moreMenuOpen = !moreMenuOpen"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>
<svg aria-hidden="true" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>
</button>
<Transition name="ai-more-popout">
<div v-if="moreMenuOpen" class="ai-more-popout" role="menu">
@@ -70,7 +72,7 @@
:disabled="!hasActiveMessages || store.state.streaming"
@click="emit('clear-context'); moreMenuOpen = false"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>
<span>{{ $t('aiChat.clearContext') }}</span>
</button>
<!-- F-15 阶段2: 压缩上下文(LLM 摘要落 system + 历史消息标 compressed 归档) -->
@@ -79,10 +81,18 @@
:disabled="!hasActiveMessages || store.state.streaming || store.isCompressing.value"
@click="emit('compress-context'); moreMenuOpen = false"
>
<svg v-if="!store.isCompressing.value" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
<svg v-else class="ai-btn-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg>
<svg aria-hidden="true" v-if="!store.isCompressing.value" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
<svg aria-hidden="true" v-else class="ai-btn-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg>
<span>{{ store.isCompressing.value ? $t('aiChat.compressing') : $t('aiChat.compressContext') }}</span>
</button>
<!-- I12(2026-10-05):快捷键速查入口(零快捷键提示补课;面板体在 AiChat Teleport 渲染) -->
<button
class="ai-more-item"
@click="emit('show-shortcuts'); moreMenuOpen = false"
>
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="6" width="20" height="12" rx="2"/><line x1="6" y1="10" x2="6.01" y2="10"/><line x1="10" y1="10" x2="10.01" y2="10"/><line x1="14" y1="10" x2="14.01" y2="10"/><line x1="18" y1="10" x2="18.01" y2="10"/><line x1="7" y1="14" x2="7.01" y2="14"/><line x1="11" y1="14" x2="11.01" y2="14"/><line x1="15" y1="14" x2="15.01" y2="14"/></svg>
<span>{{ $t('aiChat.shortcutsTitle') }}</span>
</button>
</div>
</Transition>
</div>
@@ -90,26 +100,31 @@
<!-- 嵌入模式:最大化/还原 + 分离 + 关闭 -->
<template v-if="!detached">
<button class="ai-btn-icon" @click="emit('toggle-maximize')" :title="store.state.maximized ? $t('aiChat.restoreSidebar') : $t('aiChat.maximize')">
<svg v-if="store.state.maximized" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>
<svg v-else width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3"/></svg>
<svg aria-hidden="true" v-if="store.state.maximized" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>
<svg aria-hidden="true" v-else width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3"/></svg>
</button>
<button class="ai-btn-icon" @click="emit('detach')" :title="$t('aiChat.detachWindow')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="M9 21H3v-6"/><path d="M21 3l-7 7"/><path d="M3 21l7-7"/></svg>
<svg aria-hidden="true" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="M9 21H3v-6"/><path d="M21 3l-7 7"/><path d="M3 21l7-7"/></svg>
</button>
<button class="ai-btn-icon" @click="emit('close-panel')" :title="$t('aiChat.closePanel')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
<svg aria-hidden="true" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</template>
<!-- 分离模式:吸附 + 置顶 + 关闭 -->
<!-- 分离模式:收回主面板 + 吸附 + 置顶 + 关闭 -->
<template v-else>
<!-- V11b(2026-10-05):收回分离窗回主面板(emitReattachToMain → 主窗 reattachPanel),
分离窗自关。与「关闭」区别:关闭仅关窗不恢复主面板,收回=分离→主面板合一 -->
<button class="ai-btn-icon" @click="emit('reattach')" :title="$t('aiChat.reattachToMain')">
<svg aria-hidden="true" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/><path d="M9 12h6"/><path d="M12 9l0 6"/></svg>
</button>
<button class="ai-btn-icon" :class="{ 'ai-btn-icon--active': store.state.docked }" @click="emit('dock')" :title="store.state.docked ? $t('aiChat.undock') : $t('aiChat.dockRight')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="8" height="18" rx="1"/><rect x="14" y="3" width="8" height="18" rx="1"/></svg>
<svg aria-hidden="true" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="8" height="18" rx="1"/><rect x="14" y="3" width="8" height="18" rx="1"/></svg>
</button>
<button class="ai-btn-icon" :class="{ 'ai-btn-icon--active': alwaysOnTop }" @click="emit('toggle-always-on-top')" :title="alwaysOnTop ? $t('aiChat.unpin') : $t('aiChat.pinOnTop')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2C8 2 6 5 6 8v3l-2 2v1h16v-1l-2-2V8c0-3-2-6-6-6z"/><line x1="12" y1="14" x2="12" y2="22"/></svg>
<svg aria-hidden="true" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2C8 2 6 5 6 8v3l-2 2v1h16v-1l-2-2V8c0-3-2-6-6-6z"/><line x1="12" y1="14" x2="12" y2="22"/></svg>
</button>
<button class="ai-btn-icon" @click="emit('close-detached')" :title="$t('aiChat.closeWindow')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
<svg aria-hidden="true" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</template>
</div>
@@ -121,8 +136,11 @@
<div class="ai-bottom-tools-right">
<!-- 无人值守任务进度(AiTaskProgress,设计 §4.3):当前会话有进度快照时显示,
静默期(子代理长跑/大工具执行/重入退避)凭最后快照可见「在干活」;终局即清。
max_rounds=0(不限轮次)不显分母进度条,只显轮次与 token。 -->
<div v-if="taskProgress" class="ai-task-progress">
max_rounds=0(不限轮次)不显分母进度条,只显轮次与 token。
A8 双进度矛盾:输入区 agentic 实时进度条可见时(streaming 且 agentRound>0)
隐藏本快照徽章——两处同屏且轮数口径不同(实时 vs 每5轮快照)互相矛盾;
快照徽章只服务输入区不可见场景(托盘/单轮大工具静默期)。 -->
<div v-if="taskProgress && !agenticProgressVisible" class="ai-task-progress">
<span class="ai-task-progress-dot"></span>
<span class="ai-task-progress-text">{{ taskProgressText }}</span>
<span
@@ -135,10 +153,10 @@
</div>
<!-- 目标计数 -->
<div v-if="goals.length" class="ai-goals-inline">
<span class="ai-goals-inline-badge ai-tool-badge" :class="{ 'is-active': goalsExpanded }" @click="togglePanel('goals')" :title="$t('aiChat.viewGoals')">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>
<button type="button" class="ai-goals-inline-badge ai-tool-badge" :class="{ 'is-active': goalsExpanded }" @click="togglePanel('goals')" :title="$t('aiChat.viewGoals')">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>
{{ goals.filter(g => g.status === 'active').length }}/{{ goals.length }}
</span>
</button>
<Transition name="ai-pop">
<div v-if="goalsExpanded" class="ai-goals-inline-list">
<div v-for="(g, i) in goals" :key="i" class="ai-goal-inline-item" :class="{ 'ai-goal-completed': g.status === 'completed' }">
@@ -152,10 +170,10 @@
</div>
<!-- 历史消息 -->
<div v-if="historyMsgs.length" class="ai-history-inline">
<span class="ai-history-inline-badge ai-tool-badge" :class="{ 'is-active': historyExpanded }" @click="togglePanel('history')" :title="$t('aiChat.viewHistory')">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<button type="button" class="ai-history-inline-badge ai-tool-badge" :class="{ 'is-active': historyExpanded }" @click="togglePanel('history')" :title="$t('aiChat.viewHistory')">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{{ historyMsgs.length }}
</span>
</button>
<Transition name="ai-pop">
<div v-if="historyExpanded" class="ai-history-inline-list">
<div v-for="h in historyMsgs" :key="h.id" class="ai-history-inline-item" @click="scrollToMessage(h.id)">
@@ -168,10 +186,10 @@
</div>
<!-- 上下文摘要 -->
<div v-if="summaryMsgs.length" class="ai-summary-inline">
<span class="ai-summary-inline-badge ai-tool-badge" :class="{ 'is-active': summaryExpanded }" @click="togglePanel('summary')" :title="$t('aiChat.viewSummary')">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h12"/></svg>
<button type="button" class="ai-summary-inline-badge ai-tool-badge" :class="{ 'is-active': summaryExpanded }" @click="togglePanel('summary')" :title="$t('aiChat.viewSummary')">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h12"/></svg>
{{ summaryMsgs.length }}
</span>
</button>
<Transition name="ai-pop">
<div v-if="summaryExpanded" class="ai-summary-inline-list">
<div v-for="(s, i) in summaryMsgs" :key="i" class="ai-summary-inline-item" @click="scrollToMessage(s.id)">
@@ -183,9 +201,9 @@
</div>
<!-- L3 完整上下文 -->
<div v-if="contextInfo.goals.length || contextInfo.summaryCount > 0" class="ai-context-inline">
<span class="ai-context-inline-badge ai-tool-badge" :class="{ 'is-active': contextExpanded }" @click="togglePanel('context')" :title="$t('aiChat.viewContext')">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><circle cx="12" cy="8" r="1" fill="currentColor"/></svg>
</span>
<button type="button" class="ai-context-inline-badge ai-tool-badge" :class="{ 'is-active': contextExpanded }" @click="togglePanel('context')" :title="$t('aiChat.viewContext')">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><circle cx="12" cy="8" r="1" fill="currentColor"/></svg>
</button>
<Transition name="ai-pop">
<div v-if="contextExpanded" class="ai-context-inline-list">
<!-- 系统提示 -->
@@ -276,8 +294,12 @@ const emit = defineEmits<{
(e: 'dock'): void
/** 置顶切换(父 toggleAlwaysOnTop 调 Tauri setAlwaysOnTop) */
(e: 'toggle-always-on-top'): void
/** V11b 收回分离窗回主面板(父 emitReattachToMain 广播,主窗 reattachPanel 执行) */
(e: 'reattach'): void
/** 关闭分离窗口(store.closeDetachedWindow) */
(e: 'close-detached'): void
/** I12 展示快捷键速查面板(父 shortcutsVisible=true,面板体在父 Teleport 到 body) */
(e: 'show-shortcuts'): void
/** Provider 循环切换后回传 model 名(父 showToast 弹切换提示,保持单一 toast 源) */
(e: 'provider-switched', modelName: string): void
}>()
@@ -387,6 +409,11 @@ const taskProgress = computed(() => {
if (!id) return null
return pendingTaskProgress.get(id) ?? null
})
// A8 双进度矛盾:输入区 agentic 实时进度条可见条件(与 AiChat.vue showAgenticProgress 同口径)——
// 可见时 TopBar 快照徽章让位隐藏(轮数口径不同:实时 vs 每5轮快照,同屏互相矛盾)
const agenticProgressVisible = computed(() =>
store.state.streaming && store.state.agentRound > 0,
)
const taskProgressText = computed(() => {
const p = taskProgress.value
if (!p) return ''
@@ -654,7 +681,7 @@ onBeforeUnmount(() => {
white-space: nowrap;
max-width: 200px;
}
/* 审批徽标(待审批>0 时显于头部,点击跳首个审批卡) */
/* 审批徽标(待审批>0 时显于头部,点击跳首个审批卡;div→button 后归零默认样式) */
.ai-approval-badge {
display: inline-flex;
align-items: center;
@@ -667,10 +694,18 @@ onBeforeUnmount(() => {
font-weight: 500;
white-space: nowrap;
transition: opacity 0.15s var(--df-ease);
font-family: inherit;
appearance: none;
/* review ③:div→button 后 UA outset 边框需显式归零(appearance:none 不含 border) */
border: none;
}
.ai-approval-badge:hover {
opacity: 0.85;
}
.ai-approval-badge:focus-visible {
outline: 1.5px solid var(--df-accent, #4a9eff);
outline-offset: 1px;
}
/* ═══ 模型选择器(对话透明化 L1:紧凑下拉) ═══ */
.ai-model-picker {
@@ -840,7 +875,7 @@ onBeforeUnmount(() => {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
/* ═══ Provider Bar (header 中间区) ═══ */
/* ═══ Provider Bar (header 中间区;div→button 后归零默认样式) ═══ */
.ai-provider-bar {
display: inline-flex;
align-items: center;
@@ -848,6 +883,11 @@ onBeforeUnmount(() => {
font-size: 11px;
color: var(--df-text-secondary);
white-space: nowrap;
font-family: inherit;
appearance: none;
background: none;
border: none;
padding: 0;
}
.ai-provider-bar--switchable {
cursor: pointer;
@@ -855,6 +895,11 @@ onBeforeUnmount(() => {
.ai-provider-bar--switchable:hover {
color: var(--df-text-primary);
}
.ai-provider-bar--switchable:focus-visible {
outline: 1.5px solid var(--df-accent, #4a9eff);
outline-offset: 2px;
border-radius: 2px;
}
/* provider 连通状态点:灰=未测/绿=最近成功/红=最近失败(基于最后 assistant 消息 isError) */
.provider-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--df-text-dim); flex-shrink: 0; transition: background .2s; }
.provider-dot--ok { background: var(--df-success); }
@@ -889,6 +934,14 @@ onBeforeUnmount(() => {
border: 0.5px solid var(--df-border);
transition: color 0.15s, background 0.15s;
white-space: nowrap;
/* UX第五轮 P2-T1:span→button 键盘可达后,归零 button 默认样式保持原视觉 */
font-family: inherit;
font-weight: normal;
appearance: none;
}
.ai-tool-badge:focus-visible {
outline: 1.5px solid var(--df-accent, #4a9eff);
outline-offset: 1px;
}
.ai-tool-badge svg { vertical-align: middle; } /* 图标在行高内垂直居中(与数字同基线)*/
.ai-tool-badge:hover {
@@ -22,7 +22,7 @@
</div>
<div class="project-meta-row">
<span class="project-meta-item">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 3v12"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 01-9 9"/></svg>
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 3v12"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 01-9 9"/></svg>
{{ $t('dashboard.taskUnit', { n: p.activeTasks }) }}
</span>
<span class="project-meta-time">{{ p.lastActivity }}</span>
+11 -11
View File
@@ -3,7 +3,7 @@
<!-- Logo(7b:brand 区只保留品牌标识,收缩按钮已移到底部操作区) -->
<div class="sidebar-brand">
<div class="brand-mark">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" fill="url(#brand-grad)" />
<defs><linearGradient id="brand-grad" x1="3" y1="2" x2="21" y2="22">
<stop stop-color="#7b6ff0"/><stop offset="1" stop-color="#5eaff0"/>
@@ -54,7 +54,7 @@
<!-- AI 面板切换按钮(footer 常驻跨路由渲染,承担后台运行中会话的全局可见标识) -->
<button class="nav-link ai-toggle-btn" :class="{ 'ai-toggle-btn--active': aiStore.state.panelOpen }" @click="aiStore.togglePanel()" :title="runningConvCount > 0 ? `${runningConvCount} 个会话运行中` : (collapsed ? $t('nav.aiPanel') : undefined)">
<span class="nav-link-icon ai-spark-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M19 3v3M21 4.5h-3M5 17v3M6.5 18.5h-3"/></svg>
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M19 3v3M21 4.5h-3M5 17v3M6.5 18.5h-3"/></svg>
</span>
<span v-if="!collapsed" class="nav-link-text">{{ $t('nav.aiPanel') }}</span>
<span v-if="!collapsed && runningConvCount > 0" class="nav-link-badge">{{ runningConvCount }}</span>
@@ -63,15 +63,15 @@
</button>
<router-link to="/settings" class="nav-link" active-class="nav-link--active" :title="collapsed ? $t('nav.settings') : undefined">
<span class="nav-link-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
</span>
<span v-if="!collapsed" class="nav-link-text">{{ $t('nav.settings') }}</span>
</router-link>
<!-- 7b:收缩/展开切换按钮(最底部,快捷键 Ctrl+B 同效) -->
<button class="nav-link collapse-toggle-btn" @click="toggleCollapsed" :title="collapsed ? $t('nav.expand') : $t('nav.collapse')">
<span class="nav-link-icon">
<svg v-if="!collapsed" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
<svg aria-hidden="true" v-if="!collapsed" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
<svg aria-hidden="true" v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</span>
<span v-if="!collapsed" class="nav-link-text">{{ $t('nav.collapse') }}</span>
<span v-if="!collapsed" class="ai-toggle-shortcut">Ctrl+B</span>
@@ -151,20 +151,20 @@ const runningConvCount = computed(() => {
const primaryNav = [
{ path: '/dashboard', label: 'nav.overview', badge: null,
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>' },
svg: '<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>' },
{ path: '/ideas', label: 'nav.ideas', badge: null,
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6M10 22h4M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1h-6a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/></svg>' },
svg: '<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6M10 22h4M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1h-6a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/></svg>' },
{ path: '/projects', label: 'nav.projects', badge: null,
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>' },
svg: '<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>' },
{ path: '/tasks', label: 'nav.tasks', badge: null,
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>' },
svg: '<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>' },
]
const secondaryNav = [
{ path: '/knowledge', label: 'nav.knowledge',
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>' },
svg: '<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>' },
{ path: '/audit', label: 'nav.audit',
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>' },
svg: '<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>' },
]
</script>
+1 -1
View File
@@ -30,7 +30,7 @@
type="button"
:title="$t('fileExplorer.deleteCurrentModule')"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
<svg aria-hidden="true" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<!-- 添加依赖弹窗 -->
+6 -6
View File
@@ -46,20 +46,20 @@
<div class="toolbar-right">
<button class="btn btn-ghost btn-sm" type="button" :title="$t('fileExplorer.editCurrentModule')" @click="openEditModule">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<button class="btn btn-ghost btn-sm" type="button" :title="$t('fileExplorer.deleteCurrentModule')" @click="confirmRemoveModule">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
<button class="btn btn-ghost btn-sm" type="button" :title="$t('fileExplorer.addModuleTitle')" @click="openAddModule">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<button class="btn btn-ghost btn-sm" type="button" :disabled="refreshing" @click="refresh">
<span v-if="refreshing" class="spinner"></span>
{{ refreshing ? $t('fileExplorer.refreshing') : $t('fileExplorer.refresh') }}
</button>
<button class="btn btn-ghost btn-sm" type="button" :title="$t('fileExplorer.detachToWindow')" @click="onDetach">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 3h6v6"/>
<path d="M10 14L21 3"/>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
@@ -74,10 +74,10 @@
<div class="explorer-sidebar">
<div class="explorer-view-tabs">
<button class="view-tab" :class="{ active: viewMode === 'tree' }" @click="viewMode = 'tree'">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
</button>
<button class="view-tab" :class="{ active: viewMode === 'changes' }" @click="switchToChanges">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
<span v-if="changedCount > 0" class="changed-badge">{{ changedCount }}</span>
</button>
</div>
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,7 +7,7 @@
<span v-if="!showHistory" class="branch-count">{{ gitStatus?.changed_files.length ?? 0 }} {{ $t('gitChanges.changedFiles') }}</span>
<span v-else class="branch-count">{{ totalCommits }} {{ $t('gitChanges.commitsUnit') }}</span>
<button v-if="!showHistory" class="branch-switch-btn" @click="onSwitchBranch" :title="$t('gitChanges.switchBranch')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>
</button>
</div>
@@ -85,7 +85,7 @@
<div v-else class="gc-history">
<!-- 提交搜索框(前端过滤已加载提交:subject/author) -->
<div class="gc-search">
<svg class="gc-search-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<svg aria-hidden="true" class="gc-search-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input
v-model="commitSearch"
class="gc-search-input"
+2 -1
View File
@@ -153,9 +153,10 @@ async function batchApprove(decision: 'approve' | 'reject') {
}
}
_batchProcessing.value = false
// 若有失败项,console.warn 诊断(用户可打开 DevTools 查看具体失败项)
// #14(2026-10-05):部分失败从静默 console.warn 升级为用户可见 toast(仍保留 warn 留痕诊断)
if (failCount > 0) {
console.warn(`[AI] 批量审批完成: ${successCount} 成功, ${failCount} 失败`)
void emit('ai-toast', { msg: t('aiTool.batchPartialFail', { ok: successCount, fail: failCount }), type: 'warning' })
}
}
+27 -6
View File
@@ -5,6 +5,7 @@
//! - newConversation/switchConversation 写 appSettings 持久化活跃会话 id
import { ref } from 'vue'
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai'
@@ -15,12 +16,32 @@ import { nextMsgId, getConvState, clearConvStreamState, clearLastDelta, startApp
import { t } from '@/i18n/i18n-helpers'
import type { AiConversationDetail, AiMessage, AiToolCallInfo, AiToolCallStatus, ConvId, MessageId } from '@/api/types'
// ── V11c(2026-10-05)活跃会话 KV 按窗口域分流 ──
// 主窗与分离窗是独立 JS context,却共用同一 SQLite KV:两窗各自切会话都写 'df-ai-active-conv'
// 会互相覆盖(后写者赢),重启后两窗恢复到同一会话。按窗口 label 分流:主窗 'df-ai-active-conv'
// (历史 KV 兼容,主窗数据零迁移),分离窗 'df-ai-active-conv-detached';读端(loadConversations
// 启动恢复)与写端(切会话/新建/自动路由/删除回落)统一走本函数取键。
// 每次调用现解析:窗口对象在 webview 启动即存在(getCurrentWebviewWindow 同步),无启动竞态;
// 非 Tauri 环境(单测/工具链)兜底主窗键。useAiEvents 的自动路由写入点同源 import 消费。
export function activeConvKvKey(): string {
try {
return getCurrentWebviewWindow().label === 'main'
? 'df-ai-active-conv'
: 'df-ai-active-conv-detached'
} catch {
return 'df-ai-active-conv'
}
}
const appSettings = useAppSettingsStore()
/** 拉取会话列表;首次加载时若 appSettings 仍有有效 active-conv 且当前无活跃对话则恢复 */
async function loadConversations() {
try {
state.conversations = await aiApi.listConversations()
// includeArchived=true:侧栏归档分组(ConversationSidebar archivedConvs)数据源即本内存列表,
// 若拉取时排除归档,重启后已归档会话不可见且无恢复入口(2026-10-05 实锤 bug)。
// 活跃/归档分组由侧栏 groupedActive/archivedConvs 各自分离渲染;后端已分组截断,归档不挤占活跃名额。
state.conversations = await aiApi.listConversations(50, true)
// 虚拟项保底:活跃会话未落库(懒创建——首条消息前 DB 无记录)时,侧栏显示虚拟占位项。
// 后端 ai_conversation_create 仅生成 id 存内存不落库,首条消息发送后 save_conversation 才写库。
// 此处 active 会话不在 DB 列表时补一个虚拟项,实现「点新建立即出现在侧栏,不发消息不落库」。
@@ -42,7 +63,7 @@ async function loadConversations() {
})
}
// 恢复上次活跃对话(仅在首次加载、ID 仍有效且当前无活跃对话时)
const pending = appSettings.get<string | null>('df-ai-active-conv', null)
const pending = appSettings.get<string | null>(activeConvKvKey(), null)
if (pending && !state.activeConversationId && state.conversations.some(c => c.id === pending)) {
await switchConversation(pending)
}
@@ -101,7 +122,7 @@ async function newConversation() {
)
if (!result) return // 失败已推气泡,保持当前视图
state.activeConversationId = result.id
void appSettings.set('df-ai-active-conv', result.id)
void appSettings.set(activeConvKvKey(), result.id)
// G3.5:新对话无历史,load_more 游标复位
loadMoreCursor.hasMore = false
loadMoreCursor.earliestSeq = null
@@ -319,7 +340,7 @@ export async function switchConversation(id: string, force = false, opts?: { noC
console.warn('[AI] switchConversation 失败(对话不存在),创建新对话:', id)
const created = await aiApi.createConversation()
if (mySwitchId !== _latestSwitchId) return 'ok'
void appSettings.set('df-ai-active-conv', created.id)
void appSettings.set(activeConvKvKey(), created.id)
// 兜底置 activeId:此路径原只持久化不写 state.activeConversationId,旧(不存在)id 残留
// 会让后续所有事件被 isCurrent 判为非当前而丢弃或误路由
state.activeConversationId = created.id
@@ -418,7 +439,7 @@ export async function switchConversation(id: string, force = false, opts?: { noC
return 'ok'
}
// 切换提交完成才持久化活跃会话 + 重算 streaming(active 已指向目标 conv)
void appSettings.set('df-ai-active-conv', id)
void appSettings.set(activeConvKvKey(), id)
setStreaming(targetGen, { convId: id, reason: 'switchConversation-recompute' })
// P2-2 完成卡回补:重启/分离窗回收后内存卡清零,按父 conv 反查子代理轨迹重建。
@@ -552,7 +573,7 @@ async function deleteConversation(id: string) {
clearLastDelta(id)
discardConvPendingApprovals(id)
if (wasActive) {
void appSettings.remove('df-ai-active-conv')
void appSettings.remove(activeConvKvKey())
// 删除活跃会话 → load_more 游标复位(无 active 视图)
loadMoreCursor.hasMore = false
loadMoreCursor.earliestSeq = null
+2 -2
View File
@@ -19,7 +19,7 @@ import { state } from '@/stores/ai'
import { nextMsgId, setConvState, convStates, switchingConvs, getConvStreamState, setConvCurrentText, setConvStreaming, clearConvStreamState, clearAllApprovalTimers, clearAllToolSlowTimers, flushCurrentText, friendlyError, notifyConversationChanged, clearLastDelta, discardConvPendingApprovals, addConvAgentFinished, clearConvAgentFinished, type AgentFinishedStatus } from './aiShared'
import { resetStreamWatchdog, clearAllStreamWatchdogs, clearResumeAttempts } from './useAiStream'
import { setStreaming } from './streamingGuard'
import { scheduleConversationsRefresh } from './useAiConversations'
import { scheduleConversationsRefresh, activeConvKvKey } from './useAiConversations'
import { pendingTaskParked, pendingTaskProgress } from './useAiPendingState'
import { clearConvBoundaryCache } from './useTaskBoundary'
import { handleStreamingEvent } from './useAiStreamingEvents'
@@ -187,7 +187,7 @@ export function handleEvent(event: AiChatEvent) {
// 首次收到事件时同步当前对话 id(后端自动建对话的场景)并持久化
if (convId && !state.activeConversationId) {
state.activeConversationId = convId
void appSettings.set('df-ai-active-conv', convId)
void appSettings.set(activeConvKvKey(), convId)
}
// L2 状态机:AiConvStateChanged 是 per-conv 状态信号,在 isCurrent 守卫之前处理
// (切走会话时也须更新 conv_state,防停止按钮/MaxRoundsCard 错态)
+7 -1
View File
@@ -14,7 +14,7 @@
//! - drainQueue 由 handleEvent(AiCompleted) 经 ai-drain-queue 事件总线触发(非直接调用,
//! 打破 events↔send 循环依赖);本模块模块级监听该事件
import { listen } from '@tauri-apps/api/event'
import { listen, emit } from '@tauri-apps/api/event'
import { invoke } from '@tauri-apps/api/core'
import { ref } from 'vue'
import { aiApi } from '@/api'
@@ -537,6 +537,12 @@ async function stopChat() {
// 决策 a(逐条续发):不再清队列,保留供 AiCompleted 链式触发 drainQueue 续发。
// 后端 agentic/mod.rs emit AiCompleted 注释明确「保证前端收事件时后端已可接下一条,队列续发不被拒」;
// 当前未完成回复已入库(agentic/mod.rs save_conversation)保留为截断回复。
// I8(2026-10-05):停止≠清队,用户预期常是「全部停下」——队列非空时一次性提示
// 「停止后 N 条排队消息仍将自动发送」,给用户明确预期(要全停可逐条取消/清队列)。
const pendingQueued = state.queue.filter(q => q.conversationId === state.activeConversationId).length
if (pendingQueued > 0) {
void emit('ai-toast', { msg: t('ai.queueWillContinue', { n: pendingQueued }), type: 'info' })
}
try {
// F-260616-09 B 批4(决策 e):传 activeConversationId,停止仅作用于当前 conv。
await aiApi.stopChat(state.activeConversationId)
+12 -3
View File
@@ -25,10 +25,19 @@ export function handleToolEvent(event: AiChatEvent): boolean {
}
// 同 id 重复 Started 不重复 push(provider id 不稳,防残留 running 空卡)
if (!findToolCall(event.id)) {
// #15(2026-10-05):末条非 assistant(如 system 占位/思考 pill/完成轨迹折叠重排)时
// 原直接丢弃工具卡 → running 卡消失但工具仍在执行。兜底向前找最近 assistant 落卡;
// 全程无 assistant(极端:首轮工具前无任何回复)才真丢弃(与原行为一致)。
const lastMsg = state.messages[state.messages.length - 1]
if (lastMsg && lastMsg.role === 'assistant') {
lastMsg.toolCalls = lastMsg.toolCalls || []
lastMsg.toolCalls.push(info)
let target = (lastMsg && lastMsg.role === 'assistant') ? lastMsg : null
if (!target) {
for (let i = state.messages.length - 1; i >= 0; i--) {
if (state.messages[i].role === 'assistant') { target = state.messages[i]; break }
}
}
if (target) {
target.toolCalls = target.toolCalls || []
target.toolCalls.push(info)
}
}
// 工具开始执行即挂慢执行计时器(超时仅 toast,不动 running 态)
+52 -1
View File
@@ -12,6 +12,7 @@
import { invoke } from '@tauri-apps/api/core'
import { state } from '@/stores/ai'
import { useAppSettingsStore } from '@/stores/appSettings'
import { nextMsgId, getConvState, convStates } from './aiShared'
import type { MessageId } from '@/api/types'
import { setStreaming } from './streamingGuard'
@@ -58,6 +59,45 @@ export async function emitDetachedOpenConversation(convId: string): Promise<void
}
}
// ── V11b(2026-10-05)跨窗收回分离窗:reattachPanel 的 UI 入口 ──
// 病灶:reattachPanel(关闭全部分离窗+回主面板)全仓零 UI 调用——分离窗内「关闭」按钮走
// closeDetachedWindow(仅关窗不恢复主面板),用户想收回只能手动重开主面板。主/分离窗是
// 独立 JS context,分离窗按钮无法直调主窗 store;走 Tauri 全局事件广播(对齐 OPEN_CONV_EVENT
// 模式):分离窗 emit → 主窗(label 判定)监听 → 执行 reattachPanel。
const REATTACH_EVENT = 'ai-reattach-panel'
/** 发起方(分离窗)侧:广播收回事件,主窗口消费 */
export async function emitReattachToMain(): Promise<void> {
try {
const { emit } = await import('@tauri-apps/api/event')
await emit(REATTACH_EVENT, {})
} catch (e) {
console.error('[AI] 跨窗收回事件发送失败:', e)
}
}
/** 承接方(主窗口)侧:注册收回监听。handler=reattachPanel(由 AiChat 挂载时注入,归窗口域) */
let _reattachListenerRegistered = false
export function registerMainReattachListener(handler: () => Promise<void>): void {
if (_reattachListenerRegistered) return
_reattachListenerRegistered = true
void (async () => {
try {
const [{ listen }, { getCurrentWebviewWindow }] = await Promise.all([
import('@tauri-apps/api/event'),
import('@tauri-apps/api/webviewWindow'),
])
if (getCurrentWebviewWindow().label !== 'main') return
await listen<Record<string, never>>(REATTACH_EVENT, async () => {
await handler()
})
} catch (e) {
console.error('[AI] 主窗口收回监听注册失败:', e)
_reattachListenerRegistered = false
}
})()
}
/** 承接方(分离窗)侧:注册跨窗切会话监听,切换完成后聚焦本窗口(点击发生在主窗口,
* 焦点须跟到承接会话的窗口,否则用户感知「点了没反应」)。
* handler 由 useAiConversations 注入:切会话逻辑归会话域,避免本模块反向依赖造成模块环。 */
@@ -114,6 +154,12 @@ async function detachPanel(convId?: string) {
localStorage.setItem(textKey(targetConv), state.currentText)
}
}
// V11c: 种子分离窗活跃会话 KV——分离窗 AiChat 挂载同样跑 loadConversations(),
// 读按窗口域分流的 'df-ai-active-conv-detached'(独立 JS context 不与主窗共 pinia state),
// 不种子则分离窗启动回落列表首条而非主窗正看的会话。目标 conv 为空(无活跃会话)跳过。
if (targetConv) {
void useAppSettingsStore().set('df-ai-active-conv-detached', targetConv)
}
// 主窗口 state 与分离窗口 state 独立(各自 webview 独立 JS context,
// stores/ai.ts 模块级单例仅在同 webview 内共享),清主窗口 state 不影响分离窗口生成态。
// 分离窗口接管生成后,主窗口不应再持该会话生成态残留(防重开面板时 UI 显生成中幽灵/进度条)。
@@ -133,7 +179,10 @@ async function detachPanel(convId?: string) {
minHeight: 400,
center: true,
decorations: true,
alwaysOnTop: true, // 分离窗口默认置顶(AI 助手常态:用户在主窗口工作时希望 AI 辅助窗口可见)
// V11a(2026-10-05):默认不置顶——原 true 会让分离窗盖住其他应用(VSCode/浏览器)妨碍工作;
// 用户开过置顶的偏好走 AiChat.vue 的 df-ai-always-on-top KV 恢复链(分离窗挂载时
// setAlwaysOnTop(saved)),默认改 false 不影响已开置顶用户,只改变首次/未开者的初始态。
alwaysOnTop: false,
})
win.once('tauri://created', () => {
state.panelOpen = false
@@ -183,6 +232,8 @@ async function reattachPanel() {
state.docked = false
state.panelOpen = true
persistUiState()
// V11c: 清分离窗活跃会话 KV(分离窗已全关,残留无意义;下次 detachPanel 会重新种子)
void useAppSettingsStore().remove('df-ai-active-conv-detached')
// 时序加固:reattach 关闭分离窗口后,显式停分离窗口残留 listener(分离窗口 close 异步,
// onBeforeUnmount stopListener 可能延迟),防主面板重新挂载 startListener 时与旧 listener 重叠竞态。
stopListener()
+20 -16
View File
@@ -15,7 +15,7 @@
//! 与 TaskDetail/AiChat/MessageList 同实例,渲染结果零变化。
import { ref, computed, nextTick, type Ref, type ComputedRef } from 'vue'
import { useMarkdown } from '../useMarkdown'
import { useMarkdown, wrapNakedDiff } from '../useMarkdown'
import { useAppSettingsStore } from '../../stores/appSettings'
/// 流式渲染返回的单块渲染结果(html + 模板 v-for key)
@@ -122,7 +122,9 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
const purify = getPurify()
if (!marked || !purify) return escapeFallback(block)
try {
return purify.sanitize(marked.parse(block) as string)
// R8:流式块同源裸 diff 预处理(原仅完成态 renderMd 有),流式中裸 +/- 行即红绿代码块,
// 不再等完成态突变丢阅读锚点。守卫3(围栏内不动)保证与完成态 renderMd 幂等不双重包裹。
return purify.sanitize(marked.parse(wrapNakedDiff(block)) as string)
} catch {
return escapeFallback(block)
}
@@ -150,7 +152,8 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
// AR-1 流式 MD 开关关 → 回退纯文本短路(escapeFallback 全文转义 + <br>,原行为)
// mdReady 未就绪也走纯文本兜底(marked 尚未加载完成)。
if (!streamingMdEnabled.value || !mdReady.value || !getMarked() || !getPurify()) {
_prevStreamText = '' // 纯文本降级路径不增量缓存(下帧回全量,防旧帧基线误命中)
// 纯文本降级路径不增量缓存(下帧回全量,防旧帧基线误命中)→ 三元组清零走统一入口(DRY)
resetIncrementalBaseline()
return [{ html: escapeFallback(text), key: 'fallback' }]
}
// 增量切块:追加场景复用上一帧已完成块,只对「旧末块 + 增长段」增量 lexer。
@@ -159,13 +162,10 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
let blocks: string[]
if (appendOnly) {
const growth = text.slice(_prevStreamText.length)
const combined = _prevTail + growth
const combinedBlocks = splitBlocks(combined)
blocks = combinedBlocks.length <= 1
// 仍单块增长(旧末块未跨边界)→ 已完成块不变,新末块 = 旧末块+增长
? _prevCompleted.concat(combinedBlocks)
// 旧末块跨边界拆出若干已完成块(围栏闭合/新 \n\n)+ 新末块
: _prevCompleted.concat(combinedBlocks.slice(0, -1), combinedBlocks[combinedBlocks.length - 1])
// splitBlocks 输出无论 1 块(旧末块未跨边界,新末块=旧末块+增长)还是 N 块
//(旧末块跨边界拆出若干已完成块+新末块),语义均为「追加到已完成块序列之后」,
// 原 if/else 两分支 concat 结果恒等价 → 收敛单 concat(死分支清除,DRY)。
blocks = _prevCompleted.concat(splitBlocks(_prevTail + growth))
} else {
blocks = splitBlocks(text)
}
@@ -240,6 +240,14 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
})
}
/// 三元组增量基线清零(唯一写入口):纯文本降级路径/清流式状态/清块缓存三处共用。
/// 手抄三行式已废弃,清基线必须走本函数(DRY 2026-10-05)。
function resetIncrementalBaseline(): void {
_prevStreamText = ''
_prevCompleted = []
_prevTail = ''
}
/// 清理流式渲染状态(streaming 翻 false / 会话切换 / 卸载前调)
/// 取消 pending rAF + 清 streamingBlocks + 重置 lastStreamText + 清增量基线。
function clearStreamingState(): void {
@@ -247,9 +255,7 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
streamingBlocks.value = []
lastStreamText = ''
// 清增量基线(防新轮/新会话误命中旧帧已完成块)。
_prevStreamText = ''
_prevCompleted = []
_prevTail = ''
resetIncrementalBaseline()
}
/// 仅取消 pending rAF(不清 streamingBlocks/lastStreamText)。
@@ -263,9 +269,7 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
/// 同步清增量基线(已完成块引用旧会话内容,防跨会话误复用)。
function resetStreamCache(): void {
_blockCache.clear()
_prevStreamText = ''
_prevCompleted = []
_prevTail = ''
resetIncrementalBaseline()
}
return {
+5 -1
View File
@@ -205,6 +205,10 @@ function renderMd(text: string): string {
* 本函数在 marked.parse 前扫描文本,把「连续 ≥2 行以 + 或 - 开头、且至少含 1 行 +」的
* 段落整段包成 ```diff 围栏代码块,复用②子项已就绪的 hljs diff 红绿样式,零额外 CSS。
*
* R8(2026-10-05)改 export:流式渲染链(useStreamRenderer.renderStreamingBlocks)同源复用,
* 治「流式中裸 diff 渲染为普通段落,完成后突变红绿代码块」的阅读锚点丢失。
* 幂等安全:守卫3(围栏内不动)保证流式已包的块,完成态 renderMd 再处理不双重包裹。
*
* 守卫:
* 1. **纯 - 列表不触发**: 普通 markdown ul 每行 `- ` 是常见语法,纯 - 会误伤成代码块。
* 故要求段内至少含 1 行 `+ `(+ 必须含),这是裸 diff 区别于普通列表的强特征。
@@ -221,7 +225,7 @@ const _DIFF_LINE_RE = /^[ \t]*[+-][^\n]*$/ // 行首可选空格/tab + +/- + 任
const _PLUS_LINE_RE = /^[ \t]*\+[^\n]*$/ // 含 + 的行(+ 必须含的判定)
const _FENCE_OPEN_RE = /^[ \t]{0,3}(```|~~~)/ // marked 围栏开头(行首 ≤3 空格 + 3+ 反引号/波浪)
function wrapNakedDiff(text: string): string {
export function wrapNakedDiff(text: string): string {
if (!text) return text
// 快速短路:全文无 +/- 行首特征直接返回原文(避免大文本白跑切段)
if (!/[+-]/.test(text)) return text
+5 -1
View File
@@ -27,7 +27,7 @@ export default {
aiStreamRetry: 'Connection interrupted, auto-retrying ({attempt}/{max})…',
// Fatal errors (4xx/auth) are not retryable — prompt user to check settings instead
notRetryable: 'This error cannot be resolved by retrying. Please check the configuration.',
streamInterruptedAfterTool: '⚠ The tool finished, but the following reply was interrupted (no data stream for a long time). You can click Continue to retry.',
streamInterruptedAfterTool: '⚠ The tool finished, but the following reply was interrupted (no data stream for a long time). Click Retry to continue (reruns the whole turn; completed tools will execute again).',
streamInterrupted: '⚠ The response was interrupted (no data stream for a long time). This may be due to a network disconnection or a backend error. Please try again.',
// Light-convergence notice when the watchdog probe reports the backend already finished (only the closing event was lost)
streamFinishedConverged: 'The response was interrupted during finalization and has been converged.',
@@ -45,6 +45,7 @@ export default {
queuedSendFailed: 'Failed to send queued message: {error}',
// UX-P2-A2: toast when queued messages get dropped by error/regenerate/edit (was silent)
queueCleared: 'Cleared {n} queued message(s)',
queueWillContinue: 'Stopped. {n} queued message(s) will still send automatically.',
approvalTimeout: '⚰ Approval timed out, automatically rejected: {toolName}',
// conversation load/switch failure feedback (network jitter/IPC down → empty list/blank, was silent)
loadConvFail: 'Failed to load conversations. Please check your network or backend connection.',
@@ -181,6 +182,9 @@ export default {
echoDirs: '{n} dirs',
echoBudget: 'budget {rounds} rounds / {tokens} tokens / {secs}s',
echoNoBudget: 'no budget limit',
// A12: budget validation field locator; A11: dirty-edit kept notice
budgetInvalidField: '{field} must be a positive integer',
dirtyKept: 'Unsaved edits kept (target conversation switched, verify before confirming)',
},
},
}
+22 -2
View File
@@ -49,6 +49,19 @@ export default {
pinOnTop: 'Pin on top',
unpin: 'Unpin',
closeWindow: 'Close window',
// I9(2026-10-05) text/code file attachment
pickFileTitle: 'Select text/code files',
textFileFilter: 'Text/code files',
fileTooLargeTruncated: 'File too large, truncated on insert',
fileReadFailed: 'Failed to read file, skipped',
shortcutsTitle: 'Keyboard shortcuts',
scNewConv: 'New conversation',
scFocusInput: 'Focus message input',
scRegenerate: 'Regenerate last reply',
scClearChat: 'Clear current conversation',
scSearch: 'Focus conversation search',
scToggleSidebar: 'Toggle sidebar',
scCloseOverlay: 'Close dialog / panel',
// ── Approval overlay (global top-right) ──
approvalWaitingTitle: 'Approval required',
@@ -163,9 +176,10 @@ export default {
copyAsImageFailed: 'Failed to copy as image',
// ── Message actions bar ──
regenerate: 'Regenerate',
regenerateFailed: 'Regenerate failed: {msg}',
// regenerateUnavailable: Ctrl+R / retry paths still call store.regenerate(); precheck toast key
// (I6 revert removed the UI button only — key still referenced by useAiSend.ts, review 2026-10-05 ②)
regenerateUnavailable: 'No reply to regenerate',
regenerateFailed: 'Regenerate failed: {msg}',
// fetch_url "Install obscura via AI" button triggers sendMessage; failure toast (mirrors editMessageFailed)
sendFailed: 'Send failed: {msg}',
@@ -237,6 +251,8 @@ export default {
compressing: 'Compressing…',
clearSuccess: 'Context cleared',
compressSuccess: 'Context compressed',
// B4: one-shot auto-compress notice (loop auto-compress no longer fully silent)
autoCompressed: 'Context auto-compressed (earlier messages folded into summary)',
compressError: 'Compression failed: {msg}',
clearError: 'Clear failed: {msg}',
// Separator expand / collapse
@@ -279,6 +295,10 @@ export default {
tokenInLabel: 'Input (cache miss, full price)',
tokenCacheLabel: 'Cache hit (low price)',
tokenOutLabel: 'Output',
// #12 conversation-cumulative tokens (token detail popover footer)
tokenConvTotal: 'Conversation total',
tokenConvTotalIn: 'Total in',
tokenConvTotalOut: 'Total out',
tokenReasonLabel: 'Reasoning',
tokenCacheRateLabel: 'Cache hit rate',
tokenModelLabel: 'Model',
+6
View File
@@ -14,6 +14,10 @@ export default {
argsCollapse: 'Collapse args',
// batch approve high-risk interception (count badge + confirm text)
batchHighRiskHint: '{n} high-risk',
// R7 group-header failure badge: folded group contains failed tool cards
groupFailCount: '{n} failed',
// #14 batch approve partial failure toast
batchPartialFail: 'Batch done: {ok} approved, {fail} failed.',
batchHighRiskConfirm: 'Batch approval includes {n} high-risk operations (e.g. delete/command execution/network requests). Approve all?',
rejectedHint: 'User rejected this action',
// tool execution slow hint (frontend fallback, no backend cancel IPC)
@@ -30,6 +34,8 @@ export default {
// Singular expand/collapse (ToolCard single-card content, command output toggle)
expand: 'Expand',
collapse: 'Collapse',
diffExpand: 'Expand all {n} lines',
diffCollapse: 'Collapse diff',
written: 'written',
items: 'items',
lines: 'lines',
+5 -1
View File
@@ -27,7 +27,7 @@ export default {
aiStreamRetry: '与上游连接中断,自动重试中({attempt}/{max})…',
// Fatal 类错误(4xx/鉴权)不可重试,提示用户去设置而非重试
notRetryable: '此错误无法通过重试解决,请检查配置',
streamInterruptedAfterTool: '⚠ 工具已执行完成,后续回复中断(长时间无数据流)。可点继续重试。',
streamInterruptedAfterTool: '⚠ 工具已执行完成,后续回复中断(长时间无数据流)。可点「重试」续跑(整轮重跑,已完成工具会重新执行)。',
streamInterrupted: '⚠ 响应中断(长时间无数据流)。可能是网络断连或后端异常,请重试。',
// 看门狗探测到后端已正常收敛(仅收尾事件丢失)时补的轻收敛提示,非错误语气
streamFinishedConverged: '响应在收尾阶段中断,已自动完成收敛。',
@@ -45,6 +45,7 @@ export default {
queuedSendFailed: '队列消息发送失败:{error}',
// UX-P2-A2:出错/重生成/编辑清排队消息的 toast 提示(原静默丢弃无任何反馈)
queueCleared: '已清 {n} 条排队消息',
queueWillContinue: '已停止,{n} 条排队消息仍将自动发送',
approvalTimeout: '⚰ 审批超时,已自动拒绝:{toolName}',
// 对话加载/切换失败反馈(网络抖动/IPC 断开 → 列表空/切换空白,原静默无反馈)
loadConvFail: '对话列表加载失败,请检查网络或后端连接',
@@ -176,6 +177,9 @@ export default {
echoDirs: '{n} 个目录',
echoBudget: '预算 {rounds} 轮 / {tokens} tokens / {secs}s',
echoNoBudget: '预算不限',
// A12: 预算校验错误定位字段;A11: 脏编辑保留提示
budgetInvalidField: '{field} 需为正整数',
dirtyKept: '有未提交的编辑已保留(目标会话已切换,确认前请核对)',
},
},
}
+22 -2
View File
@@ -49,6 +49,19 @@ export default {
pinOnTop: '置顶',
unpin: '取消置顶',
closeWindow: '关闭窗口',
// I9(2026-10-05)文本/代码文件附件
pickFileTitle: '选择文本/代码文件',
textFileFilter: '文本/代码文件',
fileTooLargeTruncated: '文件过大,已截断插入',
fileReadFailed: '文件读取失败,已跳过',
shortcutsTitle: '键盘快捷键',
scNewConv: '新建对话',
scFocusInput: '聚焦输入框',
scRegenerate: '重新生成回复',
scClearChat: '清空当前对话',
scSearch: '聚焦会话搜索',
scToggleSidebar: '展开/收起侧栏',
scCloseOverlay: '关闭弹窗/面板',
// ── 审批浮层(全局右上角)──
approvalWaitingTitle: '有待审批请求',
@@ -164,9 +177,10 @@ export default {
copyAsImageFailed: '复制为图片失败',
// ── 消息操作栏──
regenerate: '重新生成',
regenerateFailed: '重新生成失败:{msg}',
// regenerateUnavailable:Ctrl+R / 重试链仍走 store.regenerate(),前置校验失败 toast 用
// (I6 回滚只撤了 UI 按钮,此键仍被 useAiSend.ts 引用——review 2026-10-05 ②)
regenerateUnavailable: '没有可重新生成的回复',
regenerateFailed: '重新生成失败:{msg}',
// fetch_url "AI 帮装 obscura" 按钮触发 sendMessage 失败的 toast(对齐 editMessageFailed 模式)
sendFailed: '发送失败:{msg}',
@@ -238,6 +252,8 @@ export default {
compressing: '压缩中…',
clearSuccess: '上下文已清空',
compressSuccess: '上下文已压缩',
// B4: 自动压缩完成一次性提示(loop 自动压缩不再全程静默)
autoCompressed: '上下文已自动压缩(较早消息已折叠为摘要)',
compressError: '压缩失败:{msg}',
clearError: '清空失败:{msg}',
// 折叠条展开 / 收起
@@ -280,6 +296,10 @@ export default {
tokenInLabel: '输入(未命中,全价)',
tokenCacheLabel: '缓存命中(低价)',
tokenOutLabel: '输出',
// #12 会话累计 token(token 详情面板尾部段)
tokenConvTotal: '会话累计',
tokenConvTotalIn: '会话累计·输入',
tokenConvTotalOut: '会话累计·输出',
tokenReasonLabel: '思考(reasoning)',
tokenCacheRateLabel: '缓存命中率',
tokenModelLabel: '模型',
+6
View File
@@ -14,6 +14,10 @@ export default {
argsCollapse: '收起参数',
// 批量批准 High 风险拦截(计数徽标 + 二次确认文案)
batchHighRiskHint: '含 {n} 项高风险',
// R7 组头失败徽标:折叠组内含失败工具卡
groupFailCount: '{n} 失败',
// #14 批量审批部分失败提示
batchPartialFail: '批量完成:{ok} 项成功,{fail} 项失败',
batchHighRiskConfirm: '批量批准包含 {n} 项高风险操作(如删除/命令执行/网络请求),确认全部放行?',
rejectedHint: '用户拒绝了此操作',
// 工具执行超时提示(前端降级,无后端取消 IPC)
@@ -29,6 +33,8 @@ export default {
clickToCopy: '点击复制完整路径',
collapse: '收起',
expand: '展开',
diffExpand: '展开全部 {n} 行',
diffCollapse: '收起 diff',
written: '已写入',
items: '项',
lines: '行',
+9 -9
View File
@@ -12,7 +12,7 @@
<!-- 顶部拖动条(双击不最大化,因 decorations:false) -->
<div class="popup-header" data-tauri-drag-region @mousedown="onDragStart" @mouseup="onDragEnd">
<div class="popup-header-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 11l3 3L22 4" />
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
</svg>
@@ -20,7 +20,7 @@
</div>
<div class="popup-header-title">{{ $t('aiChat.popupTitle') }}</div>
<button class="popup-header-close" :title="$t('common.close')" @click.stop="onClose">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
@@ -71,7 +71,7 @@
@click.stop="onApproveOnce(tc)"
>
<span v-if="processingId === tc.id" class="popup-btn-spinner"></span>
<svg v-else width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
<svg aria-hidden="true" v-else width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{{ $t('aiChat.dirAuthOnce') }}<span class="popup-btn-key">1</span>
</button>
<button
@@ -79,7 +79,7 @@
:disabled="processingId === tc.id"
@click.stop="onApproveSession(tc)"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
<svg aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{{ $t('aiChat.dirAuthSession') }}
</button>
<button
@@ -87,7 +87,7 @@
:disabled="processingId === tc.id"
@click.stop="onApproveAlways(tc)"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
<svg aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{{ $t('aiChat.dirAuthAlways') }}<span class="popup-btn-key">2</span>
</button>
<button
@@ -95,7 +95,7 @@
:disabled="processingId === tc.id"
@click.stop="onReject(tc)"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
<svg aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
{{ $t('aiChat.dirAuthDeny') }}<span class="popup-btn-key">3</span>
</button>
</div>
@@ -106,7 +106,7 @@
@click.stop="onApprove(tc)"
>
<span v-if="processingId === tc.id" class="popup-btn-spinner"></span>
<svg v-else width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
<svg aria-hidden="true" v-else width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{{ $t('aiTool.approve') }}<span class="popup-btn-key">1</span>
</button>
<button
@@ -114,7 +114,7 @@
:disabled="processingId === tc.id"
@click.stop="onReject(tc)"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
<svg aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
{{ $t('aiTool.reject') }}<span class="popup-btn-key">3</span>
</button>
</div>
@@ -123,7 +123,7 @@
<!-- 底部:打开审批面板(聚焦主窗口) -->
<button class="popup-footer" @click="onActivate">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 3h6v6" /><path d="M9 21H3v-6" /><path d="M21 3l-7 7" /><path d="M3 21l7-7" />
</svg>
{{ $t('aiChat.popupOpenPanel') }}
+4 -4
View File
@@ -4,7 +4,7 @@
<h1>{{ t('auditLog.title') }}</h1>
<div class="header-actions">
<button class="btn btn-ghost" @click="reloadAudit" :disabled="loading" v-if="activeTab === 'audit'">
<svg v-if="loading" class="audit-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg>
<svg aria-hidden="true" v-if="loading" class="audit-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg>
{{ t('auditLog.refresh') }}
</button>
</div>
@@ -13,11 +13,11 @@
<!-- Tab 导航 -->
<nav class="detail-tabs audit-tabs">
<button class="tab-btn" :class="{ 'tab-active': activeTab === 'audit' }" type="button" @click="switchTab('audit')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
{{ t('auditLog.tabAudit') }}
</button>
<button class="tab-btn" :class="{ 'tab-active': activeTab === 'conversations' }" type="button" @click="switchTab('conversations')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
{{ t('auditLog.tabConversations') }}
</button>
</nav>
@@ -43,7 +43,7 @@
<!-- 状态条 -->
<div v-if="loading" class="empty-state audit-loading">
<svg class="audit-spinner audit-spinner--lg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg>
<svg aria-hidden="true" class="audit-spinner audit-spinner--lg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg>
</div>
<div v-else-if="errorMsg" class="empty-state audit-error">⚠ {{ errorMsg }}</div>
<div v-else-if="filteredRecords.length === 0" class="empty-state">{{ t('auditLog.empty') }}</div>
+2 -2
View File
@@ -8,8 +8,8 @@
</div>
<div class="dash-header-right">
<button class="btn btn-ghost btn-sm dash-refresh-btn" @click="refresh" :disabled="loading" :title="$t('dashboard.refresh')">
<svg v-if="loading" class="dash-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
<svg aria-hidden="true" v-if="loading" class="dash-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg>
<svg aria-hidden="true" v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
</button>
</div>
</header>
+3 -3
View File
@@ -130,7 +130,7 @@
<!-- 标题行操作入口 -->
<div class="module-manager-header">
<button class="btn btn-primary btn-sm" type="button" @click="openAddModule">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
{{ $t('fileExplorer.addModule') }}
</button>
<button
@@ -171,10 +171,10 @@
<span class="module-path-overview" :title="m.path">{{ m.path }}</span>
<span class="module-row-actions">
<button class="ai-btn-icon" :title="$t('fileExplorer.editModule')" @click="openEditModule(m)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
<svg aria-hidden="true" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<button class="ai-btn-icon module-row-action-danger" :title="$t('fileExplorer.deleteCurrentModule')" @click="confirmRemoveModule(m)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
<svg aria-hidden="true" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
</span>
</div>