feat: 接入 pi 原生能力——Usage Card、思考等级切换、压缩与分支摘要
This commit is contained in:
@@ -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())
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,12 +167,20 @@ 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">
|
||||
<div className="absolute inset-x-4 top-3 z-10 flex flex-col gap-2">
|
||||
{(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">
|
||||
<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 ? (
|
||||
@@ -155,7 +203,7 @@ export function ChatView({
|
||||
)}
|
||||
|
||||
{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">
|
||||
<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}
|
||||
@@ -164,10 +212,81 @@ export function ChatView({
|
||||
</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,9 +834,41 @@ 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"
|
||||
/>
|
||||
|
||||
{/* 工具行:左「+」,右 模型 / 权限 / 发送 */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* +:添加附件 */}
|
||||
<button
|
||||
type="button"
|
||||
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="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-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"
|
||||
>
|
||||
<LuGauge size={13} className={compacting ? 'animate-pulse' : ''} />
|
||||
{stats?.context?.percent != null ? formatPercent(stats.context.percent) : '上下文'}
|
||||
</button>
|
||||
|
||||
{/* 模型选择 */}
|
||||
<button
|
||||
ref={modelBtnRef}
|
||||
@@ -770,7 +877,7 @@ export function Composer({
|
||||
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"
|
||||
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">
|
||||
@@ -780,6 +887,24 @@ export function Composer({
|
||||
</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}
|
||||
@@ -789,7 +914,7 @@ export function Composer({
|
||||
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 ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -802,7 +927,7 @@ export function Composer({
|
||||
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]"
|
||||
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" />
|
||||
@@ -814,7 +939,7 @@ export function Composer({
|
||||
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"
|
||||
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">
|
||||
@@ -824,6 +949,7 @@ export function Composer({
|
||||
)}
|
||||
</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
|
||||
)
|
||||
}
|
||||
@@ -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,13 +233,19 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
|
||||
case 'message_end': {
|
||||
const role = readRole(event.message)
|
||||
const messages = [...state.messages]
|
||||
// 回填最近的流式占位(assistant)
|
||||
// 回填最近的流式占位——仅限 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') {
|
||||
// 若最后一条是本地乐观插入的用户消息,用服务端版本回填
|
||||
const last = messages[messages.length - 1]
|
||||
@@ -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
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)}%`
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user