接入创作工作台原生文档导入
创作 Agent 文档上传在原生壳内优先调用 HostBridge 文本导入 宿主返回文本继续转换为 File 并复用现有后端文档解析链路 补充工作台原生导入测试和宿主壳文档边界
This commit is contained in:
@@ -2305,3 +2305,10 @@
|
|||||||
- 决策:新增 HostBridge method `file.exportAudio`、H5 facade `exportHostAudioFile()` 和通用音频输入面板导出入口。H5 只传当前页面已持有的 `base64Data`、清洗后的文件名和允许的 `audio/mpeg`、`audio/mp4`、`audio/wav`、`audio/ogg`、`audio/webm` MIME;移动端写入 Expo 缓存音频后交给系统分享 / 保存面板,桌面端打开 Tauri 系统保存对话框并写入音频字节。单次不超过 20 MiB,成功只返回文件名和字节数。`CreativeAudioInputPanel` 只在当前资产包含本地 `Blob`、`fileName`、允许 MIME 且宿主声明 `file.exportAudio` 时显示导出入口;远端已上传音频不展示导出。
|
- 决策:新增 HostBridge method `file.exportAudio`、H5 facade `exportHostAudioFile()` 和通用音频输入面板导出入口。H5 只传当前页面已持有的 `base64Data`、清洗后的文件名和允许的 `audio/mpeg`、`audio/mp4`、`audio/wav`、`audio/ogg`、`audio/webm` MIME;移动端写入 Expo 缓存音频后交给系统分享 / 保存面板,桌面端打开 Tauri 系统保存对话框并写入音频字节。单次不超过 20 MiB,成功只返回文件名和字节数。`CreativeAudioInputPanel` 只在当前资产包含本地 `Blob`、`fileName`、允许 MIME 且宿主声明 `file.exportAudio` 时显示导出入口;远端已上传音频不展示导出。
|
||||||
- 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`src/services/host-bridge/hostBridge.ts`、`src/components/common/CreativeAudioInputPanel.tsx`、`apps/mobile-shell/`、`apps/desktop-shell/`、原生壳能力检查脚本和 HostBridge 架构文档。
|
- 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`src/services/host-bridge/hostBridge.ts`、`src/components/common/CreativeAudioInputPanel.tsx`、`apps/mobile-shell/`、`apps/desktop-shell/`、原生壳能力检查脚本和 HostBridge 架构文档。
|
||||||
- 验证方式:`npm run check:native-shells`、`npm run test -- src/components/common/CreativeAudioInputPanel.test.tsx`、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
- 验证方式:`npm run check:native-shells`、`npm run test -- src/components/common/CreativeAudioInputPanel.test.tsx`、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||||
|
|
||||||
|
## 2026-06-18 创作 Agent 文档上传接入原生壳文本导入
|
||||||
|
|
||||||
|
- 背景:创作 Agent 工作台已有“上传文档”入口,但在 Expo / Tauri 壳内仍只触发浏览器隐藏文件输入;移动和桌面壳已经具备 `file.importText` 的真实系统文档选择能力。
|
||||||
|
- 决策:创作 Agent 工作台在 `native_app` 且宿主声明 `file.importText` 时优先调用 `importHostTextFile()`,将宿主返回的 UTF-8 文本副本转换成浏览器 `File` 后继续调用现有 `/api/runtime/creation-agent/document-inputs/parse`。原生壳只负责受控选择和返回文本内容,不暴露设备 URI、本机路径或通用文件系统,也不在前端绕过后端文档解析、256KB 解析限制、docx 支持或错误口径。普通浏览器、小程序和未声明该能力的裁剪壳继续使用原 `<input type="file">` 路径。
|
||||||
|
- 影响范围:`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`。
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -58,7 +58,7 @@ AI H5 sandbox
|
|||||||
- `openHostExternalUrl()`:原生 App 宿主的受控外链入口。H5 中需要离开主站的外链在 `native_app` 下先通过 `app.openExternalUrl` 请求宿主系统浏览器打开;只允许 `http:`、`https:`、`mailto:`、`tel:`,相对路径会先归一化到当前站点绝对 URL。宿主不可用或拒绝时回退浏览器外链行为,普通浏览器和小程序保持原有 `<a>` 语义。
|
- `openHostExternalUrl()`:原生 App 宿主的受控外链入口。H5 中需要离开主站的外链在 `native_app` 下先通过 `app.openExternalUrl` 请求宿主系统浏览器打开;只允许 `http:`、`https:`、`mailto:`、`tel:`,相对路径会先归一化到当前站点绝对 URL。宿主不可用或拒绝时回退浏览器外链行为,普通浏览器和小程序保持原有 `<a>` 语义。
|
||||||
- `navigateHostNativePage()`:受控跳转宿主页,供订阅授权、支付、登录等 adapter 复用。Expo 移动壳首版只接受同源 H5 route 并切换 WebView URL;Tauri 桌面壳同样只接受 `https://app.genarrative.world` 同源 H5 route 并在主窗口内跳转。真正原生页面、登录和支付能力必须等对应 SDK / 页面接入后再声明支持。
|
- `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 承接。
|
||||||
- `importHostTextFile()`:原生 App 宿主的受控文本导入入口。Expo 移动壳通过 Expo DocumentPicker 打开系统文档选择器,Tauri 桌面壳通过系统文件选择框读取用户选择的文本文件;两端都只接受 `text/plain`、`text/markdown`、`text/csv`、`application/json` 或对应扩展名,单次不超过 5 MiB,成功只返回清洗后的文件名、MIME、UTF-8 文本内容和字节数,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;用户取消时由 H5 facade 归为 `false`。
|
- `importHostTextFile()`:原生 App 宿主的受控文本导入入口。Expo 移动壳通过 Expo DocumentPicker 打开系统文档选择器,Tauri 桌面壳通过系统文件选择框读取用户选择的文本文件;两端都只接受 `text/plain`、`text/markdown`、`text/csv`、`application/json` 或对应扩展名,单次不超过 5 MiB,成功只返回清洗后的文件名、MIME、UTF-8 文本内容和字节数,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;用户取消时由 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`,宿主未声明时保留浏览器下载路径。
|
- `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` 回调;在桌面壳同时声明 `file.imageDropped` 时,只有拖入坐标命中当前主图卡片且未被上层元素遮挡的面板会消费该事件。普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。
|
- `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` 回调;在桌面壳同时声明 `file.imageDropped` 时,只有拖入坐标命中当前主图卡片且未被上层元素遮挡的面板会消费该事件。普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。
|
||||||
- `importHostAudioFile()`:原生 App 宿主的受控音频导入入口。Expo 移动壳通过 Expo DocumentPicker 打开系统音频选择器,Tauri 壳通过系统文件选择框读取用户选择的音频;两端都只接受 `audio/mpeg`、`audio/mp4`、`audio/wav`、`audio/ogg`、`audio/webm` 或对应扩展名,单次不超过 20 MiB,成功只返回清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力。H5 的通用音频输入面板 `CreativeAudioInputPanel` 在 `native_app` 且声明 `file.importAudio` 时优先调用宿主导入,并把结果转换成现有 `File` 后继续复用 `readFileAsAsset(file, 'uploaded')` 音频处理链路;普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。
|
- `importHostAudioFile()`:原生 App 宿主的受控音频导入入口。Expo 移动壳通过 Expo DocumentPicker 打开系统音频选择器,Tauri 壳通过系统文件选择框读取用户选择的音频;两端都只接受 `audio/mpeg`、`audio/mp4`、`audio/wav`、`audio/ogg`、`audio/webm` 或对应扩展名,单次不超过 20 MiB,成功只返回清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力。H5 的通用音频输入面板 `CreativeAudioInputPanel` 在 `native_app` 且声明 `file.importAudio` 时优先调用宿主导入,并把结果转换成现有 `File` 后继续复用 `readFileAsAsset(file, 'uploaded')` 音频处理链路;普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { afterEach, expect, test, vi } from 'vitest';
|
|||||||
|
|
||||||
import * as creationAgentServices from '../../services/creation-agent';
|
import * as creationAgentServices from '../../services/creation-agent';
|
||||||
import { createCreationAgentChatQuickActions } from '../../services/creation-agent';
|
import { createCreationAgentChatQuickActions } from '../../services/creation-agent';
|
||||||
|
import * as hostBridgeServices from '../../services/host-bridge/hostBridge';
|
||||||
import {
|
import {
|
||||||
type CreationAgentTheme,
|
type CreationAgentTheme,
|
||||||
CreationAgentWorkspace,
|
CreationAgentWorkspace,
|
||||||
@@ -642,6 +643,71 @@ test('creation agent workspace appends parsed document text into composer', asyn
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('creation agent workspace imports document text through native HostBridge', async () => {
|
||||||
|
ensureScrollApis();
|
||||||
|
|
||||||
|
const inputClickSpy = vi
|
||||||
|
.spyOn(HTMLInputElement.prototype, 'click')
|
||||||
|
.mockImplementation(() => undefined);
|
||||||
|
vi.spyOn(hostBridgeServices, 'canUseNativeHostCapability').mockReturnValue(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
vi.spyOn(hostBridgeServices, 'importHostTextFile').mockResolvedValue({
|
||||||
|
action: 'selected',
|
||||||
|
fileName: '世界设定.md',
|
||||||
|
content: '原生壳导入的世界设定',
|
||||||
|
mimeType: 'text/markdown',
|
||||||
|
bytes: 30,
|
||||||
|
});
|
||||||
|
const parseSpy = vi
|
||||||
|
.spyOn(creationAgentServices, 'parseCreationAgentDocumentInput')
|
||||||
|
.mockResolvedValue({
|
||||||
|
document: {
|
||||||
|
fileName: '世界设定.md',
|
||||||
|
contentType: 'text/markdown',
|
||||||
|
sizeBytes: 30,
|
||||||
|
text: '第一章:雾中的钟楼',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
render(
|
||||||
|
<CreationAgentWorkspace
|
||||||
|
session={{
|
||||||
|
sessionId: 'creation-agent-session-1',
|
||||||
|
title: null,
|
||||||
|
currentTurn: 0,
|
||||||
|
progressPercent: 0,
|
||||||
|
anchors: [],
|
||||||
|
messages: [],
|
||||||
|
}}
|
||||||
|
theme={testTheme}
|
||||||
|
loadingText="正在准备"
|
||||||
|
composerPlaceholder="输入消息"
|
||||||
|
primaryActionLabel="生成结果页"
|
||||||
|
onBack={() => {}}
|
||||||
|
onSubmitText={() => {}}
|
||||||
|
onPrimaryAction={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '上传文档' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
(screen.getByPlaceholderText('输入消息') as HTMLTextAreaElement).value,
|
||||||
|
).toBe('第一章:雾中的钟楼');
|
||||||
|
});
|
||||||
|
expect(parseSpy).toHaveBeenCalledWith(expect.any(File));
|
||||||
|
const parsedFile = parseSpy.mock.calls[0]?.[0] as File;
|
||||||
|
expect(parsedFile.name).toBe('世界设定.md');
|
||||||
|
expect(parsedFile.type).toBe('text/markdown');
|
||||||
|
expect(inputClickSpy).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
inputClickSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('creation agent workspace renders selected reference image with shared preview row', async () => {
|
test('creation agent workspace renders selected reference image with shared preview row', async () => {
|
||||||
ensureScrollApis();
|
ensureScrollApis();
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import {
|
|||||||
parseCreationAgentDocumentInput,
|
parseCreationAgentDocumentInput,
|
||||||
resolveCreationAgentProgressHint,
|
resolveCreationAgentProgressHint,
|
||||||
} from '../../services/creation-agent';
|
} from '../../services/creation-agent';
|
||||||
|
import {
|
||||||
|
canUseNativeHostCapability,
|
||||||
|
importHostTextFile,
|
||||||
|
} from '../../services/host-bridge/hostBridge';
|
||||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||||
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
||||||
import { PlatformIconButton } from '../common/PlatformIconButton';
|
import { PlatformIconButton } from '../common/PlatformIconButton';
|
||||||
@@ -421,21 +425,13 @@ export function CreationAgentWorkspace({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const openDocumentInputPicker = () => {
|
const parseAndAppendDocumentInputFile = async (file: File) => {
|
||||||
documentInputRef.current?.click();
|
const response = await parseCreationAgentDocumentInput(file);
|
||||||
|
appendDocumentInputText(response.document.text);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openReferenceImagePicker = () => {
|
const runDocumentInputTask = async (task: () => Promise<void>) => {
|
||||||
referenceImageInputRef.current?.click();
|
if (isBusy || isParsingDocumentInput) {
|
||||||
};
|
|
||||||
|
|
||||||
const handleDocumentInputChange = async (
|
|
||||||
event: ChangeEvent<HTMLInputElement>,
|
|
||||||
) => {
|
|
||||||
const file = event.target.files?.[0] ?? null;
|
|
||||||
event.target.value = '';
|
|
||||||
|
|
||||||
if (!file || isBusy || isParsingDocumentInput) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,8 +439,7 @@ export function CreationAgentWorkspace({
|
|||||||
setDocumentInputError(null);
|
setDocumentInputError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await parseCreationAgentDocumentInput(file);
|
await task();
|
||||||
appendDocumentInputText(response.document.text);
|
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
setDocumentInputError(
|
setDocumentInputError(
|
||||||
parseError instanceof Error
|
parseError instanceof Error
|
||||||
@@ -456,6 +451,45 @@ export function CreationAgentWorkspace({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openDocumentInputPicker = () => {
|
||||||
|
if (isBusy || isParsingDocumentInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canUseNativeHostCapability('file.importText')) {
|
||||||
|
void runDocumentInputTask(async () => {
|
||||||
|
const importedTextFile = await importHostTextFile();
|
||||||
|
if (!importedTextFile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await parseAndAppendDocumentInputFile(
|
||||||
|
new File([importedTextFile.content], importedTextFile.fileName, {
|
||||||
|
type: importedTextFile.mimeType,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
documentInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openReferenceImagePicker = () => {
|
||||||
|
referenceImageInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDocumentInputChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0] ?? null;
|
||||||
|
event.target.value = '';
|
||||||
|
|
||||||
|
if (!file || isBusy || isParsingDocumentInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void runDocumentInputTask(() => parseAndAppendDocumentInputFile(file));
|
||||||
|
};
|
||||||
|
|
||||||
const handleReferenceImageInputChange = async (
|
const handleReferenceImageInputChange = async (
|
||||||
event: ChangeEvent<HTMLInputElement>,
|
event: ChangeEvent<HTMLInputElement>,
|
||||||
) => {
|
) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user