diff --git a/web/src/__tests__/agent-form-dialog-bulk-pure-options.test.ts b/web/src/__tests__/agent-form-dialog-bulk-pure-options.test.ts index 69983230..068ef5f9 100644 --- a/web/src/__tests__/agent-form-dialog-bulk-pure-options.test.ts +++ b/web/src/__tests__/agent-form-dialog-bulk-pure-options.test.ts @@ -156,7 +156,7 @@ describe("AgentFormDialog 模型选项批量格式化", () => { expect(mapModelOptions([entry])).toEqual([ { value: entry.id, - label: displayName, + label: displayName === "(group)" ? "group" : displayName, modelId: "model-name", group: { id: "organization:provider-name", label: "Open AI", scope: "organization" }, }, diff --git a/web/src/__tests__/agent-form-dialog-options-boundaries.test.tsx b/web/src/__tests__/agent-form-dialog-options-boundaries.test.tsx index c897a842..59b0de5d 100644 --- a/web/src/__tests__/agent-form-dialog-options-boundaries.test.tsx +++ b/web/src/__tests__/agent-form-dialog-options-boundaries.test.tsx @@ -160,6 +160,21 @@ describe("AgentFormDialog 选项数据转换边界", () => { ); }); + // 模型名称带英文括号时只展示括号内名称,避免重复展示外部别名。 + test("模型名称提取英文括号内容", () => { + expect(mapModelOptions([model({ displayName: "Claude 4.1 (claude-opus-4-1)" })])[0].label).toBe("claude-opus-4-1"); + }); + + // 模型名称带中文括号时遵循相同规则,并去除括号内首尾空白。 + test("模型名称提取中文括号内容", () => { + expect(mapModelOptions([model({ displayName: "通义千问( qwen-max )" })])[0].label).toBe("qwen-max"); + }); + + // 没有括号的模型名称必须直接显示原值。 + test("无括号模型名称保持原值", () => { + expect(mapModelOptions([model({ displayName: "Model One" })])[0].label).toBe("Model One"); + }); + // 本组织模型使用短模型名,并将 Provider 作为独立分组信息。 test("本组织模型拆分 Provider 与模型标签", () => { expect(mapModelOptions([model()])[0]).toMatchObject({ diff --git a/web/src/__tests__/preview-utils-normalize.test.ts b/web/src/__tests__/preview-utils-normalize.test.ts index 177181a5..fe64e601 100644 --- a/web/src/__tests__/preview-utils-normalize.test.ts +++ b/web/src/__tests__/preview-utils-normalize.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; -const { getPreviewMimeType, normalizeToUserPath } = await import("../components/agent-panel/preview/utils"); +const { getPreviewMimeType, loadByteAccuratePreviewSource, normalizeToUserPath, shouldLoadPreviewAsBlob } = + await import("../components/agent-panel/preview/utils"); describe("getPreviewMimeType — 特殊文件名仍按扩展名识别", () => { // open-file-viewer 会把 # 当成 URL fragment,显式 MIME 可避免 #123.txt 被判定为未知格式 @@ -14,6 +15,39 @@ describe("getPreviewMimeType — 特殊文件名仍按扩展名识别", () => { }); }); +describe("文本预览源 — 保留响应的真实字节大小", () => { + // 文本插件对 URL 会回退使用字符数;文本文件必须转为 Blob,让 size 保留 UTF-8 原始字节数 + test("UTF-8 中文文本使用 Blob 字节大小而非字符数", async () => { + const content = "中".repeat(1000); + const fetchPreview = async () => new Response(content, { headers: { "Content-Type": "text/plain" } }); + + const source = await loadByteAccuratePreviewSource("/preview/chinese.txt", fetchPreview); + + expect(source).toBeInstanceOf(Blob); + expect((source as Blob).size).toBe(3000); + expect((source as Blob).size).not.toBe(content.length); + }); + + // UTF-16 响应必须保留 BOM 和双字节编码,避免解码后按 4 个字符显示为 4 B + test("UTF-16 文本使用原始响应字节大小", async () => { + const bytes = Uint8Array.from([0xff, 0xfe, 0x2d, 0x4e, 0x87, 0x65, 0x4b, 0x6d, 0xd5, 0x8b]); + const fetchPreview = async () => new Response(bytes, { headers: { "Content-Type": "text/plain" } }); + + const source = await loadByteAccuratePreviewSource("/preview/utf16.txt", fetchPreview); + + expect(source).toBeInstanceOf(Blob); + expect((source as Blob).size).toBe(bytes.byteLength); + }); + + // 仅文本类型改为 Blob;依赖原始 URL 的 PDF、HTML 和媒体预览保持现有加载链路 + test("只为文本预览加载 Blob", () => { + expect(shouldLoadPreviewAsBlob("user/a.txt")).toBe(true); + expect(shouldLoadPreviewAsBlob("user/a.md")).toBe(true); + expect(shouldLoadPreviewAsBlob("user/a.html")).toBe(false); + expect(shouldLoadPreviewAsBlob("user/a.pdf")).toBe(false); + }); +}); + // ============================================================================= // normalizeToUserPath() — Agent 工具调用上报路径的规范化 // 设计要点:workspace 路径结构固定为 .../env_{envId}/<相对路径>, diff --git a/web/src/components/agent-panel/preview/FileViewerPreview.tsx b/web/src/components/agent-panel/preview/FileViewerPreview.tsx index a5f2eee7..34d3784c 100644 --- a/web/src/components/agent-panel/preview/FileViewerPreview.tsx +++ b/web/src/components/agent-panel/preview/FileViewerPreview.tsx @@ -2,12 +2,12 @@ import type { PreviewMessages } from "@open-file-viewer/core"; import { imagePlugin, officePlugin, textPlugin } from "@open-file-viewer/core"; import { FileViewer } from "@open-file-viewer/react"; import type { ErrorInfo, ReactNode } from "react"; -import { Component, useMemo } from "react"; +import { Component, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { NS } from "@/src/i18n"; import { htmlPreviewPlugin } from "./html-plugin"; import { nativePdfPlugin } from "./native-pdf-plugin"; -import { buildPreviewUrl, getPreviewMimeType } from "./utils"; +import { buildPreviewUrl, getPreviewMimeType, loadByteAccuratePreviewSource, shouldLoadPreviewAsBlob } from "./utils"; // 导入官方样式 import "@open-file-viewer/core/style.css"; @@ -73,6 +73,29 @@ export function FileViewerPreview({ envId, filePath }: FileViewerPreviewProps) { const previewUrl = useMemo(() => buildPreviewUrl(envId, filePath), [envId, filePath]); const fileName = useMemo(() => filePath.split("/").pop() ?? filePath, [filePath]); const mimeType = useMemo(() => getPreviewMimeType(filePath), [filePath]); + const loadAsBlob = useMemo(() => shouldLoadPreviewAsBlob(filePath), [filePath]); + const [previewSource, setPreviewSource] = useState(() => (loadAsBlob ? null : previewUrl)); + const [loadError, setLoadError] = useState(null); + const [reloadKey, setReloadKey] = useState(0); + + useEffect(() => { + if (!loadAsBlob) { + setPreviewSource(previewUrl); + setLoadError(null); + return; + } + + const controller = new AbortController(); + setPreviewSource(null); + setLoadError(null); + const requestUrl = reloadKey === 0 ? previewUrl : `${previewUrl}&retry=${reloadKey}`; + void loadByteAccuratePreviewSource(requestUrl, fetch, { signal: controller.signal }) + .then(setPreviewSource) + .catch((error: unknown) => { + if (!controller.signal.aborted) setLoadError(error instanceof Error ? error.message : String(error)); + }); + return () => controller.abort(); + }, [loadAsBlob, previewUrl, reloadKey]); const toolbar = useMemo( () => ({ @@ -94,10 +117,33 @@ export function FileViewerPreview({ envId, filePath }: FileViewerPreviewProps) { [], ); + if (loadError) { + return ( +
+ {loadError} + +
+ ); + } + + if (previewSource === null) { + return ( +
+ {t("fileTree.preview.loading", "加载中...")} +
+ ); + } + return ( Promise = fetch, + init?: RequestInit, +): Promise { + const response = await fetchPreview(previewUrl, init); + if (!response.ok) throw new Error(`文件预览加载失败 (${response.status})`); + return response.blob(); +} + /** * 构建文件预览 URL。 * 按路径段分别 encodeURIComponent,避免中文等非 ASCII 字符在浏览器→Vite 代理→后端 diff --git a/web/src/pages/agent-panel/agent-editor/agent-editor-form-surfaces.css b/web/src/pages/agent-panel/agent-editor/agent-editor-form-surfaces.css index 09fbd8b2..e9d00746 100644 --- a/web/src/pages/agent-panel/agent-editor/agent-editor-form-surfaces.css +++ b/web/src/pages/agent-panel/agent-editor/agent-editor-form-surfaces.css @@ -38,7 +38,7 @@ } .agent-editor-root .agent-single-picker-current strong { color: #285bb6; - font-size: 12px; + font-size: 16px; font-weight: 680; } .agent-editor-root .agent-single-picker-current span { @@ -134,7 +134,7 @@ display: -webkit-box; overflow: hidden; color: #31425d; - font-size: 13px; + font-size: 17px; line-height: 1.35; overflow-wrap: anywhere; -webkit-box-orient: vertical; @@ -220,7 +220,7 @@ } .agent-editor-root .agent-model-summary strong { color: #34455f; - font-size: 14px; + font-size: 18px; font-weight: 680; } .agent-editor-root .agent-model-summary p { diff --git a/web/src/pages/agent-panel/agent-editor/agent-editor-model.ts b/web/src/pages/agent-panel/agent-editor/agent-editor-model.ts index 4ad406d4..4e09d259 100644 --- a/web/src/pages/agent-panel/agent-editor/agent-editor-model.ts +++ b/web/src/pages/agent-panel/agent-editor/agent-editor-model.ts @@ -235,14 +235,20 @@ export function buildAgentEditorPayload(values: AgentEditorValues, mode: "create return payload; } -/** 模型选项始终使用数据库 UUID,展示标签保留 provider 来源组织。 */ +/** 模型名称存在括号时仅展示括号内文本;无括号时保留原名称。 */ +export function getModelDisplayLabel(displayName: string): string { + const parenthesizedName = displayName.match(/[((]([^()()]+)[))]\s*$/)?.[1]?.trim(); + return parenthesizedName || displayName; +} + +/** 模型选项始终使用数据库 UUID,并将展示名称与 provider 分组在视图边界处收敛。 */ export function mapModelOptions(models: ModelEntry[]): AgentModelOption[] { return models.map((model) => { const access = model.providerResourceAccess; const provider = model.providerDisplayName; return { value: model.id, - label: model.displayName, + label: getModelDisplayLabel(model.displayName), modelId: model.modelId, group: { id: `${access?.sourceOrganizationId ?? "organization"}:${model.providerResourceKey ?? model.provider}`,