From b88ed43aef4735fbbb3f7cbe1ffe67ac22066bcb Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 19 Jun 2026 08:22:38 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E5=88=9B=E4=BD=9C=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8F=B0=E4=BC=9A=E8=AF=9D=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 创作 Agent 工作台在原生壳中消费 file.exportText 导出会话 Markdown 补充会话导出按钮门控、payload 和错误提示测试 更新宿主壳能力文档和共享决策记录 --- .../shared-memory/decision-log.md | 7 + ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 4 + ...前端架构】宿主壳能力统一协议-2026-06-17.md | 2 +- .../CreationAgentWorkspace.test.tsx | 192 +++++++++++++++ .../creation-agent/CreationAgentWorkspace.tsx | 231 +++++++++++++++++- 5 files changed, 426 insertions(+), 10 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 0e733d942..c5674bf07 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2381,6 +2381,13 @@ - 影响范围:`src/components/creation-agent/CreationAgentWorkspace.tsx`、`src/services/host-bridge/hostBridge.ts`、Expo / Tauri HostBridge 文档。 - 验证方式:`npm run check:native-shells`、`npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx`、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 +## 2026-06-19 创作 Agent 会话导出接入原生壳文本导出 + +- 背景:Expo / Tauri 壳已经实现 `file.exportText`,但 H5 创作 Agent 工作台还没有消费该能力;原生壳内又禁止网页自动下载和 `` 直接落盘,需要通过受控 HostBridge 保存用户可带走的文本。 +- 决策:创作 Agent 工作台在 `native_app` 且宿主声明 `file.exportText` 时显示“导出会话”图标按钮,把当前会话标题、摘要、进度、锚点、消息、流式回复和输入草稿组装为 `text/markdown`,并在 H5 侧按共享 5 MiB 上限计算 UTF-8 byte 后再调用 `exportHostTextFile()`。普通浏览器、小程序、旧壳或裁剪壳不展示该入口;宿主取消或 unsupported 不触发浏览器下载回退,错误统一显示在 composer 上方现有状态条。 +- 影响范围:`src/components/creation-agent/CreationAgentWorkspace.tsx`、`src/services/host-bridge/hostBridge.ts`、Expo / Tauri HostBridge 文档。 +- 验证方式:`npm run check:native-shells`、`npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx`、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 + ## 2026-06-18 反馈凭证上传接入原生壳图片导入 - 背景:帮助与反馈页的上传凭证入口在原生壳内仍只能触发浏览器隐藏文件输入;Expo / Tauri 壳已具备受控 `file.importImage` 图片选择能力。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 40cddf872..58770c239 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -309,6 +309,8 @@ GameBridge 禁止: 2026-06-18 追加:移动壳声明并实现 `file.importText`,通过 Expo DocumentPicker 打开系统文档选择器,只接受 `text/plain`、`text/markdown`、`text/csv`、`application/json` 或对应扩展名,单次不超过 5 MiB;读取文本内容前必须先通过 picker `size` 或 Expo `File.size` 拿到可信 byte count 并完成上限校验,成功只返回清洗后的文件名、MIME、UTF-8 文本内容和字节数,不暴露设备本地 URI,也不开放通用文件系统。H5 创作 Agent 工作台在原生壳声明该能力时优先打开宿主系统选择器,再把返回文本转换成现有浏览器 `File` 并继续调用 `/api/runtime/creation-agent/document-inputs/parse`,不在前端绕过后端文档解析、大小校验或 docx 处理。 +2026-06-19 追加:H5 创作 Agent 工作台在移动壳声明 `file.exportText` 时提供会话 Markdown 导出入口。H5 只把当前会话标题、摘要、进度、锚点、消息、流式回复和输入草稿组装成 `text/markdown` 文本,先按共享 5 MiB 上限计算 UTF-8 byte,再通过 `exportHostTextFile()` 交给 Expo 系统分享 / 保存面板;宿主取消、缺能力或 unsupported 时不做浏览器下载回退,保持原生壳文件保存只走受控 HostBridge 能力。 + 2026-06-18 追加:移动壳图片和音频导入 / 导出不再只信 Expo picker 返回的 MIME 或 H5 声明的 MIME。Expo 壳会对 PNG / JPEG / WebP、MP3 / MP4-M4A / WAV / OGG / WebM 做 base64 bytes 头部识别,导入时要求系统 picker 的 MIME 与 bytes 匹配,导出时要求 H5 payload 的 `mimeType` 与 `base64Data` 解码后的 bytes 匹配;不匹配统一返回 `invalid_request`,不会写入缓存文件、调起系统分享或把内容回传给 H5。移动图片导出还会按 MIME 补齐 `.png` / `.jpg` / `.webp` 文件名扩展,避免系统分享 / 保存面板拿到无扩展或错扩展的缓存图片。 2026-06-18 追加:移动壳音频导入的 DocumentPicker `type` 参数包含 `audio/*`,用于兼容 iOS / Android 文件提供方只按通用音频类型暴露候选文件的情况;这不放宽 HostBridge 返回契约。系统返回 `application/octet-stream`、空 MIME 或其它宽泛 MIME 时,移动壳只会按 `.mp3` / `.m4a` / `.mp4` / `.wav` / `.ogg` / `.webm` 扩展名归一到允许 MIME,并继续执行 base64 bytes 匹配,扩展名或 bytes 任一不匹配都返回 `invalid_request`。 @@ -386,6 +388,8 @@ GameBridge 禁止: 2026-06-18 追加:桌面壳声明并实现 `file.importText`,通过系统文件选择框读取用户选择的文本文件,只接受 `text/plain`、`text/markdown`、`text/csv`、`application/json` 对应扩展名,单次不超过 5 MiB;成功只返回清洗后的文件名、MIME、UTF-8 文本内容和字节数,不暴露本机绝对路径,也不开放通用文件系统。H5 创作 Agent 工作台复用同一 HostBridge 文本导入入口和后端解析接口;普通浏览器、小程序和未声明该能力的裁剪壳继续保留原 `` 路径。 +2026-06-19 追加:桌面壳同样承接 H5 创作 Agent 会话 Markdown 导出。H5 侧只在宿主声明 `file.exportText` 且当前会话存在可导出内容时显示导出按钮,导出前完成 5 MiB UTF-8 byte 校验;Tauri 只负责系统保存对话框、文件名清洗、写入文本和返回文件名 / 字节数,不把 dialog、fs 或本机路径暴露给 H5。 + 2026-06-18 追加:桌面壳图片和音频导入 / 导出不再只信扩展名或 H5 声明的 MIME。Tauri Rust 侧会对 PNG / JPEG / WebP、MP3 / MP4-M4A / WAV / OGG / WebM 做 bytes 头部识别,导入时要求文件扩展名对应的允许 MIME 与真实 bytes 匹配,导出时要求 H5 payload 的 `mimeType` 与 `base64Data` 解码后的 bytes 匹配;不匹配统一返回 `invalid_request`,继续不暴露本机绝对路径或通用文件系统能力。 2026-06-18 追加:H5 个人中心的邀请码填写和兑换码弹窗开始消费 `clipboard.readText`。Tauri 壳仍只通过 Rust 侧 clipboard-manager 返回纯文本,不开放插件 JS guest API;H5 只把文本填入现有输入框,不自动提交,也不把剪贴板内容交给宿主侧业务处理。 diff --git a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md index 98c4c3a34..76163ed26 100644 --- a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md +++ b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md @@ -67,7 +67,7 @@ Tauri 桌面壳启动时必须按 `label="main"` 解析 `tauri.conf.json` 主窗 - `reloadHostWebView()`:原生 App 宿主的受控 WebView 刷新入口。H5 只能请求刷新当前承载主站的宿主 WebView;Expo 移动壳调用当前 `react-native-webview` 的 `reload()`,Tauri 桌面壳调用主 `WebviewWindow.reload()`。该能力不接受 payload,不开放任意 URL 导航、脚本执行、Tauri guest API 或 RN WebView ref;成功只表示宿主已发起刷新,刷新后当前 H5 上下文会卸载。`AuthGate` 在登录态从未登录变为已登录、或从已登录变为未登录时优先调用该能力刷新当前容器;宿主未声明、返回失败或不可用时再回退浏览器 `window.location.reload()`。 - `openHostExternalUrl()`:原生 App 宿主的受控外链入口。H5 中需要离开主站的外链在 `native_app` 下先通过 `app.openExternalUrl` 请求宿主系统浏览器打开;只允许 `http:`、`https:`、`mailto:`、`tel:`,相对路径会先归一化到当前站点绝对 URL。宿主不可用或拒绝时回退浏览器外链行为,普通浏览器和小程序保持原有 `` 语义。 - `navigateHostNativePage()`:受控跳转宿主页,供订阅授权、支付、登录等 adapter 复用。Expo 移动壳首版只接受同源 H5 route 并切换 WebView URL;Tauri 桌面壳同样只接受 `https://app.genarrative.world` 同源 H5 route 并在主窗口内跳转。真正原生页面、登录和支付能力必须等对应 SDK / 页面接入后再声明支持。 -- `exportHostTextFile()`:原生 App 宿主的受控文本导出入口。Expo 移动壳通过 `file.exportText` 写入缓存文本文件并交给系统分享 / 保存面板;Tauri 桌面壳通过 `file.exportText` 打开系统保存对话框并写入用户选择的文件。文件名必须清洗,单次文本不超过 5 MiB,成功只返回文件名和字节数,不把本机绝对路径暴露给 H5;系统分享不可用或用户取消时返回明确错误,由 H5 fallback 承接。 +- `exportHostTextFile()`:原生 App 宿主的受控文本导出入口。Expo 移动壳通过 `file.exportText` 写入缓存文本文件并交给系统分享 / 保存面板;Tauri 桌面壳通过 `file.exportText` 打开系统保存对话框并写入用户选择的文件。文件名必须清洗,单次文本不超过 5 MiB,成功只返回文件名和字节数,不把本机绝对路径暴露给 H5;系统分享不可用或用户取消时返回明确错误,由 H5 fallback 承接。创作 Agent 工作台在 `native_app` 且声明该能力时提供会话 Markdown 导出入口,导出内容只来自当前 H5 已持有的会话标题、摘要、进度、锚点、消息、流式回复和输入草稿,并在 H5 侧先按同一 5 MiB 上限做 UTF-8 byte 校验;普通浏览器、小程序和未声明能力的裁剪壳不展示该入口。 - `importHostTextFile()`:原生 App 宿主的受控文本导入入口。Expo 移动壳通过 Expo DocumentPicker 打开系统文档选择器,Tauri 桌面壳通过系统文件选择框读取用户选择的文本文件;两端都只接受 `text/plain`、`text/markdown`、`text/csv`、`application/json` 或对应扩展名,单次不超过 5 MiB,成功只返回清洗后的文件名、MIME、UTF-8 文本内容和字节数,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;宿主必须在读取文本内容前拿到可信 byte count 并完成上限校验,移动壳在 picker 缺少 `size` 时改用 Expo `File.size`,仍拿不到可信大小时直接拒绝导入;用户取消时由 H5 facade 归为 `false`。创作 Agent 工作台在 `native_app` 且声明该能力时优先调用宿主文本导入,并把结果转换成现有浏览器 `File` 后继续复用后端 `/api/runtime/creation-agent/document-inputs/parse` 解析链路;普通浏览器、小程序和未声明能力的裁剪壳继续使用原文件输入。 - `exportHostImageFile()`:原生 App 宿主的受控图片导出入口。H5 只传自己生成的图片 `base64Data`、清洗后的文件名和允许的 `image/png` / `image/jpeg` / `image/webp` MIME;Expo 移动壳写入缓存图片后交给系统分享 / 保存面板,Tauri 桌面壳打开系统保存对话框并写入图片字节。单次图片不超过 5 MiB,成功只返回文件名和字节数,不回传本机绝对路径。当前分享卡下载在 native app 中优先走 `file.exportImage`,宿主未声明时保留浏览器下载路径。 - `importHostImageFile()` / `captureHostImageFile()` / `subscribeHostImageDrop()`:原生 App 宿主的受控图片导入入口。Expo 移动壳通过 Expo ImagePicker 请求相册权限并打开系统相册选择器,也可在声明 `file.captureImage` 时请求相机权限并打开系统相机拍摄图片;Tauri 壳通过系统文件选择框或主窗口拖拽事件读取用户选择 / 拖入的图片,不声明拍摄能力。图片能力都只接受 `image/png`、`image/jpeg`、`image/webp`,单次不超过 10 MiB,成功只返回文件名、MIME、base64 内容、字节数和可选拖入坐标,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;移动拍摄不请求麦克风权限。H5 的通用图片输入面板 `CreativeImageInputPanel` 在 `native_app` 且声明 `file.importImage` / `file.captureImage` 时分别调用宿主导入 / 拍摄,并把结果转换成现有 `File` 回调;反馈页上传凭证、个人资料头像上传和方洞结果页图片槽位上传在 `native_app` 且声明 `file.importImage` 时同样优先调用宿主图片导入,其中反馈页继续复用原有数量、大小、data URL 和提交 payload 校验,头像继续复用 H5 侧图片类型、5 MiB 大小限制、方形裁剪与 `updateAuthProfile` 上传链路,方洞结果页继续把图片内容写回当前封面 / 背景 / 形状 / 洞口槽位并走现有自动保存和发布链路;在桌面壳同时声明 `file.imageDropped` 时,只有拖入坐标命中当前主图卡片且未被上层元素遮挡的面板会消费该事件。普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。 diff --git a/src/components/creation-agent/CreationAgentWorkspace.test.tsx b/src/components/creation-agent/CreationAgentWorkspace.test.tsx index b43a344cf..2e5826d71 100644 --- a/src/components/creation-agent/CreationAgentWorkspace.test.tsx +++ b/src/components/creation-agent/CreationAgentWorkspace.test.tsx @@ -708,6 +708,198 @@ test('creation agent workspace imports document text through native HostBridge', } }); +test('creation agent workspace exports session markdown through native HostBridge', async () => { + ensureScrollApis(); + + vi.spyOn(hostBridgeServices, 'canUseNativeHostCapability').mockImplementation( + (capability) => capability === 'file.exportText', + ); + const exportSpy = vi + .spyOn(hostBridgeServices, 'exportHostTextFile') + .mockResolvedValue({ + action: 'saved', + fileName: '创作记录-潮声工坊.md', + bytes: 120, + }); + + render( + {}} + onSubmitText={() => {}} + onPrimaryAction={() => {}} + />, + ); + + fireEvent.change(screen.getByPlaceholderText('输入消息'), { + target: { + value: '补充当前草稿', + }, + }); + fireEvent.click(screen.getByRole('button', { name: '导出会话' })); + + await waitFor(() => { + expect(exportSpy).toHaveBeenCalledTimes(1); + }); + + const payload = exportSpy.mock.calls[0]?.[0]; + expect(payload?.fileName).toBe('创作记录-潮声工坊.md'); + expect(payload?.mimeType).toBe('text/markdown'); + expect(payload?.content).toContain('# 潮声工坊'); + expect(payload?.content).toContain('创作进度: 64%'); + expect(payload?.content).toContain('- 基调: 温暖悬疑 (confirmed)'); + expect(payload?.content).toContain('### 用户'); + expect(payload?.content).toContain('主角想找回失踪的师傅。'); + expect(payload?.content).toContain('## 当前输入'); + expect(payload?.content).toContain('补充当前草稿'); +}); + +test('creation agent workspace hides native export when capability or content is missing', () => { + ensureScrollApis(); + + vi.spyOn(hostBridgeServices, 'canUseNativeHostCapability').mockReturnValue( + false, + ); + + const { rerender } = render( + {}} + onSubmitText={() => {}} + onPrimaryAction={() => {}} + />, + ); + + expect(screen.queryByRole('button', { name: '导出会话' })).toBeNull(); + + vi.spyOn(hostBridgeServices, 'canUseNativeHostCapability').mockReturnValue( + true, + ); + rerender( + {}} + onSubmitText={() => {}} + onPrimaryAction={() => {}} + />, + ); + + expect(screen.queryByRole('button', { name: '导出会话' })).toBeNull(); +}); + +test('creation agent workspace shows native export errors near composer', async () => { + ensureScrollApis(); + + vi.spyOn(hostBridgeServices, 'canUseNativeHostCapability').mockImplementation( + (capability) => capability === 'file.exportText', + ); + vi.spyOn(hostBridgeServices, 'exportHostTextFile').mockRejectedValue( + new Error('系统保存面板暂时不可用。'), + ); + + render( + {}} + onSubmitText={() => {}} + onPrimaryAction={() => {}} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: '导出会话' })); + + await waitFor(() => { + const errorMessage = screen.getByText('系统保存面板暂时不可用。'); + + expect(errorMessage).toBeTruthy(); + expect(errorMessage.className).toContain('platform-status-message'); + }); + + expect( + screen.getByRole('button', { name: '导出会话' }).getAttribute('disabled'), + ).toBeNull(); +}); + test('creation agent workspace renders selected reference image with shared preview row', async () => { ensureScrollApis(); diff --git a/src/components/creation-agent/CreationAgentWorkspace.tsx b/src/components/creation-agent/CreationAgentWorkspace.tsx index 1e20c6718..53c95c333 100644 --- a/src/components/creation-agent/CreationAgentWorkspace.tsx +++ b/src/components/creation-agent/CreationAgentWorkspace.tsx @@ -1,7 +1,15 @@ -import { ArrowLeft, ImagePlus, Paperclip, Send, Sparkles } from 'lucide-react'; +import { + ArrowLeft, + Download, + ImagePlus, + Paperclip, + Send, + Sparkles, +} from 'lucide-react'; import type { ChangeEvent } from 'react'; import { useEffect, useRef, useState } from 'react'; +import { HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES } from '../../../packages/shared/src/contracts/hostBridge'; import { type CreationAgentProgressCopy, normalizeCreationAgentProgress, @@ -10,6 +18,7 @@ import { } from '../../services/creation-agent'; import { canUseNativeHostCapability, + exportHostTextFile, importHostTextFile, } from '../../services/host-bridge/hostBridge'; import { PlatformActionButton } from '../common/PlatformActionButton'; @@ -103,6 +112,7 @@ const AUTO_SCROLL_FOLLOW_THRESHOLD_PX = 96; const DOCUMENT_INPUT_ACCEPT = '.txt,.md,.markdown,.docx,.csv,.json,text/plain,text/markdown,text/csv,application/json,application/vnd.openxmlformats-officedocument.wordprocessingml.document'; const REFERENCE_IMAGE_INPUT_ACCEPT = 'image/png,image/jpeg,image/webp'; +const CREATION_AGENT_EXPORT_MIME_TYPE = 'text/markdown'; function uniqueRecommendedReplies(recommendedReplies: string[] = []) { return [ @@ -295,6 +305,147 @@ function scrollMessageListToBottom(container: HTMLDivElement) { container.scrollTop = container.scrollHeight; } +function getCreationAgentMessageRoleLabel(role: string) { + if (role === 'user') { + return '用户'; + } + + if (role === 'assistant') { + return '助手'; + } + + if (role === 'system') { + return '系统'; + } + + return role.trim() || '消息'; +} + +function hasCreationAgentExportableContent( + session: CreationAgentSessionView, + streamingReplyText: string, + draftText: string, +) { + return Boolean( + session.title?.trim() || + session.assistantSummary?.trim() || + session.anchors.some( + (anchor) => anchor.label.trim() || anchor.value.trim(), + ) || + session.messages.some((message) => message.text.trim()) || + streamingReplyText.trim() || + draftText.trim(), + ); +} + +function buildCreationAgentSessionMarkdown({ + session, + progress, + streamingReplyText, + draftText, +}: { + session: CreationAgentSessionView; + progress: number; + streamingReplyText: string; + draftText: string; +}) { + if (!hasCreationAgentExportableContent(session, streamingReplyText, draftText)) { + return null; + } + + const sections: string[] = []; + const title = session.title?.trim() || `创作记录 ${session.sessionId}`; + const summary = session.assistantSummary?.trim(); + const visibleAnchors = session.anchors.filter( + (anchor) => anchor.label.trim() || anchor.value.trim(), + ); + const visibleMessages = session.messages.filter((message) => + message.text.trim(), + ); + const streamingText = streamingReplyText.trim(); + const currentDraftText = draftText.trim(); + + sections.push(`# ${title}`); + sections.push( + [ + `会话 ID: ${session.sessionId}`, + `当前轮次: ${session.currentTurn}`, + `创作进度: ${progress}%`, + ].join('\n'), + ); + + if (summary) { + sections.push(['## 摘要', summary].join('\n\n')); + } + + if (visibleAnchors.length > 0) { + sections.push( + [ + '## 已确认要点', + visibleAnchors + .map((anchor) => { + const label = anchor.label.trim() || anchor.key; + const value = anchor.value.trim(); + const status = anchor.status.trim(); + const statusText = status ? ` (${status})` : ''; + + return `- ${label}: ${value || '未填写'}${statusText}`; + }) + .join('\n'), + ].join('\n\n'), + ); + } + + if (visibleMessages.length > 0 || streamingText) { + const messageSections = visibleMessages.map((message) => { + const roleLabel = getCreationAgentMessageRoleLabel(message.role); + const timeLabel = message.createdAt?.trim(); + const heading = timeLabel ? `${roleLabel} · ${timeLabel}` : roleLabel; + + return [`### ${heading}`, message.text.trim()].join('\n\n'); + }); + + if (streamingText) { + messageSections.push(['### 助手(生成中)', streamingText].join('\n\n')); + } + + sections.push(['## 对话记录', messageSections.join('\n\n')].join('\n\n')); + } + + if (currentDraftText) { + sections.push(['## 当前输入', currentDraftText].join('\n\n')); + } + + return `${sections.join('\n\n')}\n`; +} + +function normalizeCreationAgentExportFileNameSegment(rawValue: string) { + return rawValue + .trim() + .split('') + .map((character) => + /[\u0000-\u001f<>:"/\\|?*]/u.test(character) ? '-' : character, + ) + .join('') + .replace(/\s+/g, ' ') + .replace(/^[.\s-]+/, '') + .slice(0, 80) + .trim(); +} + +function buildCreationAgentExportFileName(session: CreationAgentSessionView) { + const segment = + normalizeCreationAgentExportFileNameSegment(session.title ?? '') || + normalizeCreationAgentExportFileNameSegment(session.sessionId) || + 'creation-agent'; + + return `创作记录-${segment}.md`; +} + +function getUtf8ByteLength(text: string) { + return new TextEncoder().encode(text).length; +} + export function CreationAgentWorkspace({ session, theme, @@ -325,6 +476,7 @@ export function CreationAgentWorkspace({ ); const [isParsingDocumentInput, setIsParsingDocumentInput] = useState(false); const [isReadingReferenceImage, setIsReadingReferenceImage] = useState(false); + const [isExportingSessionText, setIsExportingSessionText] = useState(false); // 统一聊天区只在用户仍停留在底部附近时跟随新内容,避免流式回复持续抢走阅读位置。 const messageListRef = useRef(null); const documentInputRef = useRef(null); @@ -385,6 +537,17 @@ export function CreationAgentWorkspace({ message.role === 'assistant' ? index : lastIndex, -1, ); + const sessionExportMarkdown = buildCreationAgentSessionMarkdown({ + session, + progress, + streamingReplyText, + draftText, + }); + const canExportSessionText = + Boolean(sessionExportMarkdown) && + canUseNativeHostCapability('file.exportText'); + const isComposerFileTaskRunning = + isParsingDocumentInput || isReadingReferenceImage || isExportingSessionText; const armAutoScrollToBottom = () => { shouldAutoScrollRef.current = true; @@ -406,7 +569,7 @@ export function CreationAgentWorkspace({ const submit = () => { const text = draftText.trim(); - if (!text || isBusy || isParsingDocumentInput || isReadingReferenceImage) { + if (!text || isBusy || isComposerFileTaskRunning) { return; } @@ -475,6 +638,42 @@ export function CreationAgentWorkspace({ documentInputRef.current?.click(); }; + const exportSessionText = async () => { + if ( + !sessionExportMarkdown || + isBusy || + isComposerFileTaskRunning || + !canExportSessionText + ) { + return; + } + + const exportBytes = getUtf8ByteLength(sessionExportMarkdown); + if (exportBytes > HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES) { + setDocumentInputError('会话文本超过 5 MiB,暂时无法导出。'); + return; + } + + setIsExportingSessionText(true); + setDocumentInputError(null); + + try { + await exportHostTextFile({ + fileName: buildCreationAgentExportFileName(session), + content: sessionExportMarkdown, + mimeType: CREATION_AGENT_EXPORT_MIME_TYPE, + }); + } catch (exportError) { + setDocumentInputError( + exportError instanceof Error + ? exportError.message + : '导出会话失败,请稍后重试。', + ); + } finally { + setIsExportingSessionText(false); + } + }; + const openReferenceImagePicker = () => { referenceImageInputRef.current?.click(); }; @@ -686,7 +885,7 @@ export function CreationAgentWorkspace({ label={isParsingDocumentInput ? '正在解析文档' : '上传文档'} title={isParsingDocumentInput ? '正在解析文档' : '上传文档'} aria-busy={isParsingDocumentInput} - disabled={isBusy || isParsingDocumentInput} + disabled={isBusy || isComposerFileTaskRunning} onClick={openDocumentInputPicker} icon={ + {canExportSessionText ? ( + { + void exportSessionText(); + }} + icon={ + + } + className="h-11 w-11 shrink-0" + /> + ) : null} {onReferenceImageChange ? (