From e7da0f912ecc6920d40e964ed749ac92d374c6ab Mon Sep 17 00:00:00 2001 From: Rick Ye Date: Tue, 8 Sep 2026 17:21:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=20pi=20=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=E8=83=BD=E5=8A=9B=E2=80=94=E2=80=94Usage=20Card?= =?UTF-8?q?=E3=80=81=E6=80=9D=E8=80=83=E7=AD=89=E7=BA=A7=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E3=80=81=E5=8E=8B=E7=BC=A9=E4=B8=8E=E5=88=86=E6=94=AF=E6=91=98?= =?UTF-8?q?=E8=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/src/main/ipc.ts | 37 ++ apps/desktop/src/main/pi/agent-service.ts | 230 +++++++++++- apps/desktop/src/preload/index.ts | 11 + apps/desktop/src/renderer/src/App.tsx | 26 +- .../src/renderer/src/components/ChatView.tsx | 189 ++++++++-- .../src/renderer/src/components/Composer.tsx | 332 ++++++++++++++---- .../renderer/src/components/MessageItem.tsx | 261 ++++++++++++-- .../src/renderer/src/components/UsageCard.tsx | 177 ++++++++++ .../src/renderer/src/hooks/useAgent.ts | 177 +++++++++- apps/desktop/src/renderer/src/lib/api.ts | 9 + apps/desktop/src/renderer/src/lib/utils.ts | 19 + apps/desktop/src/shared/types.ts | 69 ++++ 12 files changed, 1378 insertions(+), 159 deletions(-) create mode 100644 apps/desktop/src/renderer/src/components/UsageCard.tsx diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 5cebabc..06aa8bd 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -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()) diff --git a/apps/desktop/src/main/pi/agent-service.ts b/apps/desktop/src/main/pi/agent-service.ts index 6665c5a..b6714b0 100644 --- a/apps/desktop/src/main/pi/agent-service.ts +++ b/apps/desktop/src/main/pi/agent-service.ts @@ -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([...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[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[0]) + break + } + // bash 执行、扩展注入、压缩/分支摘要等注入上下文的消息 + case 'bashExecution': + case 'custom': + case 'compactionSummary': + case 'branchSummary': + byKey.get('other')!.tokens += estimateTokens(message as Parameters[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 { 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 { 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 { + 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 { + this.runtime?.session.abortCompaction() + } + + /** 开关自动压缩(阈值/溢出触发;pi settings 持久化) */ + async setAutoCompactionEnabled(enabled: boolean): Promise { + const runtime = await this.getRuntime() + runtime.session.setAutoCompactionEnabled(enabled) + this.emit({ type: 'state', state: this.buildState(runtime.session) }) + } + + /** 会话树上的可回溯点(当前分支上的用户消息,按序) */ + async listForkPoints(): Promise { + 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 { + 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 { diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index dcc1ad1..9b2a973 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -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 => ipcRenderer.invoke('auth:setKey', providerId, apiKey), logoutProvider: (providerId: string): Promise => ipcRenderer.invoke('auth:logout', providerId), + setThinkingLevel: (level: ThinkingLevel): Promise => ipcRenderer.invoke('thinking:set', level), + compactSession: (instructions?: string): Promise<{ summary: string; tokensBefore: number }> => + ipcRenderer.invoke('session:compact', instructions), + abortCompaction: (): Promise => ipcRenderer.invoke('session:compact:abort'), + setAutoCompaction: (enabled: boolean): Promise => ipcRenderer.invoke('session:autoCompact', enabled), + listForkPoints: (): Promise => ipcRenderer.invoke('session:forkPoints'), + navigateTree: (entryId: string, options?: NavigateTreeOptions): Promise => + ipcRenderer.invoke('session:navigate', entryId, options), getSettings: (): Promise => ipcRenderer.invoke('settings:get'), setSystemPrompt: (text: string): Promise => ipcRenderer.invoke('settings:setPrompt', text), setThemeColor: (id: string): Promise => ipcRenderer.invoke('settings:setTheme', id), diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 0a88661..5760c89 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -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(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} /> } diff --git a/apps/desktop/src/renderer/src/components/ChatView.tsx b/apps/desktop/src/renderer/src/components/ChatView.tsx index 5712caa..c702be0 100644 --- a/apps/desktop/src/renderer/src/components/ChatView.tsx +++ b/apps/desktop/src/renderer/src/components/ChatView.tsx @@ -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, 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( ) i += 1 @@ -80,6 +97,12 @@ function renderTurns( return items } +const COMPACTION_REASON_LABELS: Record = { + 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 (
- {(error || sessionError) && ( -
- -
- {sessionError ? ( - <> -
Agent 会话初始化失败
-
{sessionError}
- - ) : ( -
{error}
- )} +
+ {(error || sessionError) && ( +
+ +
+ {sessionError ? ( + <> +
Agent 会话初始化失败
+
{sessionError}
+ + ) : ( +
{error}
+ )} +
+
- -
- )} + )} - {retry && ( -
- - - 请求失败,自动重试 {retry.attempt}/{retry.maxAttempts} - - {retry.errorMessage} -
- )} + {retry && ( +
+ + + 请求失败,自动重试 {retry.attempt}/{retry.maxAttempts} + + {retry.errorMessage} +
+ )} + + {compacting && ( +
+ + 正在压缩上下文,较早的对话将归档为摘要… + +
+ )} + + {navigating && ( +
+ + 正在回溯并生成分支摘要… +
+ )} + + {compactionNotice && !compacting && ( +
+ {compactionNotice.aborted || compactionNotice.errorMessage ? ( + + ) : ( + + )} +
+ {compactionNotice.aborted ? ( + 已停止压缩 + ) : compactionNotice.errorMessage ? ( + 压缩失败:{compactionNotice.errorMessage} + ) : ( + <> + + 上下文已压缩({COMPACTION_REASON_LABELS[compactionNotice.reason]} + {compactionNotice.tokensBefore + ? `,约 ${formatTokens(compactionNotice.tokensBefore)} tokens 归档为摘要` + : ''} + ) + + {compactionNotice.summary && ( + + {compactionNotice.summary} + + )} + + )} +
+ +
+ )} +
{hasContent ? (
- {renderTurns(messages, tools, streaming, turnActive, pendingApprovals)} + {renderTurns( + messages, + tools, + streaming, + turnActive, + pendingApprovals, + pinnedRef, + forkPoints, + navigating, + onRewind + )}
) : (
diff --git a/apps/desktop/src/renderer/src/components/Composer.tsx b/apps/desktop/src/renderer/src/components/Composer.tsx index df202d6..eea5b1a 100644 --- a/apps/desktop/src/renderer/src/components/Composer.tsx +++ b/apps/desktop/src/renderer/src/components/Composer.tsx @@ -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 = { + 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 }): 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(null) const [catalogLoading, setCatalogLoading] = useState(false) const [attachments, setAttachments] = useState([]) @@ -129,6 +180,7 @@ export function Composer({ const localCommands = useMemo( () => [ { 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(null) const permBtnRef = useRef(null) + const usageBtnRef = useRef(null) + const thinkingBtnRef = useRef(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({
)} -
- {/* +:添加附件 */} - - + {/* 输入区整行在上(仿 ZCode),工具行独立在下 */} +