feat: 接入 pi 原生能力——Usage Card、思考等级切换、压缩与分支摘要
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user