新增: F-15阶段2手动上下文管理(2 IPC+3事件+前端按钮+status渲染)
This commit is contained in:
@@ -384,6 +384,191 @@ pub async fn ai_chat_clear(state: State<'_, AppState>) -> Result<(), String> {
|
|||||||
Ok(())
|
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)
|
/// 编辑最后一条 user 消息并重新生成(UX-09)
|
||||||
///
|
///
|
||||||
/// 流程(复用 ai_regenerate 的 spawn 模式):占用 generating → 校验活跃对话一致 →
|
/// 流程(复用 ai_regenerate 的 spawn 模式):占用 generating → 校验活跃对话一致 →
|
||||||
|
|||||||
@@ -140,6 +140,13 @@ pub enum AiChatEvent {
|
|||||||
/// 每次重试前 emit,前端可在错误气泡内显示「重试 n/m」。
|
/// 每次重试前 emit,前端可在错误气泡内显示「重试 n/m」。
|
||||||
/// attempt 从 1 开始(首次失败后第一次重试=attempt 1),max_attempts = max_retries + 1。
|
/// attempt 从 1 开始(首次失败后第一次重试=attempt 1),max_attempts = max_retries + 1。
|
||||||
AiStreamRetry { attempt: u32, max_attempts: u32, conversation_id: Option<String> },
|
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 },
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -118,6 +118,9 @@ pub fn run() {
|
|||||||
commands::ai::ai_pending_tool_calls,
|
commands::ai::ai_pending_tool_calls,
|
||||||
commands::ai::ai_chat_clear,
|
commands::ai::ai_chat_clear,
|
||||||
commands::ai::ai_is_generating,
|
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_list_providers,
|
||||||
commands::ai::ai_save_provider,
|
commands::ai::ai_save_provider,
|
||||||
commands::ai::ai_set_provider,
|
commands::ai::ai_set_provider,
|
||||||
|
|||||||
@@ -90,6 +90,25 @@ export const aiApi = {
|
|||||||
return invoke('ai_chat_stop')
|
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 提供商 */
|
/** 列出所有 AI 提供商 */
|
||||||
listProviders(): Promise<AiProviderConfig[]> {
|
listProviders(): Promise<AiProviderConfig[]> {
|
||||||
return invoke('ai_list_providers')
|
return invoke('ai_list_providers')
|
||||||
|
|||||||
@@ -251,6 +251,28 @@
|
|||||||
<button class="ai-btn-icon" @click="confirmNewConversation" :title="$t('aiChat.newConversation')">
|
<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>
|
<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>
|
</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,带二次确认防误删) -->
|
<!-- 清空对话(真删 DB messages,带二次确认防误删) -->
|
||||||
<button class="ai-btn-icon" @click="confirmClearChat" :title="$t('aiChat.clearChat')">
|
<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>
|
<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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 消息列表 -->
|
<!-- 消息列表(F-15 阶段2:按 renderItems 扁平渲染 — 折叠段 emit 'sep' 项,
|
||||||
<div
|
正常消息 + 展开的折叠段内消息 emit 'msg' 项。连续 archived_segment/compressed
|
||||||
v-for="msg in store.state.messages"
|
合并一条分隔条,点击展开/收起看原文) -->
|
||||||
:key="msg.id"
|
<template v-for="item in renderItems" :key="item.key">
|
||||||
class="ai-msg"
|
<!-- 折叠分隔条(归档段 / 压缩段) -->
|
||||||
:class="'ai-msg--' + msg.role"
|
<div
|
||||||
:data-msg-id="msg.id"
|
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">
|
<div class="ai-msg-avatar ai-msg-avatar--user">
|
||||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
<svg 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>
|
||||||
<div class="ai-msg-bubble ai-msg-bubble--user">
|
<div class="ai-msg-bubble ai-msg-bubble--user">
|
||||||
{{ msg.content }}
|
{{ item.msg.content }}
|
||||||
<button
|
<button
|
||||||
class="ai-copy-btn"
|
class="ai-copy-btn"
|
||||||
:title="$t('aiChat.copyMsg')"
|
: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>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- UX-09:末条 user 消息编辑按钮(非生成中、非当前编辑态)hover 显示 -->
|
<!-- UX-09:末条 user 消息编辑按钮(非生成中、非当前编辑态)hover 显示 -->
|
||||||
<button
|
<button
|
||||||
v-if="isLastUser(msg) && !store.state.streaming && !editingMsgId"
|
v-if="isLastUser(item.msg) && !store.state.streaming && !editingMsgId"
|
||||||
class="ai-msg-edit-btn"
|
class="ai-msg-edit-btn"
|
||||||
:title="$t('aiChat.editMessage')"
|
: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>
|
<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>
|
</button>
|
||||||
<!-- UX-2025-13:消息时间戳(相对时间+hover绝对时间) -->
|
<!-- 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>
|
</div>
|
||||||
|
|
||||||
<!-- AI 消息 -->
|
<!-- AI 消息 -->
|
||||||
@@ -384,29 +433,29 @@
|
|||||||
<!-- 文本内容(流式或固定,Markdown 渲染) -->
|
<!-- 文本内容(流式或固定,Markdown 渲染) -->
|
||||||
<!-- UX-2025-01:流式分块v-for,已完成块DOM稳定不重建→选文字保持 -->
|
<!-- UX-2025-01:流式分块v-for,已完成块DOM稳定不重建→选文字保持 -->
|
||||||
<div
|
<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 ai-msg-bubble--ai ai-md"
|
||||||
:class="{ 'ai-msg-bubble--error': msg.isError }"
|
:class="{ 'ai-msg-bubble--error': item.msg.isError }"
|
||||||
:key="'md-' + msg.id + '-' + (isLastAi(msg) ? _mdRenderKey : 0)"
|
:key="'md-' + item.msg.id + '-' + (isLastAi(item.msg) ? _mdRenderKey : 0)"
|
||||||
>
|
>
|
||||||
<!-- 流式:分块渲染(已完成块key稳定→DOM不重建) -->
|
<!-- 流式:分块渲染(已完成块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>
|
<div v-for="block in streamingBlocks" :key="block.key" v-html="block.html"></div>
|
||||||
<span v-if="isViewingGenerating" class="ai-cursor"></span>
|
<span v-if="isViewingGenerating" class="ai-cursor"></span>
|
||||||
</template>
|
</template>
|
||||||
<!-- 完成/历史:整段v-html(不变) -->
|
<!-- 完成/历史:整段v-html(不变) -->
|
||||||
<div v-else v-html="renderContent(msg)"></div>
|
<div v-else v-html="renderContent(item.msg)"></div>
|
||||||
<button
|
<button
|
||||||
v-if="msg.content"
|
v-if="item.msg.content"
|
||||||
class="ai-copy-btn ai-copy-btn--ai"
|
class="ai-copy-btn ai-copy-btn--ai"
|
||||||
:title="$t('aiChat.copyMsg')"
|
: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>
|
<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>
|
</button>
|
||||||
</div>
|
</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>
|
<span class="ai-cursor"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -414,19 +463,19 @@
|
|||||||
仅非错误、有内容、且非流式生成中的 AI 消息显示。
|
仅非错误、有内容、且非流式生成中的 AI 消息显示。
|
||||||
重新生成仅最后一条 AI 消息显示(语义:重生成末尾回复)。 -->
|
重新生成仅最后一条 AI 消息显示(语义:重生成末尾回复)。 -->
|
||||||
<div
|
<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"
|
class="ai-msg-actions"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="ai-msg-act"
|
class="ai-msg-act"
|
||||||
:title="$t('aiChat.copyMsg')"
|
: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>
|
<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>
|
<span>{{ $t('aiChat.copyMsg') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="isLastAi(msg)"
|
v-if="isLastAi(item.msg)"
|
||||||
class="ai-msg-act"
|
class="ai-msg-act"
|
||||||
:disabled="store.state.streaming"
|
:disabled="store.state.streaming"
|
||||||
:title="$t('aiChat.regenerate')"
|
:title="$t('aiChat.regenerate')"
|
||||||
@@ -443,12 +492,12 @@
|
|||||||
「去设置」仅 error_type ∈ {auth, provider_config} 显示;
|
「去设置」仅 error_type ∈ {auth, provider_config} 显示;
|
||||||
Fatal 时附 notRetryable 提示,说明为何无重试按钮。 -->
|
Fatal 时附 notRetryable 提示,说明为何无重试按钮。 -->
|
||||||
<div
|
<div
|
||||||
v-if="msg.isError && !store.state.streaming"
|
v-if="item.msg.isError && !store.state.streaming"
|
||||||
class="ai-msg-actions ai-msg-actions--error"
|
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
|
<button
|
||||||
v-if="canRetry(msg)"
|
v-if="canRetry(item.msg)"
|
||||||
class="ai-msg-act ai-msg-act--primary"
|
class="ai-msg-act ai-msg-act--primary"
|
||||||
:disabled="store.state.streaming"
|
:disabled="store.state.streaming"
|
||||||
@click.stop="handleRetry"
|
@click.stop="handleRetry"
|
||||||
@@ -457,7 +506,7 @@
|
|||||||
<span>{{ $t('aiChat.retry') }}</span>
|
<span>{{ $t('aiChat.retry') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="canOpenSettings(msg)"
|
v-if="canOpenSettings(item.msg)"
|
||||||
class="ai-msg-act"
|
class="ai-msg-act"
|
||||||
@click.stop="goToSettings"
|
@click.stop="goToSettings"
|
||||||
>
|
>
|
||||||
@@ -469,7 +518,7 @@
|
|||||||
|
|
||||||
<!-- token 用量(仅最后一条 AI 消息 + 非流式;ai.ts handleEvent 已按 df-show-token-usage 开关过滤 lastTokenUsage) -->
|
<!-- token 用量(仅最后一条 AI 消息 + 非流式;ai.ts handleEvent 已按 df-show-token-usage 开关过滤 lastTokenUsage) -->
|
||||||
<div
|
<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"
|
class="ai-token-usage"
|
||||||
>
|
>
|
||||||
<span class="ai-token-usage-icon">🔣</span>
|
<span class="ai-token-usage-icon">🔣</span>
|
||||||
@@ -478,22 +527,23 @@
|
|||||||
|
|
||||||
<!-- 工具调用卡片(渲染/折叠/审批全下沉到 ToolCardList+ToolCard 子组件,AiChat 仅转发审批) -->
|
<!-- 工具调用卡片(渲染/折叠/审批全下沉到 ToolCardList+ToolCard 子组件,AiChat 仅转发审批) -->
|
||||||
<ToolCardList
|
<ToolCardList
|
||||||
v-if="msg.toolCalls && msg.toolCalls.length > 0"
|
v-if="item.msg.toolCalls && item.msg.toolCalls.length > 0"
|
||||||
ref="toolCardListRef"
|
ref="toolCardListRef"
|
||||||
:toolCalls="msg.toolCalls"
|
:toolCalls="item.msg.toolCalls"
|
||||||
@approve="({ id, approved }) => store.approveToolCall(id, approved)"
|
@approve="({ id, approved }) => store.approveToolCall(id, approved)"
|
||||||
@batch-approve="(decision) => store.batchApprove(decision)"
|
@batch-approve="(decision) => store.batchApprove(decision)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- UX-2025-13:消息时间戳(相对时间+hover绝对时间),流式生成中不显示避免抖动 -->
|
<!-- UX-2025-13:消息时间戳(相对时间+hover绝对时间),流式生成中不显示避免抖动 -->
|
||||||
<span
|
<span
|
||||||
v-if="!(isLastAi(msg) && store.state.streaming)"
|
v-if="!(isLastAi(item.msg) && store.state.streaming)"
|
||||||
class="ai-msg-time"
|
class="ai-msg-time"
|
||||||
:title="formatDate(msg.timestamp)"
|
:title="formatDate(item.msg.timestamp)"
|
||||||
>{{ formatRelativeZh(msg.timestamp) }}</span>
|
>{{ formatRelativeZh(item.msg.timestamp) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 回到底部(上滑看历史 / 有新内容时显示) -->
|
<!-- 回到底部(上滑看历史 / 有新内容时显示) -->
|
||||||
@@ -1760,6 +1810,8 @@ onBeforeUnmount(() => {
|
|||||||
// 之前 stopListener 定义但全仓零调用 → listener+watchdog 永久泄漏;分离窗口主窗口 AiChat
|
// 之前 stopListener 定义但全仓零调用 → listener+watchdog 永久泄漏;分离窗口主窗口 AiChat
|
||||||
// 卸载(走 App.vue v-if detach)时 listener 仍挂后端,watchdog 130s 后写已卸载 state。
|
// 卸载(走 App.vue v-if detach)时 listener 仍挂后端,watchdog 130s 后写已卸载 state。
|
||||||
store.stopListener()
|
store.stopListener()
|
||||||
|
// F-15 阶段2: 释放上下文管理事件监听器
|
||||||
|
store.stopContextListener()
|
||||||
if (_unlistenToolSlow) { _unlistenToolSlow(); _unlistenToolSlow = null } // B-260616-12
|
if (_unlistenToolSlow) { _unlistenToolSlow(); _unlistenToolSlow = null } // B-260616-12
|
||||||
if (_toastTimer) { clearTimeout(_toastTimer); _toastTimer = null }
|
if (_toastTimer) { clearTimeout(_toastTimer); _toastTimer = null }
|
||||||
// UX-2025-20:释放标题淡入计时器
|
// UX-2025-20:释放标题淡入计时器
|
||||||
@@ -1849,6 +1901,8 @@ onMounted(async () => {
|
|||||||
_unlistenToolSlow = await listen<{ name: string }>('ai-tool-slow-toast', (e) => {
|
_unlistenToolSlow = await listen<{ name: string }>('ai-tool-slow-toast', (e) => {
|
||||||
showToast(t('aiTool.executionSlow', { name: e.payload.name }), 'warning')
|
showToast(t('aiTool.executionSlow', { name: e.payload.name }), 'warning')
|
||||||
})
|
})
|
||||||
|
// F-15 阶段2: 注册上下文管理事件监听器(清空/压缩生命周期事件 → toast/刷新)
|
||||||
|
await initContextEventListeners()
|
||||||
// 分离模式:接管主窗口当前对话(含生成中态),保持正在进行的对话
|
// 分离模式:接管主窗口当前对话(含生成中态),保持正在进行的对话
|
||||||
if (props.detached) {
|
if (props.detached) {
|
||||||
const params = new URLSearchParams(location.hash.split('?')[1] || '')
|
const params = new URLSearchParams(location.hash.split('?')[1] || '')
|
||||||
@@ -2145,6 +2199,183 @@ async function handleStopLoop(): Promise<void> {
|
|||||||
watch(() => pendingMaxRounds.value, (v) => {
|
watch(() => pendingMaxRounds.value, (v) => {
|
||||||
if (!v) maxRoundsActing.value = false
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -3300,6 +3531,62 @@ body.ai-sidebar-resizing * {
|
|||||||
opacity: 0.6;
|
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 冲突) ═══ */
|
/* ═══ 错误 toast(复用 Settings.vue .toast 样式,前缀 ai- 防 scoped 冲突) ═══ */
|
||||||
.ai-toast {
|
.ai-toast {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
168
src/composables/ai/useAiContext.ts
Normal file
168
src/composables/ai/useAiContext.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,6 +83,10 @@ export async function switchConversation(id: string) {
|
|||||||
content: m.content || '',
|
content: m.content || '',
|
||||||
model: m.model,
|
model: m.model,
|
||||||
timestamp: Date.now(),
|
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 => {
|
toolCalls: m.tool_calls?.map((tc: any): AiToolCallInfo => {
|
||||||
// 逐条容错:单条坏 arguments 仅降级为空对象,不影响整条对话回填
|
// 逐条容错:单条坏 arguments 仅降级为空对象,不影响整条对话回填
|
||||||
let args: unknown = {}
|
let args: unknown = {}
|
||||||
|
|||||||
@@ -140,5 +140,25 @@ export default {
|
|||||||
exportJson: 'JSON',
|
exportJson: 'JSON',
|
||||||
exportTxt: 'Plain text',
|
exportTxt: 'Plain text',
|
||||||
exportFailed: 'Export failed: {msg}',
|
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',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,5 +140,25 @@ export default {
|
|||||||
exportJson: 'JSON',
|
exportJson: 'JSON',
|
||||||
exportTxt: '纯文本',
|
exportTxt: '纯文本',
|
||||||
exportFailed: '导出失败:{msg}',
|
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: '上下文摘要',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { useAiSend, initDrainQueueListener } from '@/composables/ai/useAiSend'
|
|||||||
import { useAiConversations } from '@/composables/ai/useAiConversations'
|
import { useAiConversations } from '@/composables/ai/useAiConversations'
|
||||||
import { useAiWindow } from '@/composables/ai/useAiWindow'
|
import { useAiWindow } from '@/composables/ai/useAiWindow'
|
||||||
import { useAiPanel } from '@/composables/ai/useAiPanel'
|
import { useAiPanel } from '@/composables/ai/useAiPanel'
|
||||||
|
import { useAiContext } from '@/composables/ai/useAiContext'
|
||||||
|
|
||||||
/// 模块级单例 state — 全应用共享(composables 通过 `import { state } from '@/stores/ai'` 取用)
|
/// 模块级单例 state — 全应用共享(composables 通过 `import { state } from '@/stores/ai'` 取用)
|
||||||
export const state = reactive({
|
export const state = reactive({
|
||||||
@@ -141,5 +142,6 @@ export function useAiStore() {
|
|||||||
...useAiConversations(),
|
...useAiConversations(),
|
||||||
...useAiWindow(),
|
...useAiWindow(),
|
||||||
...useAiPanel(),
|
...useAiPanel(),
|
||||||
|
...useAiContext(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user