import { app, dialog, shell } from 'electron' import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { createAgentSessionRuntime, createAgentSessionServices, createAgentSessionFromServices, getAgentDir, loadSkills, SessionManager, type AgentSession, type AgentSessionEvent, type AgentSessionRuntime, type CreateAgentSessionRuntimeFactory, type SessionInfo } from '@earendil-works/pi-coding-agent' import { ApprovalService, createApprovalExtension } from './approval' import { demoTools, TOOLS_REQUIRING_APPROVAL } from '../tools/demo-tools' import type { AgentUiEvent, AppSettings, AttachmentInfo, ChatResources, CommandDetail, CustomProviderConfig, ModelCatalog, ModelInfo, PermissionMode, ResourceDetails, SendImage, SessionMeta, SessionState, SessionStatsInfo, SkillDetail, WorkspaceFile, WorkspaceMeta } from '@shared/types' /** 增量事件合批的刷新间隔(ms),参考 pi-web 的 60ms 快照节流经验取略低值 */ const DELTA_FLUSH_MS = 40 /** * 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 = { systemPrompt: '' } /** 当前工作区目录(agent cwd),会话按目录隔离 */ private workspaceDir = '' private approvalsRegistered = false /** 权限模式(Composer pill / 审批行为),持久化在应用设置里 */ private permissionMode: PermissionMode = 'confirm' readonly approvals = new ApprovalService() /** 工作区根目录:文档/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 = { systemPrompt: typeof raw.systemPrompt === 'string' ? raw.systemPrompt : '', 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 } } catch { this.settings = { systemPrompt: '', permissionMode: this.permissionMode } } 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 await this.rebuildRuntime() } 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) } /** 供 IPC 层主动广播事件(如审批结果同步到所有窗口) */ broadcastEvent(event: AgentUiEvent): void { this.emit(event) } 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() // 审批拦截只需注册一次(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: { ...(this.settings.systemPrompt.trim() ? { systemPrompt: this.settings.systemPrompt } : {}), extensionFactories: [ createApprovalExtension(this.approvals, TOOLS_REQUIRING_APPROVAL, () => this.permissionMode) ] } }) 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, customTools: demoTools }) 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) this.emit({ type: 'session_switched', state, messages: session.messages.map((m) => toCloneable(m)), activeTools: this.collectToolStates(session) }) } /** 从历史消息中恢复工具状态(用于切换会话后 ToolCard 的参数/结果展示) */ private collectToolStates(session: AgentSession): Record { const tools: Record = {} for (const message of session.messages) { const m = message as { role?: string; content?: unknown; toolCallId?: unknown; toolName?: unknown; isError?: unknown } if (m.role !== 'assistant' || !Array.isArray(m.content)) continue for (const block of m.content as Array<{ type?: string; id?: string; name?: string; arguments?: unknown }>) { if (block.type === 'toolCall' && block.id) { tools[block.id] = { toolName: block.name ?? 'tool', args: toCloneable(block.arguments), status: 'done' } } } } for (const message of session.messages) { const m = message as { role?: string; toolCallId?: unknown; toolName?: unknown; content?: unknown; isError?: unknown } if (m.role !== 'toolResult' || typeof m.toolCallId !== 'string') continue tools[m.toolCallId] = { ...((tools[m.toolCallId] as Record) ?? { toolName: m.toolName ?? 'tool' }), result: toCloneable(m), status: m.isError ? 'error' : 'done' } } return tools } 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) }) 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 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 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 } } private async emitStats(): Promise { try { const runtime = await this.getRuntime() const stats = runtime.session.getSessionStats() const usage = runtime.session.getContextUsage() const info: SessionStatsInfo = { tokens: { ...stats.tokens }, cost: stats.cost, contextPercent: usage && typeof usage.percent === 'number' ? Math.round(usage.percent) : undefined } 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() let skills: ChatResources['skills'] = [] try { const loaded = loadSkills({ cwd: this.cwd, agentDir: getAgentDir(), skillPaths: [], includeDefaults: true }) skills = loaded.skills.map((s) => ({ name: s.name, description: s.description ?? '' })) } catch (err) { console.error('[agent-service] load skills failed:', err) } const commands = runtime.session.promptTemplates.map((t) => ({ name: t.name, description: t.description ?? '', kind: 'prompt' as const })) return { commands, skills } } /** 设置页「技能 / 命令」详情列表(含来源文件与资源目录,供打开/刷新) */ async listResourceDetails(): Promise { const agentDir = getAgentDir() const dirs = { globalSkills: join(agentDir, 'skills'), projectSkills: join(this.cwd, '.pi', 'skills'), globalCommands: join(agentDir, 'prompts'), projectCommands: join(this.cwd, '.pi', 'prompts') } let skills: SkillDetail[] = [] try { const loaded = loadSkills({ cwd: this.cwd, agentDir, skillPaths: [], includeDefaults: true }) skills = loaded.skills.map((s) => ({ name: s.name, description: s.description ?? '', filePath: s.filePath, scope: s.sourceInfo?.scope === 'user' ? 'user' : 'project', disableModelInvocation: Boolean(s.disableModelInvocation) })) } catch (err) { console.error('[agent-service] load skill details failed:', err) } const commands: CommandDetail[] = [] try { const runtime = await this.getRuntime() commands.push( ...runtime.session.promptTemplates.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) })) ) } 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 } } // ---- 工作区文件(@ 引用)---- 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() 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}`)) 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`), 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) }) } // ---- 应用设置 ---- getSettings(): AppSettings { return { ...this.settings } } /** 更新系统提示词后重建运行时(continueRecent 重新挂载当前最近会话,历史保留) */ async setSystemPrompt(text: string): Promise { this.settings = { ...this.settings, systemPrompt: text } this.saveSettings() await this.rebuildRuntime() } /** 切换权限模式:即时生效(审批扩展每笔工具调用实时读取),并广播给渲染层 */ 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) }) } } /** 主题色偏好(纯外观,仅持久化;渲染层负责应用) */ setThemeColor(id: string): void { this.settings = { ...this.settings, themeColor: id } this.saveSettings() } private async rebuildRuntime(): Promise { const old = this.runtime this.runtime = null this.initPromise = 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) }) } await this.ensureReady() } } /** 视为文本的扩展名(直接按 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() } /** 事件消息对象转为可结构化克隆的普通对象(剥离 undefined/函数/循环引用) */ function toCloneable(value: unknown): unknown { try { return JSON.parse(JSON.stringify(value ?? null)) } catch { return null } } 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 '' }