1222 lines
45 KiB
TypeScript
1222 lines
45 KiB
TypeScript
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 ''
|
||
}
|