- 输入侧:编辑消息不再静默销毁草稿(备份/回填)、补全浮层滚随、贴图 loading 态+并发上限复核、mention 失焦收口、富文本图文同粘、上传失败提示 - 消息流:图片点击 lightbox 放大、复制钮 focus 可见、复制为图片降级文案据实、JSON 块恢复可选、bucketSig 改哈希防长会话卡顿、user 长消息折叠、工具卡流式滚尾 - 会话框架:后台任务面板入点外/Esc 收起编排、恢复任务行级 busy+失败 toast、菜单底部翻转、搜索态统一清、归档禁改名对齐、listbox 语义+方向键、状态点 title、窄侧栏容器查询 - 跨端:删除目标不再抹其余目标完成态(前后端 GoalEntry 全量传递+失败回滚) - 边界条:MESSAGE_CAP 滚动淘汰不再静默,头部一键补齐完整会话,历史定位文案据实区分
412 lines
16 KiB
Vue
412 lines
16 KiB
Vue
<template>
|
|
<!-- ═══ TopBar 编排层(c1c 拆分:1697 → 子件目录 top-bar/,零行为变更)═══
|
|
区块自治:HeaderLeft/ProviderCenter/MoreMenu/WindowControls(顶部四块)+
|
|
TaskProgressBadge/RunningTasks/Goals/History/Summary/Context(底部六面板)+
|
|
ProviderSwitchBar(切换临时 bar),样式与区块逻辑随子件迁移。
|
|
本层只留跨区块编排:五面板互斥(togglePanel)/点外关闭/ESC/scrollToMessage/
|
|
msgDerived 单遍派生(三路共享)/provider 切换 bar 显隐。
|
|
外部契约不变:4 props + 16 emits 原样透传 AiChat(含 new-conversation /
|
|
clear-chat 两个历史遗留未触发 emit,D-A 波已报备,原样保留)。 ═══ -->
|
|
<div class="ai-header">
|
|
<HeaderLeft
|
|
:pending-approval-count="pendingApprovalCount"
|
|
@toggle-sidebar="emit('toggle-sidebar')"
|
|
@scroll-to-pending="emit('scroll-to-pending')"
|
|
/>
|
|
<ProviderCenter
|
|
:active-provider-name="activeProviderName"
|
|
@provider-switched="showProviderSwitched"
|
|
/>
|
|
<div class="ai-header-actions">
|
|
<MoreMenu
|
|
:has-active-messages="hasActiveMessages"
|
|
@clear-context="emit('clear-context')"
|
|
@compress-context="emit('compress-context')"
|
|
@show-shortcuts="emit('show-shortcuts')"
|
|
/>
|
|
<WindowControls
|
|
:detached="detached"
|
|
:always-on-top="alwaysOnTop"
|
|
@toggle-maximize="emit('toggle-maximize')"
|
|
@detach="emit('detach')"
|
|
@close-panel="emit('close-panel')"
|
|
@reattach="emit('reattach')"
|
|
@dock="emit('dock')"
|
|
@toggle-always-on-top="emit('toggle-always-on-top')"
|
|
@close-detached="emit('close-detached')"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 底部工具区:标题 + 目标 + 历史消息 + 上下文摘要 -->
|
|
<div class="ai-bottom-tools" ref="goalsRef">
|
|
<span v-if="currentConvTitle" class="ai-bottom-title">{{ currentConvTitle }}</span>
|
|
<div class="ai-bottom-tools-right">
|
|
<TaskProgressBadge />
|
|
<RunningTasksPanel
|
|
:expanded="runningTasksExpanded"
|
|
@toggle="togglePanel('running')"
|
|
@close="runningTasksExpanded = false"
|
|
/>
|
|
<GoalsPanel
|
|
:expanded="goalsExpanded"
|
|
@toggle="togglePanel('goals')"
|
|
@close="goalsExpanded = false"
|
|
/>
|
|
<HistoryPanel
|
|
:items="historyMsgs"
|
|
:expanded="historyExpanded"
|
|
:locate-miss="locateMissHint"
|
|
@toggle="togglePanel('history')"
|
|
@navigate="scrollToMessage"
|
|
/>
|
|
<SummaryPanel
|
|
:items="summaryMsgs"
|
|
:expanded="summaryExpanded"
|
|
:locate-miss="locateMissHint"
|
|
@toggle="togglePanel('summary')"
|
|
@navigate="scrollToMessage"
|
|
/>
|
|
<ContextPanel
|
|
:info="contextInfo"
|
|
:expanded="contextExpanded"
|
|
@toggle="togglePanel('context')"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<ProviderSwitchBar
|
|
:visible="providerBarVisible"
|
|
:provider-name="activeProviderName"
|
|
/>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
// TopBar 编排层(第三批抽离自 AiChat.vue,c1c 二次拆分至 top-bar/ 子件目录)
|
|
// 零行为变更。provider/model 等区块逻辑在各子件内直读 store(单例共享),动作 emit 回本层,
|
|
// 本层透传 AiChat(父持有 clear/compress/confirm 逻辑 + 单一 toast 源)。
|
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { useAiStore } from '../../stores/ai'
|
|
import HeaderLeft from './top-bar/HeaderLeft.vue'
|
|
import ProviderCenter from './top-bar/ProviderCenter.vue'
|
|
import MoreMenu from './top-bar/MoreMenu.vue'
|
|
import WindowControls from './top-bar/WindowControls.vue'
|
|
import TaskProgressBadge from './top-bar/TaskProgressBadge.vue'
|
|
import RunningTasksPanel from './top-bar/RunningTasksPanel.vue'
|
|
import GoalsPanel from './top-bar/GoalsPanel.vue'
|
|
import HistoryPanel from './top-bar/HistoryPanel.vue'
|
|
import SummaryPanel from './top-bar/SummaryPanel.vue'
|
|
import ContextPanel from './top-bar/ContextPanel.vue'
|
|
import ProviderSwitchBar from './top-bar/ProviderSwitchBar.vue'
|
|
import type { HistoryItem, SummaryItem, ContextInfo } from './top-bar/types'
|
|
|
|
defineProps<{
|
|
/** 分离窗口模式(嵌入 vs 分离决定顶部按钮组) */
|
|
detached?: boolean
|
|
/** 有活跃消息(清空/压缩按钮启用条件;父 computed) */
|
|
hasActiveMessages: boolean
|
|
/** 待审批数(审批角标显隐 + 计数;父 computed) */
|
|
pendingApprovalCount: number
|
|
/** 分离窗口置顶态(父持有,经 prop 透传按钮 active 态) */
|
|
alwaysOnTop: boolean
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
/** 折叠/展开会话侧栏(store.toggleSidebar,父转发保持 store 单一调用源) */
|
|
(e: 'toggle-sidebar'): void
|
|
/** 新建会话(父 confirmNewConversation,F-09 决策e 直接新建无中断确认) */
|
|
(e: 'new-conversation'): void
|
|
/** 清空上下文(归档保留;父 handleClearContext) */
|
|
(e: 'clear-context'): void
|
|
/** 压缩上下文(LLM 摘要;父 handleCompressContext) */
|
|
(e: 'compress-context'): void
|
|
/** 清空对话(真删 DB;父 confirmClearChat 带二次确认) */
|
|
(e: 'clear-chat'): void
|
|
/** 点击审批角标滚动到首个待审批(父 scrollToFirstPending) */
|
|
(e: 'scroll-to-pending'): void
|
|
/** 最大化/还原(store.toggleMaximize) */
|
|
(e: 'toggle-maximize'): void
|
|
/** 分离窗口(store.detachPanel) */
|
|
(e: 'detach'): void
|
|
/** 关闭面板(store.togglePanel) */
|
|
(e: 'close-panel'): void
|
|
/** 吸附/取消吸附(store.dockDetached) */
|
|
(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
|
|
}>()
|
|
|
|
const store = useAiStore()
|
|
const { t } = useI18n()
|
|
|
|
// ── 当前生效 provider 名(ProviderCenter header 显示 + ProviderSwitchBar 提示共用一份)──
|
|
const activeProviderName = computed(() => {
|
|
const active = store.state.providers.find(p => p.id === store.state.activeProvider)
|
|
return active?.name || store.state.providers[0]?.name || t('aiChat.notConfigured')
|
|
})
|
|
|
|
// ── 当前对话标题 ──
|
|
const currentConvTitle = computed(() => {
|
|
const id = store.state.activeConversationId
|
|
if (!id) return ''
|
|
const conv = store.state.conversations.find(c => c.id === id)
|
|
return conv?.title || ''
|
|
})
|
|
|
|
// ── 📋 历史/摘要/项目名单遍派生(历史面板 + 摘要面板 + L3 上下文三路共享) ──
|
|
/** 时间显示:今日 HH:mm / 昨天 HH:mm / MM-DD HH:mm */
|
|
function fmtTime(ts: number): string {
|
|
const d = new Date(ts)
|
|
const now = new Date()
|
|
const pad = (n: number) => String(n).padStart(2, '0')
|
|
const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
// 同一天 → 只显示时间
|
|
if (d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate()) {
|
|
return hm
|
|
}
|
|
// 昨天
|
|
const yest = new Date(now)
|
|
yest.setDate(yest.getDate() - 1)
|
|
if (d.getFullYear() === yest.getFullYear() && d.getMonth() === yest.getMonth() && d.getDate() === yest.getDate()) {
|
|
return `${t('aiChat.yesterday')} ${hm}`
|
|
}
|
|
// 更早
|
|
return `${pad(d.getMonth()+1)}-${pad(d.getDate())} ${hm}`
|
|
}
|
|
const MAX_HISTORY = 20
|
|
const MAX_CHARS = 80
|
|
// 单次遍历 messages,一次产出「历史(user 消息)/摘要(system 消息)/项目名」三路派生,
|
|
// 替代原先三处 computed 各自全量扫描(historyMsgs/summaryMsgs/contextInfo)。
|
|
// 三路的筛选条件、收集顺序、截断与反转语义与拆分前逐项一致。
|
|
const msgDerived = computed(() => {
|
|
const history: HistoryItem[] = []
|
|
const summaries: SummaryItem[] = []
|
|
const projectLabels = new Set<string>()
|
|
for (const m of store.state.messages) {
|
|
if (m.role === 'user') {
|
|
// 项目名收集只认 user 的 mentionSpans(不设 content/timestamp 门槛),
|
|
// 与原 contextInfo 的独立遍历同条件,故与下面的历史筛选互不干扰。
|
|
if (m.mentionSpans?.length) {
|
|
for (const sp of m.mentionSpans) {
|
|
if (sp.kind === 'project' && sp.label) projectLabels.add(sp.label)
|
|
}
|
|
}
|
|
if (m.content && m.timestamp) {
|
|
history.push({
|
|
id: m.id,
|
|
text: m.content.length > MAX_CHARS ? m.content.slice(0, MAX_CHARS) + '…' : m.content,
|
|
time: fmtTime(m.timestamp),
|
|
num: history.length + 1,
|
|
})
|
|
}
|
|
} else if (m.role === 'system' && m.content) {
|
|
summaries.push({
|
|
id: m.id,
|
|
text: m.content.length > 120 ? m.content.slice(0, 120) + '…' : m.content,
|
|
})
|
|
}
|
|
}
|
|
return {
|
|
history: history.slice(-MAX_HISTORY).reverse(),
|
|
summaries,
|
|
projectNames: Array.from(projectLabels).join(', '),
|
|
}
|
|
})
|
|
const historyMsgs = computed<HistoryItem[]>(() => msgDerived.value.history)
|
|
const summaryMsgs = computed<SummaryItem[]>(() => msgDerived.value.summaries)
|
|
|
|
// ── 五面板互斥编排(goals/history/summary/context/running)──
|
|
const goalsExpanded = ref(false)
|
|
const historyExpanded = ref(false)
|
|
const summaryExpanded = ref(false)
|
|
const contextExpanded = ref(false)
|
|
const runningTasksExpanded = ref(false)
|
|
|
|
// F6-1(走查-信息UX 面六):目标消息在折叠段(已归档/已压缩)内时,折叠态不渲染
|
|
// [data-msg-id] 元素,querySelector 必空。原实现先收面板再查 DOM,未命中即静默无反应;
|
|
// 现改为面板保持展开 + 行内提示引导展开分隔条。展开折叠段的状态在 MessageList 内持有,
|
|
// 本组件不可达,提示是最小闭环(不跨组件撬状态)。
|
|
const locateMissHint = ref('')
|
|
let locateMissTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
function showLocateMiss(msgId?: string): void {
|
|
// 走查批:miss 原因据实区分——消息不在当前数组(已被 MESSAGE_CAP 滚动淘汰)给淘汰文案,
|
|
// 引导点消息流头部边界条补齐;在数组但 DOM 未命中(折叠段未展开)才提示展开分隔条。
|
|
const prunedOut =
|
|
msgId != null &&
|
|
store.state.messagesPruned > 0 &&
|
|
!store.state.messages.some(m => m.id === msgId)
|
|
locateMissHint.value = prunedOut
|
|
? t('aiChat.historyLocatePruned')
|
|
: t('aiChat.historyLocateInSegment')
|
|
if (locateMissTimer) clearTimeout(locateMissTimer)
|
|
locateMissTimer = setTimeout(() => {
|
|
locateMissHint.value = ''
|
|
locateMissTimer = null
|
|
}, 5000)
|
|
}
|
|
|
|
/** 点击历史/摘要消息项 → 滚动定位到对应的消息气泡 */
|
|
function scrollToMessage(msgId: string) {
|
|
// 消息列表已有 data-msg-id 属性,直接 DOM 查询定位。
|
|
// msgId 由后端生成,可能含 CSS 选择器元字符(: . [ ] / 空格 等),
|
|
// 模板拼 selector 会被选择器解析器当伪类/类名异常 → querySelector 抛错或返回 null。
|
|
// 用 CSS.escape 转义 msgId,确保任意字符串安全嵌入属性选择器。
|
|
// 旧浏览器无 CSS.escape 的兜底:encodeURIComponent + attr*= 模糊匹配(非精确,降级路径)。
|
|
let el: Element | null = null
|
|
const escaped = typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
|
|
? CSS.escape(msgId)
|
|
: encodeURIComponent(msgId)
|
|
try {
|
|
el = document.querySelector(`[data-msg-id="${escaped}"]`)
|
|
} catch {
|
|
// 转义后仍异常(理论不会)→ 退化为遍历查找
|
|
const all = document.querySelectorAll('[data-msg-id]')
|
|
el = Array.from(all).find(n => n.getAttribute('data-msg-id') === msgId) ?? null
|
|
}
|
|
if (el) {
|
|
locateMissHint.value = ''
|
|
historyExpanded.value = false
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
return
|
|
}
|
|
// 未命中:不收面板,提示替代静默(带 msgId 供淘汰/折叠段原因区分)
|
|
showLocateMiss(msgId)
|
|
}
|
|
|
|
/** 目标/历史下拉容器 DOM 引用(用于点击外部检测) */
|
|
const goalsRef = ref<HTMLElement | null>(null)
|
|
|
|
function onBottomToolsClickOutside(e: MouseEvent) {
|
|
if (!goalsRef.value) return
|
|
if (goalsExpanded.value && !goalsRef.value.contains(e.target as Node)) {
|
|
goalsExpanded.value = false
|
|
}
|
|
if (historyExpanded.value && !goalsRef.value.contains(e.target as Node)) {
|
|
historyExpanded.value = false
|
|
}
|
|
if (summaryExpanded.value && !goalsRef.value.contains(e.target as Node)) {
|
|
summaryExpanded.value = false
|
|
}
|
|
if (contextExpanded.value && !goalsRef.value.contains(e.target as Node)) {
|
|
contextExpanded.value = false
|
|
}
|
|
if (runningTasksExpanded.value && !goalsRef.value.contains(e.target as Node)) {
|
|
runningTasksExpanded.value = false
|
|
}
|
|
}
|
|
|
|
/** 弹出面板互斥:goals/history/summary/context/running 五个 badge 面板,点一个收起其他,再点当前切换。 */
|
|
function togglePanel(panel: 'goals' | 'history' | 'summary' | 'context' | 'running') {
|
|
locateMissHint.value = ''
|
|
const refs = { goals: goalsExpanded, history: historyExpanded, summary: summaryExpanded, context: contextExpanded, running: runningTasksExpanded }
|
|
const willOpen = !refs[panel].value
|
|
goalsExpanded.value = historyExpanded.value = summaryExpanded.value = contextExpanded.value = runningTasksExpanded.value = false
|
|
refs[panel].value = willOpen
|
|
// B3(running 面板展开拉可恢复清单)随 RunningTasksPanel 迁移,由其 expanded watch 承接
|
|
}
|
|
|
|
/** 按 Escape 关闭所有下拉 */
|
|
function onBottomToolsKeydown(e: KeyboardEvent) {
|
|
if (e.key === 'Escape') {
|
|
goalsExpanded.value = false
|
|
historyExpanded.value = false
|
|
summaryExpanded.value = false
|
|
contextExpanded.value = false
|
|
runningTasksExpanded.value = false
|
|
}
|
|
}
|
|
onMounted(() => {
|
|
document.addEventListener('click', onBottomToolsClickOutside)
|
|
document.addEventListener('keydown', onBottomToolsKeydown)
|
|
})
|
|
onBeforeUnmount(() => {
|
|
document.removeEventListener('click', onBottomToolsClickOutside)
|
|
document.removeEventListener('keydown', onBottomToolsKeydown)
|
|
if (locateMissTimer) { clearTimeout(locateMissTimer); locateMissTimer = null }
|
|
})
|
|
|
|
// ── L3 完整上下文(ContextPanel 数据派生)──
|
|
const contextInfo = computed<ContextInfo>(() => {
|
|
const convId = store.state.activeConversationId
|
|
const conv = convId ? store.state.conversations.find(c => c.id === convId) : undefined
|
|
return {
|
|
goals: conv?.pinned_goals ?? [],
|
|
summaryCount: msgDerived.value.summaries.length,
|
|
osInfo: 'Windows 11 / PowerShell',
|
|
projectNames: msgDerived.value.projectNames || t('aiChat.none'),
|
|
}
|
|
})
|
|
|
|
// ── Provider 切换临时 bar ──
|
|
// 切换后展示当前 model 名小字,0.15s 淡入,2s 后淡出。bar 显隐在本层持有(ref + Transition 在子件),
|
|
// toast 经 emit 回父(父持有 toast reactive 容器)。
|
|
const providerBarVisible = ref(false)
|
|
let _providerBarTimer: ReturnType<typeof setTimeout> | null = null
|
|
function showProviderSwitched(modelName: string) {
|
|
// toast:经 emit 回父(单一 toast 源)
|
|
emit('provider-switched', modelName)
|
|
// bar:ref 控制显隐,Transition 驱动 0.15s 淡入/淡出
|
|
providerBarVisible.value = true
|
|
if (_providerBarTimer) clearTimeout(_providerBarTimer)
|
|
_providerBarTimer = setTimeout(() => { providerBarVisible.value = false }, 2000)
|
|
}
|
|
|
|
// 释放 provider 切换 bar 计时器
|
|
onBeforeUnmount(() => {
|
|
if (_providerBarTimer) { clearTimeout(_providerBarTimer); _providerBarTimer = null }
|
|
})
|
|
</script>
|
|
|
|
<!-- ═══ TopBar 编排层 scoped 样式(c1c 拆分:仅留本层模板直含的容器规则,
|
|
区块样式随各子件迁移(共享工具类 .ai-tool-badge/.ai-btn-icon/.ai-pop 过渡
|
|
由消费子件各带 scoped 副本,全局 .ai-btn-icon 为抽取时旧样式不可依赖)。
|
|
CSS 变量(--df-*)由根级提供,共享同一份。 ═══ -->
|
|
<style scoped>
|
|
/* ═══ Header ═══ */
|
|
.ai-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 10px 14px;
|
|
border-bottom: 0.5px solid var(--df-border);
|
|
}
|
|
.ai-header-actions {
|
|
display: flex;
|
|
gap: 4px;
|
|
}
|
|
/* 底部工具区(标题左 + 工具右) */
|
|
.ai-bottom-tools {
|
|
display: flex;
|
|
align-items: center;
|
|
|
|
padding: 0 14px 4px;
|
|
}
|
|
.ai-bottom-title {
|
|
font-size: 11px;
|
|
color: var(--df-text-secondary);
|
|
min-width: 0;
|
|
margin-right: auto;
|
|
/* 长标题单行截断(防换行撑高工具行;右侧徽章族 flex-shrink:0,分离窄窗口更挤) */
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.ai-bottom-tools-right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
flex-shrink: 0;
|
|
margin-left: auto;
|
|
}
|
|
</style>
|