import { app, dialog, shell } from 'electron' import { nativeTheme } from 'electron' import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { homedir } from 'node:os' import { createAgentSessionRuntime, createAgentSessionServices, createAgentSessionFromServices, estimateTokens, getAgentDir, getPowerShellConfig, SessionManager, type AgentSession, type AgentSessionEvent, type AgentSessionRuntime, type CreateAgentSessionRuntimeFactory, type PromptTemplate, type SessionInfo, type Skill } from '@earendil-works/pi-coding-agent' import { ApprovalService, createApprovalExtension } from './approval' import { EXTERNAL_PLUGINS_ENABLED, getPluginHost } from '../plugins/plugin-host' import { browserToolNames } from '../tools/browser-tools' import type { AgentUiEvent, AppSettings, AttachmentInfo, ChatResources, CommandDetail, ContextBreakdownEntry, ContextUsageInfo, CustomProviderConfig, ForkPoint, ModelCatalog, ModelInfo, NavigateTreeOptions, PermissionMode, ResourceDetails, SendImage, SessionMeta, SessionState, SessionStatsInfo, SkillDetail, ThinkingLevel, ThemeMode, WorkspaceFile, WorkspaceMeta } from '@shared/types' /** 增量事件合批的刷新间隔(ms),参考 pi-web 的 60ms 快照节流经验取略低值 */ const DELTA_FLUSH_MS = 40 /** SDK 注册的自定义工具名(用量卡片中与内置「系统工具」分开统计)。 * browserTools 已迁入浏览器插件(pi.registerTool 注册),这里同步其名单。 */ const CUSTOM_TOOL_NAMES = new Set(browserToolNames) /** 需要人工审批的工具名单(高危内置工具;业务演示工具已随 demo-tools.ts 移除) */ const TOOLS_REQUIRING_APPROVAL: ReadonlySet = new Set(['bash', 'powershell', 'write', 'edit']) /** 资源禁用清单(disabledSkills/disabledCommands)的白名单式解析:仅保留非空字符串并去重 */ function parseNameList(value: unknown): string[] { if (!Array.isArray(value)) return [] return [...new Set(value.filter((n): n is string => typeof n === 'string' && n.trim().length > 0).map((n) => n.trim()))] } /** Windows 优先 PowerShell:探测结果进程内缓存(getPowerShellConfig 会 spawnSync where 探测, * 且平台与安装情况不会在应用运行中变化)。探测失败保持 bash(Git Bash)不动。 */ let powershellProbe: boolean | null = null /** win32 且存在 pwsh/powershell 可执行文件时,把会话的默认 bash 工具换成 powershell, * 模型的 shell 调用走原生 PowerShell 而非 Git Bash。幂等:无 bash 或已换过则原样返回。 * 注意不能用 options.tools 传入(那会变成工具白名单,把插件注册的工具全部过滤掉), * 也不能只传四个基础名调 setActiveToolsByName(那会把已激活的扩展工具一并挤掉), * 所以按当前激活名单做替换。 */ function preferPowerShell(session: AgentSession): void { if (process.platform !== 'win32') return if (powershellProbe === null) { try { getPowerShellConfig() powershellProbe = true } catch { powershellProbe = false } } if (!powershellProbe) return const names = session.getActiveToolNames() if (!names.includes('bash') || names.includes('powershell')) return session.setActiveToolsByName(names.map((name) => (name === 'bash' ? 'powershell' : name))) } /** * pi 会话运行时的主进程封装: * - 基于 AgentSessionRuntime 支持会话新建/切换(自动重挂事件订阅) * - 复用 ~/.pi 的配置、凭据与会话持久化(sessions//) * - 归一化 pi 事件为 AgentUiEvent 转发渲染层 */ export class AgentService { private runtime: AgentSessionRuntime | null = null private initPromise: Promise | null = null private listeners = new Set<(event: AgentUiEvent) => void>() private deltaQueue: AgentUiEvent[] = [] private flushTimer: NodeJS.Timeout | null = null private attached: AgentSession | null = null private unsubscribeAttached: (() => void) | null = null private settings: AppSettings = {} /** 当前工作区目录(agent cwd),会话按目录隔离 */ private workspaceDir = '' private approvalsRegistered = false /** 权限模式(Composer pill / 审批行为),持久化在应用设置里 */ private permissionMode: PermissionMode = 'confirm' /** 技能清单 token 估算缓存(随工作区失效) */ private skillsEstimate: { cwd: string; value: number } | null = null /** pi 全量发现的技能/命令快照(skillsOverride/promptsOverride 在每次 loader reload 时写入)。 * 被禁用的资源在 loader 里已过滤掉,设置页靠这份快照展示"已禁用"状态。 */ private resourceSnapshotSkills: Skill[] = [] private resourceSnapshotPrompts: PromptTemplate[] = [] readonly approvals = new ApprovalService() constructor() { // 设置同步读入 + 应用持久化的深浅色偏好(nativeTheme 会影响渲染层 // prefers-color-scheme 与原生控件配色;窗口创建前生效可避免首帧闪错色) this.loadSettings() nativeTheme.themeSource = this.settings.themeMode ?? 'system' } /** 工作区根目录:文档/Inkreach,每个子目录 = 一个工作区 */ get workspacesRoot(): string { return join(app.getPath('documents'), 'Inkreach') } private get defaultWorkspaceDir(): string { return join(this.workspacesRoot, 'Default') } private get cwd(): string { return this.workspaceDir || this.defaultWorkspaceDir } private get settingsFile(): string { return join(app.getPath('userData'), 'aso-settings.json') } private loadSettings(): AppSettings { try { const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as Partial this.settings = { workspace: typeof raw.workspace === 'string' ? raw.workspace : undefined, customWorkspaces: Array.isArray(raw.customWorkspaces) ? raw.customWorkspaces.filter((p): p is string => typeof p === 'string') : undefined, // 历史版本存过 autoEdit/plan,统一收敛回 confirm permissionMode: raw.permissionMode === 'full' ? 'full' : 'confirm', themeColor: typeof raw.themeColor === 'string' ? raw.themeColor : undefined, themeMode: raw.themeMode === 'light' || raw.themeMode === 'dark' ? raw.themeMode : 'system', disabledSkills: parseNameList(raw.disabledSkills), disabledCommands: parseNameList(raw.disabledCommands) } } catch { this.settings = { permissionMode: this.permissionMode, themeMode: 'system' } } this.permissionMode = this.settings.permissionMode ?? 'confirm' this.initWorkspaces() return this.settings } /** 确保根目录与默认工作区存在,并解析当前工作区(设置值失效时回退 Default) */ private initWorkspaces(): void { try { mkdirSync(this.defaultWorkspaceDir, { recursive: true }) } catch (err) { console.error('[agent-service] init workspaces failed:', err) } const saved = this.settings.workspace this.workspaceDir = saved && existsSync(saved) ? saved : this.defaultWorkspaceDir } // ---- 工作区 ---- async listWorkspaces(): Promise { try { mkdirSync(this.defaultWorkspaceDir, { recursive: true }) } catch { // 目录已存在或创建失败时仍尝试枚举 } const entries: WorkspaceMeta[] = [] const seen = new Set([this.workspacesRoot.toLowerCase()]) try { for (const d of readdirSync(this.workspacesRoot, { withFileTypes: true })) { if (!d.isDirectory()) continue const path = join(this.workspacesRoot, d.name) entries.push({ name: d.name, path }) seen.add(path.toLowerCase()) } } catch (err) { console.error('[agent-service] list workspaces failed:', err) } // 目录选择器添加的根目录之外的工作区 for (const p of this.settings.customWorkspaces ?? []) { if (!existsSync(p)) continue const normalized = join(p).replace(/[/\\]+$/, '') if (seen.has(normalized.toLowerCase())) continue seen.add(normalized.toLowerCase()) entries.push({ name: dirname(normalized) === normalized ? normalized : normalized.split(/[\\/]/).pop() || normalized, path: normalized }) } if (!entries.some((w) => w.name === 'Default')) { entries.unshift({ name: 'Default', path: this.defaultWorkspaceDir }) } return entries.sort((a, b) => (a.name === 'Default' ? -1 : b.name === 'Default' ? 1 : a.name.localeCompare(b.name))) } async createWorkspace(name: string): Promise { const trimmed = name.trim() if (!trimmed || /[\\/:*?"<>|]/.test(trimmed)) { throw new Error('工作区名称非法(不能包含 \\/:*?"<>|)') } const path = join(this.workspacesRoot, trimmed) if (existsSync(path)) { throw new Error(`工作区已存在: ${trimmed}`) } mkdirSync(path, { recursive: true }) return { name: trimmed, path } } /** 切换工作区:持久化设置后重建运行时(cwd 变化 → 会话列表随目录隔离)。 * 允许任意已存在目录;根目录之外的目录记入自定义工作区以便下次列出。 */ async openWorkspace(path: string): Promise { const resolved = join(path).replace(/[/\\]+$/, '') if (!existsSync(resolved)) { throw new Error(`工作区目录不存在: ${path}`) } if (samePath(resolved, this.cwd)) return if (dirname(resolved).toLowerCase() !== this.workspacesRoot.toLowerCase()) { const custom = new Set(this.settings.customWorkspaces ?? []) custom.add(resolved) this.settings.customWorkspaces = [...custom] } this.settings.workspace = resolved this.saveSettings() this.workspaceDir = resolved // 工作区切换:新 cwd 的最近会话才是目标(不做原会话恢复) await this.rebuildRuntime({ restoreSession: false }) } private saveSettings(): void { try { writeFileSync(this.settingsFile, JSON.stringify(this.settings, null, 2), 'utf8') } catch (err) { console.error('[agent-service] save settings failed:', err) } } onEvent(fn: (event: AgentUiEvent) => void): () => void { this.listeners.add(fn) return () => this.listeners.delete(fn) } private emit(event: AgentUiEvent): void { for (const fn of this.listeners) { try { fn(event) } catch (err) { console.error('[agent-service] listener error:', err) } } } /** 增量类事件合批下发,避免高频 delta 打爆 IPC */ private emitDelta(event: AgentUiEvent): void { this.deltaQueue.push(event) if (this.flushTimer) return this.flushTimer = setTimeout(() => { this.flushTimer = null const batch = this.deltaQueue this.deltaQueue = [] for (const e of batch) this.emit(e) }, DELTA_FLUSH_MS) } private getRuntime(): Promise { if (!this.initPromise) { this.initPromise = this.create().catch((err) => { this.initPromise = null throw err }) } return this.initPromise } private async create(): Promise { const cwd = this.cwd const agentDir = getAgentDir() this.settings = this.loadSettings() // 项目级第三方插件目录随工作区同步(.pi/extensions;目录变更即重扫描插件列表) getPluginHost().setProjectExtensionsDir(EXTERNAL_PLUGINS_ENABLED ? cwd : null) // 审批拦截只需注册一次(create 会随工作区切换多次执行) if (!this.approvalsRegistered) { this.approvalsRegistered = true this.approvals.onRequest((request) => { this.emit({ type: 'approval_request', request }) }) this.approvals.onSettle((id, approved, timedOut) => { this.emit({ type: 'approval_settled', id, approved, timedOut }) }) } const buildServices = (targetCwd: string, targetAgentDir: string) => createAgentSessionServices({ cwd: targetCwd, agentDir: targetAgentDir, resourceLoaderOptions: { extensionFactories: [ createApprovalExtension(this.approvals, TOOLS_REQUIRING_APPROVAL, () => this.permissionMode), // 插件聚合 extension:全部已启用插件在同一真实 ExtensionAPI 上下文里激活 getPluginHost().createAggregatedExtension() ], // 技能/命令启停:发现阶段按 AppSettings 过滤(被禁资源不进系统提示词与 / 命令面板)。 // 全量结果先快照给设置页(被禁项在 loader 结果里不可见);闭包在每次 reload 重跑, // 工作区切换/重建运行时后快照随之刷新。 skillsOverride: (base) => { this.resourceSnapshotSkills = base.skills const disabled = new Set(this.settings.disabledSkills ?? []) return { skills: base.skills.filter((s) => !disabled.has(s.name)), diagnostics: base.diagnostics } }, promptsOverride: (base) => { this.resourceSnapshotPrompts = base.prompts const disabled = new Set(this.settings.disabledCommands ?? []) return { prompts: base.prompts.filter((p) => !disabled.has(p.name)), diagnostics: base.diagnostics } } } }) const initialServices = await buildServices(cwd, agentDir) // 每次切换/新建时由 runtime 调用;cwd 变化时重建服务,否则复用 const factory: CreateAgentSessionRuntimeFactory = async ({ cwd: nextCwd, agentDir: nextAgentDir, sessionManager, sessionStartEvent }) => { const services = nextCwd === initialServices.cwd && nextAgentDir === initialServices.agentDir ? initialServices : await buildServices(nextCwd, nextAgentDir) const result = await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent // 业务工具(browserTools 等)由插件经 pi.registerTool 注册(聚合 extension), // 此处不再传 customTools }) // 会话创建是唯一收口点:新建/切换/分叉/重载都会经过这里,统一应用 Windows PowerShell 偏好 preferPowerShell(result.session) return { ...result, services, diagnostics: services.diagnostics } } const sessionManager = SessionManager.continueRecent(cwd) const runtime = await createAgentSessionRuntime(factory, { cwd, agentDir, sessionManager }) // 会话被替换(new/switch/fork)后重挂事件订阅并通知渲染层 runtime.setRebindSession(async (session) => { this.attach(session) this.emitSwitched(session) }) this.attach(runtime.session) // 此前 runtime 从未挂到实例上(rebuildRuntime 的 dispose 永远拿不到旧实例),补上 this.runtime = runtime return runtime } private attach(session: AgentSession): void { if (this.attached === session) return this.unsubscribeAttached?.() this.attached = session this.unsubscribeAttached = session.subscribe((event) => this.handleEvent(event)) } private emitSwitched(session: AgentSession): void { const state = this.buildState(session) // 工具状态不再随事件重发:渲染层用 historyToTools 从 messages 自行重建, // 重复克隆一遍全量工具参数/结果是长会话切换卡顿的大头(历史被序列化两次) this.emit({ type: 'session_switched', state, messages: session.messages.map((m) => toCloneable(m)) }) // 切换/回溯后立刻带上一次统计(上下文容量、缓存命中率),Usage Card 无需等第一回合 void this.emitStats() } async ensureReady(): Promise { try { const runtime = await this.getRuntime() this.emitSwitched(runtime.session) return this.buildState(runtime.session) } catch (err) { const message = err instanceof Error ? err.message : String(err) this.emit({ type: 'session_error', message }) throw err } } private handleEvent(event: AgentSessionEvent): void { switch (event.type) { case 'agent_start': this.emit({ type: 'agent_start' }) break case 'agent_end': this.emit({ type: 'agent_end' }) break case 'agent_settled': this.emit({ type: 'agent_settled' }) void this.emitStats() break case 'message_start': this.emit({ type: 'message_start', role: readRole(event.message) }) break case 'message_update': { const inner = event.assistantMessageEvent if (inner?.type === 'text_delta' && inner.delta) { this.emitDelta({ type: 'delta', kind: 'text', delta: inner.delta }) } else if (inner?.type === 'thinking_delta' && inner.delta) { this.emitDelta({ type: 'delta', kind: 'thinking', delta: inner.delta }) } break } case 'message_end': this.emit({ type: 'message_end', message: toCloneable(event.message) }) // 每条 assistant 消息落地都会刷新 usage → 上下文容量/缓存命中率即时更新 if (readRole(event.message) === 'assistant') void this.emitStats() break case 'tool_execution_start': this.emit({ type: 'tool_start', toolCallId: event.toolCallId, toolName: event.toolName, args: toCloneable(event.args) }) break case 'tool_execution_update': this.emit({ type: 'tool_update', toolCallId: event.toolCallId, toolName: event.toolName, partialResult: toCloneable(event.partialResult) }) break case 'tool_execution_end': this.emit({ type: 'tool_end', toolCallId: event.toolCallId, toolName: event.toolName, result: toCloneable(event.result), isError: event.isError }) break case 'queue_update': this.emit({ type: 'queue_update', steering: [...event.steering], followUp: [...event.followUp] }) break case 'auto_retry_start': this.emit({ type: 'retry', attempt: event.attempt, maxAttempts: event.maxAttempts, errorMessage: event.errorMessage }) break case 'session_info_changed': this.emit({ type: 'session_info', name: event.name }) break case 'thinking_level_changed': this.emit({ type: 'thinking_level_changed', level: event.level }) if (this.attached) this.emit({ type: 'state', state: this.buildState(this.attached) }) break case 'compaction_start': this.emit({ type: 'compaction_start', reason: event.reason }) break case 'compaction_end': { this.emit({ type: 'compaction_end', reason: event.reason, aborted: event.aborted, summary: event.result?.summary, tokensBefore: event.result?.tokensBefore, errorMessage: event.errorMessage, // 压缩替换了上下文历史(插入 compactionSummary 通知行),渲染层就地换血 messages: (this.attached?.messages ?? []).map((m) => toCloneable(m)) }) void this.emitStats() break } default: break } } private buildState(session: AgentSession): SessionState { const model = session.model const modelInfo: ModelInfo | undefined = model ? { id: String(model.id ?? ''), provider: String(model.provider ?? ''), name: String(model.name ?? model.id ?? '') } : undefined const thinkingLevels = session.supportsThinking() ? session.getAvailableThinkingLevels() : [] return { sessionId: session.sessionId, sessionName: session.sessionName, model: modelInfo, streaming: session.isStreaming, activeTools: session.getActiveToolNames(), messageCount: session.messages.length, workspace: this.cwd, permissionMode: this.permissionMode, thinkingLevel: session.thinkingLevel, thinkingLevels, supportsThinking: thinkingLevels.length > 0, isCompacting: session.isCompacting, autoCompactionEnabled: session.autoCompactionEnabled } } /** 上下文容量详情:getContextUsage + 按 ZCode 卡片类目的 token 估算(chars/4,与 pi 一致)。 * 类目:消息 / 自定义工具 / 系统工具 / 系统提示词 / 技能 / 其他; * 技能块注入在系统提示词内,从提示词估算中拆出(不重复计数)。 */ private buildContextInfo(session: AgentSession): ContextUsageInfo { const agg = this.aggregateSession(session) const breakdown: ContextBreakdownEntry[] = [ { key: 'messages', label: '消息', tokens: agg.breakdown.get('messages') ?? 0 }, { key: 'customTools', label: '自定义工具', tokens: agg.breakdown.get('customTools') ?? 0 }, { key: 'builtinTools', label: '系统工具', tokens: agg.breakdown.get('builtinTools') ?? 0 }, { key: 'systemPrompt', label: '系统提示词', tokens: 0 }, { key: 'skills', label: '技能', tokens: 0 }, { key: 'other', label: '其他', tokens: agg.breakdown.get('other') ?? 0 } ] const byKey = new Map(breakdown.map((b) => [b.key, b])) const systemTokens = Math.ceil(session.systemPrompt.length / 4) const skillsTokens = this.estimateSkillsTokens(session) byKey.get('systemPrompt')!.tokens = Math.max(0, systemTokens - skillsTokens) byKey.get('skills')!.tokens = skillsTokens const usage = session.getContextUsage() return { tokens: usage && typeof usage.tokens === 'number' ? usage.tokens : null, contextWindow: usage?.contextWindow ?? 0, percent: usage && typeof usage.percent === 'number' ? usage.percent : null, ...(agg.lastOutputTokens !== undefined ? { lastOutputTokens: agg.lastOutputTokens } : {}), breakdown } } /** 技能清单注入系统提示词的 token 估算(按 cwd 缓存;技能清单会话内极少变化)。 * 数据源与系统提示词同源(resource loader,覆盖 ~/.agents/skills 等全部默认位置) */ private estimateSkillsTokens(session: AgentSession): number { if (this.skillsEstimate && this.skillsEstimate.cwd === this.cwd) return this.skillsEstimate.value let value = 0 try { const loaded = session.resourceLoader.getSkills() value = Math.ceil(loaded.skills.reduce((sum, s) => sum + s.name.length + s.description.length + 8, 0) / 4) } catch { value = 0 } this.skillsEstimate = { cwd: this.cwd, value } return value } // ---- 会话统计增量聚合 ---- // emitStats 每条 assistant 消息落地都会触发;长会话下每次全量遍历估算 // token 是 O(全部消息)。消息列表正常只追加:以「已聚合前缀长度 + 前缀 // 末条消息引用」为锚点,每次只聚合新增尾部;压缩/回溯/会话切换/运行时 // 重建后消息对象引用必变(或长度回退),锚点失配即自动整体重算。 private sessionAgg: { count: number anchor: unknown cacheInput: number cacheRead: number cacheWrite: number toolCalls: number toolErrors: number /** 消息侧 breakdown(systemPrompt/skills 在外层另算) */ breakdown: Map lastOutputTokens: number | undefined } | null = null private aggregateSession(session: AgentSession) { const messages = session.messages let agg = this.sessionAgg if ( agg === null || messages.length < agg.count || (agg.count > 0 && messages[agg.count - 1] !== agg.anchor) ) { agg = { count: 0, anchor: null, cacheInput: 0, cacheRead: 0, cacheWrite: 0, toolCalls: 0, toolErrors: 0, breakdown: new Map([ ['messages', 0], ['customTools', 0], ['builtinTools', 0], ['other', 0] ]), lastOutputTokens: undefined } } for (let i = agg.count; i < messages.length; i++) { const m = messages[i] as { role?: string toolName?: unknown content?: unknown isError?: unknown usage?: { input?: unknown; output?: unknown; cacheRead?: unknown; cacheWrite?: unknown } } const bump = (key: string): void => { agg!.breakdown.set(key, (agg!.breakdown.get(key) ?? 0) + estimateMessageTokens(messages[i])) } switch (m.role) { case 'assistant': { bump('messages') if (m.usage) { agg.cacheInput += Number(m.usage.input ?? 0) || 0 agg.cacheRead += Number(m.usage.cacheRead ?? 0) || 0 agg.cacheWrite += Number(m.usage.cacheWrite ?? 0) || 0 if (typeof m.usage.output === 'number') agg.lastOutputTokens = m.usage.output } if (Array.isArray(m.content)) { for (const block of m.content as Array<{ type?: unknown }>) { if (block && typeof block === 'object' && block.type === 'toolCall') agg.toolCalls += 1 } } break } case 'user': bump('messages') break case 'toolResult': { if (m.isError === true) agg.toolErrors += 1 bump( typeof m.toolName === 'string' && CUSTOM_TOOL_NAMES.has(m.toolName) ? 'customTools' : 'builtinTools' ) break } // bash 执行、扩展注入、压缩/分支摘要等注入上下文的消息 case 'bashExecution': case 'custom': case 'compactionSummary': case 'branchSummary': bump('other') break default: break } } agg.count = messages.length agg.anchor = messages.length > 0 ? messages[messages.length - 1] : null this.sessionAgg = agg return agg } private async emitStats(): Promise { try { const runtime = await this.getRuntime() const session = runtime.session const stats = session.getSessionStats() const usage = session.getContextUsage() const agg = this.aggregateSession(session) // 缓存命中率:读缓存 / 全部提示 token(input + cacheRead + cacheWrite), // 按本会话所有 assistant 响应聚合,0 表示会话内尚无任何响应 const denominator = agg.cacheInput + agg.cacheRead + agg.cacheWrite const info: SessionStatsInfo = { tokens: { ...stats.tokens }, cost: stats.cost, contextPercent: usage && typeof usage.percent === 'number' ? Math.round(usage.percent) : undefined, context: this.buildContextInfo(session), ...(denominator > 0 ? { cacheHitRate: Math.round((agg.cacheRead / denominator) * 100), cacheTokens: { read: agg.cacheRead, write: agg.cacheWrite, input: agg.cacheInput } } : {}), ...(agg.toolCalls > 0 ? { toolStats: { calls: agg.toolCalls, successRate: Math.round(((agg.toolCalls - agg.toolErrors) / agg.toolCalls) * 100) } } : {}) } this.emit({ type: 'stats', stats: info }) } catch { // 统计失败不打断主流程 } } async getState(): Promise { const runtime = await this.getRuntime() return this.buildState(runtime.session) } async send(text: string, images?: SendImage[]): Promise { const runtime = await this.getRuntime() const session = runtime.session const imageContent = images?.length ? images.map((img) => ({ type: 'image' as const, data: img.data, mimeType: img.mimeType })) : undefined const options = { ...(session.isStreaming ? { streamingBehavior: 'followUp' as const } : {}), ...(imageContent ? { images: imageContent } : {}) } await session.prompt(text, Object.keys(options).length > 0 ? options : undefined) } // ---- Chatbox 资源(/ 命令、/skill: 技能)---- /** 重建排队消息(编辑/删除/排序/置顶都走这里:clear 后按新顺序重推)。 * 空闲时队列会被 pi 立即消费,无需(也不能)重推。 */ async setQueue(steering: string[], followUp: string[]): Promise { const runtime = await this.getRuntime() const session = runtime.session if (!session.isStreaming) return session.clearQueue() for (const text of steering) void session.steer(text) for (const text of followUp) void session.followUp(text) } async listChatResources(): Promise { const runtime = await this.getRuntime() // 与系统提示词同源:resource loader 的技能发现(含 ~/.agents/skills 与工作区 .agents/skills) const skills = runtime.session.resourceLoader.getSkills().skills.map((s) => ({ name: s.name, description: s.description })) const commands = runtime.session.promptTemplates.map((t) => ({ name: t.name, description: t.description ?? '', kind: 'prompt' as const })) return { commands, skills } } /** 设置页「技能 / 命令」详情列表(含来源文件与资源目录,供打开/刷新)。 * 技能取自 resource loader(与系统提示词同源,覆盖 ~/.agents/skills 与工作区 .agents/skills)。 * 数据源用 override 写入的全量快照:被禁资源在会话里已过滤,只有快照还看得到。 */ async listResourceDetails(): Promise { const agentDir = getAgentDir() const dirs = { globalSkills: join(agentDir, 'skills'), globalAgentsSkills: join(homedir(), '.agents', 'skills'), projectSkills: join(this.cwd, '.pi', 'skills'), projectAgentsSkills: join(this.cwd, '.agents', 'skills'), globalCommands: join(agentDir, 'prompts'), projectCommands: join(this.cwd, '.pi', 'prompts') } const disabledSkills = new Set(this.settings.disabledSkills ?? []) const disabledCommands = new Set(this.settings.disabledCommands ?? []) const skills: SkillDetail[] = [] try { const runtime = await this.getRuntime() const all = this.resourceSnapshotSkills.length > 0 ? this.resourceSnapshotSkills : runtime.session.resourceLoader.getSkills().skills for (const s of all) { skills.push({ name: s.name, description: s.description, filePath: s.filePath, // 与 resource loader 的默认归类一致:无来源信息时按用户级 scope: s.sourceInfo?.scope === 'project' ? 'project' : 'user', disableModelInvocation: Boolean(s.disableModelInvocation), disabled: disabledSkills.has(s.name) }) } } catch (err) { console.error('[agent-service] load skill details failed:', err) } const commands: CommandDetail[] = [] try { const runtime = await this.getRuntime() const all = this.resourceSnapshotPrompts.length > 0 ? this.resourceSnapshotPrompts : runtime.session.promptTemplates commands.push( ...all.map((t) => ({ name: t.name, description: t.description ?? '', argumentHint: t.argumentHint || undefined, filePath: t.filePath, scope: t.sourceInfo?.scope === 'user' ? ('user' as const) : ('project' as const), disabled: disabledCommands.has(t.name) })) ) } catch (err) { console.error('[agent-service] list command details failed:', err) } skills.sort((a, b) => a.name.localeCompare(b.name)) commands.sort((a, b) => a.name.localeCompare(b.name)) return { skills, commands, dirs } } /** 启用/禁用单个技能或命令:持久化到 AppSettings 后重建运行时(发现阶段过滤随之生效) */ async setResourceEnabled(kind: 'skill' | 'command', name: string, enabled: boolean): Promise { const trimmed = name.trim() if (!trimmed) throw new Error('资源名不能为空') const field = kind === 'skill' ? ('disabledSkills' as const) : ('disabledCommands' as const) const next = new Set(this.settings[field] ?? []) if (enabled) next.delete(trimmed) else next.add(trimmed) this.settings = { ...this.settings, [field]: [...next].sort((a, b) => a.localeCompare(b)) } this.saveSettings() await this.rebuildRuntime() const session = this.runtime?.session if (session) this.emit({ type: 'state', state: this.buildState(session) }) } // ---- 工作区文件(@ 引用)---- async listWorkspaceFiles(): Promise { const root = this.cwd const out: WorkspaceFile[] = [] const SKIP = new Set(['.git', 'node_modules', 'dist', 'out', 'build', '.pi', '.next', 'coverage']) const walk = (dir: string, depth: number): void => { if (depth > 4 || out.length >= 400) return let entries: import('node:fs').Dirent[] try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return } for (const entry of entries) { if (out.length >= 400) return if (entry.name.startsWith('.') && entry.name !== '.env') continue const full = join(dir, entry.name) const rel = full.slice(root.length + 1).replace(/\\/g, '/') if (entry.isDirectory()) { if (SKIP.has(entry.name)) continue out.push({ path: rel, name: entry.name, isDir: true }) walk(full, depth + 1) } else if (entry.isFile()) { out.push({ path: rel, name: entry.name, isDir: false }) } } } walk(root, 0) return out } // ---- 附件(+ 选择文件)---- async pickAttachments(parent?: Electron.BrowserWindow): Promise { const options = { title: '添加附件', properties: ['openFile', 'multiSelections'] as Array<'openFile' | 'multiSelections'>, filters: [ { name: '常用文件', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'pdf', 'md', 'txt', 'csv', 'json', 'xlsx', 'docx'] }, { name: '所有文件', extensions: ['*'] } ] } const result = parent ? await dialog.showOpenDialog(parent, options) : await dialog.showOpenDialog(options) if (result.canceled || result.filePaths.length === 0) return [] return result.filePaths.map((path) => this.prepareAttachment(path)) } prepareAttachment(path: string): AttachmentInfo { const name = path.split(/[\\/]/).pop() ?? path const ext = name.toLowerCase().split('.').pop() ?? '' const IMAGE_MIME: Record = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp' } const mimeType = IMAGE_MIME[ext] ?? '' if (mimeType) { try { const stat = statSync(path) if (stat.size > 8 * 1024 * 1024) { throw new Error(`图片过大(>8MB): ${name}`) } return { name, path, mimeType, data: readFileSync(path).toString('base64') } } catch (err) { console.error('[agent-service] read attachment failed:', err) return { name, path, mimeType: '', data: '' } } } // 非图片:小体积且为文本的文件读出内容,发送时注入 块;二进制仅留路径引用 try { const stat = statSync(path) if (stat.size <= 512 * 1024) { const buf = readFileSync(path) const isText = TEXT_EXTS.has(ext) || looksLikeText(buf) if (isText) { return { name, path, mimeType: '', data: '', text: buf.toString('utf8') } } } } catch (err) { console.error('[agent-service] read attachment failed:', err) } return { name, path, mimeType: '', data: '' } } async steer(text: string): Promise { const runtime = await this.getRuntime() await runtime.session.steer(text) } async stop(): Promise { if (!this.runtime && !this.initPromise) return const runtime = await this.getRuntime() // 压缩/分支摘要独立于 agent run,abort() 不会打断它们,停止时一并取消 runtime.session.abortCompaction() runtime.session.abortBranchSummary() await runtime.session.abort() } // ---- 会话管理 ---- async listSessions(): Promise { const infos = await SessionManager.list(this.cwd) // 空会话(一条消息都没发过)不上列表:第一句发出之后才在侧栏可见 const metas = infos .filter((info) => info.messageCount > 0) .sort((a, b) => b.modified.getTime() - a.modified.getTime()) .map((info) => toSessionMeta(info)) // 未落盘的活跃会话兜底合并进列表(同样要求已有消息) const session = this.runtime?.session if (session && session.messages.length > 0 && !metas.some((m) => samePath(m.path, session.sessionFile ?? ''))) { const now = new Date().toISOString() metas.unshift({ id: session.sessionId, path: session.sessionFile ?? '', name: session.sessionName, firstMessage: firstUserText(session.messages), created: now, modified: now, messageCount: session.messages.length }) } return metas } async newSession(): Promise { const runtime = await this.getRuntime() if (runtime.session.isStreaming) { await runtime.session.abort() } await runtime.newSession() // rebind 回调负责发 session_switched } async openSession(path: string): Promise { const runtime = await this.getRuntime() if (runtime.session.isStreaming) { await runtime.session.abort() } await runtime.switchSession(path) } async renameSession(name: string): Promise { const runtime = await this.getRuntime() runtime.session.setSessionName(name) this.emit({ type: 'session_info', name }) } async deleteSession(path: string): Promise { const runtime = await this.getRuntime() const active = runtime.session.sessionFile await shell.trashItem(path).catch((err: unknown) => { throw new Error(`移入回收站失败: ${err instanceof Error ? err.message : String(err)}`) }) if (active && samePath(active, path)) { await runtime.newSession() } } // ---- 模型与凭据 ---- /** 自定义提供商存放在 pi 的 models.json(与 pi CLI 共享,运行时自动合并进目录) */ private get modelsJsonFile(): string { return join(getAgentDir(), 'models.json') } private readModelsConfig(): { providers: Record> } { try { const raw = JSON.parse(readFileSync(this.modelsJsonFile, 'utf8')) as Record if (raw && typeof raw.providers === 'object' && raw.providers !== null && !Array.isArray(raw.providers)) { return { providers: raw.providers as Record> } } } catch { // 文件不存在或损坏时按空配置处理(pi 侧同样容错) } return { providers: {} } } listCustomProviders(): CustomProviderConfig[] { const { providers } = this.readModelsConfig() return Object.entries(providers) .map(([id, p]) => ({ id, name: typeof p.name === 'string' ? p.name : id, baseUrl: typeof p.baseUrl === 'string' ? p.baseUrl : '', apiKey: typeof p.apiKey === 'string' ? p.apiKey : '', api: typeof p.api === 'string' ? p.api : 'openai-completions', models: Array.isArray(p.models) ? p.models .map((m) => String((m as { id?: unknown })?.id ?? '')) .filter(Boolean) : [] })) .sort((a, b) => a.id.localeCompare(b.id)) } /** 保存(新增/覆盖)自定义提供商:写 models.json 后重建运行时使其生效 */ async saveCustomProvider(config: CustomProviderConfig): Promise { const id = config.id.trim() if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(id)) { throw new Error('提供商 ID 只能包含字母、数字、- 和 _,且以字母或数字开头') } const baseUrl = config.baseUrl.trim() if (!/^https?:\/\//.test(baseUrl)) throw new Error('API 地址需以 http(s):// 开头') if (!config.apiKey.trim()) throw new Error('请填写 API Key') const modelIds = [...new Set(config.models.map((m) => m.trim()).filter(Boolean))] if (modelIds.length === 0) throw new Error('请至少填写一个模型 ID') const doc = this.readModelsConfig() doc.providers[id] = { name: config.name.trim() || id, baseUrl, apiKey: config.apiKey.trim(), api: config.api, models: modelIds.map((mid) => ({ id: mid, name: mid, reasoning: false, input: ['text'], contextWindow: 128000, maxTokens: 8192 })) } try { writeFileSync(this.modelsJsonFile, JSON.stringify(doc, null, 2), 'utf8') } catch (err) { throw new Error(`写入 models.json 失败: ${err instanceof Error ? err.message : String(err)}`) } await this.rebuildRuntime() const session = this.runtime?.session if (session) this.emit({ type: 'state', state: this.buildState(session) }) } async deleteCustomProvider(id: string): Promise { const doc = this.readModelsConfig() if (!doc.providers[id]) throw new Error(`自定义提供商不存在: ${id}`) delete doc.providers[id] try { writeFileSync(this.modelsJsonFile, JSON.stringify(doc, null, 2), 'utf8') } catch (err) { throw new Error(`写入 models.json 失败: ${err instanceof Error ? err.message : String(err)}`) } await this.rebuildRuntime() const session = this.runtime?.session if (session) this.emit({ type: 'state', state: this.buildState(session) }) } async getModelCatalog(): Promise { const runtime = await this.getRuntime() const mr = runtime.session.modelRuntime const credentials = await mr.listCredentials() const credSet = new Set(credentials.map((c) => `${c.providerId}:${c.type}`)) // 自定义提供商的 apiKey 存在 models.json(不进凭据存储),有 key 即视为已配置 const customKeyed = new Set( Object.entries(this.readModelsConfig().providers) .filter(([, v]) => Boolean((v as { apiKey?: unknown })?.apiKey)) .map(([id]) => id) ) const providers = mr .getProviders() .map((p) => { const pid = String(p.id ?? '') return { id: pid, name: String(p.name ?? pid), hasApiKey: credSet.has(`${pid}:api_key`) || customKeyed.has(pid), isOAuth: credSet.has(`${pid}:oauth`), models: mr .getModels(pid) .map((m) => ({ id: String(m.id ?? ''), name: String(m.name ?? m.id ?? ''), contextWindow: Number(m.contextWindow ?? 0), reasoning: Boolean(m.reasoning) })) } }) // 已配置凭据的排前面,其余按名称排序 .sort((a, b) => { const av = a.hasApiKey || a.isOAuth ? 0 : 1 const bv = b.hasApiKey || b.isOAuth ? 0 : 1 return av - bv || a.name.localeCompare(b.name) }) return { providers } } async setApiKey(providerId: string, apiKey: string): Promise { const runtime = await this.getRuntime() const mr = runtime.session.modelRuntime // login 走凭据存储的 modify(持久化到 ~/.pi/agent/auth.json,与 pi CLI 共享) await mr.login(providerId, 'api_key', { prompt: async () => apiKey, notify: async () => undefined }) this.emit({ type: 'state', state: this.buildState(runtime.session) }) } async logoutProvider(providerId: string): Promise { const runtime = await this.getRuntime() await runtime.session.modelRuntime.logout(providerId) this.emit({ type: 'state', state: this.buildState(runtime.session) }) } async setModel(providerId: string, modelId: string): Promise { const runtime = await this.getRuntime() const mr = runtime.session.modelRuntime const model = mr.getModel(providerId, modelId) if (!model) { throw new Error(`未知模型: ${providerId}/${modelId}`) } await runtime.session.setModel(model, { persist: true }) this.emit({ type: 'state', state: this.buildState(runtime.session) }) } // ---- 思考等级 / 压缩 / 分支摘要(pi 原生能力) ---- /** 设置思考等级(persist 到全局默认值);等级按模型能力校验 */ async setThinkingLevel(level: ThinkingLevel): Promise { const runtime = await this.getRuntime() const session = runtime.session if (!session.getAvailableThinkingLevels().includes(level)) { throw new Error(`当前模型不支持思考等级: ${level}`) } // thinking_level_changed 事件会回推 state;这里直接广播兜底 session.setThinkingLevel(level, { persist: true }) this.emit({ type: 'state', state: this.buildState(session) }) } /** 手动压缩上下文(/compact): abort 当前运行后生成摘要并替换历史 */ async compactSession(customInstructions?: string): Promise<{ summary: string; tokensBefore: number }> { const runtime = await this.getRuntime() const trimmed = customInstructions?.trim() const result = await runtime.session.compact(trimmed || undefined) return { summary: result.summary, tokensBefore: result.tokensBefore } } /** 取消进行中的压缩 */ async abortCompaction(): Promise { this.runtime?.session.abortCompaction() } /** 开关自动压缩(阈值/溢出触发;pi settings 持久化) */ async setAutoCompactionEnabled(enabled: boolean): Promise { const runtime = await this.getRuntime() runtime.session.setAutoCompactionEnabled(enabled) this.emit({ type: 'state', state: this.buildState(runtime.session) }) } /** 会话树上的可回溯点(当前分支上的用户消息,按序) */ async listForkPoints(): Promise { const runtime = await this.getRuntime() return runtime.session.getUserMessagesForForking().map((p) => ({ entryId: p.entryId, text: p.text })) } /** 回溯到某个用户消息(同会话文件内切换分支);summarize=true 时为被离开的分支生成分支摘要 */ async navigateTree(entryId: string, options?: NavigateTreeOptions): Promise { const runtime = await this.getRuntime() const session = runtime.session if (session.isStreaming) { await session.abort() } const result = await session.navigateTree(entryId, { ...(options?.summarize ? { summarize: true } : {}), ...(options?.customInstructions?.trim() ? { customInstructions: options.customInstructions.trim() } : {}) }) if (result.cancelled) return this.emitSwitched(session) } /** 在会话末尾分叉出新会话(pi fork:复制到新会话文件,原会话保留不动)。 * fork 后 runtime 重挂新会话,rebind 回调负责发 session_switched。 */ async forkSession(): Promise { const runtime = await this.getRuntime() const session = runtime.session if (session.isStreaming) { await session.abort() } const leafId = session.sessionManager.getLeafId() if (!leafId) { throw new Error('会话还没有可分叉的内容') } await this.forkSessionAt(leafId, 'at') } /** 从会话树上的任意锚点分叉新会话:'at' = 新会话包含该条为止;'before' = 到其父条目为止 */ async forkSessionAt(entryId: string, position: 'at' | 'before'): Promise { const runtime = await this.getRuntime() const session = runtime.session if (session.isStreaming) { await session.abort() } const result = await runtime.fork(entryId, { position }) if (result.cancelled) return } // ---- 应用设置 ---- getSettings(): AppSettings { return { ...this.settings } } /** 当前生效的系统提示词(pi 原生提示词 + 技能注入,只读展示用) */ async getSystemPrompt(): Promise { const runtime = await this.getRuntime() return runtime.session.systemPrompt } /** 深浅色模式:写入 Electron nativeTheme(渲染层 prefers-color-scheme 随之变化)并持久化 */ setThemeMode(mode: ThemeMode): void { this.settings = { ...this.settings, themeMode: mode } this.saveSettings() nativeTheme.themeSource = mode } /** 主题色偏好(纯外观,仅持久化;渲染层负责应用) */ setThemeColor(id: string): void { this.settings = { ...this.settings, themeColor: id } this.saveSettings() } /** 切换权限模式:即时生效(审批扩展每笔工具调用实时读取),并广播给渲染层 */ setPermissionMode(mode: PermissionMode): void { this.permissionMode = mode this.settings = { ...this.settings, permissionMode: mode } this.saveSettings() const session = this.runtime?.session ?? this.attached if (session) { this.emit({ type: 'state', state: this.buildState(session) }) } } /** 重建运行时(插件重载/启停、工作区切换时调用,extensionFactories 重跑)。 * restoreSession=true(插件重载):重建对渲染层尽量透明—— * - 进行中的回合先经 abort 正常结算(事件照常送达,渲染层自行收口), * 再退订/销毁旧运行时——dispose 不含 abort,直接销毁会留下悬空请求; * - 重建走 continueRecent(最近会话):用户开着的不是它时切回原会话; * 原会话是未落盘的空新会话时退回空会话,避免静默接管别的会话; * - 会话未变化时只推轻量 state,不广播 session_switched(该事件语义 * 保留给真正的会话变更,全量换血会重挂载渲染层全部消息)。 * restoreSession=false(工作区切换):新 cwd 的最近会话即目标状态, * 直接全量广播切换。 */ async rebuildRuntime(options?: { restoreSession?: boolean }): Promise { const restoreSession = options?.restoreSession !== false const old = this.runtime this.runtime = null this.initPromise = null if (old?.session.isStreaming) { await old.session.abort().catch(() => undefined) } // abort 结算会落盘当前回合,未持久会话的文件路径此时才物化——在其后读取 const previousPath = old?.session.sessionFile ?? null this.unsubscribeAttached?.() this.unsubscribeAttached = null this.attached = null if (old) { await old.dispose().catch((err: unknown) => { console.error('[agent-service] runtime dispose error:', err) }) } let runtime: AgentSessionRuntime try { runtime = await this.getRuntime() } catch (err) { const message = err instanceof Error ? err.message : String(err) this.emit({ type: 'session_error', message }) throw err } if (!restoreSession) { this.emitSwitched(runtime.session) return } const restored = runtime.session.sessionFile ?? '' if (previousPath) { if (!samePath(restored, previousPath) && existsSync(previousPath)) { // 被拽到了最近会话:切回用户原本打开的会话(rebind 发 session_switched,真实变更) await runtime.switchSession(previousPath) return } } else if (runtime.session.messages.length > 0) { // 原会话是未落盘的空新会话:不接管最近会话,回到空会话 await runtime.newSession() return } this.emit({ type: 'state', state: this.buildState(runtime.session) }) } } /** 视为文本的扩展名(直接按 UTF-8 读入,随消息注入) */ const TEXT_EXTS = new Set([ 'md', 'markdown', 'txt', 'csv', 'tsv', 'json', 'jsonl', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', 'env', 'js', 'mjs', 'cjs', 'jsx', 'ts', 'mts', 'cts', 'tsx', 'py', 'rb', 'rs', 'go', 'java', 'kt', 'kts', 'swift', 'c', 'h', 'cpp', 'hpp', 'cc', 'cs', 'php', 'html', 'htm', 'css', 'scss', 'less', 'vue', 'svelte', 'astro', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'psm1', 'bat', 'cmd', 'sql', 'graphql', 'gql', 'prisma', 'dockerfile', 'makefile', 'gradle', 'gitignore', 'editorconfig', 'log', 'xml' ]) /** 无扩展名/罕见扩展名时按内容嗅探:首 4KB 出现 NUL 即视为二进制 */ function looksLikeText(buf: Buffer): boolean { const probe = buf.subarray(0, 4096) return !probe.includes(0) } function toSessionMeta(info: SessionInfo): SessionMeta { return { id: info.id, path: info.path, name: info.name, firstMessage: info.firstMessage, created: info.created.toISOString(), modified: info.modified.toISOString(), messageCount: info.messageCount } } function samePath(a: string, b: string): boolean { return a.replace(/\\/g, '/').toLowerCase() === b.replace(/\\/g, '/').toLowerCase() } /** 事件消息对象转为可结构化克隆的普通对象(剥离函数/循环引用等不可克隆值)。 * structuredClone 远快于 JSON 往返——切换会话时整段历史都要过一遍这里, * 长上下文下 JSON 序列化+反序列化是主进程最重的同步开销。 */ function toCloneable(value: unknown): unknown { if (value === null || typeof value !== 'object') return value ?? null try { return structuredClone(value) } catch { try { return JSON.parse(JSON.stringify(value)) } catch { return null } } } /** 消息级 token 估算缓存:消息对象落定后不可变(pi 只追加),按引用缓存。 * emitStats 在每条 assistant 消息后都会全量重扫,不缓存的话整个会话是 O(n²) 的重复估算。 */ const messageTokenCache = new WeakMap() function estimateMessageTokens(message: object): number { let tokens = messageTokenCache.get(message) if (tokens === undefined) { tokens = estimateTokens(message as Parameters[0]) messageTokenCache.set(message, tokens) } return tokens } function readRole(message: unknown): string { if (message && typeof message === 'object' && 'role' in message) { return String((message as { role: unknown }).role) } return 'unknown' } /** 提取第一条用户消息的纯文本(新会话未落盘时用作列表标题) */ function firstUserText(messages: unknown[]): string { for (const message of messages) { if (readRole(message) !== 'user') continue const content = (message as { content?: unknown }).content if (typeof content === 'string' && content.trim()) return content if (Array.isArray(content)) { const text = content .map((block) => block && typeof block === 'object' && typeof (block as { text?: unknown }).text === 'string' ? (block as { text: string }).text : '' ) .join('') .trim() if (text) return text } } return '' }