Files
inkreach-agent/apps/desktop/src/main/pi/agent-service.ts
T

970 lines
34 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.
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/<encoded-cwd>/)
* - 归一化 pi 事件为 AgentUiEvent 转发渲染层
*/
export class AgentService {
private runtime: AgentSessionRuntime | null = null
private initPromise: Promise<AgentSessionRuntime> | 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<AppSettings>
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<WorkspaceMeta[]> {
try {
mkdirSync(this.defaultWorkspaceDir, { recursive: true })
} catch {
// 目录已存在或创建失败时仍尝试枚举
}
const entries: WorkspaceMeta[] = []
const seen = new Set<string>([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<WorkspaceMeta> {
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<void> {
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<AgentSessionRuntime> {
if (!this.initPromise) {
this.initPromise = this.create().catch((err) => {
this.initPromise = null
throw err
})
}
return this.initPromise
}
private async create(): Promise<AgentSessionRuntime> {
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<string, unknown> {
const tools: Record<string, unknown> = {}
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<string, unknown>) ?? { toolName: m.toolName ?? 'tool' }),
result: toCloneable(m),
status: m.isError ? 'error' : 'done'
}
}
return tools
}
async ensureReady(): Promise<SessionState> {
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<void> {
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<SessionState> {
const runtime = await this.getRuntime()
return this.buildState(runtime.session)
}
async send(text: string, images?: SendImage[]): Promise<void> {
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<void> {
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<ChatResources> {
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<ResourceDetails> {
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<WorkspaceFile[]> {
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<AttachmentInfo[]> {
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<string, string> = {
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: '' }
}
}
// 非图片:小体积且为文本的文件读出内容,发送时注入 <attachment> 块;二进制仅留路径引用
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<void> {
const runtime = await this.getRuntime()
await runtime.session.steer(text)
}
async stop(): Promise<void> {
if (!this.runtime && !this.initPromise) return
const runtime = await this.getRuntime()
await runtime.session.abort()
}
// ---- 会话管理 ----
async listSessions(): Promise<SessionMeta[]> {
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<void> {
const runtime = await this.getRuntime()
if (runtime.session.isStreaming) {
await runtime.session.abort()
}
await runtime.newSession()
// rebind 回调负责发 session_switched
}
async openSession(path: string): Promise<void> {
const runtime = await this.getRuntime()
if (runtime.session.isStreaming) {
await runtime.session.abort()
}
await runtime.switchSession(path)
}
async renameSession(name: string): Promise<void> {
const runtime = await this.getRuntime()
runtime.session.setSessionName(name)
this.emit({ type: 'session_info', name })
}
async deleteSession(path: string): Promise<void> {
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<string, Record<string, unknown>> } {
try {
const raw = JSON.parse(readFileSync(this.modelsJsonFile, 'utf8')) as Record<string, unknown>
if (raw && typeof raw.providers === 'object' && raw.providers !== null && !Array.isArray(raw.providers)) {
return { providers: raw.providers as Record<string, Record<string, unknown>> }
}
} 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<void> {
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<void> {
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<ModelCatalog> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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 ''
}