新增: F-15阶段2手动上下文管理(2 IPC+3事件+前端按钮+status渲染)

This commit is contained in:
2026-06-17 01:09:41 +08:00
parent 7fd29e68c3
commit 4194842561
10 changed files with 751 additions and 36 deletions

View File

@@ -251,6 +251,28 @@
<button class="ai-btn-icon" @click="confirmNewConversation" :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"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
</button>
<!-- F-15 阶段2: 清空上下文(历史消息归档保留,不删 DB;新对话不受影响)
空会话/全归档/生成中禁用(无活跃消息时不发起无意义 IPC) -->
<button
class="ai-btn-icon"
:disabled="!hasActiveMessages || store.state.streaming"
:title="$t('aiChat.clearContext')"
@click="handleClearContext"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="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>
</button>
<!-- F-15 阶段2: 压缩上下文(LLM 摘要落 system + 历史消息标 compressed 归档)
loading(isCompressing)时禁用 + 显示 spinner;/全归档/生成中禁用 -->
<button
class="ai-btn-icon"
:class="{ 'ai-btn-icon--active': store.isCompressing.value }"
:disabled="!hasActiveMessages || store.state.streaming || store.isCompressing.value"
:title="store.isCompressing.value ? $t('aiChat.compressing') : $t('aiChat.compressContext')"
@click="handleCompressContext"
>
<svg v-if="!store.isCompressing.value" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="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="13" height="13" 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>
</button>
<!-- 清空对话(真删 DB messages,带二次确认防误删) -->
<button class="ai-btn-icon" @click="confirmClearChat" :title="$t('aiChat.clearChat')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg>
@@ -339,40 +361,67 @@
</template>
</div>
<!-- 消息列表 -->
<div
v-for="msg in store.state.messages"
:key="msg.id"
class="ai-msg"
:class="'ai-msg--' + msg.role"
:data-msg-id="msg.id"
>
<!-- 消息列表(F-15 阶段2: renderItems 扁平渲染 折叠段 emit 'sep' ,
正常消息 + 展开的折叠段内消息 emit 'msg' 连续 archived_segment/compressed
合并一条分隔条,点击展开/收起看原文) -->
<template v-for="item in renderItems" :key="item.key">
<!-- 折叠分隔条(归档段 / 压缩段) -->
<div
v-if="item.kind === 'sep'"
class="ai-msg-segment"
: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">
<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>
<template v-else>
<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"/>
</template>
</svg>
<span class="ai-msg-segment-text">
<template v-if="item.seg.kind === 'archived'">{{ $t('aiChat.contextArchived', { n: item.seg.msgs.length }) }}</template>
<template v-else>{{ $t('aiChat.compressed') }}</template>
</span>
<span class="ai-msg-segment-toggle">
{{ expandedSegmentIds.has(item.seg.key) ? $t('aiChat.collapse') : $t('aiChat.expand') }}
</span>
</div>
<!-- 消息(正常段 + 展开的折叠段内消息) -->
<div
v-else
class="ai-msg"
:class="'ai-msg--' + item.msg.role"
:data-msg-id="item.msg.id"
>
<!-- 用户消息 -->
<div v-if="msg.role === 'user'" class="ai-msg-user">
<div v-if="item.msg.role === 'user'" 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>
</div>
<div class="ai-msg-bubble ai-msg-bubble--user">
{{ msg.content }}
{{ item.msg.content }}
<button
class="ai-copy-btn"
:title="$t('aiChat.copyMsg')"
@click.stop="copyMsgContent(msg)"
@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>
</button>
</div>
<!-- UX-09:末条 user 消息编辑按钮(非生成中非当前编辑态)hover 显示 -->
<button
v-if="isLastUser(msg) && !store.state.streaming && !editingMsgId"
v-if="isLastUser(item.msg) && !store.state.streaming && !editingMsgId"
class="ai-msg-edit-btn"
:title="$t('aiChat.editMessage')"
@click.stop="startEdit(msg)"
@click.stop="startEdit(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="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>
<!-- UX-2025-13:消息时间戳(相对时间+hover绝对时间) -->
<span class="ai-msg-time ai-msg-time--user" :title="formatDate(msg.timestamp)">{{ formatRelativeZh(msg.timestamp) }}</span>
<span class="ai-msg-time ai-msg-time--user" :title="formatDate(item.msg.timestamp)">{{ formatRelativeZh(item.msg.timestamp) }}</span>
</div>
<!-- AI 消息 -->
@@ -384,29 +433,29 @@
<!-- 文本内容流式或固定Markdown 渲染 -->
<!-- UX-2025-01:流式分块v-for,已完成块DOM稳定不重建选文字保持 -->
<div
v-if="msg.content || (isLastAi(msg) && store.state.currentText)"
v-if="item.msg.content || (isLastAi(item.msg) && store.state.currentText)"
class="ai-msg-bubble ai-msg-bubble--ai ai-md"
:class="{ 'ai-msg-bubble--error': msg.isError }"
:key="'md-' + msg.id + '-' + (isLastAi(msg) ? _mdRenderKey : 0)"
:class="{ 'ai-msg-bubble--error': item.msg.isError }"
:key="'md-' + item.msg.id + '-' + (isLastAi(item.msg) ? _mdRenderKey : 0)"
>
<!-- 流式:分块渲染(已完成块key稳定DOM不重建) -->
<template v-if="isLastAi(msg) && store.state.streaming && store.state.currentText">
<template v-if="isLastAi(item.msg) && store.state.streaming && store.state.currentText">
<div v-for="block in streamingBlocks" :key="block.key" v-html="block.html"></div>
<span v-if="isViewingGenerating" class="ai-cursor"></span>
</template>
<!-- 完成/历史:整段v-html(不变) -->
<div v-else v-html="renderContent(msg)"></div>
<div v-else v-html="renderContent(item.msg)"></div>
<button
v-if="msg.content"
v-if="item.msg.content"
class="ai-copy-btn ai-copy-btn--ai"
:title="$t('aiChat.copyMsg')"
@click.stop="copyMsgContent(msg)"
@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>
</button>
</div>
<!-- 流式开始但尚无文本时显示光标 -->
<div v-else-if="isLastAi(msg) && isViewingGenerating" class="ai-msg-bubble ai-msg-bubble--ai">
<div v-else-if="isLastAi(item.msg) && isViewingGenerating" class="ai-msg-bubble ai-msg-bubble--ai">
<span class="ai-cursor"></span>
</div>
@@ -414,19 +463,19 @@
仅非错误有内容且非流式生成中的 AI 消息显示
重新生成仅最后一条 AI 消息显示(语义:重生成末尾回复) -->
<div
v-if="msg.content && !msg.isError && !store.state.streaming"
v-if="item.msg.content && !item.msg.isError && !store.state.streaming"
class="ai-msg-actions"
>
<button
class="ai-msg-act"
:title="$t('aiChat.copyMsg')"
@click.stop="copyMsgContent(msg)"
@click.stop="copyMsgContent(item.msg)"
>
<svg width="11" height="11" 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>
<span>{{ $t('aiChat.copyMsg') }}</span>
</button>
<button
v-if="isLastAi(msg)"
v-if="isLastAi(item.msg)"
class="ai-msg-act"
:disabled="store.state.streaming"
:title="$t('aiChat.regenerate')"
@@ -443,12 +492,12 @@
去设置 error_type {auth, provider_config} 显示;
Fatal 时附 notRetryable 提示,说明为何无重试按钮 -->
<div
v-if="msg.isError && !store.state.streaming"
v-if="item.msg.isError && !store.state.streaming"
class="ai-msg-actions ai-msg-actions--error"
>
<span v-if="!canRetry(msg)" class="ai-msg-not-retryable">{{ $t('ai.notRetryable') }}</span>
<span v-if="!canRetry(item.msg)" class="ai-msg-not-retryable">{{ $t('ai.notRetryable') }}</span>
<button
v-if="canRetry(msg)"
v-if="canRetry(item.msg)"
class="ai-msg-act ai-msg-act--primary"
:disabled="store.state.streaming"
@click.stop="handleRetry"
@@ -457,7 +506,7 @@
<span>{{ $t('aiChat.retry') }}</span>
</button>
<button
v-if="canOpenSettings(msg)"
v-if="canOpenSettings(item.msg)"
class="ai-msg-act"
@click.stop="goToSettings"
>
@@ -469,7 +518,7 @@
<!-- token 用量(仅最后一条 AI 消息 + 非流式;ai.ts handleEvent 已按 df-show-token-usage 开关过滤 lastTokenUsage) -->
<div
v-if="isLastAi(msg) && !store.state.streaming && store.state.lastTokenUsage"
v-if="isLastAi(item.msg) && !store.state.streaming && store.state.lastTokenUsage"
class="ai-token-usage"
>
<span class="ai-token-usage-icon">🔣</span>
@@ -478,22 +527,23 @@
<!-- 工具调用卡片(渲染/折叠/审批全下沉到 ToolCardList+ToolCard 子组件,AiChat 仅转发审批) -->
<ToolCardList
v-if="msg.toolCalls && msg.toolCalls.length > 0"
v-if="item.msg.toolCalls && item.msg.toolCalls.length > 0"
ref="toolCardListRef"
:toolCalls="msg.toolCalls"
:toolCalls="item.msg.toolCalls"
@approve="({ id, approved }) => store.approveToolCall(id, approved)"
@batch-approve="(decision) => store.batchApprove(decision)"
/>
<!-- UX-2025-13:消息时间戳(相对时间+hover绝对时间),流式生成中不显示避免抖动 -->
<span
v-if="!(isLastAi(msg) && store.state.streaming)"
v-if="!(isLastAi(item.msg) && store.state.streaming)"
class="ai-msg-time"
:title="formatDate(msg.timestamp)"
>{{ formatRelativeZh(msg.timestamp) }}</span>
:title="formatDate(item.msg.timestamp)"
>{{ formatRelativeZh(item.msg.timestamp) }}</span>
</div>
</div>
</div>
</div>
</template>
</div>
<!-- 回到底部(上滑看历史 / 有新内容时显示) -->
@@ -1760,6 +1810,8 @@ onBeforeUnmount(() => {
// 之前 stopListener 定义但全仓零调用 → listener+watchdog 永久泄漏;分离窗口主窗口 AiChat
// 卸载(走 App.vue v-if detach)时 listener 仍挂后端,watchdog 130s 后写已卸载 state。
store.stopListener()
// F-15 阶段2: 释放上下文管理事件监听器
store.stopContextListener()
if (_unlistenToolSlow) { _unlistenToolSlow(); _unlistenToolSlow = null } // B-260616-12
if (_toastTimer) { clearTimeout(_toastTimer); _toastTimer = null }
// UX-2025-20:释放标题淡入计时器
@@ -1849,6 +1901,8 @@ onMounted(async () => {
_unlistenToolSlow = await listen<{ name: string }>('ai-tool-slow-toast', (e) => {
showToast(t('aiTool.executionSlow', { name: e.payload.name }), 'warning')
})
// F-15 阶段2: 注册上下文管理事件监听器(清空/压缩生命周期事件 → toast/刷新)
await initContextEventListeners()
// 分离模式:接管主窗口当前对话(含生成中态),保持正在进行的对话
if (props.detached) {
const params = new URLSearchParams(location.hash.split('?')[1] || '')
@@ -2145,6 +2199,183 @@ async function handleStopLoop(): Promise<void> {
watch(() => pendingMaxRounds.value, (v) => {
if (!v) maxRoundsActing.value = false
})
// ── F-15 阶段2: 手动上下文管理(清空 / 压缩) ──
// ChatMessage.status 渲染语义(types.ts 未含 status 字段,前端经 cast 读写闭环):
// null | undefined | 'active' → 正常消息(现状)
// 'archived_segment' → 清空上下文归档标记 → 折叠成"已归档 N 条"分隔条
// 'compressed' → 压缩标记 → 折叠成"已压缩(展开看摘要)"块
// 'truncated' → switchConversation 已过滤,不到这里
// 连续同 status 合并一条分隔条(避免每条都折叠条 → N 条归档只显 1 条分隔条)。
interface AiMessageWithStatus extends AiMessage {
status?: string | null
}
/** 判断消息是否"折叠态"(归档段 / 压缩段) */
function isFoldedStatus(msg: AiMessage): boolean {
const s = (msg as AiMessageWithStatus).status
return s === 'archived_segment' || s === 'compressed'
}
/**
* 消息列表分段预处理 — 连续同 status 合并成一条"段"。
*
* 段类型:
* { kind:'normal', msg } — 单条正常消息(保持现状渲染)
* { kind:'archived', key, msgs: [...] } — 连续 archived_segment 合并,折叠成分隔条
* { kind:'compressed', key, msgs: [...] }— 连续 compressed 合并,折叠成摘要块
*
* 设计:用 computed 自动随 messages 变化重算;展开/收起由 expandedSegmentIds 控制
* (段 key=首条消息 id,展开时内联渲染段内每条消息,复用现有 user/ai 气泡模板)。
*
* 注意:折叠段展开时内部消息按原样渲染(走 normal 段的同款分支),
* 故抽出 renderSingleMessage(msg) 复用——但 Vue 模板无函数式组件,
* 此处用 v-if 分支在段内展开块中复刻 user/ai 渲染逻辑(模板侧实现)。
*/
type MessageSegment =
| { kind: 'normal'; msg: AiMessage }
| { kind: 'archived'; key: string; msgs: AiMessage[] }
| { kind: 'compressed'; key: string; msgs: AiMessage[] }
const messageSegments = computed<MessageSegment[]>(() => {
const msgs = store.state.messages
const segments: MessageSegment[] = []
let i = 0
while (i < msgs.length) {
const m = msgs[i]
const status = (m as AiMessageWithStatus).status
if (status === 'archived_segment' || status === 'compressed') {
// 连续同 status 合并
const group: AiMessage[] = []
const key = `seg-${m.id}`
while (i < msgs.length && (msgs[i] as AiMessageWithStatus).status === status) {
group.push(msgs[i])
i++
}
segments.push(status === 'archived_segment'
? { kind: 'archived', key, msgs: group }
: { kind: 'compressed', key, msgs: group })
} else {
segments.push({ kind: 'normal', msg: m })
i++
}
}
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
}
/**
* 扁平渲染项 — 把 segments 展开成 v-for 可直接遍历的扁平列表,避免模板内双重 v-for
* + 消息块复制(原消息块约 150 行,复制展开分支代价过大且易漂移)。
*
* 项类型:
* { kind:'msg', key, msg } — 渲染单条消息(走原 user/ai 气泡块,模板 v-if seg.kind==='msg')
* { kind:'sep', key, seg } — 渲染分隔条(折叠态标题/计数 + 展开/收起按钮)
*
* 规则:折叠段未展开时只 emit sep 项(段内消息隐藏);展开后 emit sep + 段内每条 msg 项。
* 正常段 emit msg 项。messages 变化或展开态切换都触发重算(computed 依赖两者)。
*/
type RenderItem =
| { kind: 'msg'; key: string; msg: AiMessage }
| { kind: 'sep'; key: string; seg: MessageSegment & { key: string } }
const renderItems = computed<RenderItem[]>(() => {
const items: RenderItem[] = []
for (const seg of messageSegments.value) {
if (seg.kind === 'normal') {
items.push({ kind: 'msg', key: 'm-' + seg.msg.id, msg: seg.msg })
} else {
// 折叠段:总 emit sep;展开时再 emit 段内每条 msg
items.push({ kind: 'sep', key: seg.key, seg })
if (expandedSegmentIds.value.has(seg.key)) {
for (const m of seg.msgs) {
items.push({ kind: 'msg', key: 'e-' + seg.key + '-' + m.id, msg: m })
}
}
}
}
return items
})
/**
* 是否存在"活跃消息"(非折叠态)— 控制清空/压缩按钮启用。
* 空会话 / 全部已归档/压缩 → 无活跃消息 → 按钮禁用(不发起无意义 IPC)。
* 折叠段内的消息不计(它们已脱离当前上下文)。
*/
const hasActiveMessages = computed(() =>
store.state.messages.some(m => !isFoldedStatus(m)),
)
/**
* 清空当前对话上下文(二次确认 → aiApi.clearContext)。
* 历史消息后端标 archived_segment 归档保留,DB 不删;新对话不受影响。
*/
async function handleClearContext(): Promise<void> {
const convId = store.state.activeConversationId
if (!convId) return
if (store.state.streaming) return // 生成中禁操作,避免与流式事件竞争
if (!hasActiveMessages.value) return // 无活跃消息(空/全归档)禁操作
if (!await confirmDialog(t('aiChat.clearContextConfirm'))) return
try {
await store.clearContext()
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
showToast(t('aiChat.clearError', { msg }), 'error')
}
}
/**
* 压缩当前对话上下文(LLM 摘要落 system + 历史消息标 compressed)。
* loading 由 store.isCompressing 控制(防重复点击);失败走 ai_error 事件或 catch。
*/
async function handleCompressContext(): Promise<void> {
if (store.isCompressing.value) return // loading 中禁重复点击
if (store.state.streaming) return // 生成中禁操作
if (!hasActiveMessages.value) return // 无活跃消息禁操作
try {
await store.compressContext()
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
showToast(t('aiChat.compressError', { msg }), 'error')
}
}
/**
* 注册上下文管理事件监听器(onMounted 调),注入 toast 副作用回调。
* - onCleared:toast 成功 + 刷新消息列表(后端会重发 conversation_changed,但本地立刷更及时)
* - onCompressed:toast 成功(摘要 system 消息由后端 emit 后经 conversation_changed 刷新回填)
* - onError:toast 错误(isCompressing 已在监听器内复位)
*/
async function initContextEventListeners(): Promise<void> {
await store.initContextListener({
onCleared: () => {
showToast(t('aiChat.clearSuccess'), 'info')
// 后端 emit ai_context_cleared 后会重置 session.messages,前端刷对话拉最新状态。
// 守卫:事件到达前用户可能切走,activeConversationId 变 null,跳过刷新避免空指针。
if (store.state.activeConversationId) {
void store.switchConversation(store.state.activeConversationId)
}
},
onCompressed: (_convId, _summary) => {
showToast(t('aiChat.compressSuccess'), 'info')
// 刷新对话(历史消息落库标 compressed + system 摘要消息回填到视图)
if (store.state.activeConversationId) {
void store.switchConversation(store.state.activeConversationId)
}
},
onError: (_convId, message) => {
showToast(t('aiChat.compressError', { msg: message }), 'error')
},
})
}
</script>
<style scoped>
@@ -3300,6 +3531,62 @@ body.ai-sidebar-resizing * {
opacity: 0.6;
}
/* ═══ F-15 阶段2: 折叠分隔条(归档段 / 压缩段) ═══
连续 archived_segment/compressed 消息合并成一条分隔条;点击展开看原文。
风格对齐 .ai-conv-group-title(dim 小字 + 上下留白),不抢正常消息视觉焦点。 */
.ai-msg-segment {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
margin: 2px 0;
background: color-mix(in srgb, var(--df-text-dim) 8%, transparent);
border: 0.5px dashed color-mix(in srgb, var(--df-text-dim) 35%, transparent);
border-radius: var(--df-radius);
font-size: 11px;
color: var(--df-text-dim);
cursor: pointer;
user-select: none;
transition: background 0.15s var(--df-ease), border-color 0.15s var(--df-ease);
}
.ai-msg-segment:hover {
background: color-mix(in srgb, var(--df-text-dim) 14%, transparent);
border-color: color-mix(in srgb, var(--df-text-dim) 50%, transparent);
color: var(--df-text-secondary);
}
.ai-msg-segment--compressed {
/* 压缩段与归档段视觉区分:accent 色调(压缩是 LLM 产出的摘要,更"主动") */
background: color-mix(in srgb, var(--df-accent) 8%, transparent);
border-color: color-mix(in srgb, var(--df-accent) 30%, transparent);
color: var(--df-accent);
}
.ai-msg-segment--compressed:hover {
background: color-mix(in srgb, var(--df-accent) 14%, transparent);
border-color: color-mix(in srgb, var(--df-accent) 45%, transparent);
}
.ai-msg-segment-icon { flex-shrink: 0; }
.ai-msg-segment-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ai-msg-segment-toggle { flex-shrink: 0; font-size: 10px; opacity: 0.8; }
/* ── F-15 阶段2: 按钮禁用态(清空/压缩上下文,空会话/全归档/生成中)──
复用现有 .ai-btn-icon:hover 风格,禁用时降透明度 + not-allowed,不发 hover 高亮。 */
.ai-btn-icon:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.ai-btn-icon:disabled:hover {
background: transparent;
color: var(--df-text-dim);
}
/* 压缩中 spinner 图标旋转动画 */
.ai-btn-spinner {
animation: ai-btn-spin 0.9s linear infinite;
}
@keyframes ai-btn-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* ═══ 错误 toast(复用 Settings.vue .toast 样式,前缀 ai- 防 scoped 冲突) ═══ */
.ai-toast {
position: absolute;