From bd9b44937cc0efb1cd949403200f32dcfeaae75a Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 21 Oct 2026 00:11:58 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(buddy):=20=E5=8C=BA=E5=88=86=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E8=A2=AB=E7=A7=BB=E5=8A=A8=E6=88=96=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E4=B8=8E=E5=85=B6=E4=BB=96=E9=A2=84=E8=A7=88=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 文件在列出或展示之后被移动或删除时,预览与产出页统一显示“加载失败”,用户分不清是文件不见了 还是预览本身出了问题。 现在运行时对这类情况返回 FILE_NOT_FOUND:空间文件服务与产出服务在文件缺失时抛出该错误码, 文件预览和产出页据此显示“文件已被移动或删除”,其他失败仍显示原来的加载失败提示。 补充了中英文文案与对应的单元测试。 Co-Authored-By: Claude Sonnet 5.5 --- .../service/src/artifacts/ArtifactService.ts | 5 ++++- .../artifacts/__tests__/ArtifactService.spec.ts | 9 +++++++++ apps/buddy/service/src/rpc/runtimeRequest.ts | 1 + .../buddy/service/src/spaces/SpaceFileService.ts | 14 ++++++++++++-- .../spaces/__tests__/SpaceFileService.spec.ts | 8 ++++++++ apps/buddy/shared/runtime/localChatError.ts | 2 ++ apps/buddy/src/i18n/locales/en-US/diagnostics.ts | 1 + apps/buddy/src/i18n/locales/en-US/tasks.ts | 1 + apps/buddy/src/i18n/locales/zh-CN/diagnostics.ts | 1 + apps/buddy/src/i18n/locales/zh-CN/tasks.ts | 1 + .../modules/files/widgets/DesktopFilePreview.vue | 11 ++++++++--- .../DesktopArtifactContextSurface.vue | 4 ++-- .../__tests__/useResourcePreviews.spec.ts | 16 ++++++++++++++++ .../widgets/context-panel/useArtifactPreview.ts | 10 ++++++++-- apps/buddy/src/shared/lib/localChatError.ts | 1 + .../src/workbench/common/workbenchLabels.ts | 2 ++ 16 files changed, 77 insertions(+), 10 deletions(-) diff --git a/apps/buddy/service/src/artifacts/ArtifactService.ts b/apps/buddy/service/src/artifacts/ArtifactService.ts index f6c3a4e12..03c42f598 100644 --- a/apps/buddy/service/src/artifacts/ArtifactService.ts +++ b/apps/buddy/service/src/artifacts/ArtifactService.ts @@ -373,7 +373,10 @@ export class ArtifactService { ) { throw new ArtifactError('VALIDATION_FAILED') } - const bytes = await readFile(artifact.currentPath) + // The record keeps the path the file had when it was presented; it may have been moved or deleted since. + const bytes = await readFile(artifact.currentPath).catch((error: NodeJS.ErrnoException) => { + throw error.code === 'ENOENT' ? new ArtifactError('FILE_NOT_FOUND') : error + }) if (bytes.byteLength > BUDDY_ARTIFACT_TEXT_BYTES_LIMIT) throw new ArtifactError('ARTIFACT_SIZE_LIMIT') let text: string diff --git a/apps/buddy/service/src/artifacts/__tests__/ArtifactService.spec.ts b/apps/buddy/service/src/artifacts/__tests__/ArtifactService.spec.ts index 104c9e72e..5d0076977 100644 --- a/apps/buddy/service/src/artifacts/__tests__/ArtifactService.spec.ts +++ b/apps/buddy/service/src/artifacts/__tests__/ArtifactService.spec.ts @@ -111,6 +111,15 @@ describe('artifactService', () => { expect(fixture.service.listConversationArtifacts('conversation-1')).toHaveLength(2) }) + it('reports an output whose file was moved or deleted as not found', async () => { + const fixture = await createFixture() + const path = join(fixture.workspace, 'report.md') + await writeFile(path, '# report\n') + const [artifact] = await fixture.service.presentOutputs({ conversationId: 'conversation-1', cwd: fixture.workspace, grants: fixture.grants, paths: [path] }) + await rm(path) + await expect(fixture.service.readText(artifact!.id)).rejects.toMatchObject({ code: 'FILE_NOT_FOUND' }) + }) + it.each(['report.md', 'REPORT.MD', 'report.markdown'])('reads %s as Markdown without changing the file', async (name) => { const fixture = await createFixture() const path = join(fixture.workspace, name) diff --git a/apps/buddy/service/src/rpc/runtimeRequest.ts b/apps/buddy/service/src/rpc/runtimeRequest.ts index e359a94b4..ccbdaa02b 100644 --- a/apps/buddy/service/src/rpc/runtimeRequest.ts +++ b/apps/buddy/service/src/rpc/runtimeRequest.ts @@ -36,6 +36,7 @@ export type BuddyServiceErrorCode | 'DIRECTORY_NOT_AUTHORIZED' | 'MODEL_INPUT_UNSUPPORTED' | 'MODEL_INPUT_TOO_LARGE' + | 'FILE_NOT_FOUND' | 'SPACE_UNAVAILABLE' | 'VALIDATION_FAILED' diff --git a/apps/buddy/service/src/spaces/SpaceFileService.ts b/apps/buddy/service/src/spaces/SpaceFileService.ts index a2440f5b6..1b5f162a9 100644 --- a/apps/buddy/service/src/spaces/SpaceFileService.ts +++ b/apps/buddy/service/src/spaces/SpaceFileService.ts @@ -51,9 +51,9 @@ export class SpaceFileService { } async read(input: SpaceFileTarget): Promise { - const target = await this.resolve(input) + const target = await this.resolve(input).catch(rethrowMissingFile) this.requireDirectory(input) - const preview = await readFilePreview(target.root, target.path) + const preview = await readFilePreview(target.root, target.path).catch(rethrowMissingFile) this.requireDirectory(input) return preview } @@ -235,3 +235,13 @@ export class SpaceFileService { return { path: target.canonicalPath, root: directory.canonicalRoot } } } + +// A file moved or deleted after it was listed is reported on its own, so the preview can say so. +function rethrowMissingFile(error: unknown): never { + for (let current = error, depth = 0; current && typeof current === 'object' && depth < 5; current = (current as { cause?: unknown }).cause, depth++) { + const code = (current as { code?: unknown }).code + if (code === 'ENOENT' || code === 'PATH_NOT_FOUND') + throw new BuddyServiceError('FILE_NOT_FOUND') + } + throw error +} diff --git a/apps/buddy/service/src/spaces/__tests__/SpaceFileService.spec.ts b/apps/buddy/service/src/spaces/__tests__/SpaceFileService.spec.ts index 28747a832..7b23de7a9 100644 --- a/apps/buddy/service/src/spaces/__tests__/SpaceFileService.spec.ts +++ b/apps/buddy/service/src/spaces/__tests__/SpaceFileService.spec.ts @@ -74,6 +74,14 @@ describe('space file browsing', () => { expect(await f.files.read({ ...f.target, path: 'large.txt' })).toMatchObject({ kind: 'oversized', text: null }) }) + it('reports a file that was moved or deleted as not found', async () => { + const f = await fixture() + await writeFile(join(f.workspace, 'note.md'), '# note\n') + await expect(f.files.read({ ...f.target, path: 'note.md' })).resolves.toMatchObject({ kind: 'text' }) + await rm(join(f.workspace, 'note.md')) + await expect(f.files.read({ ...f.target, path: 'note.md' })).rejects.toMatchObject({ code: 'FILE_NOT_FOUND' }) + }) + it('saves UTF-8 content without changing its BOM, rejects stale etags and rechecks grants', async () => { const f = await fixture() const path = join(f.workspace, 'note.md') diff --git a/apps/buddy/shared/runtime/localChatError.ts b/apps/buddy/shared/runtime/localChatError.ts index 1e7e741d0..ee2f32eb8 100644 --- a/apps/buddy/shared/runtime/localChatError.ts +++ b/apps/buddy/shared/runtime/localChatError.ts @@ -34,6 +34,7 @@ export type LocalChatErrorCode | 'FILE_RECOVERY_REQUIRED' | 'FILE_OPERATION_CANCELLED' | 'FILE_PERMISSION_REVOKED' + | 'FILE_NOT_FOUND' | 'FILE_UNSUPPORTED' | 'FILE_ENGINE_INTERRUPTED' | 'LOCAL_CHAT_OPERATION_FAILED' @@ -107,6 +108,7 @@ const LOCAL_CHAT_ERROR_CODES = new Set([ 'FILE_RECOVERY_REQUIRED', 'FILE_OPERATION_CANCELLED', 'FILE_PERMISSION_REVOKED', + 'FILE_NOT_FOUND', 'FILE_UNSUPPORTED', 'FILE_ENGINE_INTERRUPTED', 'LOCAL_CHAT_OPERATION_FAILED', diff --git a/apps/buddy/src/i18n/locales/en-US/diagnostics.ts b/apps/buddy/src/i18n/locales/en-US/diagnostics.ts index 49ef8996f..3a06bf3d9 100644 --- a/apps/buddy/src/i18n/locales/en-US/diagnostics.ts +++ b/apps/buddy/src/i18n/locales/en-US/diagnostics.ts @@ -79,6 +79,7 @@ export default { 'desktop.error.credentialStore': 'Encrypted credential storage is unavailable', 'desktop.error.credentialStoreFailure': 'Could not read or save encrypted credentials', 'desktop.error.directoryNotAuthorized': 'Authorize this directory first', + 'desktop.error.fileNotFound': 'This file has been moved or deleted', 'desktop.error.taskRecoveryRequired': 'Some records for this execution cannot be read. The original records are preserved, and you can start a new turn. Export diagnostics from Logs if you need help.', 'desktop.error.fileRecoveryRequired': 'The outcome of an earlier file operation is unconfirmed, so related writes are paused. Export diagnostics from Logs if you need help.', 'desktop.error.resourceRecoveryRequired': 'An earlier operation has an unverified outcome. Check the files and external state before starting a new operation; the old operation will not be replayed automatically.', diff --git a/apps/buddy/src/i18n/locales/en-US/tasks.ts b/apps/buddy/src/i18n/locales/en-US/tasks.ts index 88fc5e584..03caf7564 100644 --- a/apps/buddy/src/i18n/locales/en-US/tasks.ts +++ b/apps/buddy/src/i18n/locales/en-US/tasks.ts @@ -171,6 +171,7 @@ export default { 'desktop.context.sourceLoadFailed': 'Could not load the source viewer', 'desktop.context.previewUnavailable': 'This file cannot be previewed here yet', 'desktop.context.previewLoadFailed': 'Could not load this file preview', + 'desktop.context.previewFileMissing': 'This file has been moved or deleted', 'desktop.context.tabCount': 'Enhanced capability tabs, {count} total', 'desktop.search.taskCount': 'Tasks ({count})', 'desktop.search.description': 'Search tasks and spaces', diff --git a/apps/buddy/src/i18n/locales/zh-CN/diagnostics.ts b/apps/buddy/src/i18n/locales/zh-CN/diagnostics.ts index 04b51e58d..dce8c7163 100644 --- a/apps/buddy/src/i18n/locales/zh-CN/diagnostics.ts +++ b/apps/buddy/src/i18n/locales/zh-CN/diagnostics.ts @@ -77,6 +77,7 @@ export default { 'desktop.error.credentialStore': '系统凭据加密服务不可用', 'desktop.error.credentialStoreFailure': '系统凭据读取或保存失败', 'desktop.error.directoryNotAuthorized': '请先授权这个目录', + 'desktop.error.fileNotFound': '文件已被移动或删除', 'desktop.error.taskRecoveryRequired': '此执行的部分记录无法读取,原始记录已保留。可以继续新一轮任务;如需协助,可在日志中导出诊断信息。', 'desktop.error.fileRecoveryRequired': '上次文件操作的结果尚未确认,相关写入已暂停。如需协助,可在日志中导出诊断信息。', 'desktop.error.resourceRecoveryRequired': '上次操作的结果尚未确认。请核对文件和外部状态后发起新操作,旧操作不会自动重跑。', diff --git a/apps/buddy/src/i18n/locales/zh-CN/tasks.ts b/apps/buddy/src/i18n/locales/zh-CN/tasks.ts index 3d0a2d978..0d216b2d0 100644 --- a/apps/buddy/src/i18n/locales/zh-CN/tasks.ts +++ b/apps/buddy/src/i18n/locales/zh-CN/tasks.ts @@ -169,6 +169,7 @@ export default { 'desktop.context.sourceLoadFailed': '无法加载源码查看器', 'desktop.context.previewUnavailable': '暂不支持在此预览该文件', 'desktop.context.previewLoadFailed': '无法读取该文件预览', + 'desktop.context.previewFileMissing': '文件已被移动或删除', 'desktop.context.tabCount': '增强能力页签,共 {count} 个', 'desktop.search.taskCount': '任务 ({count})', 'desktop.search.description': '当前可以搜索任务和空间', diff --git a/apps/buddy/src/modules/files/widgets/DesktopFilePreview.vue b/apps/buddy/src/modules/files/widgets/DesktopFilePreview.vue index 948d0c36c..dde7867e6 100644 --- a/apps/buddy/src/modules/files/widgets/DesktopFilePreview.vue +++ b/apps/buddy/src/modules/files/widgets/DesktopFilePreview.vue @@ -3,6 +3,7 @@ import type { LocalChatApi } from '@buddy-electron/shared/localChatApi' import type { BuddyFileQuote } from '@buddy-shared/conversation/buddyUserContent' import type { LocalSpaceFilePreview } from '@buddy-shared/spaces/spaceFileApi' import type { WorkbenchView } from '@/workbench/common/workbench' +import { parseLocalChatPublicError } from '@buddy-shared/runtime/localChatError' import { spaceFileTargetSchema } from '@buddy-shared/spaces/spaceFileApi' import { computed, shallowRef, useTemplateRef, watch } from 'vue' import WorkbenchMenu from '@/shared/ui/contributions/WorkbenchMenu.vue' @@ -19,6 +20,7 @@ const props = withDefaults(defineProps<{ view: WorkbenchView, files: LocalChatAp const { labels, controller } = useWorkbench() const preview = shallowRef(null) const failed = shallowRef(false) +const missing = shallowRef(false) const modes = computed(() => fileDocumentModes({ preview: preview.value?.kind === 'image' || (preview.value?.kind === 'text' && isMarkdownFile(props.view.title)), source: preview.value?.kind === 'text', edit: false })) const mode = computed({ get: () => resolveFileDocumentMode(props.view.state.mode, modes.value), set: value => controller.updateView(props.view.id, { state: { ...props.view.state, mode: value } }) }) const quoteSource = computed(() => ({ @@ -43,14 +45,17 @@ watch([identity, attempt], async (_, __, onCleanup) => { onCleanup(() => active = false) preview.value = null failed.value = false + missing.value = false try { const value = await props.files.readFile(spaceFileTargetSchema.strip().parse(props.view.resource.data)) if (active) preview.value = value } - catch { - if (active) + catch (error) { + if (active) { failed.value = true + missing.value = error instanceof Error && parseLocalChatPublicError(error.message)?.code === 'FILE_NOT_FOUND' + } } }, { immediate: true }) const document = useTemplateRef>('document') @@ -80,7 +85,7 @@ function captureResource() { diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/useResourcePreviews.spec.ts b/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/useResourcePreviews.spec.ts index 8b5ef2a49..cb63f10bd 100644 --- a/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/useResourcePreviews.spec.ts +++ b/apps/buddy/src/modules/tasks/widgets/context-panel/__tests__/useResourcePreviews.spec.ts @@ -225,6 +225,16 @@ describe('resource previews', () => { expect(state.textPreviewLoading.value).toBe(false) }) + it('tells a moved or deleted file apart from other read failures', async () => { + const artifact = { ...createArtifact('moved'), name: 'moved.md', mimeType: 'text/markdown' } + const missing = own(() => useArtifactPreview({ artifact: () => artifact, readText: () => failWith('Error invoking remote method: Error: LEXORA_LOCAL_CHAT_ERROR:FILE_NOT_FOUND:0') })) + const broken = own(() => useArtifactPreview({ artifact: () => artifact, readText: () => failWith('LEXORA_LOCAL_CHAT_ERROR:LOCAL_CHAT_OPERATION_FAILED:0') })) + await vi.waitFor(() => expect(missing.state.textPreviewFailed.value).toBe(true)) + await vi.waitFor(() => expect(broken.state.textPreviewFailed.value).toBe(true)) + expect(missing.state.textPreviewMissing.value).toBe(true) + expect(broken.state.textPreviewMissing.value).toBe(false) + }) + it('ignores late text success and failure after switching artifacts or closing the preview', async () => { const first = deferred() const second = deferred() @@ -302,6 +312,12 @@ describe('resource previews', () => { }) }) +function failWith(message: string) { + return async (): Promise => { + throw new Error(message) + } +} + function filesTab(): TaskFilesContextTab { return { id: 'files:first', scope: 'task:first', kind: 'files', rootName: 'fixture', target: { spaceId: 'space', directoryId: 'directory', revision: 1, path: '' } } } diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/useArtifactPreview.ts b/apps/buddy/src/modules/tasks/widgets/context-panel/useArtifactPreview.ts index 1daf09518..96fe99d9f 100644 --- a/apps/buddy/src/modules/tasks/widgets/context-panel/useArtifactPreview.ts +++ b/apps/buddy/src/modules/tasks/widgets/context-panel/useArtifactPreview.ts @@ -1,4 +1,5 @@ import type { LocalArtifact, LocalArtifactText } from '@buddy-shared/artifacts/artifactApi' +import { parseLocalChatPublicError } from '@buddy-shared/runtime/localChatError' import { computed, readonly, shallowRef, watch } from 'vue' import { isMarkdownArtifact, isTextMimeType } from './artifactContextPresentation' @@ -14,6 +15,7 @@ export function useArtifactPreview(options: ArtifactPreviewOptions) { const previewOpen = shallowRef(false) const textPreview = shallowRef(null) const textPreviewFailed = shallowRef(false) + const textPreviewMissing = shallowRef(false) const textPreviewLoading = shallowRef(false) const imageUrl = computed(() => { const artifact = options.artifact() @@ -41,6 +43,7 @@ export function useArtifactPreview(options: ArtifactPreviewOptions) { previewOpen.value = false textPreview.value = null textPreviewFailed.value = false + textPreviewMissing.value = false textPreviewLoading.value = kind === 'file' && (isTextMimeType(mimeType) || isMarkdownArtifact(options.artifact())) if (!textPreviewLoading.value) return @@ -49,9 +52,11 @@ export function useArtifactPreview(options: ArtifactPreviewOptions) { if (active) textPreview.value = result } - catch { - if (active) + catch (error) { + if (active) { textPreviewFailed.value = true + textPreviewMissing.value = error instanceof Error && parseLocalChatPublicError(error.message)?.code === 'FILE_NOT_FOUND' + } } finally { if (active) @@ -79,6 +84,7 @@ export function useArtifactPreview(options: ArtifactPreviewOptions) { previewUrl, textPreview: readonly(textPreview), textPreviewFailed: readonly(textPreviewFailed), + textPreviewMissing: readonly(textPreviewMissing), textPreviewLoading: readonly(textPreviewLoading), } } diff --git a/apps/buddy/src/shared/lib/localChatError.ts b/apps/buddy/src/shared/lib/localChatError.ts index 7c590b844..92315780a 100644 --- a/apps/buddy/src/shared/lib/localChatError.ts +++ b/apps/buddy/src/shared/lib/localChatError.ts @@ -49,6 +49,7 @@ export function resolveLocalChatErrorMessage(error: unknown, language: BuddyLoca FILE_RECOVERY_REQUIRED: 'desktop.error.fileRecoveryRequired', FILE_OPERATION_CANCELLED: 'desktop.chat.unknownError', FILE_PERMISSION_REVOKED: 'desktop.error.directoryNotAuthorized', + FILE_NOT_FOUND: 'desktop.error.fileNotFound', FILE_UNSUPPORTED: 'desktop.chat.unknownError', FILE_ENGINE_INTERRUPTED: 'desktop.chat.unknownError', LOCAL_CHAT_OPERATION_FAILED: 'desktop.chat.unknownError', diff --git a/apps/buddy/src/workbench/common/workbenchLabels.ts b/apps/buddy/src/workbench/common/workbenchLabels.ts index 4801467b5..b3f75f190 100644 --- a/apps/buddy/src/workbench/common/workbenchLabels.ts +++ b/apps/buddy/src/workbench/common/workbenchLabels.ts @@ -33,6 +33,7 @@ const zh = { discardDraft: '放弃内容', missing: '此视图暂不可用,资源和布局已保留。', failed: '视图加载失败。', + fileMissing: '文件已被移动或删除。', restoreFailed: '工作台恢复失败,请重试。', backupFailed: '未保存内容的备份失败。请保存文件或重试备份。', conflict: '文件已在外部修改。请比较内容后选择保留的版本。', @@ -96,6 +97,7 @@ const en: Labels = { discardDraft: 'Discard draft', missing: 'This view is unavailable. Its resource and layout have been retained.', failed: 'The view could not be loaded.', + fileMissing: 'This file has been moved or deleted.', restoreFailed: 'Could not restore the workbench. Please retry.', backupFailed: 'Could not back up unsaved changes. Save the file or retry the backup.', conflict: 'The file has changed on disk. Compare both versions before choosing which to keep.', From 4de90e0661e97fa1485eafecebdd16df987092c7 Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 21 Oct 2026 00:15:13 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(buddy):=20=E5=9C=A8=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=99=A8=E5=92=8C=E4=BA=A7=E5=87=BA=E9=A1=B5?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E9=9F=B3=E9=A2=91=E4=B8=8E=E8=A7=86=E9=A2=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 音频和视频文件此前无法预览:图片被整个读成 data URL,超过 1 MiB 的文件只显示文件名。 现在改用原生 video、audio 元素播放,由本地文件按字节范围流式提供。 - 新增 lexora-media 协议:每个请求都通过 spaceFiles.locate 重新校验空间授权, 按字节范围返回 206 或 416。 - SpaceFileService.read 对音频、视频只返回预览类型而不读取内容,并直接在预览视图中打开。 - lexora-artifact 协议同样按字节范围流式提供音频、视频产出,空间之外的产出在产出页 使用同一个播放器。 - CSP 增加 media-src,允许上述两个协议。 新增 mediaProtocol、mediaFiles、DesktopMediaContent 及空间文件、产出服务的单元测试。 Co-Authored-By: Claude Sonnet 5.5 --- .../main/__tests__/mediaProtocol.spec.ts | 65 +++++++++++ .../electron/main/app/DesktopRuntimeHost.ts | 2 + apps/buddy/electron/main/app/environment.ts | 3 +- .../buddy/electron/main/attachmentProtocol.ts | 5 + apps/buddy/electron/main/mediaProtocol.ts | 31 ++++++ apps/buddy/electron/main/serveMediaFile.ts | 53 +++++++++ apps/buddy/index.html | 2 +- .../service/src/artifacts/ArtifactService.ts | 9 +- .../__tests__/ArtifactService.spec.ts | 10 ++ .../service/src/spaces/SpaceFileService.ts | 10 ++ .../spaces/__tests__/SpaceFileService.spec.ts | 10 ++ .../shared/files/__tests__/mediaFiles.spec.ts | 43 ++++++++ apps/buddy/shared/files/filePreview.ts | 2 +- apps/buddy/shared/files/mediaFiles.ts | 104 ++++++++++++++++++ .../src/app/workbench/useDesktopWorkbench.ts | 12 +- apps/buddy/src/i18n/locales/en-US/tasks.ts | 1 + apps/buddy/src/i18n/locales/zh-CN/tasks.ts | 1 + .../files/widgets/DesktopFilePreview.vue | 9 +- .../DesktopArtifactContextSurface.vue | 4 +- .../context-panel/useArtifactPreview.ts | 8 ++ .../ui/files/DesktopDocumentContent.vue | 5 +- .../shared/ui/files/DesktopMediaContent.vue | 25 +++++ .../__tests__/DesktopMediaContent.spec.ts | 42 +++++++ .../src/workbench/common/workbenchLabels.ts | 2 + 24 files changed, 447 insertions(+), 11 deletions(-) create mode 100644 apps/buddy/electron/main/__tests__/mediaProtocol.spec.ts create mode 100644 apps/buddy/electron/main/mediaProtocol.ts create mode 100644 apps/buddy/electron/main/serveMediaFile.ts create mode 100644 apps/buddy/shared/files/__tests__/mediaFiles.spec.ts create mode 100644 apps/buddy/shared/files/mediaFiles.ts create mode 100644 apps/buddy/src/shared/ui/files/DesktopMediaContent.vue create mode 100644 apps/buddy/src/shared/ui/files/__tests__/DesktopMediaContent.spec.ts diff --git a/apps/buddy/electron/main/__tests__/mediaProtocol.spec.ts b/apps/buddy/electron/main/__tests__/mediaProtocol.spec.ts new file mode 100644 index 000000000..46dcf0eae --- /dev/null +++ b/apps/buddy/electron/main/__tests__/mediaProtocol.spec.ts @@ -0,0 +1,65 @@ +import { Buffer } from 'node:buffer' +import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { serveMediaFile } from '../serveMediaFile' + +const cleanup: Array<() => Promise> = [] +afterEach(async () => { + for (const dispose of cleanup.splice(0)) await dispose() +}) +async function directory() { + const root = await mkdtemp(join(tmpdir(), 'buddy-media-')) + cleanup.push(() => rm(root, { recursive: true, force: true })) + return root +} + +describe('media protocol', () => { + it('streams the whole file with range support advertised', async () => { + const path = join(await directory(), 'clip.mp4') + await writeFile(path, Buffer.from('0123456789')) + const response = await serveMediaFile(path, null) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe('video/mp4') + expect(response.headers.get('accept-ranges')).toBe('bytes') + expect(response.headers.get('content-length')).toBe('10') + expect(await response.text()).toBe('0123456789') + }) + + it('answers byte ranges with 206 and only the requested bytes', async () => { + const path = join(await directory(), 'song.mp3') + await writeFile(path, Buffer.from('0123456789')) + const middle = await serveMediaFile(path, 'bytes=2-5') + expect(middle.status).toBe(206) + expect(middle.headers.get('content-range')).toBe('bytes 2-5/10') + expect(await middle.text()).toBe('2345') + const tail = await serveMediaFile(path, 'bytes=-3') + expect(tail.headers.get('content-range')).toBe('bytes 7-9/10') + expect(await tail.text()).toBe('789') + }) + + it('rejects unsatisfiable ranges, unknown types and empty files cleanly', async () => { + const root = await directory() + await writeFile(join(root, 'clip.webm'), Buffer.from('abc')) + await writeFile(join(root, 'empty.ogg'), '') + await writeFile(join(root, 'notes.txt'), 'abc') + const unsatisfiable = await serveMediaFile(join(root, 'clip.webm'), 'bytes=10-') + expect(unsatisfiable.status).toBe(416) + expect(unsatisfiable.headers.get('content-range')).toBe('bytes */3') + expect((await serveMediaFile(join(root, 'notes.txt'), null)).status).toBe(415) + const empty = await serveMediaFile(join(root, 'empty.ogg'), null) + expect(empty.status).toBe(200) + expect(empty.headers.get('content-length')).toBe('0') + }) + + it('does not follow a link at the leaf', async () => { + const root = await directory() + await writeFile(join(root, 'secret.mp4'), 'secret') + try { + await symlink(join(root, 'secret.mp4'), join(root, 'link.mp4')) + } + catch { return } + expect((await serveMediaFile(join(root, 'link.mp4'), null)).status).toBe(404) + }) +}) diff --git a/apps/buddy/electron/main/app/DesktopRuntimeHost.ts b/apps/buddy/electron/main/app/DesktopRuntimeHost.ts index 412e68cae..3515fcb40 100644 --- a/apps/buddy/electron/main/app/DesktopRuntimeHost.ts +++ b/apps/buddy/electron/main/app/DesktopRuntimeHost.ts @@ -28,6 +28,7 @@ import { LexoraConfigStore } from '../config/LexoraConfigStore' import { ContextPanelHost } from '../context-panel/ContextPanelHost' import { DesktopPerformanceMonitor } from '../diagnostics/DesktopPerformanceMonitor' import { registerExtensionAuthoringRpc } from '../extensions/registerExtensionAuthoringRpc' +import { installMediaProtocol } from '../mediaProtocol' import { DesktopNetwork } from '../network/DesktopNetwork' import { registerWebHostRpc } from '../network/registerWebHostRpc' import { createDesktopFeatures } from '../platform/desktopFeatures' @@ -279,6 +280,7 @@ export class DesktopRuntimeHost { service.start() this.performance.start() this.#subscriptions.push(installAttachmentProtocol(service)) + this.#subscriptions.push(installMediaProtocol(service)) this.#subscriptions.push(installRendererProtocol()) } diff --git a/apps/buddy/electron/main/app/environment.ts b/apps/buddy/electron/main/app/environment.ts index b07f44a12..a8c280358 100644 --- a/apps/buddy/electron/main/app/environment.ts +++ b/apps/buddy/electron/main/app/environment.ts @@ -14,6 +14,7 @@ import { OPERATING_SYSTEM } from '../../../shared/platform/identifiers' import { attachmentSchemePrivileges } from '../attachmentProtocol' import { DesktopDiagnosticLogger } from '../desktopDiagnostics' import { extensionSchemePrivileges } from '../extensions/ExtensionProtocol' +import { mediaSchemePrivileges } from '../mediaProtocol' import { resolveBuddyRuntimePaths } from '../paths' import { desktopHosts } from '../platform/desktopHost' import { rendererSchemePrivileges } from '../rendererProtocol' @@ -130,7 +131,7 @@ export function initializeDesktopEnvironment(environment: DesktopEnvironment): v } } bootstrapStep('register_protocols', () => { - protocol.registerSchemesAsPrivileged([...attachmentSchemePrivileges, rendererSchemePrivileges, extensionSchemePrivileges, fileIconSchemePrivileges, themeSchemePrivileges]) + protocol.registerSchemesAsPrivileged([...attachmentSchemePrivileges, rendererSchemePrivileges, mediaSchemePrivileges, extensionSchemePrivileges, fileIconSchemePrivileges, themeSchemePrivileges]) }) bootstrapStep('desktop_identity', () => desktopHosts[currentPlatform.id].setIdentity?.(paths.desktopName)) } diff --git a/apps/buddy/electron/main/attachmentProtocol.ts b/apps/buddy/electron/main/attachmentProtocol.ts index 634b04235..86a3f13d1 100644 --- a/apps/buddy/electron/main/attachmentProtocol.ts +++ b/apps/buddy/electron/main/attachmentProtocol.ts @@ -8,6 +8,8 @@ import { resolveBuddyFileReader } from '../../platform/native/nativeHost' import { artifactsRequestSchemas, artifactsResponseSchemas } from '../../shared/artifacts/artifactApi' import { attachmentsRequestSchemas } from '../../shared/conversation/attachmentApi' import { composerResourcesRpc } from '../../shared/conversation/composerApi' +import { mediaKindOf } from '../../shared/files/mediaFiles' +import { serveMediaFile } from './serveMediaFile' const ATTACHMENT_PROTOCOL = 'lexora-attachment' const ARTIFACT_PROTOCOL = 'lexora-artifact' @@ -54,6 +56,9 @@ export function installAttachmentProtocol(runtime: BuddyServiceSupervisor): () = const preview = artifactsResponseSchemas.artifactPreview.parse( await runtime.request(method, input), ) + // Audio and video are streamed in ranges instead of being read whole, so a long recording can be seeked. + if (scheme === ARTIFACT_PROTOCOL && mediaKindOf(preview.path)) + return await serveMediaFile(preview.path, request.headers.get('range'), request.signal) const body = await readFile(preview.path) return new Response(body, { headers: { diff --git a/apps/buddy/electron/main/mediaProtocol.ts b/apps/buddy/electron/main/mediaProtocol.ts new file mode 100644 index 000000000..816726230 --- /dev/null +++ b/apps/buddy/electron/main/mediaProtocol.ts @@ -0,0 +1,31 @@ +import type { BuddyServiceSupervisor } from './runtime/BuddyServiceSupervisor' +import { protocol } from 'electron' +import { MEDIA_PROTOCOL, parseMediaUrl } from '../../shared/files/mediaFiles' +import { spaceFilesRpc } from '../../shared/spaces/spaceFileApi' +import { serveMediaFile } from './serveMediaFile' + +export const mediaSchemePrivileges: Electron.CustomScheme = { + scheme: MEDIA_PROTOCOL, + privileges: { secure: true, standard: true, stream: true, supportFetchAPI: true }, +} + +export function installMediaProtocol(runtime: Pick): () => void { + protocol.handle(MEDIA_PROTOCOL, async (request) => { + if (request.method !== 'GET') + return new Response(null, { status: 405 }) + const target = parseMediaUrl(request.url) + if (!target) + return new Response(null, { status: 400 }) + try { + // The service re-checks the space, directory grant and revision on every request, as it does for any file read. + const located = spaceFilesRpc.locate.response.parse(await runtime.request(spaceFilesRpc.locate.method, spaceFilesRpc.locate.input.parse(target))) + if (located.kind !== 'file') + return new Response(null, { status: 404 }) + return await serveMediaFile(located.path, request.headers.get('range'), request.signal) + } + catch { + return new Response(null, { status: 404 }) + } + }) + return () => protocol.unhandle(MEDIA_PROTOCOL) +} diff --git a/apps/buddy/electron/main/serveMediaFile.ts b/apps/buddy/electron/main/serveMediaFile.ts new file mode 100644 index 000000000..7c376c8f0 --- /dev/null +++ b/apps/buddy/electron/main/serveMediaFile.ts @@ -0,0 +1,53 @@ +import { lstat, open } from 'node:fs/promises' +import { Readable } from 'node:stream' +import { mediaMimeType, parseMediaRange } from '../../shared/files/mediaFiles' + +// Streams a local audio or video file with HTTP Range support, so media elements can seek without loading it whole. +export async function serveMediaFile(path: string, rangeHeader: string | null, signal?: AbortSignal): Promise { + const mimeType = mediaMimeType(path) + if (!mimeType) + return new Response(null, { status: 415 }) + // The path is canonical when located; refusing a leaf that has since become a link narrows the swap window. + const linked = await lstat(path) + if (!linked.isFile()) + return new Response(null, { status: 404 }) + const handle = await open(path, 'r') + try { + const metadata = await handle.stat() + if (!metadata.isFile() || metadata.size !== linked.size) { + await handle.close() + return new Response(null, { status: 404 }) + } + const headers: Record = { + 'accept-ranges': 'bytes', + 'cache-control': 'private, no-store', + 'content-type': mimeType, + 'x-content-type-options': 'nosniff', + } + const range = rangeHeader ? parseMediaRange(rangeHeader, metadata.size) : null + if (range === 'unsatisfiable') { + await handle.close() + return new Response(null, { status: 416, headers: { ...headers, 'content-range': `bytes */${metadata.size}` } }) + } + if (metadata.size === 0) { + await handle.close() + return new Response(null, { status: 200, headers: { ...headers, 'content-length': '0' } }) + } + const start = range?.start ?? 0 + const end = range?.end ?? metadata.size - 1 + const stream = handle.createReadStream({ start, end, autoClose: true }) + signal?.addEventListener('abort', () => stream.destroy(), { once: true }) + return new Response(Readable.toWeb(stream) as ReadableStream, { + status: range ? 206 : 200, + headers: { + ...headers, + 'content-length': String(end - start + 1), + ...(range ? { 'content-range': `bytes ${start}-${end}/${metadata.size}` } : {}), + }, + }) + } + catch (error) { + await handle.close().catch(() => {}) + throw error + } +} diff --git a/apps/buddy/index.html b/apps/buddy/index.html index dbf6c18ce..9e528e4f3 100644 --- a/apps/buddy/index.html +++ b/apps/buddy/index.html @@ -3,7 +3,7 @@ - + Lexora diff --git a/apps/buddy/service/src/artifacts/ArtifactService.ts b/apps/buddy/service/src/artifacts/ArtifactService.ts index 03c42f598..0f679b8cf 100644 --- a/apps/buddy/service/src/artifacts/ArtifactService.ts +++ b/apps/buddy/service/src/artifacts/ArtifactService.ts @@ -18,6 +18,7 @@ import { import { relativeCanonicalPath } from '../../../platform/filesystem/filePaths' import { Emitter } from '../../../shared/events/Emitter' import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' +import { mediaMimeType } from '../../../shared/files/mediaFiles' import { resolveGrantedPath } from '../directories/resolveGrantedPath' export const BUDDY_ARTIFACT_COUNT_LIMIT = 512 @@ -355,7 +356,13 @@ export class ArtifactService { resolvePreview(id: string): { mimeType: string, path: string } { const artifact = this.#requireVisibleArtifact(id) - if (artifact.kind !== 'file' || !artifact.mimeType.startsWith('image/')) + if (artifact.kind !== 'file') + throw new ArtifactError('VALIDATION_FAILED') + // Audio and video are served in ranges by the protocol; their content type comes from the extension, which the artifact record does not map. + const mimeType = mediaMimeType(artifact.currentPath) + if (mimeType) + return { mimeType, path: artifact.currentPath } + if (!artifact.mimeType.startsWith('image/')) throw new ArtifactError('VALIDATION_FAILED') return { mimeType: artifact.mimeType, path: artifact.currentPath } } diff --git a/apps/buddy/service/src/artifacts/__tests__/ArtifactService.spec.ts b/apps/buddy/service/src/artifacts/__tests__/ArtifactService.spec.ts index 5d0076977..80e5650e0 100644 --- a/apps/buddy/service/src/artifacts/__tests__/ArtifactService.spec.ts +++ b/apps/buddy/service/src/artifacts/__tests__/ArtifactService.spec.ts @@ -111,6 +111,16 @@ describe('artifactService', () => { expect(fixture.service.listConversationArtifacts('conversation-1')).toHaveLength(2) }) + it('resolves audio and video outputs for streaming but still refuses other binary files', async () => { + const fixture = await createFixture() + const paths = ['voice.MP3', 'clip.mp4', 'archive.zip'].map(name => join(fixture.workspace, name)) + await Promise.all(paths.map(path => writeFile(path, Buffer.from([1, 2, 3])))) + const [voice, clip, archive] = await fixture.service.presentOutputs({ conversationId: 'conversation-1', cwd: fixture.workspace, grants: fixture.grants, paths }) + expect(fixture.service.resolvePreview(voice!.id)).toEqual({ mimeType: 'audio/mpeg', path: voice!.currentPath }) + expect(fixture.service.resolvePreview(clip!.id)).toEqual({ mimeType: 'video/mp4', path: clip!.currentPath }) + expect(() => fixture.service.resolvePreview(archive!.id)).toThrow(/artifact operation failed/i) + }) + it('reports an output whose file was moved or deleted as not found', async () => { const fixture = await createFixture() const path = join(fixture.workspace, 'report.md') diff --git a/apps/buddy/service/src/spaces/SpaceFileService.ts b/apps/buddy/service/src/spaces/SpaceFileService.ts index 1b5f162a9..e7d329b6c 100644 --- a/apps/buddy/service/src/spaces/SpaceFileService.ts +++ b/apps/buddy/service/src/spaces/SpaceFileService.ts @@ -5,6 +5,7 @@ import { Buffer } from 'node:buffer' import { stat } from 'node:fs/promises' import { isAbsolute, relative, resolve, sep } from 'node:path' import { Emitter } from '../../../shared/events/Emitter' +import { mediaKindOf } from '../../../shared/files/mediaFiles' import { resolveGrantedPath } from '../directories/resolveGrantedPath' import { readFilePreview } from '../files/readFilePreview' import { BuddyServiceError } from '../rpc/runtimeRequest' @@ -53,6 +54,15 @@ export class SpaceFileService { async read(input: SpaceFileTarget): Promise { const target = await this.resolve(input).catch(rethrowMissingFile) this.requireDirectory(input) + const media = mediaKindOf(target.path) + if (media) { + // Audio and video are streamed by the media protocol, so the size limits that apply to inline previews do not. + const metadata = await stat(target.path).catch(rethrowMissingFile) + if (!metadata.isFile()) + throw new BuddyServiceError('VALIDATION_FAILED') + this.requireDirectory(input) + return { kind: media, sizeBytes: metadata.size, text: null, imageUrl: null } + } const preview = await readFilePreview(target.root, target.path).catch(rethrowMissingFile) this.requireDirectory(input) return preview diff --git a/apps/buddy/service/src/spaces/__tests__/SpaceFileService.spec.ts b/apps/buddy/service/src/spaces/__tests__/SpaceFileService.spec.ts index 7b23de7a9..6948f1772 100644 --- a/apps/buddy/service/src/spaces/__tests__/SpaceFileService.spec.ts +++ b/apps/buddy/service/src/spaces/__tests__/SpaceFileService.spec.ts @@ -74,6 +74,16 @@ describe('space file browsing', () => { expect(await f.files.read({ ...f.target, path: 'large.txt' })).toMatchObject({ kind: 'oversized', text: null }) }) + it('previews audio and video by type and size without reading their content', async () => { + const f = await fixture() + await writeFile(join(f.workspace, 'clip.MP4'), Buffer.alloc(2 * 1024 * 1024, 1)) + await writeFile(join(f.workspace, 'song.mp3'), Buffer.from([1, 2, 3])) + expect(await f.files.read({ ...f.target, path: 'clip.MP4' })).toEqual({ kind: 'video', sizeBytes: 2 * 1024 * 1024, text: null, imageUrl: null }) + expect(await f.files.read({ ...f.target, path: 'song.mp3' })).toEqual({ kind: 'audio', sizeBytes: 3, text: null, imageUrl: null }) + await rm(join(f.workspace, 'song.mp3')) + await expect(f.files.read({ ...f.target, path: 'song.mp3' })).rejects.toMatchObject({ code: 'FILE_NOT_FOUND' }) + }) + it('reports a file that was moved or deleted as not found', async () => { const f = await fixture() await writeFile(join(f.workspace, 'note.md'), '# note\n') diff --git a/apps/buddy/shared/files/__tests__/mediaFiles.spec.ts b/apps/buddy/shared/files/__tests__/mediaFiles.spec.ts new file mode 100644 index 000000000..ccfcd3833 --- /dev/null +++ b/apps/buddy/shared/files/__tests__/mediaFiles.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { createMediaUrl, mediaKindOf, mediaMimeType, parseMediaRange, parseMediaUrl } from '../mediaFiles' + +const target = { spaceId: 'space-1', directoryId: 'directory-1', revision: 3, path: '媒体/演示 视频.mp4' } + +describe('media files', () => { + it('tells audio and video apart by extension, ignoring case and hidden names', () => { + expect(mediaKindOf('a/b/clip.MP4')).toBe('video') + expect(mediaKindOf('song.flac')).toBe('audio') + expect(mediaMimeType('clip.mov')).toBe('video/quicktime') + expect(mediaKindOf('notes.md')).toBeNull() + expect(mediaKindOf('.mp3')).toBeNull() + expect(mediaKindOf('constructor')).toBeNull() + expect(mediaMimeType('toString')).toBeNull() + }) + + it('round-trips a file target through the media URL, including non-ASCII paths', () => { + expect(parseMediaUrl(createMediaUrl(target))).toEqual(target) + expect(parseMediaUrl(createMediaUrl(target, 2))).toEqual(target) + }) + + it('rejects URLs of another scheme, host, shape or payload', () => { + const url = createMediaUrl(target) + expect(parseMediaUrl(url.replace('lexora-media:', 'lexora-app:'))).toBeNull() + expect(parseMediaUrl(url.replace('//file/', '//other/'))).toBeNull() + expect(parseMediaUrl(`${url}/extra`)).toBeNull() + expect(parseMediaUrl('lexora-media://file/not-json')).toBeNull() + expect(parseMediaUrl(`lexora-media://file/${btoa(JSON.stringify({ ...target, path: 'a.mp4', extra: true })).replaceAll('=', '')}`)).toBeNull() + }) + + it('parses single byte ranges and refuses everything else', () => { + expect(parseMediaRange('bytes=0-99', 1000)).toEqual({ start: 0, end: 99 }) + expect(parseMediaRange('bytes=900-', 1000)).toEqual({ start: 900, end: 999 }) + expect(parseMediaRange('bytes=-100', 1000)).toEqual({ start: 900, end: 999 }) + expect(parseMediaRange('bytes=-5000', 1000)).toEqual({ start: 0, end: 999 }) + expect(parseMediaRange('bytes=500-5000', 1000)).toEqual({ start: 500, end: 999 }) + expect(parseMediaRange('bytes=1000-', 1000)).toBe('unsatisfiable') + expect(parseMediaRange('bytes=10-5', 1000)).toBe('unsatisfiable') + expect(parseMediaRange('bytes=0-1,5-9', 1000)).toBe('unsatisfiable') + expect(parseMediaRange('bytes=-', 1000)).toBe('unsatisfiable') + expect(parseMediaRange('bytes=0-0', 0)).toBe('unsatisfiable') + }) +}) diff --git a/apps/buddy/shared/files/filePreview.ts b/apps/buddy/shared/files/filePreview.ts index 5a6aad0f2..cdcf1549e 100644 --- a/apps/buddy/shared/files/filePreview.ts +++ b/apps/buddy/shared/files/filePreview.ts @@ -15,7 +15,7 @@ export const directoryPageSchema = z.object({ }).strict() export const filePreviewSchema = z.object({ - kind: z.enum(['text', 'image', 'binary', 'oversized']), + kind: z.enum(['text', 'image', 'audio', 'video', 'binary', 'oversized']), sizeBytes: z.number().int().nonnegative(), text: z.string().max(1024 * 1024).nullable(), imageUrl: z.string().max(12 * 1024 * 1024).nullable(), diff --git a/apps/buddy/shared/files/mediaFiles.ts b/apps/buddy/shared/files/mediaFiles.ts new file mode 100644 index 000000000..c7c9a7146 --- /dev/null +++ b/apps/buddy/shared/files/mediaFiles.ts @@ -0,0 +1,104 @@ +import type { SpaceFileTarget } from '../spaces/spaceFileApi' +import { spaceFileTargetSchema } from '../spaces/spaceFileApi' + +export const MEDIA_PROTOCOL = 'lexora-media' + +export type MediaKind = 'audio' | 'video' + +// Only containers and codecs Chromium can decode on its own; there is no transcoding fallback. +const AUDIO_MIME_TYPES: Readonly> = { + mp3: 'audio/mpeg', + wav: 'audio/wav', + ogg: 'audio/ogg', + oga: 'audio/ogg', + opus: 'audio/ogg', + m4a: 'audio/mp4', + aac: 'audio/aac', + flac: 'audio/flac', + weba: 'audio/webm', +} + +const VIDEO_MIME_TYPES: Readonly> = { + mp4: 'video/mp4', + m4v: 'video/mp4', + webm: 'video/webm', + mov: 'video/quicktime', + ogv: 'video/ogg', +} + +function extensionOf(path: string): string { + const name = path.split(/\//).at(-1) ?? '' + const dot = name.lastIndexOf('.') + return dot > 0 ? name.slice(dot + 1).toLowerCase() : '' +} + +export function mediaKindOf(path: string): MediaKind | null { + const extension = extensionOf(path) + if (Object.hasOwn(AUDIO_MIME_TYPES, extension)) + return 'audio' + return Object.hasOwn(VIDEO_MIME_TYPES, extension) ? 'video' : null +} + +export function mediaMimeType(path: string): string | null { + const extension = extensionOf(path) + if (Object.hasOwn(AUDIO_MIME_TYPES, extension)) + return AUDIO_MIME_TYPES[extension]! + return Object.hasOwn(VIDEO_MIME_TYPES, extension) ? VIDEO_MIME_TYPES[extension]! : null +} + +function toBase64Url(value: string): string { + const bytes = new TextEncoder().encode(value) + let binary = '' + for (const byte of bytes) binary += String.fromCharCode(byte) + return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '') +} + +function fromBase64Url(value: string): string { + const binary = atob(value.replaceAll('-', '+').replaceAll('_', '/')) + return new TextDecoder('utf-8', { fatal: true }).decode(Uint8Array.from(binary, character => character.charCodeAt(0))) +} + +// The URL carries the file target, never a filesystem path; the main process re-checks the grant on every request. +export function createMediaUrl(target: SpaceFileTarget, version = 0): string { + const { spaceId, directoryId, revision, path } = target + const encoded = toBase64Url(JSON.stringify({ spaceId, directoryId, revision, path })) + return `${MEDIA_PROTOCOL}://file/${encoded}${version ? `?v=${version}` : ''}` +} + +export function parseMediaUrl(value: string): SpaceFileTarget | null { + try { + const url = new URL(value) + if (url.protocol !== `${MEDIA_PROTOCOL}:` || url.hostname !== 'file') + return null + const segments = url.pathname.split('/').filter(Boolean) + if (segments.length !== 1) + return null + const parsed = spaceFileTargetSchema.safeParse(JSON.parse(fromBase64Url(segments[0]!))) + return parsed.success ? parsed.data : null + } + catch { + return null + } +} + +export type MediaByteRange = { start: number, end: number } | 'unsatisfiable' + +// Single `bytes=a-b`, `bytes=a-` and `bytes=-n` forms; multi-range requests are refused rather than answered in full. +export function parseMediaRange(header: string, size: number): MediaByteRange { + const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim()) + if (!match || (!match[1] && !match[2]) || size <= 0) + return 'unsatisfiable' + let start: number + let end: number + if (!match[1]) { + start = Math.max(size - Number(match[2]), 0) + end = size - 1 + } + else { + start = Number(match[1]) + end = match[2] ? Number(match[2]) : size - 1 + } + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start >= size || end < start) + return 'unsatisfiable' + return { start, end: Math.min(end, size - 1) } +} diff --git a/apps/buddy/src/app/workbench/useDesktopWorkbench.ts b/apps/buddy/src/app/workbench/useDesktopWorkbench.ts index 852cfb07f..71d09646d 100644 --- a/apps/buddy/src/app/workbench/useDesktopWorkbench.ts +++ b/apps/buddy/src/app/workbench/useDesktopWorkbench.ts @@ -12,6 +12,7 @@ import type { ChatReadingPositions } from '@/modules/tasks/ui' import type { CommandContext, DropPosition, ResourceRef, SplitDirection, WorkbenchView } from '@/workbench/common/workbench' import type { ViewCloseDecision } from '@/workbench/services/WorkbenchController' import { buddyUserContentToText, getBuddyUserContentResourceIds, hasBuddyUserContent } from '@buddy-shared/conversation/buddyUserContent' +import { mediaKindOf } from '@buddy-shared/files/mediaFiles' import { isSkillAvailable } from '@buddy-shared/skills/skillApi' import { spaceFileTargetSchema } from '@buddy-shared/spaces/spaceFileApi' import { NButton, useDialog } from 'naive-ui' @@ -386,11 +387,16 @@ export function useDesktopWorkbench(options: { buddyCatalog?: Readonly(null) const failed = shallowRef(false) const missing = shallowRef(false) -const modes = computed(() => fileDocumentModes({ preview: preview.value?.kind === 'image' || (preview.value?.kind === 'text' && isMarkdownFile(props.view.title)), source: preview.value?.kind === 'text', edit: false })) +const modes = computed(() => fileDocumentModes({ preview: preview.value?.kind === 'image' || preview.value?.kind === 'audio' || preview.value?.kind === 'video' || (preview.value?.kind === 'text' && isMarkdownFile(props.view.title)), source: preview.value?.kind === 'text', edit: false })) const mode = computed({ get: () => resolveFileDocumentMode(props.view.state.mode, modes.value), set: value => controller.updateView(props.view.id, { state: { ...props.view.state, mode: value } }) }) const quoteSource = computed(() => ({ kind: 'file', @@ -40,6 +41,10 @@ const selection = useResourceSelectionQuote({ language: () => props.language, }) const attempt = shallowRef(0) +const media = computed(() => { + const kind = preview.value?.kind + return kind === 'audio' || kind === 'video' ? { kind, url: createMediaUrl(spaceFileTargetSchema.strip().parse(props.view.resource.data), attempt.value) } : null +}) watch([identity, attempt], async (_, __, onCleanup) => { let active = true onCleanup(() => active = false) @@ -93,7 +98,7 @@ function captureResource() { diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopArtifactContextSurface.vue b/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopArtifactContextSurface.vue index dbce66213..7ef16ba4b 100644 --- a/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopArtifactContextSurface.vue +++ b/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopArtifactContextSurface.vue @@ -8,6 +8,7 @@ import { computed, useTemplateRef, watch } from 'vue' import { useBuddyI18n } from '@/i18n/buddyI18n' import { FileIcon, FolderIcon } from '@/shared/ui/file-icon' import DesktopDocumentContent from '@/shared/ui/files/DesktopDocumentContent.vue' +import DesktopMediaContent from '@/shared/ui/files/DesktopMediaContent.vue' import BuddyImagePreview from '@/shared/ui/media/BuddyImagePreview.vue' import ResourceSelectionQuoteMenu from '@/shared/ui/selection/ResourceSelectionQuoteMenu.vue' import { useResourceQuoteReveal } from '@/shared/ui/selection/useResourceQuoteReveal' @@ -45,7 +46,7 @@ const selection = useResourceSelectionQuote({ }) const { t } = useBuddyI18n(() => props.language) -const { refreshText, failImage, openPreview, previewIndex, previewOpen, previewSources, previewUrl, textPreview, textPreviewFailed, textPreviewLoading, textPreviewMissing } = useArtifactPreview({ +const { refreshText, failImage, media, openPreview, previewIndex, previewOpen, previewSources, previewUrl, textPreview, textPreviewFailed, textPreviewLoading, textPreviewMissing } = useArtifactPreview({ artifact: () => props.artifact, readText: () => props.readArtifactText, }) @@ -86,6 +87,7 @@ watch(textPreview, selection.close, { flush: 'sync' }) @error="failImage" > +
{{ t('common.loading') }} diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/useArtifactPreview.ts b/apps/buddy/src/modules/tasks/widgets/context-panel/useArtifactPreview.ts index 96fe99d9f..92122a6f8 100644 --- a/apps/buddy/src/modules/tasks/widgets/context-panel/useArtifactPreview.ts +++ b/apps/buddy/src/modules/tasks/widgets/context-panel/useArtifactPreview.ts @@ -1,4 +1,5 @@ import type { LocalArtifact, LocalArtifactText } from '@buddy-shared/artifacts/artifactApi' +import { mediaKindOf } from '@buddy-shared/files/mediaFiles' import { parseLocalChatPublicError } from '@buddy-shared/runtime/localChatError' import { computed, readonly, shallowRef, watch } from 'vue' import { isMarkdownArtifact, isTextMimeType } from './artifactContextPresentation' @@ -23,6 +24,12 @@ export function useArtifactPreview(options: ArtifactPreviewOptions) { ? `lexora-artifact://preview/${encodeURIComponent(artifact.artifactId)}?v=${encodeURIComponent(artifact.updatedAt)}` : null }) + // Audio and video play in place; the protocol serves them in ranges so seeking does not load the whole file. + const media = computed(() => { + const artifact = options.artifact() + const kind = artifact.kind === 'file' ? mediaKindOf(artifact.name) : null + return kind ? { kind, url: `lexora-artifact://preview/${encodeURIComponent(artifact.artifactId)}?v=${encodeURIComponent(artifact.updatedAt)}` } : null + }) const previewUrl = computed(() => previewFailed.value ? null : imageUrl.value) const previewSources = computed(() => previewUrl.value ? [previewUrl.value] : []) @@ -77,6 +84,7 @@ export function useArtifactPreview(options: ArtifactPreviewOptions) { return { refreshText: () => revision.value++, failImage, + media, openPreview, previewIndex, previewOpen, diff --git a/apps/buddy/src/shared/ui/files/DesktopDocumentContent.vue b/apps/buddy/src/shared/ui/files/DesktopDocumentContent.vue index 1bd5b6e29..d49a8d8e6 100644 --- a/apps/buddy/src/shared/ui/files/DesktopDocumentContent.vue +++ b/apps/buddy/src/shared/ui/files/DesktopDocumentContent.vue @@ -1,5 +1,6 @@ + + + + diff --git a/apps/buddy/src/shared/ui/files/__tests__/DesktopMediaContent.spec.ts b/apps/buddy/src/shared/ui/files/__tests__/DesktopMediaContent.spec.ts new file mode 100644 index 000000000..8ff7d4c15 --- /dev/null +++ b/apps/buddy/src/shared/ui/files/__tests__/DesktopMediaContent.spec.ts @@ -0,0 +1,42 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest' +import { createApp, h, nextTick, shallowRef } from 'vue' +import DesktopMediaContent from '../DesktopMediaContent.vue' + +function mount(kind: 'audio' | 'video') { + const url = shallowRef('lexora-media://file/abc') + const root = document.createElement('div') + document.body.append(root) + const app = createApp({ render: () => h(DesktopMediaContent, { kind, url: url.value, name: 'clip', failedLabel: '无法播放' }) }) + app.mount(root) + const dispose = () => { + app.unmount() + root.remove() + } + return { root, url, dispose } +} + +describe('media content', () => { + it('renders a video player for video and an audio player for audio', () => { + const video = mount('video') + expect(video.root.querySelector('video')?.getAttribute('src')).toBe('lexora-media://file/abc') + expect(video.root.querySelector('audio')).toBeNull() + video.dispose() + const audio = mount('audio') + expect(audio.root.querySelector('audio')?.getAttribute('src')).toBe('lexora-media://file/abc') + expect(audio.root.querySelector('video')).toBeNull() + audio.dispose() + }) + + it('reports a playback error and clears it when the source changes', async () => { + const view = mount('video') + expect(view.root.querySelector('[role="alert"]')).toBeNull() + view.root.querySelector('video')!.dispatchEvent(new Event('error')) + await nextTick() + expect(view.root.querySelector('[role="alert"]')?.textContent?.trim()).toBe('无法播放') + view.url.value = 'lexora-media://file/def' + await nextTick() + expect(view.root.querySelector('[role="alert"]')).toBeNull() + view.dispose() + }) +}) diff --git a/apps/buddy/src/workbench/common/workbenchLabels.ts b/apps/buddy/src/workbench/common/workbenchLabels.ts index b3f75f190..773d67dc3 100644 --- a/apps/buddy/src/workbench/common/workbenchLabels.ts +++ b/apps/buddy/src/workbench/common/workbenchLabels.ts @@ -34,6 +34,7 @@ const zh = { missing: '此视图暂不可用,资源和布局已保留。', failed: '视图加载失败。', fileMissing: '文件已被移动或删除。', + mediaFailed: '无法播放此文件,可能是当前不支持的编码格式。', restoreFailed: '工作台恢复失败,请重试。', backupFailed: '未保存内容的备份失败。请保存文件或重试备份。', conflict: '文件已在外部修改。请比较内容后选择保留的版本。', @@ -98,6 +99,7 @@ const en: Labels = { missing: 'This view is unavailable. Its resource and layout have been retained.', failed: 'The view could not be loaded.', fileMissing: 'This file has been moved or deleted.', + mediaFailed: 'This file cannot be played. Its encoding may not be supported.', restoreFailed: 'Could not restore the workbench. Please retry.', backupFailed: 'Could not back up unsaved changes. Save the file or retry the backup.', conflict: 'The file has changed on disk. Compare both versions before choosing which to keep.',