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

1222 lines
45 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 { nativeTheme } from 'electron'
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import {
createAgentSessionRuntime,
createAgentSessionServices,
createAgentSessionFromServices,
estimateTokens,
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 { browserTools } 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 注册的自定义工具名(用量卡片中与内置「系统工具」分开统计) */
const CUSTOM_TOOL_NAMES = new Set<string>(browserTools.map((tool) => tool.name))
/** 需要人工审批的工具名单(高危内置工具;业务演示工具已随 demo-tools.ts 移除) */
const TOOLS_REQUIRING_APPROVAL: ReadonlySet<string> = new Set(['bash', 'write', 'edit'])
/**
* 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 = {}
/** 当前工作区目录(agent cwd),会话按目录隔离 */
private workspaceDir = ''
private approvalsRegistered = false
/** 权限模式(Composer pill / 审批行为),持久化在应用设置里 */
private permissionMode: PermissionMode = 'confirm'
/** 技能清单 token 估算缓存(随工作区失效) */
private skillsEstimate: { cwd: string; value: number } | null = null
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<AppSettings>
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'
}
} 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<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: {
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: browserTools
})
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<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) })
// 每条 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 breakdown: ContextBreakdownEntry[] = [
{ key: 'messages', label: '消息', tokens: 0 },
{ key: 'customTools', label: '自定义工具', tokens: 0 },
{ key: 'builtinTools', label: '系统工具', tokens: 0 },
{ key: 'systemPrompt', label: '系统提示词', tokens: 0 },
{ key: 'skills', label: '技能', tokens: 0 },
{ key: 'other', label: '其他', tokens: 0 }
]
const byKey = new Map(breakdown.map((b) => [b.key, b]))
const systemTokens = Math.ceil(session.systemPrompt.length / 4)
const skillsTokens = this.estimateSkillsTokens()
byKey.get('systemPrompt')!.tokens = Math.max(0, systemTokens - skillsTokens)
byKey.get('skills')!.tokens = skillsTokens
for (const message of session.messages) {
const m = message as { role?: string; toolName?: unknown; content?: unknown }
switch (m.role) {
case 'user':
case 'assistant':
byKey.get('messages')!.tokens += estimateMessageTokens(message)
break
case 'toolResult': {
const key =
typeof m.toolName === 'string' && CUSTOM_TOOL_NAMES.has(m.toolName) ? 'customTools' : 'builtinTools'
byKey.get(key)!.tokens += estimateMessageTokens(message)
break
}
// bash 执行、扩展注入、压缩/分支摘要等注入上下文的消息
case 'bashExecution':
case 'custom':
case 'compactionSummary':
case 'branchSummary':
byKey.get('other')!.tokens += estimateMessageTokens(message)
break
default:
break
}
}
const usage = session.getContextUsage()
let lastOutputTokens: number | undefined
for (let i = session.messages.length - 1; i >= 0; i--) {
const m = session.messages[i] as { role?: string; usage?: { output?: unknown } }
if (m.role === 'assistant' && m.usage && typeof m.usage.output === 'number') {
lastOutputTokens = m.usage.output
break
}
}
return {
tokens: usage && typeof usage.tokens === 'number' ? usage.tokens : null,
contextWindow: usage?.contextWindow ?? 0,
percent: usage && typeof usage.percent === 'number' ? usage.percent : null,
...(lastOutputTokens !== undefined ? { lastOutputTokens } : {}),
breakdown
}
}
/** 技能清单注入系统提示词的 token 估算(按 cwd 缓存;技能清单会话内极少变化) */
private estimateSkillsTokens(): number {
if (this.skillsEstimate && this.skillsEstimate.cwd === this.cwd) return this.skillsEstimate.value
let value = 0
try {
const loaded = loadSkills({ cwd: this.cwd, agentDir: getAgentDir(), skillPaths: [], includeDefaults: true })
value = Math.ceil(
loaded.skills.reduce((sum, s) => sum + s.name.length + (s.description?.length ?? 0) + 8, 0) / 4
)
} catch {
value = 0
}
this.skillsEstimate = { cwd: this.cwd, value }
return value
}
private async emitStats(): Promise<void> {
try {
const runtime = await this.getRuntime()
const session = runtime.session
const stats = session.getSessionStats()
const usage = session.getContextUsage()
// 缓存命中率:读缓存 / 全部提示 token(input + cacheRead + cacheWrite),
// 按本会话所有 assistant 响应聚合,0 表示会话内尚无任何响应
let cacheInput = 0
let cacheRead = 0
let cacheWrite = 0
let toolCalls = 0
let toolErrors = 0
for (const message of session.messages) {
const m = message as {
role?: string
usage?: { input?: unknown; cacheRead?: unknown; cacheWrite?: unknown }
content?: unknown
isError?: unknown
}
if (m.role === 'assistant') {
if (m.usage) {
cacheInput += Number(m.usage.input ?? 0) || 0
cacheRead += Number(m.usage.cacheRead ?? 0) || 0
cacheWrite += Number(m.usage.cacheWrite ?? 0) || 0
}
if (Array.isArray(m.content)) {
for (const block of m.content as Array<{ type?: unknown }>) {
if (block && typeof block === 'object' && block.type === 'toolCall') toolCalls += 1
}
}
} else if (m.role === 'toolResult' && m.isError === true) {
toolErrors += 1
}
}
const denominator = cacheInput + cacheRead + 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((cacheRead / denominator) * 100),
cacheTokens: { read: cacheRead, write: cacheWrite, input: cacheInput }
}
: {}),
...(toolCalls > 0
? {
toolStats: {
calls: toolCalls,
successRate: Math.round(((toolCalls - toolErrors) / toolCalls) * 100)
}
}
: {})
}
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()
// 压缩/分支摘要独立于 agent run,abort() 不会打断它们,停止时一并取消
runtime.session.abortCompaction()
runtime.session.abortBranchSummary()
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) })
}
// ---- 思考等级 / 压缩 / 分支摘要(pi 原生能力) ----
/** 设置思考等级(persist 到全局默认值);等级按模型能力校验 */
async setThinkingLevel(level: ThinkingLevel): Promise<void> {
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<void> {
this.runtime?.session.abortCompaction()
}
/** 开关自动压缩(阈值/溢出触发;pi settings 持久化) */
async setAutoCompactionEnabled(enabled: boolean): Promise<void> {
const runtime = await this.getRuntime()
runtime.session.setAutoCompactionEnabled(enabled)
this.emit({ type: 'state', state: this.buildState(runtime.session) })
}
/** 会话树上的可回溯点(当前分支上的用户消息,按序) */
async listForkPoints(): Promise<ForkPoint[]> {
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<void> {
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<void> {
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('会话还没有可分叉的内容')
}
const result = await runtime.fork(leafId, { position: 'at' })
if (result.cancelled) return
}
// ---- 应用设置 ----
getSettings(): AppSettings {
return { ...this.settings }
}
/** 当前生效的系统提示词(pi 原生提示词 + 技能注入,只读展示用) */
async getSystemPrompt(): Promise<string> {
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) })
}
}
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()
}
/** 事件消息对象转为可结构化克隆的普通对象(剥离函数/循环引用等不可克隆值)。
* 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<object, number>()
function estimateMessageTokens(message: object): number {
let tokens = messageTokenCache.get(message)
if (tokens === undefined) {
tokens = estimateTokens(message as Parameters<typeof estimateTokens>[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 ''
}