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 {
+11
View File
@@ -9,12 +9,15 @@ import type {
BrowserUiState,
ChatResources,
CustomProviderConfig,
ForkPoint,
ModelCatalog,
NavigateTreeOptions,
PermissionMode,
ResourceDetails,
SendImage,
SessionMeta,
SessionState,
ThinkingLevel,
WorkspaceFile,
WorkspaceMeta
} from '@shared/types'
@@ -65,6 +68,14 @@ const api = {
setApiKey: (providerId: string, apiKey: string): Promise<void> =>
ipcRenderer.invoke('auth:setKey', providerId, apiKey),
logoutProvider: (providerId: string): Promise<void> => ipcRenderer.invoke('auth:logout', providerId),
setThinkingLevel: (level: ThinkingLevel): Promise<void> => ipcRenderer.invoke('thinking:set', level),
compactSession: (instructions?: string): Promise<{ summary: string; tokensBefore: number }> =>
ipcRenderer.invoke('session:compact', instructions),
abortCompaction: (): Promise<void> => ipcRenderer.invoke('session:compact:abort'),
setAutoCompaction: (enabled: boolean): Promise<void> => ipcRenderer.invoke('session:autoCompact', enabled),
listForkPoints: (): Promise<ForkPoint[]> => ipcRenderer.invoke('session:forkPoints'),
navigateTree: (entryId: string, options?: NavigateTreeOptions): Promise<void> =>
ipcRenderer.invoke('session:navigate', entryId, options),
getSettings: (): Promise<AppSettings> => ipcRenderer.invoke('settings:get'),
setSystemPrompt: (text: string): Promise<void> => ipcRenderer.invoke('settings:setPrompt', text),
setThemeColor: (id: string): Promise<void> => ipcRenderer.invoke('settings:setTheme', id),
+25 -1
View File
@@ -61,6 +61,8 @@ export default function App(): JSX.Element {
state,
sessions,
workspaces,
forkPoints,
navigating,
send,
stop,
clearError,
@@ -72,7 +74,13 @@ export default function App(): JSX.Element {
resolveApproval,
setPermissionMode,
refreshSessions,
openWorkspace
openWorkspace,
setThinkingLevel,
compact,
abortCompaction,
setAutoCompaction,
dismissCompactionNotice,
navigateTree
} = useAgent()
const prefillRef = useRef<string | null>(null)
const [settingsOpen, setSettingsOpen] = useState(false)
@@ -226,7 +234,14 @@ export default function App(): JSX.Element {
retry={state.retry}
sessionError={state.sessionError}
pendingApprovals={state.approvals}
compacting={state.compacting}
compactionNotice={state.compactionNotice}
navigating={navigating}
forkPoints={forkPoints}
onDismissError={clearError}
onDismissCompactionNotice={dismissCompactionNotice}
onAbortCompaction={abortCompaction}
onRewind={(entryId, summarize) => navigateTree(entryId, { summarize })}
onPickSuggestion={handlePickSuggestion}
onApplySelection={handleApplySelection}
/>
@@ -242,12 +257,21 @@ export default function App(): JSX.Element {
permissionMode={state.sessionState?.permissionMode ?? 'confirm'}
sessionKey={state.sessionState?.sessionId}
sessionMessageCount={state.sessionState?.messageCount}
stats={state.stats}
thinkingLevel={state.sessionState?.thinkingLevel}
thinkingLevels={state.sessionState?.thinkingLevels}
compacting={state.compacting}
autoCompactionEnabled={state.sessionState?.autoCompactionEnabled ?? true}
onSend={send}
onStop={stop}
onNewSession={newSession}
onSetModel={setModel}
onOpenSettings={() => setSettingsOpen(true)}
onPermissionMode={setPermissionMode}
onSetThinkingLevel={setThinkingLevel}
onCompact={compact}
onAbortCompaction={abortCompaction}
onToggleAutoCompaction={setAutoCompaction}
prefillRef={prefillRef}
/>
}
@@ -1,7 +1,9 @@
import { useEffect, useRef } from 'react'
import type { ReactNode } from 'react'
import { LuRotateCw, LuTriangleAlert } from 'react-icons/lu'
import type { ApprovalRequest, ToolState, UiMessage } from '../hooks/useAgent'
import { LuFoldVertical, LuLoaderCircle, LuRotateCw, LuTriangleAlert, LuX } from 'react-icons/lu'
import type { ApprovalRequest, ForkPoint } from '@shared/types'
import type { CompactionNotice, ToolState, UiMessage } from '../hooks/useAgent'
import { formatTokens } from '../lib/utils'
import { AssistantTurn, MessageItem } from './MessageItem'
import { SelectionActions } from './SelectionActions'
@@ -33,16 +35,23 @@ function EmptyState({ onPick }: { onPick: (text: string) => void }): ReactNode {
}
/** 连续 assistant 消息(pi 每个工具轮次一条)合并为一个回合渲染,
* 整轮思考 + 工具调用共享一张 beautifului ToolChips 卡。 */
* 整轮思考 + 工具调用共享一张 beautifului ToolChips 卡。
* pinnedRef 向下透传:回合结束时据此判断用户是否位于底部,位于底部才即时折叠。
* forkPoints 与时间线中的用户消息按出现顺序一一对齐(pi getUserMessagesForForking 同序)。 */
function renderTurns(
messages: UiMessage[],
tools: Record<string, ToolState>,
streaming: boolean,
turnActive: boolean,
pendingApprovals: ApprovalRequest[]
pendingApprovals: ApprovalRequest[],
pinnedRef: { current: boolean },
forkPoints: ForkPoint[],
rewinding: boolean,
onRewind: (entryId: string, summarize: boolean) => void
): ReactNode[] {
const items: ReactNode[] = []
let i = 0
let userMessageIdx = 0
while (i < messages.length) {
const message = messages[i]
if (message.role === 'assistant') {
@@ -60,10 +69,14 @@ function renderTurns(
streaming={streaming && isLive}
turnActive={turnActive && isLive}
pendingApprovals={pendingApprovals}
scrollPinnedRef={pinnedRef}
/>
)
i = j
} else {
// 用户消息按序对齐回溯点;非用户角色(压缩/分支摘要通知行)不占序号
const forkEntryId =
message.role === 'user' && !message.local ? forkPoints[userMessageIdx++]?.entryId : undefined
items.push(
<MessageItem
key={message.key}
@@ -72,6 +85,10 @@ function renderTurns(
streaming={streaming && i === messages.length - 1}
turnActive={turnActive && i === messages.length - 1}
pendingApprovals={pendingApprovals}
scrollPinnedRef={pinnedRef}
forkEntryId={forkEntryId}
rewinding={rewinding}
onRewind={onRewind}
/>
)
i += 1
@@ -80,6 +97,12 @@ function renderTurns(
return items
}
const COMPACTION_REASON_LABELS: Record<CompactionNotice['reason'], string> = {
manual: '手动触发',
threshold: '接近窗口上限,自动触发',
overflow: '上下文溢出,自动触发'
}
export function ChatView({
messages,
tools,
@@ -89,7 +112,14 @@ export function ChatView({
retry,
sessionError,
pendingApprovals,
compacting,
compactionNotice,
navigating,
forkPoints,
onDismissError,
onDismissCompactionNotice,
onAbortCompaction,
onRewind,
onPickSuggestion,
onApplySelection
}: {
@@ -101,7 +131,17 @@ export function ChatView({
retry: { attempt: number; maxAttempts: number; errorMessage: string } | null
sessionError: string | null
pendingApprovals: ApprovalRequest[]
/** 正在压缩上下文 */
compacting: boolean
/** 最近一次压缩结果通知(自动消退) */
compactionNotice: CompactionNotice | null
/** 分支回溯/摘要生成进行中 */
navigating: boolean
forkPoints: ForkPoint[]
onDismissError: () => void
onDismissCompactionNotice: () => void
onAbortCompaction: () => void
onRewind: (entryId: string, summarize: boolean) => void
onPickSuggestion: (text: string) => void
onApplySelection: (instruction: string, selection: string) => void
}): ReactNode {
@@ -127,47 +167,126 @@ export function ChatView({
return () => cancelAnimationFrame(raf)
}, [messages, tools, streaming])
// 压缩结果通知 8 秒后自动消退
useEffect(() => {
if (!compactionNotice) return
const timer = window.setTimeout(onDismissCompactionNotice, 8000)
return () => window.clearTimeout(timer)
}, [compactionNotice, onDismissCompactionNotice])
const hasContent = messages.length > 0
return (
<div className="relative flex min-h-0 flex-1 flex-col">
{(error || sessionError) && (
<div className="absolute inset-x-4 top-3 z-10 flex items-start gap-2.5 rounded-control border border-red/40 bg-red-tint px-4 py-3 text-[13px] text-red">
<LuTriangleAlert size={16} className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
{sessionError ? (
<>
<div className="mb-0.5 font-medium">Agent 会话初始化失败</div>
<div className="break-all text-red/80">{sessionError}</div>
</>
) : (
<div className="break-all">{error}</div>
)}
<div className="absolute inset-x-4 top-3 z-10 flex flex-col gap-2">
{(error || sessionError) && (
<div className="flex items-start gap-2.5 rounded-control border border-red/40 bg-red-tint px-4 py-3 text-[13px] text-red">
<LuTriangleAlert size={16} className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
{sessionError ? (
<>
<div className="mb-0.5 font-medium">Agent 会话初始化失败</div>
<div className="break-all text-red/80">{sessionError}</div>
</>
) : (
<div className="break-all">{error}</div>
)}
</div>
<button
type="button"
onClick={onDismissError}
className="shrink-0 rounded-control px-1.5 text-red/70 transition-colors hover:bg-red/10 hover:text-red"
>
✕
</button>
</div>
<button
type="button"
onClick={onDismissError}
className="shrink-0 rounded-control px-1.5 text-red/70 transition-colors hover:bg-red/10 hover:text-red"
>
✕
</button>
</div>
)}
)}
{retry && (
<div className="absolute inset-x-4 top-3 z-10 flex items-center gap-2.5 rounded-card border border-orange/40 bg-orange-tint px-4 py-2.5 text-[13px] text-orange">
<LuRotateCw size={14} className="animate-spin" />
<span>
请求失败,自动重试 {retry.attempt}/{retry.maxAttempts}
</span>
<span className="truncate text-orange/80">{retry.errorMessage}</span>
</div>
)}
{retry && (
<div className="flex items-center gap-2.5 rounded-card border border-orange/40 bg-orange-tint px-4 py-2.5 text-[13px] text-orange">
<LuRotateCw size={14} className="animate-spin" />
<span>
请求失败,自动重试 {retry.attempt}/{retry.maxAttempts}
</span>
<span className="truncate text-orange/80">{retry.errorMessage}</span>
</div>
)}
{compacting && (
<div className="flex items-center gap-2.5 rounded-card border border-line bg-surface px-4 py-2.5 text-[13px] text-ink-2 shadow-card">
<LuLoaderCircle size={14} className="animate-spin text-ink-3" />
<span className="min-w-0 flex-1">正在压缩上下文,较早的对话将归档为摘要…</span>
<button
type="button"
onClick={onAbortCompaction}
className="shrink-0 rounded-control px-1.5 text-ink-3 transition-colors hover:bg-hover hover:text-ink"
>
停止
</button>
</div>
)}
{navigating && (
<div className="flex items-center gap-2.5 rounded-card border border-line bg-surface px-4 py-2.5 text-[13px] text-ink-2 shadow-card">
<LuLoaderCircle size={14} className="animate-spin text-ink-3" />
<span className="min-w-0 flex-1">正在回溯并生成分支摘要…</span>
</div>
)}
{compactionNotice && !compacting && (
<div className="flex items-start gap-2.5 rounded-card border border-line bg-surface px-4 py-2.5 text-[13px] text-ink-2 shadow-card">
{compactionNotice.aborted || compactionNotice.errorMessage ? (
<LuTriangleAlert size={14} className="mt-0.5 shrink-0 text-orange" />
) : (
<LuFoldVertical size={14} className="mt-0.5 shrink-0 text-accent-ink" />
)}
<div className="min-w-0 flex-1">
{compactionNotice.aborted ? (
<span>已停止压缩</span>
) : compactionNotice.errorMessage ? (
<span className="break-all">压缩失败:{compactionNotice.errorMessage}</span>
) : (
<>
<span>
上下文已压缩({COMPACTION_REASON_LABELS[compactionNotice.reason]}
{compactionNotice.tokensBefore
? `,约 ${formatTokens(compactionNotice.tokensBefore)} tokens 归档为摘要`
: ''}
)
</span>
{compactionNotice.summary && (
<span className="mt-0.5 line-clamp-2 block text-[12px] leading-4 text-ink-3">
{compactionNotice.summary}
</span>
)}
</>
)}
</div>
<button
type="button"
onClick={onDismissCompactionNotice}
className="shrink-0 rounded-control px-1.5 text-ink-3 transition-colors hover:bg-hover hover:text-ink"
>
<LuX size={13} />
</button>
</div>
)}
</div>
<div ref={scrollRef} onScroll={handleScroll} className="min-h-0 flex-1 overflow-y-auto">
{hasContent ? (
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
{renderTurns(messages, tools, streaming, turnActive, pendingApprovals)}
{renderTurns(
messages,
tools,
streaming,
turnActive,
pendingApprovals,
pinnedRef,
forkPoints,
navigating,
onRewind
)}
</div>
) : (
<div className="h-full">
@@ -1,12 +1,24 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import type { JSX, RefObject } from 'react'
import { LuCheck, LuFileText, LuGripVertical, LuHand, LuPencil, LuShieldCheck, LuTrash2, LuX } from 'react-icons/lu'
import type { AttachmentInfo, ChatResources, ModelCatalog, ModelInfo, PermissionMode, WorkspaceFile } from '@shared/types'
import { LuBrain, LuCheck, LuFileText, LuGauge, LuGripVertical, LuHand, LuPencil, LuShieldCheck, LuTrash2, LuX } from 'react-icons/lu'
import type {
AttachmentInfo,
ChatResources,
ModelCatalog,
ModelInfo,
PermissionMode,
SessionStatsInfo,
ThinkingLevel,
WorkspaceFile
} from '@shared/types'
import { UsageCard } from './UsageCard'
import { formatPercent } from '../lib/utils'
/**
* Prompt Bar(beautifului.dev #prompt-bar):
* 14px 圆角容器 + 五列网格(+/输入/模型/权限/发送),focus 边框加深。
* Prompt Bar(beautifului.dev #prompt-bar,布局仿 ZCode Chatbox):
* 14px 圆角容器,输入区整行在上,工具行在下(左「+」、右模型/权限/发送),
* focus 边框加深。
* "/" 命令面板:应用内动作 + pi 文件命令模板 + /skill: 技能(发送时由 SDK 展开);
* "@" 引用工作区文件;"+" 添加附件(图片为真实 ImageContent,文本文件内容注入,其余 @path 引用);
* 支持直接粘贴截图(clipboard 图片 → 附件)。
@@ -76,6 +88,18 @@ const PERMISSION_MODES: Array<{ id: PermissionMode; label: string; desc: string;
{ id: 'full', label: '完全访问', desc: '自动执行所有工具调用,不再逐一确认。', icon: LuShieldCheck }
]
/** 思考等级展示名与排序(pi ThinkingLevel) */
const THINKING_LEVEL_ORDER: ThinkingLevel[] = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
const THINKING_LEVEL_LABELS: Record<ThinkingLevel, { label: string; desc: string }> = {
off: { label: '关闭', desc: '不进行思考,直接回答。' },
minimal: { label: '最简', desc: '极少量思考,最快响应。' },
low: { label: '低', desc: '轻量思考,开销小幅增加。' },
medium: { label: '中', desc: '平衡思考深度与速度。' },
high: { label: '高', desc: '更深入的推理,适合复杂任务。' },
xhigh: { label: '超高', desc: '深度推理,明显增加耗时与开销。' },
max: { label: '最大', desc: '模型支持的极限推理预算。' }
}
export function Composer({
streaming,
queue,
@@ -84,12 +108,21 @@ export function Composer({
permissionMode = 'confirm',
sessionKey,
sessionMessageCount,
stats,
thinkingLevel,
thinkingLevels = [],
compacting,
autoCompactionEnabled = true,
onSend,
onStop,
onNewSession,
onSetModel,
onOpenSettings,
onPermissionMode,
onSetThinkingLevel,
onCompact,
onAbortCompaction,
onToggleAutoCompaction,
prefillRef
}: {
streaming: boolean
@@ -102,12 +135,26 @@ export function Composer({
sessionKey?: string
/** 当前会话消息数(0 = 新建未发言的空会话) */
sessionMessageCount?: number
/** 会话统计(Usage Card 数据源) */
stats: SessionStatsInfo | null
/** 当前思考等级 */
thinkingLevel?: ThinkingLevel
/** 当前模型可用的思考等级(空 = 不支持思考,隐藏切换器) */
thinkingLevels?: ThinkingLevel[]
/** 正在压缩上下文 */
compacting: boolean
/** 自动压缩开关 */
autoCompactionEnabled?: boolean
onSend: (text: string, images?: { mimeType: string; data: string }[]) => void
onStop: () => void
onNewSession: () => void
onSetModel: (providerId: string, modelId: string) => void
onOpenSettings: () => void
onPermissionMode: (mode: PermissionMode) => void
onSetThinkingLevel: (level: ThinkingLevel) => void
onCompact: (instructions?: string) => void
onAbortCompaction: () => void
onToggleAutoCompaction: (enabled: boolean) => void
/** 供空状态建议词点击后填入 */
prefillRef: RefObject<string | null>
}): JSX.Element {
@@ -116,6 +163,10 @@ export function Composer({
const [modelPos, setModelPos] = useState<{ top: number; left: number; right: number } | null>(null)
const [permOpen, setPermOpen] = useState(false)
const [permPos, setPermPos] = useState<{ top: number; left: number; right: number } | null>(null)
const [usageOpen, setUsageOpen] = useState(false)
const [usagePos, setUsagePos] = useState<{ top: number; left: number; right: number } | null>(null)
const [thinkingOpen, setThinkingOpen] = useState(false)
const [thinkingPos, setThinkingPos] = useState<{ top: number; left: number; right: number } | null>(null)
const [catalog, setCatalog] = useState<ModelCatalog | null>(null)
const [catalogLoading, setCatalogLoading] = useState(false)
const [attachments, setAttachments] = useState<AttachmentInfo[]>([])
@@ -129,6 +180,7 @@ export function Composer({
const localCommands = useMemo<LocalCommand[]>(
() => [
{ id: 'new', label: '/new', hint: '新会话', run: onNewSession },
{ id: 'compact', label: '/compact', hint: '压缩上下文(可附指令,如 /compact 专注数据表设计)', run: () => undefined },
{ id: 'settings', label: '/settings', hint: '打开设置', run: onOpenSettings },
{ id: 'stop', label: '/stop', hint: '停止生成', run: onStop, enabled: streaming },
{ id: 'clear', label: '/clear', hint: '清空输入', run: () => setValue('') }
@@ -136,10 +188,12 @@ export function Composer({
[onNewSession, onOpenSettings, onStop, streaming]
)
// "/" 命令面板:输入以 / 开头且当前词无空格时触发
// "/" 命令面板:输入以 / 开头且当前词无空格时触发;
// /compact 例外——允许带参数(如「/compact 保留表设计讨论」),带参数时保持面板打开
const trimmed = value.trimStart()
const slashExec = trimmed === value ? /^\/(\S*)$/.exec(trimmed) : null
const slashQuery = slashExec ? (slashExec[1] ?? '').toLowerCase() : null
const compactWithArgs = /^\/compact\s+\S/.test(trimmed)
const slashQuery = slashExec ? (slashExec[1] ?? '').toLowerCase() : compactWithArgs ? 'compact' : null
// "@ " 引用文件:末个 @ 词令牌触发
const atMatch = /@([^\s@]*)$/.exec(value)
@@ -368,7 +422,8 @@ export function Composer({
}, [menuOpen])
const closeMenu = (): void => {
if (atMatch) setValue(value.replace(/@[^\s@]*$/, ''))
else if (slashQuery !== null) setValue('')
else if (slashExec) setValue('')
// 带参数的 /compact:仅关闭面板,保留已输入的指令
}
useEffect(() => setActiveIdx(0), [value])
@@ -410,6 +465,8 @@ export function Composer({
const modelBtnRef = useRef<HTMLButtonElement>(null)
const permBtnRef = useRef<HTMLButtonElement>(null)
const usageBtnRef = useRef<HTMLButtonElement>(null)
const thinkingBtnRef = useRef<HTMLButtonElement>(null)
const anchorOf = (el: HTMLElement | null): { top: number; left: number; right: number } | null => {
if (!el) return null
@@ -446,9 +503,40 @@ export function Composer({
setPermOpen(true)
}
const toggleUsageCard = (): void => {
if (usageOpen) {
setUsageOpen(false)
return
}
setUsagePos(anchorOf(usageBtnRef.current))
setUsageOpen(true)
}
const toggleThinkingMenu = (): void => {
if (thinkingOpen) {
setThinkingOpen(false)
return
}
setThinkingPos(anchorOf(thinkingBtnRef.current))
setThinkingOpen(true)
}
/** /compact 的参数随命令一起输入(如「/compact 保留数据表设计讨论」) */
const runCompactCommand = (): void => {
const m = /^\/compact(?:\s+([\s\S]+))?$/.exec(trimmed)
const instructions = m?.[1]?.trim()
onCompact(instructions || undefined)
setValue('')
}
const pickMenuItem = (index: number): void => {
if (index < commandItems.length) {
const item = commandItems[index]
if (item.key === 'local-compact') {
runCompactCommand()
textareaRef.current?.focus()
return
}
setValue('')
textareaRef.current?.focus()
if (item.insert) {
@@ -733,21 +821,8 @@ export function Composer({
</div>
)}
<div className="grid grid-cols-[28px_minmax(0,1fr)_auto_auto_28px] items-end gap-x-1 gap-y-1.5">
{/* +:添加附件 */}
<button
type="button"
aria-label="添加附件"
onClick={addAttachments}
disabled={disabled || picking}
title="添加附件(图片/文本文件随消息发送,其余文件以 @路径 引用)"
className="col-start-1 row-start-1 flex size-7 shrink-0 justify-self-start items-center justify-center rounded-[8px] text-ink-3 transition-[background-color,color,transform] duration-150 hover:bg-hover hover:text-ink active:scale-[0.94] disabled:cursor-not-allowed disabled:opacity-40"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 5v14M5 12h14" />
</svg>
</button>
{/* 输入区整行在上(仿 ZCode),工具行独立在下 */}
<div className="flex flex-col gap-0.5">
<textarea
ref={textareaRef}
value={value}
@@ -759,71 +834,122 @@ export function Composer({
disabled={disabled}
placeholder={disabled ? 'Agent 会话未就绪…' : streaming ? '继续输入以排队后续修改…' : '写下消息,/ 命令与技能、@ 引用文件'}
aria-label="Prompt"
className="col-start-2 row-start-1 min-h-7 w-full min-w-0 resize-none bg-transparent px-1 py-[5px] text-[13px] leading-[18px] text-ink outline-none [overflow-wrap:anywhere] placeholder:text-ink-3 disabled:cursor-not-allowed"
className="min-h-7 w-full min-w-0 resize-none bg-transparent px-1.5 py-1.5 text-[13px] leading-[18px] text-ink outline-none [overflow-wrap:anywhere] placeholder:text-ink-3 disabled:cursor-not-allowed"
/>
{/* 模型选择 */}
<button
ref={modelBtnRef}
type="button"
aria-expanded={modelOpen}
aria-label="Choose model"
onClick={openModelMenu}
disabled={disabled}
className="col-start-3 row-start-1 flex h-7 shrink-0 items-center gap-1 rounded-[8px] px-1.5 text-[12px] font-medium text-ink-2 transition-colors duration-150 hover:bg-hover hover:text-ink disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="max-w-[160px] truncate">{model?.name ?? '选择模型'}</span>
<span className="text-ink-3">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M6 9l6 6 6-6" />
</svg>
</span>
</button>
{/* 权限模式(图标) */}
<button
ref={permBtnRef}
type="button"
aria-expanded={permOpen}
aria-label={`权限模式:${permMeta.label}`}
onClick={togglePermMenu}
disabled={disabled}
title={`权限模式:${permMeta.label}(${permMeta.desc})`}
className={`col-start-4 row-start-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-[8px] transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-40 ${
permissionMode === 'full' ? 'bg-accent-tint text-accent-ink' : 'text-ink-3 hover:text-ink-2'
}`}
>
<permMeta.icon size={14} />
</button>
{/* 发送 / 停止 */}
{streaming ? (
{/* 工具行:左「+」,右 模型 / 权限 / 发送 */}
<div className="flex items-center gap-1">
{/* +:添加附件 */}
<button
type="button"
aria-label="停止生成"
onClick={onStop}
className="col-start-5 row-start-1 flex size-7 shrink-0 items-center justify-center rounded-[8px] bg-ink text-canvas transition-[background-color,color,transform] duration-200 hover:opacity-90 active:scale-[0.94]"
aria-label="添加附件"
onClick={addAttachments}
disabled={disabled || picking}
title="添加附件(图片/文本文件随消息发送,其余文件以 @路径 引用)"
className="flex size-7 shrink-0 items-center justify-center rounded-[8px] text-ink-3 transition-[background-color,color,transform] duration-150 hover:bg-hover hover:text-ink active:scale-[0.94] disabled:cursor-not-allowed disabled:opacity-40"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<rect x="6" y="6" width="12" height="12" rx="2" />
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 5v14M5 12h14" />
</svg>
</button>
) : (
<div className="min-w-0 flex-1" />
{/* 上下文用量 pill(Usage Card) */}
<button
ref={usageBtnRef}
type="button"
aria-label="Send"
onClick={submit}
disabled={!canSend}
className="col-start-5 row-start-1 flex size-7 shrink-0 items-center justify-center rounded-[8px] transition-[background-color,color,transform] duration-200 enabled:active:scale-[0.94] disabled:cursor-not-allowed"
style={canSend ? { background: 'var(--ink)', color: 'var(--canvas)' } : { background: 'var(--line-strong)', color: 'var(--ink-2)' }}
aria-expanded={usageOpen}
aria-label="上下文用量"
onClick={toggleUsageCard}
title="上下文容量、分类用量与缓存命中率"
className="flex h-7 shrink-0 items-center gap-1 rounded-[8px] px-1.5 text-[12px] font-medium tabular-nums text-ink-2 transition-colors duration-150 hover:bg-hover hover:text-ink"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 19V5M5 12l7-7 7 7" />
</svg>
<LuGauge size={13} className={compacting ? 'animate-pulse' : ''} />
{stats?.context?.percent != null ? formatPercent(stats.context.percent) : '上下文'}
</button>
)}
</div>
{/* 模型选择 */}
<button
ref={modelBtnRef}
type="button"
aria-expanded={modelOpen}
aria-label="Choose model"
onClick={openModelMenu}
disabled={disabled}
className="flex h-7 shrink-0 items-center gap-1 rounded-[8px] px-1.5 text-[12px] font-medium text-ink-2 transition-colors duration-150 hover:bg-hover hover:text-ink disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="max-w-[160px] truncate">{model?.name ?? '选择模型'}</span>
<span className="text-ink-3">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M6 9l6 6 6-6" />
</svg>
</span>
</button>
{/* 思考等级切换器(模型不支持思考时隐藏) */}
{thinkingLevels.length > 0 && (
<button
ref={thinkingBtnRef}
type="button"
aria-expanded={thinkingOpen}
aria-label={`思考等级:${thinkingLevel ? THINKING_LEVEL_LABELS[thinkingLevel]?.label ?? thinkingLevel : '未知'}`}
onClick={toggleThinkingMenu}
title={`思考等级:${thinkingLevel ? THINKING_LEVEL_LABELS[thinkingLevel]?.label ?? thinkingLevel : ''}`}
className={`flex h-7 shrink-0 items-center gap-1 rounded-[8px] px-1.5 text-[12px] font-medium transition-colors duration-150 hover:bg-hover disabled:cursor-not-allowed disabled:opacity-50 ${
thinkingLevel && thinkingLevel !== 'off' ? 'text-accent-ink' : 'text-ink-2 hover:text-ink'
}`}
>
<LuBrain size={13} />
<span>{thinkingLevel ? THINKING_LEVEL_LABELS[thinkingLevel]?.label ?? thinkingLevel : '思考'}</span>
</button>
)}
{/* 权限模式(图标) */}
<button
ref={permBtnRef}
type="button"
aria-expanded={permOpen}
aria-label={`权限模式:${permMeta.label}`}
onClick={togglePermMenu}
disabled={disabled}
title={`权限模式:${permMeta.label}(${permMeta.desc})`}
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-[8px] transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-40 ${
permissionMode === 'full' ? 'bg-accent-tint text-accent-ink' : 'text-ink-3 hover:text-ink-2'
}`}
>
<permMeta.icon size={14} />
</button>
{/* 发送 / 停止 */}
{streaming ? (
<button
type="button"
aria-label="停止生成"
onClick={onStop}
className="flex size-7 shrink-0 items-center justify-center rounded-[8px] bg-ink text-canvas transition-[background-color,color,transform] duration-200 hover:opacity-90 active:scale-[0.94]"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<rect x="6" y="6" width="12" height="12" rx="2" />
</svg>
</button>
) : (
<button
type="button"
aria-label="Send"
onClick={submit}
disabled={!canSend}
className="flex size-7 shrink-0 items-center justify-center rounded-[8px] transition-[background-color,color,transform] duration-200 enabled:active:scale-[0.94] disabled:cursor-not-allowed"
style={canSend ? { background: 'var(--ink)', color: 'var(--canvas)' } : { background: 'var(--line-strong)', color: 'var(--ink-2)' }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 19V5M5 12l7-7 7 7" />
</svg>
</button>
)}
</div>
</div>
</div>
{/* 命令 / 技能 / 文件 菜单(portal 覆盖层,向上弹出) */}
{menuOpen && menuPos && (
@@ -913,6 +1039,62 @@ export function Composer({
</ComposerMenu>
)}
{/* 思考等级菜单(portal,向上弹出,右缘对齐) */}
{thinkingOpen && thinkingPos && (
<ComposerMenu anchor={thinkingPos} align="right" onClose={() => setThinkingOpen(false)}>
<div className="w-72 p-1.5">
{[...thinkingLevels]
.sort((a, b) => THINKING_LEVEL_ORDER.indexOf(a) - THINKING_LEVEL_ORDER.indexOf(b))
.map((level) => {
const meta = THINKING_LEVEL_LABELS[level] ?? { label: level, desc: '' }
const active = level === thinkingLevel
return (
<button
key={level}
type="button"
onClick={() => {
onSetThinkingLevel(level)
setThinkingOpen(false)
}}
className={`flex w-full items-start gap-2.5 rounded-control px-2.5 py-2 text-left transition-colors hover:bg-hover ${
active ? 'bg-hover' : ''
}`}
>
<span className="mt-0.5 flex size-6 shrink-0 items-center justify-center text-ink-2">
<LuBrain size={13} />
</span>
<span className="min-w-0 flex-1">
<span className="block text-[12.5px] font-medium text-ink">{meta.label}</span>
{meta.desc && <span className="block text-[11.5px] leading-4 text-ink-3">{meta.desc}</span>}
</span>
{active && <LuCheck size={14} className="mt-1 shrink-0 text-accent-ink" />}
</button>
)
})}
</div>
</ComposerMenu>
)}
{/* Usage Card(portal,向上弹出) */}
{usageOpen && usagePos && (
<UsageCard
anchor={usagePos}
stats={stats}
compacting={compacting}
autoCompactionEnabled={autoCompactionEnabled}
onClose={() => setUsageOpen(false)}
onCompact={() => {
onCompact()
setUsageOpen(false)
}}
onAbortCompaction={() => {
onAbortCompaction()
setUsageOpen(false)
}}
onToggleAutoCompaction={onToggleAutoCompaction}
/>
)}
{/* 模型选择菜单(portal,向上弹出) */}
{modelOpen && modelPos && (
<ComposerMenu anchor={modelPos} align="right" onClose={() => setModelOpen(false)}>
@@ -7,6 +7,8 @@ import { StreamingText } from './StreamingText'
import { Thinking, thinkingRows } from './Thinking'
import { ToolChips, type ToolCallView } from './ToolChips'
import { useEntrance } from '../hooks/useEntrance'
import { LuFoldVertical, LuHistory, LuUndo2 } from 'react-icons/lu'
import { formatTokens } from '../lib/utils'
interface ContentBlock {
type?: string
@@ -39,7 +41,9 @@ function lastThinkingLine(text: string): string {
return ''
}
/** 工作计时:working=true 起表,结束后冻结读数 */
/** 工作计时:working=true 起表,结束后冻结读数。
* working 中途抖动(pi 轮次间隙 streaming/turnActive 抖动)不锁存:
* 恢复 true 时解除冻结,继续从起点计时。 */
function useWorkingSeconds(working: boolean): string {
const startRef = useRef<number | null>(null)
const [finalSecs, setFinalSecs] = useState<number | null>(null)
@@ -53,6 +57,10 @@ function useWorkingSeconds(working: boolean): string {
}, [working])
useEffect(() => {
if (working && finalSecs !== null) {
setFinalSecs(null)
return
}
if (!working && startRef.current !== null && finalSecs === null) {
setFinalSecs(Math.max(1, Math.round((Date.now() - startRef.current) / 1000)))
}
@@ -78,37 +86,77 @@ function useWorkingSeconds(working: boolean): string {
* 回合内发出多次 agent_start/agent_end,streaming 会中途抖动,划分跟着抖就会
* 出现正文在答案区与过程区之间来回跳的闪现);正在流式的消息始终渲染在底部
* 作为答案的雏形,它结束、下一轮开始后即「沉」入过程区。 */
/** 结论底部的常驻复制按钮:不随流式抖动挂载/卸载(以前每个正文段各挂一个,
* 会随回合内 streaming 抖动反复闪现),复制内容始终取当前结论文本。 */
function CopyAnswerButton({ text }: { text: string }): ReactNode {
const [copied, setCopied] = useState(false)
useEffect(() => {
if (!copied) return
const t = setTimeout(() => setCopied(false), 1600)
return () => clearTimeout(t)
}, [copied])
const disabled = !text.trim()
return (
<button
type="button"
aria-label={copied ? '已复制' : '复制回答'}
title={copied ? '已复制' : '复制回答'}
disabled={disabled}
onClick={() => {
void navigator.clipboard.writeText(text).then(() => setCopied(true))
}}
className="flex size-6 items-center justify-center rounded-[6px] text-ink-3 transition-colors duration-100 hover:bg-hover-2 hover:text-ink-2 disabled:cursor-default disabled:opacity-40"
>
{copied ? (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6L9 17l-5-5" />
</svg>
) : (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="12" height="12" rx="2.5" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</button>
)
}
export function AssistantTurn({
messages,
tools,
streaming,
turnActive,
pendingApprovals
pendingApprovals,
scrollPinnedRef
}: {
messages: UiMessage[]
tools: Record<string, ToolState>
streaming: boolean
turnActive: boolean
pendingApprovals: ApprovalRequest[]
/** 聊天滚动容器的吸底标记(ChatView 持有):回合结束时只在用户位于底部
* 时执行即时折叠——用户上翻阅读时折叠会猛拽滚动位置/滚动条,保持展开 */
scrollPinnedRef?: { current: boolean }
}): ReactNode {
const processNodes: ReactNode[] = []
const answerNodes: ReactNode[] = []
const labelCount = new Map<string, number>()
let calls = 0
let thinking = 0
let callsAndThinking: ToolCallView[] = []
let thinkingTexts: string[] = []
let pendingCalls: ToolCallView[] = []
let stream: UiMessage['stream'] | null = null
let answerText = ''
/** 连续的思考/工具行作为一组裸行铺出(组内保留顺序),不缩进 */
/** 连续的工具行作为一组裸行铺出(组内保留顺序),不缩进 */
const flushRows = (): void => {
if (!callsAndThinking.length && !thinkingTexts.length) return
const group = { calls: callsAndThinking, thinkingTexts }
callsAndThinking = []
thinkingTexts = []
if (!pendingCalls.length) return
const group = pendingCalls
pendingCalls = []
processNodes.push(
<div key={`rows-${processNodes.length}`} className="my-1.5">
<ToolChips calls={group.calls} thinking={group.thinkingTexts} />
<ToolChips calls={group} />
</div>
)
}
@@ -118,12 +166,21 @@ export function AssistantTurn({
for (const block of readBlocks(raw)) {
const thinkingText = block.type === 'thinking' ? (block.text ?? block.thinking) : undefined
if (block.type === 'thinking' && typeof thinkingText === 'string' && thinkingText.trim()) {
thinkingTexts.push(thinkingText)
// 已完成的思考以展开的 Thinking 面板留在过程区——与流式形态一致。
// 以前在这里压成 ToolChips 的单行摘要,每个工具轮次 message_end 时
// 展开面板「缩」成一行、下一轮又展开新面板,看起来像过程区被反复收起。
flushRows()
processNodes.push(
<div key={`think-${processNodes.length}`} className="mb-3">
<Thinking rows={thinkingRows(thinkingText)} streaming={false} />
</div>
)
thinking += 1
continue
}
if (block.type === 'text' && block.text) {
flushRows()
if (asAnswer) answerText = answerText ? `${answerText}\n\n${block.text}` : block.text
const node = (
<div key={`${asAnswer ? 'a' : 'p'}-${processNodes.length + answerNodes.length}`} className="my-3">
<Markdown text={block.text} />
@@ -139,14 +196,14 @@ export function AssistantTurn({
if (!tool) {
const pending = pendingApprovals.find((a) => a.toolName === name)
if (pending) {
callsAndThinking.push({ id: block.id, name, pendingApproval: { args: pending.args } })
pendingCalls.push({ id: block.id, name, pendingApproval: { args: pending.args } })
calls += 1
continue
}
}
const dup = labelCount.get(name) ?? 0
labelCount.set(name, dup + 1)
callsAndThinking.push({ id: block.id, name, tool })
pendingCalls.push({ id: block.id, name, tool })
calls += 1
}
}
@@ -171,14 +228,35 @@ export function AssistantTurn({
: false
const hasProcess = calls > 0 || thinking > 0 || streamHasThinking
// 折叠状态:历史回合默认收起,进行中的回合默认展开;回合真正结束
// (agent_settled → turnActive=false,包含压缩/重试续跑都完成)才自动收起
const [collapsed, setCollapsed] = useState<boolean>(() => !turnActive && hasProcess)
const prevTurnActive = useRef(turnActive)
// 回合是否仍在进行:turnActive/streaming 在 pi 的轮次间隙(工具执行、自动压缩、
// 排队续跑)都会中途抖动,流式占位还在(stream 非空)也代表内容仍在到达——
// 任一为真都按「进行中」处理,绝不能只看 turnActive(抖一下就会误折叠整个回合)
const live = turnActive || streaming || Boolean(stream)
// 折叠状态:历史回合默认收起,进行中的回合默认展开;结算(live 由真转假)后
// 延迟一小段才自动收起,抖动恢复即取消;且仅当用户位于聊天底部时才收起——
// 上翻阅读时折叠会猛拽滚动位置,保持展开。因抖动被自动收起的回合在 live 恢复
// 时自动重新展开;用户手动收起的不参与自动展开。
const [collapsed, setCollapsed] = useState<boolean>(() => !live && hasProcess)
const autoCollapsedRef = useRef(false)
const prevLiveRef = useRef(live)
useEffect(() => {
if (prevTurnActive.current && !turnActive) setCollapsed(true)
prevTurnActive.current = turnActive
}, [turnActive])
const prev = prevLiveRef.current
prevLiveRef.current = live
if (prev && !live) {
const timer = window.setTimeout(() => {
if (scrollPinnedRef?.current !== false) {
autoCollapsedRef.current = true
setCollapsed(true)
}
}, 600)
return () => window.clearTimeout(timer)
}
if (!prev && live && autoCollapsedRef.current) {
autoCollapsedRef.current = false
setCollapsed(false)
}
}, [live, scrollPinnedRef])
// 过程区折叠/展开:GSAP 高度 + 透明度补间(可中断续接)。内联高度完全由
// 动画层持有,React 不渲染 style,避免重渲染时清掉补间中的值。
@@ -204,7 +282,7 @@ export function AssistantTurn({
else animateExpand(el)
}, [collapsed, hasProcess])
const workedFor = useWorkingSeconds(turnActive)
const workedFor = useWorkingSeconds(turnActive || streaming)
const headerLabel = workedFor || `${calls} 次工具调用${thinking ? `,${thinking} 条消息` : ''}`
// 流式分段:思考段属过程(过程区折叠时隐藏,由头部下方的紧凑行代为展示
@@ -213,6 +291,11 @@ export function AssistantTurn({
const streamNodes: ReactNode[] = []
if (stream) {
const segments = stream.segments
// 光标只挂在最后一个非空正文段末尾(CSS ::after 附加),而非每个正文段一个
let lastTextSeg = -1
segments.forEach((s, i) => {
if (s.kind === 'text' && s.text) lastTextSeg = i
})
segments.forEach((seg, i) => {
if (seg.kind === 'thinking') {
const rows = thinkingRows(seg.text)
@@ -229,8 +312,9 @@ export function AssistantTurn({
return
}
if (seg.text) {
answerText = answerText ? `${answerText}\n\n${seg.text}` : seg.text
streamNodes.push(
<StreamingText key={`seg-${i}`} streaming={streaming} text={seg.text}>
<StreamingText key={`seg-${i}`} streaming={streaming && i === lastTextSeg}>
<Markdown text={seg.text} streaming={streaming} />
</StreamingText>
)
@@ -242,6 +326,11 @@ export function AssistantTurn({
const compactThinking =
collapsed && hasProcess && stream && lastSeg?.kind === 'thinking' ? lastThinkingLine(lastSeg.text) : null
// 复制操作行一旦出现就保持挂载:回合内多轮流转会让 answerText 在「流式段」与
// 「已完成消息」两个来源间短暂换源(瞬间为空),不锁存会闪现
const [hasAnswer, setHasAnswer] = useState(answerText.trim().length > 0)
if (answerText.trim() && !hasAnswer) setHasAnswer(true)
return (
<div className="min-w-0 max-w-full">
{hasProcess && (
@@ -249,7 +338,10 @@ export function AssistantTurn({
<button
type="button"
aria-expanded={!collapsed}
onClick={() => setCollapsed((v) => !v)}
onClick={() => {
autoCollapsedRef.current = false
setCollapsed((v) => !v)
}}
className="-mx-1.5 flex h-7 w-fit items-center gap-1.5 rounded-control px-1.5 text-[12.5px] text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
>
<svg
@@ -306,6 +398,105 @@ export function AssistantTurn({
)}
{/* 回合进行中但没有流式占位:工具执行或自动压缩的静默间隙,给一个存活指示 */}
{turnActive && !stream && <Loading label="处理中" variant="Dots" />}
{/* 结论底部的复制操作:常驻不闪现 */}
{(hasAnswer || answerText.trim().length > 0) && (
<div className="mt-1.5 flex">
<CopyAnswerButton text={answerText} />
</div>
)}
</div>
)
}
/** 回溯菜单:从这条用户消息切回会话树上的旧节点,可选为被离开的分支生成分支摘要 */
function RewindMenu({ entryId, onRewind }: { entryId: string; onRewind?: (entryId: string, summarize: boolean) => void }): ReactNode {
const [open, setOpen] = useState(false)
if (!onRewind) return null
return (
<div className="relative">
<button
type="button"
aria-label="回溯到此处"
title="回溯到这条消息(会话树上此节点之后的分叉将被离开)"
onClick={() => setOpen((v) => !v)}
className="flex size-6 items-center justify-center rounded-[6px] text-ink-3 transition-colors hover:bg-hover-2 hover:text-ink-2"
>
<LuHistory size={13} />
</button>
{open && (
<>
<div className="fixed inset-0 z-20" onClick={() => setOpen(false)} />
<div className="absolute bottom-full right-0 z-30 mb-1 w-60 overflow-hidden rounded-card border border-line bg-surface p-1 shadow-overlay">
<button
type="button"
onClick={() => {
setOpen(false)
onRewind(entryId, true)
}}
className="flex w-full items-start gap-2.5 rounded-control px-2.5 py-2 text-left transition-colors hover:bg-hover"
>
<span className="mt-0.5 flex size-6 shrink-0 items-center justify-center text-ink-2">
<LuFoldVertical size={13} />
</span>
<span className="min-w-0 flex-1">
<span className="block text-[12.5px] font-medium text-ink">生成摘要并回溯</span>
<span className="block text-[11.5px] leading-4 text-ink-3">被离开的分支会总结为分支摘要,上下文不丢失</span>
</span>
</button>
<button
type="button"
onClick={() => {
setOpen(false)
onRewind(entryId, false)
}}
className="flex w-full items-start gap-2.5 rounded-control px-2.5 py-2 text-left transition-colors hover:bg-hover"
>
<span className="mt-0.5 flex size-6 shrink-0 items-center justify-center text-ink-2">
<LuUndo2 size={13} />
</span>
<span className="min-w-0 flex-1">
<span className="block text-[12.5px] font-medium text-ink">直接回溯</span>
<span className="block text-[11.5px] leading-4 text-ink-3">不生成分支摘要</span>
</span>
</button>
</div>
</>
)}
</div>
)
}
/** 压缩 / 分支摘要通知行(pi 会话时间线中的 compactionSummary / branchSummary) */
function SystemNotice({ message }: { message: UiMessage }): ReactNode | null {
const ref = useEntrance<HTMLDivElement>()
const raw = (message.raw ?? {}) as { summary?: unknown; tokensBefore?: unknown }
const summary = typeof raw.summary === 'string' ? raw.summary : ''
const [expanded, setExpanded] = useState(false)
const isCompaction = message.role === 'compactionSummary'
const tokensBefore = typeof raw.tokensBefore === 'number' ? raw.tokensBefore : undefined
return (
<div ref={ref} className="flex justify-center">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
title={summary || undefined}
className={`flex min-w-0 max-w-[85%] flex-col items-center gap-1 rounded-[10px] border border-line px-4 py-2 text-center transition-colors hover:bg-hover ${
expanded ? 'bg-surface' : ''
}`}
>
<span className="flex items-center gap-1.5 text-[12px] text-ink-3">
{isCompaction ? <LuFoldVertical size={12} /> : <LuHistory size={12} />}
{isCompaction ? '上下文已压缩' : '分支摘要'}
{isCompaction && tokensBefore ? ` · 此前约 ${formatTokens(tokensBefore)} tokens 已归档为摘要` : ''}
{summary && !expanded && <span className="text-ink-3/70">(点击展开)</span>}
</span>
{expanded && summary && (
<span className="max-h-48 w-full overflow-y-auto whitespace-pre-wrap text-left text-[12px] leading-5 text-ink-2">
{summary}
</span>
)}
</button>
</div>
)
}
@@ -315,21 +506,40 @@ export function MessageItem({
tools,
streaming,
turnActive,
pendingApprovals = []
pendingApprovals = [],
scrollPinnedRef,
forkEntryId,
rewinding,
onRewind
}: {
message: UiMessage
tools: Record<string, ToolState>
streaming: boolean
turnActive: boolean
pendingApprovals?: ApprovalRequest[]
scrollPinnedRef?: { current: boolean }
/** 对应会话树上的回溯点(用户消息按序对齐 forkPoints) */
forkEntryId?: string
/** 分支回溯进行中(禁用回溯入口) */
rewinding?: boolean
onRewind?: (entryId: string, summarize: boolean) => void
}): ReactNode | null {
const ref = useEntrance<HTMLDivElement>()
if (message.role === 'compactionSummary' || message.role === 'branchSummary') {
return <SystemNotice message={message} />
}
if (message.role === 'user') {
const blocks = readBlocks(message.raw)
const text = blocks.map((b) => b.text ?? '').join('') || ''
return (
<div ref={ref} className="flex justify-end">
<div ref={ref} className="group/msg flex items-center justify-end gap-1.5">
{forkEntryId && !rewinding && (
<div className="opacity-0 transition-opacity duration-100 group-hover/msg:opacity-100">
<RewindMenu entryId={forkEntryId} onRewind={onRewind} />
</div>
)}
<div className="max-w-[85%] rounded-[10px] bg-inset px-4 py-3 text-base whitespace-pre-wrap leading-normal text-ink">
{text}
</div>
@@ -346,6 +556,7 @@ export function MessageItem({
streaming={streaming}
turnActive={turnActive}
pendingApprovals={pendingApprovals}
scrollPinnedRef={scrollPinnedRef}
/>
</div>
)
@@ -0,0 +1,177 @@
import { createPortal } from 'react-dom'
import type { JSX } from 'react'
import { LuLoaderCircle, LuX } from 'react-icons/lu'
import type { ContextBreakdownEntry, SessionStatsInfo } from '@shared/types'
import { formatPercent, formatTokens } from '../lib/utils'
/**
* Usage Card(逐项复刻 ZCode 上下文容量卡):
* - 上下文容量:估算 token / 窗口(百分比)+ 单色进度条
* - 类目清单:消息 / 自定义工具 / 系统工具 / 系统提示词 / 技能 / 其他,右对齐占比
* - 平均缓存命中率:单行 label + 百分比
* - 底部:自动压缩开关在左,立即压缩在右
*/
/** ZCode 卡片配色:条与圆点为蓝色系 */
const BLUE = '#4e7ef3'
const BAR_TRACK = 'color-mix(in srgb, #4e7ef3 14%, transparent)'
/** 每个类目占比 = 该类 token / 上下文中估算总量(各行相加 ≈ 100%,与 ZCode 一致) */
function shareOf(entry: ContextBreakdownEntry, total: number): number {
return total > 0 ? (entry.tokens / total) * 100 : 0
}
export function UsageCard({
anchor,
stats,
compacting,
autoCompactionEnabled,
onClose,
onCompact,
onAbortCompaction,
onToggleAutoCompaction
}: {
/** Composer 工具行锚点(bottom/left/right,向上弹出) */
anchor: { top: number; left: number; right: number }
stats: SessionStatsInfo | null
compacting: boolean
autoCompactionEnabled: boolean
onClose: () => void
onCompact: () => void
onAbortCompaction: () => void
onToggleAutoCompaction: (enabled: boolean) => void
}): JSX.Element {
const context = stats?.context
const total = context?.tokens ?? null
const window_ = context?.contextWindow ?? 0
const percent = context?.percent ?? null
const breakdown = context?.breakdown ?? []
const estimatedTotal = breakdown.reduce((sum, b) => sum + b.tokens, 0)
const cacheRate = stats?.cacheHitRate
const cacheTokens = stats?.cacheTokens
return createPortal(
<>
<div className="fixed inset-0 z-30" onClick={onClose} />
<div
className="fixed z-40 w-[400px] overflow-hidden rounded-card border border-line bg-surface shadow-overlay"
style={{
bottom: window.innerHeight - anchor.top + 8,
left: Math.max(8, anchor.left)
}}
>
{/* 上下文容量 + 单色进度条 */}
<div className="px-5 pt-4">
<div className="flex items-baseline justify-between">
<span className="text-[13px] font-medium text-ink">上下文容量</span>
<span className="text-[12.5px] tabular-nums text-ink-2">
{total !== null ? formatTokens(total) : '—'}
{window_ > 0 ? ` / ${formatTokens(window_)}` : ''}
{percent !== null ? `(${formatPercent(percent)})` : ''}
</span>
</div>
<div className="mt-2.5 h-[5px] w-full overflow-hidden rounded-full" style={{ background: BAR_TRACK }}>
{percent !== null && percent > 0 && (
<div
className="h-full rounded-full transition-[width] duration-300"
style={{ width: `${Math.min(100, percent)}%`, background: BLUE }}
/>
)}
</div>
</div>
{/* 类目清单:圆点 + 标签 + 右对齐占比(悬浮显示估算 token) */}
<div className="flex flex-col gap-[7px] px-5 pb-4 pt-3.5">
{estimatedTotal > 0 ? (
breakdown.map((b) => (
<div
key={b.key}
title={`${b.label} ≈ ${formatTokens(b.tokens)} tokens`}
className="flex min-w-0 items-center gap-2.5 text-[12.5px]"
>
<span className="size-[7px] shrink-0 rounded-full" style={{ background: BLUE }} />
<span className="shrink-0 text-ink-2">{b.label}</span>
<span className="min-w-0 flex-1 text-right tabular-nums text-ink-2">
{formatPercent(shareOf(b, estimatedTotal))}
</span>
</div>
))
) : (
<div className="py-0.5 text-[12.5px] text-ink-3">发送第一条消息后显示用量分布</div>
)}
</div>
{/* 平均缓存命中率 */}
<div className="border-t border-line px-5 py-3">
<div
className="flex items-baseline justify-between"
title={
cacheTokens
? `命中 ${formatTokens(cacheTokens.read)} · 写入 ${formatTokens(cacheTokens.write)} · 未命中 ${formatTokens(cacheTokens.input)}`
: undefined
}
>
<span className="text-[12.5px] text-ink-2">平均缓存命中率</span>
<span className="text-[12.5px] font-medium tabular-nums text-ink">
{cacheRate !== undefined ? formatPercent(cacheRate) : '—'}
</span>
</div>
</div>
{/* 底部:自动压缩开关(左) · 立即压缩(右) */}
<div className="flex items-center justify-between border-t border-line px-5 py-3">
<button
type="button"
role="switch"
aria-checked={autoCompactionEnabled}
onClick={() => onToggleAutoCompaction(!autoCompactionEnabled)}
title="上下文接近窗口上限时自动压缩历史(阈值/溢出触发)"
className="flex items-center gap-2 text-[12px] text-ink-2 transition-colors hover:text-ink"
>
<span
className={`relative h-4 w-7 rounded-full transition-colors duration-150 ${
autoCompactionEnabled ? 'bg-accent-ink' : 'bg-line-strong'
}`}
>
<span
className={`absolute top-0.5 size-3 rounded-full bg-surface shadow-sm transition-all duration-150 ${
autoCompactionEnabled ? 'left-3.5' : 'left-0.5'
}`}
/>
</span>
自动压缩
</button>
{compacting ? (
<div className="flex items-center gap-2">
<span className="flex items-center gap-1 text-[12px] text-ink-2">
<LuLoaderCircle size={12} className="animate-spin" />
压缩中
</span>
<button
type="button"
onClick={onAbortCompaction}
className="flex h-7 items-center gap-1 rounded-[7px] border border-line bg-surface px-2.5 text-[12px] text-ink-2 transition-colors hover:bg-hover hover:text-ink"
>
<LuX size={12} />
停止
</button>
</div>
) : (
<button
type="button"
onClick={onCompact}
disabled={total === null || total === 0}
title="把较早的对话历史压缩为摘要,释放上下文空间(可附带自定义指令,见 /compact)"
className="flex h-7 items-center rounded-[7px] border border-line bg-surface px-2.5 text-[12px] text-ink-2 transition-colors hover:bg-hover hover:text-ink disabled:cursor-default disabled:opacity-40 disabled:hover:bg-surface"
>
立即压缩
</button>
)}
</div>
</div>
</>,
document.body
)
}
+158 -19
View File
@@ -1,5 +1,17 @@
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react'
import type { AgentUiEvent, ApprovalRequest, PermissionMode, SendImage, SessionMeta, SessionState, SessionStatsInfo, WorkspaceMeta } from '@shared/types'
import type {
AgentUiEvent,
ApprovalRequest,
ForkPoint,
NavigateTreeOptions,
PermissionMode,
SendImage,
SessionMeta,
SessionState,
SessionStatsInfo,
ThinkingLevel,
WorkspaceMeta
} from '@shared/types'
export type { ApprovalRequest }
@@ -18,6 +30,16 @@ export interface SettledApproval {
timedOut: boolean
}
/** 压缩通知(compaction_end 后展示于聊天顶部,自动消退) */
export interface CompactionNotice {
reason: 'manual' | 'threshold' | 'overflow'
aborted: boolean
summary?: string
tokensBefore?: number
errorMessage?: string
at: number
}
export interface UiMessage {
key: string
role: string
@@ -46,12 +68,17 @@ interface AgentUiState {
ready: boolean
approvals: ApprovalRequest[]
settledApprovals: SettledApproval[]
/** 正在压缩上下文(手动/阈值/溢出) */
compacting: boolean
/** 最近一次压缩的结果通知(null = 已展示/已过时) */
compactionNotice: CompactionNotice | null
}
type Action =
| { type: 'event'; event: AgentUiEvent }
| { type: 'optimistic-user'; key: string; text: string }
| { type: 'clear-error' }
| { type: 'clear-compaction-notice' }
const initialState: AgentUiState = {
messages: [],
@@ -66,7 +93,9 @@ const initialState: AgentUiState = {
retry: null,
ready: false,
approvals: [],
settledApprovals: []
settledApprovals: [],
compacting: false,
compactionNotice: null
}
let keyCounter = 0
@@ -82,13 +111,14 @@ function readRole(message: unknown): string {
return 'unknown'
}
/** 历史消息(会话切换/恢复时)映射为 UI 消息 */
/** 历史消息(会话切换/恢复时)映射为 UI 消息。
* compactionSummary / branchSummary 保留为通知行(压缩与分支摘要在时间线中可见)。 */
function historyToMessages(messages: unknown[]): UiMessage[] {
const result: UiMessage[] = []
for (const raw of messages) {
const role = readRole(raw)
if (role === 'toolResult') continue
if (role === 'user' || role === 'assistant') {
if (role === 'user' || role === 'assistant' || role === 'compactionSummary' || role === 'branchSummary') {
result.push({ key: nextKey('h'), role, raw })
}
}
@@ -136,6 +166,9 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
if (action.type === 'clear-error') {
return { ...state, error: null }
}
if (action.type === 'clear-compaction-notice') {
return { ...state, compactionNotice: null }
}
const event = action.event
switch (event.type) {
@@ -155,6 +188,8 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
turnActive: false,
queue: { steering: [], followUp: [] },
stats: null,
compacting: false,
compactionNotice: null,
sessionState: event.state,
messages: historyToMessages(event.messages),
tools: historyToTools(event.messages)
@@ -198,12 +233,18 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
case 'message_end': {
const role = readRole(event.message)
const messages = [...state.messages]
// 回填最近的流式占位(assistant)
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].stream) {
messages[i] = { key: messages[i].key, role, raw: event.message }
return { ...state, messages }
// 回填最近的流式占位——仅限 assistant:占位只为 assistant 的 message_start
// 创建,若不校验角色,迟到/交错的 user、toolResult message_end 会把占位
// 误替换掉(内容丢失、回合分组被非 assistant 消息切断,过程区误判收起)
if (role === 'assistant') {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].stream) {
messages[i] = { key: messages[i].key, role, raw: event.message }
return { ...state, messages }
}
}
messages.push({ key: nextKey('a'), role: 'assistant', raw: event.message })
return { ...state, messages }
}
if (role === 'user') {
// 若最后一条是本地乐观插入的用户消息,用服务端版本回填
@@ -214,14 +255,7 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
}
return state
}
if (role === 'toolResult') {
// 工具结果已在 ToolCard 中通过 tool_end 展示,跳过独立消息
return state
}
if (role === 'assistant') {
messages.push({ key: nextKey('a'), role: 'assistant', raw: event.message })
return { ...state, messages }
}
// toolResult 等其他角色:已在 ToolCard 中通过 tool_end 展示,不独立成消息
return state
}
case 'tool_start': {
@@ -277,6 +311,32 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
}
case 'stats':
return { ...state, stats: event.stats }
case 'thinking_level_changed': {
if (!state.sessionState) return state
return {
...state,
sessionState: { ...state.sessionState, thinkingLevel: event.level }
}
}
case 'compaction_start':
return { ...state, compacting: true }
case 'compaction_end': {
// 压缩替换了上下文历史:就地换血消息列表(含 compactionSummary 通知行),
// 但不动 streaming/turnActive —— 自动压缩发生在回合中途,回合还会续跑
return {
...state,
compacting: false,
messages: historyToMessages(event.messages),
compactionNotice: {
reason: event.reason,
aborted: event.aborted,
summary: event.summary,
tokensBefore: event.tokensBefore,
errorMessage: event.errorMessage,
at: Date.now()
}
}
}
default:
return state
}
@@ -286,6 +346,10 @@ export interface UseAgent {
state: AgentUiState
sessions: SessionMeta[]
workspaces: WorkspaceMeta[]
/** 回溯点(当前分支上的用户消息,按序与时间线中的用户消息一一对应) */
forkPoints: ForkPoint[]
/** 分支回溯(含分支摘要生成)进行中 */
navigating: boolean
send: (text: string, images?: SendImage[]) => void
stop: () => void
clearError: () => void
@@ -299,12 +363,22 @@ export interface UseAgent {
setPermissionMode: (mode: PermissionMode) => void
createWorkspace: (name: string) => void
openWorkspace: (path: string) => void
setThinkingLevel: (level: ThinkingLevel) => void
compact: (instructions?: string) => void
abortCompaction: () => void
setAutoCompaction: (enabled: boolean) => void
dismissCompactionNotice: () => void
navigateTree: (entryId: string, options?: NavigateTreeOptions) => void
}
export function useAgent(): UseAgent {
const [state, dispatch] = useReducer(reducer, initialState)
const [sessions, setSessions] = useState<SessionMeta[]>([])
const [workspaces, setWorkspaces] = useState<WorkspaceMeta[]>([])
/** 回溯点(当前分支上的用户消息,按序),随会话内容刷新 */
const [forkPoints, setForkPoints] = useState<ForkPoint[]>([])
/** 分支回溯进行中(含分支摘要生成) */
const [navigating, setNavigating] = useState(false)
const refreshWorkspaces = useCallback((): void => {
void window.api
@@ -326,6 +400,13 @@ export function useAgent(): UseAgent {
.then(setSessions)
.catch(() => undefined)
}
// 回溯点与会话内容同步:切换/压缩/回溯后重取
if (event.type === 'session_switched' || event.type === 'compaction_end') {
void window.api
.listForkPoints()
.then(setForkPoints)
.catch(() => undefined)
}
})
return unsubscribe
}, [])
@@ -442,11 +523,55 @@ export function useAgent(): UseAgent {
[dispatchError, refreshSessions, refreshWorkspaces]
)
// ---- 思考等级 / 压缩 / 分支摘要(pi 原生能力) ----
const setThinkingLevel = useCallback(
(level: ThinkingLevel): void => {
window.api.setThinkingLevel(level).catch(dispatchError)
},
[dispatchError]
)
const compact = useCallback(
(instructions?: string): void => {
window.api.compactSession(instructions).catch(dispatchError)
},
[dispatchError]
)
const abortCompaction = useCallback((): void => {
window.api.abortCompaction().catch(() => undefined)
}, [])
const setAutoCompaction = useCallback((enabled: boolean): void => {
window.api.setAutoCompaction(enabled).catch(() => undefined)
}, [])
const dismissCompactionNotice = useCallback((): void => {
dispatch({ type: 'clear-compaction-notice' })
}, [])
/** 回溯到某个用户消息;summarize=true 时为被离开的分支生成分支摘要 */
const navigateTree = useCallback(
(entryId: string, options?: NavigateTreeOptions): void => {
setNavigating(true)
window.api
.navigateTree(entryId, options)
.then(() => window.api.listForkPoints())
.then(setForkPoints)
.catch(dispatchError)
.finally(() => setNavigating(false))
},
[dispatchError]
)
return useMemo(
() => ({
state,
sessions,
workspaces,
forkPoints,
navigating,
send,
stop,
clearError,
@@ -459,12 +584,20 @@ export function useAgent(): UseAgent {
resolveApproval,
setPermissionMode,
createWorkspace,
openWorkspace
openWorkspace,
setThinkingLevel,
compact,
abortCompaction,
setAutoCompaction,
dismissCompactionNotice,
navigateTree
}),
[
state,
sessions,
workspaces,
forkPoints,
navigating,
send,
stop,
clearError,
@@ -477,7 +610,13 @@ export function useAgent(): UseAgent {
resolveApproval,
setPermissionMode,
createWorkspace,
openWorkspace
openWorkspace,
setThinkingLevel,
compact,
abortCompaction,
setAutoCompaction,
dismissCompactionNotice,
navigateTree
]
)
}
+9
View File
@@ -8,14 +8,17 @@ import type {
BrowserUiState,
ChatResources,
CustomProviderConfig,
ForkPoint,
ModelCatalog,
ModelInfo,
NavigateTreeOptions,
PermissionMode,
ResourceDetails,
SendImage,
SessionMeta,
SessionState,
SessionStatsInfo,
ThinkingLevel,
WorkspaceFile,
WorkspaceMeta
} from '@shared/types'
@@ -70,6 +73,12 @@ declare global {
setModel: (providerId: string, modelId: string) => Promise<void>
setApiKey: (providerId: string, apiKey: string) => Promise<void>
logoutProvider: (providerId: string) => Promise<void>
setThinkingLevel: (level: ThinkingLevel) => Promise<void>
compactSession: (instructions?: string) => Promise<{ summary: string; tokensBefore: number }>
abortCompaction: () => Promise<void>
setAutoCompaction: (enabled: boolean) => Promise<void>
listForkPoints: () => Promise<ForkPoint[]>
navigateTree: (entryId: string, options?: NavigateTreeOptions) => Promise<void>
getSettings: () => Promise<AppSettings>
setSystemPrompt: (text: string) => Promise<void>
setThemeColor: (id: string) => Promise<void>
@@ -4,3 +4,22 @@ import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs))
}
/** token 数中文格式化:1.2万 / 3.3万(ZCode 风格),万以下用千分位 */
export function formatTokens(n: number): string {
if (!Number.isFinite(n)) return '0'
if (n >= 1e8) return `${stripTrailingZero(n / 1e8)}亿`
if (n >= 1e4) return `${stripTrailingZero(n / 1e4)}万`
return n.toLocaleString('en-US')
}
function stripTrailingZero(v: number): string {
return v.toFixed(1).replace(/\.0$/, '')
}
/** 百分比展示(ZCode 卡片风格):一位小数按需保留 —— 91.2%、4%、0.4%、28.4% */
export function formatPercent(p: number): string {
const clamped = Math.max(0, Math.min(100, p))
const rounded = Math.round(clamped * 10) / 10
return `${Number.isInteger(rounded) ? rounded : rounded.toFixed(1)}%`
}
+69
View File
@@ -19,6 +19,16 @@ export interface SessionState {
workspace?: string
/** 权限模式(Composer 权限 pill / 审批行为) */
permissionMode?: PermissionMode
/** 当前思考等级(pi ThinkingLevel) */
thinkingLevel?: ThinkingLevel
/** 当前模型可用的思考等级(供切换器渲染) */
thinkingLevels?: ThinkingLevel[]
/** 当前模型是否支持思考 */
supportsThinking?: boolean
/** 正在压缩上下文 / 生成分支摘要 */
isCompacting?: boolean
/** 自动压缩(阈值/溢出触发)是否开启 */
autoCompactionEnabled?: boolean
}
/** 工作区 = 一个本地目录(agent 的工作目录,会话按目录隔离) */
@@ -27,11 +37,56 @@ export interface WorkspaceMeta {
path: string
}
/** 思考等级(pi:off 关闭推理,max 最高) */
export type ThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'
/** 上下文按类目的用量拆分(估算值,chars/4 启发式,与 pi 一致)。
* 类目对齐 ZCode 卡片:消息 / 自定义工具 / 系统工具 / 系统提示词 / 技能 / 其他 */
export interface ContextBreakdownEntry {
key: 'messages' | 'customTools' | 'builtinTools' | 'systemPrompt' | 'skills' | 'other'
label: string
tokens: number
}
/** 上下文容量(Usage Card 数据源,来自 session.getContextUsage + 消息拆分估算) */
export interface ContextUsageInfo {
/** 上下文 token 估算;null = 暂不可知(如刚压缩完、下一次响应前) */
tokens: number | null
contextWindow: number
/** 占上下文窗口百分比 0-100;tokens 未知时为 null */
percent: number | null
/** 上一轮 LLM 响应的输出 token(本次响应预留量),取不到时省略 */
lastOutputTokens?: number
breakdown: ContextBreakdownEntry[]
}
export interface SessionStatsInfo {
tokens: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number }
cost: number
/** 上下文占用百分比 0-100,取不到时省略 */
contextPercent?: number
/** 上下文容量详情(Usage Card) */
context?: ContextUsageInfo
/** 缓存命中率 0-100:cacheRead / (input + cacheRead + cacheWrite),无数据时省略 */
cacheHitRate?: number
/** 会话累计的缓存相关 token(命中率明细展示) */
cacheTokens?: { read: number; write: number; input: number }
/** 工具调用统计(成功率按 toolResult 是否出错计) */
toolStats?: { calls: number; successRate: number }
}
/** 会话树上的可回溯点(用户消息,与当前分支上的用户消息按顺序一一对应) */
export interface ForkPoint {
entryId: string
text: string
}
/** 分支回溯选项 */
export interface NavigateTreeOptions {
/** true = 为被离开的分支生成分支摘要(pi branch summarization) */
summarize?: boolean
/** 摘要的自定义指令(summarize 时生效) */
customInstructions?: string
}
/** 会话列表项(SessionManager.list 的映射) */
@@ -271,3 +326,17 @@ export type AgentUiEvent =
| { type: 'error'; message: string }
| { type: 'session_info'; name?: string }
| { type: 'stats'; stats: SessionStatsInfo }
| { type: 'thinking_level_changed'; level: ThinkingLevel }
| { type: 'compaction_start'; reason: 'manual' | 'threshold' | 'overflow' }
| {
type: 'compaction_end'
reason: 'manual' | 'threshold' | 'overflow'
aborted: boolean
/** 压缩摘要文本(aborted/失败时省略) */
summary?: string
/** 压缩前的上下文 token(用于展示释放量) */
tokensBefore?: number
errorMessage?: string
/** 压缩后的最新消息列表(含 compactionSummary 通知行,替代整次 session_switched) */
messages: unknown[]
}