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

1393 lines
54 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 { homedir } from 'node:os'
import {
createAgentSessionRuntime,
createAgentSessionServices,
createAgentSessionFromServices,
estimateTokens,
getAgentDir,
getPowerShellConfig,
SessionManager,
type AgentSession,
type AgentSessionEvent,
type AgentSessionRuntime,
type CreateAgentSessionRuntimeFactory,
type PromptTemplate,
type SessionInfo,
type Skill
} from '@earendil-works/pi-coding-agent'
import { ApprovalService, createApprovalExtension } from './approval'
import { EXTERNAL_PLUGINS_ENABLED, getPluginHost } from '../plugins/plugin-host'
import { browserToolNames } from '../tools/browser-tools'
import type {
AgentUiEvent,
AppSettings,
AttachmentInfo,
ChatResources,
CommandDetail,
ContextBreakdownEntry,
ContextUsageInfo,
CustomProviderConfig,
ForkPoint,
ModelCatalog,
ModelInfo,
NavigateTreeOptions,
PermissionMode,
ResourceDetails,
SendImage,
SessionMeta,
SessionState,
SessionStatsInfo,
SkillDetail,
ThinkingLevel,
ThemeMode,
WorkspaceFile,
WorkspaceMeta
} from '@shared/types'
/** 增量事件合批的刷新间隔(ms),参考 pi-web 的 60ms 快照节流经验取略低值 */
const DELTA_FLUSH_MS = 40
/** SDK 注册的自定义工具名(用量卡片中与内置「系统工具」分开统计)。
* browserTools 已迁入浏览器插件(pi.registerTool 注册),这里同步其名单。 */
const CUSTOM_TOOL_NAMES = new Set<string>(browserToolNames)
/** 需要人工审批的工具名单(高危内置工具;业务演示工具已随 demo-tools.ts 移除) */
const TOOLS_REQUIRING_APPROVAL: ReadonlySet<string> = new Set(['bash', 'powershell', 'write', 'edit'])
/** 资源禁用清单(disabledSkills/disabledCommands)的白名单式解析:仅保留非空字符串并去重 */
function parseNameList(value: unknown): string[] {
if (!Array.isArray(value)) return []
return [...new Set(value.filter((n): n is string => typeof n === 'string' && n.trim().length > 0).map((n) => n.trim()))]
}
/** Windows 优先 PowerShell:探测结果进程内缓存(getPowerShellConfig 会 spawnSync where 探测,
* 且平台与安装情况不会在应用运行中变化)。探测失败保持 bash(Git Bash)不动。 */
let powershellProbe: boolean | null = null
/** win32 且存在 pwsh/powershell 可执行文件时,把会话的默认 bash 工具换成 powershell,
* 模型的 shell 调用走原生 PowerShell 而非 Git Bash。幂等:无 bash 或已换过则原样返回。
* 注意不能用 options.tools 传入(那会变成工具白名单,把插件注册的工具全部过滤掉),
* 也不能只传四个基础名调 setActiveToolsByName(那会把已激活的扩展工具一并挤掉),
* 所以按当前激活名单做替换。 */
function preferPowerShell(session: AgentSession): void {
if (process.platform !== 'win32') return
if (powershellProbe === null) {
try {
getPowerShellConfig()
powershellProbe = true
} catch {
powershellProbe = false
}
}
if (!powershellProbe) return
const names = session.getActiveToolNames()
if (!names.includes('bash') || names.includes('powershell')) return
session.setActiveToolsByName(names.map((name) => (name === 'bash' ? 'powershell' : name)))
}
/**
* pi 会话运行时的主进程封装:
* - 基于 AgentSessionRuntime 支持会话新建/切换(自动重挂事件订阅)
* - 复用 ~/.pi 的配置、凭据与会话持久化(sessions/<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
/** pi 全量发现的技能/命令快照(skillsOverride/promptsOverride 在每次 loader reload 时写入)。
* 被禁用的资源在 loader 里已过滤掉,设置页靠这份快照展示"已禁用"状态。 */
private resourceSnapshotSkills: Skill[] = []
private resourceSnapshotPrompts: PromptTemplate[] = []
readonly approvals = new ApprovalService()
constructor() {
// 设置同步读入 + 应用持久化的深浅色偏好(nativeTheme 会影响渲染层
// prefers-color-scheme 与原生控件配色;窗口创建前生效可避免首帧闪错色)
this.loadSettings()
nativeTheme.themeSource = this.settings.themeMode ?? 'system'
}
/** 工作区根目录:文档/Inkreach,每个子目录 = 一个工作区 */
get workspacesRoot(): string {
return join(app.getPath('documents'), 'Inkreach')
}
private get defaultWorkspaceDir(): string {
return join(this.workspacesRoot, 'Default')
}
private get cwd(): string {
return this.workspaceDir || this.defaultWorkspaceDir
}
private get settingsFile(): string {
return join(app.getPath('userData'), 'aso-settings.json')
}
private loadSettings(): AppSettings {
try {
const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as Partial<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',
disabledSkills: parseNameList(raw.disabledSkills),
disabledCommands: parseNameList(raw.disabledCommands)
}
} catch {
this.settings = { permissionMode: this.permissionMode, themeMode: 'system' }
}
this.permissionMode = this.settings.permissionMode ?? 'confirm'
this.initWorkspaces()
return this.settings
}
/** 确保根目录与默认工作区存在,并解析当前工作区(设置值失效时回退 Default) */
private initWorkspaces(): void {
try {
mkdirSync(this.defaultWorkspaceDir, { recursive: true })
} catch (err) {
console.error('[agent-service] init workspaces failed:', err)
}
const saved = this.settings.workspace
this.workspaceDir = saved && existsSync(saved) ? saved : this.defaultWorkspaceDir
}
// ---- 工作区 ----
async listWorkspaces(): Promise<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
// 工作区切换:新 cwd 的最近会话才是目标(不做原会话恢复)
await this.rebuildRuntime({ restoreSession: false })
}
private saveSettings(): void {
try {
writeFileSync(this.settingsFile, JSON.stringify(this.settings, null, 2), 'utf8')
} catch (err) {
console.error('[agent-service] save settings failed:', err)
}
}
onEvent(fn: (event: AgentUiEvent) => void): () => void {
this.listeners.add(fn)
return () => this.listeners.delete(fn)
}
private emit(event: AgentUiEvent): void {
for (const fn of this.listeners) {
try {
fn(event)
} catch (err) {
console.error('[agent-service] listener error:', err)
}
}
}
/** 增量类事件合批下发,避免高频 delta 打爆 IPC */
private emitDelta(event: AgentUiEvent): void {
this.deltaQueue.push(event)
if (this.flushTimer) return
this.flushTimer = setTimeout(() => {
this.flushTimer = null
const batch = this.deltaQueue
this.deltaQueue = []
for (const e of batch) this.emit(e)
}, DELTA_FLUSH_MS)
}
private getRuntime(): Promise<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()
// 项目级第三方插件目录随工作区同步(.pi/extensions;目录变更即重扫描插件列表)
getPluginHost().setProjectExtensionsDir(EXTERNAL_PLUGINS_ENABLED ? cwd : null)
// 审批拦截只需注册一次(create 会随工作区切换多次执行)
if (!this.approvalsRegistered) {
this.approvalsRegistered = true
this.approvals.onRequest((request) => {
this.emit({ type: 'approval_request', request })
})
this.approvals.onSettle((id, approved, timedOut) => {
this.emit({ type: 'approval_settled', id, approved, timedOut })
})
}
const buildServices = (targetCwd: string, targetAgentDir: string) =>
createAgentSessionServices({
cwd: targetCwd,
agentDir: targetAgentDir,
resourceLoaderOptions: {
extensionFactories: [
createApprovalExtension(this.approvals, TOOLS_REQUIRING_APPROVAL, () => this.permissionMode),
// 插件聚合 extension:全部已启用插件在同一真实 ExtensionAPI 上下文里激活
getPluginHost().createAggregatedExtension()
],
// 技能/命令启停:发现阶段按 AppSettings 过滤(被禁资源不进系统提示词与 / 命令面板)。
// 全量结果先快照给设置页(被禁项在 loader 结果里不可见);闭包在每次 reload 重跑,
// 工作区切换/重建运行时后快照随之刷新。
skillsOverride: (base) => {
this.resourceSnapshotSkills = base.skills
const disabled = new Set(this.settings.disabledSkills ?? [])
return { skills: base.skills.filter((s) => !disabled.has(s.name)), diagnostics: base.diagnostics }
},
promptsOverride: (base) => {
this.resourceSnapshotPrompts = base.prompts
const disabled = new Set(this.settings.disabledCommands ?? [])
return { prompts: base.prompts.filter((p) => !disabled.has(p.name)), diagnostics: base.diagnostics }
}
}
})
const initialServices = await buildServices(cwd, agentDir)
// 每次切换/新建时由 runtime 调用;cwd 变化时重建服务,否则复用
const factory: CreateAgentSessionRuntimeFactory = async ({
cwd: nextCwd,
agentDir: nextAgentDir,
sessionManager,
sessionStartEvent
}) => {
const services =
nextCwd === initialServices.cwd && nextAgentDir === initialServices.agentDir
? initialServices
: await buildServices(nextCwd, nextAgentDir)
const result = await createAgentSessionFromServices({
services,
sessionManager,
sessionStartEvent
// 业务工具(browserTools 等)由插件经 pi.registerTool 注册(聚合 extension),
// 此处不再传 customTools
})
// 会话创建是唯一收口点:新建/切换/分叉/重载都会经过这里,统一应用 Windows PowerShell 偏好
preferPowerShell(result.session)
return { ...result, services, diagnostics: services.diagnostics }
}
const sessionManager = SessionManager.continueRecent(cwd)
const runtime = await createAgentSessionRuntime(factory, { cwd, agentDir, sessionManager })
// 会话被替换(new/switch/fork)后重挂事件订阅并通知渲染层
runtime.setRebindSession(async (session) => {
this.attach(session)
this.emitSwitched(session)
})
this.attach(runtime.session)
// 此前 runtime 从未挂到实例上(rebuildRuntime 的 dispose 永远拿不到旧实例),补上
this.runtime = runtime
return runtime
}
private attach(session: AgentSession): void {
if (this.attached === session) return
this.unsubscribeAttached?.()
this.attached = session
this.unsubscribeAttached = session.subscribe((event) => this.handleEvent(event))
}
private emitSwitched(session: AgentSession): void {
const state = this.buildState(session)
// 工具状态不再随事件重发:渲染层用 historyToTools 从 messages 自行重建,
// 重复克隆一遍全量工具参数/结果是长会话切换卡顿的大头(历史被序列化两次)
this.emit({
type: 'session_switched',
state,
messages: session.messages.map((m) => toCloneable(m))
})
// 切换/回溯后立刻带上一次统计(上下文容量、缓存命中率),Usage Card 无需等第一回合
void this.emitStats()
}
async ensureReady(): Promise<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 agg = this.aggregateSession(session)
const breakdown: ContextBreakdownEntry[] = [
{ key: 'messages', label: '消息', tokens: agg.breakdown.get('messages') ?? 0 },
{ key: 'customTools', label: '自定义工具', tokens: agg.breakdown.get('customTools') ?? 0 },
{ key: 'builtinTools', label: '系统工具', tokens: agg.breakdown.get('builtinTools') ?? 0 },
{ key: 'systemPrompt', label: '系统提示词', tokens: 0 },
{ key: 'skills', label: '技能', tokens: 0 },
{ key: 'other', label: '其他', tokens: agg.breakdown.get('other') ?? 0 }
]
const byKey = new Map(breakdown.map((b) => [b.key, b]))
const systemTokens = Math.ceil(session.systemPrompt.length / 4)
const skillsTokens = this.estimateSkillsTokens(session)
byKey.get('systemPrompt')!.tokens = Math.max(0, systemTokens - skillsTokens)
byKey.get('skills')!.tokens = skillsTokens
const usage = session.getContextUsage()
return {
tokens: usage && typeof usage.tokens === 'number' ? usage.tokens : null,
contextWindow: usage?.contextWindow ?? 0,
percent: usage && typeof usage.percent === 'number' ? usage.percent : null,
...(agg.lastOutputTokens !== undefined ? { lastOutputTokens: agg.lastOutputTokens } : {}),
breakdown
}
}
/** 技能清单注入系统提示词的 token 估算(按 cwd 缓存;技能清单会话内极少变化)。
* 数据源与系统提示词同源(resource loader,覆盖 ~/.agents/skills 等全部默认位置) */
private estimateSkillsTokens(session: AgentSession): number {
if (this.skillsEstimate && this.skillsEstimate.cwd === this.cwd) return this.skillsEstimate.value
let value = 0
try {
const loaded = session.resourceLoader.getSkills()
value = Math.ceil(loaded.skills.reduce((sum, s) => sum + s.name.length + s.description.length + 8, 0) / 4)
} catch {
value = 0
}
this.skillsEstimate = { cwd: this.cwd, value }
return value
}
// ---- 会话统计增量聚合 ----
// emitStats 每条 assistant 消息落地都会触发;长会话下每次全量遍历估算
// token 是 O(全部消息)。消息列表正常只追加:以「已聚合前缀长度 + 前缀
// 末条消息引用」为锚点,每次只聚合新增尾部;压缩/回溯/会话切换/运行时
// 重建后消息对象引用必变(或长度回退),锚点失配即自动整体重算。
private sessionAgg: {
count: number
anchor: unknown
cacheInput: number
cacheRead: number
cacheWrite: number
toolCalls: number
toolErrors: number
/** 消息侧 breakdown(systemPrompt/skills 在外层另算) */
breakdown: Map<string, number>
lastOutputTokens: number | undefined
} | null = null
private aggregateSession(session: AgentSession) {
const messages = session.messages
let agg = this.sessionAgg
if (
agg === null ||
messages.length < agg.count ||
(agg.count > 0 && messages[agg.count - 1] !== agg.anchor)
) {
agg = {
count: 0,
anchor: null,
cacheInput: 0,
cacheRead: 0,
cacheWrite: 0,
toolCalls: 0,
toolErrors: 0,
breakdown: new Map([
['messages', 0],
['customTools', 0],
['builtinTools', 0],
['other', 0]
]),
lastOutputTokens: undefined
}
}
for (let i = agg.count; i < messages.length; i++) {
const m = messages[i] as {
role?: string
toolName?: unknown
content?: unknown
isError?: unknown
usage?: { input?: unknown; output?: unknown; cacheRead?: unknown; cacheWrite?: unknown }
}
const bump = (key: string): void => {
agg!.breakdown.set(key, (agg!.breakdown.get(key) ?? 0) + estimateMessageTokens(messages[i]))
}
switch (m.role) {
case 'assistant': {
bump('messages')
if (m.usage) {
agg.cacheInput += Number(m.usage.input ?? 0) || 0
agg.cacheRead += Number(m.usage.cacheRead ?? 0) || 0
agg.cacheWrite += Number(m.usage.cacheWrite ?? 0) || 0
if (typeof m.usage.output === 'number') agg.lastOutputTokens = m.usage.output
}
if (Array.isArray(m.content)) {
for (const block of m.content as Array<{ type?: unknown }>) {
if (block && typeof block === 'object' && block.type === 'toolCall') agg.toolCalls += 1
}
}
break
}
case 'user':
bump('messages')
break
case 'toolResult': {
if (m.isError === true) agg.toolErrors += 1
bump(
typeof m.toolName === 'string' && CUSTOM_TOOL_NAMES.has(m.toolName) ? 'customTools' : 'builtinTools'
)
break
}
// bash 执行、扩展注入、压缩/分支摘要等注入上下文的消息
case 'bashExecution':
case 'custom':
case 'compactionSummary':
case 'branchSummary':
bump('other')
break
default:
break
}
}
agg.count = messages.length
agg.anchor = messages.length > 0 ? messages[messages.length - 1] : null
this.sessionAgg = agg
return agg
}
private async emitStats(): Promise<void> {
try {
const runtime = await this.getRuntime()
const session = runtime.session
const stats = session.getSessionStats()
const usage = session.getContextUsage()
const agg = this.aggregateSession(session)
// 缓存命中率:读缓存 / 全部提示 token(input + cacheRead + cacheWrite),
// 按本会话所有 assistant 响应聚合,0 表示会话内尚无任何响应
const denominator = agg.cacheInput + agg.cacheRead + agg.cacheWrite
const info: SessionStatsInfo = {
tokens: { ...stats.tokens },
cost: stats.cost,
contextPercent: usage && typeof usage.percent === 'number' ? Math.round(usage.percent) : undefined,
context: this.buildContextInfo(session),
...(denominator > 0
? {
cacheHitRate: Math.round((agg.cacheRead / denominator) * 100),
cacheTokens: { read: agg.cacheRead, write: agg.cacheWrite, input: agg.cacheInput }
}
: {}),
...(agg.toolCalls > 0
? {
toolStats: {
calls: agg.toolCalls,
successRate: Math.round(((agg.toolCalls - agg.toolErrors) / agg.toolCalls) * 100)
}
}
: {})
}
this.emit({ type: 'stats', stats: info })
} catch {
// 统计失败不打断主流程
}
}
async getState(): Promise<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()
// 与系统提示词同源:resource loader 的技能发现(含 ~/.agents/skills 与工作区 .agents/skills)
const skills = runtime.session.resourceLoader.getSkills().skills.map((s) => ({
name: s.name,
description: s.description
}))
const commands = runtime.session.promptTemplates.map((t) => ({
name: t.name,
description: t.description ?? '',
kind: 'prompt' as const
}))
return { commands, skills }
}
/** 设置页「技能 / 命令」详情列表(含来源文件与资源目录,供打开/刷新)。
* 技能取自 resource loader(与系统提示词同源,覆盖 ~/.agents/skills 与工作区 .agents/skills)。
* 数据源用 override 写入的全量快照:被禁资源在会话里已过滤,只有快照还看得到。 */
async listResourceDetails(): Promise<ResourceDetails> {
const agentDir = getAgentDir()
const dirs = {
globalSkills: join(agentDir, 'skills'),
globalAgentsSkills: join(homedir(), '.agents', 'skills'),
projectSkills: join(this.cwd, '.pi', 'skills'),
projectAgentsSkills: join(this.cwd, '.agents', 'skills'),
globalCommands: join(agentDir, 'prompts'),
projectCommands: join(this.cwd, '.pi', 'prompts')
}
const disabledSkills = new Set(this.settings.disabledSkills ?? [])
const disabledCommands = new Set(this.settings.disabledCommands ?? [])
const skills: SkillDetail[] = []
try {
const runtime = await this.getRuntime()
const all = this.resourceSnapshotSkills.length > 0 ? this.resourceSnapshotSkills : runtime.session.resourceLoader.getSkills().skills
for (const s of all) {
skills.push({
name: s.name,
description: s.description,
filePath: s.filePath,
// 与 resource loader 的默认归类一致:无来源信息时按用户级
scope: s.sourceInfo?.scope === 'project' ? 'project' : 'user',
disableModelInvocation: Boolean(s.disableModelInvocation),
disabled: disabledSkills.has(s.name)
})
}
} catch (err) {
console.error('[agent-service] load skill details failed:', err)
}
const commands: CommandDetail[] = []
try {
const runtime = await this.getRuntime()
const all =
this.resourceSnapshotPrompts.length > 0 ? this.resourceSnapshotPrompts : runtime.session.promptTemplates
commands.push(
...all.map((t) => ({
name: t.name,
description: t.description ?? '',
argumentHint: t.argumentHint || undefined,
filePath: t.filePath,
scope: t.sourceInfo?.scope === 'user' ? ('user' as const) : ('project' as const),
disabled: disabledCommands.has(t.name)
}))
)
} catch (err) {
console.error('[agent-service] list command details failed:', err)
}
skills.sort((a, b) => a.name.localeCompare(b.name))
commands.sort((a, b) => a.name.localeCompare(b.name))
return { skills, commands, dirs }
}
/** 启用/禁用单个技能或命令:持久化到 AppSettings 后重建运行时(发现阶段过滤随之生效) */
async setResourceEnabled(kind: 'skill' | 'command', name: string, enabled: boolean): Promise<void> {
const trimmed = name.trim()
if (!trimmed) throw new Error('资源名不能为空')
const field = kind === 'skill' ? ('disabledSkills' as const) : ('disabledCommands' as const)
const next = new Set(this.settings[field] ?? [])
if (enabled) next.delete(trimmed)
else next.add(trimmed)
this.settings = { ...this.settings, [field]: [...next].sort((a, b) => a.localeCompare(b)) }
this.saveSettings()
await this.rebuildRuntime()
const session = this.runtime?.session
if (session) this.emit({ type: 'state', state: this.buildState(session) })
}
// ---- 工作区文件(@ 引用)----
async listWorkspaceFiles(): Promise<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}`))
// 自定义提供商的 apiKey 存在 models.json(不进凭据存储),有 key 即视为已配置
const customKeyed = new Set(
Object.entries(this.readModelsConfig().providers)
.filter(([, v]) => Boolean((v as { apiKey?: unknown })?.apiKey))
.map(([id]) => id)
)
const providers = mr
.getProviders()
.map((p) => {
const pid = String(p.id ?? '')
return {
id: pid,
name: String(p.name ?? pid),
hasApiKey: credSet.has(`${pid}:api_key`) || customKeyed.has(pid),
isOAuth: credSet.has(`${pid}:oauth`),
models: mr
.getModels(pid)
.map((m) => ({
id: String(m.id ?? ''),
name: String(m.name ?? m.id ?? ''),
contextWindow: Number(m.contextWindow ?? 0),
reasoning: Boolean(m.reasoning)
}))
}
})
// 已配置凭据的排前面,其余按名称排序
.sort((a, b) => {
const av = a.hasApiKey || a.isOAuth ? 0 : 1
const bv = b.hasApiKey || b.isOAuth ? 0 : 1
return av - bv || a.name.localeCompare(b.name)
})
return { providers }
}
async setApiKey(providerId: string, apiKey: string): Promise<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('会话还没有可分叉的内容')
}
await this.forkSessionAt(leafId, 'at')
}
/** 从会话树上的任意锚点分叉新会话:'at' = 新会话包含该条为止;'before' = 到其父条目为止 */
async forkSessionAt(entryId: string, position: 'at' | 'before'): Promise<void> {
const runtime = await this.getRuntime()
const session = runtime.session
if (session.isStreaming) {
await session.abort()
}
const result = await runtime.fork(entryId, { position })
if (result.cancelled) return
}
// ---- 应用设置 ----
getSettings(): AppSettings {
return { ...this.settings }
}
/** 当前生效的系统提示词(pi 原生提示词 + 技能注入,只读展示用) */
async getSystemPrompt(): Promise<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) })
}
}
/** 重建运行时(插件重载/启停、工作区切换时调用,extensionFactories 重跑)。
* restoreSession=true(插件重载):重建对渲染层尽量透明——
* - 进行中的回合先经 abort 正常结算(事件照常送达,渲染层自行收口),
* 再退订/销毁旧运行时——dispose 不含 abort,直接销毁会留下悬空请求;
* - 重建走 continueRecent(最近会话):用户开着的不是它时切回原会话;
* 原会话是未落盘的空新会话时退回空会话,避免静默接管别的会话;
* - 会话未变化时只推轻量 state,不广播 session_switched(该事件语义
* 保留给真正的会话变更,全量换血会重挂载渲染层全部消息)。
* restoreSession=false(工作区切换):新 cwd 的最近会话即目标状态,
* 直接全量广播切换。 */
async rebuildRuntime(options?: { restoreSession?: boolean }): Promise<void> {
const restoreSession = options?.restoreSession !== false
const old = this.runtime
this.runtime = null
this.initPromise = null
if (old?.session.isStreaming) {
await old.session.abort().catch(() => undefined)
}
// abort 结算会落盘当前回合,未持久会话的文件路径此时才物化——在其后读取
const previousPath = old?.session.sessionFile ?? null
this.unsubscribeAttached?.()
this.unsubscribeAttached = null
this.attached = null
if (old) {
await old.dispose().catch((err: unknown) => {
console.error('[agent-service] runtime dispose error:', err)
})
}
let runtime: AgentSessionRuntime
try {
runtime = await this.getRuntime()
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
this.emit({ type: 'session_error', message })
throw err
}
if (!restoreSession) {
this.emitSwitched(runtime.session)
return
}
const restored = runtime.session.sessionFile ?? ''
if (previousPath) {
if (!samePath(restored, previousPath) && existsSync(previousPath)) {
// 被拽到了最近会话:切回用户原本打开的会话(rebind 发 session_switched,真实变更)
await runtime.switchSession(previousPath)
return
}
} else if (runtime.session.messages.length > 0) {
// 原会话是未落盘的空新会话:不接管最近会话,回到空会话
await runtime.newSession()
return
}
this.emit({ type: 'state', state: this.buildState(runtime.session) })
}
}
/** 视为文本的扩展名(直接按 UTF-8 读入,随消息注入) */
const TEXT_EXTS = new Set([
'md', 'markdown', 'txt', 'csv', 'tsv', 'json', 'jsonl', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', 'env',
'js', 'mjs', 'cjs', 'jsx', 'ts', 'mts', 'cts', 'tsx',
'py', 'rb', 'rs', 'go', 'java', 'kt', 'kts', 'swift', 'c', 'h', 'cpp', 'hpp', 'cc', 'cs', 'php',
'html', 'htm', 'css', 'scss', 'less', 'vue', 'svelte', 'astro',
'sh', 'bash', 'zsh', 'fish', 'ps1', 'psm1', 'bat', 'cmd',
'sql', 'graphql', 'gql', 'prisma', 'dockerfile', 'makefile', 'gradle', 'gitignore', 'editorconfig', 'log', 'xml'
])
/** 无扩展名/罕见扩展名时按内容嗅探:首 4KB 出现 NUL 即视为二进制 */
function looksLikeText(buf: Buffer): boolean {
const probe = buf.subarray(0, 4096)
return !probe.includes(0)
}
function toSessionMeta(info: SessionInfo): SessionMeta { return {
id: info.id,
path: info.path,
name: info.name,
firstMessage: info.firstMessage,
created: info.created.toISOString(),
modified: info.modified.toISOString(),
messageCount: info.messageCount
}
}
function samePath(a: string, b: string): boolean {
return a.replace(/\\/g, '/').toLowerCase() === b.replace(/\\/g, '/').toLowerCase()
}
/** 事件消息对象转为可结构化克隆的普通对象(剥离函数/循环引用等不可克隆值)。
* structuredClone 远快于 JSON 往返——切换会话时整段历史都要过一遍这里,
* 长上下文下 JSON 序列化+反序列化是主进程最重的同步开销。 */
function toCloneable(value: unknown): unknown {
if (value === null || typeof value !== 'object') return value ?? null
try {
return structuredClone(value)
} catch {
try {
return JSON.parse(JSON.stringify(value))
} catch {
return null
}
}
}
/** 消息级 token 估算缓存:消息对象落定后不可变(pi 只追加),按引用缓存。
* emitStats 在每条 assistant 消息后都会全量重扫,不缓存的话整个会话是 O(n²) 的重复估算。 */
const messageTokenCache = new WeakMap<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 ''
}