Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
},
Expand Down
15 changes: 15 additions & 0 deletions web/src/__tests__/agent-form-dialog-options-boundaries.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
36 changes: 35 additions & 1 deletion web/src/__tests__/preview-utils-normalize.test.ts
Original file line number Diff line number Diff line change
@@ -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 被判定为未知格式
Expand All @@ -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}/<相对路径>,
Expand Down
52 changes: 49 additions & 3 deletions web/src/components/agent-panel/preview/FileViewerPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string | Blob | null>(() => (loadAsBlob ? null : previewUrl));
const [loadError, setLoadError] = useState<string | null>(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(
() => ({
Expand All @@ -94,10 +117,33 @@ export function FileViewerPreview({ envId, filePath }: FileViewerPreviewProps) {
[],
);

if (loadError) {
return (
<div className="flex-1 flex flex-col items-center justify-center p-4 gap-3" role="alert">
<span className="text-xs font-medium text-red-500">{loadError}</span>
<button
type="button"
className="text-xs text-primary hover:underline"
onClick={() => setReloadKey((key) => key + 1)}
>
{t("fileTree.preview.retry", "重试")}
</button>
</div>
);
}

if (previewSource === null) {
return (
<div className="flex-1 flex items-center justify-center p-4" role="status">
<span className="text-xs text-text-muted">{t("fileTree.preview.loading", "加载中...")}</span>
</div>
);
}

return (
<FileViewerErrorBoundary filePath={filePath}>
<FileViewer
file={previewUrl}
file={previewSource}
fileName={fileName}
mimeType={mimeType}
plugins={plugins}
Expand Down
23 changes: 23 additions & 0 deletions web/src/components/agent-panel/preview/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,29 @@ export function getPreviewMimeType(filePath: string): string | undefined {
return;
}

/**
* URL 形式的文本源会让 @open-file-viewer 在元数据缺失时退化为 `text.length`,
* 把字符数误显示为字节数。HTML 需要保留 URL 供 sandbox iframe 渲染,因此不在此转换。
*/
export function shouldLoadPreviewAsBlob(filePath: string): boolean {
const category = classifyFile(filePath);
return category === "code" || category === "markdown";
}

/**
* 将文本预览响应保留为 Blob,使预览器使用原始响应字节数并自行按 BOM 解码。
* 非成功响应必须在进入预览器前显式失败,避免把错误页当作文件内容展示。
*/
export async function loadByteAccuratePreviewSource(
previewUrl: string,
fetchPreview: (url: string, init?: RequestInit) => Promise<Response> = fetch,
init?: RequestInit,
): Promise<Blob> {
const response = await fetchPreview(previewUrl, init);
if (!response.ok) throw new Error(`文件预览加载失败 (${response.status})`);
return response.blob();
}

/**
* 构建文件预览 URL。
* 按路径段分别 encodeURIComponent,避免中文等非 ASCII 字符在浏览器→Vite 代理→后端
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 8 additions & 2 deletions web/src/pages/agent-panel/agent-editor/agent-editor-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down
Loading