新增: 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

@@ -384,6 +384,191 @@ pub async fn ai_chat_clear(state: State<'_, AppState>) -> Result<(), String> {
Ok(())
}
/// 手动上下文分段(F-15 阶段2):归档保护区外消息,不删 DB。
///
/// 把保护区外(active)的消息经 messages_mut 标 `status="archived_segment"`
/// (is_active 自动 false,sanitize 隔离不进 LLM 上下文,前端按 is_active 过滤折叠),
/// 保护区最近 N 条 active 不动(用户当前上下文连续性)。落库持久化新 status,不删 DB。
///
/// 空会话/无 active 可分段消息 → emit AiContextCleared 后 noop 返回 Ok(不报错)。
///
/// 与 ai_chat_clear 区别:clear 真删 DB 全清;本命令只软分段(归档),保留可追溯历史。
#[tauri::command]
pub async fn ai_chat_clear_context(
app: AppHandle,
state: State<'_, AppState>,
conversation_id: String,
) -> Result<(), String> {
// 活跃对话一致性:仅对当前活跃对话分段(防陈旧快照标错对话的消息)。
// 与 ai_chat_send/ai_regenerate 取 session 模式一致(state.ai_session.lock)。
let conv_id = {
let mut session = state.ai_session.lock().await;
if session.active_conversation_id.as_deref() != Some(conversation_id.as_str()) {
return Err("对话已切换,无法分段".to_string());
}
// 保护区:保留最近 PROTECT_COUNT 条 active。
// PROTECT_COUNT 是 df-ai context.rs 私有常量(=6,build_for_request 同款保护区),
// 未导出到 crate 外,此处用同值字面量 + 注释指明出处,与 build_for_request 语义一致。
const PROTECT_COUNT: usize = 6;
let protect_start = session.messages.len().saturating_sub(PROTECT_COUNT);
// 空会话/全在保护区(消息 ≤ N 条) → 无可分段消息,emit 后 noop
if protect_start == 0 {
drop(session);
let _ = app.emit("ai-chat-event", AiChatEvent::AiContextCleared {
conversation_id: Some(conversation_id.clone()),
});
return Ok(());
}
// 对保护区外 active 消息标 archived_segment(messages_mut 直接改 status)。
// history_tokens 同步:ContextManager 未暴露 history_tokens 写 setter,但 push/compress_old_messages
// 内部用 saturating_sub 维护。此处经 messages_mut 改 status 后 history_tokens 会与 active 集脱钩,
// 但 build_for_request 超预算裁剪路径仍按 history_tokens 决策——为保 token 预算一致性,
// 采用"扣减后等价"的近似:记录被标 archived_segment 消息的 token,经临时 helper 修正。
// df-ai 未提供 history_tokens 减法 API,故此处保留与 compress_old_messages 一致的口径——
// 不直接改 history_tokens(私有字段,无 setter);token 与 active 集可能短暂脱钩,
// 下次 build_for_request 时若 history_tokens 偏高只会触发更早裁剪(保守,不劣化安全性)。
for t in session.messages.messages_mut()[..protect_start].iter_mut() {
if t.message.is_active() {
t.message.status = Some("archived_segment".to_string());
}
}
drop(session);
conversation_id
};
// 落库持久化新 status(照 save_conversation 模式,DB 持久化新 status)
save_conversation(&state.ai_session, &state.db, &conv_id, None, None).await;
let _ = app.emit("ai-chat-event", AiChatEvent::AiContextCleared {
conversation_id: Some(conv_id),
});
Ok(())
}
/// 手动 LLM 压缩上下文(F-15 阶段2):摘要保护区外消息并插入首位。
///
/// 流程(失败不阻塞,消息状态不变):
/// 1. emit AiCompressing + set_compressing(true) 防重入
/// 2. 算 compress_end(保护区外,同 clear_context 的 protect_start)
/// 3. 先取保护区外 active 消息的克隆(读不改)→ 喂 compress_via_llm
/// 4. LLM 成功 → compress_old_messages 标 compressed(扣 token) + insert_at(0, system 摘要)
/// 5. LLM 失败 → set_compressing(false) + emit AiError(不含 api_key) + 返回 Err;消息状态不变
///
/// 空会话/无 active 可压缩消息 → set_compressing(false) 后 emit AiCompressing noop 返回 Ok。
#[tauri::command]
pub async fn ai_chat_compress_context(
app: AppHandle,
state: State<'_, AppState>,
conversation_id: String,
language: Option<String>,
) -> Result<(), String> {
let provider_config = super::prompt::get_active_provider(&state).await?;
const PROTECT_COUNT: usize = 6;
let lang = language.unwrap_or_else(|| "zh-CN".to_string());
// 取 active 克隆 + compress_end(读不改,LLM 失败则消息状态完全不变)
let (conv_id, active_msgs, compress_end) = {
let mut session = state.ai_session.lock().await;
if session.active_conversation_id.as_deref() != Some(conversation_id.as_str()) {
return Err("对话已切换,无法压缩".to_string());
}
if session.messages.is_compressing() {
return Err("压缩正在进行中".to_string());
}
// emit 开始 + 置位防重入(成对释放,见下方所有出口)
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompressing {
conversation_id: Some(conversation_id.clone()),
});
session.messages.set_compressing(true);
let protect_start = session.messages.len().saturating_sub(PROTECT_COUNT);
if protect_start == 0 {
// 空会话/全在保护区 → 无可压缩消息,set_compressing(false) 后返回 Ok
session.messages.set_compressing(false);
let cid = session.active_conversation_id.clone();
drop(session);
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompressed {
conversation_id: cid,
summary: String::new(),
});
return Ok(());
}
// 读 active 克隆(不改 status):compress_old_messages 留到 LLM 成功后才调
let active_msgs: Vec<ChatMessage> = session
.messages
.messages_mut()[..protect_start]
.iter()
.filter(|t| t.message.is_active())
.map(|t| t.message.clone())
.collect();
let cid = session.active_conversation_id.clone().unwrap_or_else(|| conversation_id.clone());
(cid, active_msgs, protect_start)
};
if active_msgs.is_empty() {
// 保护区外无 active 消息(已全 compressed/archived_segment/truncated) → noop
let mut session = state.ai_session.lock().await;
session.messages.set_compressing(false);
drop(session);
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompressed {
conversation_id: Some(conv_id),
summary: String::new(),
});
return Ok(());
}
// 拿 provider(照 ai_chat_send 的 build_provider_for 模式,密钥经 resolve_provider_secret 闭环,
// FR-S1 api_key 绝不进 payload/日志/错误信息)
let provider = match super::secret::build_provider_for(&provider_config) {
Ok(p) => p,
Err(e) => {
let mut session = state.ai_session.lock().await;
session.messages.set_compressing(false);
drop(session);
let _ = app.emit("ai-chat-event", AiChatEvent::AiError {
error: format!("压缩失败: {}", e),
error_type: Some(super::ErrorType::Auth),
conversation_id: Some(conv_id),
});
return Err(format!("压缩失败: {}", e));
}
};
let llm_concurrency = state.llm_concurrency.clone();
let summary = match super::compress::compress_via_llm(
provider.as_ref(),
&provider_config,
active_msgs,
&lang,
&llm_concurrency,
).await {
Ok(s) => s,
Err(e) => {
// LLM 失败:不阻塞,set_compressing(false),消息状态不变(未调 compress_old_messages)
let mut session = state.ai_session.lock().await;
session.messages.set_compressing(false);
drop(session);
let _ = app.emit("ai-chat-event", AiChatEvent::AiError {
error: format!("压缩失败: {}", e),
error_type: Some(super::ErrorType::Unknown),
conversation_id: Some(conv_id),
});
return Err(format!("压缩失败: {}", e));
}
};
// LLM 成功 → 标 compressed(扣 token) + 摘要插首位
{
let mut session = state.ai_session.lock().await;
let _compressed = session.messages.compress_old_messages(compress_end);
session.messages.insert_at(0, ChatMessage::system(&summary));
session.messages.set_compressing(false);
}
save_conversation(&state.ai_session, &state.db, &conv_id, None, None).await;
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompressed {
conversation_id: Some(conv_id),
summary,
});
Ok(())
}
/// 编辑最后一条 user 消息并重新生成UX-09
///
/// 流程(复用 ai_regenerate 的 spawn 模式):占用 generating → 校验活跃对话一致 →

View File

@@ -140,6 +140,13 @@ pub enum AiChatEvent {
/// 每次重试前 emit前端可在错误气泡内显示「重试 n/m」。
/// attempt 从 1 开始(首次失败后第一次重试=attempt 1)max_attempts = max_retries + 1。
AiStreamRetry { attempt: u32, max_attempts: u32, conversation_id: Option<String> },
/// F-15 阶段2 手动上下文分段完成:会话归档不删(archived_segment 标记),
/// 前端可据此刷新消息视图(归档段折叠/隐藏)。
AiContextCleared { conversation_id: Option<String> },
/// F-15 阶段2 手动 LLM 压缩开始:前端展示压缩中态(spinner/禁用按钮防重入)。
AiCompressing { conversation_id: Option<String> },
/// F-15 阶段2 手动 LLM 压缩完成:摘要已插入消息首位,前端可展示摘要 + 移除压缩中态。
AiCompressed { conversation_id: Option<String>, summary: String },
}
// ============================================================

View File

@@ -118,6 +118,9 @@ pub fn run() {
commands::ai::ai_pending_tool_calls,
commands::ai::ai_chat_clear,
commands::ai::ai_is_generating,
// F-15 阶段2 手动上下文管理:分段归档 + LLM 压缩
commands::ai::ai_chat_clear_context,
commands::ai::ai_chat_compress_context,
commands::ai::ai_list_providers,
commands::ai::ai_save_provider,
commands::ai::ai_set_provider,

View File

@@ -90,6 +90,25 @@ export const aiApi = {
return invoke('ai_chat_stop')
},
// ── F-15 阶段2: 手动上下文管理(清空 / 压缩) ──
// 后端经 ai-chat-event emit 生命周期事件:
// ai_context_cleared { conversation_id } — 清空完成
// ai_compressing { conversation_id } — 压缩开始(loading)
// ai_compressed { conversation_id, summary } — 压缩成功(摘要随事件到前端)
// ai_error { message, conversation_id } — 压缩失败
// 历史消息落库时 status 标 archived_segment(清空)/ compressed(压缩),
// 前端按 status 折叠成"已归档/已压缩"分隔条(可展开看原文)。
/** 清空当前对话上下文(历史消息归档保留,DB 不删;新对话不受影响) */
clearContext(conversationId: string): Promise<void> {
return invoke('ai_chat_clear_context', { conversationId })
},
/** 压缩当前对话上下文(LLM 摘要落 system 消息 + 历史消息标 compressed 归档) */
compressContext(conversationId: string, language: string): Promise<void> {
return invoke('ai_chat_compress_context', { conversationId, language })
},
/** 列出所有 AI 提供商 */
listProviders(): Promise<AiProviderConfig[]> {
return invoke('ai_list_providers')

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;

View File

@@ -0,0 +1,168 @@
//! F-15 阶段2: 手动上下文管理 — 清空 / 压缩
//!
//! 职责:
//! - clearContext():二次确认 → aiApi.clearContext(activeConversationId)
//! - compressContext():aiApi.compressContext(activeConversationId, locale)
//! + isCompressing ref(loading,防重复点击)
//! - 模块级 listener:消费后端 emit 的 ai-chat-event 生命周期事件:
//! ai_compressing → isCompressing=true
//! ai_compressed → isCompressing=false + (成功 toast 由调用方弹,摘要已在事件里)
//! ai_context_cleared → (刷新提示由调用方弹)
//! ai_error → isCompressing=false + (错误 toast 由调用方弹)
//!
//! 设计:
//! - 与 useAiEvents.startListener 共存:后者监听 AiChatEvent 联合类型(不含
//! ai_compressing/ai_compressed/ai_context_cleared,因 types.ts 不在本任务白名单),
//! 本模块注册第二个 listen('ai-chat-event') 监听器,专门按 type 字符串分发上述 4 类事件。
//! 两个监听器都触发同一后端事件,但各取所需类型,Tauri 事件多 listener 是合法的。
//! - toast/刷新副作用经回调注入(composable 无组件上下文,无 toast ref),
//! AiChat.vue 调 initContextListener(onCleared/onCompressed/onError) 注入副作用,
//! 避免循环依赖(composable import 组件 toast)。
//! - listener 幂等:已注册则直接返回(对齐 useAiEvents.startListener 的并发去重)。
//!
//! 耦合:
//! - aiApi.clearContext / aiApi.compressContext(IPC)
//! - state.activeConversationId(读当前对话)
//! - appSettings.get(df-ai-language/df-language)(压缩语言;与 useAiSend.sendMessage 同源)
import { ref } from 'vue'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai'
const appSettings = useAppSettingsStore()
/// 压缩中 loading(防重复点击;ai_compressing→true / ai_compressed|ai_error→false)
const isCompressing = ref(false)
/// 已注册的 ai-chat-event 第二监听器(生命周期事件);幂等
let _unlistenContext: UnlistenFn | null = null
/// 副作用回调(由 AiChat.vue 注入:toast / 刷新 / 摘要渲染)
/// 用对象形式便于选择性注入(只压成功了才需 onCompressed)
interface ContextCallbacks {
/** 清空完成(ai_context_cleared):toast + 刷新消息列表 */
onCleared?: (conversationId: string) => void
/** 压缩成功(ai_compressed):toast + 渲染摘要 system 消息 */
onCompressed?: (conversationId: string, summary: string) => void
/** 压缩失败 / 错误(ai_error with conversation_id):toast */
onError?: (conversationId: string, message: string) => void
}
let _callbacks: ContextCallbacks = {}
/**
* 注册 ai-chat-event 生命周期事件监听器(幂等)。
*
* AiChat.vue onMounted 调一次,注入 toast/刷新副作用回调。
* 不能在模块加载时副作用注册(组件未挂载,listener 仍生效但回调为空 → 无意义);
* 必须在 AiChat 挂载后注入回调再注册,保证回调已就绪。
*
* 事件载荷是任意对象(新 type 未进 AiChatEvent 联合,types.ts 不在本任务白名单),
* 这里按 type 字符串结构判定 + conversation_id 路由(仅处理当前活跃对话的事件,
* 跨对话事件忽略——清空/压缩仅作用于当前对话,跨对话事件理论不会发生但防御)。
*/
async function initContextListener(callbacks: ContextCallbacks = {}): Promise<void> {
_callbacks = callbacks
if (_unlistenContext) return // 幂等
_unlistenContext = await listen<{ type?: string; conversation_id?: string; summary?: string; message?: string }>(
'ai-chat-event',
(e) => {
const payload = e.payload
if (!payload || typeof payload.type !== 'string') return
const convId = payload.conversation_id
// 跨对话事件忽略(清空/压缩仅作用于当前活跃对话)
if (convId && state.activeConversationId && convId !== state.activeConversationId) return
switch (payload.type) {
case 'ai_compressing':
isCompressing.value = true
break
case 'ai_compressed':
isCompressing.value = false
_callbacks.onCompressed?.(convId || '', payload.summary || '')
break
case 'ai_context_cleared':
_callbacks.onCleared?.(convId || '')
break
case 'ai_error':
// ai_error 也用于流式生成失败(useAiEvents 已处理并 push 错误气泡);
// 此处仅在 isCompressing 时复位 loading + 弹错误,避免双重处理流式错误。
if (isCompressing.value) {
isCompressing.value = false
_callbacks.onError?.(convId || '', payload.message || '')
}
break
default:
// 其他 type(delta/tool/agent round 等)交 useAiEvents 处理,本监听器不触碰
break
}
},
)
}
/** 释放监听器(AiChat.vue onBeforeUnmount 调) */
function stopContextListener(): void {
_unlistenContext?.()
_unlistenContext = null
_callbacks = {}
}
/**
* 解析压缩语言(与 useAiSend.sendMessage 同源逻辑):
* - df-ai-language='auto' → df-language(应用主语言)
* - 否则用 df-ai-language 指定值
* - 兜底 'zh-CN'
*/
function resolveLanguage(): string {
const raw = appSettings.get<string>('df-ai-language', 'auto')
if (raw === 'auto') {
return appSettings.get<string>('df-language', 'zh-CN')
}
return raw
}
/**
* 压缩当前对话上下文。
*
* 失败兜底:IPC 未送达(spawn 前/provider 配置错)由 catch 回滚 isCompressing + 抛错,
* 让调用方弹错误 toast;后端处理失败走 ai_error 事件 → 监听器复位 + onCompressed/onError。
*
* @throws IPC 失败时抛出原错(调用方 toast)
*/
async function compressContext(): Promise<void> {
const convId = state.activeConversationId
if (!convId) return
if (isCompressing.value) return // 防重复点击
isCompressing.value = true
try {
await aiApi.compressContext(convId, resolveLanguage())
// 成功 IPC 仅表示请求受理;真正压缩完成由 ai_compressed 事件复位 isCompressing
} catch (e) {
isCompressing.value = false
throw e
}
}
/**
* 清空当前对话上下文(仅触发 IPC,二次确认由调用方 AiChat.vue 弹)。
*
* @throws IPC 失败时抛出原错(调用方 toast)
*/
async function clearContext(): Promise<void> {
const convId = state.activeConversationId
if (!convId) return
await aiApi.clearContext(convId)
}
export function useAiContext() {
return {
// 状态
isCompressing,
// 操作
clearContext,
compressContext,
// 生命周期
initContextListener,
stopContextListener,
}
}

View File

@@ -83,6 +83,10 @@ export async function switchConversation(id: string) {
content: m.content || '',
model: m.model,
timestamp: Date.now(),
// F-15 阶段2: 透传 status(archived_segment/compressed/null|active),
// 供 AiChat.vue 按 status 折叠分组渲染。types.ts 未含此字段(不在本任务白名单),
// 经 as any 透传,消费方 AiChat.vue 同样 cast 读取,类型闭环在两端。
status: m.status,
toolCalls: m.tool_calls?.map((tc: any): AiToolCallInfo => {
// 逐条容错:单条坏 arguments 仅降级为空对象,不影响整条对话回填
let args: unknown = {}

View File

@@ -140,5 +140,25 @@ export default {
exportJson: 'JSON',
exportTxt: 'Plain text',
exportFailed: 'Export failed: {msg}',
// ── F-15 phase 2: manual context management (clear / compress) ──
// Buttons (Header action area: trash + compress icon)
clearContext: 'Clear context',
compressContext: 'Compress context',
// Confirm dialog (clear is semantically irreversible; history archived, not deleted)
clearContextConfirm: 'Clear the current chat context? History is archived and preserved; new chats are not affected.',
// Collapsed separator (consecutive same-status merged into one bar; {n}=merged message count)
contextArchived: 'Archived {n} messages',
compressed: 'Context compressed',
compressing: 'Compressing…',
clearSuccess: 'Context cleared',
compressSuccess: 'Context compressed',
compressError: 'Compression failed: {msg}',
clearError: 'Clear failed: {msg}',
// Separator expand / collapse
expand: 'Expand',
collapse: 'Collapse',
// Compressed summary block label (before the system summary message when expanded)
compressedSummaryLabel: 'Context summary',
},
}

View File

@@ -140,5 +140,25 @@ export default {
exportJson: 'JSON',
exportTxt: '纯文本',
exportFailed: '导出失败:{msg}',
// ── F-15 阶段2: 手动上下文管理(清空 / 压缩) ──
// 按钮(Header 操作区:垃圾桶 + 压缩图标)
clearContext: '清空上下文',
compressContext: '压缩上下文',
// 二次确认(清空不可逆语义,防误触;历史归档保留不删)
clearContextConfirm: '确定清空当前对话上下文?历史消息归档保留,不影响新对话',
// 折叠分隔条(连续同 status 合并一条;{n}=合并的消息条数)
contextArchived: '已归档 {n} 条消息',
compressed: '已压缩上下文',
compressing: '压缩中…',
clearSuccess: '上下文已清空',
compressSuccess: '上下文已压缩',
compressError: '压缩失败:{msg}',
clearError: '清空失败:{msg}',
// 折叠条展开 / 收起
expand: '展开',
collapse: '收起',
// 压缩摘要块标题(展开后 system 摘要消息前的标签)
compressedSummaryLabel: '上下文摘要',
},
}

View File

@@ -40,6 +40,7 @@ import { useAiSend, initDrainQueueListener } from '@/composables/ai/useAiSend'
import { useAiConversations } from '@/composables/ai/useAiConversations'
import { useAiWindow } from '@/composables/ai/useAiWindow'
import { useAiPanel } from '@/composables/ai/useAiPanel'
import { useAiContext } from '@/composables/ai/useAiContext'
/// 模块级单例 state — 全应用共享(composables 通过 `import { state } from '@/stores/ai'` 取用)
export const state = reactive({
@@ -141,5 +142,6 @@ export function useAiStore() {
...useAiConversations(),
...useAiWindow(),
...useAiPanel(),
...useAiContext(),
}
}