接入原生壳文档导入

新增 HostBridge file.importDocument 契约与文档 MIME 边界

实现 Expo 与 Tauri 受控文档选择和 base64 返回

创作 Agent 工作台优先使用文档导入并保留文本导入兜底

同步宿主壳门禁、测试、架构文档和共享记忆
This commit is contained in:
2026-06-19 09:35:37 +08:00
parent 030a9fa209
commit f619d861c9
20 changed files with 755 additions and 20 deletions
@@ -1055,6 +1055,10 @@ const sharedHostBridgePayloadLimits = {
sharedContractSource,
'HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES',
),
HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES: extractTsNumberConst(
sharedContractSource,
'HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES',
),
HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES: extractTsNumberConst(
sharedContractSource,
'HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES',
@@ -1105,6 +1109,10 @@ const desktopHostBridgePayloadLimits = {
rustHostSource,
'IMPORT_TEXT_MAX_BYTES',
),
HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES: extractRustNumberConst(
rustHostSource,
'IMPORT_DOCUMENT_MAX_BYTES',
),
HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES: extractRustNumberConst(
rustHostSource,
'EXPORT_IMAGE_MAX_BYTES',
@@ -1593,6 +1601,7 @@ const requiredRustHostSnippets = [
'"clipboard.readText"',
'"file.exportText"',
'"file.importText"',
'"file.importDocument"',
'"file.exportImage"',
'"file.importImage"',
'"file.importAudio"',
@@ -18,6 +18,7 @@ pub(crate) fn capabilities() -> Vec<&'static str> {
"clipboard.readText",
"file.exportText",
"file.importText",
"file.importDocument",
"file.exportImage",
"file.importImage",
"file.importAudio",
@@ -55,6 +56,7 @@ mod tests {
"clipboard.readText",
"file.exportText",
"file.importText",
"file.importDocument",
"file.exportImage",
"file.importImage",
"file.importAudio",
@@ -1,7 +1,8 @@
use crate::host_bridge::capabilities::capabilities;
use crate::host_bridge::files::{
export_audio_payload, export_image_payload, export_text_payload, import_audio_file_payload,
import_image_file_payload, import_text_file_payload, write_export_bytes_file,
import_document_file_payload, import_image_file_payload, import_text_file_payload,
write_export_bytes_file,
write_export_text_file,
};
use crate::host_bridge::protocol::{
@@ -313,6 +314,28 @@ pub(super) async fn execute_host_bridge_request(
Err(error) => failed(request.id, "host_error", error.to_string()),
}
}
"file.importDocument" => {
let file_path = app
.dialog()
.file()
.add_filter("Document", &["txt", "md", "markdown", "csv", "json", "docx"])
.blocking_pick_file();
let Some(file_path) = file_path else {
return failed(request.id, "cancelled", "file import cancelled");
};
let path = match file_path.into_path() {
Ok(path) => path,
Err(error) => return failed(request.id, "host_error", error.to_string()),
};
let import_result =
tauri::async_runtime::spawn_blocking(move || import_document_file_payload(path))
.await;
match import_result {
Ok(Ok(payload)) => ok(request.id, payload),
Ok(Err(error)) => failed(request.id, "invalid_request", error),
Err(error) => failed(request.id, "host_error", error.to_string()),
}
}
"file.exportImage" => {
let (file_name, bytes) = match export_image_payload(&request) {
Ok(payload) => payload,
@@ -608,6 +631,10 @@ mod tests {
.as_array()
.unwrap()
.contains(&json!("file.importText")));
assert!(result["capabilities"]
.as_array()
.unwrap()
.contains(&json!("file.importDocument")));
assert!(result["capabilities"]
.as_array()
.unwrap()
@@ -8,6 +8,7 @@ pub(crate) const EXPORT_TEXT_MAX_BYTES: usize = 5 * 1024 * 1024;
const EXPORT_IMAGE_MAX_BYTES: usize = 5 * 1024 * 1024;
const EXPORT_AUDIO_MAX_BYTES: usize = 20 * 1024 * 1024;
pub(crate) const IMPORT_TEXT_MAX_BYTES: u64 = 5 * 1024 * 1024;
const IMPORT_DOCUMENT_MAX_BYTES: u64 = 5 * 1024 * 1024;
const IMPORT_IMAGE_MAX_BYTES: u64 = 10 * 1024 * 1024;
const IMPORT_AUDIO_MAX_BYTES: u64 = 20 * 1024 * 1024;
const EXPORT_FILE_NAME_FALLBACK: &str = "genarrative-export.txt";
@@ -107,6 +108,20 @@ fn import_text_mime_type(path: &Path) -> Option<&'static str> {
}
}
fn import_document_mime_type(path: &Path) -> Option<&'static str> {
match path
.extension()
.and_then(|extension| extension.to_str())
.map(|extension| extension.to_ascii_lowercase())
.as_deref()
{
Some("docx") => {
Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document")
}
_ => import_text_mime_type(path),
}
}
pub(crate) fn import_text_file_payload(path: PathBuf) -> Result<Value, String> {
if !path.is_file() {
return Err("text file is required".to_string());
@@ -140,6 +155,39 @@ pub(crate) fn import_text_file_payload(path: PathBuf) -> Result<Value, String> {
}))
}
pub(crate) fn import_document_file_payload(path: PathBuf) -> Result<Value, String> {
if !path.is_file() {
return Err("document file is required".to_string());
}
let mime_type = import_document_mime_type(&path)
.ok_or_else(|| "document MIME must be allowed".to_string())?;
let metadata = fs::metadata(&path).map_err(|error| error.to_string())?;
let byte_count = metadata.len();
if byte_count == 0 || byte_count > IMPORT_DOCUMENT_MAX_BYTES {
return Err("document exceeds import size limit".to_string());
}
let bytes = fs::read(&path).map_err(|error| error.to_string())?;
let byte_count = bytes.len() as u64;
if byte_count == 0 || byte_count > IMPORT_DOCUMENT_MAX_BYTES {
return Err("document exceeds import size limit".to_string());
}
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.map(normalize_export_file_name)
.unwrap_or_else(|| "genarrative-import-document.txt".to_string());
Ok(json!({
"action": "selected",
"fileName": file_name,
"base64Data": BASE64_STANDARD.encode(bytes),
"mimeType": mime_type,
"bytes": byte_count,
}))
}
fn export_image_extension(mime_type: &str) -> Option<&'static str> {
match mime_type {
"image/png" => Some("png"),
@@ -652,6 +700,81 @@ mod tests {
fs::remove_file(large_path).expect("remove large text");
}
#[test]
fn import_document_file_payload_reads_docx_without_exposing_path() {
let path = std::env::temp_dir().join(format!(
"genarrative-host-bridge-import-document-{}.docx",
std::process::id()
));
let bytes = b"PK\x03\x04docx".to_vec();
fs::write(&path, &bytes).expect("write import document");
let payload = import_document_file_payload(path.clone()).expect("payload");
assert_eq!(payload["action"], "selected");
assert_eq!(
payload["fileName"],
path.file_name().unwrap().to_str().unwrap()
);
assert_eq!(payload["base64Data"], BASE64_STANDARD.encode(&bytes));
assert_eq!(
payload["mimeType"],
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
);
assert_eq!(payload["bytes"], bytes.len() as u64);
assert!(!payload
.to_string()
.contains(path.to_string_lossy().as_ref()));
fs::remove_file(path).expect("remove import document");
}
#[test]
fn import_document_file_payload_reuses_text_document_mime_types() {
let path = std::env::temp_dir().join(format!(
"genarrative-host-bridge-import-document-{}.md",
std::process::id()
));
fs::write(&path, "暖灯猫街").expect("write import document");
let payload = import_document_file_payload(path.clone()).expect("payload");
assert_eq!(payload["base64Data"], BASE64_STANDARD.encode("暖灯猫街"));
assert_eq!(payload["mimeType"], "text/markdown");
assert_eq!(payload["bytes"], "暖灯猫街".len() as u64);
fs::remove_file(path).expect("remove import document");
}
#[test]
fn import_document_file_payload_rejects_invalid_or_oversized_documents() {
let image_path = std::env::temp_dir().join(format!(
"genarrative-host-bridge-import-document-{}.png",
std::process::id()
));
fs::write(&image_path, b"text").expect("write image-like document");
assert_eq!(
import_document_file_payload(image_path.clone()).unwrap_err(),
"document MIME must be allowed"
);
fs::remove_file(image_path).expect("remove image-like document");
let large_path = std::env::temp_dir().join(format!(
"genarrative-host-bridge-import-document-large-{}.docx",
std::process::id()
));
fs::write(
&large_path,
vec![b'a'; IMPORT_DOCUMENT_MAX_BYTES as usize + 1],
)
.expect("write large document");
assert_eq!(
import_document_file_payload(large_path.clone()).unwrap_err(),
"document exceeds import size limit"
);
fs::remove_file(large_path).expect("remove large document");
}
#[test]
fn export_image_payload_decodes_allowed_image_base64() {
let mut valid = request("file.exportImage");
@@ -5,7 +5,7 @@ use std::sync::{Arc, Condvar, Mutex};
pub(crate) const HOST_BRIDGE_PROTOCOL: &str = "GenarrativeHostBridge";
pub(crate) const HOST_BRIDGE_VERSION: u8 = 1;
pub(crate) const HOST_BRIDGE_METHODS: [&str; 24] = [
pub(crate) const HOST_BRIDGE_METHODS: [&str; 25] = [
"host.getRuntime",
"appearance.getColorScheme",
"auth.requestLogin",
@@ -22,6 +22,7 @@ pub(crate) const HOST_BRIDGE_METHODS: [&str; 24] = [
"clipboard.readText",
"file.exportText",
"file.importText",
"file.importDocument",
"file.exportImage",
"file.importImage",
"file.captureImage",
@@ -641,6 +641,7 @@ for (const boundaryImport of sharedPayloadBoundaryImports) {
const forbiddenLocalPayloadBoundaryDeclarations = [
'EXPORT_TEXT_MAX_BYTES',
'IMPORT_TEXT_MAX_BYTES',
'IMPORT_DOCUMENT_MAX_BYTES',
'EXPORT_IMAGE_MAX_BYTES',
'IMPORT_IMAGE_MAX_BYTES',
'EXPORT_AUDIO_MAX_BYTES',
@@ -648,12 +649,14 @@ const forbiddenLocalPayloadBoundaryDeclarations = [
'HOST_BRIDGE_BADGE_COUNT_MAX',
'HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES',
'HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES',
'HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES',
'HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES',
'HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES',
'HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES',
'HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES',
'HOST_BRIDGE_QR_CODE_VALUE_MAX_LENGTH',
'HOST_BRIDGE_TEXT_MIME_TYPES',
'HOST_BRIDGE_DOCUMENT_MIME_TYPES',
'HOST_BRIDGE_IMAGE_MIME_TYPES',
'HOST_BRIDGE_AUDIO_MIME_TYPES',
];
@@ -671,6 +674,7 @@ if (!hostBridgeSource.includes('function assertImportedFileSizeWithinLimit')) {
for (const [functionName, readCall] of [
['importTextFile', 'file.text()'],
['importDocumentFile', 'file.base64()'],
['importAudioFile', 'file.base64()'],
]) {
const functionBody = extractFunctionBody(hostBridgeSource, functionName);
@@ -1339,6 +1343,7 @@ if (
for (const snippet of [
'file.exportText',
'file.importText',
'file.importDocument',
'file.exportImage',
'file.importImage',
'file.captureImage',
@@ -1367,6 +1372,7 @@ for (const snippet of [
'cancelQrCodeScan',
'failQrCodeScan',
'subscribeQrScannerState',
'MOBILE_DOCUMENT_PICKER_TYPES',
'MOBILE_AUDIO_DOCUMENT_PICKER_TYPES',
"'audio/*'",
'File(asset.uri)',
@@ -1495,6 +1501,7 @@ for (const capability of [
'clipboard.readText',
'file.exportText',
'file.importText',
'file.importDocument',
'file.exportImage',
'file.importImage',
'file.captureImage',
@@ -77,6 +77,7 @@ const MP4_AUDIO_BASE64 = encodeBytes([
0, 0, 0, 0x18, 0x66, 0x74, 0x79, 0x70, 0x4d, 0x34, 0x41, 0x20,
]);
const WEBM_BASE64 = encodeBytes([0x1a, 0x45, 0xdf, 0xa3, 0x01, 0x00]);
const DOCX_BASE64 = Buffer.from('PK\x03\x04docx', 'binary').toString('base64');
vi.mock('expo-clipboard', () => ({
getStringAsync: vi.fn(),
@@ -369,6 +370,9 @@ describe('handleMobileHostBridgeMessage', () => {
expect(
(okResponse.result as { capabilities: string[] }).capabilities,
).toContain('file.importText');
expect(
(okResponse.result as { capabilities: string[] }).capabilities,
).toContain('file.importDocument');
expect(
(okResponse.result as { capabilities: string[] }).capabilities,
).toContain('file.importImage');
@@ -1107,6 +1111,130 @@ describe('handleMobileHostBridgeMessage', () => {
expect(expectFailed(oversized).error.code).toBe('invalid_request');
});
test('file.importDocument 调起系统文档选择器并返回受控 DOCX 数据', async () => {
fileBase64Data.set('file:///private/mobile/world.docx', DOCX_BASE64);
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
canceled: false,
assets: [
{
uri: 'file:///private/mobile/world.docx',
name: ' ../世界:设定?.docx ',
mimeType:
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
size: 8,
lastModified: 1,
},
],
});
const response = await send(request('file.importDocument'));
expect(expectOk(response).result).toEqual({
action: 'selected',
fileName: '世界-设定-.docx',
base64Data: DOCX_BASE64,
mimeType:
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
bytes: 8,
});
expect(DocumentPicker.getDocumentAsync).toHaveBeenCalledWith({
copyToCacheDirectory: true,
multiple: false,
type: [
'text/*',
'text/plain',
'text/markdown',
'text/csv',
'application/json',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
],
});
});
test('file.importDocument 在系统选择结果缺少 size 时先用文件大小门禁', async () => {
fileBase64Data.set('file:///private/mobile/world.docx', DOCX_BASE64);
fileSizes.set('file:///private/mobile/world.docx', 8);
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
canceled: false,
assets: [
{
uri: 'file:///private/mobile/world.docx',
name: 'world.docx',
mimeType: 'application/octet-stream',
lastModified: 1,
},
],
});
const response = await send(request('file.importDocument'));
expect(expectOk(response).result).toEqual({
action: 'selected',
fileName: 'world.docx',
base64Data: DOCX_BASE64,
mimeType:
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
bytes: 8,
});
expect(fileBase64Reads).toEqual(['file:///private/mobile/world.docx']);
fileSizes.set('file:///private/mobile/world.docx', 5 * 1024 * 1024 + 1);
fileBase64Reads.length = 0;
const oversized = await send(request('file.importDocument'));
expect(expectFailed(oversized).error.code).toBe('invalid_request');
expect(fileBase64Reads).toEqual([]);
});
test('file.importDocument 取消选择并拒绝非法 MIME 与超限文档', async () => {
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
canceled: true,
assets: null,
});
const cancelled = await send(request('file.importDocument'));
expect(expectFailed(cancelled).error.code).toBe('cancelled');
fileBase64Data.set('file:///private/mobile/story.png', DOCX_BASE64);
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
canceled: false,
assets: [
{
uri: 'file:///private/mobile/story.png',
name: 'story.png',
mimeType: 'image/png',
size: 8,
lastModified: 1,
},
],
});
const unsupportedMime = await send(request('file.importDocument'));
expect(expectFailed(unsupportedMime).error.code).toBe('invalid_request');
fileBase64Data.set('file:///private/mobile/world.docx', DOCX_BASE64);
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
canceled: false,
assets: [
{
uri: 'file:///private/mobile/world.docx',
name: 'world.docx',
mimeType:
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
size: 5 * 1024 * 1024 + 1,
lastModified: 1,
},
],
});
const oversized = await send(request('file.importDocument'));
expect(expectFailed(oversized).error.code).toBe('invalid_request');
});
test('file.exportImage 写入缓存图片并调起系统分享', async () => {
const response = await send(
request('file.exportImage', {
@@ -36,6 +36,7 @@ import {
exportImageFile,
exportTextFile,
importAudioFile,
importDocumentFile,
importImageFile,
importTextFile,
} from './files';
@@ -277,6 +278,8 @@ export async function dispatchMobileHostBridgeRequest(
return ok(request, await exportTextFile(request.payload));
case 'file.importText':
return ok(request, await importTextFile());
case 'file.importDocument':
return ok(request, await importDocumentFile());
case 'file.exportImage':
return ok(request, await exportImageFile(request.payload));
case 'file.importImage':
@@ -11,18 +11,22 @@ import {
type FileExportTextPayload,
type FileExportTextResult,
type FileImportAudioResult,
type FileImportDocumentResult,
type FileImportImageResult,
type FileImportTextResult,
type HostBridgeAudioMimeType,
type HostBridgeDocumentMimeType,
type HostBridgeError,
type HostBridgeImageMimeType,
type HostBridgeTextMimeType,
HOST_BRIDGE_AUDIO_MIME_TYPES,
HOST_BRIDGE_DOCUMENT_MIME_TYPES,
HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES,
HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES,
HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES,
HOST_BRIDGE_IMAGE_MIME_TYPES,
HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES,
HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES,
HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES,
HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES,
HOST_BRIDGE_TEXT_MIME_TYPES,
@@ -33,6 +37,9 @@ import { invalidRequest } from './protocol';
const HOST_BRIDGE_TEXT_MIME_TYPE_SET = new Set<HostBridgeTextMimeType>(
HOST_BRIDGE_TEXT_MIME_TYPES,
);
const HOST_BRIDGE_DOCUMENT_MIME_TYPE_SET = new Set<HostBridgeDocumentMimeType>(
HOST_BRIDGE_DOCUMENT_MIME_TYPES,
);
const HOST_BRIDGE_IMAGE_MIME_TYPE_SET = new Set<HostBridgeImageMimeType>(
HOST_BRIDGE_IMAGE_MIME_TYPES,
);
@@ -43,6 +50,10 @@ export const MOBILE_AUDIO_DOCUMENT_PICKER_TYPES = [
'audio/*',
...HOST_BRIDGE_AUDIO_MIME_TYPES,
];
export const MOBILE_DOCUMENT_PICKER_TYPES = [
'text/*',
...HOST_BRIDGE_DOCUMENT_MIME_TYPES,
];
const BASE64_ALPHABET =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
@@ -294,6 +305,31 @@ function normalizeImportedTextMimeType(
return null;
}
function normalizeImportedDocumentMimeType(
value: unknown,
fileName: string,
): HostBridgeDocumentMimeType | null {
if (typeof value === 'string') {
const mimeType = value.toLowerCase();
if (
HOST_BRIDGE_DOCUMENT_MIME_TYPE_SET.has(
mimeType as HostBridgeDocumentMimeType,
)
) {
return mimeType as HostBridgeDocumentMimeType;
}
}
const textMimeType = normalizeImportedTextMimeType(value, fileName);
if (textMimeType) {
return textMimeType;
}
return fileName.toLowerCase().endsWith('.docx')
? 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
: null;
}
function assertImportedFileSizeWithinLimit(
pickerSize: unknown,
file: File,
@@ -372,6 +408,57 @@ export async function importTextFile(): Promise<FileImportTextResult> {
};
}
export async function importDocumentFile(): Promise<FileImportDocumentResult> {
const result = await DocumentPicker.getDocumentAsync({
copyToCacheDirectory: true,
multiple: false,
type: MOBILE_DOCUMENT_PICKER_TYPES,
});
if (result.canceled) {
throw {
code: 'cancelled',
message: 'file import cancelled',
} satisfies HostBridgeError;
}
const asset = result.assets[0];
if (!asset?.uri) {
throw invalidRequest('document file is required');
}
const fileName = normalizeHostBridgeExportFileName(
asset.name || 'genarrative-import-document.txt',
);
const mimeType = normalizeImportedDocumentMimeType(asset.mimeType, fileName);
if (!mimeType) {
throw invalidRequest('mimeType must be an allowed document type');
}
const file = new File(asset.uri);
assertImportedFileSizeWithinLimit(
asset.size,
file,
HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES,
'document exceeds file import size limit',
);
const base64Data = normalizedBase64Data(await file.base64());
if (!base64Data) {
throw invalidRequest('base64Data is required');
}
const bytes = base64DecodedByteLength(base64Data);
if (bytes <= 0 || bytes > HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES) {
throw invalidRequest('document exceeds file import size limit');
}
return {
action: 'selected',
fileName,
base64Data,
mimeType,
bytes,
};
}
export async function exportImageFile(
payload: unknown,
): Promise<FileExportImageResult> {
@@ -75,6 +75,7 @@
- 2026-06-19 草稿生成 HostBridge 消费门禁:`PlatformEntryFlowShellImpl` 只负责派生草稿通知和未读数量,实际宿主同步经 `platformHostBridgeSync.ts` 调用 `showHostLocalNotification` / `setHostAppBadgeCount``npm run check:native-shells` 必须运行该同步层的真实 Tauri transport 测试,并继续覆盖通知模型、未读计数模型、音频导入和文档导入等 H5 HostBridge 消费测试。
- 2026-06-18 剪贴板读取能力:新增 `clipboard.readText` HostBridge capabilityH5 只能读取纯文本结果,契约限制返回文本最多 100000 字符;Expo 壳通过 `expo-clipboard` 读取系统剪贴板文本,Tauri 壳通过 Rust 侧 `tauri-plugin-clipboard-manager` 读取文本且不开放插件 JS guest API。该能力不读取图片、HTML、文件列表或剪贴板监听事件,宿主未声明或读取失败时由 H5 视作失败并保留原流程。
- 2026-06-18 文本文件导入能力:新增 `file.importText` HostBridge capabilityH5 统一通过 `importHostTextFile()` 读取宿主返回的纯文本内容;Expo 壳通过 `expo-document-picker` 打开系统文档选择器,Tauri 壳通过系统文件选择框读取真实文本文件。两端只接受 `text/plain``text/markdown``text/csv``application/json` 或对应扩展名,单次不超过 5 MiB,成功只返回清洗后的文件名、MIME、UTF-8 文本内容和字节数,不暴露设备 URI / 本机绝对路径,也不开放通用文件系统。
- 2026-06-19 文档文件导入能力:新增 `file.importDocument` HostBridge capability,作为创作 Agent 工作台优先导入路径;Expo 壳通过 DocumentPicker、Tauri 壳通过系统文件选择框读取文本类文档或 DOCX 副本。两端只接受文本 MIME / DOCX MIME 或对应扩展名,单次不超过 5 MiB,成功只返回清洗后的文件名、MIME、base64 内容和字节数,不暴露设备 URI、本机绝对路径,也不开放通用文件系统;H5 把返回内容转换成浏览器 `File` 后继续走后端 `/api/runtime/creation-agent/document-inputs/parse`,不在前端解析 DOCX。旧壳只声明 `file.importText` 时继续使用文本导入兜底。
- 2026-06-18 Tauri 系统托盘:桌面壳启用真实 OS 托盘并复用品牌图标,托盘菜单只执行显示主窗口、刷新主窗口和退出应用,左键点击托盘图标恢复并聚焦主窗口;该能力归桌面壳自身,不进入 HostBridge capability,不向 H5 暴露托盘、菜单、shell 或任意窗口控制 API。托盘注册成功时主窗口关闭按钮只隐藏到托盘,必须通过托盘“退出”结束应用;托盘注册失败不得阻断主窗口启动,也不得拦截关闭,避免窗口消失后无法恢复。`check:native-shells` 和 Tauri cargo test 覆盖托盘配置、菜单动作映射和关闭策略。
- 2026-06-18 Tauri 单实例:桌面壳启用 `tauri-plugin-single-instance` 并要求该插件最先注册;重复启动 App 时第二实例退出,只唤醒、取消最小化并聚焦已有主窗口,不把第二实例 argv / cwd 作为事件透传给 H5。Windows / Linux 的二次实例深链只通过 single-instance 的 `deep-link` feature 交给 Tauri deep-link 插件,再由 `shell/deep_link.rs` 做受控 URL 归一。
- 2026-06-18 Tauri 桌面深链:桌面壳启用 `tauri-plugin-deep-link`,但不安装 JS guest 包、不把 deep-link command 加入主窗口 capability,也不新增 HostBridge capability。Tauri 配置只注册 `genarrative` schemeRust 层只接受 `genarrative://open/...``genarrative://app/...``genarrative://<path>``https://app.genarrative.world/...`,统一跳转到同源 H5 并补写 `native_app``tauri_desktop`、当前平台、版本和真实 capability 清单;外域、明文协议和危险协议不进入主 WebView。
@@ -2377,7 +2378,7 @@
## 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">` 路径。
- 决策:创作 Agent 工作台在 `native_app` 且宿主声明 `file.importDocument` 时优先调用 `importHostDocumentFile()`宿主返回的文本类文档或 DOCX base64 副本转换成浏览器 `File` 后继续调用现有 `/api/runtime/creation-agent/document-inputs/parse`;旧壳只声明 `file.importText` 时才调用 `importHostTextFile()` 兜底。原生壳只负责受控选择和返回文档副本,不暴露设备 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`
@@ -2545,7 +2546,7 @@
## 2026-06-19 HostBridge 载荷边界单一来源
- 背景:文件导入导出、剪贴板、角标、本地通知和 request id 都已经在 Expo 与 Tauri 两套壳里有运行时校验;如果 MIME 清单、字节上限或文本长度只靠人工同步,新增文件类型或调整上限时会出现 H5 契约、移动壳和桌面壳互相漂移。
- 决策:`packages/shared/src/contracts/hostBridge.ts` 是 HostBridge 载荷边界的声明来源,导出文本 / 图片 / 音频 MIME 清单、导入 / 导出字节上限、导出文件名 fallback / 长度上限、request id 长度、角标上限、剪贴板文本长度和本地通知标题 / 正文长度。Expo 移动壳必须直接导入这些共享常量,`apps/mobile-shell/scripts/check-config.mjs` 会拒绝移动壳重新本地声明文件大小或 MIME 清单;移动壳 `file.importText` / `file.importAudio` 必须在读取文本内容或音频 base64 前,通过 picker `size` 或 Expo `File.size` 拿到可信 byte count 并完成上限校验,无法拿到可信大小时直接拒绝导入。Tauri 桌面壳按 Rust 运行时代码镜像实现,`apps/desktop-shell/scripts/check-config.mjs` 必须反查共享契约并拒绝漂移。
- 决策:`packages/shared/src/contracts/hostBridge.ts` 是 HostBridge 载荷边界的声明来源,导出文本 / 图片 / 音频 MIME 清单、文档导入 MIME 清单、导入 / 导出字节上限、导出文件名 fallback / 长度上限、request id 长度、角标上限、剪贴板文本长度和本地通知标题 / 正文长度。Expo 移动壳必须直接导入这些共享常量,`apps/mobile-shell/scripts/check-config.mjs` 会拒绝移动壳重新本地声明文件大小或 MIME 清单;移动壳 `file.importText` / `file.importDocument` / `file.importAudio` 必须在读取文本内容或 base64 前,通过 picker `size` 或 Expo `File.size` 拿到可信 byte count 并完成上限校验,无法拿到可信大小时直接拒绝导入。Tauri 桌面壳按 Rust 运行时代码镜像实现,`apps/desktop-shell/scripts/check-config.mjs` 必须反查共享契约并拒绝漂移。
- 影响范围:`packages/shared/src/contracts/hostBridge.ts``apps/mobile-shell/src/host-bridge/files.ts``apps/mobile-shell/scripts/check-config.mjs``apps/desktop-shell/src-tauri/src/host_bridge/``apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。
- 验证方式:`npm run mobile-shell:typecheck``npm run desktop-shell:typecheck``npm run test -- packages/shared/src/contracts/hostBridge.test.ts``npm run check:native-shells``npm run check:encoding``git diff --check`
@@ -217,6 +217,7 @@ npm run check:native-shells
```
该命令会覆盖 H5 HostBridge 关键测试、微信 / Expo / Tauri 三端桥接层文件结构门禁、完整相对路径文档反查、H5 HostBridge 事件订阅双能力门控反查、移动端和桌面端单端源码清单门禁、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面壳 release `--no-bundle` 构建烟测,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描,确认 Expo managed config、移动端 iOS / Android production bundle、打包 H5 资产、Tauri release 入口和 H5 HostBridge 真实调用链没有漂移;扫描范围包含微信小程序壳生产 `.js`、共享 HostBridge 契约、H5 native transport,并自动覆盖已接入真实宿主能力 facade 的 H5 生产调用链文件。壳源码和配置继续严格禁止 mock / fake / placeholder / stub / TODO / FIXME / 占位 / 模拟 / 伪造;H5 业务调用链允许正常表单 `placeholder` 属性和业务占位图文案,但仍禁止 mock / fake / stub / TODO / FIXME / 模拟 / 伪造等替身痕迹。
创作 Agent 原生壳文档导入优先走 `file.importDocument`,旧壳只声明 `file.importText` 时才回退文本导入;相关变更必须让根级和单端门禁覆盖共享 method、capability profile、文档 MIME / 5 MiB 上限、读取前 size 校验,以及 H5 base64 转 `File` 后继续走后端文档解析的链路。
该命令会反查微信小程序 `WECHAT_HOST_CAPABILITIES` 与共享 `HOST_BRIDGE_WECHAT_MINI_PROGRAM_CAPABILITIES` 一致;小程序生产代码继续保留 CommonJS 运行时镜像,不直接 import TypeScript shared 包。
该命令同时会运行微信小程序 `miniprogram/host-bridge/``miniprogram/shell/``pages/web-view` 样式和 `scripts/miniprogram-web-view-auth.test.ts` 的壳层测试,保证微信桥接层拆分后的支付、订阅消息、九宫切图、分享目标和 WebView 登录 / 分享入口行为与 Expo、Tauri 壳一起验收。
该命令还会反查微信小程序 `app.json.pages``host-bridge/protocol.js` 页面 URL、H5 小程序页面常量、H5 订阅授权页面常量、WebView 分享入口、分享目标消息类型、`WEB_VIEW_SOURCE_QUERY`、微信请求头运行时标记、H5 runtime parser、H5 路由保留字段和 H5 / API base URL 格式,避免页面路由、来源标记、宿主上下文 query 或域名配置在微信壳、H5 HostBridge 与运行时配置之间分叉。生产 / 开发 H5 与 API 域名都必须显式配置为纯 HTTPS domain,运行时开发域名回退生产域名只作为异常兜底。
File diff suppressed because one or more lines are too long
@@ -69,6 +69,7 @@ Tauri 桌面壳启动时必须按 `label="main"` 解析 `tauri.conf.json` 主窗
- `navigateHostNativePage()`:受控跳转宿主页,供订阅授权、支付、登录等 adapter 复用。Expo 移动壳首版只接受同源 H5 route 并切换 WebView URLTauri 桌面壳同样只接受 `https://app.genarrative.world` 同源 H5 route 并在主窗口内跳转。真正原生页面、登录和支付能力必须等对应 SDK / 页面接入后再声明支持。
- `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` 解析链路;普通浏览器、小程序和未声明能力的裁剪壳继续使用原文件输入。
- `importHostDocumentFile()`:原生 App 宿主的受控文档导入入口。Expo 移动壳通过 Expo DocumentPickerTauri 桌面壳通过系统文件选择框读取用户选择的文档副本;两端都只接受 `text/plain``text/markdown``text/csv``application/json``application/vnd.openxmlformats-officedocument.wordprocessingml.document` 或对应 `.txt` / `.md` / `.markdown` / `.csv` / `.json` / `.docx` 扩展名,单次不超过 5 MiB。成功只返回清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI、本机绝对路径或通用文件系统能力;宿主必须在读取 base64 前拿到可信 byte count 并完成上限校验,移动壳在 picker 缺少 `size` 时改用 Expo `File.size`,仍拿不到可信大小时直接拒绝导入。创作 Agent 工作台在 `native_app` 且声明该能力时优先调用宿主文档导入,把返回 base64 转换成现有浏览器 `File` 后继续调用 `/api/runtime/creation-agent/document-inputs/parse`;旧壳只声明 `file.importText` 时才回退到文本导入,普通浏览器、小程序和未声明能力的裁剪壳继续使用原文件输入。该能力不在前端解析 DOCX,也不绕过后端文档解析、大小校验或错误口径。
- `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` 时,只有拖入坐标命中当前主图卡片且未被上层元素遮挡的面板会消费该事件。普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。
- `scanHostQrCode()`:原生 App 宿主的受控二维码扫描入口。Expo 移动壳声明 `scanner.scanQrCode`,通过 `expo-camera` 的真实相机权限和 `CameraView` 扫描 QR code,成功只返回清洗后的二维码文本与 `qr_code` 格式,单次值最多保留 4096 字符且拒绝空值和控制字符;用户关闭或系统取消返回 `cancelled`,H5 不会继续连带弹出浏览器摄像头权限。Tauri 桌面壳只把 `scanner.scanQrCode` 保留在 method 白名单中用于明确返回 `unsupported_method`,不声明 capability、不伪造桌面扫码。个人中心扫码入口在 `native_app` 且宿主声明该能力时优先调用原生扫码;宿主不支持、旧壳缺能力或扫码结果非法时继续打开现有浏览器摄像头扫码弹层,普通浏览器和小程序保持原有路径。
@@ -16,7 +16,10 @@ import {
HOST_BRIDGE_PUBLIC_WEB_URL,
HOST_BRIDGE_TAURI_COMMAND,
HOST_BRIDGE_CAPABILITIES,
HOST_BRIDGE_DOCUMENT_MIME_TYPES,
HOST_BRIDGE_EVENTS,
HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES,
HOST_BRIDGE_TEXT_MIME_TYPES,
isHostBridgeMethod,
isHostBridgeCapability,
isHostBridgeEventName,
@@ -139,6 +142,7 @@ describe('HostBridge shared contract helpers', () => {
expect(isHostBridgeCapability('network.statusChanged')).toBe(true);
expect(isHostBridgeCapability('clipboard.readText')).toBe(true);
expect(isHostBridgeCapability('file.importText')).toBe(true);
expect(isHostBridgeCapability('file.importDocument')).toBe(true);
expect(isHostBridgeCapability('file.importImage')).toBe(true);
expect(isHostBridgeCapability('file.captureImage')).toBe(true);
expect(isHostBridgeCapability('scanner.scanQrCode')).toBe(true);
@@ -208,6 +212,12 @@ describe('HostBridge shared contract helpers', () => {
expect(HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES).toContain(
'file.captureImage',
);
expect(HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES).toContain(
'file.importDocument',
);
expect(HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES).toContain(
'file.importDocument',
);
expect(HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES).toContain(
'scanner.scanQrCode',
);
@@ -258,6 +268,14 @@ describe('HostBridge shared contract helpers', () => {
});
});
test('文档导入契约包含文本和 DOCX 边界', () => {
expect(HOST_BRIDGE_DOCUMENT_MIME_TYPES).toEqual([
...HOST_BRIDGE_TEXT_MIME_TYPES,
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
]);
expect(HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES).toBe(5 * 1024 * 1024);
});
test('归一化宿主本地通知内容', () => {
expect(
normalizeHostBridgeLocalNotification({
@@ -82,6 +82,7 @@ export const HOST_BRIDGE_METHODS = [
'clipboard.readText',
'file.exportText',
'file.importText',
'file.importDocument',
'file.exportImage',
'file.importImage',
'file.captureImage',
@@ -145,6 +146,7 @@ export const HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES: readonly HostBridgeCapab
'clipboard.readText',
'file.exportText',
'file.importText',
'file.importDocument',
'file.exportImage',
'file.importImage',
'file.captureImage',
@@ -183,6 +185,7 @@ export const HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES: readonly HostBridgeCapabili
'clipboard.readText',
'file.exportText',
'file.importText',
'file.importDocument',
'file.exportImage',
'file.importImage',
'file.importAudio',
@@ -484,6 +487,23 @@ export type FileImportTextResult = {
bytes: number;
};
export type HostBridgeDocumentMimeType =
| HostBridgeTextMimeType
| 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
export const HOST_BRIDGE_DOCUMENT_MIME_TYPES = [
...HOST_BRIDGE_TEXT_MIME_TYPES,
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
] as const satisfies readonly HostBridgeDocumentMimeType[];
export type FileImportDocumentResult = {
action: 'selected';
fileName: string;
base64Data: string;
mimeType: HostBridgeDocumentMimeType;
bytes: number;
};
export type FileExportImagePayload = {
fileName: string;
base64Data: string;
@@ -581,6 +601,7 @@ export type FileExportAudioResult = {
export const HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024;
export const HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024;
export const HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES = 5 * 1024 * 1024;
export const HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
export const HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
export const HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES = 20 * 1024 * 1024;
+4 -3
View File
@@ -32,6 +32,7 @@ const h5HostBridgeScannedFacadeImports = new Set([
'getHostNetworkStatus',
'getNativeAppHostRuntime',
'importHostAudioFile',
'importHostDocumentFile',
'importHostImageFile',
'importHostTextFile',
'navigateHostNativePage',
@@ -1012,10 +1013,10 @@ function assertNativeShellCapabilityPlan() {
'navigation.canGoBack',
...sharedMethods.slice(8, 12),
'network.statusChanged',
...sharedMethods.slice(12, 21),
sharedMethods[21],
...sharedMethods.slice(12, 22),
sharedMethods[22],
'file.imageDropped',
...sharedMethods.slice(22),
...sharedMethods.slice(23),
],
'native shell documented method table',
);
@@ -643,14 +643,88 @@ test('creation agent workspace appends parsed document text into composer', asyn
});
});
test('creation agent workspace imports document text through native HostBridge', async () => {
test('creation agent workspace imports DOCX through native HostBridge document flow', async () => {
ensureScrollApis();
const inputClickSpy = vi
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(() => undefined);
vi.spyOn(hostBridgeServices, 'canUseNativeHostCapability').mockReturnValue(
true,
vi.spyOn(hostBridgeServices, 'canUseNativeHostCapability').mockImplementation(
(capability) => capability === 'file.importDocument',
);
const importTextSpy = vi
.spyOn(hostBridgeServices, 'importHostTextFile')
.mockResolvedValue(false);
vi.spyOn(hostBridgeServices, 'importHostDocumentFile').mockResolvedValue({
action: 'selected',
fileName: '世界设定.docx',
base64Data: 'UEsDBGRvY3g=',
mimeType:
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
bytes: 8,
});
const parseSpy = vi
.spyOn(creationAgentServices, 'parseCreationAgentDocumentInput')
.mockResolvedValue({
document: {
fileName: '世界设定.docx',
contentType:
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
sizeBytes: 8,
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('世界设定.docx');
expect(parsedFile.type).toBe(
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
);
expect(parsedFile.size).toBe(8);
expect(importTextSpy).not.toHaveBeenCalled();
expect(inputClickSpy).not.toHaveBeenCalled();
} finally {
inputClickSpy.mockRestore();
}
});
test('creation agent workspace falls back to native text import for older shells', async () => {
ensureScrollApis();
const inputClickSpy = vi
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(() => undefined);
vi.spyOn(hostBridgeServices, 'canUseNativeHostCapability').mockImplementation(
(capability) => capability === 'file.importText',
);
vi.spyOn(hostBridgeServices, 'importHostTextFile').mockResolvedValue({
action: 'selected',
@@ -19,6 +19,7 @@ import {
import {
canUseNativeHostCapability,
exportHostTextFile,
importHostDocumentFile,
importHostTextFile,
} from '../../services/host-bridge/hostBridge';
import { PlatformActionButton } from '../common/PlatformActionButton';
@@ -446,6 +447,20 @@ function getUtf8ByteLength(text: string) {
return new TextEncoder().encode(text).length;
}
function base64DataToFile(
base64Data: string,
fileName: string,
mimeType: string,
) {
const binary = atob(base64Data);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new File([bytes], fileName, { type: mimeType });
}
export function CreationAgentWorkspace({
session,
theme,
@@ -619,6 +634,24 @@ export function CreationAgentWorkspace({
return;
}
if (canUseNativeHostCapability('file.importDocument')) {
void runDocumentInputTask(async () => {
const importedDocumentFile = await importHostDocumentFile();
if (!importedDocumentFile) {
return;
}
await parseAndAppendDocumentInputFile(
base64DataToFile(
importedDocumentFile.base64Data,
importedDocumentFile.fileName,
importedDocumentFile.mimeType,
),
);
});
return;
}
if (canUseNativeHostCapability('file.importText')) {
void runDocumentInputTask(async () => {
const importedTextFile = await importHostTextFile();
+144 -6
View File
@@ -15,6 +15,7 @@ import {
getHostRuntime,
getNativeAppHostRuntime,
importHostAudioFile,
importHostDocumentFile,
importHostImageFile,
importHostTextFile,
isWechatMiniProgramWebViewRuntime,
@@ -925,15 +926,24 @@ describe('hostBridge', () => {
mimeType: 'text/markdown',
bytes: 12,
}
: request.method === 'file.importAudio'
: request.method === 'file.importDocument'
? {
action: 'selected',
fileName: '敲击音效.webm',
base64Data: 'YXVkaW8=',
mimeType: 'audio/webm',
bytes: 5,
fileName: '世界设定.docx',
base64Data: 'UEsDBGRvY3g=',
mimeType:
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
bytes: 8,
}
: true,
: request.method === 'file.importAudio'
? {
action: 'selected',
fileName: '敲击音效.webm',
base64Data: 'YXVkaW8=',
mimeType: 'audio/webm',
bytes: 5,
}
: true,
};
});
window.history.replaceState(
@@ -955,6 +965,7 @@ describe('hostBridge', () => {
'network.status',
'share.open',
'file.importText',
'file.importDocument',
'file.exportImage',
'file.importImage',
'file.captureImage',
@@ -1048,6 +1059,14 @@ describe('hostBridge', () => {
mimeType: 'text/markdown',
bytes: 12,
});
await expect(importHostDocumentFile()).resolves.toEqual({
action: 'selected',
fileName: '世界设定.docx',
base64Data: 'UEsDBGRvY3g=',
mimeType:
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
bytes: 8,
});
await expect(importHostAudioFile()).resolves.toEqual({
action: 'selected',
fileName: '敲击音效.webm',
@@ -1188,6 +1207,12 @@ describe('hostBridge', () => {
timeoutMs: 30000,
}),
});
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'file.importDocument',
timeoutMs: 30000,
}),
});
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'file.importAudio',
@@ -1273,6 +1298,7 @@ describe('hostBridge', () => {
await expect(importHostImageFile()).resolves.toBe(false);
await expect(scanHostQrCode()).resolves.toBe(false);
await expect(importHostTextFile()).resolves.toBe(false);
await expect(importHostDocumentFile()).resolves.toBe(false);
await expect(importHostAudioFile()).resolves.toBe(false);
await expect(
exportHostAudioFile({
@@ -1308,6 +1334,7 @@ describe('hostBridge', () => {
await expect(importHostImageFile()).resolves.toBe(false);
await expect(scanHostQrCode()).resolves.toBe(false);
await expect(importHostTextFile()).resolves.toBe(false);
await expect(importHostDocumentFile()).resolves.toBe(false);
await expect(importHostAudioFile()).resolves.toBe(false);
await expect(
exportHostAudioFile({
@@ -1611,6 +1638,54 @@ describe('hostBridge', () => {
});
});
test('原生 App 宿主通过 HostBridge 导入文档文件', async () => {
const invoke = vi.fn(
async (_command: string, args?: Record<string, unknown>) => {
const request = (args as { request: { id: string } }).request;
return {
bridge: 'GenarrativeHostBridge',
version: 1,
id: request.id,
ok: true,
result: {
action: 'selected',
fileName: ' 世界设定.docx ',
base64Data: 'UEsDBGRvY3g=',
mimeType:
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
bytes: 8,
},
};
},
);
window.history.replaceState(
null,
'',
nativeAppPath(['file.importDocument']),
);
window.__TAURI__ = {
core: {
invoke: asTauriInvoke(invoke),
},
};
await expect(importHostDocumentFile()).resolves.toEqual({
action: 'selected',
fileName: '世界设定.docx',
base64Data: 'UEsDBGRvY3g=',
mimeType:
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
bytes: 8,
});
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'file.importDocument',
timeoutMs: 30000,
}),
});
});
test('原生 App 宿主通过 HostBridge 导入音频文件', async () => {
const invoke = vi.fn(
async (_command: string, args?: Record<string, unknown>) => {
@@ -1770,6 +1845,69 @@ describe('hostBridge', () => {
await expect(importHostTextFile()).resolves.toBe(false);
});
test('原生 App 宿主取消导入文档时回退为 false', async () => {
const invoke = vi.fn(
async (_command: string, args?: Record<string, unknown>) => {
const request = (args as { request: { id: string } }).request;
return {
bridge: 'GenarrativeHostBridge',
version: 1,
id: request.id,
ok: false,
error: {
code: 'cancelled',
message: 'file import cancelled',
},
};
},
);
window.history.replaceState(
null,
'',
nativeAppPath(['file.importDocument']),
);
window.__TAURI__ = {
core: {
invoke: asTauriInvoke(invoke),
},
};
await expect(importHostDocumentFile()).resolves.toBe(false);
});
test('原生 App 宿主导入文档返回畸形 payload 时回退为 false', async () => {
const invoke = vi.fn(
async (_command: string, args?: Record<string, unknown>) => {
const request = (args as { request: { id: string } }).request;
return {
bridge: 'GenarrativeHostBridge',
version: 1,
id: request.id,
ok: true,
result: {
action: 'selected',
fileName: '世界设定.exe',
base64Data: 'UEsDBGRvY3g=',
mimeType: 'application/octet-stream',
bytes: 8,
},
};
},
);
window.history.replaceState(
null,
'',
nativeAppPath(['file.importDocument']),
);
window.__TAURI__ = {
core: {
invoke: asTauriInvoke(invoke),
},
};
await expect(importHostDocumentFile()).resolves.toBe(false);
});
test('原生 App 宿主取消导入图片时回退为 false', async () => {
const invoke = vi.fn(
async (_command: string, args?: Record<string, unknown>) => {
+57
View File
@@ -9,11 +9,13 @@ import type {
FileExportTextPayload,
FileExportTextResult,
FileImportAudioResult,
FileImportDocumentResult,
FileImportImageResult,
FileImportTextResult,
HapticsImpactPayload,
HostBridgeAudioMimeType,
HostBridgeCapability,
HostBridgeDocumentMimeType,
HostBridgeImageMimeType,
HostBridgeMethod,
HostBridgeRuntimeResult,
@@ -27,6 +29,7 @@ import type {
ShareOpenPayload,
} from '../../../packages/shared/src/contracts/hostBridge';
import {
HOST_BRIDGE_DOCUMENT_MIME_TYPES,
HOST_BRIDGE_NATIVE_APP_QUERY,
HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY,
HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY,
@@ -110,6 +113,8 @@ export type HostFileImportImageResult = FileImportImageResult;
export type HostFileImportTextResult = FileImportTextResult;
export type HostFileImportDocumentResult = FileImportDocumentResult;
export type HostFileImportAudioResult = FileImportAudioResult;
export type HostScannerScanQrCodeResult = ScannerScanQrCodeResult;
@@ -951,6 +956,9 @@ const HOST_BRIDGE_TEXT_MIME_TYPES = new Set<HostBridgeTextMimeType>([
'text/csv',
'application/json',
]);
const HOST_BRIDGE_DOCUMENT_MIME_TYPE_SET = new Set<HostBridgeDocumentMimeType>(
HOST_BRIDGE_DOCUMENT_MIME_TYPES,
);
function normalizeHostTextImportResult(
payload: FileImportTextResult | null | undefined,
@@ -1000,6 +1008,55 @@ export async function importHostTextFile() {
}
}
function normalizeHostDocumentImportResult(
payload: FileImportDocumentResult | null | undefined,
): FileImportDocumentResult | false {
if (
!payload ||
typeof payload !== 'object' ||
payload.action !== 'selected' ||
!HOST_BRIDGE_DOCUMENT_MIME_TYPE_SET.has(payload.mimeType) ||
typeof payload.fileName !== 'string' ||
!payload.fileName.trim() ||
typeof payload.base64Data !== 'string' ||
!payload.base64Data.trim() ||
typeof payload.bytes !== 'number' ||
!Number.isInteger(payload.bytes) ||
payload.bytes <= 0
) {
return false;
}
return {
action: 'selected',
fileName: payload.fileName.trim(),
base64Data: payload.base64Data,
mimeType: payload.mimeType,
bytes: payload.bytes,
};
}
export async function importHostDocumentFile() {
if (!canUseNativeHostCapability('file.importDocument')) {
return false;
}
try {
return normalizeHostDocumentImportResult(
await requestNativeAppHostBridge<FileImportDocumentResult>(
'file.importDocument',
undefined,
{ timeoutMs: 30000 },
),
);
} catch (error) {
if (isUnsupportedHostBridgeError(error)) {
return false;
}
throw error;
}
}
const HOST_BRIDGE_AUDIO_MIME_TYPES = new Set<HostBridgeAudioMimeType>([
'audio/mpeg',
'audio/mp4',