Files
DevFlow/src/api/ai.ts
T
lxy 9e26d6b19c 优化: 脉络卡归属置顶与源会话跳转
- 当前会话归属项目组置顶,focused 标记高亮
- source_ref 解析出可跳转会话 id,消息级引用反查补全
- 条目相对时间/空态引导/标题点击跳源,失败静默
2026-10-12 18:12:26 +08:00

431 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 })
},
}