修复: ai-chat 交互体验批(草稿备份/图片放大/目标完成态/淘汰边界条)

- 输入侧:编辑消息不再静默销毁草稿(备份/回填)、补全浮层滚随、贴图 loading 态+并发上限复核、mention 失焦收口、富文本图文同粘、上传失败提示
- 消息流:图片点击 lightbox 放大、复制钮 focus 可见、复制为图片降级文案据实、JSON 块恢复可选、bucketSig 改哈希防长会话卡顿、user 长消息折叠、工具卡流式滚尾
- 会话框架:后台任务面板入点外/Esc 收起编排、恢复任务行级 busy+失败 toast、菜单底部翻转、搜索态统一清、归档禁改名对齐、listbox 语义+方向键、状态点 title、窄侧栏容器查询
- 跨端:删除目标不再抹其余目标完成态(前后端 GoalEntry 全量传递+失败回滚)
- 边界条:MESSAGE_CAP 滚动淘汰不再静默,头部一键补齐完整会话,历史定位文案据实区分
This commit is contained in:
lxy
2026-10-25 22:32:49 +08:00
parent 3caf2d047f
commit 62776a30f1
38 changed files with 3848 additions and 2778 deletions
+154
View File
@@ -0,0 +1,154 @@
<template>
<!-- ── L3 完整上下文面板 ──
c1c 拆分自 TopBar.vue,零行为变更。纯展示:数据(info=ContextInfo)由父派生传入,
展开态由父持有(五面板互斥)。 -->
<div v-if="info.goals.length || info.summaryCount > 0" class="ai-context-inline">
<button type="button" class="ai-context-inline-badge ai-tool-badge" :class="{ 'is-active': expanded }" @click="emit('toggle')" :title="$t('aiChat.viewContext')" :aria-label="$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="expanded" class="ai-context-inline-list">
<!-- 系统提示 -->
<div class="ai-context-section">
<div class="ai-context-section-title">{{ $t('aiChat.contextSystemPrompt') }}</div>
<div class="ai-context-section-body">
<div class="ai-context-row">
<span class="ai-context-label">{{ $t('aiChat.goalsCount', { n: info.goals.length }) }}</span>
<span class="ai-context-value">{{ info.goals.join('; ') || $t('aiChat.none') }}</span>
</div>
<div class="ai-context-row">
<span class="ai-context-label">{{ $t('aiChat.contextEnvironment') }}</span>
<span class="ai-context-value">{{ info.osInfo }}</span>
</div>
<div class="ai-context-row">
<span class="ai-context-label">{{ $t('aiChat.contextCompressedSummary') }}</span>
<span class="ai-context-value">{{ info.summaryCount }} {{ $t('aiChat.contextCountUnit') }}</span>
</div>
</div>
</div>
<!-- 增强上下文 -->
<div class="ai-context-section">
<div class="ai-context-section-title">{{ $t('aiChat.contextEnrichment') }}</div>
<div class="ai-context-section-body">
<div class="ai-context-row">
<span class="ai-context-label">{{ $t('aiChat.projectLabel') }}</span>
<span class="ai-context-value">{{ info.projectNames || $t('aiChat.none') }}</span>
</div>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import type { ContextInfo } from './types'
defineProps<{
/** 完整上下文数据(父 computed:conv.pinned_goals + 摘要计数 + 项目名) */
info: ContextInfo
/** 面板展开态(父持有,五面板互斥) */
expanded: boolean
}>()
const emit = defineEmits<{
/** 徽章点击(父 togglePanel('context') 做互斥编排) */
(e: 'toggle'): void
}>()
</script>
<style scoped>
/* 底部工具通用徽标(共享工具类,五面板各自带副本) */
.ai-tool-badge {
cursor: pointer;
font-size: 11px;
line-height: 16px;
padding: 2px 8px;
border-radius: 4px;
display: inline-block;
vertical-align: middle;
gap: 4px;
color: var(--df-text);
background: var(--df-bg-card);
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);
outline-offset: 1px;
}
.ai-tool-badge svg { vertical-align: middle; } /* 图标在行高内垂直居中(与数字同基线)*/
.ai-tool-badge:hover {
background: var(--df-bg-card-hover);
}
/* 面板激活态徽章(accent 描边,当前展开的面板入口可见) */
.ai-tool-badge.is-active {
color: var(--df-accent);
border-color: color-mix(in srgb, var(--df-accent) 45%, transparent);
background: var(--df-accent-bg);
}
/* ═══ L3 完整上下文(底部) ═══ */
.ai-context-inline {
position: relative;
display: inline-flex;
align-items: center;
}
.ai-context-inline-list {
position: absolute;
left: auto; right: 0;
top: 100%;
z-index: 100;
min-width: 280px;
max-width: 420px;
background: var(--df-bg-raised);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
margin-top: 2px;
}
.ai-context-section {
padding: 6px 10px;
border-bottom: 0.5px solid var(--df-border);
}
.ai-context-section:last-child { border-bottom: none; }
.ai-context-section-title {
font-size: 10px;
font-weight: 600;
color: var(--df-accent);
margin-bottom: 4px;
}
.ai-context-section-body {
display: flex;
flex-direction: column;
gap: 3px;
}
.ai-context-row {
display: flex;
gap: 6px;
font-size: 11px;
}
.ai-context-label {
flex-shrink: 0;
color: var(--df-text-dim);
min-width: 56px;
}
.ai-context-value {
color: var(--df-text-secondary);
word-break: break-all;
}
/* context 面板补 max-height 滚动(原无上限,增强上下文多时同理) */
.ai-context-inline-list {
max-height: 360px;
overflow-y: auto;
}
/* ═══ 底部面板 popover 过渡(ux-9d:平滑出入动画,对齐 more-menu popout) ═══ */
.ai-pop-enter-active { transition: opacity 0.16s var(--df-ease), transform 0.16s var(--df-ease); }
.ai-pop-leave-active { transition: opacity 0.12s var(--df-ease), transform 0.12s var(--df-ease); }
.ai-pop-enter-from,
.ai-pop-leave-to { opacity: 0; transform: translateY(-4px) scale(0.98); }
</style>
+182
View File
@@ -0,0 +1,182 @@
<template>
<!-- 🎯 对话目标面板(对话透明化 L1:Goal visibility)。
c1c 拆分自 TopBar.vue,零行为变更。目标加载/删除/回写 store 自含;
展开态由父持有(五面板互斥),清空目标时经 close 事件请求收起(原 :918 语义)。 -->
<div v-if="goals.length" class="ai-goals-inline">
<button type="button" class="ai-goals-inline-badge ai-tool-badge" :class="{ 'is-active': expanded }" @click="emit('toggle')" :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 }}
</button>
<Transition name="ai-pop">
<div v-if="expanded" 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' }">
<span v-if="g.status === 'active'" class="ai-goal-dot"></span>
<span v-else class="ai-goal-check">✓</span>
<span class="ai-goal-text">{{ g.text }}</span>
<button class="ai-goal-remove" @click.stop="removeGoal(i)" :aria-label="$t('common.delete')">×</button>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { aiApi } from '../../../api/ai'
import { useAiStore } from '../../../stores/ai'
import type { GoalEntry } from '../../../api/types'
defineProps<{
/** 面板展开态(父持有,五面板互斥) */
expanded: boolean
}>()
const emit = defineEmits<{
/** 徽章点击(父 togglePanel('goals') 做互斥编排) */
(e: 'toggle'): void
/** 请求收起面板(目标清空时;父置 expanded=false) */
(e: 'close'): void
}>()
const store = useAiStore()
const goals = ref<GoalEntry[]>([])
/** 监听 activeConversationId 变化,加载目标 */
watch(() => store.state.activeConversationId, (convId) => {
loadGoals(convId)
}, { immediate: true })
/** 消息变化后也刷新目标 */
watch(() => store.state.messages.length, () => {
setTimeout(() => loadGoals(store.state.activeConversationId), 100)
})
function loadGoals(convId: string | null) {
if (!convId) { goals.value = []; return }
const conv = store.state.conversations.find(c => c.id === convId)
// 兼容旧格式(string[]):后端迁移过渡期可能返回纯文本数组
const raw = conv?.pinned_goals ?? []
goals.value = raw.map((g: any) =>
typeof g === 'string' ? { text: g, status: 'active' as const } : g
)
}
/** 移除第 i 个目标 */
async function removeGoal(i: number) {
const convId = store.state.activeConversationId
if (!convId) return
const prev = goals.value
const next = prev.filter((_, idx) => idx !== i)
goals.value = next
try {
// 跨端修复(走查批E):原 next.map(g => g.text) 把 GoalEntry 降级 string[],后端逐条
// GoalEntry::new 重置 active,已完成目标(status='completed')的完成态被抹。改传完整
// GoalEntry[](text/status 原样透传,snake_case),与后端 ai_update_conversation_goals
// 的 Vec<GoalEntry> 反序列化字段一一对应。
await aiApi.updateConversationGoals(convId, next)
} catch (e) {
// 失败回滚乐观删除(对齐 useAiConversations.withConvOp 回写范式),面板不收
goals.value = prev
console.error('[goals] 更新目标列表失败,已回滚:', e)
return
}
if (next.length === 0) emit('close')
// UX 走查-对话域 B4:删除后同步回写 store 的 conv.pinned_goals(对齐 AiCompleted 回写
// useAiLifecycleEvents:126-129 的结构)。不回写则 loadGoals 从 store 重读旧列表,
// 已删目标在下次 messages.length watch 触发刷新时"复活"。
const conv = store.state.conversations.find(c => c.id === convId)
if (conv) conv.pinned_goals = next
}
</script>
<style scoped>
/* 底部工具通用徽标(共享工具类,五面板各自带副本) */
.ai-tool-badge {
cursor: pointer;
font-size: 11px;
line-height: 16px;
padding: 2px 8px;
border-radius: 4px;
display: inline-block;
vertical-align: middle;
gap: 4px;
color: var(--df-text);
background: var(--df-bg-card);
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);
outline-offset: 1px;
}
.ai-tool-badge svg { vertical-align: middle; } /* 图标在行高内垂直居中(与数字同基线)*/
.ai-tool-badge:hover {
background: var(--df-bg-card-hover);
}
/* 面板激活态徽章(accent 描边,当前展开的面板入口可见) */
.ai-tool-badge.is-active {
color: var(--df-accent);
border-color: color-mix(in srgb, var(--df-accent) 45%, transparent);
background: var(--df-accent-bg);
}
/* 目标计数(底部) */
.ai-goals-inline {
position: relative;
display: inline-flex;
align-items: center;
}
.ai-goals-inline-list {
position: absolute;
top: 100%;
left: auto; right: 0;
z-index: 100;
min-width: 200px;
max-width: 360px;
background: var(--df-bg-raised);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 4px 0;
margin-top: 2px;
}
.ai-goal-inline-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
font-size: 11px;
cursor: pointer;
border-radius: 2px;
}
.ai-goal-inline-item:hover {
background: var(--df-bg-card);
}
.ai-goal-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-goal-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--df-success); flex-shrink: 0; }
.ai-goal-check { color: var(--df-success); font-weight: 500; flex-shrink: 0; }
.ai-goal-remove { border: none; background: transparent; color: var(--df-text-dim); cursor: pointer; padding: 0 2px; font-size: 13px; line-height: 1; flex-shrink: 0; }
.ai-goal-remove:hover {
color: var(--df-danger);
}
/* goals 面板补 max-height 滚动(原无上限,目标多时无限拉高遮对话) */
.ai-goals-inline-list {
max-height: 300px;
overflow-y: auto;
}
/* ═══ 底部面板 popover 过渡(ux-9d:平滑出入动画,对齐 more-menu popout) ═══ */
.ai-pop-enter-active { transition: opacity 0.16s var(--df-ease), transform 0.16s var(--df-ease); }
.ai-pop-leave-active { transition: opacity 0.12s var(--df-ease), transform 0.12s var(--df-ease); }
.ai-pop-enter-from,
.ai-pop-leave-to { opacity: 0; transform: translateY(-4px) scale(0.98); }
</style>
+195
View File
@@ -0,0 +1,195 @@
<template>
<!-- TopBar 头部左侧区(侧栏开关 + 助手标题 + 审批角标 + 挂机引擎态徽章)。
自 TopBar.vue c1c 拆分迁入,零行为变更;挂机轮询/熔断复位/设置跳转随区块整体迁移。 -->
<div class="ai-header-left">
<button class="ai-btn-icon" @click="emit('toggle-sidebar')" :title="$t('aiChat.conversationList')" :aria-label="$t('aiChat.conversationList')">
<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 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>
<button
v-if="pendingApprovalCount > 0"
type="button"
class="ai-approval-badge df-badge df-badge--danger"
:title="$t('aiChat.pendingApprovalHint')"
@click="emit('scroll-to-pending')"
>
⏳ {{ $t('aiChat.pendingApprovalCount', { n: pendingApprovalCount }) }}
</button>
<!-- 挂机引擎态徽章:开启=「挂机中」(在途附「执行中」),熔断=红徽点击复位;关闭态不显示 -->
<button
v-if="unattendedStatus?.breaker_tripped"
type="button"
class="ai-unattended-badge df-badge df-badge--danger"
:title="$t('aiChat.unattendedResetHint')"
@click="resetUnattendedBreaker"
>
{{ $t('aiChat.unattendedTripped') }}
</button>
<button
v-else-if="unattendedStatus?.enabled"
type="button"
class="ai-unattended-badge df-badge df-badge--accent"
:title="$t('aiChat.unattendedBadgeHint')"
@click="jumpToUnattendedSettings"
>
🛌 {{ $t('aiChat.unattendedOn') }}<template v-if="unattendedStatus.in_flight_conv"> · {{ $t('aiChat.unattendedExecuting') }}</template>
</button>
</div>
</template>
<script setup lang="ts">
// ── 挂机引擎态徽章(P1 总装):消费 unattended_status,开启/熔断时 30s 级轮询刷新 ──
// 关闭态不显示徽章,轮询降频到 2 分钟(仍能感知设置页里的开启动作;单次 KV 级纯读,开销可忽略)
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { invoke } from '@tauri-apps/api/core'
import { useAppSettingsStore } from '../../../stores/appSettings'
defineProps<{
/** 待审批数(审批角标显隐 + 计数;父 computed) */
pendingApprovalCount: number
}>()
const emit = defineEmits<{
/** 折叠/展开会话侧栏(store.toggleSidebar,父转发保持 store 单一调用源) */
(e: 'toggle-sidebar'): void
/** 点击审批角标滚动到首个待审批(父 scrollToFirstPending) */
(e: 'scroll-to-pending'): void
}>()
interface UnattendedEngineStatus {
enabled: boolean
claim_scope: string
in_flight_conv: string | null
breaker_tripped: boolean
poll_secs: number
}
const UNATTENDED_ACTIVE_MS = 30_000
const UNATTENDED_IDLE_MS = 120_000
const router = useRouter()
const appSettings = useAppSettingsStore()
const unattendedStatus = ref<UnattendedEngineStatus | null>(null)
let unattendedTimer: ReturnType<typeof setTimeout> | null = null
// 卸载守卫:onBeforeUnmount 清定时器后,在途 invoke 的续延还会回来排下一轮,须拦住防卸载后空转
let unattendedDisposed = false
async function refreshUnattended() {
if (unattendedDisposed) return
try {
unattendedStatus.value = await invoke<UnattendedEngineStatus>('unattended_status')
} catch { /* IPC 失败保留上次状态,下轮重试 */ }
if (unattendedDisposed) return
const s = unattendedStatus.value
const delay = s?.enabled || s?.breaker_tripped ? UNATTENDED_ACTIVE_MS : UNATTENDED_IDLE_MS
unattendedTimer = setTimeout(() => void refreshUnattended(), delay)
}
/** 点击徽章跳设置页挂机区块(集成类首面板;activeCategory 由 appSettings 持久化驱动) */
function jumpToUnattendedSettings() {
void appSettings.set('df-settings-active-category', 'integrations')
void router.push('/settings')
}
/** 熔断复位:开关 false→true(引擎在关态轮询里清熔断与失败计数,再开即恢复领单) */
async function resetUnattendedBreaker() {
try {
await invoke('unattended_set_enabled', { enabled: false })
await invoke('unattended_set_enabled', { enabled: true })
} catch (e) {
console.warn('[topbar] 挂机熔断复位失败:', e)
}
void refreshUnattended()
}
onMounted(() => { void refreshUnattended() })
onBeforeUnmount(() => {
unattendedDisposed = true
if (unattendedTimer) { clearTimeout(unattendedTimer); unattendedTimer = null }
})
</script>
<style scoped>
.ai-header-left {
display: flex;
align-items: center;
gap: 6px;
}
.ai-header-icon {
display: flex;
align-items: center;
color: var(--df-accent);
}
.ai-header-title {
font-size: 13px;
font-weight: 500;
color: var(--df-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 200px;
}
/* 审批徽标(待审批>0 时显于头部,点击跳首个审批卡)
2026-10-18 wave2:迁 .df-badge--danger 透明彩底徽章(色由全局公共类接管,此处只留布局) */
.ai-approval-badge {
display: inline-flex;
align-items: center;
cursor: pointer;
font-weight: 500;
white-space: nowrap;
transition: opacity 0.15s var(--df-ease);
appearance: none;
}
.ai-approval-badge:hover {
opacity: 0.85;
}
.ai-approval-badge:focus-visible {
outline: 1.5px solid var(--df-accent);
outline-offset: 1px;
}
/* 挂机引擎态徽章(开启=accent / 熔断=danger,均由 df-badge 变体接管,此处只留布局) */
.ai-unattended-badge {
display: inline-flex;
align-items: center;
cursor: pointer;
font-weight: 500;
white-space: nowrap;
appearance: none;
transition: opacity 0.15s var(--df-ease);
}
.ai-unattended-badge:hover {
opacity: 0.85;
}
/* ── 图标按钮(共享工具类):wave2 样式仅存在于原 TopBar scoped(全局 components.css
副本为抽取时旧样式,逐字不一致),拆分子件各自带本副本防视觉回归 ── */
.ai-btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-md);
background: var(--df-bg-card);
color: var(--df-text-dim);
cursor: pointer;
transition: border-color 0.15s var(--df-ease), color 0.15s var(--df-ease), background 0.15s var(--df-ease);
}
.ai-btn-icon:hover {
border-color: var(--df-border-strong);
background: var(--df-bg-card-hover);
color: var(--df-text-secondary);
}
.ai-btn-icon--active {
border-color: color-mix(in srgb, var(--df-accent) 45%, transparent);
background: var(--df-accent-bg);
color: var(--df-accent);
}
.ai-btn-icon:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.ai-btn-icon:disabled:hover {
border-color: var(--df-border);
background: var(--df-bg-card);
color: var(--df-text-dim);
}
</style>
+149
View File
@@ -0,0 +1,149 @@
<template>
<!-- ── 📋 历史消息面板(当前会话 user 消息列表)──
c1c 拆分自 TopBar.vue,零行为变更。纯展示:数据(items)与定位未命中提示(locateMiss)
由父传入;点击/回车/空格经 navigate 回父执行滚动定位(命中收 history 面板语义在父)。 -->
<div v-if="items.length" class="ai-history-inline">
<button type="button" class="ai-history-inline-badge ai-tool-badge" :class="{ 'is-active': expanded }" @click="emit('toggle')" :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>
{{ items.length }}
</button>
<Transition name="ai-pop">
<div v-if="expanded" class="ai-history-inline-list">
<div v-for="h in items" :key="h.id" class="ai-history-inline-item" role="button" tabindex="0" @click="emit('navigate', h.id)" @keydown.enter.prevent="emit('navigate', h.id)" @keydown.space.prevent="emit('navigate', h.id)">
<span class="ai-history-index">{{ h.num }}</span>
<span class="ai-history-text">{{ h.text }}</span>
<span class="ai-history-time">{{ h.time }}</span>
</div>
<!-- F6-1:目标消息在折叠段内未渲染,面板不收 + 行内提示 -->
<div v-if="locateMiss" class="ai-locate-miss">{{ locateMiss }}</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import type { HistoryItem } from './types'
defineProps<{
/** 历史消息项(父 msgDerived 单遍派生) */
items: HistoryItem[]
/** 面板展开态(父持有,五面板互斥) */
expanded: boolean
/** F6-1 定位未命中提示(空串=不显示;父 locateMissHint) */
locateMiss: string
}>()
const emit = defineEmits<{
/** 徽章点击(父 togglePanel('history') 做互斥编排) */
(e: 'toggle'): void
/** 点击历史消息项 → 父滚动定位到对应消息气泡 */
(e: 'navigate', msgId: string): void
}>()
</script>
<style scoped>
/* 底部工具通用徽标(共享工具类,五面板各自带副本) */
.ai-tool-badge {
cursor: pointer;
font-size: 11px;
line-height: 16px;
padding: 2px 8px;
border-radius: 4px;
display: inline-block;
vertical-align: middle;
gap: 4px;
color: var(--df-text);
background: var(--df-bg-card);
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);
outline-offset: 1px;
}
.ai-tool-badge svg { vertical-align: middle; } /* 图标在行高内垂直居中(与数字同基线)*/
.ai-tool-badge:hover {
background: var(--df-bg-card-hover);
}
/* 面板激活态徽章(accent 描边,当前展开的面板入口可见) */
.ai-tool-badge.is-active {
color: var(--df-accent);
border-color: color-mix(in srgb, var(--df-accent) 45%, transparent);
background: var(--df-accent-bg);
}
/* 历史消息(底部) */
.ai-history-inline {
position: relative;
display: inline-flex;
align-items: center;
}
.ai-history-inline-list {
position: absolute;
left: auto; right: 0;
top: 100%;
z-index: 100;
min-width: 240px;
max-width: 400px;
max-height: 300px;
overflow-y: auto;
background: var(--df-bg-raised);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 4px 0;
margin-top: 2px;
}
.ai-history-inline-item {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 4px 8px;
font-size: 11px;
cursor: pointer;
color: var(--df-text-secondary);
transition: background 0.1s;
}
.ai-history-inline-item:hover {
background: var(--df-bg-card);
color: var(--df-text);
}
.ai-history-index {
flex-shrink: 0;
font-size: 10px;
color: var(--df-text-dim);
min-width: 16px;
text-align: left;
font-family: var(--df-font-mono);
}
.ai-history-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.4;
}
.ai-history-time {
flex-shrink: 0;
font-size: 10px;
color: var(--df-text-dim);
font-family: var(--df-font-mono);
}
/* F6-1:历史/摘要定位未命中提示(目标消息在折叠段内,引导展开分隔条) */
.ai-locate-miss {
padding: 6px 10px;
font-size: 11px;
line-height: 1.4;
color: var(--df-warning);
}
/* ═══ 底部面板 popover 过渡(ux-9d:平滑出入动画,对齐 more-menu popout) ═══ */
.ai-pop-enter-active { transition: opacity 0.16s var(--df-ease), transform 0.16s var(--df-ease); }
.ai-pop-leave-active { transition: opacity 0.12s var(--df-ease), transform 0.12s var(--df-ease); }
.ai-pop-enter-from,
.ai-pop-leave-to { opacity: 0; transform: translateY(-4px) scale(0.98); }
</style>
+165
View File
@@ -0,0 +1,165 @@
<template>
<!-- ══ 更多菜单(...):收纳低频的上下文管理操作(清空上下文/压缩上下文)══ -->
<!-- 使用频率分级:新建/清空对话/窗口控制是高频常用,上下文管理是低频项;
收进 popout 降低顶部视觉密度,需时从 ... 入口展开。点击外部收起。 -->
<div class="ai-more-menu" ref="moreMenuRef">
<button
class="ai-btn-icon"
:class="{ 'ai-btn-icon--active': moreMenuOpen }"
:title="$t('aiChat.moreActions')"
:aria-label="$t('aiChat.moreActions')"
@click="moreMenuOpen = !moreMenuOpen"
>
<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">
<!-- F-15 阶段2: 清空上下文(历史消息归档保留,不删 DB;新对话不受影响) -->
<button
class="ai-more-item"
:disabled="!hasActiveMessages || store.state.streaming"
@click="emit('clear-context'); 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"><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 归档) -->
<button
class="ai-more-item"
:disabled="!hasActiveMessages || store.state.streaming || store.isCompressing.value"
@click="emit('compress-context'); moreMenuOpen = false"
>
<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>
</template>
<script setup lang="ts">
// TopBar 更多菜单 popout(c1c 拆分自 TopBar.vue,零行为变更):
// 低频的清空/压缩上下文收入 ... popout;点击按钮切换,点击外部收起,ESC 收起(键盘友好)。
// 动作经 emit 回父(父持有 clear/compress/confirm 逻辑 + 单一 toast 源)。
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useAiStore } from '../../../stores/ai'
defineProps<{
/** 有活跃消息(清空/压缩按钮启用条件;父 computed) */
hasActiveMessages: boolean
}>()
const emit = defineEmits<{
/** 清空上下文(归档保留;父 handleClearContext) */
(e: 'clear-context'): void
/** 压缩上下文(LLM 摘要;父 handleCompressContext) */
(e: 'compress-context'): void
/** I12 展示快捷键速查面板(父 shortcutsVisible=true,面板体在父 Teleport 到 body) */
(e: 'show-shortcuts'): void
}>()
const store = useAiStore()
// ══ 更多菜单 popout 状态(上下文管理操作收纳)══
// 顶部按钮过多时,低频的清空/压缩上下文收入 ... popout;点击按钮切换,点击外部收起。
const moreMenuOpen = ref(false)
const moreMenuRef = ref<HTMLElement | null>(null)
function onDocClickCloseMoreMenu(e: MouseEvent) {
if (!moreMenuOpen.value) return
const root = moreMenuRef.value
if (root && !root.contains(e.target as Node)) {
moreMenuOpen.value = false
}
}
onMounted(() => document.addEventListener('click', onDocClickCloseMoreMenu))
onBeforeUnmount(() => document.removeEventListener('click', onDocClickCloseMoreMenu))
// ESC 键收起更多菜单(键盘友好)
function onEscCloseMoreMenu(e: KeyboardEvent) {
if (e.key === 'Escape' && moreMenuOpen.value) moreMenuOpen.value = false
}
onMounted(() => document.addEventListener('keydown', onEscCloseMoreMenu))
onBeforeUnmount(() => document.removeEventListener('keydown', onEscCloseMoreMenu))
</script>
<style scoped>
/* ── 图标按钮(共享工具类):wave2 样式仅存在于原 TopBar scoped(全局 components.css
副本为抽取时旧样式,逐字不一致),拆分子件各自带本副本防视觉回归 ── */
.ai-btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-md);
background: var(--df-bg-card);
color: var(--df-text-dim);
cursor: pointer;
transition: border-color 0.15s var(--df-ease), color 0.15s var(--df-ease), background 0.15s var(--df-ease);
}
.ai-btn-icon:hover {
border-color: var(--df-border-strong);
background: var(--df-bg-card-hover);
color: var(--df-text-secondary);
}
.ai-btn-icon--active {
border-color: color-mix(in srgb, var(--df-accent) 45%, transparent);
background: var(--df-accent-bg);
color: var(--df-accent);
}
.ai-btn-icon:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.ai-btn-icon:disabled:hover {
border-color: var(--df-border);
background: var(--df-bg-card);
color: var(--df-text-dim);
}
/* ── 更多菜单 popout(清空/压缩上下文等低频操作收纳;CSS 原缺失致打开样式错乱)── */
.ai-more-menu { position: relative; } /* popout absolute 定位基准 */
.ai-more-popout {
position: absolute;
top: calc(100% + 4px);
right: 0;
min-width: 168px;
padding: 4px;
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
box-shadow: var(--df-shadow-sm);
z-index: 100;
display: flex;
flex-direction: column;
gap: 2px;
}
.ai-more-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border: none;
border-radius: var(--df-radius-sm);
background: transparent;
color: var(--df-text-secondary);
font-size: 12px;
cursor: pointer;
white-space: nowrap;
}
.ai-more-item:hover:not(:disabled) { background: var(--df-bg-card-hover); color: var(--df-text); }
.ai-more-item:disabled { opacity: 0.35; cursor: not-allowed; }
/* Transition:淡入 + 下滑 */
.ai-more-popout-enter-active, .ai-more-popout-leave-active { transition: opacity .15s, transform .15s; }
.ai-more-popout-enter-from, .ai-more-popout-leave-to { opacity: 0; transform: translateY(-4px); }
/* 压缩中 spinner:.ai-btn-spinner(含 ai-btn-spin 旋转动画)由 components.css 全局定义,
原本地重复 animation 声明删除(2026-10-18 整洁波,逐字重复零视觉变更) */
</style>
@@ -0,0 +1,220 @@
<template>
<!-- TopBar 头部中间区:provider 状态 + 模型选择器。自 TopBar.vue c1c 拆分迁入,零行为变更。
provider/model 选择逻辑直接读 store(单例共享);cycleProvider 后经 provider-switched
回父(父负责弹临时 bar + 向 AiChat 转发 toast,保持单一 toast 源)。 -->
<div class="ai-header-center">
<button
type="button"
class="ai-provider-bar"
:class="{ 'ai-provider-bar--switchable': store.state.providers.length > 1 }"
v-if="store.state.providers.length > 0"
:title="store.state.providers.length > 1 ? $t('aiChat.providerSwitchSetsDefault') : ''"
@click="cycleProvider"
>
<span class="df-dot provider-dot" :class="'provider-dot--' + providerStatus"></span>
<span class="provider-name">{{ activeProviderName }}</span>
</button>
<div class="ai-provider-bar ai-provider-empty" v-else>
<span class="provider-hint">{{ $t('aiChat.providerNotConfigured') }}</span>
</div>
<div class="ai-model-picker" :title="$t('aiChat.specifyModeHint') || ''">
<select
v-if="enabledModels.length"
class="ai-model-select ai-model-select--compact"
:value="selectedModelId"
@change="onModelSelect($event)"
>
<option v-for="m in enabledModels" :key="m.model_id" :value="m.model_id">
{{ modelOptionLabel(m) }}
</option>
</select>
<span v-else class="ai-model-empty">{{ $t('aiChat.noModels') }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, watch } from 'vue'
import { useAiStore } from '../../../stores/ai'
import type { ModelConfig } from '../../../api/types'
defineProps<{
/** 当前生效 provider 名(父 computed;切换临时 bar 同用一份) */
activeProviderName: string
}>()
const emit = defineEmits<{
/** Provider 循环切换后回传 model 名(父 showToast 弹切换提示,保持单一 toast 源) */
(e: 'provider-switched', modelName: string): void
}>()
const store = useAiStore()
/** provider 连通状态(零侵入,基于最后一条 assistant 消息 isError 推断):
* unknown=未发过(灰) / ok=最近一次 AI 请求成功(绿) / error=最近失败(红,如 401/超时/服务不通)。
* 不主动 ping(省配额),反映最近一次实际请求结果。切换 provider 后状态保留直到新请求。 */
const providerStatus = computed<'unknown' | 'ok' | 'error'>(() => {
const msgs = store.state.messages
for (let i = msgs.length - 1; i >= 0; i--) {
const m = msgs[i]
if (m.role === 'assistant') return m.isError ? 'error' : 'ok'
}
return 'unknown'
})
// ── 模型选择器(对话透明化 L1:折叠为紧凑下拉,直接选择模型) ──
// active provider 的 model_configs(用户在 Settings 拉取过的,仅 enabled 项)。
// 空池(provider 未拉取/无 enabled 模型)时下拉为空。
const activeProviderRecord = computed(() =>
store.state.providers.find(p => p.id === store.state.activeProvider)
|| store.state.providers.find(p => p.is_default)
|| store.state.providers[0]
|| null,
)
const enabledModels = computed(() => {
const p = activeProviderRecord.value
if (!p?.model_configs) return []
return p.model_configs
.filter(m => m.enabled)
.slice()
.sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0))
})
const selectedModelId = computed(() => store.modelOverride.value || '')
function modelOptionLabel(m: ModelConfig): string {
return m.label || m.model_id
}
function onModelSelect(e: Event) {
const v = (e.target as HTMLSelectElement).value
store.modelOverride.value = v || null
}
// 切换会话:仅当手选模型空/对目标模型池无效时才回落权重最高(保留用户手选,不每次重置)
watch(() => store.state.activeConversationId, () => {
const current = store.modelOverride.value
const valid = current && enabledModels.value.some(m => m.model_id === current)
if (!valid) {
store.modelOverride.value = enabledModels.value[0]?.model_id || null
}
})
// 切 provider 后模型 override 同步(修复:provider 切换后 header 模型名不刷新):
// - modelOverride 是模块级 ref(useAiSend)不持久化,切 provider 后仍持旧 provider 的 model_id,
// 该 id 不在新 provider 池里 → 下拉显示错位(UI 显示 X,实际后端用路由默认 Y)。
// - 守卫:当前选择对新 provider 模型池无效(空/不在池)时,默认选权重最高模型。
// enabledModels 已按 weight 降序排,model[0] 即权重最高。
// - immediate:组件挂载时也校验(modelOverride 为 null → 默认权重最高,与下拉首项对齐)。
watch(enabledModels, (models) => {
const current = store.modelOverride.value
const valid = current && models.some(m => m.model_id === current)
if ((!valid || !current) && models.length) {
store.modelOverride.value = models[0].model_id
}
}, { immediate: true })
// 循环切换 Provider(provider bar 点击)
function cycleProvider() {
const ps = store.state.providers
if (ps.length < 2) return
// activeProvider 为 null 时从 DB 默认(is_default)起算,避免 findIndex=-1 跳到 providers[0]
const startId = store.state.activeProvider || ps.find(p => p.is_default)?.id || null
const idx = startId ? ps.findIndex(p => p.id === startId) : -1
// idx=-1 且无默认 provider 时 fallback 到 0(极端边界,正常路径不会走到)
const next = ps[(Math.max(idx, 0) + 1) % ps.length]
store.setProvider(next.id)
// 切换反馈 — toast 提示当前 model + 临时 bar 展示 model 名(2s 后淡出)。
// toast 机制:复用父自管 reactive showToast(分离窗口无 App.vue 根 toast),经 emit 回父弹。
emit('provider-switched', next.name || next.id)
}
</script>
<style scoped>
/* header 中间区:provider + 模型选择器并排 */
.ai-header-center {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
justify-content: center;
min-width: 0;
}
/* ═══ 模型选择器(对话透明化 L1:紧凑下拉) ═══ */
.ai-model-picker {
display: flex;
align-items: center;
min-width: 0;
}
.ai-model-select {
min-width: 0;
max-width: 180px;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
background: var(--df-bg);
color: var(--df-text);
font-size: 10px;
font-family: var(--df-font-mono);
padding: 3px 6px;
outline: none;
cursor: pointer;
}
/* 聚焦可见环:聚焦态无边框变化,且 scoped 选择器特异性高于全局 :focus-visible 兜底,须就地补环,仅键盘聚焦显示 */
.ai-model-select:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 1px; }
.ai-model-select--compact {
max-width: 130px;
}
.ai-model-select:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.ai-model-empty {
font-size: 10px;
color: var(--df-text-dim);
white-space: nowrap;
}
/* ═══ Provider Bar (header 中间区)
2026-10-18 wave2:对齐 workbench .session-status pill 范式——bg-card 底+边框+8px 圆角+12px 字,
状态点迁 .df-dot(7px 尺寸由全局公共类提供);div→button 后归零默认样式 ═══ */
.ai-provider-bar {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--df-text-secondary);
white-space: nowrap;
font-family: inherit;
appearance: none;
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-md);
padding: 4px 12px;
transition: border-color 0.15s var(--df-ease);
}
.ai-provider-bar--switchable {
cursor: pointer;
}
.ai-provider-bar--switchable:hover {
border-color: var(--df-border-strong);
color: var(--df-text);
}
.ai-provider-bar--switchable:focus-visible {
outline: 1.5px solid var(--df-accent);
outline-offset: 2px;
border-radius: var(--df-radius-md);
}
/* provider 连通状态点:灰=未测/绿=最近成功/红=最近失败(基于最后 assistant 消息 isError);
尺寸/圆角走全局 .df-dot,此处只管色 */
.provider-dot { flex-shrink: 0; transition: background .2s; background: var(--df-text-dim); }
.provider-dot--ok { background: var(--df-success); }
.provider-dot--error { background: var(--df-danger); }
.provider-name {
font-size: 12px;
font-family: var(--df-font-mono);
color: var(--df-text-secondary);
}
.ai-provider-empty {
background: var(--df-danger-bg);
}
.provider-hint {
font-size: 11px;
color: var(--df-danger);
}
</style>
@@ -0,0 +1,55 @@
<template>
<!-- UX-2025-14: Provider 切换临时 bar(切换后展示当前 model 名,2s 后淡出)。
c1c 拆分自 TopBar.vue,零行为变更。纯展示:显隐(visible)与 provider 名由父持有;
仍渲染于底部工具区之后(与拆分前 DOM 位置一致)。 -->
<Transition name="ai-provider-switch">
<div v-if="visible" class="ai-provider-switch-bar">
<span class="ai-provider-switch-dot"></span>
<span class="ai-provider-switch-text">{{ $t('aiChat.providerSwitchHint', { model: providerName }) }}</span>
</div>
</Transition>
</template>
<script setup lang="ts">
defineProps<{
/** bar 显隐(父 providerBarVisible,2s 自动收) */
visible: boolean
/** 当前生效 provider 名(父 computed,与 header provider bar 同一份) */
providerName: string
}>()
</script>
<style scoped>
/* Provider 切换临时 bar(淡入淡出 0.15s) */
.ai-provider-switch-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 14px;
background: color-mix(in srgb, var(--df-accent) 12%, transparent);
border-bottom: 0.5px solid color-mix(in srgb, var(--df-accent) 25%, transparent);
font-size: 11px;
color: var(--df-accent);
user-select: none;
}
.ai-provider-switch-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--df-accent);
flex-shrink: 0;
animation: ai-agentic-pulse 1s ease-in-out infinite;
}
.ai-provider-switch-text {
font-family: var(--df-font-mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-provider-switch-enter-active,
.ai-provider-switch-leave-active { transition: opacity 0.15s var(--df-ease), max-height 0.15s var(--df-ease); }
.ai-provider-switch-enter-from,
.ai-provider-switch-leave-to { opacity: 0; max-height: 0; padding-top: 0; padding-bottom: 0; }
.ai-provider-switch-enter-to,
.ai-provider-switch-leave-from { opacity: 1; max-height: 32px; }
</style>
@@ -0,0 +1,314 @@
<template>
<!-- #14 桌面任务卡:后台在途任务(多会话并发生成中,点击切回)。
c1c 拆分自 TopBar.vue,零行为变更。面板展开态由父持有(五面板互斥编排),
本子件经 toggle/close 事件回报;展开时拉取可恢复清单(原 togglePanel B3 分支语义)。 -->
<div v-if="runningTasks.length || recoverableTasks.length" class="ai-running-tasks-inline">
<button type="button" class="ai-running-tasks-badge ai-tool-badge df-badge df-badge--info" :class="{ 'is-active': expanded }" @click="emit('toggle')" :title="$t('aiChat.runningTasks')">
<span class="ai-running-tasks-pulse"></span>
{{ runningTasks.length }}
</button>
<Transition name="ai-pop">
<div v-if="expanded" class="ai-running-tasks-list">
<button v-for="task in runningTasks" :key="task.id" type="button" class="ai-running-task-item" @click="switchToRunning(task.id)">
<span class="ai-running-task-dot"></span>
<span class="ai-running-task-title">{{ task.title }}</span>
<span class="ai-running-task-round">{{ t('aiChat.runningRounds', { n: task.rounds }) }}</span>
</button>
<template v-if="recoverableTasks.length">
<div class="ai-running-tasks-sep"></div>
<div v-for="task in recoverableTasks" :key="task.id" class="ai-running-task-item ai-running-task-item--parked">
<button type="button" class="ai-running-task-main" @click="switchToRunning(task.id)">
<span class="ai-running-task-dot ai-running-task-dot--parked"></span>
<span class="ai-running-task-title">{{ task.title }}</span>
</button>
<button type="button" class="ai-running-task-recover" @click="recoverParked(task.id)" :title="t('aiChat.recoverNow')">{{ t('aiChat.recoverNow') }}</button>
</div>
</template>
</div>
</Transition>
<!-- 走查批E:恢复失败就近 toast(原仅 console.warn 用户无感;对齐侧栏 recoverableFail 键) -->
<Transition name="ai-pop">
<div v-if="recoverToast.visible" class="ai-recover-toast">{{ recoverToast.msg }}</div>
</Transition>
</div>
</template>
<script setup lang="ts">
// TopBar 后台在途/可恢复任务面板(c1c 拆分自 TopBar.vue,零行为变更)。
// 数据源=convRounds(轮次追踪,事件写)× store.isGenerating(状态,同源 convStates),
// 排除当前会话(自身进度已有 agentic 实时进度条,不重复)。响应式:convRounds 是
// aiShared 模块级 reactive Map,computed 内遍历即建依赖。
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../../stores/ai'
import { aiApi } from '../../../api/ai'
import { convRounds } from '../../../composables/ai/aiShared'
import { switchConversation } from '../../../composables/ai/useAiConversations'
import { useToast } from '../../../composables/useToast'
const props = defineProps<{
/** 面板展开态(父持有,五面板互斥) */
expanded: boolean
}>()
const emit = defineEmits<{
/** 徽章点击(父 togglePanel('running') 做互斥编排) */
(e: 'toggle'): void
/** 请求收起面板(切回会话时;父置 expanded=false) */
(e: 'close'): void
}>()
const { t } = useI18n()
const store = useAiStore()
const runningTasks = computed(() => {
const items: { id: string; title: string; rounds: number }[] = []
for (const [id, rounds] of convRounds) {
if (id === store.state.activeConversationId) continue
if (!store.isGenerating(id)) continue
const conv = store.state.conversations.find(c => c.id === id)
items.push({ id, title: conv?.title || t('aiChat.untitledConversation'), rounds })
}
return items
})
// V49 可恢复入口(B3):数据源双轨——conversations 中 loop_state='parked'(实时投影,
// 侧栏 limit 内)+ ai_list_recoverable 完备真相 IPC(面板展开时拉取,补侧栏截断漏项)。
// 合并去重按 id;点击切回,继续与否由用户在会话内决定,不做自动续跑
const recoverableFromList = computed(() => {
return store.state.conversations
.filter(c => c.loop_state === 'parked' && c.id !== store.state.activeConversationId)
.map(c => ({ id: c.id, title: c.title || t('aiChat.untitledConversation') }))
})
const recoverableFromIpc = ref<{ id: string; title: string | null }[]>([])
async function refreshRecoverable() {
try {
const items = await aiApi.listRecoverable()
recoverableFromIpc.value = items.map(i => ({ id: i.id, title: i.title }))
} catch { /* IPC 失败退回 list 投影,不阻断面板 */ }
}
const recoverableTasks = computed(() => {
const seen = new Set<string>()
const merged: { id: string; title: string }[] = []
for (const item of [...recoverableFromList.value, ...recoverableFromIpc.value]) {
if (item.id === store.state.activeConversationId || seen.has(item.id)) continue
seen.add(item.id)
merged.push({ id: item.id, title: item.title || t('aiChat.untitledConversation') })
}
return merged.slice(0, 10)
})
// B3:任务面板展开时拉一次可恢复清单完备真相(补侧栏 limit 截断漏项);失败退回 list 投影
// (原 togglePanel('running') 展开分支,拆分后由展开态 watch 承接,触发时机等价)
watch(() => props.expanded, (open) => {
if (open) void refreshRecoverable()
})
async function switchToRunning(id: string) {
emit('close')
if (id !== store.state.activeConversationId) {
await switchConversation(id)
}
}
/** B4:恢复 parked 会话(注入恢复引导消息续跑),成功后切回该会话 */
const recoveringIds = ref(new Set<string>())
// 走查批E:恢复失败就近 toast(本组件自持容器,父 AiChat 仍为唯一聊天流 toast 源)
const { toast: recoverToast, showToast: showRecoverToast } = useToast()
async function recoverParked(id: string) {
if (recoveringIds.value.has(id)) return
recoveringIds.value.add(id)
try {
await aiApi.recoverParked(id)
recoveringIds.value.delete(id)
await switchToRunning(id)
} catch (e) {
recoveringIds.value.delete(id)
console.warn('[topbar] 恢复失败:', e)
showRecoverToast(t('aiChat.recoverableFail', { msg: String(e) }), 'error')
}
}
</script>
<style scoped>
/* 底部工具通用徽标(共享工具类,五面板各自带副本) */
.ai-tool-badge {
cursor: pointer;
font-size: 11px;
line-height: 16px;
padding: 2px 8px;
border-radius: 4px;
display: inline-block;
vertical-align: middle;
gap: 4px;
color: var(--df-text);
background: var(--df-bg-card);
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);
outline-offset: 1px;
}
.ai-tool-badge svg { vertical-align: middle; } /* 图标在行高内垂直居中(与数字同基线)*/
.ai-tool-badge:hover {
background: var(--df-bg-card-hover);
}
/* 面板激活态徽章(accent 描边,当前展开的面板入口可见) */
.ai-tool-badge.is-active {
color: var(--df-accent);
border-color: color-mix(in srgb, var(--df-accent) 45%, transparent);
background: var(--df-accent-bg);
}
/* ── #14 桌面任务卡:后台在途任务徽章+面板 ── */
.ai-running-tasks-inline {
position: relative;
display: inline-flex;
align-items: center;
}
.ai-running-tasks-badge {
display: inline-flex;
align-items: center;
gap: 4px;
}
/* wave2:后台会话徽章迁 .df-badge--info(透明彩底 info 系);scoped 需显式覆写压过 .ai-tool-badge 灰底 */
.ai-running-tasks-badge.df-badge--info {
background: var(--df-info-bg);
color: var(--df-info);
border-color: color-mix(in srgb, var(--df-info) 30%, transparent);
}
.ai-running-tasks-pulse {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--df-success);
animation: ai-running-pulse 2s ease-in-out infinite;
}
@keyframes ai-running-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.ai-running-tasks-list {
position: absolute;
left: auto; right: 0;
top: 100%;
z-index: 100;
min-width: 260px;
max-width: 400px;
max-height: 300px;
overflow-y: auto;
background: var(--df-bg-raised);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 4px 0;
}
.ai-running-task-item {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 5px 10px;
border: none;
background: none;
cursor: pointer;
text-align: left;
font-size: 12px;
color: var(--df-text);
}
.ai-running-task-item:hover {
background: var(--df-bg-card-hover);
}
.ai-running-task-dot {
flex-shrink: 0;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--df-success);
}
.ai-running-task-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-running-task-round {
flex-shrink: 0;
font-size: 11px;
color: var(--df-text-secondary);
}
/* V49 可恢复分区:琥珀点+「可恢复」徽标,与进行中(绿)区分 */
.ai-running-tasks-sep {
height: 1px;
margin: 4px 8px;
background: var(--df-border);
}
.ai-running-task-dot--parked {
background: var(--df-warning);
}
/* B4 parked 行改双钮布局(切回 | 恢复),恢复钮琥珀描边醒目 */
.ai-running-task-item--parked {
display: flex;
align-items: center;
gap: 6px;
padding: 0;
}
.ai-running-task-item--parked:hover {
background: none;
}
.ai-running-task-main {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
padding: 5px 0 5px 10px;
border: none;
background: none;
cursor: pointer;
text-align: left;
font-size: 12px;
color: var(--df-text);
}
.ai-running-task-main:hover {
background: var(--df-bg-card-hover);
}
.ai-running-task-recover {
flex-shrink: 0;
margin-right: 10px;
padding: 2px 8px;
border: 1px solid var(--df-warning);
border-radius: var(--df-radius-sm);
background: none;
cursor: pointer;
font-size: 11px;
color: var(--df-warning);
}
.ai-running-task-recover:hover {
background: var(--df-warning);
color: #fff;
}
/* 走查批E:恢复失败就近 toast(容器内下方弹出,视觉对齐侧栏 .ai-conv-toast,全 token) */
.ai-recover-toast {
position: absolute;
top: calc(100% + 4px);
right: 0;
z-index: 110; /* 高于面板列表(100),失败时面板仍展开不被盖 */
padding: 6px 10px;
border-radius: var(--df-radius);
font-size: 11px;
max-width: 280px;
overflow-wrap: anywhere;
box-shadow: var(--df-shadow-sm);
background: var(--df-danger-bg);
color: var(--df-danger);
border: 0.5px solid var(--df-danger);
}
/* ═══ 底部面板 popover 过渡(ux-9d:平滑出入动画,对齐 more-menu popout) ═══ */
.ai-pop-enter-active { transition: opacity 0.16s var(--df-ease), transform 0.16s var(--df-ease); }
.ai-pop-leave-active { transition: opacity 0.12s var(--df-ease), transform 0.12s var(--df-ease); }
.ai-pop-enter-from,
.ai-pop-leave-to { opacity: 0; transform: translateY(-4px) scale(0.98); }
</style>
+139
View File
@@ -0,0 +1,139 @@
<template>
<!-- ── 📄 上下文摘要面板(压缩/归档的系统消息)──
c1c 拆分自 TopBar.vue,零行为变更。纯展示:数据(items)与定位未命中提示(locateMiss)
由父传入;点击项经 navigate 回父执行滚动定位。 -->
<div v-if="items.length" class="ai-summary-inline">
<button type="button" class="ai-summary-inline-badge ai-tool-badge" :class="{ 'is-active': expanded }" @click="emit('toggle')" :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>
{{ items.length }}
</button>
<Transition name="ai-pop">
<div v-if="expanded" class="ai-summary-inline-list">
<div v-for="(s, i) in items" :key="i" class="ai-summary-inline-item" role="button" tabindex="0" @click="emit('navigate', s.id)" @keydown.enter.prevent="emit('navigate', s.id)" @keydown.space.prevent="emit('navigate', s.id)">
<span class="ai-summary-label">{{ $t('aiChat.compressedSummaryLabel') }} #{{ items.length - i }}</span>
<p class="ai-summary-preview">{{ s.text }}</p>
</div>
<!-- F6-1:摘要项同享定位未命中提示(压缩段折叠时 system 消息同样不在 DOM) -->
<div v-if="locateMiss" class="ai-locate-miss">{{ locateMiss }}</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import type { SummaryItem } from './types'
defineProps<{
/** 摘要消息项(父 msgDerived 单遍派生,与 historyMsgs 共享单次遍历) */
items: SummaryItem[]
/** 面板展开态(父持有,五面板互斥) */
expanded: boolean
/** F6-1 定位未命中提示(空串=不显示;父 locateMissHint) */
locateMiss: string
}>()
const emit = defineEmits<{
/** 徽章点击(父 togglePanel('summary') 做互斥编排) */
(e: 'toggle'): void
/** 点击摘要项 → 父滚动定位到对应消息气泡 */
(e: 'navigate', msgId: string): void
}>()
</script>
<style scoped>
/* 底部工具通用徽标(共享工具类,五面板各自带副本) */
.ai-tool-badge {
cursor: pointer;
font-size: 11px;
line-height: 16px;
padding: 2px 8px;
border-radius: 4px;
display: inline-block;
vertical-align: middle;
gap: 4px;
color: var(--df-text);
background: var(--df-bg-card);
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);
outline-offset: 1px;
}
.ai-tool-badge svg { vertical-align: middle; } /* 图标在行高内垂直居中(与数字同基线)*/
.ai-tool-badge:hover {
background: var(--df-bg-card-hover);
}
/* 面板激活态徽章(accent 描边,当前展开的面板入口可见) */
.ai-tool-badge.is-active {
color: var(--df-accent);
border-color: color-mix(in srgb, var(--df-accent) 45%, transparent);
background: var(--df-accent-bg);
}
/* ═══ 上下文摘要(底部) ═══ */
.ai-summary-inline {
position: relative;
display: inline-flex;
align-items: center;
}
.ai-summary-inline-list {
position: absolute;
left: auto; right: 0;
top: 100%;
z-index: 100;
min-width: 280px;
max-width: 420px;
max-height: 360px;
overflow-y: auto;
background: var(--df-bg-raised);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 6px 0;
margin-top: 2px;
}
.ai-summary-inline-item {
padding: 6px 10px;
cursor: pointer;
border-bottom: 0.5px solid var(--df-border);
transition: background 0.1s;
}
.ai-summary-inline-item:last-child { border-bottom: none; }
.ai-summary-inline-item:hover {
background: var(--df-bg-card);
}
.ai-summary-label {
display: block;
font-size: 10px;
font-weight: 600;
color: var(--df-accent);
margin-bottom: 2px;
}
.ai-summary-preview {
margin: 0;
font-size: 11px;
color: var(--df-text-secondary);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* F6-1:历史/摘要定位未命中提示(目标消息在折叠段内,引导展开分隔条) */
.ai-locate-miss {
padding: 6px 10px;
font-size: 11px;
line-height: 1.4;
color: var(--df-warning);
}
/* ═══ 底部面板 popover 过渡(ux-9d:平滑出入动画,对齐 more-menu popout) ═══ */
.ai-pop-enter-active { transition: opacity 0.16s var(--df-ease), transform 0.16s var(--df-ease); }
.ai-pop-leave-active { transition: opacity 0.12s var(--df-ease), transform 0.12s var(--df-ease); }
.ai-pop-enter-from,
.ai-pop-leave-to { opacity: 0; transform: translateY(-4px) scale(0.98); }
</style>
@@ -0,0 +1,226 @@
<template>
<!-- 无人值守任务进度(AiTaskProgress,设计 §4.3):当前会话有进度快照时显示,
静默期(子代理长跑/大工具执行/重入退避)凭最后快照可见「在干活」;终局即清。
max_rounds=0(不限轮次)不显分母进度条,只显轮次与 token(防假进度规则2:无分母禁画百分比)。
U2 三层(设计 §三):活性=脉冲点(静默降速,永不伪装停止);位置=轮次/token 数字;
内容=阶段标签(最近工具映射)+已完成步数,静默档附加判断材料(不自动判死,决策留给用户)。
A8 双进度矛盾:输入区 agentic 实时进度条可见时(streaming 且 agentRound>0)
隐藏本快照徽章——两处同屏且轮数口径不同(实时 vs 每5轮快照)互相矛盾;
快照徽章只服务输入区不可见场景(托盘/单轮大工具静默期)。 -->
<div
v-if="taskProgress && !agenticProgressVisible"
class="ai-task-progress"
:class="silenceTier !== 'normal' ? `ai-task-progress--${silenceTier}` : ''"
:title="taskProgressHint"
>
<span class="ai-task-progress-dot"></span>
<span class="ai-task-progress-text">{{ taskProgressText }}</span>
<span v-if="stageText" class="ai-task-progress-stage">{{ stageText }}</span>
<span v-if="silenceSuffix" class="ai-task-progress-silence">{{ silenceSuffix }}</span>
<span
v-if="taskProgress.maxRounds > 0"
class="ai-task-progress-bar"
:title="$t('ai.taskProgress.barHint', { round: taskProgress.round, max: taskProgress.maxRounds })"
>
<span class="ai-task-progress-fill" :style="{ width: taskProgressPct + '%' }"></span>
</span>
</div>
</template>
<script setup lang="ts">
// TopBar 无人值守快照徽章(c1c 拆分自 TopBar.vue,零行为变更):
// 消费 useAiEvents 归集的 pendingTaskProgress(per-conv Map,终局即清):无快照不渲染。
// 切会话自动跟随(Map 按 activeId 取),多会话并发各显各的,不串台。
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../../stores/ai'
import { pendingTaskProgress } from '../../../composables/ai/useAiPendingState'
const { t } = useI18n()
const store = useAiStore()
const taskProgress = computed(() => {
const id = store.state.activeConversationId
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 ''
const tokens = p.tokensUsed >= 1000 ? `${(p.tokensUsed / 1000).toFixed(1)}k` : String(p.tokensUsed)
return p.maxRounds > 0
? t('ai.taskProgress.running', { round: p.round, max: p.maxRounds, tokens })
: t('ai.taskProgress.unlimited', { round: p.round, tokens })
})
const taskProgressPct = computed(() => {
const p = taskProgress.value
if (!p || p.maxRounds <= 0) return 0
return Math.min(100, Math.round((p.round / p.maxRounds) * 100))
})
// ── U2 进度语义三层补齐(设计 §三):内容层(最近动作)+静默期三档(§3.4) ──
// 规则1 兑现:下方定时器只算「距上次信号多久」,永不推进任何计数(轮次/步数只由事件写)。
// 规则4 兑现:活性=脉冲节奏(静默降速),位置=数字,分开渲染;永不自动判死(重入退避最长
// 15min 是合法等待),警示档只给判断材料,「疑似卡死可停止」的决策留给用户。
// 内容层数据源:工具卡挂在 assistant 消息上(useAiToolEvents),从消息流纯派生——零新事件
// 监听零协议改动。steps=当前消息流工具调用总数(压缩仅标记不清消息,计数不回退;清空上下文
// 重置属用户显式重开,语义自洽);last 优先取 running 卡(当前动作),无 running 取最后一张。
const toolActivity = computed(() => {
let steps = 0
let lastName: string | null = null
let runningName: string | null = null
for (const m of store.state.messages) {
const tcs = m.toolCalls
if (!tcs?.length) continue
steps += tcs.length
for (const tc of tcs) {
lastName = tc.name
if (tc.status === 'running') runningName = tc.name
}
}
return { steps, last: runningName ?? lastName }
})
// 阶段标签映射(设计 §3.3 同源词表桌面份):工具名→语义标签;未命中直显工具名
const STAGE_KEYS: Record<string, string> = {
read_file: 'read', read_symbol: 'read', search_files: 'read', list_directory: 'read',
write_file: 'edit', patch_file: 'edit',
run_command: 'run',
spawn_agent: 'agent',
}
const STAGE_PREFIXES: [string, string][] = [['knowledge_', 'knowledge'], ['idea_', 'knowledge'], ['task_', 'knowledge']]
function stageKeyOf(name: string): string | null {
if (STAGE_KEYS[name]) return STAGE_KEYS[name]
for (const [p, k] of STAGE_PREFIXES) if (name.startsWith(p)) return k
return null
}
const stageText = computed(() => {
const act = toolActivity.value
if (!act.last) return ''
const key = stageKeyOf(act.last)
const parts = [key ? t(`ai.taskProgress.stage.${key}`) : act.last]
if (act.steps > 0) parts.push(t('ai.taskProgress.steps', { n: act.steps }))
return parts.join(' · ')
})
// 静默期计时:基准=最近一次真实信号(AiTaskProgress.updatedAt 或消息流指纹变化时刻)。
// 指纹 watch 只在信号真实变化时刷新(规则5:同值重刷不产生新活性);文本增量/工具卡增减/
// 轮次推进都会被捕获。
const lastActivityAt = ref(Date.now())
watch(() => {
const msgs = store.state.messages
const last = msgs[msgs.length - 1]
return [msgs.length, last?.id, last?.content?.length, last?.toolCalls?.length, store.state.agentRound]
}, () => { lastActivityAt.value = Date.now() })
// 切会话用已知时间锚点重置(快照 updatedAt/末条消息时间),防「刚切回就看不到本就持续的静默」
watch(() => store.state.activeConversationId, () => {
const msgs = store.state.messages
const last = msgs[msgs.length - 1]
lastActivityAt.value = Math.max(taskProgress.value?.updatedAt ?? 0, last?.timestamp ?? 0)
})
// 15s 心跳只为刷新「距上次信号」展示(§3.4 档位阈值 2/10min,15s 粒度足够;规则1:定时器
// 不碰任何计数)
const nowTick = ref(Date.now())
let silenceTimer: ReturnType<typeof setInterval> | null = null
onMounted(() => {
silenceTimer = setInterval(() => { nowTick.value = Date.now() }, 15_000)
})
onBeforeUnmount(() => {
if (silenceTimer) { clearInterval(silenceTimer); silenceTimer = null }
})
const silenceSecs = computed(() => {
const p = taskProgress.value
if (!p) return 0
return Math.max(0, Math.floor((nowTick.value - Math.max(p.updatedAt, lastActivityAt.value)) / 1000))
})
// §3.4 三档:<2min 正常 / 2-10min 长执行(脉冲降速) / >10min 警示(给判断材料不自动判死)
const silenceTier = computed<'normal' | 'long' | 'warn'>(() => {
const s = silenceSecs.value
if (s >= 600) return 'warn'
if (s >= 120) return 'long'
return 'normal'
})
const silenceSuffix = computed(() => {
if (!taskProgress.value) return ''
if (silenceTier.value === 'warn') return t('ai.taskProgress.silentShort', { n: Math.floor(silenceSecs.value / 60) })
if (silenceTier.value === 'long') return t('ai.taskProgress.longRun')
return ''
})
// 悬浮提示:警示档给完整判断材料(距上次活动 n 分钟+最后动作+出路),长执行档给成因提示,
// 正常档不弹(位置信息正文已有,避免无意义提示)
const taskProgressHint = computed(() => {
if (!taskProgress.value) return ''
if (silenceTier.value === 'warn') {
return t('ai.taskProgress.silentWarn', { n: Math.floor(silenceSecs.value / 60), tool: toolActivity.value.last ?? '-' })
}
if (silenceTier.value === 'long') return t('ai.taskProgress.longRunHint')
return ''
})
</script>
<style scoped>
/* 无人值守任务进度(设计 §4.3):绿点+轮次/token 文本+细进度条,与右侧徽章族同排 */
.ai-task-progress {
display: inline-flex;
align-items: center;
gap: 5px;
margin-right: 4px;
white-space: nowrap;
}
.ai-task-progress-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--df-success);
flex-shrink: 0;
animation: ai-task-progress-pulse 1.6s ease-in-out infinite;
}
.ai-task-progress-text {
font-size: 11px;
color: var(--df-text-secondary);
}
.ai-task-progress-bar {
display: inline-block;
width: 48px;
height: 3px;
border-radius: 2px;
background: color-mix(in srgb, var(--df-text-dim) 25%, transparent);
overflow: hidden;
}
.ai-task-progress-fill {
display: block;
height: 100%;
border-radius: 2px;
background: var(--df-success);
transition: width .4s var(--df-ease);
}
@keyframes ai-task-progress-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
/* U2 静默期档位(设计 §3.4):长执行/警示=脉冲降速——活性层表达「慢」而非伪装「停」,
不自动判死;内容层/静默档文字用弱化色,视觉从属位置层数字 */
.ai-task-progress--long .ai-task-progress-dot,
.ai-task-progress--warn .ai-task-progress-dot {
animation-duration: 3.2s;
}
/* 警示档(静默>10min)状态点转琥珀:给判断材料,不自动判死 */
.ai-task-progress--warn .ai-task-progress-dot {
background: var(--df-warning);
}
.ai-task-progress-stage {
font-size: 11px;
color: var(--df-text-dim);
}
.ai-task-progress-silence {
font-size: 11px;
color: color-mix(in srgb, var(--df-warning) 70%, var(--df-text-secondary));
}
</style>
@@ -0,0 +1,101 @@
<template>
<!-- TopBar 顶部窗口控制按钮组(c1c 拆分自 TopBar.vue,零行为变更)。
多根 fragment:按钮平铺进父 .ai-header-actions flex,与拆分前 DOM 同构。 -->
<!-- 嵌入模式:最大化/还原 + 分离 + 关闭 -->
<template v-if="!detached">
<button class="ai-btn-icon" @click="emit('toggle-maximize')" :title="store.state.maximized ? $t('aiChat.restoreSidebar') : $t('aiChat.maximize')" :aria-label="store.state.maximized ? $t('aiChat.restoreSidebar') : $t('aiChat.maximize')">
<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')" :aria-label="$t('aiChat.detachWindow')">
<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')" :aria-label="$t('aiChat.closePanel')">
<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')" :aria-label="$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')" :aria-label="store.state.docked ? $t('aiChat.undock') : $t('aiChat.dockRight')">
<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')" :aria-label="alwaysOnTop ? $t('aiChat.unpin') : $t('aiChat.pinOnTop')">
<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')" :aria-label="$t('aiChat.closeWindow')">
<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>
<script setup lang="ts">
import { useAiStore } from '../../../stores/ai'
defineProps<{
/** 分离窗口模式(嵌入 vs 分离决定按钮组) */
detached?: boolean
/** 分离窗口置顶态(父持有,经 prop 透传按钮 active 态) */
alwaysOnTop: boolean
}>()
const emit = defineEmits<{
/** 最大化/还原(store.toggleMaximize) */
(e: 'toggle-maximize'): void
/** 分离窗口(store.detachPanel) */
(e: 'detach'): void
/** 关闭面板(store.togglePanel) */
(e: 'close-panel'): void
/** V11b 收回分离窗回主面板(父 emitReattachToMain 广播,主窗 reattachPanel 执行) */
(e: 'reattach'): void
/** 吸附/取消吸附(store.dockDetached) */
(e: 'dock'): void
/** 置顶切换(父 toggleAlwaysOnTop 调 Tauri setAlwaysOnTop) */
(e: 'toggle-always-on-top'): void
/** 关闭分离窗口(store.closeDetachedWindow) */
(e: 'close-detached'): void
}>()
const store = useAiStore()
</script>
<style scoped>
/* ── 图标按钮(共享工具类):wave2 样式仅存在于原 TopBar scoped(全局 components.css
副本为抽取时旧样式,逐字不一致),拆分子件各自带本副本防视觉回归 ── */
.ai-btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-md);
background: var(--df-bg-card);
color: var(--df-text-dim);
cursor: pointer;
transition: border-color 0.15s var(--df-ease), color 0.15s var(--df-ease), background 0.15s var(--df-ease);
}
.ai-btn-icon:hover {
border-color: var(--df-border-strong);
background: var(--df-bg-card-hover);
color: var(--df-text-secondary);
}
.ai-btn-icon--active {
border-color: color-mix(in srgb, var(--df-accent) 45%, transparent);
background: var(--df-accent-bg);
color: var(--df-accent);
}
.ai-btn-icon:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.ai-btn-icon:disabled:hover {
border-color: var(--df-border);
background: var(--df-bg-card);
color: var(--df-text-dim);
}
</style>
+17
View File
@@ -0,0 +1,17 @@
// TopBar 拆分子件共享类型(从 TopBar.vue <script setup> 内联接口上移,零行为变更)。
// 单一来源:父(TopBar)派生数据与子件 props 共用同一形状,避免双处声明漂移。
import type { GoalEntry } from '../../../api/types'
/** 底部「历史消息」面板项(user 消息截断摘要,fmtTime 时间戳) */
export interface HistoryItem { id: string; text: string; time: string; num: number }
/** 底部「上下文摘要」面板项(压缩/归档 system 消息截断摘要) */
export interface SummaryItem { id: string; text: string }
/** L3 完整上下文面板数据(系统提示 + 增强上下文两节) */
export interface ContextInfo {
goals: GoalEntry[]
summaryCount: number
osInfo: string
projectNames: string
}