feat: 接入 pi 原生能力——Usage Card、思考等级切换、压缩与分支摘要

This commit is contained in:
Rick Ye
2026-09-08 17:21:27 +08:00
parent adf3c3e118
commit e7da0f912e
12 changed files with 1378 additions and 159 deletions
+37
View File
@@ -141,6 +141,43 @@ export function registerIpc(): AgentService {
return pass(() => service.logoutProvider(providerId))
})
// ---- 思考等级 / 压缩 / 分支摘要(pi 原生能力) ----
ipcMain.handle('thinking:set', (_event, level: unknown) => {
if (typeof level !== 'string') throw new Error('invalid thinking level')
return pass(() => service.setThinkingLevel(level as never))
})
ipcMain.handle('session:compact', (_event, instructions: unknown) => {
if (instructions !== undefined && instructions !== null && typeof instructions !== 'string') {
throw new Error('invalid compaction instructions')
}
return pass(() => service.compactSession(instructions || undefined))
})
ipcMain.handle('session:compact:abort', () => pass(() => service.abortCompaction()))
ipcMain.handle('session:autoCompact', (_event, enabled: unknown) => {
if (typeof enabled !== 'boolean') throw new Error('invalid flag')
return pass(() => service.setAutoCompactionEnabled(enabled))
})
ipcMain.handle('session:forkPoints', () => pass(() => service.listForkPoints()))
ipcMain.handle('session:navigate', (_event, entryId: unknown, options: unknown) => {
if (typeof entryId !== 'string' || !entryId.trim()) throw new Error('invalid entry id')
const o = (options ?? {}) as { summarize?: unknown; customInstructions?: unknown }
if (o.summarize !== undefined && typeof o.summarize !== 'boolean') throw new Error('invalid navigate options')
if (o.customInstructions !== undefined && typeof o.customInstructions !== 'string') {
throw new Error('invalid navigate options')
}
return pass(() =>
service.navigateTree(entryId, {
...(o.summarize === true ? { summarize: true } : {}),
...(typeof o.customInstructions === 'string' ? { customInstructions: o.customInstructions } : {})
})
)
})
// ---- 应用设置 ----
ipcMain.handle('settings:get', () => service.getSettings())
+226 -4
View File
@@ -5,6 +5,7 @@ import {
createAgentSessionRuntime,
createAgentSessionServices,
createAgentSessionFromServices,
estimateTokens,
getAgentDir,
loadSkills,
SessionManager,
@@ -23,9 +24,13 @@ import type {
AttachmentInfo,
ChatResources,
CommandDetail,
ContextBreakdownEntry,
ContextUsageInfo,
CustomProviderConfig,
ForkPoint,
ModelCatalog,
ModelInfo,
NavigateTreeOptions,
PermissionMode,
ResourceDetails,
SendImage,
@@ -33,6 +38,7 @@ import type {
SessionState,
SessionStatsInfo,
SkillDetail,
ThinkingLevel,
WorkspaceFile,
WorkspaceMeta
} from '@shared/types'
@@ -40,6 +46,9 @@ import type {
/** 增量事件合批的刷新间隔(ms),参考 pi-web 的 60ms 快照节流经验取略低值 */
const DELTA_FLUSH_MS = 40
/** SDK 注册的自定义工具名(用量卡片中与内置「系统工具」分开统计) */
const CUSTOM_TOOL_NAMES = new Set<string>([...demoTools, ...browserTools].map((tool) => tool.name))
/**
* pi 会话运行时的主进程封装:
* - 基于 AgentSessionRuntime 支持会话新建/切换(自动重挂事件订阅)
@@ -60,6 +69,8 @@ export class AgentService {
private approvalsRegistered = false
/** 权限模式(Composer pill / 审批行为),持久化在应用设置里 */
private permissionMode: PermissionMode = 'confirm'
/** 技能清单 token 估算缓存(随工作区失效) */
private skillsEstimate: { cwd: string; value: number } | null = null
readonly approvals = new ApprovalService()
/** 工作区根目录:文档/Inkreach,每个子目录 = 一个工作区 */
@@ -303,6 +314,8 @@ export class AgentService {
messages: session.messages.map((m) => toCloneable(m)),
activeTools: this.collectToolStates(session)
})
// 切换/回溯后立刻带上一次统计(上下文容量、缓存命中率),Usage Card 无需等第一回合
void this.emitStats()
}
/** 从历史消息中恢复工具状态(用于切换会话后 ToolCard 的参数/结果展示) */
@@ -367,6 +380,8 @@ export class AgentService {
}
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({
@@ -407,6 +422,27 @@ export class AgentService {
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
}
@@ -421,6 +457,7 @@ export class AgentService {
name: String(model.name ?? model.id ?? '')
}
: undefined
const thinkingLevels = session.supportsThinking() ? session.getAvailableThinkingLevels() : []
return {
sessionId: session.sessionId,
sessionName: session.sessionName,
@@ -429,19 +466,146 @@ export class AgentService {
activeTools: session.getActiveToolNames(),
messageCount: session.messages.length,
workspace: this.cwd,
permissionMode: this.permissionMode
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 += estimateTokens(message as Parameters<typeof estimateTokens>[0])
break
case 'toolResult': {
const key =
typeof m.toolName === 'string' && CUSTOM_TOOL_NAMES.has(m.toolName) ? 'customTools' : 'builtinTools'
byKey.get(key)!.tokens += estimateTokens(message as Parameters<typeof estimateTokens>[0])
break
}
// bash 执行、扩展注入、压缩/分支摘要等注入上下文的消息
case 'bashExecution':
case 'custom':
case 'compactionSummary':
case 'branchSummary':
byKey.get('other')!.tokens += estimateTokens(message as Parameters<typeof estimateTokens>[0])
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 stats = runtime.session.getSessionStats()
const usage = runtime.session.getContextUsage()
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
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 {
@@ -644,6 +808,9 @@ export class AgentService {
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()
}
@@ -855,6 +1022,61 @@ export class AgentService {
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)
}
// ---- 应用设置 ----
getSettings(): AppSettings {