优化: 聊天体验批与Web规范合规
This commit is contained in:
+154
-2
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- 添加依赖弹窗 -->
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user