- 当前会话归属项目组置顶,focused 标记高亮 - source_ref 解析出可跳转会话 id,消息级引用反查补全 - 条目相对时间/空态引导/标题点击跳源,失败静默
431 lines
19 KiB
TypeScript
431 lines
19 KiB
TypeScript
//! AI 聊天 API — IPC invoke 封装 + 事件监听
|
||
|
||
import { invoke } from '@tauri-apps/api/core'
|
||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||
import type { AiChatEvent, AiConversationDetail, AiConversationSummary, AiProviderConfig, AiToolMeta, ContentPart, MentionSpan, ModelConfig, SkillInfo, TaskBoundary } from './types'
|
||
|
||
export const aiApi = {
|
||
/**
|
||
* 发送消息(非阻塞,通过 ai-chat-event 流式返回);skill 传技能名则注入其 SKILL.md。
|
||
* parts 透传给后端 ai_chat_send → user_parts → provider vision 端点。
|
||
* parts 为空/undefined → 后端走 user() 纯文本(向后兼容)。
|
||
* Input Augmentation: mentionSpans 透传给后端 ai_chat_send 的 mention_spans 参数
|
||
* (后端 resolve_and_inject 按 kind 投影成 Augmentation 注入 system prompt)。
|
||
* undefined/空数组 → 不传(后端 None 走无 mention 路径,向后兼容)。
|
||
*/
|
||
sendMessage(message: string, language?: string, skill?: string, modelOverride?: string | null, parts?: ContentPart[], conversationId?: string | null, mentionSpans?: MentionSpan[]): Promise<string> {
|
||
return invoke('ai_chat_send', {
|
||
message,
|
||
language: language || 'zh-CN',
|
||
skill: skill || null,
|
||
modelOverride: modelOverride || null,
|
||
parts: parts && parts.length > 0 ? parts : null,
|
||
// 传 conv_id,操作指定 conv 的 per_conv(不依赖 active 单值)。
|
||
conversationId: conversationId || null,
|
||
// Input Augmentation: 非空数组才传(undefined/空让 payload 不含该键,后端默认 None)。
|
||
mentionSpans: mentionSpans && mentionSpans.length > 0 ? mentionSpans : null,
|
||
})
|
||
},
|
||
|
||
/** 强制发送(复位 generating 残留后走 send 同款流程);parts/mentionSpans 透传同 sendMessage。 */
|
||
forceSend(message: string, language?: string, skill?: string, modelOverride?: string | null, parts?: ContentPart[], conversationId?: string | null, mentionSpans?: MentionSpan[]): Promise<string> {
|
||
return invoke('ai_chat_force_send', {
|
||
message,
|
||
language: language || 'zh-CN',
|
||
skill: skill || null,
|
||
modelOverride: modelOverride || null,
|
||
parts: parts && parts.length > 0 ? parts : null,
|
||
// 传 conv_id,强制复位+发送仅作用于目标 conv。
|
||
conversationId: conversationId || null,
|
||
// Input Augmentation: 同 sendMessage,非空数组才传(后端 mention_spans 参数)。
|
||
mentionSpans: mentionSpans && mentionSpans.length > 0 ? mentionSpans : null,
|
||
})
|
||
},
|
||
|
||
/**
|
||
* 重新生成最后一条 AI 回复。
|
||
* 后端:占用 generating → 弹出末尾 AI 回复(保留触发它的 user 消息)→ 重跑 agentic loop。
|
||
* 前端消费:与 sendMessage 一样需本地 push 空气泡占位 + 置 streaming(见 useAiSend.regenerate)。
|
||
*/
|
||
regenerate(conversationId: string, language?: string, modelOverride?: string | null): Promise<string> {
|
||
return invoke('ai_regenerate', {
|
||
conversationId,
|
||
language: language || 'zh-CN',
|
||
modelOverride: modelOverride || null,
|
||
})
|
||
},
|
||
|
||
/**
|
||
* 编辑最后一条 user 消息并重新生成。
|
||
* 后端:占用 generating → 替换末条 user content → 其后消息标 truncated(软删)→ 重跑 agentic loop。
|
||
* 前端消费:调 editMessage 前需移除被编辑消息之后的所有消息(视图截断)+ push 空气泡占位 + 置 streaming。
|
||
* 中间 user 消息编辑被后端拒绝(只允许末条)。
|
||
*/
|
||
editMessage(conversationId: string, newMessage: string, language?: string, modelOverride?: string | null): Promise<string> {
|
||
return invoke('ai_chat_edit', {
|
||
conversationId,
|
||
newMessage,
|
||
language: language || 'zh-CN',
|
||
modelOverride: modelOverride || null,
|
||
})
|
||
},
|
||
|
||
/** 批准/拒绝工具调用 */
|
||
approve(toolCallId: string, approved: boolean): Promise<string> {
|
||
return invoke('ai_approve', { toolCallId, approved })
|
||
},
|
||
|
||
/**
|
||
* 路径授权弹窗决策(消费 AiDirAuthRequired 挂起)。
|
||
* decision: 'once'(本次单次,执行后清)/ 'session'(当前会话,切会话清)/ 'always'(始终,持久化)/ 'deny'(拒绝)。
|
||
* 后端 remove pending → 写授权目录(once/session/persistent)→ 执行工具 → try_continue 恢复 loop。
|
||
*/
|
||
authorizeDir(toolCallId: string, decision: 'once' | 'session' | 'always' | 'deny'): Promise<string> {
|
||
return invoke('ai_authorize_dir', { toolCallId, decision })
|
||
},
|
||
|
||
/**
|
||
* 续跑 agentic 循环(达 max_iterations 暂停态点继续)。
|
||
* 后端 ai_continue_loop:复位 stop_flag → try_continue_agent_loop 重新 spawn
|
||
* run_agentic_loop(iteration 从 0 重计,再跑 max_iterations 轮)。
|
||
*/
|
||
continueLoop(conversationId: string): Promise<string> {
|
||
return invoke('ai_continue_loop', { conversationId })
|
||
},
|
||
|
||
/** 流中断探测:后端按真实状态返回 generating/resumed/finished,供看门狗超时自动续跑 */
|
||
resumeInterrupted(conversationId: string): Promise<'generating' | 'resumed' | 'finished'> {
|
||
return invoke('ai_resume_interrupted', { conversationId })
|
||
},
|
||
|
||
/**
|
||
* 停止 agentic 循环并走完成流程(达 max_iterations 暂停态点停止)。
|
||
* 后端 ai_stop_loop:复位 generating + emit AiCompleted(暂停前已 save)。
|
||
*/
|
||
stopLoop(conversationId: string): Promise<string> {
|
||
return invoke('ai_stop_loop', { conversationId })
|
||
},
|
||
|
||
/** 查询某对话积压的待审批工具(重启后恢复 toolCard pending_approval 态用)。
|
||
* 返 kind 字段(risk/path),前端按 kind 渲染不同决策按钮。 */
|
||
pendingToolCalls(conversationId: string): Promise<{ tool_call_id: string; conversation_id: string | null; kind: 'risk' | 'path' }[]> {
|
||
return invoke('ai_pending_tool_calls', { conversationId })
|
||
},
|
||
|
||
// ── 无人值守任务边界(设计 §2.3):发起时一次授权,存续期生效,随会话同亡 ──
|
||
|
||
/** 读取会话任务边界(配置卡回显/侧栏徽标)。无边界返回 null(幂等读不报错) */
|
||
getTaskBoundary(conversationId: string): Promise<TaskBoundary | null> {
|
||
return invoke('ai_task_boundary_get', { conversationId })
|
||
},
|
||
|
||
/** 设置会话任务边界。boundary 传 null = 清除,回退逐次审批;
|
||
* 后端把 risk_ceiling=high 钳到 medium;对话未落库时内存即生效,首条消息落库随行 */
|
||
setTaskBoundary(conversationId: string, boundary: TaskBoundary | null): Promise<void> {
|
||
return invoke('ai_task_boundary_set', { conversationId, boundary })
|
||
},
|
||
|
||
/** 清空对话 */
|
||
clearChat(): Promise<void> {
|
||
return invoke('ai_chat_clear')
|
||
},
|
||
|
||
/**
|
||
* 停止当前生成。
|
||
* 传 convId,停止仅作用于目标 conv(不杀其他后台 conv 的 loop)。
|
||
* convId 省略时后端 fallback active conv。
|
||
*/
|
||
stopChat(convId?: string | null): Promise<void> {
|
||
return invoke('ai_chat_stop', { conversationId: convId || null })
|
||
},
|
||
|
||
/**
|
||
* 查询后端真实 generating 状态(发送前 IPC 查后端真值)。
|
||
* 传 convId 精确查指定 conv;省略时后端 fallback active conv。
|
||
*/
|
||
isGenerating(convId?: string | null): Promise<boolean> {
|
||
return invoke('ai_is_generating', { conversationId: convId || null })
|
||
},
|
||
|
||
// ── 手动上下文管理(清空 / 压缩) ──
|
||
// 后端经 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')
|
||
},
|
||
|
||
/** 保存 AI 提供商 */
|
||
saveProvider(input: {
|
||
id?: string
|
||
name: string
|
||
providerType: string
|
||
baseUrl: string
|
||
apiKey: string
|
||
defaultModel: string
|
||
modelConfigs: ModelConfig[]
|
||
/** 供应商级采样温度覆盖(写 config JSON);缺省不传 → 后端 config 保持 None,主链路走默认 0.7 */
|
||
temperatureOverride?: number
|
||
}): Promise<string> {
|
||
return invoke('ai_save_provider', {
|
||
id: input.id,
|
||
name: input.name,
|
||
providerType: input.providerType,
|
||
baseUrl: input.baseUrl,
|
||
apiKey: input.apiKey,
|
||
defaultModel: input.defaultModel,
|
||
modelConfigs: input.modelConfigs,
|
||
temperatureOverride: input.temperatureOverride,
|
||
})
|
||
},
|
||
|
||
/** 设置活跃提供商 */
|
||
setProvider(providerId: string): Promise<void> {
|
||
return invoke('ai_set_provider', { providerId })
|
||
},
|
||
|
||
/**
|
||
* 更新 provider 负载均衡池配置:仅 UPDATE enabled/weight + 重建 per_provider caps。
|
||
* 走轻量分支,不经 ai_save_provider 全量 INSERT OR REPLACE(避免空 api_key 触发 R-PD-1 密钥迁移)。
|
||
* 后端 weight clamp [0,100];落库后立即 reload_provider_caps,下条消息即按新配置 acquire。
|
||
*/
|
||
updateProviderPool(providerId: string, enabled: boolean, weight: number): Promise<void> {
|
||
return invoke('ai_update_provider_pool', { providerId, enabled, weight })
|
||
},
|
||
|
||
/** 删除 AI 提供商 */
|
||
deleteProvider(providerId: string): Promise<void> {
|
||
return invoke('ai_delete_provider', { providerId })
|
||
},
|
||
|
||
/**
|
||
* 测试连接并拉取厂商模型列表。
|
||
* 后端:DB 取 provider → 内存解析 api_key → fetch_and_probe
|
||
* (网络拉模型名 + 探测 4 维度)→ 写回 model_configs → 返回。
|
||
* 返回值 ModelConfig 不含 api_key。
|
||
* 需已落库的 providerId(新建态先保存再拉取)。
|
||
*/
|
||
fetchModels(providerId: string): Promise<ModelConfig[]> {
|
||
return invoke('ai_fetch_models', { providerId })
|
||
},
|
||
|
||
/**
|
||
* 一次性测试连接用**表单当前值**测试,不写库。
|
||
* - 新建态:传表单填的 providerType/baseUrl/apiKey,providerId=null。
|
||
* - 编辑态(apiKey 留空=不改):传 providerId 让后端从 DB 沿用旧密钥;
|
||
* apiKey 非空则直接用表单新值。
|
||
* 成功返回模型列表(ModelConfig,不含 api_key),失败抛错。
|
||
*/
|
||
testConnection(input: {
|
||
providerType: string
|
||
baseUrl: string
|
||
apiKey: string
|
||
providerId?: string | null
|
||
}): Promise<ModelConfig[]> {
|
||
return invoke('ai_test_connection', {
|
||
providerType: input.providerType,
|
||
baseUrl: input.baseUrl,
|
||
apiKey: input.apiKey,
|
||
providerId: input.providerId ?? null,
|
||
})
|
||
},
|
||
|
||
/**
|
||
* 单模型探测:纯 CPU 启发式 + 预设表,无网络。
|
||
* 返回填充了 probe_source 的 ModelConfig。用途:手动补模型名后探测能力维度。
|
||
*/
|
||
probeModel(modelId: string): Promise<ModelConfig> {
|
||
return invoke('ai_probe_model', { modelId })
|
||
},
|
||
|
||
/** 设置 LLM 调用并发上限(全局 / 单对话,运行时即时生效;软收敛不中断进行中调用) */
|
||
setConcurrencyConfig(globalLimit: number, perConvLimit: number): Promise<void> {
|
||
return invoke('ai_set_concurrency_config', { globalLimit, perConvLimit })
|
||
},
|
||
|
||
/** 设置 Agentic 循环最大轮次(运行时调整;loop 入口锁定边界,热改下次发消息生效) */
|
||
setAgentMaxIterations(value: number): Promise<void> {
|
||
return invoke('ai_set_agent_max_iterations', { value })
|
||
},
|
||
|
||
/** 设置流式对话失败自动重试次数(只重试流前失败,0=不重试,默认3,上限10) */
|
||
setAgentMaxRetries(value: number): Promise<void> {
|
||
return invoke('ai_set_agent_max_retries', { value })
|
||
},
|
||
|
||
/**
|
||
* 列出本机 Claude 技能(project + skills + commands + plugins)。
|
||
* @param projectPath 当前项目根目录(可选)。传入时后端额外扫项目级
|
||
* `.claude/skills` 各技能目录下的 SKILL.md(source="project",同名覆盖 home 级);
|
||
* 不传沿用已设项目根(无则仅 home 级)。
|
||
*/
|
||
listSkills(projectPath?: string): Promise<SkillInfo[]> {
|
||
return invoke('ai_list_skills', { projectPath: projectPath ?? null })
|
||
},
|
||
|
||
/**
|
||
* Input Augmentation:进程内重载 skills 缓存(OnceLock → RwLock 后支持热重载)。
|
||
*
|
||
* 用户增删/修改 SKILL.md 后无需重启即可让后续 `/技能` 注入读到最新内容。
|
||
* 后端 ai_reload_skills 复用 scan_skills 走剥 frontmatter 的读取 + invalidate 缓存,
|
||
* 返回重载后的全量 skills 列表(供前端同步刷新 skills state)。
|
||
*/
|
||
reloadSkills(): Promise<SkillInfo[]> {
|
||
return invoke('ai_reload_skills')
|
||
},
|
||
|
||
/** 获取 AI 工具文件访问授权目录列表(含 workspace_root) */
|
||
getAllowedDirs(): Promise<string[]> {
|
||
return invoke<string[]>('ai_get_allowed_dirs')
|
||
},
|
||
|
||
/** 列出全部已注册 AI 工具的名称与风险级(审批高风险名单的权威数据源) */
|
||
listTools(): Promise<AiToolMeta[]> {
|
||
return invoke('ai_list_tools')
|
||
},
|
||
|
||
/** 设置 AI 工具授权目录(持久化 + 同步内存白名单),返回规范化后的列表 */
|
||
setAllowedDirs(dirs: string[]): Promise<string[]> {
|
||
return invoke<string[]>('ai_set_allowed_dirs', { dirs })
|
||
},
|
||
|
||
/** 监听 AI 聊天事件 */
|
||
onEvent(callback: (event: AiChatEvent) => void): Promise<UnlistenFn> {
|
||
return listen<AiChatEvent>('ai-chat-event', (e) => {
|
||
callback(e.payload)
|
||
})
|
||
},
|
||
|
||
// ── 对话管理 ──
|
||
|
||
/** 创建新对话 */
|
||
createConversation(): Promise<{ id: string }> {
|
||
return invoke('ai_conversation_create')
|
||
},
|
||
|
||
/** 列出对话(摘要;limit 默认 50,includeArchived 默认 false 隐藏归档) */
|
||
listConversations(limit?: number, includeArchived?: boolean): Promise<AiConversationSummary[]> {
|
||
return invoke('ai_conversation_list', {
|
||
limit: limit ?? null,
|
||
includeArchived: includeArchived ?? false,
|
||
})
|
||
},
|
||
|
||
/** B3 可恢复会话清单(loop_state='parked' 完备真相,不受侧栏 limit 截断) */
|
||
listRecoverable(): Promise<{ id: string; title: string | null; model: string | null; updated_at: string }[]> {
|
||
return invoke('ai_list_recoverable')
|
||
},
|
||
|
||
/** B4 恢复 parked 会话(断点续跑闭环:注入恢复引导消息,重走 spawn 正典链路) */
|
||
recoverParked(conversationId: string): Promise<string> {
|
||
return invoke('ai_recover_parked', { conversationId })
|
||
},
|
||
|
||
/** 切换到指定对话 */
|
||
switchConversation(conversationId: string): Promise<AiConversationDetail> {
|
||
return invoke('ai_conversation_switch', { conversationId })
|
||
},
|
||
|
||
/**
|
||
* 加载更早历史消息(G3.5 滚顶分页)。
|
||
* beforeSeq=当前已加载最早消息的 seq(游标,后端返 earliest_seq),返回更早的 PAGE_SIZE 条。
|
||
* 返 messages JSON 数组 + has_more(是否还有更早)+ earliest_seq(新游标,null=已到尽头)。
|
||
*/
|
||
loadMoreMessages(conversationId: string, beforeSeq: number): Promise<{
|
||
messages: string
|
||
has_more: boolean
|
||
earliest_seq: number | null
|
||
}> {
|
||
return invoke('ai_conversation_load_more', { conversationId, beforeSeq })
|
||
},
|
||
|
||
/** 删除对话 */
|
||
deleteConversation(conversationId: string): Promise<void> {
|
||
return invoke('ai_conversation_delete', { conversationId })
|
||
},
|
||
|
||
/** 重命名对话标题 */
|
||
renameConversation(conversationId: string, title: string): Promise<void> {
|
||
return invoke('ai_conversation_rename', { conversationId, title })
|
||
},
|
||
|
||
/** 归档/取消归档对话 */
|
||
archiveConversation(conversationId: string, archived: boolean): Promise<void> {
|
||
return invoke('ai_conversation_archive', { conversationId, archived })
|
||
},
|
||
|
||
/** 置顶/取消置顶对话*/
|
||
setPinnedConversation(conversationId: string, pinned: boolean): Promise<void> {
|
||
return invoke('ai_conversation_set_pinned', { conversationId, pinned })
|
||
},
|
||
|
||
/**
|
||
/** 导出对话为指定格式对话导出;消费 batch46 ai_conversation_export IPC)。
|
||
* 后端返回导出内容 String(markdown/json/txt),前端落 Blob 下载或复制剪贴板。
|
||
* @param format 'markdown' | 'json' | 'txt'
|
||
*/
|
||
exportConversation(conversationId: string, format: 'markdown' | 'json' | 'txt'): Promise<string> {
|
||
return invoke('ai_conversation_export', { conversationId, format })
|
||
},
|
||
|
||
/**
|
||
* 更新对话目标钉扎列表(G1 目标钉扎持久化:对话透明化 L1)。
|
||
* 接收完整目标列表(非增量,前端增/删后传全量 new Vec)。
|
||
*/
|
||
updateConversationGoals(conversationId: string, goals: string[]): Promise<void> {
|
||
return invoke('ai_update_conversation_goals', { conversationId, goals })
|
||
},
|
||
|
||
/**
|
||
* 只读窥视子代理轨迹(S3):后端复用 switch 读路径但**不切任何会话状态**。
|
||
* 子会话(origin='sub_agent')不进侧栏、不可 switch,完成卡浮层经此拉轨迹;
|
||
* 返回全量消息 JSON 字符串(不分页)。
|
||
*/
|
||
peekConversation(conversationId: string): Promise<{ title: string | null; messages: string }> {
|
||
return invoke('ai_conversation_peek', { conversationId })
|
||
},
|
||
|
||
/**
|
||
* 按父会话反查子代理轨迹精简元数据(P2-2 完成卡回补数据源,只读无副作用)。
|
||
* 后端按 origin='sub_agent' + id 前缀约定过滤,created_at 升序(轨迹时间线序)。
|
||
* 重启/分离窗回收后内存完成卡清零,经此按父 conv 重建卡片入口。
|
||
*/
|
||
listSubAgents(conversationId: string): Promise<{ conversation_id: string; title: string | null; created_at: string }[]> {
|
||
return invoke('ai_sub_agent_list', { conversationId })
|
||
},
|
||
|
||
/**
|
||
* S4 TaskDrawer「关联会话」:按任务 id 反查发起过 create/update/advance_task 的会话。
|
||
* 后端从 ai_tool_executions 审计表推导(result JSON 顶层 id 精确判等,非 LIKE),
|
||
* 已过滤子代理会话(origin='sub_agent')并按最近命中倒序去重,v1 只列主会话。
|
||
*/
|
||
listTaskConversations(taskId: string): Promise<{ conversation_id: string; title: string | null; tool_name: string; requested_at: string }[]> {
|
||
return invoke('list_task_conversations', { taskId })
|
||
},
|
||
|
||
/**
|
||
* 脉络卡:published 知识按 source_project 聚合(每项目取复用最高 perProject 条)。
|
||
* focusConv 标记来源为当前会话的分组(focused=true 供前端高亮);后端未合版本会忽略该参数。
|
||
* 纯读侧;用户点「带入上下文」才经正常发送路径显式注入,不触碰 L3 工具召回铁律。
|
||
*/
|
||
listContextBriefs(perProject?: number, focusConv?: string): Promise<{ source_project: string; focused?: boolean; briefs: { title: string; summary: string; source_ref: string | null; source_conv: string | null; source_project: string | null; reuse_count: number; updated_at: string }[] }[]> {
|
||
return invoke('ai_context_briefs', { perProject: perProject ?? null, focusConv: focusConv ?? null })
|
||
},
|
||
}
|