From 55088666ea0430ac6c56bf0fab70d6365e36daaf Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 25 Jul 2026 18:55:05 -0700 Subject: [PATCH] feat(flow-chat): add full-process / result-only scopes to copy and session export Copying a dialog turn always included thinking and tool calls, so archiving a clean answer meant hand-editing the clipboard. Sessions had no Markdown export at all. - Copy dialog (round footer + context menu) now offers "full process" and "result only". - Session list menu gains "Export as Markdown", with the same two scopes at a second menu level. Turns are read from persistence, so a partially hydrated history session still exports in full. - Both paths share one formatter that normalizes the runtime and persisted turn shapes; the extraction logic previously existed in three near-identical copies, one of which was dead code (flow_chat/hooks/useCopyDialog.ts). The persisted path also gains behavior the old copy lacked: items are ordered by orderIndex, and superseded retry attempts are dropped. Tool payloads are wrapped in width-adjusted code fences so content containing ``` cannot break out of the block. Fixes #1657 Fixes #1506 Fixes #1495 --- .../sections/sessions/SessionsSection.tsx | 227 ++++++++--- .../components/modern/ModelRoundItem.scss | 44 ++ .../components/modern/ModelRoundItem.tsx | 183 ++++----- .../modern/useFlowChatCopyDialog.ts | 89 +--- src/web-ui/src/flow_chat/hooks/index.ts | 1 - .../src/flow_chat/hooks/useCopyDialog.ts | 53 --- .../flow-chat-manager/EventHandlerModule.ts | 17 +- .../services/sessionMarkdownExport.test.ts | 80 ++++ .../services/sessionMarkdownExport.ts | 158 ++++++++ .../src/flow_chat/store/FlowChatStore.ts | 18 +- .../CodeReviewReportExportActions.tsx | 11 +- .../utils/dialogTranscriptExport.test.ts | 229 +++++++++++ .../flow_chat/utils/dialogTranscriptExport.ts | 379 ++++++++++++++++++ .../flow_chat/utils/dialogTurnCopy.test.ts | 64 +++ .../src/flow_chat/utils/dialogTurnCopy.ts | 37 ++ .../flow_chat/utils/transcriptExportLabels.ts | 52 +++ .../src/flow_chat/utils/userInputText.ts | 35 ++ src/web-ui/src/locales/en-US/common.json | 3 + src/web-ui/src/locales/en-US/flow-chat.json | 47 ++- src/web-ui/src/locales/zh-CN/common.json | 3 + src/web-ui/src/locales/zh-CN/flow-chat.json | 47 ++- src/web-ui/src/locales/zh-TW/common.json | 3 + src/web-ui/src/locales/zh-TW/flow-chat.json | 47 ++- .../providers/FlowChatMenuProvider.ts | 22 +- .../src/shared/utils/browserDownload.ts | 24 ++ 25 files changed, 1530 insertions(+), 343 deletions(-) delete mode 100644 src/web-ui/src/flow_chat/hooks/useCopyDialog.ts create mode 100644 src/web-ui/src/flow_chat/services/sessionMarkdownExport.test.ts create mode 100644 src/web-ui/src/flow_chat/services/sessionMarkdownExport.ts create mode 100644 src/web-ui/src/flow_chat/utils/dialogTranscriptExport.test.ts create mode 100644 src/web-ui/src/flow_chat/utils/dialogTranscriptExport.ts create mode 100644 src/web-ui/src/flow_chat/utils/dialogTurnCopy.test.ts create mode 100644 src/web-ui/src/flow_chat/utils/dialogTurnCopy.ts create mode 100644 src/web-ui/src/flow_chat/utils/transcriptExportLabels.ts create mode 100644 src/web-ui/src/flow_chat/utils/userInputText.ts create mode 100644 src/web-ui/src/shared/utils/browserDownload.ts diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx index d0abc68143..892287d068 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx @@ -7,7 +7,7 @@ import React, { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { Pencil, Trash2, Check, X, Bot, Code2, ClipboardList, Panda, MoreHorizontal, Loader2, Archive, Clock3, Copy, CircleHelp } from 'lucide-react'; +import { Pencil, Trash2, Check, X, Bot, Code2, ClipboardList, Panda, MoreHorizontal, Loader2, Archive, Clock3, Copy, CircleHelp, FileDown, ChevronLeft } from 'lucide-react'; import { IconButton, Input, Tooltip } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n'; import { flowChatStore } from '../../../../../flow_chat/store/FlowChatStore'; @@ -48,6 +48,8 @@ import type { BackgroundSubagentActivityItem, } from '@/flow_chat/utils/backgroundSubagentActivity'; import { computeFixedPopoverPosition } from '@/shared/utils/fixedPopoverViewport'; +import { exportSessionToMarkdown } from '@/flow_chat/services/sessionMarkdownExport'; +import type { TranscriptExportScope } from '@/flow_chat/utils/dialogTranscriptExport'; import { confirmWarning } from '@/component-library/components/ConfirmDialog/confirmService'; import { notificationService } from '@/shared/notification-system'; import { copyTextToClipboard } from '@/shared/utils/textSelection'; @@ -198,6 +200,9 @@ const SessionsSection: React.FC = ({ }); const [openMenuSessionId, setOpenMenuSessionId] = useState(null); const [sessionMenuPosition, setSessionMenuPosition] = useState<{ top: number; left: number } | null>(null); + /** Second level of the session menu: pick what a Markdown export includes. */ + const [isExportScopeMenu, setIsExportScopeMenu] = useState(false); + const [exportingSessionId, setExportingSessionId] = useState(null); const [runningSessionIds, setRunningSessionIds] = useState>(new Set()); const [scheduledJobsSessionId, setScheduledJobsSessionId] = useState(null); const editInputRef = useRef(null); @@ -467,17 +472,22 @@ const SessionsSection: React.FC = ({ return () => window.removeEventListener('bitfun:session-archived', handler); }, [isVisible, workspacePath, loadMetadataPage]); + const closeSessionMenu = useCallback(() => { + setOpenMenuSessionId(null); + setSessionMenuPosition(null); + setIsExportScopeMenu(false); + }, []); + useEffect(() => { if (!openMenuSessionId) return; const handleOutside = (event: MouseEvent) => { if (!sessionMenuPopoverRef.current?.contains(event.target as Node)) { - setOpenMenuSessionId(null); - setSessionMenuPosition(null); + closeSessionMenu(); } }; document.addEventListener('mousedown', handleOutside); return () => document.removeEventListener('mousedown', handleOutside); - }, [openMenuSessionId]); + }, [closeSessionMenu, openMenuSessionId]); const updateSessionMenuPosition = useCallback(() => { const anchor = sessionMenuAnchorRef.current; @@ -502,6 +512,7 @@ const SessionsSection: React.FC = ({ useEffect(() => { if (!openMenuSessionId) return; + // The second menu level has a different height; re-anchor on switch. updateSessionMenuPosition(); const handleViewportChange = () => updateSessionMenuPosition(); @@ -512,7 +523,7 @@ const SessionsSection: React.FC = ({ window.removeEventListener('resize', handleViewportChange); window.removeEventListener('scroll', handleViewportChange, true); }; - }, [openMenuSessionId, updateSessionMenuPosition]); + }, [isExportScopeMenu, openMenuSessionId, updateSessionMenuPosition]); // Clear unread completion mark after the switched session renders useEffect(() => { @@ -789,17 +800,51 @@ const SessionsSection: React.FC = ({ (e: React.MouseEvent, sessionId: string) => { e.stopPropagation(); if (openMenuSessionId === sessionId) { - setOpenMenuSessionId(null); - setSessionMenuPosition(null); + closeSessionMenu(); return; } const btn = e.currentTarget as HTMLElement; const rect = btn.getBoundingClientRect(); const { top, left } = computeFixedPopoverPosition(rect, 160, 120, 4, 8); setSessionMenuPosition({ top, left }); + setIsExportScopeMenu(false); setOpenMenuSessionId(sessionId); }, - [openMenuSessionId] + [closeSessionMenu, openMenuSessionId] + ); + + const handleExportMarkdown = useCallback( + async (e: React.MouseEvent, session: Session, scope: TranscriptExportScope) => { + e.stopPropagation(); + closeSessionMenu(); + if (exportingSessionId) return; + + setExportingSessionId(session.sessionId); + try { + await exportSessionToMarkdown( + { + sessionId: session.sessionId, + title: resolveSessionTitle(session), + workspacePath: session.workspacePath || workspacePath, + // The nav row carries the workspace's current connection; a session's + // stored ids can be stale (e.g. after an SSH port change). + remoteConnectionId: remoteConnectionId ?? session.remoteConnectionId, + remoteSshHost: remoteSshHost ?? session.remoteSshHost, + }, + scope + ); + } finally { + setExportingSessionId(null); + } + }, + [ + closeSessionMenu, + exportingSessionId, + remoteConnectionId, + remoteSshHost, + resolveSessionTitle, + workspacePath, + ] ); const handleDelete = useCallback( @@ -1248,61 +1293,117 @@ const SessionsSection: React.FC = ({ data-testid="nav-session-menu" data-session-id={session.sessionId} > - - - - - + {isExportScopeMenu ? ( + <> + + + + + ) : ( + <> + + + + + + + + )} , document.body )} diff --git a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.scss b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.scss index d35bf76703..95f1c45c93 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.scss +++ b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.scss @@ -307,6 +307,50 @@ } } +// Copy scope menu (full process / result only) +.model-round-item__copy-menu-anchor { + position: relative; + display: inline-flex; +} + +.model-round-item__copy-menu { + position: absolute; + right: 0; + bottom: calc(100% + 4px); + z-index: 20; + display: flex; + flex-direction: column; + min-width: 140px; + max-width: min(240px, calc(100vw - 24px)); + padding: 4px 0; + border: 1px solid var(--border-subtle); + border-radius: 6px; + background: var(--color-bg-elevated); + box-shadow: 0 4px 12px var(--color-overlay-black-30); +} + +.model-round-item__copy-menu-item { + display: flex; + align-items: center; + width: 100%; + min-height: 28px; + padding: 0 10px; + border: none; + background: transparent; + color: var(--color-text-secondary); + font-size: 12px; + line-height: 18px; + text-align: left; + white-space: nowrap; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; + + &:hover { + background: var(--color-overlay-white-08); + color: var(--color-text-primary); + } +} + .model-round-item__fork-btn { .spinning { animation: spin 1s linear infinite; diff --git a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx index abe8a25772..d3ad05ba1d 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx @@ -20,9 +20,8 @@ import { useCreateTypewriterRevealGate } from '../../hooks/typewriterRevealGateC import { getModelRoundItemClassName } from './modelRoundItemClassName'; import { isCollapsibleTool } from '../../tool-cards/toolCardMetadata'; import { useFlowChatContext } from './FlowChatContext'; -import { FlowChatStore } from '../../store/FlowChatStore'; import { taskCollapseStateManager } from '../../store/TaskCollapseStateManager'; -import { getEffectiveToolName, projectEffectiveToolItem } from '../../utils/toolInvocationIdentity'; +import { getEffectiveToolName } from '../../utils/toolInvocationIdentity'; import { ExportImageButton } from './ExportImageButton'; import { ForkSessionButton } from './ForkSessionButton'; import { @@ -40,6 +39,7 @@ import { getVisibleModelRoundGroupStartIndex, } from './modelRoundProgressiveRender'; import { Tooltip } from '@/component-library'; +import { notificationService } from '@/shared/notification-system'; import { createLogger } from '@/shared/utils/logger'; import { isStartupRenderTraceEnabled, @@ -47,8 +47,10 @@ import { startupTrace, } from '@/shared/utils/startupTrace'; import { SubagentProjectionView } from '../subagent/SubagentProjectionView'; -import { formatSessionViewPreviewText } from '../../utils/sessionViewPreview'; import { buildModelRoundUsageMeta } from '../../utils/tokenUsageDisplay'; +import { buildDialogTurnCopyText } from '../../utils/dialogTurnCopy'; +import type { TranscriptExportScope } from '../../utils/dialogTranscriptExport'; +import { buildTranscriptExportLabels } from '../../utils/transcriptExportLabels'; import './ModelRoundItem.scss'; import './SubagentItems.scss'; @@ -374,24 +376,44 @@ export const ModelRoundItem = React.memo( const [showRetryHistory, setShowRetryHistory] = useState(false); const [showRoundHistory, setShowRoundHistory] = useState(false); const [openHistoryRoundAttemptIds, setOpenHistoryRoundAttemptIds] = useState>({}); + const [isCopyMenuOpen, setIsCopyMenuOpen] = useState(false); const copyButtonRef = useRef(null); + const copyMenuRef = useRef(null); const renderTraceEnabled = isStartupRenderTraceEnabled(); const renderTraceStartedAtMs = renderTraceEnabled ? performance.now() : null; - + useEffect(() => { - if (!copied) return; - + if (!copied && !isCopyMenuOpen) return; + const handleClickOutside = (event: MouseEvent) => { - if (copyButtonRef.current && !copyButtonRef.current.contains(event.target as Node)) { - setCopied(false); + const target = event.target as Node; + if (copyButtonRef.current?.contains(target) || copyMenuRef.current?.contains(target)) { + return; } + setCopied(false); + setIsCopyMenuOpen(false); }; - + document.addEventListener('mousedown', handleClickOutside); return () => { document.removeEventListener('mousedown', handleClickOutside); }; - }, [copied]); + }, [copied, isCopyMenuOpen]); + + useEffect(() => { + if (!isCopyMenuOpen) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsCopyMenuOpen(false); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [isCopyMenuOpen]); const attempts = useMemo( () => sortRoundAttempts(round.attempts ?? []), @@ -613,89 +635,27 @@ export const ModelRoundItem = React.memo( }) ), [sessionId, transientNowMs, turnId]); - const extractDialogTurnContent = useCallback(() => { - const flowChatStore = FlowChatStore.getInstance(); - const state = flowChatStore.getState(); - - let targetSession = null; - for (const [, session] of state.sessions) { - if (session.dialogTurns.some((turn: any) => turn.id === turnId)) { - targetSession = session; - break; - } - } - - if (!targetSession) return ''; - - const dialogTurn = targetSession.dialogTurns.find((turn: any) => turn.id === turnId); - if (!dialogTurn) return ''; - - const contentParts: string[] = []; - - if (dialogTurn.userMessage?.content) { - contentParts.push(`${t('modelRound.userLabel')}\n${dialogTurn.userMessage.content}`); - } - - dialogTurn.modelRounds.forEach((modelRound: any) => { - const roundContent: string[] = []; - - modelRound.items.forEach((item: any) => { - if (item.type === 'text' && item.content?.trim()) { - roundContent.push(item.content.trim()); - } else if (item.type === 'thinking' && item.content?.trim()) { - roundContent.push(`[Thinking]\n${item.content.trim()}`); - } else if (item.type === 'tool' && item.toolCall) { - const effectiveItem = projectEffectiveToolItem(item); - const toolName = effectiveItem.toolName || t('copyOutput.unknownTool'); - let toolContent = t('modelRound.toolCallLabel', { name: toolName }) + '\n'; - - if (effectiveItem.toolCall.input) { - const inputStr = typeof effectiveItem.toolCall.input === 'string' - ? effectiveItem.toolCall.input - : JSON.stringify(effectiveItem.toolCall.input, null, 2); - toolContent += `\n[Input]\n\`\`\`json\n${inputStr}\n\`\`\`\n`; - } - - if (item.toolResult) { - if (item.toolResult.error) { - toolContent += `\n[Error]\n${item.toolResult.error}\n`; - } else if (item.toolResult.result !== undefined) { - const resultStr = typeof item.toolResult.result === 'string' - ? item.toolResult.result - : JSON.stringify(item.toolResult.result, null, 2); - toolContent += `\n[Result]\n\`\`\`\n${formatSessionViewPreviewText(resultStr)}\n\`\`\`\n`; - } - } - - roundContent.push(toolContent.trim()); - } - }); - - if (roundContent.length > 0) { - contentParts.push(roundContent.join('\n\n')); - } - }); - - return contentParts.join('\n\n---\n\n'); - }, [t, turnId]); - - const handleCopy = useCallback(async () => { + const handleCopyScope = useCallback(async (scope: TranscriptExportScope) => { + setIsCopyMenuOpen(false); try { - const content = extractDialogTurnContent(); - + const content = buildDialogTurnCopyText(turnId, scope, buildTranscriptExportLabels(t)); + if (!content.trim()) { - log.warn('No content to copy'); + // Result-only copy on a turn that produced no prose lands here. + log.warn('No content to copy', { turnId, scope }); + notificationService.warning(t('transcriptExport.copyEmpty')); return; } - + await navigator.clipboard.writeText(content); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch (error) { log.error('Failed to copy', error); + notificationService.error(t('errors:general.copyFailed')); } - }, [extractDialogTurnContent]); - + }, [t, turnId]); + const hasContent = sortedItems.some(item => (item.type === 'text' && (item as FlowTextItem).content.trim()) || (item.type === 'tool' && (item as FlowToolItem).toolCall) @@ -917,18 +877,51 @@ export const ModelRoundItem = React.memo( - - - - +
+ + + + + {isCopyMenuOpen && ( +
+ + +
+ )} +
+ )} diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatCopyDialog.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatCopyDialog.ts index ab19dc4d85..518685dc8f 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatCopyDialog.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatCopyDialog.ts @@ -7,107 +7,44 @@ import { globalEventBus } from '@/infrastructure/event-bus'; import { notificationService } from '@/shared/notification-system'; import { getElementText, copyTextToClipboard } from '@/shared/utils/textSelection'; import { createLogger } from '@/shared/utils/logger'; -import { FlowChatStore } from '../../store/FlowChatStore'; import { i18nService } from '@/infrastructure/i18n'; -import { formatSessionViewPreviewText } from '../../utils/sessionViewPreview'; -import { projectEffectiveToolItem } from '../../utils/toolInvocationIdentity'; +import { buildDialogTurnCopyText } from '../../utils/dialogTurnCopy'; +import type { TranscriptExportScope } from '../../utils/dialogTranscriptExport'; +import { buildTranscriptExportLabels } from '../../utils/transcriptExportLabels'; const log = createLogger('useFlowChatCopyDialog'); -function extractDialogTurnContent(turnId: string): string { - const flowChatStore = FlowChatStore.getInstance(); - const state = flowChatStore.getState(); - - let targetSession = null; - for (const [, session] of state.sessions) { - if (session.dialogTurns.some((turn: any) => turn.id === turnId)) { - targetSession = session; - break; - } - } - - if (!targetSession) return ''; - - const dialogTurn = targetSession.dialogTurns.find((turn: any) => turn.id === turnId); - if (!dialogTurn) return ''; - - const contentParts: string[] = []; - - if (dialogTurn.userMessage?.content) { - contentParts.push(`${i18nService.t('flow-chat:modelRound.userLabel')}\n${dialogTurn.userMessage.content}`); - } - - dialogTurn.modelRounds.forEach((modelRound: any) => { - const roundContent: string[] = []; - - modelRound.items.forEach((item: any) => { - if (item.type === 'text' && item.content?.trim()) { - roundContent.push(item.content.trim()); - } else if (item.type === 'thinking' && item.content?.trim()) { - roundContent.push(`[Thinking]\n${item.content.trim()}`); - } else if (item.type === 'tool' && item.toolCall) { - const effectiveItem = projectEffectiveToolItem(item); - const toolName = effectiveItem.toolName || i18nService.t('flow-chat:copyOutput.unknownTool'); - let toolContent = i18nService.t('flow-chat:modelRound.toolCallLabel', { name: toolName }) + '\n'; - - if (effectiveItem.toolCall.input) { - const inputStr = typeof effectiveItem.toolCall.input === 'string' - ? effectiveItem.toolCall.input - : JSON.stringify(effectiveItem.toolCall.input, null, 2); - toolContent += `\n[Input]\n\`\`\`json\n${inputStr}\n\`\`\`\n`; - } - - if (item.toolResult) { - if (item.toolResult.error) { - toolContent += `\n[Error]\n${item.toolResult.error}\n`; - } else if (item.toolResult.result !== undefined) { - const resultStr = typeof item.toolResult.result === 'string' - ? item.toolResult.result - : JSON.stringify(item.toolResult.result, null, 2); - toolContent += `\n[Result]\n\`\`\`\n${formatSessionViewPreviewText(resultStr)}\n\`\`\`\n`; - } - } - - roundContent.push(toolContent.trim()); - } - }); - - if (roundContent.length > 0) { - contentParts.push(roundContent.join('\n\n')); - } - }); - - return contentParts.join('\n\n---\n\n'); -} - export function useFlowChatCopyDialog(): void { useEffect(() => { - const unsubscribe = globalEventBus.on('flowchat:copy-dialog', ({ dialogTurn }) => { + const unsubscribe = globalEventBus.on('flowchat:copy-dialog', ({ dialogTurn, scope }) => { if (!dialogTurn) { log.warn('Copy failed: dialog element not provided'); return; } + const exportScope: TranscriptExportScope = scope === 'result' ? 'result' : 'full'; const dialogElement = dialogTurn as HTMLElement; let fullText = ''; - + const turnId = dialogElement.getAttribute('data-turn-id'); if (turnId) { - fullText = extractDialogTurnContent(turnId); + fullText = buildDialogTurnCopyText(turnId, exportScope, buildTranscriptExportLabels()); } - - if (!fullText) { + + // The DOM fallback cannot distinguish thinking / tool output, so it only + // stands in for a full-process copy. + if (!fullText && exportScope === 'full') { fullText = getElementText(dialogElement); } if (!fullText || fullText.trim().length === 0) { - notificationService.warning('Dialog is empty, nothing to copy'); + notificationService.warning(i18nService.t('flow-chat:transcriptExport.copyEmpty')); return; } copyTextToClipboard(fullText).then(success => { if (!success) { - notificationService.error('Copy failed. Please try again.'); + notificationService.error(i18nService.t('errors:general.copyFailed')); } }); }); diff --git a/src/web-ui/src/flow_chat/hooks/index.ts b/src/web-ui/src/flow_chat/hooks/index.ts index 2bc129fe40..dbc7c9d001 100644 --- a/src/web-ui/src/flow_chat/hooks/index.ts +++ b/src/web-ui/src/flow_chat/hooks/index.ts @@ -1,7 +1,6 @@ export { useFlowChat } from './useFlowChat'; export { useActiveSessionState } from './useActiveSessionState'; export { useAutoScroll } from './useAutoScroll'; -export { useCopyDialog } from './useCopyDialog'; export { useTypewriter } from './useTypewriter'; export { useImeEnterGuard } from './useImeEnterGuard'; export type { ImeEnterGuard } from './useImeEnterGuard'; diff --git a/src/web-ui/src/flow_chat/hooks/useCopyDialog.ts b/src/web-ui/src/flow_chat/hooks/useCopyDialog.ts deleted file mode 100644 index 01cde3ab54..0000000000 --- a/src/web-ui/src/flow_chat/hooks/useCopyDialog.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Copy-dialog hook. - * Centralizes the copy-dialog event handling to avoid duplicate listeners. - */ - -import { useEffect } from 'react'; -import { globalEventBus } from '../../infrastructure/event-bus'; -import { getElementText, copyTextToClipboard } from '../../shared/utils/textSelection'; -import { notificationService } from '../../shared/notification-system'; -import { createLogger } from '@/shared/utils/logger'; - -const log = createLogger('useCopyDialog'); - -/** - * Listen for flowchat:copy-dialog and copy text from the DOM element. - */ -export const useCopyDialog = () => { - useEffect(() => { - const unsubscribe = globalEventBus.on('flowchat:copy-dialog', ({ dialogTurn }) => { - if (!dialogTurn) { - log.warn('Dialog turn not provided'); - return; - } - - const dialogElement = dialogTurn as HTMLElement; - const fullText = getElementText(dialogElement); - - if (!fullText || fullText.trim().length === 0) { - notificationService.warning('Dialog content is empty; nothing to copy.'); - return; - } - - copyTextToClipboard(fullText).then(success => { - if (!success) { - notificationService.error('Copy failed. Please try again.'); - } - // Keep the UI quiet on success. - // Optionally: - // else { - // notificationService.success('Dialog copied to clipboard.'); - // } - }).catch(error => { - log.error('Copy failed', error); - notificationService.error('Copy failed. Please try again.'); - }); - }); - - return () => { - unsubscribe(); - }; - }, []); -}; - diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index 36107df403..bf5ca9933f 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -23,6 +23,7 @@ import { notificationService } from '../../../shared/notification-system/service import { createLogger } from '@/shared/utils/logger'; import { handleThreadGoalUpdated } from '../threadGoalEventService'; import { resolveThreadGoalUserMessageDisplay } from '../../utils/threadGoalDisplay'; +import { cleanRemoteUserInput } from '../../utils/userInputText'; import { effectiveToolInvocation, getEffectiveToolName } from '../../utils/toolInvocationIdentity'; import type { DeepReviewQueueStateChangedEvent, @@ -1445,22 +1446,6 @@ function handleImageAnalysisCompleted(_context: FlowChatContext, event: ImageAna log.info('Image analysis completed', { sessionId, success, durationMs }); } -/** - * Strip agent-internal XML wrapper tags from user input before displaying. - * Handles both normal and forwarded-agent envelopes. - */ -function cleanRemoteUserInput(raw: string): string { - const s = raw.trim(); - const userQueryMatch = s.match(/\s*([\s\S]*?)\s*<\/user_query>/); - if (userQueryMatch) { - return userQueryMatch[1].trim(); - } - - return s - .replace(/[\s\S]*?<\/system(?:_|-)reminder>/g, '') - .trim(); -} - function handleDialogTurnStarted(context: FlowChatContext, event: any): void { const { sessionId, turnId, turnIndex, userInput, originalUserInput, userMessageMetadata } = event; diff --git a/src/web-ui/src/flow_chat/services/sessionMarkdownExport.test.ts b/src/web-ui/src/flow_chat/services/sessionMarkdownExport.test.ts new file mode 100644 index 0000000000..0b62b8fca0 --- /dev/null +++ b/src/web-ui/src/flow_chat/services/sessionMarkdownExport.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { DialogTurnData } from '@/shared/types/session-history'; + +vi.mock('@/infrastructure/api/service-api/SessionAPI', () => ({ + sessionAPI: { loadSessionTurns: vi.fn() }, +})); + +vi.mock('@/infrastructure/i18n', () => ({ + i18nService: { t: (key: string) => key }, +})); + +vi.mock('@/shared/notification-system', () => ({ + notificationService: { success: vi.fn(), warning: vi.fn(), error: vi.fn() }, +})); + +const { buildSessionExportFileName, buildSessionMarkdown } = await import('./sessionMarkdownExport'); + +function turn(index: number, answer: string): DialogTurnData { + return { + turnId: `turn-${index}`, + turnIndex: index, + sessionId: 'session-1', + timestamp: 1, + userMessage: { id: `u${index}`, content: `question ${index}`, timestamp: 1 }, + modelRounds: [ + { + id: `round-${index}`, + turnId: `turn-${index}`, + roundIndex: 0, + timestamp: 1, + textItems: [{ id: `x${index}`, content: answer, isStreaming: false, timestamp: 2, orderIndex: 1 }], + toolItems: [], + thinkingItems: [ + { id: `k${index}`, content: 'internal notes', isStreaming: false, isCollapsed: true, timestamp: 1, orderIndex: 0 }, + ], + startTime: 1, + status: 'completed', + }, + ], + startTime: 1, + status: 'completed', + } as unknown as DialogTurnData; +} + +describe('buildSessionExportFileName', () => { + it('strips path separators and appends a timestamp', () => { + const name = buildSessionExportFileName( + 'Fix a/b: the "thing"', + 'session-1', + new Date('2026-07-25T10:11:12.000Z') + ); + expect(name).toBe('Fix a b the thing_2026-07-25_10-11-12.md'); + }); + + it('falls back to the session id when the title has no usable characters', () => { + const name = buildSessionExportFileName('///', 'session-1', new Date('2026-07-25T10:11:12.000Z')); + expect(name).toBe('session-1_2026-07-25_10-11-12.md'); + }); +}); + +describe('buildSessionMarkdown', () => { + const meta = { + title: 'My session', + sessionId: 'session-1', + exportedAt: new Date('2026-07-25T10:00:00.000Z'), + }; + + it('renders every turn of the session', () => { + const markdown = buildSessionMarkdown([turn(1, 'first answer'), turn(2, 'second answer')], 'full', meta); + expect(markdown).toContain('first answer'); + expect(markdown).toContain('second answer'); + expect(markdown).toContain('internal notes'); + }); + + it('drops thinking when exporting results only', () => { + const markdown = buildSessionMarkdown([turn(1, 'first answer')], 'result', meta); + expect(markdown).toContain('first answer'); + expect(markdown).not.toContain('internal notes'); + }); +}); diff --git a/src/web-ui/src/flow_chat/services/sessionMarkdownExport.ts b/src/web-ui/src/flow_chat/services/sessionMarkdownExport.ts new file mode 100644 index 0000000000..ae5e830b82 --- /dev/null +++ b/src/web-ui/src/flow_chat/services/sessionMarkdownExport.ts @@ -0,0 +1,158 @@ +/** + * Export a whole session's history to a Markdown file. + * + * Turns are read from session persistence (not from the in-memory store) so a + * partially hydrated history session still exports its full transcript. + */ + +import { sessionAPI } from '@/infrastructure/api/service-api/SessionAPI'; +import { i18nService } from '@/infrastructure/i18n'; +import { notificationService } from '@/shared/notification-system'; +import { createLogger } from '@/shared/utils/logger'; +import { downloadMarkdownInBrowser } from '@/shared/utils/browserDownload'; +import type { DialogTurnData } from '@/shared/types/session-history'; +import { + collectPersistedTurn, + formatSessionTranscriptMarkdown, + hasTranscriptContent, + type TranscriptExportScope, + type TranscriptExportTurn, +} from '../utils/dialogTranscriptExport'; +import { buildSessionTranscriptMarkdownLabels } from '../utils/transcriptExportLabels'; + +const log = createLogger('sessionMarkdownExport'); + +export interface SessionMarkdownExportTarget { + sessionId: string; + title: string; + workspacePath?: string; + remoteConnectionId?: string | null; + remoteSshHost?: string | null; +} + +export type SessionMarkdownExportResult = + | { status: 'saved'; filePath?: string } + | { status: 'cancelled' } + | { status: 'empty' } + | { status: 'failed' }; + +function isTauriDesktop(): boolean { + return typeof window !== 'undefined' && '__TAURI__' in window; +} + +const FILE_NAME_RESERVED_CHARS = '\\/:*?"<>|'; + +/** Filesystem-safe, reasonably short file stem derived from the session title. */ +export function buildSessionExportFileName(title: string, sessionId: string, exportedAt: Date): string { + const safeTitle = Array.from(title) + .map(char => (char < ' ' || FILE_NAME_RESERVED_CHARS.includes(char) ? ' ' : char)) + .join('') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 60); + const stamp = exportedAt + .toISOString() + .replace(/[:.]/g, '-') + .replace('T', '_') + .slice(0, 19); + const stem = safeTitle || sessionId; + return `${stem}_${stamp}.md`; +} + +/** Build the Markdown document for already-loaded persisted turns. */ +export function buildSessionMarkdown( + turns: DialogTurnData[], + scope: TranscriptExportScope, + meta: { title: string; sessionId: string; exportedAt: Date; workspacePath?: string } +): string { + const exportTurns: TranscriptExportTurn[] = turns + .map(collectPersistedTurn) + .filter(turn => hasTranscriptContent(turn, scope)); + + return formatSessionTranscriptMarkdown( + exportTurns, + scope, + meta, + buildSessionTranscriptMarkdownLabels() + ); +} + +/** + * Load, render, and save a session transcript. Returns the outcome so callers + * can stay silent on user cancellation. + */ +export async function exportSessionToMarkdown( + target: SessionMarkdownExportTarget, + scope: TranscriptExportScope +): Promise { + const exportedAt = new Date(); + + try { + const turns = await sessionAPI.loadSessionTurns( + target.sessionId, + target.workspacePath ?? '', + undefined, + target.remoteConnectionId ?? undefined, + target.remoteSshHost ?? undefined + ); + + const exportTurns = (turns ?? []) + .map(collectPersistedTurn) + .filter(turn => hasTranscriptContent(turn, scope)); + + if (exportTurns.length === 0) { + notificationService.warning(i18nService.t('flow-chat:transcriptExport.exportEmpty')); + return { status: 'empty' }; + } + + const markdown = formatSessionTranscriptMarkdown( + exportTurns, + scope, + { + title: target.title, + sessionId: target.sessionId, + exportedAt, + workspacePath: target.workspacePath, + }, + buildSessionTranscriptMarkdownLabels() + ); + + const fileName = buildSessionExportFileName(target.title, target.sessionId, exportedAt); + + if (isTauriDesktop()) { + const [{ save }, { writeFile }] = await Promise.all([ + import('@tauri-apps/plugin-dialog'), + import('@tauri-apps/plugin-fs'), + ]); + const filePath = await save({ + title: i18nService.t('flow-chat:transcriptExport.saveDialogTitle'), + defaultPath: fileName, + filters: [{ name: 'Markdown', extensions: ['md'] }], + }); + if (!filePath) { + return { status: 'cancelled' }; + } + await writeFile(filePath, new TextEncoder().encode(markdown)); + notificationService.success( + i18nService.t('flow-chat:transcriptExport.exportSuccess', { filePath }), + { duration: 4000 } + ); + return { status: 'saved', filePath }; + } + + downloadMarkdownInBrowser(fileName, markdown); + notificationService.success( + i18nService.t('flow-chat:transcriptExport.exportSuccess', { filePath: fileName }), + { duration: 4000 } + ); + return { status: 'saved', filePath: fileName }; + } catch (error) { + log.error('Failed to export session transcript', { + sessionId: target.sessionId, + scope, + error, + }); + notificationService.error(i18nService.t('flow-chat:transcriptExport.exportFailed')); + return { status: 'failed' }; + } +} diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 9cb597f489..7f09ec3621 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -61,6 +61,7 @@ import type { WorkspaceInfo } from '@/shared/types'; import { sessionBelongsToWorkspaceNavRow } from '../utils/sessionOrdering'; import { sessionMatchesWorkspace } from '../utils/workspaceScope'; import { resolveThreadGoalUserMessageDisplay } from '../utils/threadGoalDisplay'; +import { cleanRemoteUserInput } from '../utils/userInputText'; import { useBackgroundSubagentActivityStore } from './backgroundSubagentActivityStore'; import { sessionComposerStore } from './sessionComposerStore'; import { recordHistorySessionDiagnosticEvent } from '../services/historySessionDiagnostics'; @@ -4851,21 +4852,6 @@ export class FlowChatStore { } } - /** - * Strip agent-internal XML wrapper tags from persisted user inputs. - */ - private cleanRemoteUserInput(raw: string): string { - const s = raw.trim(); - const userQueryMatch = s.match(/\s*([\s\S]*?)\s*<\/user_query>/); - if (userQueryMatch) { - return userQueryMatch[1].trim(); - } - - return s - .replace(/[\s\S]*?<\/system(?:_|-)reminder>/g, '') - .trim(); - } - /** * Convert DialogTurnData to FlowChat DialogTurn format */ @@ -4894,7 +4880,7 @@ export class FlowChatStore { || metadata?.threadGoalObjectiveUpdated || metadata?.threadGoalContinuation ? turn.userMessage.content - : metadata?.original_text || this.cleanRemoteUserInput(turn.userMessage.content); + : metadata?.original_text || cleanRemoteUserInput(turn.userMessage.content); const displayContent = resolveThreadGoalUserMessageDisplay( rawDisplay, metadata as Record | undefined diff --git a/src/web-ui/src/flow_chat/tool-cards/CodeReviewReportExportActions.tsx b/src/web-ui/src/flow_chat/tool-cards/CodeReviewReportExportActions.tsx index 192e2dedc2..9b655badf9 100644 --- a/src/web-ui/src/flow_chat/tool-cards/CodeReviewReportExportActions.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/CodeReviewReportExportActions.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { Button, Tooltip } from '@/component-library'; import { notificationService } from '@/shared/notification-system'; import { createMarkdownEditorTab } from '@/shared/utils/tabUtils'; +import { downloadMarkdownInBrowser } from '@/shared/utils/browserDownload'; import { formatCodeReviewReportMarkdown, type CodeReviewReportData, @@ -34,16 +35,6 @@ function isTauriDesktop(): boolean { return typeof window !== 'undefined' && '__TAURI__' in window; } -function downloadMarkdownInBrowser(fileName: string, markdown: string): void { - const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement('a'); - anchor.href = url; - anchor.download = fileName; - anchor.click(); - URL.revokeObjectURL(url); -} - export const CodeReviewReportExportActions: React.FC = ({ reviewData, runManifest, diff --git a/src/web-ui/src/flow_chat/utils/dialogTranscriptExport.test.ts b/src/web-ui/src/flow_chat/utils/dialogTranscriptExport.test.ts new file mode 100644 index 0000000000..39daa6e269 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/dialogTranscriptExport.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from 'vitest'; +import type { DialogTurn } from '../types/flow-chat'; +import type { DialogTurnData } from '@/shared/types/session-history'; +import { + collectPersistedTurn, + collectRuntimeTurn, + formatSessionTranscriptMarkdown, + formatTranscriptTurnText, + hasTranscriptContent, + type SessionTranscriptMarkdownLabels, + type TranscriptExportLabels, +} from './dialogTranscriptExport'; + +const labels: TranscriptExportLabels = { + userLabel: 'USER:', + toolCallLabel: (name: string) => `TOOL: ${name}`, + unknownTool: 'unknown', + thinking: 'Thinking', + toolInput: 'Input', + toolResult: 'Result', + toolError: 'Error', + empty: '(empty)', +}; + +const markdownLabels: SessionTranscriptMarkdownLabels = { + ...labels, + scopeFull: 'Full process', + scopeResult: 'Result only', + metaSessionId: 'Session ID', + metaExportedAt: 'Exported at', + metaTurnCount: 'Turns', + metaScope: 'Scope', + metaWorkspace: 'Workspace', + turnHeading: (index: number) => `Turn ${index}`, + userHeading: 'User', + assistantHeading: 'Assistant', + toolHeading: (name: string) => `Tool ${name}`, +}; + +function runtimeTurn(): DialogTurn { + return { + id: 'turn-1', + sessionId: 'session-1', + userMessage: { id: 'u1', content: 'do the thing', timestamp: 1 }, + modelRounds: [ + { + id: 'round-1', + index: 0, + items: [ + { id: 'k1', type: 'thinking', content: 'secret reasoning', isStreaming: false, isCollapsed: true, timestamp: 2, status: 'completed' }, + { id: 't1', type: 'tool', toolName: 'Read', toolCall: { input: { path: 'a.ts' }, id: 'c1' }, toolResult: { result: 'file body', success: true }, timestamp: 3, status: 'completed' }, + { id: 'x1', type: 'text', content: 'here is the answer', isStreaming: false, timestamp: 4, status: 'completed' }, + ], + isStreaming: false, + isComplete: true, + status: 'completed', + startTime: 1, + }, + ], + startTime: 1, + status: 'completed', + } as unknown as DialogTurn; +} + +function persistedTurn(): DialogTurnData { + return { + turnId: 'turn-1', + turnIndex: 3, + sessionId: 'session-1', + timestamp: 1, + userMessage: { + id: 'u1', + content: 'persisted question', + timestamp: 1, + }, + modelRounds: [ + { + id: 'round-1', + turnId: 'turn-1', + roundIndex: 0, + timestamp: 1, + textItems: [ + { id: 'x0', content: 'superseded answer', isStreaming: false, timestamp: 2, orderIndex: 0, attemptIndex: 0 }, + { id: 'x1', content: 'final answer', isStreaming: false, timestamp: 5, orderIndex: 2, attemptIndex: 1 }, + ], + toolItems: [ + { + id: 't1', + toolName: 'Read', + toolCall: { input: { path: 'a.ts' }, id: 'c1' }, + toolResult: { result: 'file body', success: true }, + startTime: 3, + orderIndex: 1, + attemptIndex: 1, + }, + ], + thinkingItems: [ + { id: 'k1', content: 'secret reasoning', isStreaming: false, isCollapsed: true, timestamp: 4, orderIndex: 0, attemptIndex: 1 }, + ], + startTime: 1, + status: 'completed', + }, + ], + startTime: 1, + status: 'completed', + } as unknown as DialogTurnData; +} + +describe('formatTranscriptTurnText', () => { + it('includes thinking and tool calls in full scope', () => { + const text = formatTranscriptTurnText(collectRuntimeTurn(runtimeTurn()), 'full', labels); + + expect(text).toContain('USER:\ndo the thing'); + expect(text).toContain('[Thinking]\nsecret reasoning'); + expect(text).toContain('TOOL: Read'); + expect(text).toContain('[Input]'); + expect(text).toContain('[Result]'); + expect(text).toContain('here is the answer'); + }); + + it('keeps only the user message and assistant text in result scope', () => { + const text = formatTranscriptTurnText(collectRuntimeTurn(runtimeTurn()), 'result', labels); + + expect(text).toBe('USER:\ndo the thing\n\n---\n\nhere is the answer'); + expect(text).not.toContain('secret reasoning'); + expect(text).not.toContain('Read'); + }); + + it('widens fences so tool payloads cannot break out of the code block', () => { + const turn = runtimeTurn(); + (turn.modelRounds[0].items[1] as any).toolResult = { result: 'before\n```\nafter', success: true }; + + const text = formatTranscriptTurnText(collectRuntimeTurn(turn), 'full', labels); + expect(text).toContain('````\nbefore\n```\nafter\n````'); + }); + + it('drops transient runtime status text items', () => { + const turn = runtimeTurn(); + (turn.modelRounds[0].items as any[]).push({ + id: 'x2', + type: 'text', + content: 'Waiting for model…', + isStreaming: true, + timestamp: 5, + status: 'streaming', + runtimeStatus: { phase: 'waiting_model', scope: 'main' }, + }); + + const text = formatTranscriptTurnText(collectRuntimeTurn(turn), 'result', labels); + expect(text).not.toContain('Waiting for model'); + }); +}); + +describe('collectPersistedTurn', () => { + it('unwraps the user query envelope and keeps the effective attempt in order', () => { + const turn = collectPersistedTurn(persistedTurn()); + + expect(turn.turnIndex).toBe(3); + expect(turn.userContent).toBe('persisted question'); + expect(turn.rounds[0].items.map(item => item.kind)).toEqual(['thinking', 'tool', 'text']); + expect(turn.rounds[0].items[2].content).toBe('final answer'); + }); + + it('reports content presence per scope', () => { + const turn = collectPersistedTurn(persistedTurn()); + expect(hasTranscriptContent(turn, 'full')).toBe(true); + expect(hasTranscriptContent(turn, 'result')).toBe(true); + }); +}); + +describe('formatSessionTranscriptMarkdown', () => { + const meta = { + title: 'My session', + sessionId: 'session-1', + exportedAt: new Date('2026-07-25T10:00:00.000Z'), + workspacePath: '/repo', + }; + + it('renders headings, metadata, and the full process', () => { + const markdown = formatSessionTranscriptMarkdown( + [collectPersistedTurn(persistedTurn())], + 'full', + meta, + markdownLabels + ); + + expect(markdown).toContain('# My session'); + expect(markdown).toContain('- Session ID: `session-1`'); + expect(markdown).toContain('- Workspace: `/repo`'); + expect(markdown).toContain('- Scope: Full process'); + expect(markdown).toContain('## Turn 3'); + expect(markdown).toContain('### User'); + expect(markdown).toContain('persisted question'); + expect(markdown).toContain('#### Thinking'); + expect(markdown).toContain('#### Tool Read'); + expect(markdown).toContain('final answer'); + }); + + it('omits thinking and tools in result scope', () => { + const markdown = formatSessionTranscriptMarkdown( + [collectPersistedTurn(persistedTurn())], + 'result', + meta, + markdownLabels + ); + + expect(markdown).toContain('- Scope: Result only'); + expect(markdown).toContain('final answer'); + expect(markdown).not.toContain('secret reasoning'); + expect(markdown).not.toContain('#### Tool Read'); + }); + + it('widens fences so tool payloads cannot break out of the code block', () => { + const turn = persistedTurn(); + turn.modelRounds[0].toolItems[0].toolResult = { + result: 'before\n```\nafter', + success: true, + } as any; + + const markdown = formatSessionTranscriptMarkdown( + [collectPersistedTurn(turn)], + 'full', + meta, + markdownLabels + ); + + expect(markdown).toContain('````\nbefore\n```\nafter\n````'); + }); +}); diff --git a/src/web-ui/src/flow_chat/utils/dialogTranscriptExport.ts b/src/web-ui/src/flow_chat/utils/dialogTranscriptExport.ts new file mode 100644 index 0000000000..d3097b86f0 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/dialogTranscriptExport.ts @@ -0,0 +1,379 @@ +/** + * Shared dialog transcript formatting for clipboard copy and Markdown export. + * + * Two scopes are supported everywhere a transcript leaves the app: + * - `full` — user message, assistant text, thinking, and tool calls. + * - `result` — user message and assistant text only. + * + * The runtime store (`DialogTurn`) and the persisted shape (`DialogTurnData`) + * are normalized into the same intermediate form so a single formatter serves + * one-turn copy and whole-session export. + */ + +import type { + AnyFlowItem, + DialogTurn, + FlowTextItem, + FlowThinkingItem, + FlowToolItem, + ModelRound, +} from '../types/flow-chat'; +import type { DialogTurnData, ModelRoundData } from '@/shared/types/session-history'; +import { formatSessionViewPreviewText } from './sessionViewPreview'; +import { effectiveToolInvocation } from './toolInvocationIdentity'; +import { resolvePersistedUserMessageText } from './userInputText'; + +/** What a transcript export includes. */ +export type TranscriptExportScope = 'full' | 'result'; + +export interface TranscriptExportItem { + kind: 'text' | 'thinking' | 'tool'; + /** Text / thinking content. */ + content?: string; + toolName?: string; + toolInput?: unknown; + toolResult?: unknown; + toolError?: string; +} + +export interface TranscriptExportRound { + items: TranscriptExportItem[]; +} + +export interface TranscriptExportTurn { + turnIndex?: number; + userContent: string; + rounds: TranscriptExportRound[]; +} + +/** Localized labels used by the formatters. */ +export interface TranscriptExportLabels { + /** Inline user prefix for clipboard copy, e.g. "👤 User:". */ + userLabel: string; + /** Inline tool prefix for clipboard copy, e.g. "🔧 Tool call: Read". */ + toolCallLabel: (toolName: string) => string; + unknownTool: string; + thinking: string; + toolInput: string; + toolResult: string; + toolError: string; + empty: string; +} + +export interface SessionTranscriptMarkdownLabels extends TranscriptExportLabels { + scopeFull: string; + scopeResult: string; + metaSessionId: string; + metaExportedAt: string; + metaTurnCount: string; + metaScope: string; + metaWorkspace: string; + turnHeading: (index: number) => string; + userHeading: string; + assistantHeading: string; + toolHeading: (toolName: string) => string; +} + +export interface SessionTranscriptMarkdownMeta { + title: string; + sessionId: string; + exportedAt: Date; + workspacePath?: string; +} + +function normalizeText(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function stringifyPayload(value: unknown): string { + if (typeof value === 'string') return value; + try { + return JSON.stringify(value, null, 2) ?? ''; + } catch { + return String(value); + } +} + +/** + * Retries append superseded items to the same persisted round. Keep only the + * items belonging to the highest attempt, matching how the round is rendered. + */ +function effectiveAttemptIndex(round: ModelRoundData): number | undefined { + const indices = [ + ...round.textItems, + ...round.toolItems, + ...(round.thinkingItems ?? []), + ] + .map(item => item.attemptIndex) + .filter((index): index is number => typeof index === 'number'); + + return indices.length > 0 ? Math.max(...indices) : undefined; +} + +function isEffectiveAttempt( + attemptIndex: number | undefined, + effectiveIndex: number | undefined +): boolean { + if (effectiveIndex === undefined) return true; + return attemptIndex === effectiveIndex; +} + +function toolItemToExportItem( + toolName: string, + input: unknown, + toolResult: { result?: unknown; error?: string } | undefined +): TranscriptExportItem { + const effective = effectiveToolInvocation(toolName, input); + return { + kind: 'tool', + toolName: effective.toolName || toolName, + toolInput: effective.input, + toolResult: toolResult?.error ? undefined : toolResult?.result, + toolError: toolResult?.error, + }; +} + +function collectRuntimeRound(round: ModelRound): TranscriptExportRound { + const items: TranscriptExportItem[] = []; + + round.items.forEach((item: AnyFlowItem) => { + if (item.type === 'text') { + const textItem = item as FlowTextItem; + const content = normalizeText(textItem.content); + // Transient runtime status lines are presentation-only. + if (content && !textItem.runtimeStatus) { + items.push({ kind: 'text', content }); + } + return; + } + if (item.type === 'thinking') { + const content = normalizeText((item as FlowThinkingItem).content); + if (content) { + items.push({ kind: 'thinking', content }); + } + return; + } + if (item.type === 'tool') { + const toolItem = item as FlowToolItem; + if (toolItem.toolCall) { + items.push( + toolItemToExportItem(toolItem.toolName, toolItem.toolCall.input, toolItem.toolResult) + ); + } + } + }); + + return { items }; +} + +function collectPersistedRound(round: ModelRoundData): TranscriptExportRound { + const effectiveIndex = effectiveAttemptIndex(round); + + const ordered = [ + ...round.textItems.map(item => ({ + order: item.orderIndex ?? item.timestamp ?? 0, + attemptIndex: item.attemptIndex, + build: (): TranscriptExportItem | null => { + const content = normalizeText(item.content); + return content ? { kind: 'text', content } : null; + }, + })), + ...(round.thinkingItems ?? []).map(item => ({ + order: item.orderIndex ?? item.timestamp ?? 0, + attemptIndex: item.attemptIndex, + build: (): TranscriptExportItem | null => { + const content = normalizeText(item.content); + return content ? { kind: 'thinking', content } : null; + }, + })), + ...round.toolItems.map(item => ({ + order: item.orderIndex ?? item.startTime ?? 0, + attemptIndex: item.attemptIndex, + build: (): TranscriptExportItem | null => + toolItemToExportItem(item.toolName, item.toolCall?.input, item.toolResult), + })), + ] + .filter(entry => isEffectiveAttempt(entry.attemptIndex, effectiveIndex)) + .sort((a, b) => a.order - b.order); + + const items = ordered + .map(entry => entry.build()) + .filter((item): item is TranscriptExportItem => item !== null); + + return { items }; +} + +/** Normalize a live/hydrated dialog turn from the FlowChat store. */ +export function collectRuntimeTurn(turn: DialogTurn): TranscriptExportTurn { + return { + userContent: normalizeText(turn.userMessage?.content), + rounds: (turn.modelRounds ?? []).map(collectRuntimeRound), + }; +} + +/** Normalize a persisted dialog turn loaded from session storage. */ +export function collectPersistedTurn(turn: DialogTurnData): TranscriptExportTurn { + return { + turnIndex: turn.turnIndex, + userContent: normalizeText( + resolvePersistedUserMessageText(turn.userMessage?.content ?? '', turn.userMessage?.metadata) + ), + rounds: (turn.modelRounds ?? []).map(collectPersistedRound), + }; +} + +function scopedItems( + round: TranscriptExportRound, + scope: TranscriptExportScope +): TranscriptExportItem[] { + if (scope === 'full') return round.items; + return round.items.filter(item => item.kind === 'text'); +} + +/** Whether the turn produces any content under the requested scope. */ +export function hasTranscriptContent( + turn: TranscriptExportTurn, + scope: TranscriptExportScope +): boolean { + if (turn.userContent) return true; + return turn.rounds.some(round => scopedItems(round, scope).length > 0); +} + +function markdownFence(content: string, language = ''): string { + // Widen the fence when the payload itself contains a triple backtick. + const longestRun = (content.match(/`{3,}/g) ?? []) + .reduce((longest, run) => Math.max(longest, run.length), 0); + const fence = '`'.repeat(Math.max(3, longestRun + 1)); + return `${fence}${language}\n${content}\n${fence}`; +} + +function formatToolBlockText( + item: TranscriptExportItem, + labels: TranscriptExportLabels +): string { + const toolName = item.toolName || labels.unknownTool; + let block = `${labels.toolCallLabel(toolName)}\n`; + + if (item.toolInput !== undefined && item.toolInput !== null) { + block += `\n[${labels.toolInput}]\n${markdownFence(stringifyPayload(item.toolInput), 'json')}\n`; + } + + if (item.toolError) { + block += `\n[${labels.toolError}]\n${item.toolError}\n`; + } else if (item.toolResult !== undefined) { + const result = formatSessionViewPreviewText(stringifyPayload(item.toolResult)); + block += `\n[${labels.toolResult}]\n${markdownFence(result)}\n`; + } + + return block.trim(); +} + +/** + * Clipboard text for a single dialog turn. + * + * `full` reproduces the historical copy format (thinking + tool calls); + * `result` keeps only the user message and the assistant's written answer. + */ +export function formatTranscriptTurnText( + turn: TranscriptExportTurn, + scope: TranscriptExportScope, + labels: TranscriptExportLabels +): string { + const parts: string[] = []; + + if (turn.userContent) { + parts.push(`${labels.userLabel}\n${turn.userContent}`); + } + + turn.rounds.forEach(round => { + const blocks = scopedItems(round, scope).map(item => { + if (item.kind === 'text') return item.content ?? ''; + if (item.kind === 'thinking') return `[${labels.thinking}]\n${item.content ?? ''}`; + return formatToolBlockText(item, labels); + }); + + const roundContent = blocks.filter(block => block.trim().length > 0); + if (roundContent.length > 0) { + parts.push(roundContent.join('\n\n')); + } + }); + + return parts.join('\n\n---\n\n'); +} + +function formatToolBlockMarkdown( + item: TranscriptExportItem, + labels: SessionTranscriptMarkdownLabels +): string[] { + const blocks: string[] = [`#### ${labels.toolHeading(item.toolName || labels.unknownTool)}`]; + + if (item.toolInput !== undefined && item.toolInput !== null) { + blocks.push(`*${labels.toolInput}*`); + blocks.push(markdownFence(stringifyPayload(item.toolInput), 'json')); + } + + if (item.toolError) { + blocks.push(`*${labels.toolError}*`); + blocks.push(markdownFence(item.toolError)); + } else if (item.toolResult !== undefined) { + blocks.push(`*${labels.toolResult}*`); + blocks.push(markdownFence(formatSessionViewPreviewText(stringifyPayload(item.toolResult)))); + } + + return blocks; +} + +/** + * Markdown document for a whole session. + * + * Blocks are joined with a single blank line, so verbatim content (code fences, + * tool payloads) is never reflowed. + */ +export function formatSessionTranscriptMarkdown( + turns: TranscriptExportTurn[], + scope: TranscriptExportScope, + meta: SessionTranscriptMarkdownMeta, + labels: SessionTranscriptMarkdownLabels +): string { + const metaLines = [`- ${labels.metaSessionId}: \`${meta.sessionId}\``]; + if (meta.workspacePath) { + metaLines.push(`- ${labels.metaWorkspace}: \`${meta.workspacePath}\``); + } + metaLines.push(`- ${labels.metaExportedAt}: ${meta.exportedAt.toISOString()}`); + metaLines.push(`- ${labels.metaTurnCount}: ${turns.length}`); + metaLines.push( + `- ${labels.metaScope}: ${scope === 'full' ? labels.scopeFull : labels.scopeResult}` + ); + + const blocks: string[] = [`# ${meta.title}`, metaLines.join('\n')]; + + turns.forEach((turn, position) => { + blocks.push('---'); + blocks.push(`## ${labels.turnHeading(turn.turnIndex ?? position + 1)}`); + blocks.push(`### ${labels.userHeading}`); + blocks.push(turn.userContent || `*${labels.empty}*`); + + const assistantBlocks: string[] = []; + turn.rounds.forEach(round => { + scopedItems(round, scope).forEach(item => { + if (item.kind === 'text') { + assistantBlocks.push(item.content ?? ''); + return; + } + if (item.kind === 'thinking') { + assistantBlocks.push(`#### ${labels.thinking}`); + assistantBlocks.push(markdownFence(item.content ?? '')); + return; + } + assistantBlocks.push(...formatToolBlockMarkdown(item, labels)); + }); + }); + + if (assistantBlocks.length > 0) { + blocks.push(`### ${labels.assistantHeading}`); + blocks.push(...assistantBlocks); + } + }); + + return `${blocks.filter(block => block.trim().length > 0).join('\n\n')}\n`; +} diff --git a/src/web-ui/src/flow_chat/utils/dialogTurnCopy.test.ts b/src/web-ui/src/flow_chat/utils/dialogTurnCopy.test.ts new file mode 100644 index 0000000000..154700cea7 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/dialogTurnCopy.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest'; + +const sessions = new Map([ + ['session-1', { + dialogTurns: [ + { + id: 'turn-1', + userMessage: { id: 'u1', content: 'question', timestamp: 1 }, + modelRounds: [ + { + id: 'round-1', + items: [ + { id: 'k1', type: 'thinking', content: 'reasoning', timestamp: 2 }, + { id: 't1', type: 'tool', toolName: 'Read', toolCall: { input: { path: 'a.ts' }, id: 'c1' }, timestamp: 3 }, + { id: 'x1', type: 'text', content: 'answer', timestamp: 4 }, + ], + }, + ], + }, + ], + }], +]); + +vi.mock('../store/FlowChatStore', () => ({ + FlowChatStore: { + getInstance: () => ({ getState: () => ({ sessions }) }), + }, +})); + +const { buildDialogTurnCopyText, findDialogTurnById } = await import('./dialogTurnCopy'); + +const labels = { + userLabel: 'USER:', + toolCallLabel: (name: string) => `TOOL: ${name}`, + unknownTool: 'unknown', + thinking: 'Thinking', + toolInput: 'Input', + toolResult: 'Result', + toolError: 'Error', + empty: '(empty)', +}; + +describe('buildDialogTurnCopyText', () => { + it('finds the turn across sessions', () => { + expect(findDialogTurnById('turn-1')?.id).toBe('turn-1'); + expect(findDialogTurnById('missing')).toBeUndefined(); + }); + + it('copies the full process when asked for it', () => { + const text = buildDialogTurnCopyText('turn-1', 'full', labels); + expect(text).toContain('reasoning'); + expect(text).toContain('TOOL: Read'); + expect(text).toContain('answer'); + }); + + it('copies only the result when asked for it', () => { + const text = buildDialogTurnCopyText('turn-1', 'result', labels); + expect(text).toBe('USER:\nquestion\n\n---\n\nanswer'); + }); + + it('returns empty text for an unknown turn', () => { + expect(buildDialogTurnCopyText('missing', 'full', labels)).toBe(''); + }); +}); diff --git a/src/web-ui/src/flow_chat/utils/dialogTurnCopy.ts b/src/web-ui/src/flow_chat/utils/dialogTurnCopy.ts new file mode 100644 index 0000000000..e46f3393d6 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/dialogTurnCopy.ts @@ -0,0 +1,37 @@ +/** + * Clipboard text for a single dialog turn, resolved from the FlowChat store. + * + * Shared by the round footer copy action and the FlowChat context menu so both + * offer the same "full process" / "result only" scopes. + */ + +import { FlowChatStore } from '../store/FlowChatStore'; +import type { DialogTurn } from '../types/flow-chat'; +import { + collectRuntimeTurn, + formatTranscriptTurnText, + type TranscriptExportLabels, + type TranscriptExportScope, +} from './dialogTranscriptExport'; + +export function findDialogTurnById(turnId: string): DialogTurn | undefined { + const state = FlowChatStore.getInstance().getState(); + for (const [, session] of state.sessions) { + const dialogTurn = session.dialogTurns.find(turn => turn.id === turnId); + if (dialogTurn) { + return dialogTurn; + } + } + return undefined; +} + +export function buildDialogTurnCopyText( + turnId: string, + scope: TranscriptExportScope, + labels: TranscriptExportLabels +): string { + const dialogTurn = findDialogTurnById(turnId); + if (!dialogTurn) return ''; + + return formatTranscriptTurnText(collectRuntimeTurn(dialogTurn), scope, labels); +} diff --git a/src/web-ui/src/flow_chat/utils/transcriptExportLabels.ts b/src/web-ui/src/flow_chat/utils/transcriptExportLabels.ts new file mode 100644 index 0000000000..6c84976aa4 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/transcriptExportLabels.ts @@ -0,0 +1,52 @@ +/** + * Localized label bundles for transcript copy / Markdown export. + * + * Keys are resolved inside the `flow-chat` namespace so both `useTranslation` + * consumers and plain `i18nService` callers share one label source. + */ + +import { i18nService } from '@/infrastructure/i18n'; +import type { + SessionTranscriptMarkdownLabels, + TranscriptExportLabels, +} from './dialogTranscriptExport'; + +export type TranscriptTranslate = (key: string, options?: Record) => string; + +/** Bare keys resolve inside `flow-chat`; namespaced keys pass through. */ +const defaultTranslate: TranscriptTranslate = (key, options) => + i18nService.t(key.includes(':') ? key : `flow-chat:${key}`, options); + +export function buildTranscriptExportLabels( + t: TranscriptTranslate = defaultTranslate +): TranscriptExportLabels { + return { + userLabel: t('modelRound.userLabel'), + toolCallLabel: (toolName: string) => t('modelRound.toolCallLabel', { name: toolName }), + unknownTool: t('copyOutput.unknownTool'), + thinking: t('transcriptExport.thinking'), + toolInput: t('transcriptExport.toolInput'), + toolResult: t('transcriptExport.toolResult'), + toolError: t('transcriptExport.toolError'), + empty: t('transcriptExport.empty'), + }; +} + +export function buildSessionTranscriptMarkdownLabels( + t: TranscriptTranslate = defaultTranslate +): SessionTranscriptMarkdownLabels { + return { + ...buildTranscriptExportLabels(t), + scopeFull: t('transcriptExport.scopeFull'), + scopeResult: t('transcriptExport.scopeResult'), + metaSessionId: t('transcriptExport.metaSessionId'), + metaExportedAt: t('transcriptExport.metaExportedAt'), + metaTurnCount: t('transcriptExport.metaTurnCount'), + metaScope: t('transcriptExport.metaScope'), + metaWorkspace: t('shared:features.workspace'), + turnHeading: (index: number) => t('transcriptExport.turnHeading', { index }), + userHeading: t('transcriptExport.userHeading'), + assistantHeading: t('transcriptExport.assistantHeading'), + toolHeading: (toolName: string) => t('transcriptExport.toolHeading', { name: toolName }), + }; +} diff --git a/src/web-ui/src/flow_chat/utils/userInputText.ts b/src/web-ui/src/flow_chat/utils/userInputText.ts new file mode 100644 index 0000000000..390b63da1f --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/userInputText.ts @@ -0,0 +1,35 @@ +/** + * User-input text normalization shared by runtime event handling, session + * hydration, and transcript export. + */ + +/** + * Strip agent-internal XML wrapper tags from persisted / forwarded user inputs. + * Handles both normal and forwarded-agent envelopes. + */ +export function cleanRemoteUserInput(raw: string): string { + const s = raw.trim(); + const userQueryMatch = s.match(/\s*([\s\S]*?)\s*<\/user_query>/); + if (userQueryMatch) { + return userQueryMatch[1].trim(); + } + + return s + .replace(/[\s\S]*?<\/system(?:_|-)reminder>/g, '') + .trim(); +} + +/** + * Display text for a persisted user message: the original authored text when + * the turn recorded one, otherwise the stored content with wrappers removed. + */ +export function resolvePersistedUserMessageText( + content: string, + metadata?: Record +): string { + const originalText = metadata?.original_text; + if (typeof originalText === 'string' && originalText.trim()) { + return originalText; + } + return cleanRemoteUserInput(content || ''); +} diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index f30f8bfc3f..1b6a351d8f 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -168,6 +168,9 @@ "copySessionId": "Copy ID", "copySessionIdSuccess": "Session ID copied", "copySessionIdFailed": "Failed to copy session ID", + "exportMarkdown": "Export as Markdown", + "exportMarkdownFull": "Export full process", + "exportMarkdownResult": "Export result", "delete": "Delete", "confirmEdit": "Confirm", "cancelEdit": "Cancel", diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 252f3fc7e0..2abd38b09a 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -497,9 +497,10 @@ "copySelection": "Copy Selection", "copyFullContent": "Copy Full Content", "copyContent": "Copy Content", - "copyDialog": "Copy Entire Dialog", "copyToolInput": "Copy Tool Input", - "copyToolOutput": "Copy Tool Output" + "copyToolOutput": "Copy Tool Output", + "copyDialogFull": "Copy Entire Dialog (Full Process)", + "copyDialogResult": "Copy Entire Dialog (Result Only)" }, "widgetContextMenu": { "addToInput": "Add selected element to input", @@ -535,7 +536,23 @@ "spaceToActivate": "Press Space to type", "assistantPlaceholder": "Message {{name}}...", "sendHint": "Enter to send / Ctrl+Enter or Shift+Enter for new line", - "voiceInput": { "start": "Start voice input", "stop": "Stop recording", "preparing": "Preparing microphone", "transcribing": "Transcribing", "disabled": "Voice input is disabled", "unsupported": "Microphone capture is unavailable", "modelMissing": "Download the local speech model first", "cloudPending": "Cloud transcription is configured but not connected yet", "permissionDenied": "Microphone permission was denied", "deviceDisconnected": "The selected microphone was disconnected", "lowVolume": "No voice detected. Check the selected microphone.", "failed": "Voice input failed", "empty": "No speech was recognized", "transcribeOnly": "Insert transcription", "transcribeAndSend": "Transcribe and send" } + "voiceInput": { + "start": "Start voice input", + "stop": "Stop recording", + "preparing": "Preparing microphone", + "transcribing": "Transcribing", + "disabled": "Voice input is disabled", + "unsupported": "Microphone capture is unavailable", + "modelMissing": "Download the local speech model first", + "cloudPending": "Cloud transcription is configured but not connected yet", + "permissionDenied": "Microphone permission was denied", + "deviceDisconnected": "The selected microphone was disconnected", + "lowVolume": "No voice detected. Check the selected microphone.", + "failed": "Voice input failed", + "empty": "No speech was recognized", + "transcribeOnly": "Insert transcription", + "transcribeAndSend": "Transcribe and send" + } }, "workspaceStrip": { "branchTooltipUnavailable": "Not a git repository or no current branch" @@ -791,6 +808,30 @@ "toolCall": "🔧 Tool call: {{name}}", "unknownTool": "Unknown tool" }, + "transcriptExport": { + "copyFull": "Copy full process", + "copyResult": "Copy result", + "copyEmpty": "Nothing to copy in this dialog", + "exportEmpty": "This session has nothing to export", + "exportSuccess": "Session exported: {{filePath}}", + "exportFailed": "Failed to export the session", + "saveDialogTitle": "Export session as Markdown", + "scopeFull": "Full process", + "scopeResult": "Result only", + "metaSessionId": "Session ID", + "metaExportedAt": "Exported at", + "metaTurnCount": "Turns", + "metaScope": "Scope", + "turnHeading": "Turn {{index}}", + "userHeading": "👤 User", + "assistantHeading": "🤖 Assistant", + "toolHeading": "Tool · {{name}}", + "thinking": "Thinking", + "toolInput": "Input", + "toolResult": "Result", + "toolError": "Error", + "empty": "(empty)" + }, "smartRecommendations": { "title": "💡 Smart Recommendations", "close": "Close" diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index bf6a9c36b8..cd8af8cce5 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -168,6 +168,9 @@ "copySessionId": "复制 ID", "copySessionIdSuccess": "已复制会话 ID", "copySessionIdFailed": "复制会话 ID 失败", + "exportMarkdown": "导出为 Markdown", + "exportMarkdownFull": "导出完整过程", + "exportMarkdownResult": "导出结果", "delete": "删除", "confirmEdit": "确认", "cancelEdit": "取消", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 5e6a66461d..1994a02368 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -497,9 +497,10 @@ "copySelection": "复制选中内容", "copyFullContent": "复制完整内容", "copyContent": "复制内容", - "copyDialog": "复制整个对话", "copyToolInput": "复制工具输入", - "copyToolOutput": "复制工具输出" + "copyToolOutput": "复制工具输出", + "copyDialogFull": "复制整个对话(完整过程)", + "copyDialogResult": "复制整个对话(仅结果)" }, "widgetContextMenu": { "addToInput": "把选中元素加入输入框", @@ -535,7 +536,23 @@ "spaceToActivate": "按空格键快速键入", "assistantPlaceholder": "给 {{name}} 发送消息...", "sendHint": "Enter 发送 / Ctrl+Enter 或 Shift+Enter 换行", - "voiceInput": { "start": "开始语音输入", "stop": "停止录音", "preparing": "正在准备麦克风", "transcribing": "正在转写", "disabled": "语音输入已关闭", "unsupported": "当前无法使用麦克风", "modelMissing": "请先下载本地语音模型", "cloudPending": "云端转写已配置,但转写通路尚未接入", "permissionDenied": "麦克风权限被拒绝", "deviceDisconnected": "所选麦克风已断开连接", "lowVolume": "没有检测到声音,请检查所选麦克风。", "failed": "语音输入失败", "empty": "没有识别到语音", "transcribeOnly": "插入转写文本", "transcribeAndSend": "转写并发送" } + "voiceInput": { + "start": "开始语音输入", + "stop": "停止录音", + "preparing": "正在准备麦克风", + "transcribing": "正在转写", + "disabled": "语音输入已关闭", + "unsupported": "当前无法使用麦克风", + "modelMissing": "请先下载本地语音模型", + "cloudPending": "云端转写已配置,但转写通路尚未接入", + "permissionDenied": "麦克风权限被拒绝", + "deviceDisconnected": "所选麦克风已断开连接", + "lowVolume": "没有检测到声音,请检查所选麦克风。", + "failed": "语音输入失败", + "empty": "没有识别到语音", + "transcribeOnly": "插入转写文本", + "transcribeAndSend": "转写并发送" + } }, "workspaceStrip": { "branchTooltipUnavailable": "非 Git 仓库或当前无分支" @@ -791,6 +808,30 @@ "toolCall": "🔧 工具调用: {{name}}", "unknownTool": "未知工具" }, + "transcriptExport": { + "copyFull": "拷贝完整过程", + "copyResult": "拷贝结果", + "copyEmpty": "该对话没有可复制的内容", + "exportEmpty": "该会话没有可导出的内容", + "exportSuccess": "会话已导出:{{filePath}}", + "exportFailed": "导出会话失败", + "saveDialogTitle": "导出会话为 Markdown", + "scopeFull": "完整过程", + "scopeResult": "仅结果", + "metaSessionId": "会话 ID", + "metaExportedAt": "导出时间", + "metaTurnCount": "对话轮数", + "metaScope": "导出范围", + "turnHeading": "第 {{index}} 轮", + "userHeading": "👤 用户", + "assistantHeading": "🤖 助手", + "toolHeading": "工具 · {{name}}", + "thinking": "思考", + "toolInput": "输入", + "toolResult": "结果", + "toolError": "错误", + "empty": "(空)" + }, "smartRecommendations": { "title": "💡 智能推荐", "close": "关闭" diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index 60eb078584..1cf6c35257 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -168,6 +168,9 @@ "copySessionId": "複製 ID", "copySessionIdSuccess": "已複製會話 ID", "copySessionIdFailed": "複製會話 ID 失敗", + "exportMarkdown": "匯出為 Markdown", + "exportMarkdownFull": "匯出完整過程", + "exportMarkdownResult": "匯出結果", "delete": "刪除", "confirmEdit": "確認", "cancelEdit": "取消", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index 014e247cca..56b83c1fa4 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -497,9 +497,10 @@ "copySelection": "複製選中內容", "copyFullContent": "複製完整內容", "copyContent": "複製內容", - "copyDialog": "複製整個對話", "copyToolInput": "複製工具輸入", - "copyToolOutput": "複製工具輸出" + "copyToolOutput": "複製工具輸出", + "copyDialogFull": "複製整個對話(完整過程)", + "copyDialogResult": "複製整個對話(僅結果)" }, "widgetContextMenu": { "addToInput": "把選中元素加入輸入框", @@ -535,7 +536,23 @@ "spaceToActivate": "按空格鍵快速鍵入", "assistantPlaceholder": "傳訊息給 {{name}}...", "sendHint": "Enter 傳送 / Ctrl+Enter 或 Shift+Enter 換行", - "voiceInput": { "start": "開始語音輸入", "stop": "停止錄音", "preparing": "正在準備麥克風", "transcribing": "正在轉寫", "disabled": "語音輸入已關閉", "unsupported": "目前無法使用麥克風", "modelMissing": "請先下載本地語音模型", "cloudPending": "雲端轉寫已設定,但轉寫通路尚未接入", "permissionDenied": "麥克風權限被拒絕", "deviceDisconnected": "所選麥克風已中斷連線", "lowVolume": "沒有偵測到聲音,請檢查所選麥克風。", "failed": "語音輸入失敗", "empty": "沒有辨識到語音", "transcribeOnly": "插入轉寫文字", "transcribeAndSend": "轉寫並傳送" } + "voiceInput": { + "start": "開始語音輸入", + "stop": "停止錄音", + "preparing": "正在準備麥克風", + "transcribing": "正在轉寫", + "disabled": "語音輸入已關閉", + "unsupported": "目前無法使用麥克風", + "modelMissing": "請先下載本地語音模型", + "cloudPending": "雲端轉寫已設定,但轉寫通路尚未接入", + "permissionDenied": "麥克風權限被拒絕", + "deviceDisconnected": "所選麥克風已中斷連線", + "lowVolume": "沒有偵測到聲音,請檢查所選麥克風。", + "failed": "語音輸入失敗", + "empty": "沒有辨識到語音", + "transcribeOnly": "插入轉寫文字", + "transcribeAndSend": "轉寫並傳送" + } }, "workspaceStrip": { "branchTooltipUnavailable": "非 Git 存放庫或目前無分支" @@ -791,6 +808,30 @@ "toolCall": "🔧 工具調用: {{name}}", "unknownTool": "未知工具" }, + "transcriptExport": { + "copyFull": "拷貝完整過程", + "copyResult": "拷貝結果", + "copyEmpty": "該對話沒有可複製的內容", + "exportEmpty": "該工作階段沒有可匯出的內容", + "exportSuccess": "工作階段已匯出:{{filePath}}", + "exportFailed": "匯出工作階段失敗", + "saveDialogTitle": "將工作階段匯出為 Markdown", + "scopeFull": "完整過程", + "scopeResult": "僅結果", + "metaSessionId": "工作階段 ID", + "metaExportedAt": "匯出時間", + "metaTurnCount": "對話輪數", + "metaScope": "匯出範圍", + "turnHeading": "第 {{index}} 輪", + "userHeading": "👤 使用者", + "assistantHeading": "🤖 助理", + "toolHeading": "工具 · {{name}}", + "thinking": "思考", + "toolInput": "輸入", + "toolResult": "結果", + "toolError": "錯誤", + "empty": "(空)" + }, "smartRecommendations": { "title": "💡 智能推薦", "close": "關閉" diff --git a/src/web-ui/src/shared/context-menu-system/providers/FlowChatMenuProvider.ts b/src/web-ui/src/shared/context-menu-system/providers/FlowChatMenuProvider.ts index 044d14b740..4bdf9bcc31 100644 --- a/src/web-ui/src/shared/context-menu-system/providers/FlowChatMenuProvider.ts +++ b/src/web-ui/src/shared/context-menu-system/providers/FlowChatMenuProvider.ts @@ -81,12 +81,26 @@ export class FlowChatMenuProvider implements IMenuProvider { items.push({ id: 'flowchat-copy-dialog', - label: i18nService.t('flow-chat:contextMenu.copyDialog'), + label: i18nService.t('flow-chat:contextMenu.copyDialogFull'), icon: 'MessageSquare', onClick: () => { - globalEventBus.emit('flowchat:copy-dialog', { - dialogTurn, - context: flowChatContext + globalEventBus.emit('flowchat:copy-dialog', { + dialogTurn, + scope: 'full', + context: flowChatContext + }); + } + }); + + items.push({ + id: 'flowchat-copy-dialog-result', + label: i18nService.t('flow-chat:contextMenu.copyDialogResult'), + icon: 'MessageSquare', + onClick: () => { + globalEventBus.emit('flowchat:copy-dialog', { + dialogTurn, + scope: 'result', + context: flowChatContext }); } }); diff --git a/src/web-ui/src/shared/utils/browserDownload.ts b/src/web-ui/src/shared/utils/browserDownload.ts new file mode 100644 index 0000000000..25f34bc519 --- /dev/null +++ b/src/web-ui/src/shared/utils/browserDownload.ts @@ -0,0 +1,24 @@ +/** + * Browser fallback for "save as file" flows. + * + * The desktop build saves through the Tauri dialog + fs plugins; the browser + * build has no filesystem, so it hands the payload to the download manager. + */ + +export function downloadTextFileInBrowser( + fileName: string, + content: string, + mimeType = 'text/plain;charset=utf-8' +): void { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = fileName; + anchor.click(); + URL.revokeObjectURL(url); +} + +export function downloadMarkdownInBrowser(fileName: string, markdown: string): void { + downloadTextFileInBrowser(fileName, markdown, 'text/markdown;charset=utf-8'); +}