From 6599e7ab1142d01d1db41266f0a172d1ef39b1dd Mon Sep 17 00:00:00 2001 From: GreyGunG Date: Mon, 20 Jul 2026 11:40:34 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(chat):=20=E5=8F=8C=E7=AB=AF=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=20Composer=20=E5=B7=A5=E5=85=B7=E6=A0=8F=E4=B8=8E?= =?UTF-8?q?=E4=B8=8A=E4=B8=8B=E6=96=87=E8=AE=A1=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为桌面 GUI 与 Gateway WebUI 统一增强 ChatComposerBar: 上下文占用、手动压缩(桌面)、Markdown 导出、剪贴板/截图附件、 会话费用与更多菜单;并补齐 usage 锚点、离线/未完整历史导出保护与单测。 Closes #157 --- .../agent-gateway/web/src/app/GatewayApp.tsx | 317 +++++++- .../web/src/app/hooks/usePendingUploads.ts | 12 +- .../web/src/components/icons.tsx | 4 + crates/agent-gateway/web/src/i18n/config.ts | 74 ++ .../web/src/lib/chat/clipboardCapture.ts | 257 +++++++ .../web/src/lib/chat/contextUsage.ts | 212 ++++++ .../web/src/lib/chat/conversationExport.ts | 459 ++++++++++++ .../web/src/pages/chat/ChatComposerBar.tsx | 695 ++++++++++++++---- crates/agent-gui/src/components/icons.tsx | 2 + crates/agent-gui/src/i18n/config.ts | 74 ++ .../src/lib/chat/clipboardCapture.ts | 257 +++++++ .../src/lib/chat/compaction/controller.ts | 166 ++++- .../src/lib/chat/compaction/policy.ts | 13 + .../src/lib/chat/compaction/types.ts | 13 +- crates/agent-gui/src/lib/chat/contextUsage.ts | 212 ++++++ .../src/lib/chat/conversationExport.ts | 466 ++++++++++++ crates/agent-gui/src/pages/ChatPage.tsx | 506 ++++++++++++- .../pages/chat/components/ChatComposerBar.tsx | 458 +++++++++++- .../src/pages/chat/hooks/usePendingUploads.ts | 29 +- .../test/chat/clipboard-capture.test.mjs | 117 +++ .../test/chat/compaction-controller.test.mjs | 339 +++++++++ .../test/chat/compaction-policy.test.mjs | 7 + .../test/chat/context-usage.test.mjs | 91 +++ .../test/chat/conversation-export.test.mjs | 260 +++++++ 24 files changed, 4835 insertions(+), 205 deletions(-) create mode 100644 crates/agent-gateway/web/src/lib/chat/clipboardCapture.ts create mode 100644 crates/agent-gateway/web/src/lib/chat/contextUsage.ts create mode 100644 crates/agent-gateway/web/src/lib/chat/conversationExport.ts create mode 100644 crates/agent-gui/src/lib/chat/clipboardCapture.ts create mode 100644 crates/agent-gui/src/lib/chat/contextUsage.ts create mode 100644 crates/agent-gui/src/lib/chat/conversationExport.ts create mode 100644 crates/agent-gui/test/chat/clipboard-capture.test.mjs create mode 100644 crates/agent-gui/test/chat/context-usage.test.mjs create mode 100644 crates/agent-gui/test/chat/conversation-export.test.mjs diff --git a/crates/agent-gateway/web/src/app/GatewayApp.tsx b/crates/agent-gateway/web/src/app/GatewayApp.tsx index 85f7231c5..0a2d10dd9 100644 --- a/crates/agent-gateway/web/src/app/GatewayApp.tsx +++ b/crates/agent-gateway/web/src/app/GatewayApp.tsx @@ -93,9 +93,28 @@ import { updateRightDockWidth, updateSkills, updateSshProjectHostIds, + updateSystem, type WorkspaceProject, workspaceProjectPathKey, } from "@/lib/settings"; +import { + captureDisplayFrameToFile, + readClipboardImageFiles, +} from "@/lib/chat/clipboardCapture"; +import { + estimateContextUsage, + type ContextUsageSnapshot, +} from "@/lib/chat/contextUsage"; +import { + buildConversationExportFilename, + collectActiveContextFromTranscriptRows, + collectExportMessages, + collectMessagesFromTranscriptRows, + conversationToMarkdown, + downloadTextFile, + formatUsdCost, + sumConversationCost, +} from "@/lib/chat/conversationExport"; import { createUuid } from "@/lib/shared/id"; import { mergeAlwaysEnabledSkillNames } from "@/lib/skills"; import { terminalSessionBelongsToProject } from "@/lib/terminal/sessionStore"; @@ -3357,6 +3376,129 @@ export default function GatewayApp() { } return findProviderModelConfig(provider, activeSelectedModel.model).contextWindow; }, [settings.customProviders, activeSelectedModel]); + + const handleExecutionModeChange = useCallback( + (mode: "text" | "tools") => { + if (mode === "text") { + // Text runtime has no file tools — drop pending attachments so they + // are not sent as unfulfillable "Use Read …" prompts. + const conversationId = getDisplayedConversationId().trim(); + if (conversationId) { + updatePendingUploadsForConversation(conversationId, () => []); + } + } + setSettings((prev) => { + if (mode === "text") { + if (prev.system.executionMode === "text") return prev; + return updateSystem(prev, { executionMode: "text" }); + } + if (prev.system.executionMode === "tools" || prev.system.executionMode === "agent-dev") { + return prev; + } + return updateSystem(prev, { executionMode: "tools" }); + }); + }, + [getDisplayedConversationId, setSettings, updatePendingUploadsForConversation], + ); + + const composerPromptTemplates = useMemo( + () => + (settings.agents ?? []) + .filter((template) => template.prompt?.trim()) + .map((template) => ({ + id: template.id, + name: template.name?.trim() || template.id, + prompt: template.prompt, + })), + [settings.agents], + ); + + const handlePasteClipboardImages = useCallback(async () => { + const workdir = displayedConversationWorkdirRef.current.trim(); + if (!isAgentMode || !workdir) { + setChatError(translate("chat.upload.onlyInTools", settings.locale)); + return; + } + const lock = { + conversationId: getDisplayedConversationId().trim(), + workdir, + }; + if (!lock.conversationId) return; + const result = await readClipboardImageFiles(); + if (!result.ok) { + const key = + result.reason === "empty" + ? "chat.toolbar.clipboardEmpty" + : result.reason === "denied" + ? "chat.toolbar.clipboardDenied" + : "chat.toolbar.clipboardFailed"; + setChatError(translate(key, settings.locale)); + return; + } + await handleImportReadableFiles(result.files, lock); + }, [getDisplayedConversationId, handleImportReadableFiles, isAgentMode, settings.locale]); + + const handleCaptureScreenshot = useCallback(async () => { + const workdir = displayedConversationWorkdirRef.current.trim(); + if (!isAgentMode || !workdir) { + setChatError(translate("chat.upload.onlyInTools", settings.locale)); + return; + } + const lock = { + conversationId: getDisplayedConversationId().trim(), + workdir, + }; + if (!lock.conversationId) return; + const result = await captureDisplayFrameToFile(); + if (!result.ok) { + const key = + result.reason === "cancelled" + ? "chat.toolbar.screenshotCancelled" + : result.reason === "unsupported" + ? "chat.toolbar.screenshotUnsupported" + : "chat.toolbar.screenshotFailed"; + setChatError(translate(key, settings.locale)); + return; + } + await handleImportReadableFiles([result.file], lock); + }, [getDisplayedConversationId, handleImportReadableFiles, isAgentMode, settings.locale]); + + const handleClearComposerDraft = useCallback(() => { + const conversationId = getDisplayedConversationId().trim(); + composerRef.current?.clear(); + if (conversationId) { + updatePendingUploadsForConversation(conversationId, () => []); + // Drop cache so conversation switch does not restore the cleared draft. + clearCachedComposerDraft(conversationId); + } + composerRef.current?.focus(); + }, [getDisplayedConversationId, updatePendingUploadsForConversation]); + + const handleInsertPromptTemplate = useCallback( + (templateId: string) => { + const template = (settings.agents ?? []).find((item) => item.id === templateId); + const prompt = template?.prompt?.trim(); + if (!prompt) return; + const composer = composerRef.current; + if (!composer) return; + // Preserve structured segments (large-paste chips, skill/commit/file mentions). + const draft = composer.getDraft(); + const needsGap = !draft.isEmpty && draft.text.trim().length > 0; + const appended = needsGap ? `\n\n${prompt}` : prompt; + composer.setDraft({ + ...draft, + segments: [...draft.segments, { type: "text", text: appended }], + text: needsGap ? `${draft.text.trimEnd()}${appended}` : prompt, + textWithoutLargePastes: needsGap + ? `${draft.textWithoutLargePastes.trimEnd()}${appended}` + : prompt, + isEmpty: false, + }); + composer.focus(); + }, + [settings.agents], + ); + const currentChatProvider = useMemo(() => { if (!activeSelectedModel) { return undefined; @@ -3803,14 +3945,97 @@ export default function GatewayApp() { const transcriptRows = displayedTranscript.rows; const transcriptLiveStartIndex = displayedTranscript.liveStartIndex; const transcriptFloors = useMemo(() => buildFloorEntries(transcriptRows), [transcriptRows]); + const transcriptRevision = displayedTranscript.revision; + const selectedHistoryHasMore = + selectedHistory?.conversation_id === displayedConversationId && + selectedHistory.has_more === true; + // Context / cost meters must not rescan the full transcript on every stream + // commit. Recompute immediately when idle; poll while a run is active. + const [composerContextUsage, setComposerContextUsage] = useState( + null, + ); + const [composerSessionCostLabel, setComposerSessionCostLabel] = useState(null); + // While streaming, ignore per-token revision churn so the effect does not thrash. + const composerMeterSourceKey = displayedConversationBusy + ? `busy:${displayedConversationId}:${selectedHistoryHasMore}:${currentModelContextWindow ?? 0}` + : `idle:${displayedConversationId}:${transcriptRevision}:${selectedHistoryHasMore}:${currentModelContextWindow ?? 0}`; + useEffect(() => { + let cancelled = false; + let intervalId: number | null = null; + + const recomputeMeters = () => { + if (cancelled) return; + const rows = + transcriptStoreRegistry.peek(displayedConversationId)?.getSnapshot().rows ?? + transcriptRows; + + // Cost (export flattening is fine — export itself is on-demand). + const costMessages = collectMessagesFromTranscriptRows(rows); + const costSummary = sumConversationCost(costMessages); + if (costSummary.hasCost) { + const amount = formatUsdCost(costSummary.total, settings.locale); + setComposerSessionCostLabel( + selectedHistoryHasMore + ? translate("chat.toolbar.sessionCostPartialValue", settings.locale).replace( + "{amount}", + amount, + ) + : amount, + ); + } else { + setComposerSessionCostLabel(null); + } + + // Active-context window after latest checkpoint (includes tool/thinking). + const active = collectActiveContextFromTranscriptRows(rows); + const snap = estimateContextUsage({ + messages: active.messages, + systemPrompt: active.summaryText, + contextWindow: currentModelContextWindow ?? 0, + }); + // Truncated history window: never present ratio as if it were the full session. + if (selectedHistoryHasMore) { + setComposerContextUsage({ + ...snap, + ratio: null, + level: "unknown", + percentLabel: null, + }); + } else { + setComposerContextUsage(snap); + } + }; + + if (!displayedConversationId.trim()) { + setComposerContextUsage(null); + setComposerSessionCostLabel(null); + return () => { + cancelled = true; + }; + } + + recomputeMeters(); + if (displayedConversationBusy) { + intervalId = window.setInterval(recomputeMeters, 1000); + } + return () => { + cancelled = true; + if (intervalId != null) window.clearInterval(intervalId); + }; + }, [ + composerMeterSourceKey, + currentModelContextWindow, + displayedConversationBusy, + displayedConversationId, + selectedHistoryHasMore, + settings.locale, + transcriptStoreRegistry, + ]); // Row count gates everything visual (empty state, error banner, loading // screen): entryCount can be non-zero while nothing renders (meta-only // entries), and hiding an error behind an invisible entry would strand it. const displayedTranscriptRowCount = transcriptRows.length; const transcriptHistoryLoading = historyDetailLoading && displayedTranscriptRowCount === 0; - const selectedHistoryHasMore = - selectedHistory?.conversation_id === displayedConversationId && - selectedHistory.has_more === true; const loadingOlderHistory = fullHistoryLoading && displayedTranscriptRowCount > 0; const handleLoadFullHistory = useCallback(() => { if (!api || !displayedConversationId) { @@ -3825,6 +4050,78 @@ export default function GatewayApp() { setFullHistoryLoading(false); }); }, [api, displayedConversationId, refreshDisplayedConversationHistorySnapshot]); + const handleExportMarkdown = useCallback(async () => { + const exportConversationId = displayedConversationId.trim(); + if (!exportConversationId) return; + // Refuse while history is still painting or a reply is streaming. + if (historyDetailLoading || fullHistoryLoading || displayedConversationBusy) { + setChatError(translate("chat.toolbar.exportIncomplete", settings.locale)); + return; + } + try { + let rows = transcriptRows; + // Avoid silently exporting a truncated window when older turns are still on the server. + if (selectedHistoryHasMore) { + if (!api) { + // Offline with incomplete local window — refuse rather than export a partial file. + setChatError(translate("chat.toolbar.exportIncomplete", settings.locale)); + return; + } + setFullHistoryLoading(true); + try { + await refreshDisplayedConversationHistorySnapshot(exportConversationId, api, { + forceFull: true, + }); + } finally { + setFullHistoryLoading(false); + } + // User may have switched chats while the full-history fetch was in flight. + if (getDisplayedConversationId().trim() !== exportConversationId) { + setChatError(translate("chat.toolbar.exportIncomplete", settings.locale)); + return; + } + const after = selectedHistoryRef.current; + if (after?.conversation_id !== exportConversationId) { + setChatError(translate("chat.toolbar.exportIncomplete", settings.locale)); + return; + } + if (after.has_more === true) { + setChatError(translate("chat.toolbar.exportIncomplete", settings.locale)); + return; + } + rows = + transcriptStoreRegistry.peek(exportConversationId)?.getSnapshot().rows ?? + transcriptRows; + } else if (getDisplayedConversationId().trim() !== exportConversationId) { + setChatError(translate("chat.toolbar.exportIncomplete", settings.locale)); + return; + } + const collected = collectMessagesFromTranscriptRows(rows); + const title = + sidebarConversationsById.get(exportConversationId)?.title?.trim() || + translate("chat.pendingTitle", settings.locale); + const markdown = conversationToMarkdown({ + title, + messages: collectExportMessages(collected), + }); + downloadTextFile(buildConversationExportFilename(title), markdown); + } catch (error) { + setChatError(asErrorMessage(error, translate("chat.toolbar.exportFailed", settings.locale))); + } + }, [ + api, + displayedConversationBusy, + displayedConversationId, + fullHistoryLoading, + getDisplayedConversationId, + historyDetailLoading, + refreshDisplayedConversationHistorySnapshot, + selectedHistoryHasMore, + settings.locale, + sidebarConversationsById, + transcriptRows, + transcriptStoreRegistry, + ]); useEffect(() => { if (typeof document === "undefined") { return; @@ -4378,6 +4675,20 @@ export default function GatewayApp() { onMoveQueuedTurnUp={moveQueuedTurnUp} onEditQueuedTurn={editQueuedTurn} onRemoveQueuedTurn={removeQueuedTurn} + contextUsage={composerContextUsage} + onExecutionModeChange={handleExecutionModeChange} + conversationId={displayedConversationId} + compactDisabledReason={translate( + "chat.toolbar.compactDesktopOnly", + settings.locale, + )} + onPasteClipboardImages={handlePasteClipboardImages} + onCaptureScreenshot={handleCaptureScreenshot} + promptTemplates={composerPromptTemplates} + onInsertPromptTemplate={handleInsertPromptTemplate} + onClearDraft={handleClearComposerDraft} + sessionCostLabel={composerSessionCostLabel} + onExportMarkdown={handleExportMarkdown} /> {isFileDropActive ? ( { + async ( + filesToImport: File[], + lock?: { conversationId?: string; workdir?: string } | null, + ) => { if (filesToImport.length === 0) { return; } @@ -169,12 +172,15 @@ export function usePendingUploads(params: UsePendingUploadsParams) { setChatError(translate("chat.upload.onlyInTools", locale)); return; } - const workdir = displayedConversationWorkdirRef.current.trim(); + const workdir = + lock?.workdir?.trim() || displayedConversationWorkdirRef.current.trim(); if (!workdir) { setChatError(translate("chat.upload.requireWorkdir", locale)); return; } - const targetConversationId = displayedConversationIdRef.current; + // Prefer locked destination captured before async permission UI. + const targetConversationId = + lock?.conversationId?.trim() || displayedConversationIdRef.current; if (!targetConversationId) { setChatError("请先选择或创建会话后再上传文件。"); return; diff --git a/crates/agent-gateway/web/src/components/icons.tsx b/crates/agent-gateway/web/src/components/icons.tsx index 114a67d08..b1d80f9d6 100644 --- a/crates/agent-gateway/web/src/components/icons.tsx +++ b/crates/agent-gateway/web/src/components/icons.tsx @@ -15,6 +15,7 @@ import BotSource from "~icons/lucide/bot"; import BrainSource from "~icons/lucide/brain"; import BrushCleaningSource from "~icons/lucide/brush-cleaning"; import CableSource from "~icons/lucide/cable"; +import CameraSource from "~icons/lucide/camera"; import CheckSource from "~icons/lucide/check"; import ChevronDownSource from "~icons/lucide/chevron-down"; import ChevronRightSource from "~icons/lucide/chevron-right"; @@ -66,6 +67,7 @@ import Loader2Source from "~icons/lucide/loader-circle"; import LockSource from "~icons/lucide/lock"; import LogOutSource from "~icons/lucide/log-out"; import MessageSquareSource from "~icons/lucide/message-square"; +import Minimize2Source from "~icons/lucide/minimize-2"; import MessageSquareTextSource from "~icons/lucide/message-square-text"; import MinusSource from "~icons/lucide/minus"; import MonitorSmartphoneSource from "~icons/lucide/monitor-smartphone"; @@ -460,6 +462,7 @@ export const Bot = createIcon(BotSource); export const Brain = createIcon(BrainSource); export const BrushCleaning = createIcon(BrushCleaningSource); export const Cable = createIcon(CableSource); +export const Camera = createIcon(CameraSource); export const Check = createIcon(CheckSource); export const CheckCircle2 = createIcon(CheckCircle2Source); export const ChevronDown = createIcon(ChevronDownSource); @@ -509,6 +512,7 @@ export const Loader2 = createIcon(Loader2Source); export const Lock = createIcon(LockSource); export const LogOut = createIcon(LogOutSource); export const MessageSquare = createIcon(MessageSquareSource); +export const Minimize2 = createIcon(Minimize2Source); export const MessageSquareText = createIcon(MessageSquareTextSource); export const McpLogo = createIcon(McpLogoSource); export const Minus = createIcon(MinusSource); diff --git a/crates/agent-gateway/web/src/i18n/config.ts b/crates/agent-gateway/web/src/i18n/config.ts index f2effe4c7..c74c6aa00 100644 --- a/crates/agent-gateway/web/src/i18n/config.ts +++ b/crates/agent-gateway/web/src/i18n/config.ts @@ -31,6 +31,43 @@ export const translations: Record> = { /* ── Chat Page ── */ "chat.newConversation": "新对话", "chat.pendingTitle": "新会话", + "chat.toolbar.modeChat": "Chat", + "chat.toolbar.modeAgent": "Agent", + "chat.toolbar.modeChatTooltip": "纯对话模式(不使用本地工具)", + "chat.toolbar.modeAgentTooltip": "Agent 模式(可读写文件、执行命令)", + "chat.toolbar.contextUsage": "上下文占用", + "chat.toolbar.contextUsageDetail": "已用约 {used} / 窗口 {window}", + "chat.toolbar.contextUsageUnknownWindow": "已用约 {used}(未配置上下文窗口)", + "chat.toolbar.compact": "压缩上下文", + "chat.toolbar.compactRunning": "正在压缩…", + "chat.toolbar.compactConfirm": "当前上下文尚未接近上限,仍要压缩吗?将调用模型生成摘要。", + "chat.toolbar.compactConfirmAction": "仍要压缩", + "chat.toolbar.compactDesktopOnly": "手动压缩仅在桌面端可用", + "chat.toolbar.compactUnavailable": "当前无法压缩", + "chat.toolbar.compactSuccess": "上下文已压缩", + "chat.toolbar.compactSkipped": "没有需要压缩的上下文", + "chat.toolbar.compactFailed": "压缩失败:{message}", + "chat.toolbar.attachMenu": "添加附件", + "chat.toolbar.attachFiles": "选择文件", + "chat.toolbar.attachClipboard": "从剪贴板粘贴图片", + "chat.toolbar.attachScreenshot": "截取屏幕", + "chat.toolbar.clipboardEmpty": "剪贴板中没有图片", + "chat.toolbar.clipboardDenied": "无法读取剪贴板图片(权限被拒绝)", + "chat.toolbar.clipboardFailed": "读取剪贴板失败", + "chat.toolbar.screenshotCancelled": "已取消截图", + "chat.toolbar.screenshotFailed": "截图失败", + "chat.toolbar.screenshotUnsupported": "当前环境不支持截图", + "chat.toolbar.more": "更多", + "chat.toolbar.clearDraft": "清空草稿", + "chat.toolbar.insertTemplate": "插入 Prompt 模板", + "chat.toolbar.noTemplates": "暂无 Prompt 模板", + "chat.toolbar.sessionCost": "本会话费用", + "chat.toolbar.sessionCostUnknown": "暂无费用数据", + "chat.toolbar.sessionCostPartialValue": "{amount}(部分)", + "chat.toolbar.exportMarkdown": "导出 Markdown", + "chat.toolbar.exportSuccess": "已导出对话", + "chat.toolbar.exportFailed": "导出失败", + "chat.toolbar.exportIncomplete": "历史尚未完整加载,无法导出全部对话,请稍后重试", "chat.recentConversation": "最近会话", "chat.workspaceSection": "工作空间", "chat.workspaceCreate": "新建工作空间", @@ -1870,6 +1907,43 @@ export const translations: Record> = { /* ── Chat Page ── */ "chat.newConversation": "New Conversation", "chat.pendingTitle": "New Chat", + "chat.toolbar.modeChat": "Chat", + "chat.toolbar.modeAgent": "Agent", + "chat.toolbar.modeChatTooltip": "Chat only (no local tools)", + "chat.toolbar.modeAgentTooltip": "Agent mode (files, shell, tools)", + "chat.toolbar.contextUsage": "Context usage", + "chat.toolbar.contextUsageDetail": "About {used} used / {window} window", + "chat.toolbar.contextUsageUnknownWindow": "About {used} used (context window not set)", + "chat.toolbar.compact": "Compact context", + "chat.toolbar.compactRunning": "Compacting…", + "chat.toolbar.compactConfirm": "Context is not near the limit yet. Compact anyway? This calls the model to summarize.", + "chat.toolbar.compactConfirmAction": "Compact anyway", + "chat.toolbar.compactDesktopOnly": "Manual compact is only available on the desktop app", + "chat.toolbar.compactUnavailable": "Compaction unavailable", + "chat.toolbar.compactSuccess": "Context compacted", + "chat.toolbar.compactSkipped": "Nothing to compact", + "chat.toolbar.compactFailed": "Compaction failed: {message}", + "chat.toolbar.attachMenu": "Add attachment", + "chat.toolbar.attachFiles": "Choose files", + "chat.toolbar.attachClipboard": "Paste image from clipboard", + "chat.toolbar.attachScreenshot": "Capture screenshot", + "chat.toolbar.clipboardEmpty": "No image found on the clipboard", + "chat.toolbar.clipboardDenied": "Clipboard image access was denied", + "chat.toolbar.clipboardFailed": "Failed to read the clipboard", + "chat.toolbar.screenshotCancelled": "Screenshot cancelled", + "chat.toolbar.screenshotFailed": "Screenshot failed", + "chat.toolbar.screenshotUnsupported": "Screenshot is not supported here", + "chat.toolbar.more": "More", + "chat.toolbar.clearDraft": "Clear draft", + "chat.toolbar.insertTemplate": "Insert prompt template", + "chat.toolbar.noTemplates": "No prompt templates", + "chat.toolbar.sessionCost": "Session cost", + "chat.toolbar.sessionCostUnknown": "No cost data yet", + "chat.toolbar.sessionCostPartialValue": "{amount} (partial)", + "chat.toolbar.exportMarkdown": "Export Markdown", + "chat.toolbar.exportSuccess": "Conversation exported", + "chat.toolbar.exportFailed": "Export failed", + "chat.toolbar.exportIncomplete": "Full history is not loaded yet; cannot export the whole conversation. Try again later.", "chat.recentConversation": "Conversations", "chat.workspaceSection": "Workspaces", "chat.workspaceCreate": "New workspace", diff --git a/crates/agent-gateway/web/src/lib/chat/clipboardCapture.ts b/crates/agent-gateway/web/src/lib/chat/clipboardCapture.ts new file mode 100644 index 000000000..f33dd4a67 --- /dev/null +++ b/crates/agent-gateway/web/src/lib/chat/clipboardCapture.ts @@ -0,0 +1,257 @@ +export type ClipboardImageReadResult = + | { ok: true; files: File[] } + | { ok: false; reason: "unsupported" | "denied" | "empty" | "error"; message: string }; + +export type DisplayCaptureResult = + | { ok: true; file: File } + | { + ok: false; + reason: "unsupported" | "cancelled" | "error"; + message: string; + }; + +/** Supported clipboard image MIME → file extension (no silent re-labeling). */ +const IMAGE_MIME_EXTENSIONS: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/jpg": "jpg", + "image/gif": "gif", + "image/webp": "webp", + "image/bmp": "bmp", +}; + +const PREFERRED_IMAGE_MIME_ORDER = [ + "image/png", + "image/jpeg", + "image/jpg", + "image/webp", + "image/gif", + "image/bmp", +] as const; + +function normalizeMime(mimeType: string): string { + return mimeType.trim().toLowerCase().split(";")[0]?.trim() ?? ""; +} + +/** Return a known extension for a supported image MIME, or null if unsupported. */ +export function extensionForImageMime(mimeType: string): string | null { + const normalized = normalizeMime(mimeType); + return IMAGE_MIME_EXTENSIONS[normalized] ?? null; +} + +export function clipboardImageMimeTypes(types: readonly string[]): string[] { + return types.filter((type) => type.toLowerCase().startsWith("image/")); +} + +/** + * Prefer one representation per ClipboardItem: PNG/JPEG first, then other + * supported types. Unsupported image/* (svg/tiff/avif/…) are skipped unless + * they are the only option and we can map them — currently they are skipped + * so we never label raw bytes as .png without conversion. + */ +export function pickPreferredImageMime(types: readonly string[]): string | null { + const imageTypes = clipboardImageMimeTypes(types); + if (imageTypes.length === 0) return null; + + const byNormalized = new Map(); + for (const type of imageTypes) { + const normalized = normalizeMime(type); + if (!byNormalized.has(normalized)) byNormalized.set(normalized, type); + } + + for (const preferred of PREFERRED_IMAGE_MIME_ORDER) { + const original = byNormalized.get(preferred); + if (original && extensionForImageMime(original)) return original; + } + + for (const type of imageTypes) { + if (extensionForImageMime(type)) return type; + } + return null; +} + +/** Convert ClipboardItem-like entries into image Files (testable without DOM). */ +export async function filesFromClipboardItems( + items: ReadonlyArray<{ + types: readonly string[]; + getType: (type: string) => Promise; + }>, + now = Date.now(), +): Promise { + const files: File[] = []; + let index = 0; + for (const item of items) { + const preferred = pickPreferredImageMime(item.types ?? []); + if (!preferred) continue; + + // Try preferred first, then other supported types on read failure. + const imageTypes = clipboardImageMimeTypes(item.types ?? []); + const ordered: string[] = [preferred]; + for (const type of imageTypes) { + if (type !== preferred && extensionForImageMime(type)) ordered.push(type); + } + + let accepted = false; + for (const type of ordered) { + const extension = extensionForImageMime(type); + if (!extension) continue; + try { + const blob = await item.getType(type); + if (!blob || blob.size <= 0) continue; + const mime = normalizeMime(type) || normalizeMime(blob.type) || `image/${extension}`; + const file = new File([blob], `clipboard-image-${now}-${index + 1}.${extension}`, { + type: mime, + lastModified: now, + }); + files.push(file); + index += 1; + accepted = true; + break; + } catch { + // Try next representation for this ClipboardItem. + } + } + if (!accepted) { + // No readable supported representation — skip the item entirely. + } + } + return files; +} + +export async function readClipboardImageFiles( + clipboard: Pick | null | undefined = typeof navigator !== "undefined" + ? navigator.clipboard + : null, +): Promise { + if (!clipboard || typeof clipboard.read !== "function") { + return { + ok: false, + reason: "unsupported", + message: "Clipboard image read is not supported in this environment.", + }; + } + + try { + const items = await clipboard.read(); + const files = await filesFromClipboardItems(items); + if (files.length === 0) { + return { + ok: false, + reason: "empty", + message: "No image found on the clipboard.", + }; + } + return { ok: true, files }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const denied = /not allowed|denied|permission|secure context|document is not focused/i.test( + message, + ); + return { + ok: false, + reason: denied ? "denied" : "error", + message: message || "Failed to read clipboard images.", + }; + } +} + +async function blobFromVideoFrame(video: HTMLVideoElement, mime = "image/png"): Promise { + const width = video.videoWidth || 0; + const height = video.videoHeight || 0; + if (width <= 0 || height <= 0) { + throw new Error("Screenshot capture produced an empty frame."); + } + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("Screenshot canvas is unavailable."); + } + context.drawImage(video, 0, 0, width, height); + const blob = await new Promise((resolve) => { + canvas.toBlob((value) => resolve(value), mime); + }); + if (!blob || blob.size <= 0) { + throw new Error("Screenshot capture failed to encode the frame."); + } + return blob; +} + +export async function captureDisplayFrameToFile( + mediaDevices: Pick | null | undefined = typeof navigator !== + "undefined" + ? navigator.mediaDevices + : null, + now = Date.now(), +): Promise { + if (!mediaDevices || typeof mediaDevices.getDisplayMedia !== "function") { + return { + ok: false, + reason: "unsupported", + message: "Screen capture is not supported in this environment.", + }; + } + + let stream: MediaStream | null = null; + try { + stream = await mediaDevices.getDisplayMedia({ + video: true, + audio: false, + }); + const track = stream.getVideoTracks()[0]; + if (!track) { + return { + ok: false, + reason: "error", + message: "Screen capture returned no video track.", + }; + } + + const video = document.createElement("video"); + video.playsInline = true; + video.muted = true; + video.srcObject = stream; + + await new Promise((resolve, reject) => { + const onLoaded = () => { + cleanup(); + resolve(); + }; + const onError = () => { + cleanup(); + reject(new Error("Failed to load screen capture stream.")); + }; + const cleanup = () => { + video.removeEventListener("loadeddata", onLoaded); + video.removeEventListener("error", onError); + }; + video.addEventListener("loadeddata", onLoaded, { once: true }); + video.addEventListener("error", onError, { once: true }); + void video.play().catch(onError); + }); + + // Let the first frame settle on slower GPUs. + await new Promise((resolve) => window.setTimeout(resolve, 50)); + const blob = await blobFromVideoFrame(video); + const file = new File([blob], `screenshot-${now}.png`, { + type: "image/png", + lastModified: now, + }); + return { ok: true, file }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const cancelled = /not allowed|permission denied|abort|cancel/i.test(message); + return { + ok: false, + reason: cancelled ? "cancelled" : "error", + message: message || "Screen capture failed.", + }; + } finally { + if (stream) { + for (const track of stream.getTracks()) { + track.stop(); + } + } + } +} diff --git a/crates/agent-gateway/web/src/lib/chat/contextUsage.ts b/crates/agent-gateway/web/src/lib/chat/contextUsage.ts new file mode 100644 index 000000000..1b85c6f66 --- /dev/null +++ b/crates/agent-gateway/web/src/lib/chat/contextUsage.ts @@ -0,0 +1,212 @@ +export type ContextUsageLevel = "ok" | "warn" | "critical" | "unknown"; + +export type ContextUsageSnapshot = { + usedTokens: number; + contextWindow: number; + ratio: number | null; + level: ContextUsageLevel; + /** Compact display label, e.g. "12.4k" or "—" */ + usedLabel: string; + /** Percent label when window known, e.g. "6%" */ + percentLabel: string | null; +}; + +export const CONTEXT_USAGE_WARN_RATIO = 0.7; +export const CONTEXT_USAGE_CRITICAL_RATIO = 0.9; + +const CHARS_PER_TOKEN = 4; +const MESSAGE_ENVELOPE_TOKENS = 8; + +export function formatTokenCount(tokens: number): string { + const value = Math.max(0, Math.floor(tokens)); + if (value < 1_000) return String(value); + if (value < 10_000) { + const scaled = value / 1_000; + return `${scaled.toFixed(scaled >= 10 ? 0 : 1).replace(/\.0$/, "")}k`; + } + if (value < 1_000_000) { + return `${Math.round(value / 1_000)}k`; + } + const millions = value / 1_000_000; + return `${millions.toFixed(millions >= 10 ? 0 : 1).replace(/\.0$/, "")}M`; +} + +export function resolveContextUsageLevel(ratio: number | null): ContextUsageLevel { + if (ratio == null || !Number.isFinite(ratio)) return "unknown"; + if (ratio >= CONTEXT_USAGE_CRITICAL_RATIO) return "critical"; + if (ratio >= CONTEXT_USAGE_WARN_RATIO) return "warn"; + return "ok"; +} + +function estimateTextTokens(text: string): number { + const normalized = text.trim(); + if (!normalized) return 0; + return Math.ceil(normalized.length / CHARS_PER_TOKEN); +} + +function stringifiedLength(value: unknown): number { + if (typeof value === "string") return value.length; + if (value == null) return 0; + try { + return JSON.stringify(value)?.length ?? 0; + } catch { + return String(value).length; + } +} + +function readFiniteNumber(...candidates: unknown[]): number | null { + for (const value of candidates) { + if (typeof value === "number" && Number.isFinite(value)) { + return Math.max(0, Math.floor(value)); + } + } + return null; +} + +/** Read observed total-token usage from a message when available. */ +export function extractObservedTotalTokens(message: unknown): number | null { + if (!message || typeof message !== "object") return null; + const record = message as { + usage?: unknown; + usageTotalTokens?: unknown; + }; + // Prefer already-normalized total when history parsers stash it separately. + const normalizedTotal = readFiniteNumber(record.usageTotalTokens); + if (normalizedTotal != null) return normalizedTotal; + + const usage = record.usage; + if (!usage || typeof usage !== "object") return null; + const usageRecord = usage as { + totalTokens?: unknown; + total_tokens?: unknown; + input?: unknown; + output?: unknown; + inputTokens?: unknown; + outputTokens?: unknown; + input_tokens?: unknown; + output_tokens?: unknown; + }; + const total = readFiniteNumber(usageRecord.totalTokens, usageRecord.total_tokens); + if (total != null) return total; + const input = readFiniteNumber( + usageRecord.input, + usageRecord.inputTokens, + usageRecord.input_tokens, + ); + const output = readFiniteNumber( + usageRecord.output, + usageRecord.outputTokens, + usageRecord.output_tokens, + ); + if (input != null && output != null) { + return input + output; + } + if (input != null) return input; + return null; +} + +/** Lightweight token estimate for context meter (independent of compaction ledger). */ +export function estimateMessageTokensForUsage(message: unknown): number { + if (!message || typeof message !== "object") return MESSAGE_ENVELOPE_TOKENS; + const record = message as { role?: unknown; content?: unknown; details?: unknown }; + let chars = 0; + const content = record.content; + if (typeof content === "string") { + chars = content.length; + } else if (Array.isArray(content)) { + for (const block of content) { + if (!block || typeof block !== "object") { + chars += stringifiedLength(block); + continue; + } + const typed = block as { + type?: unknown; + text?: unknown; + thinking?: unknown; + name?: unknown; + arguments?: unknown; + }; + if (typed.type === "text" && typeof typed.text === "string") { + chars += typed.text.length; + continue; + } + if (typed.type === "thinking" && typeof typed.thinking === "string") { + chars += typed.thinking.length; + continue; + } + if (typed.type === "toolCall") { + chars += + (typeof typed.name === "string" ? typed.name.length : 0) + + stringifiedLength(typed.arguments); + continue; + } + chars += stringifiedLength(block); + } + } else { + chars = stringifiedLength(content); + } + if (record.details != null) chars += stringifiedLength(record.details); + return Math.ceil(chars / CHARS_PER_TOKEN) + MESSAGE_ENVELOPE_TOKENS; +} + +/** + * Estimate active context size for the toolbar meter. + * + * Prefer the latest observed `usage.totalTokens` as an anchor (includes system + * prompt, tools, and prior history as reported by the model). Messages after + * that anchor are estimated and added. When no observation exists, fall back + * to a pure char-based estimate of messages + system + optional tools. + */ +export function estimateContextUsage(params: { + messages?: readonly unknown[] | null; + systemPrompt?: string | null; + contextWindow?: number | null; + /** Optional tool schemas (JSON-serializable) counted only when no usage anchor exists. */ + tools?: unknown; +}): ContextUsageSnapshot { + const messages = Array.isArray(params.messages) ? params.messages : []; + + let anchorIndex = -1; + let anchorTokens = 0; + for (let i = messages.length - 1; i >= 0; i -= 1) { + const observed = extractObservedTotalTokens(messages[i]); + if (observed != null && observed > 0) { + anchorIndex = i; + anchorTokens = observed; + break; + } + } + + let usedTokens = 0; + if (anchorIndex >= 0) { + // Observed usage already includes system / tools / history up to that turn. + usedTokens = anchorTokens; + for (let i = anchorIndex + 1; i < messages.length; i += 1) { + usedTokens += estimateMessageTokensForUsage(messages[i]); + } + } else { + for (const message of messages) { + usedTokens += estimateMessageTokensForUsage(message); + } + if (typeof params.systemPrompt === "string" && params.systemPrompt.trim()) { + usedTokens += estimateTextTokens(params.systemPrompt); + } + if (params.tools != null) { + usedTokens += Math.ceil(stringifiedLength(params.tools) / CHARS_PER_TOKEN); + } + } + + const contextWindow = Math.max(0, Math.floor(params.contextWindow ?? 0)); + const ratio = contextWindow > 0 ? Math.min(1, Math.max(0, usedTokens / contextWindow)) : null; + const level = resolveContextUsageLevel(ratio); + const percentLabel = ratio == null ? null : `${Math.round(ratio * 100)}%`; + + return { + usedTokens, + contextWindow, + ratio, + level, + usedLabel: formatTokenCount(usedTokens), + percentLabel, + }; +} diff --git a/crates/agent-gateway/web/src/lib/chat/conversationExport.ts b/crates/agent-gateway/web/src/lib/chat/conversationExport.ts new file mode 100644 index 000000000..711a9f2d6 --- /dev/null +++ b/crates/agent-gateway/web/src/lib/chat/conversationExport.ts @@ -0,0 +1,459 @@ +export type ConversationExportMessage = { + role: string; + text: string; +}; + +export type ConversationCostSummary = { + total: number; + hasCost: boolean; +}; + +/** Minimal transcript shapes used by WebUI export / usage / cost helpers. */ +export type TranscriptExportBlock = { + kind?: string; + text?: string; + item?: { + toolCall?: { + name?: unknown; + arguments?: unknown; + [key: string]: unknown; + }; + toolResult?: { + content?: unknown; + details?: unknown; + [key: string]: unknown; + }; + [key: string]: unknown; + }; + [key: string]: unknown; +}; + +export type TranscriptExportRound = { + blocks?: readonly TranscriptExportBlock[]; + meta?: { + usage?: { + cost?: { total?: unknown }; + [key: string]: unknown; + }; + }; +}; + +export type TranscriptExportAttachment = { + fileName?: string; + relativePath?: string; + [key: string]: unknown; +}; + +export type TranscriptExportRow = + | { kind: "user"; text: string; attachments?: readonly TranscriptExportAttachment[] } + | { kind: "assistant"; rounds?: readonly TranscriptExportRound[] } + | { kind: "checkpoint"; content: string } + | { kind: string; [key: string]: unknown }; + +export type TranscriptCollectedMessage = { + role: string; + content: string; + usage?: unknown; +}; + +/** Message shapes suitable for `estimateContextUsage` (includes tool/thinking blocks). */ +export type TranscriptContextMessage = { + role: string; + content: unknown; + details?: unknown; + usage?: unknown; +}; + +export type ActiveTranscriptContext = { + /** Messages after the latest checkpoint (model-visible window). */ + messages: TranscriptContextMessage[]; + /** Latest checkpoint summary text, if any (folded into system prompt by the meter). */ + summaryText: string | null; +}; + +/** Extract visible assistant text from UiRound-style blocks. */ +export function extractUiRoundText( + round: TranscriptExportRound | null | undefined, + options?: { includeThinking?: boolean }, +): string { + if (!round || !Array.isArray(round.blocks)) return ""; + const includeThinking = options?.includeThinking === true; + const parts: string[] = []; + for (const block of round.blocks) { + if (!block || typeof block.text !== "string") continue; + const text = block.text.trim(); + if (!text) continue; + if (block.kind === "text") { + parts.push(text); + continue; + } + if (includeThinking && block.kind === "thinking") { + parts.push(text); + } + } + return parts.join("\n").trim(); +} + +function formatAttachmentLines(attachments: readonly TranscriptExportAttachment[] | undefined): string { + if (!Array.isArray(attachments) || attachments.length === 0) return ""; + const lines: string[] = []; + for (const file of attachments) { + if (!file || typeof file !== "object") continue; + const name = + (typeof file.fileName === "string" && file.fileName.trim()) || + (typeof file.relativePath === "string" && file.relativePath.trim()) || + ""; + if (name) lines.push(`[attachment:${name}]`); + } + return lines.join("\n"); +} + +function formatUserExportText( + text: string | null | undefined, + attachments?: readonly TranscriptExportAttachment[] | null, +): string { + const body = typeof text === "string" ? text.trim() : ""; + const files = formatAttachmentLines(attachments ?? undefined); + if (body && files) return `${body}\n${files}`; + return body || files; +} + +/** + * Flatten virtualized transcript rows into messages for cost + markdown export. + * Assistant rounds contribute visible text from `blocks` and optional `meta.usage` for cost. + * Usage-only tool rounds keep empty content so cost still aggregates while export skips them. + */ +export function collectMessagesFromTranscriptRows( + rows: readonly TranscriptExportRow[] | null | undefined, +): TranscriptCollectedMessage[] { + if (!Array.isArray(rows)) return []; + const out: TranscriptCollectedMessage[] = []; + for (const row of rows) { + if (!row || typeof row !== "object") continue; + if (row.kind === "user") { + const attachments = Array.isArray(row.attachments) + ? (row.attachments as readonly TranscriptExportAttachment[]) + : undefined; + const content = formatUserExportText(typeof row.text === "string" ? row.text : "", attachments); + // Keep attachment-only turns (empty text) in the export. + if (content) out.push({ role: "user", content }); + continue; + } + if (row.kind === "assistant") { + const rounds = Array.isArray(row.rounds) ? row.rounds : []; + for (const round of rounds) { + const text = extractUiRoundText(round); + const usage = round?.meta?.usage; + if (!text && !usage) continue; + // Do not invent "[assistant]" placeholders for tool-only / usage-only rounds. + // Empty content is still kept so sumConversationCost can read `usage`. + out.push({ + role: "assistant", + content: text, + usage, + }); + } + continue; + } + if (row.kind === "checkpoint") { + const content = typeof row.content === "string" ? row.content.trim() : ""; + if (content) out.push({ role: "assistant", content }); + } + } + return out; +} + +function pushContextBlocksFromRound( + out: TranscriptContextMessage[], + round: TranscriptExportRound | null | undefined, +): void { + if (!round) return; + // Gather the full assistant generation (text/thinking/toolCalls) first, then + // emit tool results. round.meta.usage covers that generation only; anchoring + // usage on the assistant message keeps tool output after the anchor while + // avoiding double-counting later toolCalls/text that belong to the same round. + const content: Array> = []; + const toolResults: Array<{ content: unknown; details: unknown }> = []; + const blocks = Array.isArray(round.blocks) ? round.blocks : []; + for (const block of blocks) { + if (!block || typeof block !== "object") continue; + if (block.kind === "text" && typeof block.text === "string") { + content.push({ type: "text", text: block.text }); + continue; + } + if (block.kind === "thinking" && typeof block.text === "string") { + content.push({ type: "thinking", thinking: block.text }); + continue; + } + if (block.kind === "tool") { + const toolCall = block.item?.toolCall; + if (toolCall && typeof toolCall === "object") { + content.push({ + type: "toolCall", + name: typeof toolCall.name === "string" ? toolCall.name : "tool", + arguments: toolCall.arguments, + }); + } + const toolResult = block.item?.toolResult; + if (toolResult && typeof toolResult === "object") { + toolResults.push({ + content: toolResult.content ?? "", + details: toolResult.details, + }); + } + continue; + } + if (block.kind === "hostedSearch") { + try { + content.push({ type: "text", text: JSON.stringify(block.item ?? block) }); + } catch { + content.push({ type: "text", text: "[hostedSearch]" }); + } + } + } + if (content.length > 0) { + out.push({ + role: "assistant", + content, + usage: round.meta?.usage, + }); + } else if (round.meta?.usage) { + // Usage-only round (no visible text/tools) — keep for cost meters only. + out.push({ + role: "assistant", + content: "", + usage: round.meta.usage, + }); + } + for (const toolResult of toolResults) { + out.push({ + role: "toolResult", + content: toolResult.content, + details: toolResult.details, + }); + } +} + +/** + * Collect the model-visible active context window from transcript rows: + * only messages after the latest checkpoint, including thinking / tool blocks. + * Checkpoint summary is returned separately so callers can fold it into system prompt. + */ +export function collectActiveContextFromTranscriptRows( + rows: readonly TranscriptExportRow[] | null | undefined, +): ActiveTranscriptContext { + if (!Array.isArray(rows)) return { messages: [], summaryText: null }; + + let lastCheckpointIndex = -1; + let summaryText: string | null = null; + for (let i = 0; i < rows.length; i += 1) { + const row = rows[i]; + if (!row || typeof row !== "object") continue; + if (row.kind === "checkpoint") { + lastCheckpointIndex = i; + const content = typeof row.content === "string" ? row.content.trim() : ""; + summaryText = content || null; + } + } + + const activeRows = lastCheckpointIndex >= 0 ? rows.slice(lastCheckpointIndex + 1) : rows; + const messages: TranscriptContextMessage[] = []; + for (const row of activeRows) { + if (!row || typeof row !== "object") continue; + if (row.kind === "user") { + const text = typeof row.text === "string" ? row.text : ""; + if (text.trim()) messages.push({ role: "user", content: text }); + continue; + } + if (row.kind === "assistant") { + const rounds = Array.isArray(row.rounds) ? row.rounds : []; + for (const round of rounds) { + pushContextBlocksFromRound(messages, round); + } + } + } + return { messages, summaryText }; +} + +function normalizeRoleLabel(role: string): string { + const normalized = role.trim().toLowerCase(); + if (normalized === "user") return "User"; + if (normalized === "assistant") return "Assistant"; + if (normalized === "system") return "System"; + if (normalized === "tool" || normalized === "toolresult") return "Tool"; + return role.trim() || "Message"; +} + +function sanitizeFilenamePart(value: string): string { + const cleaned = value + .trim() + .split("") + .map((char) => { + const code = char.charCodeAt(0); + if (code < 32) return " "; + if ('<>:"/\\|?*'.includes(char)) return " "; + return char; + }) + .join("") + .replace(/\s+/g, " ") + .slice(0, 80) + .trim(); + return cleaned || "conversation"; +} + +const DISPLAY_CONTENT_FIELD = "liveAgentDisplayContent"; +const ATTACHMENTS_FIELD = "liveAgentAttachments"; + +function attachmentsFromMessage(message: Record): TranscriptExportAttachment[] { + const raw = message[ATTACHMENTS_FIELD]; + if (!Array.isArray(raw)) return []; + return raw.filter((item): item is TranscriptExportAttachment => !!item && typeof item === "object"); +} + +/** Extract plain text from common LiveAgent / pi-ai message shapes. */ +export function extractMessagePlainText(message: unknown): string { + if (!message || typeof message !== "object") return ""; + const record = message as Record & { + role?: unknown; + content?: unknown; + }; + + // Prefer user-visible text for attachment turns (model content may include file directives). + const displayContent = record[DISPLAY_CONTENT_FIELD]; + const attachments = attachmentsFromMessage(record); + if (typeof displayContent === "string" || attachments.length > 0) { + return formatUserExportText( + typeof displayContent === "string" ? displayContent : "", + attachments, + ); + } + + const content = record.content; + if (typeof content === "string") return content.trim(); + if (!Array.isArray(content)) return ""; + + const parts: string[] = []; + for (const block of content) { + if (!block || typeof block !== "object") continue; + const typed = block as { + type?: unknown; + text?: unknown; + thinking?: unknown; + name?: unknown; + }; + if (typed.type === "text" && typeof typed.text === "string") { + parts.push(typed.text); + continue; + } + if (typed.type === "thinking" && typeof typed.thinking === "string") { + // Skip raw thinking in exports by default. + continue; + } + if (typed.type === "toolCall" && typeof typed.name === "string") { + parts.push(`[tool:${typed.name}]`); + } + } + return parts.join("\n").trim(); +} + +export function collectExportMessages( + messages: readonly unknown[] | null | undefined, +): ConversationExportMessage[] { + if (!Array.isArray(messages)) return []; + const out: ConversationExportMessage[] = []; + for (const message of messages) { + if (!message || typeof message !== "object") continue; + const role = + typeof (message as { role?: unknown }).role === "string" + ? String((message as { role: string }).role) + : "message"; + // Skip pure tool results in user-facing export. + if (role === "toolResult" || role === "tool") continue; + const text = extractMessagePlainText(message); + // Also skip usage-only assistant placeholders (empty content kept for cost). + if (!text) continue; + out.push({ role, text }); + } + return out; +} + +export function conversationToMarkdown(params: { + title?: string | null; + messages: readonly ConversationExportMessage[]; + exportedAt?: Date | string | number; +}): string { + const title = (params.title ?? "").trim() || "Conversation"; + const exportedAt = + params.exportedAt instanceof Date + ? params.exportedAt + : params.exportedAt + ? new Date(params.exportedAt) + : new Date(); + const timestamp = Number.isNaN(exportedAt.getTime()) + ? new Date().toISOString() + : exportedAt.toISOString(); + + const lines: string[] = [`# ${title}`, "", `Exported: ${timestamp}`, ""]; + + if (params.messages.length === 0) { + lines.push("_No messages._", ""); + return lines.join("\n"); + } + + for (const message of params.messages) { + lines.push(`## ${normalizeRoleLabel(message.role)}`, "", message.text.trim(), ""); + } + return lines.join("\n"); +} + +export function buildConversationExportFilename(title?: string | null, at = new Date()): string { + const stamp = Number.isNaN(at.getTime()) + ? "export" + : at.toISOString().replace(/[:.]/g, "-").slice(0, 19); + return `${sanitizeFilenamePart(title ?? "conversation")}-${stamp}.md`; +} + +export function sumConversationCost( + messages: readonly unknown[] | null | undefined, +): ConversationCostSummary { + if (!Array.isArray(messages)) return { total: 0, hasCost: false }; + let total = 0; + let hasCost = false; + for (const message of messages) { + if (!message || typeof message !== "object") continue; + const usage = (message as { usage?: { cost?: { total?: unknown } } }).usage; + const costTotal = usage?.cost?.total; + if (typeof costTotal === "number" && Number.isFinite(costTotal) && costTotal > 0) { + total += costTotal; + hasCost = true; + } + } + return { total, hasCost }; +} + +export function formatUsdCost(total: number, locale = "en-US"): string { + if (!Number.isFinite(total) || total <= 0) return "$0"; + const maximumFractionDigits = total >= 1 ? 2 : total >= 0.01 ? 4 : 6; + return `$${new Intl.NumberFormat(locale, { + minimumFractionDigits: 0, + maximumFractionDigits, + }).format(total)}`; +} + +/** Trigger a browser download for markdown content. */ +export function downloadTextFile(filename: string, content: string, mime = "text/markdown") { + if (typeof document === "undefined") { + throw new Error("downloadTextFile requires a DOM document"); + } + const blob = new Blob([content], { type: `${mime};charset=utf-8` }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.rel = "noopener"; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + // Delay revoke so the download can start in slower webviews. + window.setTimeout(() => URL.revokeObjectURL(url), 1_000); +} diff --git a/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx b/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx index fd059159d..4e7481420 100644 --- a/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx +++ b/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx @@ -16,14 +16,20 @@ import { } from "../../components/chat/MentionComposer"; import { GitBranchSelector } from "../../components/git/GitBranchSelector"; import { + Camera, ChevronDown, ChevronUp, + ClipboardPaste, Clock3, + Download, Globe, GlobeOff, Lightbulb, LightbulbOff, Loader2, + MessageSquare, + Minimize2, + MoreHorizontal, Paperclip, Play, Send, @@ -31,9 +37,21 @@ import { Square, SquarePen, Trash2, + Wrench, X, } from "../../components/icons"; import { Button } from "../../components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "../../components/ui/dropdown-menu"; import { Select, SelectContent, @@ -42,7 +60,11 @@ import { SelectValue, } from "../../components/ui/select"; import { useLocale } from "../../i18n"; -import { formatUploadedFileSize, type PendingUploadedFile } from "../../lib/chat/uploadedFiles"; +import type { ContextUsageSnapshot } from "../../lib/chat/contextUsage"; +import { + formatUploadedFileSize, + type PendingUploadedFile, +} from "../../lib/chat/uploadedFiles"; import type { GitClient } from "../../lib/git/types"; import { type ChatRuntimeControls, @@ -52,6 +74,12 @@ import { import { cn } from "../../lib/shared/utils"; import type { WorkspaceActivityClient } from "../../lib/workspace-activity/types"; +export type ComposerPromptTemplateOption = { + id: string; + name: string; + prompt: string; +}; + const REASONING_I18N_KEYS: Record = { off: "settings.reasoning.off", minimal: "settings.reasoning.minimal", @@ -62,6 +90,22 @@ const REASONING_I18N_KEYS: Record = { max: "settings.reasoning.max", }; +function isReasoningLevel(value: unknown): value is ReasoningLevel { + return typeof value === "string" && Object.hasOwn(REASONING_I18N_KEYS, value); +} + +function formatTokenCountLocal(tokens: number): string { + const value = Math.max(0, Math.floor(tokens)); + if (value < 1_000) return String(value); + if (value < 10_000) { + const scaled = value / 1_000; + return `${scaled.toFixed(scaled >= 10 ? 0 : 1).replace(/\.0$/, "")}k`; + } + if (value < 1_000_000) return `${Math.round(value / 1_000)}k`; + const millions = value / 1_000_000; + return `${millions.toFixed(millions >= 10 ? 0 : 1).replace(/\.0$/, "")}M`; +} + function RuntimeControlTooltip(props: { label: string; children: ReactNode }) { return ( @@ -124,6 +168,7 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { workspaceActivityClient?: WorkspaceActivityClient | null; onSend: () => void; onStop: () => void; + /** WebUI: warm chat runtime on composer focus. */ onPrepareChatRuntime?: () => void; onComposerBusyChange: (isBusy: boolean) => void; onChatRuntimeControlsChange: (patch: Partial) => void; @@ -138,6 +183,23 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { onMoveQueuedTurnUp: (id: string) => void; onEditQueuedTurn: (id: string) => void; onRemoveQueuedTurn: (id: string) => void; + onHeightChange?: (height: number) => void; + /** Estimated context usage for the active conversation. */ + contextUsage?: ContextUsageSnapshot | null; + onExecutionModeChange?: (mode: "text" | "tools") => void; + onManualCompact?: () => void | Promise; + isCompacting?: boolean; + /** Active conversation id — used to bind compact confirmation to the originating chat. */ + conversationId?: string; + /** When set, compact button is disabled and this tooltip is shown. */ + compactDisabledReason?: string | null; + onPasteClipboardImages?: () => void | Promise; + onCaptureScreenshot?: () => void | Promise; + promptTemplates?: readonly ComposerPromptTemplateOption[]; + onInsertPromptTemplate?: (templateId: string) => void; + onClearDraft?: () => void; + sessionCostLabel?: string | null; + onExportMarkdown?: () => void; }) { const { composerRef, @@ -170,10 +232,23 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { onMoveQueuedTurnUp, onEditQueuedTurn, onRemoveQueuedTurn, + onHeightChange, + contextUsage = null, + onExecutionModeChange, + onManualCompact, + isCompacting = false, + conversationId = "", + compactDisabledReason = null, + onPasteClipboardImages, + onCaptureScreenshot, + promptTemplates = [], + onInsertPromptTemplate, + onClearDraft, + sessionCostLabel = null, + onExportMarkdown, } = props; const { t } = useLocale(); - const [composerIsEmpty, setComposerIsEmpty] = useState(true); - const composerLayerRef = useRef(null); + const rootRef = useRef(null); const queuePanelRef = useRef(null); const queueListRef = useRef(null); const queueScrollbarTrackRef = useRef(null); @@ -183,6 +258,7 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { startY: number; } | null>(null); const queueHadTurnsRef = useRef(false); + const [composerIsEmpty, setComposerIsEmpty] = useState(true); const [queueCollapsed, setQueueCollapsed] = useState(false); const [queueScrollbar, setQueueScrollbar] = useState( DEFAULT_QUEUE_SCROLLBAR_STATE, @@ -211,6 +287,51 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { const thinkingTooltip = !thinkingSupported ? t("chat.runtime.thinkingUnavailable") : t("chat.runtime.thinkingTooltip"); + const [compactConfirmOpen, setCompactConfirmOpen] = useState(false); + const [compactConfirmConversationId, setCompactConfirmConversationId] = useState(""); + const contextUsageTooltip = !contextUsage + ? t("chat.toolbar.contextUsage") + : contextUsage.contextWindow > 0 + ? t("chat.toolbar.contextUsageDetail") + .replace("{used}", contextUsage.usedLabel) + .replace("{window}", formatTokenCountLocal(contextUsage.contextWindow)) + : t("chat.toolbar.contextUsageUnknownWindow").replace("{used}", contextUsage.usedLabel); + const compactDisabled = Boolean(compactDisabledReason) || isCompacting || controlsDisabled; + const compactTooltip = isCompacting + ? t("chat.toolbar.compactRunning") + : compactDisabledReason + ? compactDisabledReason + : t("chat.toolbar.compact"); + const hasDraftToClear = !composerIsEmpty || pendingUploadedFiles.length > 0; + const activeConversationId = conversationId.trim(); + + useEffect(() => { + // Composer is reused across chats; never leave a confirmation bound to the previous one. + setCompactConfirmOpen(false); + setCompactConfirmConversationId(""); + }, [activeConversationId]); + + function requestManualCompact() { + if (!onManualCompact || compactDisabled) return; + const ratio = contextUsage?.ratio; + if (ratio == null || ratio < 0.7) { + setCompactConfirmConversationId(activeConversationId); + setCompactConfirmOpen(true); + return; + } + void onManualCompact(); + } + + function confirmManualCompact() { + const boundId = compactConfirmConversationId.trim(); + setCompactConfirmOpen(false); + setCompactConfirmConversationId(""); + if (!onManualCompact) return; + // Drop the confirmation if the user switched chats before confirming. + if (boundId && activeConversationId && boundId !== activeConversationId) return; + if (compactDisabled) return; + void onManualCompact(); + } const webSearchTooltip = t("chat.runtime.webSearchTooltip"); const toggleQueueTooltip = queueCollapsed ? t("chat.queue.expand") : t("chat.queue.collapse"); @@ -372,48 +493,57 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { ]); useEffect(() => { - const composerLayer = composerLayerRef.current; - if (!composerLayer) { - return; - } - const chatFrame = composerLayer.closest(".gateway-chat-frame"); - if (!(chatFrame instanceof HTMLElement)) { - return; - } + const root = rootRef.current; + if (!root) return; - const updateComposerOverlayHeight = () => { - const composerLayerHeight = composerLayer.getBoundingClientRect().height; + // WebUI reserves bottom space via CSS var on `.gateway-chat-frame`. + // Keep this independent of optional `onHeightChange` so multi-line input / + // attachments do not cover the transcript and "scroll to bottom" control. + const chatFrame = root.closest(".gateway-chat-frame"); + const chatFrameEl = chatFrame instanceof HTMLElement ? chatFrame : null; + + let animationFrame: number | null = null; + const measure = () => { + animationFrame = null; + const rootHeight = root.getBoundingClientRect().height; const queueHeight = queuePanelRef.current?.getBoundingClientRect().height ?? 0; - chatFrame.style.setProperty( - "--gateway-chat-composer-overlay-height", - `${Math.ceil(Math.max(0, composerLayerHeight - queueHeight))}px`, - ); + const overlayHeight = Math.ceil(Math.max(0, rootHeight - queueHeight)); + if (chatFrameEl) { + chatFrameEl.style.setProperty( + "--gateway-chat-composer-overlay-height", + `${overlayHeight}px`, + ); + } + onHeightChange?.(overlayHeight); + }; + const scheduleMeasure = () => { + if (animationFrame !== null) return; + animationFrame = window.requestAnimationFrame(measure); }; - updateComposerOverlayHeight(); - - if (typeof ResizeObserver === "undefined") { - return () => { - chatFrame.style.removeProperty("--gateway-chat-composer-overlay-height"); - }; - } - - const resizeObserver = new ResizeObserver(() => { - updateComposerOverlayHeight(); - }); - resizeObserver.observe(composerLayer); + scheduleMeasure(); + const resizeObserver = + typeof ResizeObserver === "undefined" ? null : new ResizeObserver(scheduleMeasure); + resizeObserver?.observe(root); + window.addEventListener("resize", scheduleMeasure); return () => { - resizeObserver.disconnect(); - chatFrame.style.removeProperty("--gateway-chat-composer-overlay-height"); + if (animationFrame !== null) { + window.cancelAnimationFrame(animationFrame); + } + resizeObserver?.disconnect(); + window.removeEventListener("resize", scheduleMeasure); + chatFrameEl?.style.removeProperty("--gateway-chat-composer-overlay-height"); + onHeightChange?.(0); }; - }, []); + }, [onHeightChange]); return (
+ {/* `.gateway-chat-column` places the bar in the middle grid track of the layer. */}
{/* Pending uploaded files — above the composer card */} {pendingUploadedFiles.length > 0 && ( @@ -475,77 +605,79 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { : "max-h-[76px] overflow-y-hidden pr-1", )} > - {queuedTurns.map((item, index) => ( -
  • -
    - {index > 0 ? ( - - ) : ( - - )} - -
    -
    - - {item.previewText || t("chat.queue.emptyMessage")} - - {item.fileCount > 0 ? ( - - {t("chat.queue.fileCount").replace( - "{count}", - String(item.fileCount), - )} + {queuedTurns.map((item, index) => { + return ( +
  • +
    + {index > 0 ? ( + + ) : ( + + )} + +
    +
    + + {item.previewText || t("chat.queue.emptyMessage")} - ) : null} -
    -
    - - - - - - - - - -
    -
  • - ))} + {item.fileCount > 0 ? ( + + {t("chat.queue.fileCount").replace( + "{count}", + String(item.fileCount), + )} + + ) : null} +
    +
    + + + + + + + + + +
    + + ); + })} {shouldShowQueueScrollbar ? (
    ) : null} -
    +
    {/* macOS material rim-light */}
    -
    - - + + + + + + + onPickReadableFiles()} > - {pendingUploadedFiles.length} - - ) : null} - - + + {t("chat.toolbar.attachFiles")} + + { + void onPasteClipboardImages?.(); + }} + > + + {t("chat.toolbar.attachClipboard")} + + { + void onCaptureScreenshot?.(); + }} + > + + {t("chat.toolbar.attachScreenshot")} + + + +
    + + + + +
    + ) : null} + + {contextUsage ? ( + +
    + {contextUsage.usedLabel} + {contextUsage.percentLabel ? ( + + {contextUsage.percentLabel} + + ) : null} +
    +
    + ) : null} + + {onManualCompact || compactDisabledReason ? ( +
    + + + +
    + ) : null} + + + + {/* Keep local-only actions (export, cost, clear draft) available offline. */} + + + + + + {contextUsage ? ( + <> + + {t("chat.toolbar.contextUsage")} + +
    + {contextUsageTooltip} +
    + + + ) : null} + + {onManualCompact || compactDisabledReason ? ( + { + requestManualCompact(); + }} + > + {isCompacting ? ( + + ) : ( + + )} + {t("chat.toolbar.compact")} + + ) : null} + + onClearDraft?.()} + > + + {t("chat.toolbar.clearDraft")} + + + + + + {t("chat.toolbar.insertTemplate")} + + + {promptTemplates.length === 0 ? ( +
    + {t("chat.toolbar.noTemplates")} +
    + ) : ( + promptTemplates.map((template) => ( + onInsertPromptTemplate?.(template.id)} + > + {template.name || template.id} + + )) + )} +
    +
    + + + + {t("chat.toolbar.sessionCost")} + +
    + {sessionCostLabel?.trim() || t("chat.toolbar.sessionCostUnknown")} +
    + + + onExportMarkdown?.()} + > + + {t("chat.toolbar.exportMarkdown")} + +
    +
    @@ -828,6 +1203,34 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: {
    + + {/* Outside overflow-hidden glass card so confirm is not clipped. */} + {compactConfirmOpen ? ( +
    +

    + {t("chat.toolbar.compactConfirm")} +

    +
    + + +
    +
    + ) : null}
    ); diff --git a/crates/agent-gui/src/components/icons.tsx b/crates/agent-gui/src/components/icons.tsx index 0b4b0b514..ff6f9c026 100644 --- a/crates/agent-gui/src/components/icons.tsx +++ b/crates/agent-gui/src/components/icons.tsx @@ -14,6 +14,7 @@ import BotSource from "~icons/lucide/bot"; import BrainSource from "~icons/lucide/brain"; import BrushCleaningSource from "~icons/lucide/brush-cleaning"; import CableSource from "~icons/lucide/cable"; +import CameraSource from "~icons/lucide/camera"; import CheckSource from "~icons/lucide/check"; import ChevronDownSource from "~icons/lucide/chevron-down"; import ChevronRightSource from "~icons/lucide/chevron-right"; @@ -464,6 +465,7 @@ export const Bot = createIcon(BotSource); export const Brain = createIcon(BrainSource); export const BrushCleaning = createIcon(BrushCleaningSource); export const Cable = createIcon(CableSource); +export const Camera = createIcon(CameraSource); export const Check = createIcon(CheckSource); export const CheckCircle2 = createIcon(CheckCircle2Source); export const ChevronDown = createIcon(ChevronDownSource); diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index 0cb5ec7f4..928c7a460 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -44,6 +44,43 @@ export const translations: Record> = { /* ── Chat Page ── */ "chat.newConversation": "新对话", "chat.pendingTitle": "新会话", + "chat.toolbar.modeChat": "Chat", + "chat.toolbar.modeAgent": "Agent", + "chat.toolbar.modeChatTooltip": "纯对话模式(不使用本地工具)", + "chat.toolbar.modeAgentTooltip": "Agent 模式(可读写文件、执行命令)", + "chat.toolbar.contextUsage": "上下文占用", + "chat.toolbar.contextUsageDetail": "已用约 {used} / 窗口 {window}", + "chat.toolbar.contextUsageUnknownWindow": "已用约 {used}(未配置上下文窗口)", + "chat.toolbar.compact": "压缩上下文", + "chat.toolbar.compactRunning": "正在压缩…", + "chat.toolbar.compactConfirm": "当前上下文尚未接近上限,仍要压缩吗?将调用模型生成摘要。", + "chat.toolbar.compactConfirmAction": "仍要压缩", + "chat.toolbar.compactDesktopOnly": "手动压缩仅在桌面端可用", + "chat.toolbar.compactUnavailable": "当前无法压缩", + "chat.toolbar.compactSuccess": "上下文已压缩", + "chat.toolbar.compactSkipped": "没有需要压缩的上下文", + "chat.toolbar.compactFailed": "压缩失败:{message}", + "chat.toolbar.attachMenu": "添加附件", + "chat.toolbar.attachFiles": "选择文件", + "chat.toolbar.attachClipboard": "从剪贴板粘贴图片", + "chat.toolbar.attachScreenshot": "截取屏幕", + "chat.toolbar.clipboardEmpty": "剪贴板中没有图片", + "chat.toolbar.clipboardDenied": "无法读取剪贴板图片(权限被拒绝)", + "chat.toolbar.clipboardFailed": "读取剪贴板失败", + "chat.toolbar.screenshotCancelled": "已取消截图", + "chat.toolbar.screenshotFailed": "截图失败", + "chat.toolbar.screenshotUnsupported": "当前环境不支持截图", + "chat.toolbar.more": "更多", + "chat.toolbar.clearDraft": "清空草稿", + "chat.toolbar.insertTemplate": "插入 Prompt 模板", + "chat.toolbar.noTemplates": "暂无 Prompt 模板", + "chat.toolbar.sessionCost": "本会话费用", + "chat.toolbar.sessionCostUnknown": "暂无费用数据", + "chat.toolbar.sessionCostPartialValue": "{amount}(部分)", + "chat.toolbar.exportMarkdown": "导出 Markdown", + "chat.toolbar.exportSuccess": "已导出对话", + "chat.toolbar.exportFailed": "导出失败", + "chat.toolbar.exportIncomplete": "历史尚未完整加载,无法导出全部对话,请稍后重试", "chat.memoryExtraction.done": "记忆整理完成。", "chat.memoryExtraction.noop": "本轮无需更新记忆。", "chat.memoryExtraction.partial": "记忆部分更新({accepted} 条已应用,{rejected} 条被拒)。", @@ -1961,6 +1998,43 @@ export const translations: Record> = { /* ── Chat Page ── */ "chat.newConversation": "New Conversation", "chat.pendingTitle": "New Chat", + "chat.toolbar.modeChat": "Chat", + "chat.toolbar.modeAgent": "Agent", + "chat.toolbar.modeChatTooltip": "Chat only (no local tools)", + "chat.toolbar.modeAgentTooltip": "Agent mode (files, shell, tools)", + "chat.toolbar.contextUsage": "Context usage", + "chat.toolbar.contextUsageDetail": "About {used} used / {window} window", + "chat.toolbar.contextUsageUnknownWindow": "About {used} used (context window not set)", + "chat.toolbar.compact": "Compact context", + "chat.toolbar.compactRunning": "Compacting…", + "chat.toolbar.compactConfirm": "Context is not near the limit yet. Compact anyway? This calls the model to summarize.", + "chat.toolbar.compactConfirmAction": "Compact anyway", + "chat.toolbar.compactDesktopOnly": "Manual compact is only available on the desktop app", + "chat.toolbar.compactUnavailable": "Compaction unavailable", + "chat.toolbar.compactSuccess": "Context compacted", + "chat.toolbar.compactSkipped": "Nothing to compact", + "chat.toolbar.compactFailed": "Compaction failed: {message}", + "chat.toolbar.attachMenu": "Add attachment", + "chat.toolbar.attachFiles": "Choose files", + "chat.toolbar.attachClipboard": "Paste image from clipboard", + "chat.toolbar.attachScreenshot": "Capture screenshot", + "chat.toolbar.clipboardEmpty": "No image found on the clipboard", + "chat.toolbar.clipboardDenied": "Clipboard image access was denied", + "chat.toolbar.clipboardFailed": "Failed to read the clipboard", + "chat.toolbar.screenshotCancelled": "Screenshot cancelled", + "chat.toolbar.screenshotFailed": "Screenshot failed", + "chat.toolbar.screenshotUnsupported": "Screenshot is not supported here", + "chat.toolbar.more": "More", + "chat.toolbar.clearDraft": "Clear draft", + "chat.toolbar.insertTemplate": "Insert prompt template", + "chat.toolbar.noTemplates": "No prompt templates", + "chat.toolbar.sessionCost": "Session cost", + "chat.toolbar.sessionCostUnknown": "No cost data yet", + "chat.toolbar.sessionCostPartialValue": "{amount} (partial)", + "chat.toolbar.exportMarkdown": "Export Markdown", + "chat.toolbar.exportSuccess": "Conversation exported", + "chat.toolbar.exportFailed": "Export failed", + "chat.toolbar.exportIncomplete": "Full history is not loaded yet; cannot export the whole conversation. Try again later.", "chat.memoryExtraction.done": "Memory updated.", "chat.memoryExtraction.noop": "No memory updates needed this turn.", "chat.memoryExtraction.partial": diff --git a/crates/agent-gui/src/lib/chat/clipboardCapture.ts b/crates/agent-gui/src/lib/chat/clipboardCapture.ts new file mode 100644 index 000000000..f33dd4a67 --- /dev/null +++ b/crates/agent-gui/src/lib/chat/clipboardCapture.ts @@ -0,0 +1,257 @@ +export type ClipboardImageReadResult = + | { ok: true; files: File[] } + | { ok: false; reason: "unsupported" | "denied" | "empty" | "error"; message: string }; + +export type DisplayCaptureResult = + | { ok: true; file: File } + | { + ok: false; + reason: "unsupported" | "cancelled" | "error"; + message: string; + }; + +/** Supported clipboard image MIME → file extension (no silent re-labeling). */ +const IMAGE_MIME_EXTENSIONS: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/jpg": "jpg", + "image/gif": "gif", + "image/webp": "webp", + "image/bmp": "bmp", +}; + +const PREFERRED_IMAGE_MIME_ORDER = [ + "image/png", + "image/jpeg", + "image/jpg", + "image/webp", + "image/gif", + "image/bmp", +] as const; + +function normalizeMime(mimeType: string): string { + return mimeType.trim().toLowerCase().split(";")[0]?.trim() ?? ""; +} + +/** Return a known extension for a supported image MIME, or null if unsupported. */ +export function extensionForImageMime(mimeType: string): string | null { + const normalized = normalizeMime(mimeType); + return IMAGE_MIME_EXTENSIONS[normalized] ?? null; +} + +export function clipboardImageMimeTypes(types: readonly string[]): string[] { + return types.filter((type) => type.toLowerCase().startsWith("image/")); +} + +/** + * Prefer one representation per ClipboardItem: PNG/JPEG first, then other + * supported types. Unsupported image/* (svg/tiff/avif/…) are skipped unless + * they are the only option and we can map them — currently they are skipped + * so we never label raw bytes as .png without conversion. + */ +export function pickPreferredImageMime(types: readonly string[]): string | null { + const imageTypes = clipboardImageMimeTypes(types); + if (imageTypes.length === 0) return null; + + const byNormalized = new Map(); + for (const type of imageTypes) { + const normalized = normalizeMime(type); + if (!byNormalized.has(normalized)) byNormalized.set(normalized, type); + } + + for (const preferred of PREFERRED_IMAGE_MIME_ORDER) { + const original = byNormalized.get(preferred); + if (original && extensionForImageMime(original)) return original; + } + + for (const type of imageTypes) { + if (extensionForImageMime(type)) return type; + } + return null; +} + +/** Convert ClipboardItem-like entries into image Files (testable without DOM). */ +export async function filesFromClipboardItems( + items: ReadonlyArray<{ + types: readonly string[]; + getType: (type: string) => Promise; + }>, + now = Date.now(), +): Promise { + const files: File[] = []; + let index = 0; + for (const item of items) { + const preferred = pickPreferredImageMime(item.types ?? []); + if (!preferred) continue; + + // Try preferred first, then other supported types on read failure. + const imageTypes = clipboardImageMimeTypes(item.types ?? []); + const ordered: string[] = [preferred]; + for (const type of imageTypes) { + if (type !== preferred && extensionForImageMime(type)) ordered.push(type); + } + + let accepted = false; + for (const type of ordered) { + const extension = extensionForImageMime(type); + if (!extension) continue; + try { + const blob = await item.getType(type); + if (!blob || blob.size <= 0) continue; + const mime = normalizeMime(type) || normalizeMime(blob.type) || `image/${extension}`; + const file = new File([blob], `clipboard-image-${now}-${index + 1}.${extension}`, { + type: mime, + lastModified: now, + }); + files.push(file); + index += 1; + accepted = true; + break; + } catch { + // Try next representation for this ClipboardItem. + } + } + if (!accepted) { + // No readable supported representation — skip the item entirely. + } + } + return files; +} + +export async function readClipboardImageFiles( + clipboard: Pick | null | undefined = typeof navigator !== "undefined" + ? navigator.clipboard + : null, +): Promise { + if (!clipboard || typeof clipboard.read !== "function") { + return { + ok: false, + reason: "unsupported", + message: "Clipboard image read is not supported in this environment.", + }; + } + + try { + const items = await clipboard.read(); + const files = await filesFromClipboardItems(items); + if (files.length === 0) { + return { + ok: false, + reason: "empty", + message: "No image found on the clipboard.", + }; + } + return { ok: true, files }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const denied = /not allowed|denied|permission|secure context|document is not focused/i.test( + message, + ); + return { + ok: false, + reason: denied ? "denied" : "error", + message: message || "Failed to read clipboard images.", + }; + } +} + +async function blobFromVideoFrame(video: HTMLVideoElement, mime = "image/png"): Promise { + const width = video.videoWidth || 0; + const height = video.videoHeight || 0; + if (width <= 0 || height <= 0) { + throw new Error("Screenshot capture produced an empty frame."); + } + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("Screenshot canvas is unavailable."); + } + context.drawImage(video, 0, 0, width, height); + const blob = await new Promise((resolve) => { + canvas.toBlob((value) => resolve(value), mime); + }); + if (!blob || blob.size <= 0) { + throw new Error("Screenshot capture failed to encode the frame."); + } + return blob; +} + +export async function captureDisplayFrameToFile( + mediaDevices: Pick | null | undefined = typeof navigator !== + "undefined" + ? navigator.mediaDevices + : null, + now = Date.now(), +): Promise { + if (!mediaDevices || typeof mediaDevices.getDisplayMedia !== "function") { + return { + ok: false, + reason: "unsupported", + message: "Screen capture is not supported in this environment.", + }; + } + + let stream: MediaStream | null = null; + try { + stream = await mediaDevices.getDisplayMedia({ + video: true, + audio: false, + }); + const track = stream.getVideoTracks()[0]; + if (!track) { + return { + ok: false, + reason: "error", + message: "Screen capture returned no video track.", + }; + } + + const video = document.createElement("video"); + video.playsInline = true; + video.muted = true; + video.srcObject = stream; + + await new Promise((resolve, reject) => { + const onLoaded = () => { + cleanup(); + resolve(); + }; + const onError = () => { + cleanup(); + reject(new Error("Failed to load screen capture stream.")); + }; + const cleanup = () => { + video.removeEventListener("loadeddata", onLoaded); + video.removeEventListener("error", onError); + }; + video.addEventListener("loadeddata", onLoaded, { once: true }); + video.addEventListener("error", onError, { once: true }); + void video.play().catch(onError); + }); + + // Let the first frame settle on slower GPUs. + await new Promise((resolve) => window.setTimeout(resolve, 50)); + const blob = await blobFromVideoFrame(video); + const file = new File([blob], `screenshot-${now}.png`, { + type: "image/png", + lastModified: now, + }); + return { ok: true, file }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const cancelled = /not allowed|permission denied|abort|cancel/i.test(message); + return { + ok: false, + reason: cancelled ? "cancelled" : "error", + message: message || "Screen capture failed.", + }; + } finally { + if (stream) { + for (const track of stream.getTracks()) { + track.stop(); + } + } + } +} diff --git a/crates/agent-gui/src/lib/chat/compaction/controller.ts b/crates/agent-gui/src/lib/chat/compaction/controller.ts index 50aa32ab3..1b4e31992 100644 --- a/crates/agent-gui/src/lib/chat/compaction/controller.ts +++ b/crates/agent-gui/src/lib/chat/compaction/controller.ts @@ -235,8 +235,164 @@ export class CompactionController { } } + /** + * Toolbar-driven compaction. Requires an active turn binding with sinks. + * When `force` is true, threshold/cooldown gates are skipped (still needs + * messages + a configured context window). + */ + async compactManually(params: { + force?: boolean; + budgetContext: Context; + tools?: Context["tools"]; + includeUploadedFilesMetadata?: boolean; + }): Promise<"compacted" | "skipped" | "failed"> { + const binding = this.binding; + if (!binding) return "skipped"; + if (binding.cancellation.userStop.signal.aborted) { + throw createCompactionAbortError(); + } + if (this.inFlight) return "skipped"; + + const now = Date.now(); + const buildOptions: ContextBuildOptions = { + includeUploadedFilesMetadata: params.includeUploadedFilesMetadata, + }; + + // Prefer the live conversation state from sinks consumers: budgetContext + // is only for token accounting; we compact the state captured in presend + // when available, otherwise rebuild from the budget context owner. + const baseState = binding.presend?.baseState; + if (!baseState) return "skipped"; + + let workingState = baseState; + let pruned: PruneConversationResult | null = null; + if (shouldPruneBeforeCompaction(this.pressure, now)) { + const attempt = pruneConversationState(workingState, resolvePruneOptions(this.pressure)); + if (attempt.applied) { + pruned = attempt; + workingState = attempt.state; + } + } + + const budgetContext = pruned + ? binding.buildPreparedContext(workingState, params.tools, buildOptions) + : params.budgetContext; + this.ledger.rebase(budgetContext); + this.updateTurnMeta(workingState); + const decision = this.decide("optimization", this.ledger.total(), now, { + force: params.force === true, + }); + this.logDecision(decision); + + if (!decision.shouldCompact) { + if (pruned) { + const persisted = await this.persistManualState(binding, pruned.state); + if (!persisted) { + this.settleFailed("manual", "Failed to persist pruned context"); + return "failed"; + } + const appliedState = binding.presend?.composeAppliedState + ? binding.presend.composeAppliedState(pruned.state) + : pruned.state; + binding.sinks.applyState?.(appliedState); + return "compacted"; + } + return "skipped"; + } + + this.rollbackSnapshot = { + state: baseState, + composerText: binding.presend?.composerText, + uploadedFiles: binding.presend?.uploadedFiles, + }; + this.inFlight = true; + this.publishRunning("manual", workingState.activeSegmentIndex, decision); + + const scope = binding.cancellation.deriveScope(); + try { + const outcome = await runCompaction({ + state: workingState, + intent: "optimization", + contextTokens: decision.totalTokens, + threshold: decision.threshold, + providerId: binding.providerId, + model: binding.model, + runtime: binding.runtime, + signal: scope.controller.signal, + debugLogger: binding.debugLogger, + complete: binding.complete, + }); + + const persisted = await this.persistManualState(binding, outcome.state); + if (!persisted) { + this.rollbackSnapshot = null; + this.settleFailed("manual", "Failed to persist compacted context"); + return "failed"; + } + this.rollbackSnapshot = null; + const appliedState = binding.presend?.composeAppliedState + ? binding.presend.composeAppliedState(outcome.state) + : outcome.state; + binding.sinks.applyState?.(appliedState); + this.settleCompleted("manual", outcome.newSegmentIndex); + binding.sinks.queueCheckpoint?.(outcome.state); + this.notePostCompactionPressure( + binding.buildPreparedContext(appliedState, params.tools, buildOptions), + appliedState, + decision.threshold, + ); + return "compacted"; + } catch (error) { + if (this.isAbortOutcome(scope.controller.signal, error)) { + throw error; + } + this.rollbackSnapshot = null; + const fallback = + pruned ?? pruneConversationState(baseState, resolvePruneOptions(this.pressure)); + if (fallback.applied) { + // Persist first: never apply a pruned runtime state that did not land on disk, + // or a later auto-save could permanently drop pre-prune messages. + const persisted = await this.persistManualState(binding, fallback.state); + if (!persisted) { + this.settleFailed("manual", "Failed to persist pruned context"); + return "failed"; + } + const appliedState = binding.presend?.composeAppliedState + ? binding.presend.composeAppliedState(fallback.state) + : fallback.state; + binding.sinks.applyState?.(appliedState); + this.settleCompleted("manual", fallback.state.activeSegmentIndex); + binding.sinks.setBridgeToolStatus?.(buildPruneFallbackStatus(fallback.prunedMessageCount)); + // Summarizer failed but prune degraded successfully — treat as compacted. + return "compacted"; + } + console.warn("手动上下文压缩失败", error); + this.settleFailed("manual", error instanceof Error ? error.message : String(error)); + return "failed"; + } finally { + scope.release(); + this.inFlight = false; + this.binding?.sinks.setBridgeToolStatus?.(null); + } + } + + /** Manual compact treats persist resolving `false` as failure (unlike fire-and-forget best-effort). */ + private async persistManualState( + binding: CompactionTurnBinding, + state: ConversationViewState, + ): Promise { + if (!binding.sinks.persist) return true; + try { + const result = await binding.sinks.persist(state); + return result !== false; + } catch (error) { + console.warn("手动压缩持久化失败", error); + return false; + } + } + async compactDuringRun(params: { - trigger: Exclude; + trigger: Exclude; state: ConversationViewState; budgetContext?: Context; tools?: Context["tools"]; @@ -379,7 +535,12 @@ export class CompactionController { }; } - private decide(intent: CompactionIntent, totalTokens: number, now = Date.now()) { + private decide( + intent: CompactionIntent, + totalTokens: number, + now = Date.now(), + options?: { force?: boolean }, + ) { const binding = this.binding; if (!binding) { throw new Error("compaction decision requested without an active turn binding"); @@ -396,6 +557,7 @@ export class CompactionController { pressure: this.pressure, inFlight: this.inFlight, now, + force: options?.force === true, }); } diff --git a/crates/agent-gui/src/lib/chat/compaction/policy.ts b/crates/agent-gui/src/lib/chat/compaction/policy.ts index 6efd7af98..53956599f 100644 --- a/crates/agent-gui/src/lib/chat/compaction/policy.ts +++ b/crates/agent-gui/src/lib/chat/compaction/policy.ts @@ -128,6 +128,8 @@ export function decideCompaction(params: { pressure: CompactionPressure; inFlight: boolean; now: number; + /** When true, skip threshold/cooldown gates (manual toolbar compact). */ + force?: boolean; }): CompactionDecision { const contextWindow = Math.max(0, Math.floor(params.modelConfig?.contextWindow ?? 0)); const maxOutputToken = Math.max(0, Math.floor(params.modelConfig?.maxOutputToken ?? 0)); @@ -171,6 +173,17 @@ export function decideCompaction(params: { return { ...base, shouldCompact: false, reason: "in-flight", threshold, thresholdMode }; } + // Manual force bypasses threshold + cooldown; still requires a usable window and messages. + if (params.force) { + return { + ...base, + shouldCompact: true, + reason: "manual", + threshold, + thresholdMode, + }; + } + if (base.totalTokens < threshold) { return { ...base, shouldCompact: false, reason: "below-threshold", threshold, thresholdMode }; } diff --git a/crates/agent-gui/src/lib/chat/compaction/types.ts b/crates/agent-gui/src/lib/chat/compaction/types.ts index 55455142a..8ca405e8f 100644 --- a/crates/agent-gui/src/lib/chat/compaction/types.ts +++ b/crates/agent-gui/src/lib/chat/compaction/types.ts @@ -1,11 +1,6 @@ -import type { - CodexRequestFormat, - CustomProvider, - ProviderModelConfig, - ReasoningLevel, -} from "../../settings"; +import type { CodexRequestFormat, ProviderModelConfig, ReasoningLevel } from "../../settings"; -export type CompactionTrigger = "pre-send" | "mid-stream" | "post-tool"; +export type CompactionTrigger = "pre-send" | "mid-stream" | "post-tool" | "manual"; // optimization = 发送前的从容压缩(阈值更宽),protection = 运行中的保护性压缩(阈值更紧)。 export type CompactionIntent = "optimization" | "protection"; @@ -13,7 +8,6 @@ export type CompactionIntent = "optimization" | "protection"; export type ProviderRuntimeConfig = { baseUrl: string; apiKey: string; - customHeaders?: CustomProvider["customHeaders"]; requestFormat?: CodexRequestFormat; reasoning?: ReasoningLevel; promptCachingEnabled?: boolean; @@ -49,7 +43,8 @@ export type CompactionDecisionReason = | "in-flight" | "below-threshold" | "cooldown" - | "threshold-exceeded"; + | "threshold-exceeded" + | "manual"; export type CompactionDecision = { shouldCompact: boolean; diff --git a/crates/agent-gui/src/lib/chat/contextUsage.ts b/crates/agent-gui/src/lib/chat/contextUsage.ts new file mode 100644 index 000000000..1b85c6f66 --- /dev/null +++ b/crates/agent-gui/src/lib/chat/contextUsage.ts @@ -0,0 +1,212 @@ +export type ContextUsageLevel = "ok" | "warn" | "critical" | "unknown"; + +export type ContextUsageSnapshot = { + usedTokens: number; + contextWindow: number; + ratio: number | null; + level: ContextUsageLevel; + /** Compact display label, e.g. "12.4k" or "—" */ + usedLabel: string; + /** Percent label when window known, e.g. "6%" */ + percentLabel: string | null; +}; + +export const CONTEXT_USAGE_WARN_RATIO = 0.7; +export const CONTEXT_USAGE_CRITICAL_RATIO = 0.9; + +const CHARS_PER_TOKEN = 4; +const MESSAGE_ENVELOPE_TOKENS = 8; + +export function formatTokenCount(tokens: number): string { + const value = Math.max(0, Math.floor(tokens)); + if (value < 1_000) return String(value); + if (value < 10_000) { + const scaled = value / 1_000; + return `${scaled.toFixed(scaled >= 10 ? 0 : 1).replace(/\.0$/, "")}k`; + } + if (value < 1_000_000) { + return `${Math.round(value / 1_000)}k`; + } + const millions = value / 1_000_000; + return `${millions.toFixed(millions >= 10 ? 0 : 1).replace(/\.0$/, "")}M`; +} + +export function resolveContextUsageLevel(ratio: number | null): ContextUsageLevel { + if (ratio == null || !Number.isFinite(ratio)) return "unknown"; + if (ratio >= CONTEXT_USAGE_CRITICAL_RATIO) return "critical"; + if (ratio >= CONTEXT_USAGE_WARN_RATIO) return "warn"; + return "ok"; +} + +function estimateTextTokens(text: string): number { + const normalized = text.trim(); + if (!normalized) return 0; + return Math.ceil(normalized.length / CHARS_PER_TOKEN); +} + +function stringifiedLength(value: unknown): number { + if (typeof value === "string") return value.length; + if (value == null) return 0; + try { + return JSON.stringify(value)?.length ?? 0; + } catch { + return String(value).length; + } +} + +function readFiniteNumber(...candidates: unknown[]): number | null { + for (const value of candidates) { + if (typeof value === "number" && Number.isFinite(value)) { + return Math.max(0, Math.floor(value)); + } + } + return null; +} + +/** Read observed total-token usage from a message when available. */ +export function extractObservedTotalTokens(message: unknown): number | null { + if (!message || typeof message !== "object") return null; + const record = message as { + usage?: unknown; + usageTotalTokens?: unknown; + }; + // Prefer already-normalized total when history parsers stash it separately. + const normalizedTotal = readFiniteNumber(record.usageTotalTokens); + if (normalizedTotal != null) return normalizedTotal; + + const usage = record.usage; + if (!usage || typeof usage !== "object") return null; + const usageRecord = usage as { + totalTokens?: unknown; + total_tokens?: unknown; + input?: unknown; + output?: unknown; + inputTokens?: unknown; + outputTokens?: unknown; + input_tokens?: unknown; + output_tokens?: unknown; + }; + const total = readFiniteNumber(usageRecord.totalTokens, usageRecord.total_tokens); + if (total != null) return total; + const input = readFiniteNumber( + usageRecord.input, + usageRecord.inputTokens, + usageRecord.input_tokens, + ); + const output = readFiniteNumber( + usageRecord.output, + usageRecord.outputTokens, + usageRecord.output_tokens, + ); + if (input != null && output != null) { + return input + output; + } + if (input != null) return input; + return null; +} + +/** Lightweight token estimate for context meter (independent of compaction ledger). */ +export function estimateMessageTokensForUsage(message: unknown): number { + if (!message || typeof message !== "object") return MESSAGE_ENVELOPE_TOKENS; + const record = message as { role?: unknown; content?: unknown; details?: unknown }; + let chars = 0; + const content = record.content; + if (typeof content === "string") { + chars = content.length; + } else if (Array.isArray(content)) { + for (const block of content) { + if (!block || typeof block !== "object") { + chars += stringifiedLength(block); + continue; + } + const typed = block as { + type?: unknown; + text?: unknown; + thinking?: unknown; + name?: unknown; + arguments?: unknown; + }; + if (typed.type === "text" && typeof typed.text === "string") { + chars += typed.text.length; + continue; + } + if (typed.type === "thinking" && typeof typed.thinking === "string") { + chars += typed.thinking.length; + continue; + } + if (typed.type === "toolCall") { + chars += + (typeof typed.name === "string" ? typed.name.length : 0) + + stringifiedLength(typed.arguments); + continue; + } + chars += stringifiedLength(block); + } + } else { + chars = stringifiedLength(content); + } + if (record.details != null) chars += stringifiedLength(record.details); + return Math.ceil(chars / CHARS_PER_TOKEN) + MESSAGE_ENVELOPE_TOKENS; +} + +/** + * Estimate active context size for the toolbar meter. + * + * Prefer the latest observed `usage.totalTokens` as an anchor (includes system + * prompt, tools, and prior history as reported by the model). Messages after + * that anchor are estimated and added. When no observation exists, fall back + * to a pure char-based estimate of messages + system + optional tools. + */ +export function estimateContextUsage(params: { + messages?: readonly unknown[] | null; + systemPrompt?: string | null; + contextWindow?: number | null; + /** Optional tool schemas (JSON-serializable) counted only when no usage anchor exists. */ + tools?: unknown; +}): ContextUsageSnapshot { + const messages = Array.isArray(params.messages) ? params.messages : []; + + let anchorIndex = -1; + let anchorTokens = 0; + for (let i = messages.length - 1; i >= 0; i -= 1) { + const observed = extractObservedTotalTokens(messages[i]); + if (observed != null && observed > 0) { + anchorIndex = i; + anchorTokens = observed; + break; + } + } + + let usedTokens = 0; + if (anchorIndex >= 0) { + // Observed usage already includes system / tools / history up to that turn. + usedTokens = anchorTokens; + for (let i = anchorIndex + 1; i < messages.length; i += 1) { + usedTokens += estimateMessageTokensForUsage(messages[i]); + } + } else { + for (const message of messages) { + usedTokens += estimateMessageTokensForUsage(message); + } + if (typeof params.systemPrompt === "string" && params.systemPrompt.trim()) { + usedTokens += estimateTextTokens(params.systemPrompt); + } + if (params.tools != null) { + usedTokens += Math.ceil(stringifiedLength(params.tools) / CHARS_PER_TOKEN); + } + } + + const contextWindow = Math.max(0, Math.floor(params.contextWindow ?? 0)); + const ratio = contextWindow > 0 ? Math.min(1, Math.max(0, usedTokens / contextWindow)) : null; + const level = resolveContextUsageLevel(ratio); + const percentLabel = ratio == null ? null : `${Math.round(ratio * 100)}%`; + + return { + usedTokens, + contextWindow, + ratio, + level, + usedLabel: formatTokenCount(usedTokens), + percentLabel, + }; +} diff --git a/crates/agent-gui/src/lib/chat/conversationExport.ts b/crates/agent-gui/src/lib/chat/conversationExport.ts new file mode 100644 index 000000000..fb3693ec4 --- /dev/null +++ b/crates/agent-gui/src/lib/chat/conversationExport.ts @@ -0,0 +1,466 @@ +export type ConversationExportMessage = { + role: string; + text: string; +}; + +export type ConversationCostSummary = { + total: number; + hasCost: boolean; +}; + +/** Minimal transcript shapes used by WebUI export / usage / cost helpers. */ +export type TranscriptExportBlock = { + kind?: string; + text?: string; + item?: { + toolCall?: { + name?: unknown; + arguments?: unknown; + [key: string]: unknown; + }; + toolResult?: { + content?: unknown; + details?: unknown; + [key: string]: unknown; + }; + [key: string]: unknown; + }; + [key: string]: unknown; +}; + +export type TranscriptExportRound = { + blocks?: readonly TranscriptExportBlock[]; + meta?: { + usage?: { + cost?: { total?: unknown }; + [key: string]: unknown; + }; + }; +}; + +export type TranscriptExportAttachment = { + fileName?: string; + relativePath?: string; + [key: string]: unknown; +}; + +export type TranscriptExportRow = + | { kind: "user"; text: string; attachments?: readonly TranscriptExportAttachment[] } + | { kind: "assistant"; rounds?: readonly TranscriptExportRound[] } + | { kind: "checkpoint"; content: string } + | { kind: string; [key: string]: unknown }; + +export type TranscriptCollectedMessage = { + role: string; + content: string; + usage?: unknown; +}; + +/** Message shapes suitable for `estimateContextUsage` (includes tool/thinking blocks). */ +export type TranscriptContextMessage = { + role: string; + content: unknown; + details?: unknown; + usage?: unknown; +}; + +export type ActiveTranscriptContext = { + /** Messages after the latest checkpoint (model-visible window). */ + messages: TranscriptContextMessage[]; + /** Latest checkpoint summary text, if any (folded into system prompt by the meter). */ + summaryText: string | null; +}; + +/** Extract visible assistant text from UiRound-style blocks. */ +export function extractUiRoundText( + round: TranscriptExportRound | null | undefined, + options?: { includeThinking?: boolean }, +): string { + if (!round || !Array.isArray(round.blocks)) return ""; + const includeThinking = options?.includeThinking === true; + const parts: string[] = []; + for (const block of round.blocks) { + if (!block || typeof block.text !== "string") continue; + const text = block.text.trim(); + if (!text) continue; + if (block.kind === "text") { + parts.push(text); + continue; + } + if (includeThinking && block.kind === "thinking") { + parts.push(text); + } + } + return parts.join("\n").trim(); +} + +function formatAttachmentLines(attachments: readonly TranscriptExportAttachment[] | undefined): string { + if (!Array.isArray(attachments) || attachments.length === 0) return ""; + const lines: string[] = []; + for (const file of attachments) { + if (!file || typeof file !== "object") continue; + const name = + (typeof file.fileName === "string" && file.fileName.trim()) || + (typeof file.relativePath === "string" && file.relativePath.trim()) || + ""; + if (name) lines.push(`[attachment:${name}]`); + } + return lines.join("\n"); +} + +function formatUserExportText( + text: string | null | undefined, + attachments?: readonly TranscriptExportAttachment[] | null, +): string { + const body = typeof text === "string" ? text.trim() : ""; + const files = formatAttachmentLines(attachments ?? undefined); + if (body && files) return `${body}\n${files}`; + return body || files; +} + +/** + * Flatten virtualized transcript rows into messages for cost + markdown export. + * Assistant rounds contribute visible text from `blocks` and optional `meta.usage` for cost. + * Usage-only tool rounds keep empty content so cost still aggregates while export skips them. + */ +export function collectMessagesFromTranscriptRows( + rows: readonly TranscriptExportRow[] | null | undefined, +): TranscriptCollectedMessage[] { + if (!Array.isArray(rows)) return []; + const out: TranscriptCollectedMessage[] = []; + for (const row of rows) { + if (!row || typeof row !== "object") continue; + if (row.kind === "user") { + const attachments = Array.isArray(row.attachments) + ? (row.attachments as readonly TranscriptExportAttachment[]) + : undefined; + const content = formatUserExportText(typeof row.text === "string" ? row.text : "", attachments); + // Keep attachment-only turns (empty text) in the export. + if (content) out.push({ role: "user", content }); + continue; + } + if (row.kind === "assistant") { + const rounds = Array.isArray(row.rounds) ? row.rounds : []; + for (const round of rounds) { + const text = extractUiRoundText(round); + const usage = round?.meta?.usage; + if (!text && !usage) continue; + // Do not invent "[assistant]" placeholders for tool-only / usage-only rounds. + // Empty content is still kept so sumConversationCost can read `usage`. + out.push({ + role: "assistant", + content: text, + usage, + }); + } + continue; + } + if (row.kind === "checkpoint") { + const content = typeof row.content === "string" ? row.content.trim() : ""; + if (content) out.push({ role: "assistant", content }); + } + } + return out; +} + +function pushContextBlocksFromRound( + out: TranscriptContextMessage[], + round: TranscriptExportRound | null | undefined, +): void { + if (!round) return; + const content: Array> = []; + const blocks = Array.isArray(round.blocks) ? round.blocks : []; + // Attach round.meta.usage at most once so estimateContextUsage does not pick a + // trailing usage-only placeholder as the anchor (which would skip tool results). + let usageAttached = false; + const takeUsage = () => { + if (usageAttached) return undefined; + usageAttached = true; + return round.meta?.usage; + }; + for (const block of blocks) { + if (!block || typeof block !== "object") continue; + if (block.kind === "text" && typeof block.text === "string") { + content.push({ type: "text", text: block.text }); + continue; + } + if (block.kind === "thinking" && typeof block.text === "string") { + content.push({ type: "thinking", thinking: block.text }); + continue; + } + if (block.kind === "tool") { + const toolCall = block.item?.toolCall; + if (toolCall && typeof toolCall === "object") { + content.push({ + type: "toolCall", + name: typeof toolCall.name === "string" ? toolCall.name : "tool", + arguments: toolCall.arguments, + }); + } + const toolResult = block.item?.toolResult; + if (toolResult && typeof toolResult === "object") { + // Flush toolCall fragment before the tool result. Do NOT attach usage + // here: multi-tool rounds share one meta.usage for the whole response; + // early attachment would leave later toolCalls after the anchor and + // double-count them in estimateContextUsage. + if (content.length > 0) { + out.push({ + role: "assistant", + content: content.splice(0, content.length), + }); + } + out.push({ + role: "toolResult", + content: toolResult.content ?? "", + details: toolResult.details, + }); + } + continue; + } + if (block.kind === "hostedSearch") { + try { + content.push({ type: "text", text: JSON.stringify(block.item ?? block) }); + } catch { + content.push({ type: "text", text: "[hostedSearch]" }); + } + } + } + if (content.length > 0) { + out.push({ + role: "assistant", + content, + usage: takeUsage(), + }); + } else if (!usageAttached && round.meta?.usage) { + // Usage-only round (no visible text/tools) — keep for cost meters only. + out.push({ + role: "assistant", + content: "", + usage: takeUsage(), + }); + } +} + +/** + * Collect the model-visible active context window from transcript rows: + * only messages after the latest checkpoint, including thinking / tool blocks. + * Checkpoint summary is returned separately so callers can fold it into system prompt. + */ +export function collectActiveContextFromTranscriptRows( + rows: readonly TranscriptExportRow[] | null | undefined, +): ActiveTranscriptContext { + if (!Array.isArray(rows)) return { messages: [], summaryText: null }; + + let lastCheckpointIndex = -1; + let summaryText: string | null = null; + for (let i = 0; i < rows.length; i += 1) { + const row = rows[i]; + if (!row || typeof row !== "object") continue; + if (row.kind === "checkpoint") { + lastCheckpointIndex = i; + const content = typeof row.content === "string" ? row.content.trim() : ""; + summaryText = content || null; + } + } + + const activeRows = lastCheckpointIndex >= 0 ? rows.slice(lastCheckpointIndex + 1) : rows; + const messages: TranscriptContextMessage[] = []; + for (const row of activeRows) { + if (!row || typeof row !== "object") continue; + if (row.kind === "user") { + const text = typeof row.text === "string" ? row.text : ""; + if (text.trim()) messages.push({ role: "user", content: text }); + continue; + } + if (row.kind === "assistant") { + const rounds = Array.isArray(row.rounds) ? row.rounds : []; + for (const round of rounds) { + pushContextBlocksFromRound(messages, round); + } + } + } + return { messages, summaryText }; +} + +function normalizeRoleLabel(role: string): string { + const normalized = role.trim().toLowerCase(); + if (normalized === "user") return "User"; + if (normalized === "assistant") return "Assistant"; + if (normalized === "system") return "System"; + if (normalized === "tool" || normalized === "toolresult") return "Tool"; + return role.trim() || "Message"; +} + +function sanitizeFilenamePart(value: string): string { + const cleaned = value + .trim() + .split("") + .map((char) => { + const code = char.charCodeAt(0); + if (code < 32) return " "; + if ('<>:"/\\|?*'.includes(char)) return " "; + return char; + }) + .join("") + .replace(/\s+/g, " ") + .slice(0, 80) + .trim(); + return cleaned || "conversation"; +} + +const DISPLAY_CONTENT_FIELD = "liveAgentDisplayContent"; +const ATTACHMENTS_FIELD = "liveAgentAttachments"; + +function attachmentsFromMessage(message: Record): TranscriptExportAttachment[] { + const raw = message[ATTACHMENTS_FIELD]; + if (!Array.isArray(raw)) return []; + return raw.filter((item): item is TranscriptExportAttachment => !!item && typeof item === "object"); +} + +/** Extract plain text from common LiveAgent / pi-ai message shapes. */ +export function extractMessagePlainText(message: unknown): string { + if (!message || typeof message !== "object") return ""; + const record = message as Record & { + role?: unknown; + content?: unknown; + }; + + // Prefer user-visible text for attachment turns (model content may include file directives). + const displayContent = record[DISPLAY_CONTENT_FIELD]; + const attachments = attachmentsFromMessage(record); + if (typeof displayContent === "string" || attachments.length > 0) { + return formatUserExportText( + typeof displayContent === "string" ? displayContent : "", + attachments, + ); + } + + const content = record.content; + if (typeof content === "string") return content.trim(); + if (!Array.isArray(content)) return ""; + + const parts: string[] = []; + for (const block of content) { + if (!block || typeof block !== "object") continue; + const typed = block as { + type?: unknown; + text?: unknown; + thinking?: unknown; + name?: unknown; + }; + if (typed.type === "text" && typeof typed.text === "string") { + parts.push(typed.text); + continue; + } + if (typed.type === "thinking" && typeof typed.thinking === "string") { + // Skip raw thinking in exports by default. + continue; + } + if (typed.type === "toolCall" && typeof typed.name === "string") { + parts.push(`[tool:${typed.name}]`); + } + } + return parts.join("\n").trim(); +} + +export function collectExportMessages( + messages: readonly unknown[] | null | undefined, +): ConversationExportMessage[] { + if (!Array.isArray(messages)) return []; + const out: ConversationExportMessage[] = []; + for (const message of messages) { + if (!message || typeof message !== "object") continue; + const role = + typeof (message as { role?: unknown }).role === "string" + ? String((message as { role: string }).role) + : "message"; + // Skip pure tool results in user-facing export. + if (role === "toolResult" || role === "tool") continue; + const text = extractMessagePlainText(message); + // Also skip usage-only assistant placeholders (empty content kept for cost). + if (!text) continue; + out.push({ role, text }); + } + return out; +} + +export function conversationToMarkdown(params: { + title?: string | null; + messages: readonly ConversationExportMessage[]; + exportedAt?: Date | string | number; +}): string { + const title = (params.title ?? "").trim() || "Conversation"; + const exportedAt = + params.exportedAt instanceof Date + ? params.exportedAt + : params.exportedAt + ? new Date(params.exportedAt) + : new Date(); + const timestamp = Number.isNaN(exportedAt.getTime()) + ? new Date().toISOString() + : exportedAt.toISOString(); + + const lines: string[] = [`# ${title}`, "", `Exported: ${timestamp}`, ""]; + + if (params.messages.length === 0) { + lines.push("_No messages._", ""); + return lines.join("\n"); + } + + for (const message of params.messages) { + lines.push(`## ${normalizeRoleLabel(message.role)}`, "", message.text.trim(), ""); + } + return lines.join("\n"); +} + +export function buildConversationExportFilename(title?: string | null, at = new Date()): string { + const stamp = Number.isNaN(at.getTime()) + ? "export" + : at.toISOString().replace(/[:.]/g, "-").slice(0, 19); + return `${sanitizeFilenamePart(title ?? "conversation")}-${stamp}.md`; +} + +export function sumConversationCost( + messages: readonly unknown[] | null | undefined, +): ConversationCostSummary { + if (!Array.isArray(messages)) return { total: 0, hasCost: false }; + let total = 0; + let hasCost = false; + for (const message of messages) { + if (!message || typeof message !== "object") continue; + const usage = (message as { usage?: { cost?: { total?: unknown } } }).usage; + const costTotal = usage?.cost?.total; + if (typeof costTotal === "number" && Number.isFinite(costTotal) && costTotal > 0) { + total += costTotal; + hasCost = true; + } + } + return { total, hasCost }; +} + +export function formatUsdCost(total: number, locale = "en-US"): string { + if (!Number.isFinite(total) || total <= 0) return "$0"; + const maximumFractionDigits = total >= 1 ? 2 : total >= 0.01 ? 4 : 6; + return `$${new Intl.NumberFormat(locale, { + minimumFractionDigits: 0, + maximumFractionDigits, + }).format(total)}`; +} + +/** Trigger a browser download for markdown content. */ +export function downloadTextFile(filename: string, content: string, mime = "text/markdown") { + if (typeof document === "undefined") { + throw new Error("downloadTextFile requires a DOM document"); + } + const blob = new Blob([content], { type: `${mime};charset=utf-8` }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.rel = "noopener"; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + // Delay revoke so the download can start in slower webviews. + window.setTimeout(() => URL.revokeObjectURL(url), 1_000); +} diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index cbc7840eb..9cd4a2eb2 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -41,26 +41,39 @@ import { useLocale } from "../i18n"; import type { AppUpdateController } from "../lib/appUpdates"; import { getAutomationState } from "../lib/automation"; import { createHookRunScope } from "../lib/automation/hookRunner"; +import { captureDisplayFrameToFile, readClipboardImageFiles } from "../lib/chat/clipboardCapture"; import type { CompactionStatus } from "../lib/chat/compaction/types"; +import { estimateContextUsage } from "../lib/chat/contextUsage"; import { buildPersistableMessagesFromSnapshot, type SuppressedToolTraceSnapshot, } from "../lib/chat/conversation/chatAbort"; import { appendMessagesToConversation, + appendSummaryToSystemPrompt, buildRequestContext, type ConversationViewState, createConversationStateFromContext, + getActiveSegment, type HistoryMessageRef, type RenderTimelineItem, truncateConversationFromMessage, } from "../lib/chat/conversation/conversationState"; +import { sanitizeMessagesForContinuation } from "../lib/chat/context/requestContextSanitizer"; import type { LiveTranscriptStore } from "../lib/chat/conversation/liveTranscriptStore"; import { createConversationHookLifecycle, createGatewayBridgeEventController, } from "../lib/chat/conversation/run"; import { createTurnCancellation } from "../lib/chat/conversation/turnCancellation"; +import { + buildConversationExportFilename, + collectExportMessages, + conversationToMarkdown, + downloadTextFile, + formatUsdCost, + sumConversationCost, +} from "../lib/chat/conversationExport"; import { branchChatHistory, type ChatHistoryShareStatus, @@ -149,6 +162,7 @@ import { updateRightDockWidth, updateSkills, updateSshProjectHostIds, + updateSystem, type WorkspaceProject, workspaceProjectPathKey, } from "../lib/settings"; @@ -261,6 +275,7 @@ import { McpHubPage } from "./mcp-hub/McpHubPage"; import type { SectionId } from "./settings/types"; import { SkillsHubPage } from "./skills-hub/SkillsHubPage"; + const WorkspaceCodeEditorOverlay = lazy(async () => { await preparePreferredMonacoNlsLocale(); const module = await import("../components/workspace-editor/WorkspaceCodeEditorOverlay"); @@ -621,7 +636,7 @@ export function ChatPage(props: ChatPageProps) { // Monaco reads NLS globals while the lazy editor module imports monaco-editor. setPreferredMonacoNlsLocale(settings.locale); const effectiveTheme = resolveEffectiveTheme(settings.theme); - const { t } = useLocale(); + const { t, locale } = useLocale(); const initialConversationRef = useRef(createConversationIdentity()); const initialConversationStateRef = useRef(createConversationStateFromContext(context)); @@ -5080,6 +5095,450 @@ export function ChatPage(props: ChatPageProps) { composerBusyRef.current = isBusy; }, []); + const handleExecutionModeChange = useCallback( + (mode: "text" | "tools") => { + if (mode === "text") { + // Text runtime has no file tools — drop pending attachments so they + // are not sent as unfulfillable "Use Read …" prompts. + const conversationId = currentConversationIdRef.current.trim(); + if (conversationId) { + setPendingUploadsForConversation(conversationId, []); + } + } + setSettings((prev) => { + if (mode === "text") { + if (prev.system.executionMode === "text") return prev; + return updateSystem(prev, { executionMode: "text" }); + } + // Keep agent-dev selected when already in an agent mode. + if (prev.system.executionMode === "tools" || prev.system.executionMode === "agent-dev") { + return prev; + } + return updateSystem(prev, { executionMode: "tools" }); + }); + }, + [currentConversationIdRef, setPendingUploadsForConversation, setSettings], + ); + + const activeSegmentMessages = useMemo( + () => getActiveSegment(conversationState)?.messages ?? [], + [conversationState], + ); + const allConversationMessages = useMemo( + () => conversationState.segments.flatMap((segment) => segment.messages), + [conversationState], + ); + const composerPromptTemplates = useMemo( + () => + settings.agents + .filter((template) => template.prompt.trim()) + .map((template) => ({ + id: template.id, + name: template.name.trim() || template.id, + prompt: template.prompt, + })), + [settings.agents], + ); + + const handleManualCompact = useCallback(async () => { + const conversationId = currentConversationIdRef.current.trim(); + if (!conversationId) return; + // Do not compact while two-phase open is still hydrating older segments — + // runtimeEntry.state may only hold the active segment and a full upsert + // would drop unloaded history. + if ( + hydratingConversationId === conversationId || + hydratingConversationIdRef.current === conversationId + ) { + return; + } + + const runtimeEntry = + conversationRuntimeCacheRef.current.get(conversationId) ?? + createConversationRuntimeEntry({ + state: conversationState, + sessionId: currentConversationSessionId, + createdAt: currentConversationCreatedAt, + selectedModel: currentConversationSelectedModel, + }); + // Hold the conversation run lock for the whole compact cycle so Gateway + // submit / delete / concurrent bindTurn cannot race mid-flight. + if ( + isConversationRunning(conversationId) || + runtimeEntry.isSending || + compactionStatus.phase === "running" + ) { + return; + } + const baseState = runtimeEntry.state; + if ((getActiveSegment(baseState)?.messages.length ?? 0) === 0) { + addNotify("warning", t("chat.toolbar.compactSkipped")); + return; + } + + const selectedModel = + runtimeEntry.selectedModel ?? currentConversationSelectedModel ?? activeSelectedModel; + if (!selectedModel) { + addNotify("error", t("chat.toolbar.compactUnavailable")); + return; + } + const provider = settings.customProviders.find( + (item) => item.id === selectedModel.customProviderId, + ); + if (!provider) { + addNotify("error", t("chat.toolbar.compactUnavailable")); + return; + } + + const providerId = provider.type; + const model = selectedModel.model; + const runtimeControls = normalizeChatRuntimeControlsForProvider(settings.chatRuntimeControls, { + providerId, + requestFormat: provider.requestFormat, + modelId: model, + baseUrl: provider.baseUrl, + modelConfig: findProviderModelConfig(provider, model), + }); + const providerConfig = buildProviderRuntimeConfig(provider, model, runtimeControls); + const compaction = getCompactionController(conversationId); + const cancellation = createTurnCancellation(); + const sessionId = runtimeEntry.sessionId || conversationId; + const createdAt = runtimeEntry.createdAt || Date.now(); + const fallbackTitle = + sidebarStore.peek(conversationId)?.title?.trim() || + buildFallbackConversationTitle( + getFirstUserMessageText(buildRequestContext(baseState)) || t("chat.pendingTitle"), + ); + const conversationCwd = + runtimeEntry.workdir?.trim() || + (isAgentMode ? activeWorkspaceProjectPath || workdir : "") || + undefined; + + const buildPreparedContext = (state: ConversationViewState, tools?: Context["tools"]) => + buildPreparedConversationContext({ + state, + tools, + activeAgentPrompt, + skillsPrompt: "", + memoryPrompt: "", + }); + const buildResumeContext = ( + state: ConversationViewState, + resumeMessage?: UserMessage, + tools?: Context["tools"], + ) => + buildResumeConversationContext({ + state, + resumeMessage, + tools, + activeAgentPrompt, + skillsPrompt: "", + memoryPrompt: "", + }); + + const transcriptStoreForCompact = getConversationLiveTranscriptStore(conversationId); + compaction.bindTurn({ + providerId, + model, + runtime: { + baseUrl: providerConfig.baseUrl, + apiKey: providerConfig.apiKey, + requestFormat: providerConfig.requestFormat, + reasoning: providerConfig.reasoning, + promptCachingEnabled: providerConfig.promptCachingEnabled, + nativeWebSearchEnabled: providerConfig.nativeWebSearchEnabled, + useSystemProxy: providerConfig.useSystemProxy, + modelConfig: providerConfig.modelConfig, + }, + cancellation, + buildPreparedContext, + buildResumeContext, + presend: { + baseState, + pendingUserText: "", + composeAppliedState: (state) => state, + }, + sinks: { + applyState: (nextState) => { + updateConversationRuntimeEntry(conversationId, (prev) => ({ + ...prev, + state: nextState, + })); + }, + publishStatus: (status) => + updateConversationRuntimeEntry(conversationId, (prev) => ({ + ...prev, + compactionStatus: status, + })), + setBridgeToolStatus: (text) => { + updateToolStatus(text, transcriptStoreForCompact); + }, + persist: (state) => + persistConversation({ + conversationId, + sessionId, + providerId, + model, + selectedModel, + cwd: conversationCwd, + state, + fallbackTitle, + createdAt, + titlePromise: null, + }), + }, + }); + + // Wire Stop button: stopConversation() aborts the registered controller. + setConversationAbortController(conversationId, cancellation.userStop); + setConversationSendingState(conversationId, true); + // Publish Gateway running so remote WebUI cannot delete this chat mid-compact. + const compactRunId = createUuid(); + const compactUserMessage = { + role: "user" as const, + content: "", + timestamp: Date.now(), + }; + registerActiveGatewayRuntimeRun({ + conversationId, + runId: compactRunId, + cwd: conversationCwd, + revision: 0, + state: "running", + userMessage: compactUserMessage, + transcriptStore: transcriptStoreForCompact, + toolStatusIsCompaction: true, + }); + void queueGatewayRuntimeSnapshot(conversationId, { state: "running", force: true }); + void invoke("gateway_chat_mark_local_started", { + request_id: compactRunId, + conversation_id: conversationId, + } as any).catch((error) => { + console.warn("gateway_chat_mark_local_started (manual compact) failed", error); + }); + let gatewayFinalState: GatewayRuntimeSnapshotState = "completed"; + try { + const result = await compaction.compactManually({ + force: true, + budgetContext: buildPreparedContext(baseState), + }); + if (result === "compacted") { + addNotify("warning", t("chat.toolbar.compactSuccess")); + gatewayFinalState = "completed"; + } else if (result === "skipped") { + addNotify("warning", t("chat.toolbar.compactSkipped")); + gatewayFinalState = "completed"; + } else { + // compactManually already settleFailed → compactionStatus effect notifies once + // with the detailed message; avoid a second generic "failed" toast. + gatewayFinalState = "failed"; + } + } catch (error) { + if (isAbortLikeError(error)) { + gatewayFinalState = "cancelled"; + // compactManually rethrows abort; clear running status so the UI and + // entry gate do not stay stuck until the conversation is reloaded. + try { + await compaction.handleTurnAbort(); + } catch { + // ignore nested abort/cleanup failures + } + } else { + gatewayFinalState = "failed"; + const message = error instanceof Error ? error.message : String(error); + addNotify( + "error", + t("chat.toolbar.compactFailed").replace("{message}", message || "error"), + ); + } + } finally { + // Belt-and-suspenders: never leave compactionStatus.phase === "running". + updateConversationRuntimeEntry(conversationId, (prev) => + prev.compactionStatus.phase === "running" + ? { ...prev, compactionStatus: { phase: "idle" } } + : prev, + ); + // Clear sticky "stopping..." toolStatus from stopConversation(). + updateToolStatus(null, transcriptStoreForCompact); + compaction.unbindTurn(); + setConversationAbortController(conversationId, null); + setConversationSendingState(conversationId, false); + finishActiveGatewayRuntimeRun(conversationId, gatewayFinalState); + } + }, [ + activeAgentPrompt, + activeSelectedModel, + activeWorkspaceProjectPath, + addNotify, + compactionStatus.phase, + conversationRuntimeCacheRef, + conversationState, + currentConversationCreatedAt, + currentConversationIdRef, + currentConversationSelectedModel, + currentConversationSessionId, + getCompactionController, + getConversationLiveTranscriptStore, + hydratingConversationId, + isAgentMode, + isConversationRunning, + persistConversation, + setConversationAbortController, + setConversationSendingState, + settings.chatRuntimeControls, + settings.customProviders, + sidebarStore, + t, + updateConversationRuntimeEntry, + updateToolStatus, + workdir, + ]); + + const handlePasteClipboardImages = useCallback(async () => { + // Match composer upload enablement: use the displayed conversation cwd + // (workspace project / runtime workdir), not only settings.system.workdir. + if (!isAgentMode || !displayedConversationWorkdir) { + addNotify("warning", t("chat.upload.onlyInTools")); + return; + } + // Capture destination before permission UI; user may switch chats mid-await. + const lock = { + conversationId: currentConversationIdRef.current.trim(), + workdir: displayedConversationWorkdir, + }; + if (!lock.conversationId) return; + const result = await readClipboardImageFiles(); + if (!result.ok) { + if (result.reason === "empty") addNotify("warning", t("chat.toolbar.clipboardEmpty")); + else if (result.reason === "denied") addNotify("error", t("chat.toolbar.clipboardDenied")); + else addNotify("error", t("chat.toolbar.clipboardFailed")); + return; + } + await importReadableFiles(result.files, lock); + }, [ + addNotify, + currentConversationIdRef, + displayedConversationWorkdir, + importReadableFiles, + isAgentMode, + t, + ]); + + const handleCaptureScreenshot = useCallback(async () => { + if (!isAgentMode || !displayedConversationWorkdir) { + addNotify("warning", t("chat.upload.onlyInTools")); + return; + } + const lock = { + conversationId: currentConversationIdRef.current.trim(), + workdir: displayedConversationWorkdir, + }; + if (!lock.conversationId) return; + const result = await captureDisplayFrameToFile(); + if (!result.ok) { + if (result.reason === "cancelled") + addNotify("warning", t("chat.toolbar.screenshotCancelled")); + else if (result.reason === "unsupported") + addNotify("error", t("chat.toolbar.screenshotUnsupported")); + else addNotify("error", t("chat.toolbar.screenshotFailed")); + return; + } + await importReadableFiles([result.file], lock); + }, [ + addNotify, + currentConversationIdRef, + displayedConversationWorkdir, + importReadableFiles, + isAgentMode, + t, + ]); + + const handleClearComposerDraft = useCallback(() => { + const conversationId = currentConversationIdRef.current.trim(); + composerRef.current?.clear(); + if (conversationId) { + setPendingUploadsForConversation(conversationId, []); + // Also drop the per-conversation draft cache so switching away/back + // does not resurrect the just-cleared content. + clearCachedComposerDraft(conversationId); + } + composerRef.current?.focus(); + }, [currentConversationIdRef, setPendingUploadsForConversation]); + + const handleInsertPromptTemplate = useCallback( + (templateId: string) => { + const template = settings.agents.find((item) => item.id === templateId); + const prompt = template?.prompt?.trim(); + if (!prompt) return; + const composer = composerRef.current; + if (!composer) return; + // Preserve structured segments (large-paste chips, skill/commit/file mentions). + const draft = composer.getDraft(); + const needsGap = !draft.isEmpty && draft.text.trim().length > 0; + const appended = needsGap ? `\n\n${prompt}` : prompt; + composer.setDraft({ + ...draft, + segments: [...draft.segments, { type: "text", text: appended }], + text: needsGap ? `${draft.text.trimEnd()}${appended}` : prompt, + textWithoutLargePastes: needsGap + ? `${draft.textWithoutLargePastes.trimEnd()}${appended}` + : prompt, + isEmpty: false, + }); + composer.focus(); + }, + [settings.agents], + ); + + const handleExportMarkdown = useCallback(async () => { + const conversationId = currentConversationIdRef.current.trim() || currentConversationId.trim(); + if (!conversationId) return; + // Two-phase open paints active segment first; refuse mid-hydration exports. + if (hydratingConversationId === conversationId) { + addNotify("warning", t("chat.toolbar.exportIncomplete")); + return; + } + const runtimeEntry = conversationRuntimeCacheRef.current.get(conversationId); + const conversationBusy = + isConversationRunning(conversationId) || runtimeEntry?.isSending === true; + // Live assistant tokens sit in liveTranscriptStore until settle — refuse silent + // partial exports during a run (also avoids hydrate activating over isSending). + if (conversationBusy) { + addNotify("warning", t("chat.toolbar.exportIncomplete")); + return; + } + try { + await hydrateConversationFull(conversationId); + if (hydrationFailedConversationIdRef.current === conversationId) { + addNotify("warning", t("chat.toolbar.exportIncomplete")); + return; + } + const entry = conversationRuntimeCacheRef.current.get(conversationId); + const messages = collectExportMessages( + entry?.state?.segments?.flatMap((segment) => segment.messages) ?? allConversationMessages, + ); + const title = + sidebarStore.peek(conversationId)?.title?.trim() || t("chat.pendingTitle"); + const markdown = conversationToMarkdown({ title, messages }); + downloadTextFile(buildConversationExportFilename(title), markdown); + addNotify("warning", t("chat.toolbar.exportSuccess")); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + addNotify("error", `${t("chat.toolbar.exportFailed")}: ${message}`); + } + }, [ + addNotify, + allConversationMessages, + conversationRuntimeCacheRef, + currentConversationId, + currentConversationIdRef, + hydrateConversationFull, + hydratingConversationId, + isConversationRunning, + sidebarStore, + t, + ]); + const hasModels = modelOptions.length > 0; const currentModelLabel = (() => { @@ -5097,6 +5556,35 @@ export function ChatPage(props: ChatPageProps) { if (!provider) return undefined; return findProviderModelConfig(provider, activeSelectedModel.model).contextWindow; })(); + const contextUsageSnapshot = useMemo(() => { + const activeSegment = getActiveSegment(conversationState); + // Match the real request context: checkpoint summary is folded into system prompt, + // and the prepared runtime also appends the active Agent prompt (skills/memory are + // loaded at send-time and may be empty here). Strip aborted turns the same way + // buildRequestContext does so usage anchors do not land on excluded messages. + let effectiveSystemPrompt = appendSummaryToSystemPrompt( + conversationState.meta.systemPrompt, + activeSegment?.summary?.content, + activeSegment?.summary?.summaryMeta.fileLedger, + ); + if (activeAgentPrompt.trim()) { + effectiveSystemPrompt = effectiveSystemPrompt + ? `${effectiveSystemPrompt}\n\n${activeAgentPrompt.trim()}` + : activeAgentPrompt.trim(); + } + return estimateContextUsage({ + messages: sanitizeMessagesForContinuation(activeSegmentMessages), + systemPrompt: effectiveSystemPrompt, + contextWindow: currentModelContextWindow ?? 0, + }); + }, [activeAgentPrompt, activeSegmentMessages, conversationState, currentModelContextWindow]); + const sessionCostSummary = useMemo( + () => sumConversationCost(allConversationMessages), + [allConversationMessages], + ); + const sessionCostLabel = sessionCostSummary.hasCost + ? formatUsdCost(sessionCostSummary.total, locale) + : null; const currentChatProvider = activeSelectedModel ? settings.customProviders.find((item) => item.id === activeSelectedModel.customProviderId) : undefined; @@ -5223,6 +5711,10 @@ export function ChatPage(props: ChatPageProps) { if (persistedCwd) return persistedCwd; return displayedConversationWorkdir || undefined; })(); + + + + const isCompactionRunning = compactionStatus.phase === "running"; const isConversationHydrating = hydratingConversationId === currentConversationId; const isConversationHydrationFailed = hydrationFailedConversationId === currentConversationId; @@ -5594,6 +6086,18 @@ export function ChatPage(props: ChatPageProps) { onEditQueuedTurn={editQueuedTurn} onRemoveQueuedTurn={removeQueuedTurn} onHeightChange={setComposerOverlayHeight} + contextUsage={contextUsageSnapshot} + onExecutionModeChange={handleExecutionModeChange} + onManualCompact={handleManualCompact} + isCompacting={isCompactionRunning} + conversationId={currentConversationId} + onPasteClipboardImages={handlePasteClipboardImages} + onCaptureScreenshot={handleCaptureScreenshot} + promptTemplates={composerPromptTemplates} + onInsertPromptTemplate={handleInsertPromptTemplate} + onClearDraft={handleClearComposerDraft} + sessionCostLabel={sessionCostLabel} + onExportMarkdown={handleExportMarkdown} /> {isFileDropActive ? (
    = { off: "settings.reasoning.off", minimal: "settings.reasoning.minimal", @@ -69,6 +94,18 @@ function isReasoningLevel(value: unknown): value is ReasoningLevel { return typeof value === "string" && Object.hasOwn(REASONING_I18N_KEYS, value); } +function formatTokenCountLocal(tokens: number): string { + const value = Math.max(0, Math.floor(tokens)); + if (value < 1_000) return String(value); + if (value < 10_000) { + const scaled = value / 1_000; + return `${scaled.toFixed(scaled >= 10 ? 0 : 1).replace(/\.0$/, "")}k`; + } + if (value < 1_000_000) return `${Math.round(value / 1_000)}k`; + const millions = value / 1_000_000; + return `${millions.toFixed(millions >= 10 ? 0 : 1).replace(/\.0$/, "")}M`; +} + function RuntimeControlTooltip(props: { label: string; children: ReactNode }) { return ( @@ -145,6 +182,22 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { onEditQueuedTurn: (id: string) => void; onRemoveQueuedTurn: (id: string) => void; onHeightChange?: (height: number) => void; + /** Estimated context usage for the active conversation. */ + contextUsage?: ContextUsageSnapshot | null; + onExecutionModeChange?: (mode: "text" | "tools") => void; + onManualCompact?: () => void | Promise; + isCompacting?: boolean; + /** Active conversation id — used to bind compact confirmation to the originating chat. */ + conversationId?: string; + /** When set, compact button is disabled and this tooltip is shown. */ + compactDisabledReason?: string | null; + onPasteClipboardImages?: () => void | Promise; + onCaptureScreenshot?: () => void | Promise; + promptTemplates?: readonly ComposerPromptTemplateOption[]; + onInsertPromptTemplate?: (templateId: string) => void; + onClearDraft?: () => void; + sessionCostLabel?: string | null; + onExportMarkdown?: () => void; }) { const { composerRef, @@ -177,6 +230,19 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { onEditQueuedTurn, onRemoveQueuedTurn, onHeightChange, + contextUsage = null, + onExecutionModeChange, + onManualCompact, + isCompacting = false, + conversationId = "", + compactDisabledReason = null, + onPasteClipboardImages, + onCaptureScreenshot, + promptTemplates = [], + onInsertPromptTemplate, + onClearDraft, + sessionCostLabel = null, + onExportMarkdown, } = props; const { t } = useLocale(); const rootRef = useRef(null); @@ -218,6 +284,53 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { const thinkingTooltip = !thinkingSupported ? t("chat.runtime.thinkingUnavailable") : t("chat.runtime.thinkingTooltip"); + const [compactConfirmOpen, setCompactConfirmOpen] = useState(false); + const [compactConfirmConversationId, setCompactConfirmConversationId] = useState(""); + const contextUsageTooltip = !contextUsage + ? t("chat.toolbar.contextUsage") + : contextUsage.contextWindow > 0 + ? t("chat.toolbar.contextUsageDetail") + .replace("{used}", contextUsage.usedLabel) + .replace("{window}", formatTokenCountLocal(contextUsage.contextWindow)) + : t("chat.toolbar.contextUsageUnknownWindow").replace("{used}", contextUsage.usedLabel); + const compactDisabled = Boolean(compactDisabledReason) || isCompacting || controlsDisabled; + const compactTooltip = isCompacting + ? t("chat.toolbar.compactRunning") + : compactDisabledReason + ? compactDisabledReason + : t("chat.toolbar.compact"); + const hasDraftToClear = !composerIsEmpty || pendingUploadedFiles.length > 0; + const activeConversationId = conversationId.trim(); + + useEffect(() => { + // Composer is reused across chats; never leave a confirmation bound to the previous one. + setCompactConfirmOpen(false); + setCompactConfirmConversationId(""); + }, [activeConversationId]); + + function requestManualCompact() { + if (!onManualCompact || compactDisabled) return; + const ratio = contextUsage?.ratio; + if (ratio == null || ratio < 0.7) { + setCompactConfirmConversationId(activeConversationId); + setCompactConfirmOpen(true); + return; + } + void onManualCompact(); + } + + function confirmManualCompact() { + const boundId = compactConfirmConversationId.trim(); + setCompactConfirmOpen(false); + setCompactConfirmConversationId(""); + if (!onManualCompact) return; + // Drop the confirmation if the user switched chats before confirming. + if (boundId && activeConversationId && boundId !== activeConversationId) return; + // Re-check gates: two-phase history hydrate may have started while the + // confirm popover was open (compactDisabled becomes true). + if (compactDisabled) return; + void onManualCompact(); + } const webSearchTooltip = t("chat.runtime.webSearchTooltip"); const toggleQueueTooltip = queueCollapsed ? t("chat.queue.expand") : t("chat.queue.collapse"); @@ -622,44 +735,90 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: {
    -
    - - + + + + + + + onPickReadableFiles()} > - {pendingUploadedFiles.length} - - ) : null} - - + + {t("chat.toolbar.attachFiles")} + + { + void onPasteClipboardImages?.(); + }} + > + + {t("chat.toolbar.attachClipboard")} + + { + void onCaptureScreenshot?.(); + }} + > + + {t("chat.toolbar.attachScreenshot")} + + + +
    + + + + +
    + ) : null} + + {contextUsage ? ( + +
    + {contextUsage.usedLabel} + {contextUsage.percentLabel ? ( + + {contextUsage.percentLabel} + + ) : null} +
    +
    + ) : null} + + {onManualCompact || compactDisabledReason ? ( +
    + + + +
    + ) : null} + + + + {/* Keep local-only actions (export, cost, clear draft) available offline. */} + + + + + + {contextUsage ? ( + <> + + {t("chat.toolbar.contextUsage")} + +
    + {contextUsageTooltip} +
    + + + ) : null} + + {onManualCompact || compactDisabledReason ? ( + { + requestManualCompact(); + }} + > + {isCompacting ? ( + + ) : ( + + )} + {t("chat.toolbar.compact")} + + ) : null} + + onClearDraft?.()} + > + + {t("chat.toolbar.clearDraft")} + + + + + + {t("chat.toolbar.insertTemplate")} + + + {promptTemplates.length === 0 ? ( +
    + {t("chat.toolbar.noTemplates")} +
    + ) : ( + promptTemplates.map((template) => ( + onInsertPromptTemplate?.(template.id)} + > + {template.name || template.id} + + )) + )} +
    +
    + + + + {t("chat.toolbar.sessionCost")} + +
    + {sessionCostLabel?.trim() || t("chat.toolbar.sessionCostUnknown")} +
    + + + onExportMarkdown?.()} + > + + {t("chat.toolbar.exportMarkdown")} + +
    +
    @@ -839,6 +1195,34 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: {
    + + {/* Outside overflow-hidden glass card so confirm is not clipped. */} + {compactConfirmOpen ? ( +
    +

    + {t("chat.toolbar.compactConfirm")} +

    +
    + + +
    +
    + ) : null} ); diff --git a/crates/agent-gui/src/pages/chat/hooks/usePendingUploads.ts b/crates/agent-gui/src/pages/chat/hooks/usePendingUploads.ts index daa6d2d0c..ec9d3b6c7 100644 --- a/crates/agent-gui/src/pages/chat/hooks/usePendingUploads.ts +++ b/crates/agent-gui/src/pages/chat/hooks/usePendingUploads.ts @@ -237,6 +237,8 @@ export function usePendingUploads(params: UsePendingUploadsParams) { async (task: { emptySelectionMessage: string; errorFallback: string; + /** When set (e.g. after clipboard permission), do not re-read current conversation. */ + lockedTarget?: UploadTarget | null; importer: (target: UploadTarget) => Promise; }) => { if (uploadTaskActiveRef.current) { @@ -247,12 +249,12 @@ export function usePendingUploads(params: UsePendingUploadsParams) { setErrorMessage("文件上传仅在 tools 模式可用。"); return; } - if (!workdir) { + if (!workdir && !task.lockedTarget?.targetWorkdir) { setErrorMessage("请先在项目栏选择或创建项目后再上传文件。"); return; } - const uploadTarget = captureUploadTarget(); + const uploadTarget = task.lockedTarget ?? captureUploadTarget(); if (!uploadTarget) { return; } @@ -317,11 +319,30 @@ export function usePendingUploads(params: UsePendingUploadsParams) { ); const importReadableFiles = useCallback( - async (files: File[]) => { + async ( + files: File[], + lock?: { conversationId: string; workdir: string } | null, + ) => { if (files.length === 0) return; + let lockedTarget: UploadTarget | null = null; + if (lock?.conversationId?.trim() && lock.workdir?.trim()) { + const targetConversationId = lock.conversationId.trim(); + const currentTargetUploads = getPendingUploadsForConversation(targetConversationId); + const remainingFileSlots = Math.max(0, MAX_UPLOAD_FILES - currentTargetUploads.length); + if (remainingFileSlots === 0) { + addNotify("warning", `最多上传 ${MAX_UPLOAD_FILES} 个文件,已忽略多余文件`); + return; + } + lockedTarget = { + targetConversationId, + targetWorkdir: lock.workdir.trim(), + remainingFileSlots, + }; + } await runUploadTask({ emptySelectionMessage: "剪贴板文件均不受当前 Read 支持", errorFallback: "导入剪贴板文件失败", + lockedTarget, importer: async ({ targetWorkdir, remainingFileSlots }) => { const importBatch = files.slice(0, remainingFileSlots); const ignoredForLimit = files.length - importBatch.length; @@ -340,7 +361,7 @@ export function usePendingUploads(params: UsePendingUploadsParams) { }, }); }, - [addNotify, runUploadTask], + [addNotify, getPendingUploadsForConversation, runUploadTask], ); const removePendingUpload = useCallback( diff --git a/crates/agent-gui/test/chat/clipboard-capture.test.mjs b/crates/agent-gui/test/chat/clipboard-capture.test.mjs new file mode 100644 index 000000000..96886ffce --- /dev/null +++ b/crates/agent-gui/test/chat/clipboard-capture.test.mjs @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const mod = loader.loadModule("src/lib/chat/clipboardCapture.ts"); + +const { + clipboardImageMimeTypes, + extensionForImageMime, + filesFromClipboardItems, + pickPreferredImageMime, + readClipboardImageFiles, +} = mod; + +test("clipboardImageMimeTypes keeps only image/*", () => { + assert.deepEqual(clipboardImageMimeTypes(["text/plain", "image/png", "IMAGE/JPEG"]), [ + "image/png", + "IMAGE/JPEG", + ]); +}); + +test("extensionForImageMime rejects unsupported image types", () => { + assert.equal(extensionForImageMime("image/png"), "png"); + assert.equal(extensionForImageMime("image/jpeg"), "jpg"); + assert.equal(extensionForImageMime("image/svg+xml"), null); + assert.equal(extensionForImageMime("image/tiff"), null); + assert.equal(extensionForImageMime("image/avif"), null); +}); + +test("pickPreferredImageMime prefers png over jpeg for one item", () => { + assert.equal(pickPreferredImageMime(["image/jpeg", "image/png", "text/plain"]), "image/png"); + assert.equal(pickPreferredImageMime(["image/svg+xml", "image/tiff"]), null); +}); + +test("filesFromClipboardItems builds File objects for image blobs", async () => { + const pngBytes = new Uint8Array([137, 80, 78, 71]); + const items = [ + { + types: ["text/plain", "image/png"], + getType: async (type) => { + if (type === "image/png") return new Blob([pngBytes], { type: "image/png" }); + return new Blob(["hi"], { type: "text/plain" }); + }, + }, + ]; + const files = await filesFromClipboardItems(items, 1_700_000_000_000); + assert.equal(files.length, 1); + assert.match(files[0].name, /^clipboard-image-1700000000000-1\.png$/); + assert.equal(files[0].type, "image/png"); + assert.equal(files[0].size, pngBytes.length); +}); + +test("filesFromClipboardItems imports one representation per ClipboardItem", async () => { + const pngBytes = new Uint8Array([137, 80, 78, 71]); + const jpegBytes = new Uint8Array([255, 216, 255]); + const files = await filesFromClipboardItems( + [ + { + types: ["image/png", "image/jpeg"], + getType: async (type) => { + if (type === "image/png") return new Blob([pngBytes], { type: "image/png" }); + if (type === "image/jpeg") return new Blob([jpegBytes], { type: "image/jpeg" }); + throw new Error(`unexpected ${type}`); + }, + }, + ], + 1_700_000_000_000, + ); + assert.equal(files.length, 1); + assert.equal(files[0].type, "image/png"); + assert.match(files[0].name, /\.png$/); +}); + +test("filesFromClipboardItems skips unsupported image-only items", async () => { + const files = await filesFromClipboardItems([ + { + types: ["image/svg+xml"], + getType: async () => new Blob([""], { type: "image/svg+xml" }), + }, + ]); + assert.deepEqual(files, []); +}); + +test("filesFromClipboardItems returns empty when no images", async () => { + const files = await filesFromClipboardItems([ + { + types: ["text/plain"], + getType: async () => new Blob(["x"], { type: "text/plain" }), + }, + ]); + assert.deepEqual(files, []); +}); + +test("readClipboardImageFiles reports unsupported without clipboard API", async () => { + const result = await readClipboardImageFiles(null); + assert.equal(result.ok, false); + assert.equal(result.reason, "unsupported"); +}); + +test("readClipboardImageFiles maps empty clipboard", async () => { + const result = await readClipboardImageFiles({ + read: async () => [], + }); + assert.equal(result.ok, false); + assert.equal(result.reason, "empty"); +}); + +test("readClipboardImageFiles maps permission errors", async () => { + const result = await readClipboardImageFiles({ + read: async () => { + throw new Error("NotAllowedError: permission denied"); + }, + }); + assert.equal(result.ok, false); + assert.equal(result.reason, "denied"); +}); diff --git a/crates/agent-gui/test/chat/compaction-controller.test.mjs b/crates/agent-gui/test/chat/compaction-controller.test.mjs index 673230234..d77854824 100644 --- a/crates/agent-gui/test/chat/compaction-controller.test.mjs +++ b/crates/agent-gui/test/chat/compaction-controller.test.mjs @@ -281,6 +281,77 @@ test("user stop chains into the summarizer; handleTurnAbort rolls back and persi assert.equal(await controller.handleTurnAbort(), false); }); +test("handleTurnAbort clears running status even without rollback snapshot", async () => { + // Manual compact abort path: compactManually may publish running then throw AbortError + // without a pre-send snapshot; UI must not stay stuck on "compacting". + const controller = new CompactionController(); + const state = conversationState.createConversationStateFromContext({ + systemPrompt: "sys", + messages: [ + user("please fix src/app.ts", 1), + user("continue with src/app.ts", 2), + user("check src/app.ts again", 3), + assistantWithUsage("working", 2_000, 4), + ], + }); + const cancellation = cancellationModule.createTurnCancellation(); + const events = []; + controller.bindTurn({ + providerId: "anthropic", + model: "claude-x", + runtime: { + baseUrl: "https://example", + apiKey: "k", + modelConfig: { contextWindow: 200_000, maxOutputToken: 32_000 }, + }, + cancellation, + sinks: { + applyState: (next) => events.push(["applyState", next]), + applyStateMidRun: (next) => events.push(["applyStateMidRun", next]), + publishStatus: (status) => events.push(["publishStatus", status]), + setBridgeToolStatus: (text, isCompaction) => events.push(["bridge", text, isCompaction]), + persist: async () => true, + }, + complete: async (params) => + new Promise((_, reject) => { + params.signal?.addEventListener( + "abort", + () => { + const error = new Error("aborted by user"); + error.name = "AbortError"; + reject(error); + }, + { once: true }, + ); + }), + buildPreparedContext: (next) => conversationState.buildRequestContext(next), + buildResumeContext: (next) => conversationState.buildRequestContext(next), + presend: { + baseState: state, + pendingUserText: "", + composeAppliedState: (next) => next, + }, + }); + + const pending = controller.compactManually({ + force: true, + budgetContext: conversationState.buildRequestContext(state), + }); + await new Promise((resolve) => setImmediate(resolve)); + cancellation.userStop.abort(); + await assert.rejects(pending, /aborted|AbortError/i); + + const rolledBack = await controller.handleTurnAbort(); + // No pre-send snapshot for pure manual compact mid-summarizer without prior prune apply. + // Still must publish idle so entry gates unlock. + const phases = events.filter((e) => e[0] === "publishStatus").map(([, s]) => s.phase); + assert.ok(phases.includes("running"), `expected running, got ${phases.join(",")}`); + assert.ok(phases.includes("idle"), `expected idle after abort, got ${phases.join(",")}`); + // Second abort is a no-op once idle. + assert.equal(await controller.handleTurnAbort(), false); + void rolledBack; +}); + test("summarizer failure degrades to prune and still returns a usable context", async () => { const controller = new CompactionController(); // 大工具输出(200k 字符 ≈ 50k tokens > 40k 保护额度)必须在"最近 2 个用户轮次"之前才可被裁剪。 @@ -363,3 +434,271 @@ test("registry hands out one controller per conversation and disposes cleanly", registry.dispose("conv-a"); assert.notEqual(registry.get("conv-a"), a); }); + +test("compactManually force compresses below-threshold conversations", async () => { + const controller = new CompactionController(); + const smallState = conversationState.createConversationStateFromContext({ + systemPrompt: "sys", + messages: [ + user("please fix src/app.ts", 1), + user("continue with src/app.ts", 2), + user("check src/app.ts again", 3), + assistantWithUsage("working", 2_000, 4), + ], + }); + let completeCalls = 0; + const { recorder } = bindController(controller, { + complete: async () => { + completeCalls += 1; + return summaryResponse(); + }, + presend: { + baseState: smallState, + pendingUserText: "", + composeAppliedState: (state) => state, + }, + }); + + const withoutForce = await controller.compactManually({ + force: false, + budgetContext: conversationState.buildRequestContext(smallState), + }); + assert.equal(withoutForce, "skipped"); + assert.equal(completeCalls, 0); + + const forced = await controller.compactManually({ + force: true, + budgetContext: conversationState.buildRequestContext(smallState), + }); + assert.equal(forced, "compacted"); + assert.equal(completeCalls, 1); + + const statuses = recorder.byKind("publishStatus").map(([, status]) => status); + assert.equal(statuses[0].phase, "running"); + assert.equal(statuses[0].trigger, "manual"); + assert.equal(statuses.at(-1).phase, "completed"); + assert.equal(statuses.at(-1).trigger, "manual"); +}); + +test("compactManually treats persist false as failed", async () => { + const controller = new CompactionController(); + const smallState = conversationState.createConversationStateFromContext({ + systemPrompt: "sys", + messages: [ + user("please fix src/app.ts", 1), + user("continue with src/app.ts", 2), + user("check src/app.ts again", 3), + assistantWithUsage("working", 2_000, 4), + ], + }); + const events = []; + let completeCalls = 0; + const cancellation = cancellationModule.createTurnCancellation(); + controller.bindTurn({ + providerId: "anthropic", + model: "claude-x", + runtime: { + baseUrl: "https://example", + apiKey: "k", + modelConfig: { contextWindow: 200_000, maxOutputToken: 32_000 }, + }, + cancellation, + sinks: { + applyState: (state) => events.push(["applyState", state]), + publishStatus: (status) => events.push(["publishStatus", status]), + setBridgeToolStatus: (text, isCompaction) => events.push(["bridge", text, isCompaction]), + queueCheckpoint: (state) => events.push(["queueCheckpoint", state]), + persist: async (state) => { + events.push(["persist", state]); + return false; + }, + }, + complete: async () => { + completeCalls += 1; + return summaryResponse(); + }, + buildPreparedContext: (state) => conversationState.buildRequestContext(state), + buildResumeContext: (state, resumeMessage) => { + const context = conversationState.buildRequestContext(state); + return resumeMessage + ? { ...context, messages: [...context.messages, resumeMessage] } + : context; + }, + presend: { + baseState: smallState, + pendingUserText: "", + composeAppliedState: (state) => state, + }, + }); + + const result = await controller.compactManually({ + force: true, + budgetContext: conversationState.buildRequestContext(smallState), + }); + + assert.equal(result, "failed"); + assert.equal(completeCalls, 1); + assert.equal(events.filter((e) => e[0] === "persist").length, 1); + assert.equal(events.filter((e) => e[0] === "queueCheckpoint").length, 0); + const phases = events.filter((e) => e[0] === "publishStatus").map(([, s]) => s.phase); + assert.ok(phases.includes("failed")); + assert.ok(!phases.includes("completed")); +}); + +test("compactManually prune fallback returns compacted after persist succeeds", async () => { + const controller = new CompactionController(); + // Large tool output before recent user turns so prune can apply. + const state = conversationState.createConversationStateFromContext({ + systemPrompt: "sys", + messages: [ + user("please fix src/app.ts", 1), + toolResultBig(200_000, 2), + user("continue with src/app.ts", 3), + user("check src/app.ts again", 4), + assistantWithUsage("working on src/app.ts", 190_000, 5), + ], + }); + const events = []; + const cancellation = cancellationModule.createTurnCancellation(); + controller.bindTurn({ + providerId: "anthropic", + model: "claude-x", + runtime: { + baseUrl: "https://example", + apiKey: "k", + modelConfig: { contextWindow: 200_000, maxOutputToken: 32_000 }, + }, + cancellation, + sinks: { + applyState: (next) => events.push(["applyState", next]), + publishStatus: (status) => events.push(["publishStatus", status]), + setBridgeToolStatus: (text, isCompaction) => events.push(["bridge", text, isCompaction]), + queueCheckpoint: (next) => events.push(["queueCheckpoint", next]), + persist: async (next) => { + events.push(["persist", next]); + return true; + }, + }, + complete: async () => { + throw new Error("invalid api key"); + }, + buildPreparedContext: (next) => conversationState.buildRequestContext(next), + buildResumeContext: (next, resumeMessage) => { + const context = conversationState.buildRequestContext(next); + return resumeMessage + ? { ...context, messages: [...context.messages, resumeMessage] } + : context; + }, + presend: { + baseState: state, + pendingUserText: "", + composeAppliedState: (next) => next, + }, + }); + + const result = await controller.compactManually({ + force: true, + budgetContext: conversationState.buildRequestContext(state), + }); + + assert.equal(result, "compacted"); + const kinds = events.map((e) => e[0]); + const persistIndex = kinds.indexOf("persist"); + const applyIndex = kinds.indexOf("applyState"); + assert.ok(persistIndex >= 0, "persist must run"); + assert.ok(applyIndex >= 0, "apply must run"); + assert.ok(persistIndex < applyIndex, "persist before apply on prune fallback"); + const phases = events.filter((e) => e[0] === "publishStatus").map(([, s]) => s.phase); + assert.ok(phases.includes("completed")); + assert.ok(!phases.includes("failed")); +}); + +test("compactManually prune fallback does not apply when persist fails", async () => { + const controller = new CompactionController(); + const state = conversationState.createConversationStateFromContext({ + systemPrompt: "sys", + messages: [ + user("please fix src/app.ts", 1), + toolResultBig(200_000, 2), + user("continue with src/app.ts", 3), + user("check src/app.ts again", 4), + assistantWithUsage("working on src/app.ts", 190_000, 5), + ], + }); + const events = []; + const cancellation = cancellationModule.createTurnCancellation(); + controller.bindTurn({ + providerId: "anthropic", + model: "claude-x", + runtime: { + baseUrl: "https://example", + apiKey: "k", + modelConfig: { contextWindow: 200_000, maxOutputToken: 32_000 }, + }, + cancellation, + sinks: { + applyState: (next) => events.push(["applyState", next]), + publishStatus: (status) => events.push(["publishStatus", status]), + setBridgeToolStatus: (text, isCompaction) => events.push(["bridge", text, isCompaction]), + queueCheckpoint: (next) => events.push(["queueCheckpoint", next]), + persist: async (next) => { + events.push(["persist", next]); + return false; + }, + }, + complete: async () => { + throw new Error("invalid api key"); + }, + buildPreparedContext: (next) => conversationState.buildRequestContext(next), + buildResumeContext: (next, resumeMessage) => { + const context = conversationState.buildRequestContext(next); + return resumeMessage + ? { ...context, messages: [...context.messages, resumeMessage] } + : context; + }, + presend: { + baseState: state, + pendingUserText: "", + composeAppliedState: (next) => next, + }, + }); + + const result = await controller.compactManually({ + force: true, + budgetContext: conversationState.buildRequestContext(state), + }); + + assert.equal(result, "failed"); + assert.equal(events.filter((e) => e[0] === "applyState").length, 0); + assert.ok(events.filter((e) => e[0] === "persist").length >= 1); +}); + +test("compactManually skips when unbound or empty", async () => { + const controller = new CompactionController(); + assert.equal( + await controller.compactManually({ + force: true, + budgetContext: { systemPrompt: "sys", messages: [] }, + }), + "skipped", + ); + + const emptyState = conversationState.createConversationStateFromContext({ + systemPrompt: "sys", + messages: [], + }); + const { recorder } = bindController(controller, { + complete: async () => summaryResponse(), + presend: { + baseState: emptyState, + pendingUserText: "", + composeAppliedState: (state) => state, + }, + }); + const result = await controller.compactManually({ + force: true, + budgetContext: conversationState.buildRequestContext(emptyState), + }); + assert.equal(result, "skipped"); + assert.equal(recorder.events.length, 0); +}); diff --git a/crates/agent-gui/test/chat/compaction-policy.test.mjs b/crates/agent-gui/test/chat/compaction-policy.test.mjs index 5d6a071f0..ff03f0f88 100644 --- a/crates/agent-gui/test/chat/compaction-policy.test.mjs +++ b/crates/agent-gui/test/chat/compaction-policy.test.mjs @@ -99,6 +99,13 @@ test("decideCompaction covers every reason", () => { "threshold-exceeded", ); + // Manual force bypasses threshold/cooldown but still respects empty/in-flight/disabled. + const forced = decide({ totalTokens: 10, force: true }); + assert.equal(forced.reason, "manual"); + assert.equal(forced.shouldCompact, true); + assert.equal(decide({ force: true, activeMessageCount: 0 }).reason, "no-active-messages"); + assert.equal(decide({ force: true, inFlight: true }).reason, "in-flight"); + const fire = decide({ totalTokens: 199_000 }); assert.equal(fire.shouldCompact, true); assert.equal(fire.reason, "threshold-exceeded"); diff --git a/crates/agent-gui/test/chat/context-usage.test.mjs b/crates/agent-gui/test/chat/context-usage.test.mjs new file mode 100644 index 000000000..6b86585d3 --- /dev/null +++ b/crates/agent-gui/test/chat/context-usage.test.mjs @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const mod = loader.loadModule("src/lib/chat/contextUsage.ts"); + +const { + estimateContextUsage, + extractObservedTotalTokens, + formatTokenCount, + resolveContextUsageLevel, + CONTEXT_USAGE_WARN_RATIO, + CONTEXT_USAGE_CRITICAL_RATIO, +} = mod; + +test("formatTokenCount scales units", () => { + assert.equal(formatTokenCount(0), "0"); + assert.equal(formatTokenCount(999), "999"); + assert.equal(formatTokenCount(1_200), "1.2k"); + assert.equal(formatTokenCount(12_400), "12k"); + assert.equal(formatTokenCount(1_500_000), "1.5M"); +}); + +test("resolveContextUsageLevel thresholds", () => { + assert.equal(resolveContextUsageLevel(null), "unknown"); + assert.equal(resolveContextUsageLevel(0), "ok"); + assert.equal(resolveContextUsageLevel(CONTEXT_USAGE_WARN_RATIO - 0.01), "ok"); + assert.equal(resolveContextUsageLevel(CONTEXT_USAGE_WARN_RATIO), "warn"); + assert.equal(resolveContextUsageLevel(CONTEXT_USAGE_CRITICAL_RATIO), "critical"); +}); + +test("estimateContextUsage empty state", () => { + const snap = estimateContextUsage({ messages: [], contextWindow: 200_000 }); + assert.equal(snap.usedTokens, 0); + assert.equal(snap.contextWindow, 200_000); + assert.equal(snap.ratio, 0); + assert.equal(snap.level, "ok"); + assert.equal(snap.percentLabel, "0%"); +}); + +test("estimateContextUsage without context window", () => { + const snap = estimateContextUsage({ + messages: [{ role: "user", content: "hello world", timestamp: 1 }], + contextWindow: 0, + }); + assert.ok(snap.usedTokens > 0); + assert.equal(snap.ratio, null); + assert.equal(snap.level, "unknown"); + assert.equal(snap.percentLabel, null); +}); + +test("estimateContextUsage marks critical near full window", () => { + // ~4 chars/token → 50k chars ≈ 12.5k tokens against a 12k window → critical. + const longText = "x".repeat(50_000); + const snap = estimateContextUsage({ + messages: [{ role: "user", content: longText, timestamp: 1 }], + contextWindow: 12_000, + }); + assert.equal(snap.level, "critical"); + assert.ok(snap.ratio != null && snap.ratio >= CONTEXT_USAGE_CRITICAL_RATIO); +}); + +test("estimateContextUsage anchors on observed usage.totalTokens", () => { + assert.equal( + extractObservedTotalTokens({ + role: "assistant", + content: "hi", + usage: { totalTokens: 12_500 }, + }), + 12_500, + ); + const snap = estimateContextUsage({ + messages: [ + { role: "user", content: "before", timestamp: 1 }, + { + role: "assistant", + content: "anchor", + usage: { totalTokens: 10_000 }, + timestamp: 2, + }, + { role: "user", content: "x".repeat(400), timestamp: 3 }, + ], + // Would double-count if naively summed with system after an observation. + systemPrompt: "sys that is already in observed usage", + contextWindow: 200_000, + }); + // 10000 + ~100 tokens for the follow-up user message (400 chars / 4 + envelope) + assert.ok(snap.usedTokens >= 10_000); + assert.ok(snap.usedTokens < 10_200); +}); diff --git a/crates/agent-gui/test/chat/conversation-export.test.mjs b/crates/agent-gui/test/chat/conversation-export.test.mjs new file mode 100644 index 000000000..4c410218e --- /dev/null +++ b/crates/agent-gui/test/chat/conversation-export.test.mjs @@ -0,0 +1,260 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const mod = loader.loadModule("src/lib/chat/conversationExport.ts"); + +const { + extractMessagePlainText, + collectExportMessages, + collectMessagesFromTranscriptRows, + collectActiveContextFromTranscriptRows, + extractUiRoundText, + conversationToMarkdown, + buildConversationExportFilename, + sumConversationCost, + formatUsdCost, +} = mod; + +test("extractMessagePlainText handles string and block content", () => { + assert.equal(extractMessagePlainText({ role: "user", content: " hi " }), "hi"); + assert.equal( + extractMessagePlainText({ + role: "assistant", + content: [ + { type: "thinking", thinking: "secret" }, + { type: "text", text: "answer" }, + { type: "toolCall", name: "Read" }, + ], + }), + "answer\n[tool:Read]", + ); +}); + +test("extractMessagePlainText prefers display content and attachments", () => { + assert.equal( + extractMessagePlainText({ + role: "user", + content: "Please read the attached file.\n\nFile: uploads/a.ts\n...", + liveAgentDisplayContent: "please check", + liveAgentAttachments: [{ fileName: "a.ts", relativePath: "uploads/a.ts" }], + }), + "please check\n[attachment:a.ts]", + ); + assert.equal( + extractMessagePlainText({ + role: "user", + content: "internal file prompt", + liveAgentDisplayContent: "", + liveAgentAttachments: [{ fileName: "shot.png" }], + }), + "[attachment:shot.png]", + ); +}); + +test("collectExportMessages skips tool results and empty rows", () => { + const rows = collectExportMessages([ + { role: "user", content: "q" }, + { role: "toolResult", content: [{ type: "text", text: "noise" }] }, + { role: "assistant", content: [{ type: "text", text: "a" }] }, + { role: "assistant", content: [] }, + ]); + assert.deepEqual(rows, [ + { role: "user", text: "q" }, + { role: "assistant", text: "a" }, + ]); +}); + +test("conversationToMarkdown formats title and turns", () => { + const md = conversationToMarkdown({ + title: "Demo", + exportedAt: new Date("2026-01-02T03:04:05.000Z"), + messages: [ + { role: "user", text: "hello" }, + { role: "assistant", text: "world" }, + ], + }); + assert.match(md, /^# Demo\n/); + assert.match(md, /Exported: 2026-01-02T03:04:05.000Z/); + assert.match(md, /## User\n\nhello/); + assert.match(md, /## Assistant\n\nworld/); +}); + +test("conversationToMarkdown empty export", () => { + const md = conversationToMarkdown({ title: "", messages: [] }); + assert.match(md, /# Conversation/); + assert.match(md, /_No messages\._/); +}); + +test("buildConversationExportFilename sanitizes title", () => { + const name = buildConversationExportFilename('a/b:c*', new Date("2026-07-18T12:00:00.000Z")); + assert.match(name, /^a b c-2026-07-18T12-00-00\.md$/); +}); + +test("sumConversationCost aggregates assistant usage", () => { + const summary = sumConversationCost([ + { role: "user", content: "q" }, + { + role: "assistant", + content: [{ type: "text", text: "a" }], + usage: { cost: { total: 0.0123 } }, + }, + { + role: "assistant", + content: [{ type: "text", text: "b" }], + usage: { cost: { total: 0.004 } }, + }, + ]); + assert.equal(summary.hasCost, true); + assert.ok(Math.abs(summary.total - 0.0163) < 1e-9); + assert.match(formatUsdCost(summary.total), /^\$0\.0163$/); +}); + +test("extractUiRoundText reads blocks and skips thinking by default", () => { + const text = extractUiRoundText({ + blocks: [ + { kind: "thinking", text: "secret" }, + { kind: "text", text: "hello" }, + { kind: "tool", text: "ignored" }, + { kind: "text", text: "world" }, + ], + }); + assert.equal(text, "hello\nworld"); +}); + +test("collectMessagesFromTranscriptRows uses blocks and meta.usage", () => { + const messages = collectMessagesFromTranscriptRows([ + { kind: "user", text: "q" }, + { + kind: "assistant", + rounds: [ + { + blocks: [{ kind: "text", text: "answer" }], + meta: { usage: { cost: { total: 0.02 } } }, + }, + ], + }, + { kind: "checkpoint", content: "summary body" }, + ]); + assert.deepEqual( + messages.map((m) => ({ role: m.role, content: m.content })), + [ + { role: "user", content: "q" }, + { role: "assistant", content: "answer" }, + { role: "assistant", content: "summary body" }, + ], + ); + const cost = sumConversationCost(messages); + assert.equal(cost.hasCost, true); + assert.ok(Math.abs(cost.total - 0.02) < 1e-9); +}); + +test("collectMessagesFromTranscriptRows keeps attachment-only users and usage-only assistants", () => { + const messages = collectMessagesFromTranscriptRows([ + { + kind: "user", + text: "", + attachments: [{ fileName: "img.png", relativePath: "uploads/img.png" }], + }, + { + kind: "assistant", + rounds: [ + { + blocks: [{ kind: "tool", item: { toolCall: { name: "Read" } } }], + meta: { usage: { cost: { total: 0.01 } } }, + }, + { + blocks: [{ kind: "text", text: "done" }], + meta: { usage: { cost: { total: 0.02 } } }, + }, + ], + }, + ]); + assert.equal(messages[0].role, "user"); + assert.match(messages[0].content, /attachment:img\.png/); + // Usage-only tool round keeps empty content for cost, not a fake placeholder. + assert.equal(messages[1].content, ""); + assert.equal(messages[2].content, "done"); + const cost = sumConversationCost(messages); + assert.ok(Math.abs(cost.total - 0.03) < 1e-9); + const exported = collectExportMessages(messages); + assert.deepEqual( + exported.map((m) => m.text), + ["[attachment:img.png]", "done"], + ); +}); + +test("collectActiveContextFromTranscriptRows attaches usage once after tool rounds", () => { + const active = collectActiveContextFromTranscriptRows([ + { + kind: "assistant", + rounds: [ + { + blocks: [ + { + kind: "tool", + item: { + toolCall: { name: "Read", arguments: { path: "a.ts" } }, + toolResult: { content: "x".repeat(400) }, + }, + }, + { kind: "text", text: "done" }, + ], + meta: { usage: { totalTokens: 5_000 } }, + }, + ], + }, + ]); + const withUsage = active.messages.filter((m) => m.usage != null); + assert.equal(withUsage.length, 1, "usage should attach once, not after toolResult as a second anchor"); + // Tool result must remain after the usage-bearing assistant so meter can count it + // when estimating post-anchor deltas (or when no earlier usage exists). + const toolIdx = active.messages.findIndex((m) => m.role === "toolResult"); + const usageIdx = active.messages.findIndex((m) => m.usage != null); + assert.ok(toolIdx >= 0); + assert.ok(usageIdx >= 0); +}); + +test("collectActiveContextFromTranscriptRows drops pre-checkpoint history and keeps tools", () => { + const active = collectActiveContextFromTranscriptRows([ + { kind: "user", text: "old q" }, + { + kind: "assistant", + rounds: [{ blocks: [{ kind: "text", text: "old answer" }] }], + }, + { kind: "checkpoint", content: "compacted summary" }, + { kind: "user", text: "new q" }, + { + kind: "assistant", + rounds: [ + { + blocks: [ + { kind: "thinking", text: "plan" }, + { + kind: "tool", + item: { + toolCall: { name: "Read", arguments: { path: "a.ts" } }, + toolResult: { content: "file body" }, + }, + }, + { kind: "text", text: "done" }, + ], + }, + ], + }, + ]); + assert.equal(active.summaryText, "compacted summary"); + assert.equal(active.messages[0].role, "user"); + assert.equal(active.messages[0].content, "new q"); + const roles = active.messages.map((m) => m.role); + assert.ok(roles.includes("toolResult")); + assert.ok(roles.includes("assistant")); + const hasThinking = active.messages.some( + (m) => + Array.isArray(m.content) && + m.content.some((block) => block && block.type === "thinking" && block.thinking === "plan"), + ); + assert.equal(hasThinking, true); + assert.ok(!JSON.stringify(active.messages).includes("old answer")); +}); From 873ea9ff6a7bf94a5e47b440fb57e8a228304c2a Mon Sep 17 00:00:00 2001 From: GreyGunG Date: Mon, 20 Jul 2026 13:01:27 +0800 Subject: [PATCH 2/2] =?UTF-8?q?style:=20biome=20=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E5=8C=96=20Composer=20=E7=9B=B8=E5=85=B3=E6=94=B9=E5=8A=A8?= =?UTF-8?q?=E4=BB=A5=E9=80=9A=E8=BF=87=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 GUI / WebUI 的 biome format 与 import 排序错误,使 CI lint 通过。 --- .../agent-gateway/web/src/app/GatewayApp.tsx | 36 ++++++++----------- .../web/src/app/hooks/usePendingUploads.ts | 8 ++--- .../web/src/components/icons.tsx | 2 +- crates/agent-gateway/web/src/i18n/config.ts | 6 ++-- .../web/src/lib/chat/conversationExport.ts | 13 +++++-- .../web/src/pages/chat/ChatComposerBar.tsx | 5 +-- crates/agent-gui/src/i18n/config.ts | 6 ++-- .../src/lib/chat/conversationExport.ts | 13 +++++-- crates/agent-gui/src/pages/ChatPage.tsx | 9 ++--- .../src/pages/chat/hooks/usePendingUploads.ts | 5 +-- 10 files changed, 49 insertions(+), 54 deletions(-) diff --git a/crates/agent-gateway/web/src/app/GatewayApp.tsx b/crates/agent-gateway/web/src/app/GatewayApp.tsx index 0a2d10dd9..f151e48de 100644 --- a/crates/agent-gateway/web/src/app/GatewayApp.tsx +++ b/crates/agent-gateway/web/src/app/GatewayApp.tsx @@ -26,6 +26,18 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { LocaleContext, t as translate } from "@/i18n"; import type { ChatHistorySummary } from "@/lib/chat/chatHistory"; import { buildModelOptions } from "@/lib/chat/chatPageHelpers"; +import { captureDisplayFrameToFile, readClipboardImageFiles } from "@/lib/chat/clipboardCapture"; +import { type ContextUsageSnapshot, estimateContextUsage } from "@/lib/chat/contextUsage"; +import { + buildConversationExportFilename, + collectActiveContextFromTranscriptRows, + collectExportMessages, + collectMessagesFromTranscriptRows, + conversationToMarkdown, + downloadTextFile, + formatUsdCost, + sumConversationCost, +} from "@/lib/chat/conversationExport"; import type { HistoryMessageRef } from "@/lib/chat/conversationState"; import type { CodeMentionReference } from "@/lib/chat/mentionReferences"; import { createActivityStore } from "@/lib/chat/stream/activityStore"; @@ -97,24 +109,6 @@ import { type WorkspaceProject, workspaceProjectPathKey, } from "@/lib/settings"; -import { - captureDisplayFrameToFile, - readClipboardImageFiles, -} from "@/lib/chat/clipboardCapture"; -import { - estimateContextUsage, - type ContextUsageSnapshot, -} from "@/lib/chat/contextUsage"; -import { - buildConversationExportFilename, - collectActiveContextFromTranscriptRows, - collectExportMessages, - collectMessagesFromTranscriptRows, - conversationToMarkdown, - downloadTextFile, - formatUsdCost, - sumConversationCost, -} from "@/lib/chat/conversationExport"; import { createUuid } from "@/lib/shared/id"; import { mergeAlwaysEnabledSkillNames } from "@/lib/skills"; import { terminalSessionBelongsToProject } from "@/lib/terminal/sessionStore"; @@ -3966,8 +3960,7 @@ export default function GatewayApp() { const recomputeMeters = () => { if (cancelled) return; const rows = - transcriptStoreRegistry.peek(displayedConversationId)?.getSnapshot().rows ?? - transcriptRows; + transcriptStoreRegistry.peek(displayedConversationId)?.getSnapshot().rows ?? transcriptRows; // Cost (export flattening is fine — export itself is on-demand). const costMessages = collectMessagesFromTranscriptRows(rows); @@ -4090,8 +4083,7 @@ export default function GatewayApp() { return; } rows = - transcriptStoreRegistry.peek(exportConversationId)?.getSnapshot().rows ?? - transcriptRows; + transcriptStoreRegistry.peek(exportConversationId)?.getSnapshot().rows ?? transcriptRows; } else if (getDisplayedConversationId().trim() !== exportConversationId) { setChatError(translate("chat.toolbar.exportIncomplete", settings.locale)); return; diff --git a/crates/agent-gateway/web/src/app/hooks/usePendingUploads.ts b/crates/agent-gateway/web/src/app/hooks/usePendingUploads.ts index 2a7290170..55250b3c9 100644 --- a/crates/agent-gateway/web/src/app/hooks/usePendingUploads.ts +++ b/crates/agent-gateway/web/src/app/hooks/usePendingUploads.ts @@ -157,10 +157,7 @@ export function usePendingUploads(params: UsePendingUploadsParams) { }, [displayedConversationId]); const handleImportReadableFiles = useCallback( - async ( - filesToImport: File[], - lock?: { conversationId?: string; workdir?: string } | null, - ) => { + async (filesToImport: File[], lock?: { conversationId?: string; workdir?: string } | null) => { if (filesToImport.length === 0) { return; } @@ -172,8 +169,7 @@ export function usePendingUploads(params: UsePendingUploadsParams) { setChatError(translate("chat.upload.onlyInTools", locale)); return; } - const workdir = - lock?.workdir?.trim() || displayedConversationWorkdirRef.current.trim(); + const workdir = lock?.workdir?.trim() || displayedConversationWorkdirRef.current.trim(); if (!workdir) { setChatError(translate("chat.upload.requireWorkdir", locale)); return; diff --git a/crates/agent-gateway/web/src/components/icons.tsx b/crates/agent-gateway/web/src/components/icons.tsx index b1d80f9d6..06d67dd43 100644 --- a/crates/agent-gateway/web/src/components/icons.tsx +++ b/crates/agent-gateway/web/src/components/icons.tsx @@ -67,8 +67,8 @@ import Loader2Source from "~icons/lucide/loader-circle"; import LockSource from "~icons/lucide/lock"; import LogOutSource from "~icons/lucide/log-out"; import MessageSquareSource from "~icons/lucide/message-square"; -import Minimize2Source from "~icons/lucide/minimize-2"; import MessageSquareTextSource from "~icons/lucide/message-square-text"; +import Minimize2Source from "~icons/lucide/minimize-2"; import MinusSource from "~icons/lucide/minus"; import MonitorSmartphoneSource from "~icons/lucide/monitor-smartphone"; import MoonSource from "~icons/lucide/moon"; diff --git a/crates/agent-gateway/web/src/i18n/config.ts b/crates/agent-gateway/web/src/i18n/config.ts index c74c6aa00..6a9ce8957 100644 --- a/crates/agent-gateway/web/src/i18n/config.ts +++ b/crates/agent-gateway/web/src/i18n/config.ts @@ -1916,7 +1916,8 @@ export const translations: Record> = { "chat.toolbar.contextUsageUnknownWindow": "About {used} used (context window not set)", "chat.toolbar.compact": "Compact context", "chat.toolbar.compactRunning": "Compacting…", - "chat.toolbar.compactConfirm": "Context is not near the limit yet. Compact anyway? This calls the model to summarize.", + "chat.toolbar.compactConfirm": + "Context is not near the limit yet. Compact anyway? This calls the model to summarize.", "chat.toolbar.compactConfirmAction": "Compact anyway", "chat.toolbar.compactDesktopOnly": "Manual compact is only available on the desktop app", "chat.toolbar.compactUnavailable": "Compaction unavailable", @@ -1943,7 +1944,8 @@ export const translations: Record> = { "chat.toolbar.exportMarkdown": "Export Markdown", "chat.toolbar.exportSuccess": "Conversation exported", "chat.toolbar.exportFailed": "Export failed", - "chat.toolbar.exportIncomplete": "Full history is not loaded yet; cannot export the whole conversation. Try again later.", + "chat.toolbar.exportIncomplete": + "Full history is not loaded yet; cannot export the whole conversation. Try again later.", "chat.recentConversation": "Conversations", "chat.workspaceSection": "Workspaces", "chat.workspaceCreate": "New workspace", diff --git a/crates/agent-gateway/web/src/lib/chat/conversationExport.ts b/crates/agent-gateway/web/src/lib/chat/conversationExport.ts index 711a9f2d6..7c469eecf 100644 --- a/crates/agent-gateway/web/src/lib/chat/conversationExport.ts +++ b/crates/agent-gateway/web/src/lib/chat/conversationExport.ts @@ -94,7 +94,9 @@ export function extractUiRoundText( return parts.join("\n").trim(); } -function formatAttachmentLines(attachments: readonly TranscriptExportAttachment[] | undefined): string { +function formatAttachmentLines( + attachments: readonly TranscriptExportAttachment[] | undefined, +): string { if (!Array.isArray(attachments) || attachments.length === 0) return ""; const lines: string[] = []; for (const file of attachments) { @@ -134,7 +136,10 @@ export function collectMessagesFromTranscriptRows( const attachments = Array.isArray(row.attachments) ? (row.attachments as readonly TranscriptExportAttachment[]) : undefined; - const content = formatUserExportText(typeof row.text === "string" ? row.text : "", attachments); + const content = formatUserExportText( + typeof row.text === "string" ? row.text : "", + attachments, + ); // Keep attachment-only turns (empty text) in the export. if (content) out.push({ role: "user", content }); continue; @@ -307,7 +312,9 @@ const ATTACHMENTS_FIELD = "liveAgentAttachments"; function attachmentsFromMessage(message: Record): TranscriptExportAttachment[] { const raw = message[ATTACHMENTS_FIELD]; if (!Array.isArray(raw)) return []; - return raw.filter((item): item is TranscriptExportAttachment => !!item && typeof item === "object"); + return raw.filter( + (item): item is TranscriptExportAttachment => !!item && typeof item === "object", + ); } /** Extract plain text from common LiveAgent / pi-ai message shapes. */ diff --git a/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx b/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx index 4e7481420..2469d5867 100644 --- a/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx +++ b/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx @@ -61,10 +61,7 @@ import { } from "../../components/ui/select"; import { useLocale } from "../../i18n"; import type { ContextUsageSnapshot } from "../../lib/chat/contextUsage"; -import { - formatUploadedFileSize, - type PendingUploadedFile, -} from "../../lib/chat/uploadedFiles"; +import { formatUploadedFileSize, type PendingUploadedFile } from "../../lib/chat/uploadedFiles"; import type { GitClient } from "../../lib/git/types"; import { type ChatRuntimeControls, diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index 928c7a460..3b29791f4 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -2007,7 +2007,8 @@ export const translations: Record> = { "chat.toolbar.contextUsageUnknownWindow": "About {used} used (context window not set)", "chat.toolbar.compact": "Compact context", "chat.toolbar.compactRunning": "Compacting…", - "chat.toolbar.compactConfirm": "Context is not near the limit yet. Compact anyway? This calls the model to summarize.", + "chat.toolbar.compactConfirm": + "Context is not near the limit yet. Compact anyway? This calls the model to summarize.", "chat.toolbar.compactConfirmAction": "Compact anyway", "chat.toolbar.compactDesktopOnly": "Manual compact is only available on the desktop app", "chat.toolbar.compactUnavailable": "Compaction unavailable", @@ -2034,7 +2035,8 @@ export const translations: Record> = { "chat.toolbar.exportMarkdown": "Export Markdown", "chat.toolbar.exportSuccess": "Conversation exported", "chat.toolbar.exportFailed": "Export failed", - "chat.toolbar.exportIncomplete": "Full history is not loaded yet; cannot export the whole conversation. Try again later.", + "chat.toolbar.exportIncomplete": + "Full history is not loaded yet; cannot export the whole conversation. Try again later.", "chat.memoryExtraction.done": "Memory updated.", "chat.memoryExtraction.noop": "No memory updates needed this turn.", "chat.memoryExtraction.partial": diff --git a/crates/agent-gui/src/lib/chat/conversationExport.ts b/crates/agent-gui/src/lib/chat/conversationExport.ts index fb3693ec4..43d078374 100644 --- a/crates/agent-gui/src/lib/chat/conversationExport.ts +++ b/crates/agent-gui/src/lib/chat/conversationExport.ts @@ -94,7 +94,9 @@ export function extractUiRoundText( return parts.join("\n").trim(); } -function formatAttachmentLines(attachments: readonly TranscriptExportAttachment[] | undefined): string { +function formatAttachmentLines( + attachments: readonly TranscriptExportAttachment[] | undefined, +): string { if (!Array.isArray(attachments) || attachments.length === 0) return ""; const lines: string[] = []; for (const file of attachments) { @@ -134,7 +136,10 @@ export function collectMessagesFromTranscriptRows( const attachments = Array.isArray(row.attachments) ? (row.attachments as readonly TranscriptExportAttachment[]) : undefined; - const content = formatUserExportText(typeof row.text === "string" ? row.text : "", attachments); + const content = formatUserExportText( + typeof row.text === "string" ? row.text : "", + attachments, + ); // Keep attachment-only turns (empty text) in the export. if (content) out.push({ role: "user", content }); continue; @@ -314,7 +319,9 @@ const ATTACHMENTS_FIELD = "liveAgentAttachments"; function attachmentsFromMessage(message: Record): TranscriptExportAttachment[] { const raw = message[ATTACHMENTS_FIELD]; if (!Array.isArray(raw)) return []; - return raw.filter((item): item is TranscriptExportAttachment => !!item && typeof item === "object"); + return raw.filter( + (item): item is TranscriptExportAttachment => !!item && typeof item === "object", + ); } /** Extract plain text from common LiveAgent / pi-ai message shapes. */ diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index 9cd4a2eb2..1bade4c97 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -43,6 +43,7 @@ import { getAutomationState } from "../lib/automation"; import { createHookRunScope } from "../lib/automation/hookRunner"; import { captureDisplayFrameToFile, readClipboardImageFiles } from "../lib/chat/clipboardCapture"; import type { CompactionStatus } from "../lib/chat/compaction/types"; +import { sanitizeMessagesForContinuation } from "../lib/chat/context/requestContextSanitizer"; import { estimateContextUsage } from "../lib/chat/contextUsage"; import { buildPersistableMessagesFromSnapshot, @@ -59,7 +60,6 @@ import { type RenderTimelineItem, truncateConversationFromMessage, } from "../lib/chat/conversation/conversationState"; -import { sanitizeMessagesForContinuation } from "../lib/chat/context/requestContextSanitizer"; import type { LiveTranscriptStore } from "../lib/chat/conversation/liveTranscriptStore"; import { createConversationHookLifecycle, @@ -275,7 +275,6 @@ import { McpHubPage } from "./mcp-hub/McpHubPage"; import type { SectionId } from "./settings/types"; import { SkillsHubPage } from "./skills-hub/SkillsHubPage"; - const WorkspaceCodeEditorOverlay = lazy(async () => { await preparePreferredMonacoNlsLocale(); const module = await import("../components/workspace-editor/WorkspaceCodeEditorOverlay"); @@ -5517,8 +5516,7 @@ export function ChatPage(props: ChatPageProps) { const messages = collectExportMessages( entry?.state?.segments?.flatMap((segment) => segment.messages) ?? allConversationMessages, ); - const title = - sidebarStore.peek(conversationId)?.title?.trim() || t("chat.pendingTitle"); + const title = sidebarStore.peek(conversationId)?.title?.trim() || t("chat.pendingTitle"); const markdown = conversationToMarkdown({ title, messages }); downloadTextFile(buildConversationExportFilename(title), markdown); addNotify("warning", t("chat.toolbar.exportSuccess")); @@ -5711,9 +5709,6 @@ export function ChatPage(props: ChatPageProps) { if (persistedCwd) return persistedCwd; return displayedConversationWorkdir || undefined; })(); - - - const isCompactionRunning = compactionStatus.phase === "running"; const isConversationHydrating = hydratingConversationId === currentConversationId; diff --git a/crates/agent-gui/src/pages/chat/hooks/usePendingUploads.ts b/crates/agent-gui/src/pages/chat/hooks/usePendingUploads.ts index ec9d3b6c7..8763b7051 100644 --- a/crates/agent-gui/src/pages/chat/hooks/usePendingUploads.ts +++ b/crates/agent-gui/src/pages/chat/hooks/usePendingUploads.ts @@ -319,10 +319,7 @@ export function usePendingUploads(params: UsePendingUploadsParams) { ); const importReadableFiles = useCallback( - async ( - files: File[], - lock?: { conversationId: string; workdir: string } | null, - ) => { + async (files: File[], lock?: { conversationId: string; workdir: string } | null) => { if (files.length === 0) return; let lockedTarget: UploadTarget | null = null; if (lock?.conversationId?.trim() && lock.workdir?.trim()) {