From 98249abe12acd8afa55416909b25945cee2fbdc7 Mon Sep 17 00:00:00 2001 From: dolphin Date: Wed, 12 Aug 2026 22:46:36 +0800 Subject: [PATCH 01/19] fix(permission): let the delete failure reach the toast that explains it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend already said why a custom model could not be deleted, but the page still showed "delete failed, please refresh". Two reasons, both mine: - The response interceptor rejects a business error with a bare message string, so reading `error.data` for the reason always came up empty. Callers that want to explain a failure need the envelope; `silent` yields it. Declared the flag on AxiosRequestConfig so passing it no longer needs an `as any`. - The refusal lands while the batch is being *drafted*, not at publish — the catalog validates the changes as the draft is built. Asking for the envelope on the publish leg alone never saw it. The page-level tests now fail the draft leg, which is what actually happens; the earlier version tested a path that never fails first, which is why it passed while the toast stayed wrong. --- .../src/controllers/API/permission.ts | 15 ++- .../components/RolesAndPermissions.tsx | 41 ++++--- .../src/test/f048PermissionApi.test.ts | 4 + .../src/test/f048RolesAndPermissions.test.tsx | 108 +++++++++++++++--- src/frontend/platform/src/types/global.d.ts | 10 ++ 5 files changed, 147 insertions(+), 31 deletions(-) diff --git a/src/frontend/platform/src/controllers/API/permission.ts b/src/frontend/platform/src/controllers/API/permission.ts index bd5785f3a7..84fe176074 100644 --- a/src/frontend/platform/src/controllers/API/permission.ts +++ b/src/frontend/platform/src/controllers/API/permission.ts @@ -255,8 +255,15 @@ export async function getPermissionCatalogApi(): Promise { - return await axios.post(`/api/v1/permissions/catalog/drafts`, payload) + // The batch is validated here, not only at publish: a change the release + // cannot accept is refused while drafting. `silent` yields the response + // envelope instead of a bare message string, so a caller can read the + // business error's `data` and explain what went wrong. + return await axios.post(`/api/v1/permissions/catalog/drafts`, payload, { + silent: config.silent, + }) } export async function getPermissionCatalogDraftApi( @@ -268,10 +275,16 @@ export async function getPermissionCatalogDraftApi( export async function publishPermissionCatalogDraftApi( draftId: number, payload: PublishPermissionCatalogDraftRequest, + config: { silent?: boolean } = {}, ): Promise { + // `silent` makes the interceptor reject with the response envelope instead of + // a bare message string, which is the only way to read the `data` a business + // error carries — a caller that wants to explain *why* the publish failed + // needs it. return await axios.post( `/api/v1/permissions/catalog/drafts/${draftId}/publish`, payload, + { silent: config.silent }, ) } diff --git a/src/frontend/platform/src/pages/SystemPage/components/RolesAndPermissions.tsx b/src/frontend/platform/src/pages/SystemPage/components/RolesAndPermissions.tsx index 571d47d292..dc77bde068 100644 --- a/src/frontend/platform/src/pages/SystemPage/components/RolesAndPermissions.tsx +++ b/src/frontend/platform/src/pages/SystemPage/components/RolesAndPermissions.tsx @@ -210,13 +210,17 @@ export function RolesAndPermissions() { const handleCreateDraft = async ( changes: PermissionCatalogChange[], + config: { silent?: boolean } = {}, ): Promise => { if (!catalog) throw new Error("permission Catalog is not loaded") - return await createPermissionCatalogDraftApi({ - idempotency_key: createIdempotencyKey("catalog-draft"), - base_release_id: catalog.id, - changes, - }) + return await createPermissionCatalogDraftApi( + { + idempotency_key: createIdempotencyKey("catalog-draft"), + base_release_id: catalog.id, + changes, + }, + config, + ) } const handleReviewImpact = (draft: PermissionCatalogDraft) => { @@ -244,12 +248,20 @@ export function RolesAndPermissions() { ] : [{ type: "DELETE_MODEL", model_key: modelKey }] try { - const draft = await handleCreateDraft(changes) - await handlePublish(draft.draft_id, { - expected_current_release_id: catalog.id, - idempotency_key: createIdempotencyKey("catalog-publish"), - confirmed: true, - }) + // Deletion is refused while drafting, not at publish — ask for the + // envelope on both legs or the reason is lost on the first one. + const draft = await handleCreateDraft(changes, { silent: true }) + await handlePublish( + draft.draft_id, + { + expected_current_release_id: catalog.id, + idempotency_key: createIdempotencyKey("catalog-publish"), + confirmed: true, + }, + // Ask for the envelope: the failure below needs the reason the server + // sent, and the default rejection is a bare message string. + { silent: true }, + ) setSelectedModelKey(null) } catch (error) { // Nothing else reports this: the request layer only auto-toasts a couple of @@ -257,7 +269,9 @@ export function RolesAndPermissions() { // leave the model in place with no explanation. 25004 covers several model // -state conflicts, so name the one in the way — "state does not allow // this" leaves the author with nothing to act on. - const detail = (error as { data?: { reason?: string; reference_count?: number } })?.data + const detail = ( + error as { data?: { reason?: string; reference_count?: number } } | null + )?.data message({ variant: "error", description: @@ -271,8 +285,9 @@ export function RolesAndPermissions() { const handlePublish = async ( draftId: number, payload: PublishPermissionCatalogDraftRequest, + config: { silent?: boolean } = {}, ) => { - await publishPermissionCatalogDraftApi(draftId, payload) + await publishPermissionCatalogDraftApi(draftId, payload, config) setImpactDraft(null) await loadCatalog() message({ diff --git a/src/frontend/platform/src/test/f048PermissionApi.test.ts b/src/frontend/platform/src/test/f048PermissionApi.test.ts index 3b234af157..2f78986e98 100644 --- a/src/frontend/platform/src/test/f048PermissionApi.test.ts +++ b/src/frontend/platform/src/test/f048PermissionApi.test.ts @@ -59,6 +59,8 @@ describe("F048 Platform permission API", () => { 1, "/api/v1/permissions/catalog/drafts", expect.objectContaining({ base_release_id: 12 }), + // Off unless a caller asks for the envelope to explain a failure. + { silent: undefined }, ) expect(requestMocks.get).toHaveBeenNthCalledWith( 2, @@ -72,6 +74,8 @@ describe("F048 Platform permission API", () => { idempotency_key: "publish-1", confirmed: true, }, + // Off unless a caller asks for the envelope to explain a failure. + { silent: undefined }, ) }) diff --git a/src/frontend/platform/src/test/f048RolesAndPermissions.test.tsx b/src/frontend/platform/src/test/f048RolesAndPermissions.test.tsx index 2ad372383a..2352e07e94 100644 --- a/src/frontend/platform/src/test/f048RolesAndPermissions.test.tsx +++ b/src/frontend/platform/src/test/f048RolesAndPermissions.test.tsx @@ -15,6 +15,7 @@ const childCalls = vi.hoisted(() => ({ actionBoard: vi.fn(), modelEditor: vi.fn(), impactDialog: vi.fn(), + message: vi.fn(), })) vi.mock("@/pages/SystemPage/components/Roles", () => ({ @@ -55,11 +56,21 @@ vi.mock( ) vi.mock("@/pages/SystemPage/components/permission/ModelEditor", () => ({ - ModelEditor: (props: { model: { key: string }; createMode?: boolean }) => { + ModelEditor: (props: { + model: { key: string } + createMode?: boolean + onDeleteModel?: (modelKey: string, wasActive: boolean) => Promise + }) => { childCalls.modelEditor(props) return (
{props.createMode ? "create" : props.model.key} +
) }, @@ -97,6 +108,10 @@ vi.mock("@/pages/SystemPage/components/permission/ImpactDialog", () => ({ }, })) +vi.mock("@/components/bs-ui/toast/use-toast", () => ({ + message: (...args: unknown[]) => childCalls.message(...args), +})) + vi.mock("@/controllers/API/permission", () => ({ createPermissionCatalogDraftApi: vi.fn(), getPermissionCatalogApi: vi.fn(), @@ -233,27 +248,34 @@ describe("F048 RolesAndPermissions", () => { ) await waitFor(() => { - expect(createPermissionCatalogDraftApi).toHaveBeenCalledWith({ - idempotency_key: expect.stringMatching(/^catalog-draft-/), - base_release_id: 21, - changes: [ - { - type: "ASSIGN_ACTION_LEVEL", - action_code: "edit", - level: 2, - }, - ], - }) + expect(createPermissionCatalogDraftApi).toHaveBeenCalledWith( + { + idempotency_key: expect.stringMatching(/^catalog-draft-/), + base_release_id: 21, + changes: [ + { + type: "ASSIGN_ACTION_LEVEL", + action_code: "edit", + level: 2, + }, + ], + }, + {}, + ) }) fireEvent.click( await screen.findByRole("button", { name: "impact-dialog.publish" }), ) await waitFor(() => { - expect(publishPermissionCatalogDraftApi).toHaveBeenCalledWith(31, { - expected_current_release_id: 21, - idempotency_key: "catalog-publish-test", - confirmed: true, - }) + expect(publishPermissionCatalogDraftApi).toHaveBeenCalledWith( + 31, + { + expected_current_release_id: 21, + idempotency_key: "catalog-publish-test", + confirmed: true, + }, + {}, + ) expect(getPermissionCatalogApi).toHaveBeenCalledTimes(2) }) }) @@ -287,4 +309,56 @@ describe("F048 RolesAndPermissions", () => { }), ) }) + + it("names the blocker when a model cannot be deleted", async () => { + // 25004 covers several model-state conflicts, so its shared copy says only + // "state does not allow this". The count is the actionable part: it tells + // the author how much has to be moved off the model first. + // + // The refusal lands on the *draft* leg — the batch is validated as it is + // drafted, so a fix that only listened to the publish leg never saw it. + vi.mocked(createPermissionCatalogDraftApi).mockRejectedValueOnce({ + status_code: 25004, + data: { reason: "referenced_by_grants", reference_count: 3 }, + }) + + renderWithUser({ role: "admin", is_global_super: true }) + await waitFor(() => expect(getPermissionCatalogApi).toHaveBeenCalled()) + const modelsTab = screen.getByRole("tab", { name: "catalog.models" }) + fireEvent.mouseDown(modelsTab) + fireEvent.click(modelsTab) + + fireEvent.click(await screen.findByText("model-editor.delete")) + + await waitFor(() => { + expect(childCalls.message).toHaveBeenCalledWith({ + variant: "error", + description: "model.deleteBlockedByGrants", + }) + }) + }) + + it("falls back to the plain failure when the server sends no reason", async () => { + vi.mocked(publishPermissionCatalogDraftApi).mockRejectedValueOnce( + "some other failure", + ) + vi.mocked(createPermissionCatalogDraftApi).mockResolvedValueOnce({ + draft_id: 31, + } as never) + + renderWithUser({ role: "admin", is_global_super: true }) + await waitFor(() => expect(getPermissionCatalogApi).toHaveBeenCalled()) + const modelsTab = screen.getByRole("tab", { name: "catalog.models" }) + fireEvent.mouseDown(modelsTab) + fireEvent.click(modelsTab) + + fireEvent.click(await screen.findByText("model-editor.delete")) + + await waitFor(() => { + expect(childCalls.message).toHaveBeenCalledWith({ + variant: "error", + description: "model.deleteFailed", + }) + }) + }) }) diff --git a/src/frontend/platform/src/types/global.d.ts b/src/frontend/platform/src/types/global.d.ts index 819b9dff9c..f9d978472f 100644 --- a/src/frontend/platform/src/types/global.d.ts +++ b/src/frontend/platform/src/types/global.d.ts @@ -58,3 +58,13 @@ declare module "*.svg" { const content: any; export default content; } + +// `silent` is read by the response interceptor in `@/controllers/request.ts`: +// it skips the global error handling and rejects with the response envelope +// instead of a bare message string, so a caller can read the business error's +// `data`. Declared here so passing it no longer needs an `as any` cast. +declare module "axios" { + export interface AxiosRequestConfig { + silent?: boolean + } +} From cda11af875e2ed2aa5777601491af617066fd39b Mon Sep 17 00:00:00 2001 From: dolphin Date: Wed, 12 Aug 2026 23:28:33 +0800 Subject: [PATCH 02/19] refactor(permission): make the action and model pages readable at a glance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both pages showed everything at once and explained none of it. Action board — the card carried a name, a code, a switch, a row of resource chips and a full-width level select, so twelve actions filled five columns with repetition. The card is now a line: drag handle, name, on/off, plus a compact level dropdown underneath. Dragging stays the fast path and the dropdown is the same move for a keyboard or a touch screen; the code and the resource scope moved into a tooltip. The drop target highlights while dragging, "unassigned" lost its amber warning styling (it is an ordinary state), and the pending-changes bar can now list what actually changed — a count alone told the author nothing before they published it. Model editor — the header showed a bare "3" with no label; it now names the model, marks it off when it is, and calls the number what it is, next to a line saying the level comes from the highest action selected. The action checkboxes are grouped by level so that derivation is visible in the structure rather than asserted. Both switches gained the consequence their labels never carried, "allow same level" in particular. The model list shows tier and on/off per row. Also, from review: - Every control here overrode its height to 44px; the spec tops out at 40. Dropped the overrides so buttons, inputs and selects use their own sizes. - The selected list row sat its two lines against the top edge. - The unpublished-draft notice was the last thing in the scrolling form, so the button that publishes it was below the fold and saving looked like a no-op. It now lives in the standing footer beside the button that produced it. - Hardcoded blue/amber/red gave way to theme tokens. Tests follow three changed contracts: level is picked from a menu (new `selectMenuOption` helper), resource scope is revealed on hover, and the derived level is asserted through `data-level` since the visible text is localized. --- .../public/locales/en-US/permission.json | 19 +- .../public/locales/ja/permission.json | 19 +- .../public/locales/zh-Hans/permission.json | 19 +- .../components/RolesAndPermissions.tsx | 15 +- .../permission/ActionLevelBoard.tsx | 397 +++++++++++------- .../components/permission/ImpactDialog.tsx | 4 +- .../components/permission/ModelEditor.tsx | 221 +++++++--- .../src/test/f048ActionLevelBoard.test.tsx | 47 ++- .../src/test/f048ModelEditor.test.tsx | 8 +- src/frontend/platform/src/test/test-utils.tsx | 20 + 10 files changed, 511 insertions(+), 258 deletions(-) diff --git a/src/frontend/platform/public/locales/en-US/permission.json b/src/frontend/platform/public/locales/en-US/permission.json index 3c9b4ea473..614b09b28e 100644 --- a/src/frontend/platform/public/locales/en-US/permission.json +++ b/src/frontend/platform/public/locales/en-US/permission.json @@ -73,7 +73,15 @@ "pendingChanges_other": "{{count}} changes are not published yet", "publishChanges": "Publish changes", "discardChanges": "Discard changes", - "draftFailed": "Could not prepare the change draft. Try again." + "draftFailed": "Could not prepare the change draft. Try again.", + "appliesTo": "Applies to", + "showChangeList": "Show details", + "hideChangeList": "Hide details", + "changeSummary": { + "level": "{{name}}: {{from}} → {{to}}", + "enabled": "{{name}}: enabled", + "disabled": "{{name}}: disabled" + } }, "model": { "title": "Permission Model", @@ -83,7 +91,7 @@ "actions": "Included Actions", "action": "Model action", "active": "Enable Model", - "inactive": "Model inactive", + "inactive": "Off", "level": "Derived Level", "allowSameLevel": "Allow Same-level Grants", "create": "New Model", @@ -103,7 +111,12 @@ "confirmDelete": "Delete the model \"{{name}}\"? This takes effect immediately and cannot be undone.", "deleteFailed": "Delete failed. Refresh and try again", "deleteBlockedByGrants": "{{count}} grant(s) still use this permission model. Move them to another model before deleting it.", - "disableBeforeDelete": "Turn the model off before deleting it" + "disableBeforeDelete": "Turn the model off before deleting it", + "description": "Tick the actions this model covers. Its level is the highest action in the selection.", + "derivedLevel": "Model level", + "activeHint": "Switched off, it can no longer be granted; existing grants are unaffected.", + "allowSameLevelHint": "Holders may grant this same tier to others.", + "allowSameLevelUnavailable": "Select the manage-permission action first." }, "impact": { "title": "Confirm Publish Impact", diff --git a/src/frontend/platform/public/locales/ja/permission.json b/src/frontend/platform/public/locales/ja/permission.json index f9f8897044..72803f3cfe 100644 --- a/src/frontend/platform/public/locales/ja/permission.json +++ b/src/frontend/platform/public/locales/ja/permission.json @@ -73,7 +73,15 @@ "pendingChanges_other": "未公開の変更が {{count}} 件あります", "publishChanges": "変更を公開", "discardChanges": "変更を破棄", - "draftFailed": "変更ドラフトの作成に失敗しました。再試行してください。" + "draftFailed": "変更ドラフトの作成に失敗しました。再試行してください。", + "appliesTo": "対象リソース", + "showChangeList": "詳細を表示", + "hideChangeList": "詳細を隠す", + "changeSummary": { + "level": "{{name}}:{{from}} → {{to}}", + "enabled": "{{name}}:有効化", + "disabled": "{{name}}:無効化" + } }, "model": { "title": "権限モデル", @@ -83,7 +91,7 @@ "actions": "含まれるアクション", "action": "モデルアクション", "active": "モデルを有効化", - "inactive": "モデルは無効です", + "inactive": "無効", "level": "派生レベル", "allowSameLevel": "同レベルへの付与を許可", "create": "モデルを作成", @@ -103,7 +111,12 @@ "confirmDelete": "モデル「{{name}}」を削除しますか?即時に反映され、元に戻せません。", "deleteFailed": "削除に失敗しました。再読み込みしてやり直してください", "deleteBlockedByGrants": "この権限モデルはまだ {{count}} 件の権限付与で使用されています。先に他のモデルへ移してください", - "disableBeforeDelete": "削除する前にモデルを無効化してください" + "disableBeforeDelete": "削除する前にモデルを無効化してください", + "description": "このモデルに含める操作を選択します。レベルは選択した操作の最上位で決まります。", + "derivedLevel": "モデルレベル", + "activeHint": "無効にすると新たに付与できません。既存の付与は影響を受けません。", + "allowSameLevelHint": "保持者が同じレベルの権限を他者へ付与できます。", + "allowSameLevelUnavailable": "先に「権限管理」操作を選択してください。" }, "impact": { "title": "公開影響の確認", diff --git a/src/frontend/platform/public/locales/zh-Hans/permission.json b/src/frontend/platform/public/locales/zh-Hans/permission.json index e93c0ebe05..e2c7d3b02d 100644 --- a/src/frontend/platform/public/locales/zh-Hans/permission.json +++ b/src/frontend/platform/public/locales/zh-Hans/permission.json @@ -73,7 +73,15 @@ "pendingChanges_other": "有 {{count}} 项改动尚未发布", "publishChanges": "发布更改", "discardChanges": "放弃更改", - "draftFailed": "生成变更草案失败,请重试" + "draftFailed": "生成变更草案失败,请重试", + "appliesTo": "适用资源", + "showChangeList": "查看明细", + "hideChangeList": "收起明细", + "changeSummary": { + "level": "{{name}}:{{from}} → {{to}}", + "enabled": "{{name}}:启用", + "disabled": "{{name}}:停用" + } }, "model": { "title": "权限模型", @@ -83,7 +91,7 @@ "actions": "包含的动作", "action": "模型动作", "active": "启用模型", - "inactive": "模型已停用", + "inactive": "已停用", "level": "派生等级", "allowSameLevel": "允许同级授权", "create": "新建模型", @@ -103,7 +111,12 @@ "confirmDelete": "确认删除模型「{{name}}」?删除后立即生效,不可恢复。", "deleteFailed": "删除失败,请刷新后重试", "deleteBlockedByGrants": "该权限模型还有 {{count}} 处授权在使用,先把这些授权改到其他模型再删除", - "disableBeforeDelete": "先关闭「启用」才能删除" + "disableBeforeDelete": "先关闭「启用」才能删除", + "description": "勾选这个模型包含哪些动作,等级由其中最高的动作决定。", + "derivedLevel": "模型等级", + "activeHint": "关闭后不能再用它授权,已有授权不受影响。", + "allowSameLevelHint": "开启后,持有此模型的人可以把同等级权限授予别人。", + "allowSameLevelUnavailable": "需要先勾选「管理权限」动作。" }, "impact": { "title": "发布影响确认", diff --git a/src/frontend/platform/src/pages/SystemPage/components/RolesAndPermissions.tsx b/src/frontend/platform/src/pages/SystemPage/components/RolesAndPermissions.tsx index dc77bde068..eda62895a3 100644 --- a/src/frontend/platform/src/pages/SystemPage/components/RolesAndPermissions.tsx +++ b/src/frontend/platform/src/pages/SystemPage/components/RolesAndPermissions.tsx @@ -52,7 +52,7 @@ function CatalogState({ loading, error, onRetry }: CatalogStateProps) { ))} diff --git a/src/frontend/platform/src/pages/SystemPage/components/permission/ActionLevelBoard.tsx b/src/frontend/platform/src/pages/SystemPage/components/permission/ActionLevelBoard.tsx index 7755ae84dd..4ddd26186d 100644 --- a/src/frontend/platform/src/pages/SystemPage/components/permission/ActionLevelBoard.tsx +++ b/src/frontend/platform/src/pages/SystemPage/components/permission/ActionLevelBoard.tsx @@ -1,12 +1,18 @@ import { Button } from "@/components/bs-ui/button" import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/bs-ui/select" + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/bs-ui/dropdownMenu" import { Switch } from "@/components/bs-ui/switch" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/bs-ui/tooltip" import type { PermissionActionLevel, PermissionCatalogAction, @@ -14,8 +20,8 @@ import type { PermissionCatalogDraft, } from "@/controllers/API/permission" import { cn } from "@/utils" -import { GripVertical, ShieldAlert } from "lucide-react" -import { useEffect, useMemo, useState } from "react" +import { ChevronDown, GripVertical, Info } from "lucide-react" +import { useCallback, useEffect, useMemo, useState } from "react" import { useTranslation } from "react-i18next" import { actionLabel, resourceTypeLabel } from "./actionLabels" @@ -60,6 +66,8 @@ export function ActionLevelBoard({ const [activeStates, setActiveStates] = useState>({}) const [submitting, setSubmitting] = useState(false) const [draftFailed, setDraftFailed] = useState(false) + const [showChangeList, setShowChangeList] = useState(false) + const [draggingCode, setDraggingCode] = useState(null) const resetToRelease = () => { setLevels( @@ -73,10 +81,19 @@ export function ActionLevelBoard({ ), ) setDraftFailed(false) + setShowChangeList(false) } useEffect(resetToRelease, [normalizedActions]) + const levelName = useCallback( + (level: ActionLevelValue) => + level === null + ? t("actionLevel.unassigned") + : t("actionLevel.level", { level }), + [t], + ) + // Derived from the diff against the published release rather than accumulated // per edit, so moving a card back where it came from drops the change instead // of queueing a second one. @@ -103,6 +120,34 @@ export function ActionLevelBoard({ return changes }, [normalizedActions, levels, activeStates]) + // "3 changes" tells the author how much is pending but not what it is, and the + // impact dialog only counts affected resources. Spell each edit out before + // anyone commits to publishing it. + const changeSummaries = useMemo(() => { + const byCode = new Map( + normalizedActions.map((action) => [action.code, action]), + ) + return pendingChanges.map((change) => { + const action = byCode.get(change.action_code!) + const name = action + ? actionLabel(t, action.code, action.name) + : change.action_code! + if (change.type === "ASSIGN_ACTION_LEVEL") { + return t("actionLevel.changeSummary.level", { + name, + from: levelName(action?.level ?? null), + to: levelName(change.level ?? null), + }) + } + return t( + change.active + ? "actionLevel.changeSummary.enabled" + : "actionLevel.changeSummary.disabled", + { name }, + ) + }) + }, [levelName, normalizedActions, pendingChanges, t]) + const handleLevelChange = (actionCode: string, level: ActionLevelValue) => { if (disabled || submitting || levels[actionCode] === level) return setDraftFailed(false) @@ -146,7 +191,7 @@ export function ActionLevelBoard({ + + {showChangeList && ( +
    + {changeSummaries.map((summary) => ( +
  • + {summary} +
  • + ))} +
+ )} )} {draftFailed && (

{t("actionLevel.draftFailed")} @@ -187,158 +253,173 @@ export function ActionLevelBoard({ )} {/* The only scroll area on this tab: the level columns, header and banners stay put. */} -

- {LEVELS.map((level) => { - const key = levelKey(level) - const zoneActions = normalizedActions.filter( - (action) => levels[action.code] === level, - ) - return ( -
{ - event.preventDefault() - event.dataTransfer.dropEffect = "move" - }} - onDrop={(event) => { - event.preventDefault() - const actionCode = event.dataTransfer.getData("text/plain") - if (actionCode) handleLevelChange(actionCode, level) - }} - className={cn( - "min-h-56 rounded-xl border bg-muted/30 p-3 transition-colors", - level === null - ? "border-dashed border-amber-300 bg-amber-50/60" - : "border-border", - )} - role="region" - aria-label={ - level === null - ? t("actionLevel.unassigned") - : t("actionLevel.level", { level }) - } - > -
-

- {level === null - ? t("actionLevel.unassigned") - : t("actionLevel.level", { level })} -

- - {zoneActions.length} - -
+ +
+ {LEVELS.map((level) => { + const key = levelKey(level) + const zoneActions = normalizedActions.filter( + (action) => levels[action.code] === level, + ) + const isDropTarget = + draggingCode !== null && levels[draggingCode] !== level + return ( +
{ + event.preventDefault() + event.dataTransfer.dropEffect = "move" + }} + onDrop={(event) => { + event.preventDefault() + const actionCode = event.dataTransfer.getData("text/plain") + if (actionCode) handleLevelChange(actionCode, level) + setDraggingCode(null) + }} + className={cn( + "flex min-h-56 flex-col rounded-xl border bg-muted/30 p-3 transition-colors", + // Unassigned is a normal state, not a warning — it reads as one + // more column, distinguished by a dashed edge alone. + level === null ? "border-dashed" : "border-border", + isDropTarget && "border-primary bg-primary/5", + )} + role="region" + aria-label={levelName(level)} + > +
+

+ {levelName(level)} +

+ + {zoneActions.length} + +
-
- {zoneActions.map((action) => { - const active = activeStates[action.code] ?? action.active - return ( -
{ - event.dataTransfer.effectAllowed = "move" - event.dataTransfer.setData("text/plain", action.code) - }} - className={cn( - "rounded-lg border bg-background p-3 shadow-sm transition-opacity", - submitting && "opacity-60", - )} - > -
-
- ) - })} + + ) + })} - {zoneActions.length === 0 && ( -

- {t("actionLevel.empty")} -

- )} + {zoneActions.length === 0 && ( +

+ {t("actionLevel.empty")} +

+ )} +
-
- ) - })} -
+ ) + })} +
+ ) } diff --git a/src/frontend/platform/src/pages/SystemPage/components/permission/ImpactDialog.tsx b/src/frontend/platform/src/pages/SystemPage/components/permission/ImpactDialog.tsx index b753caf98b..c85a796905 100644 --- a/src/frontend/platform/src/pages/SystemPage/components/permission/ImpactDialog.tsx +++ b/src/frontend/platform/src/pages/SystemPage/components/permission/ImpactDialog.tsx @@ -172,7 +172,7 @@ export function ImpactDialog({ )} - - -
{!isStandard && !createMode && ( @@ -398,7 +491,7 @@ export function ModelEditor({ @@ -370,7 +399,7 @@ export default function CitationDocumentPreviewDrawer({ onClick={handleDownload} disabled={!downloadFileUrl} className="inline-flex size-8 shrink-0 items-center justify-center rounded-[6px] text-[#86909C] hover:bg-[#F2F3F5] hover:text-[#335CFF] disabled:cursor-not-allowed disabled:text-[#C9CDD4]" - aria-label="下载文档" + aria-label={t("citation.downloadDocument")} > @@ -382,7 +411,7 @@ export default function CitationDocumentPreviewDrawer({ "items-center justify-center text-[#A9AEB8] hover:bg-[#F2F3F5] hover:text-[#4E5969]", isFullBleedMobile ? "inline-flex size-8 rounded-md" : "inline-flex size-6 rounded-[6px]", )} - aria-label="关闭文档预览" + aria-label={t("citation.closeDocumentPreview")} > From b4ab8b96c54b96aeb5ff795bda814a49bae1da7a Mon Sep 17 00:00:00 2001 From: dolphin Date: Thu, 13 Aug 2026 12:38:15 +0800 Subject: [PATCH 16/19] fix(citation): play media in the source panel and download the original file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A knowledge file has two addresses: the original upload, and the renderable stand-in the backend derives from it (a clip's transcript, a pptx's converted PDF, a page's parsed markdown). Both apps used one URL for both jobs, from opposite ends: - client previewed and downloaded the stand-in, so a cited clip rendered as its transcript and downloaded as that markdown under an .mp4 name. docx/pptx had the same defect, downloading a PDF under the original extension. - platform previewed and downloaded the original, so a clip had no transcript beside it. Media citations now show what the knowledge space shows — the player plus the 识别文本 / 入库文本 pane — and downloads always hand back the original upload. Media is detected from the file name, since a clip's preview URL ends in .md. - client: `resolveCitationDocumentUrls` returns both addresses; the viewer picks per type and `FilePreview` gained `transcriptUrl` - platform: `getCitationDocumentPreviewUrl` finally returns previewUrl; the transcript pane is extracted from `RichPreviewFile` for reuse Also fixes the platform panel's dead download button (it read the unresolved citation, whose payload carries no file URL until resolved — the preview body resolved on its own, so only the button saw nothing), and both apps now report a missing URL instead of ignoring the click. Pays down the hardcoded Chinese in every file touched (~40 strings). --- src/frontend/client/eslint-suppressions.json | 3 - .../Content/CitationDocumentPreviewDrawer.tsx | 57 +++++++-------- .../Content/CitationReferencesDrawer.tsx | 57 +++++++++------ .../Chat/Messages/Content/citationUtils.ts | 50 +++++++++++-- .../client/src/locales/en/translation.json | 14 +++- .../client/src/locales/ja/translation.json | 14 +++- .../src/locales/zh-Hans/translation.json | 14 +++- .../FilePreview/RichKnowledgePreview.tsx | 4 +- .../src/pages/knowledge/FilePreview/index.tsx | 24 +++++- .../platform/eslint-suppressions.json | 5 +- .../platform/public/locales/en-US/bs.json | 19 ++++- .../platform/public/locales/ja/bs.json | 19 ++++- .../platform/public/locales/zh-Hans/bs.json | 19 ++++- .../CitationDocumentPreviewDrawer.tsx | 70 +++++++++++------- .../CitationReferencesDrawer.tsx | 51 ++++++++----- .../bs-comp/chatComponent/citationUtils.ts | 16 +++- .../components/RichPreviewFile.tsx | 73 ++++++++++++------- 17 files changed, 358 insertions(+), 151 deletions(-) diff --git a/src/frontend/client/eslint-suppressions.json b/src/frontend/client/eslint-suppressions.json index f04fc9d6c7..d5401cd71c 100644 --- a/src/frontend/client/eslint-suppressions.json +++ b/src/frontend/client/eslint-suppressions.json @@ -601,9 +601,6 @@ "no-restricted-imports": { "count": 1 }, - "no-restricted-syntax": { - "count": 17 - }, "react-hooks/exhaustive-deps": { "count": 1 } diff --git a/src/frontend/client/src/components/Chat/Messages/Content/CitationDocumentPreviewDrawer.tsx b/src/frontend/client/src/components/Chat/Messages/Content/CitationDocumentPreviewDrawer.tsx index 8110db8f0f..dd47fa4c10 100644 --- a/src/frontend/client/src/components/Chat/Messages/Content/CitationDocumentPreviewDrawer.tsx +++ b/src/frontend/client/src/components/Chat/Messages/Content/CitationDocumentPreviewDrawer.tsx @@ -10,10 +10,11 @@ import { cn } from '~/utils'; import { getCitationDocumentFileType, getCitationDocumentName, - getCitationDocumentUrl, getCitationItemBBoxes, + isMediaCitation, isRagCitation, - resolveCitationDocumentUrl, + resolveCitationDocumentUrls, + resolveCitationDownloadUrl, toAbsolutePreviewUrl, type CitationPdfBBox, } from './citationUtils'; @@ -66,13 +67,20 @@ export function CitationDocumentPreviewContent({ const itemId = preview?.itemId; const locateChunk = preview?.locateChunk; const fileName = detail ? getCitationDocumentName(detail) : ''; - const rawFileUrl = detail ? getCitationDocumentUrl(detail) : ''; - const [resolvedRawFileUrl, setResolvedRawFileUrl] = useState(rawFileUrl); + const isMedia = isMediaCitation(detail); + const [resolvedUrls, setResolvedUrls] = useState<{ originalUrl: string; previewUrl: string }>({ + originalUrl: '', + previewUrl: '', + }); const [isResolvingFileUrl, setIsResolvingFileUrl] = useState(false); - const fileType = canRenderPreview - ? resolveFileType(detail as ChatCitation, resolvedRawFileUrl || rawFileUrl) - : ''; - const fileUrl = toAbsolutePreviewUrl(resolvedRawFileUrl || rawFileUrl); + // A clip renders from the original file (the player) with its transcript + // alongside; everything else renders from the derived preview. + const rawViewerUrl = isMedia + ? resolvedUrls.originalUrl || resolvedUrls.previewUrl + : resolvedUrls.previewUrl || resolvedUrls.originalUrl; + const fileType = canRenderPreview ? resolveFileType(detail as ChatCitation, rawViewerUrl) : ''; + const fileUrl = toAbsolutePreviewUrl(rawViewerUrl); + const transcriptUrl = isMedia ? toAbsolutePreviewUrl(resolvedUrls.previewUrl) : ''; const shouldLocateChunk = !!locateChunk && fileType === 'pdf'; const bboxes: CitationPdfBBox[] = shouldLocateChunk ? getCitationItemBBoxes(detail as ChatCitation, itemId) @@ -81,7 +89,7 @@ export function CitationDocumentPreviewContent({ useEffect(() => { let active = true; - setResolvedRawFileUrl(rawFileUrl); + setResolvedUrls({ originalUrl: '', previewUrl: '' }); if (!canRenderPreview || !detail) { setIsResolvingFileUrl(false); @@ -90,24 +98,17 @@ export function CitationDocumentPreviewContent({ }; } - if (rawFileUrl) { - setIsResolvingFileUrl(false); - return () => { - active = false; - }; - } - setIsResolvingFileUrl(true); - void resolveCitationDocumentUrl(detail as ChatCitation).then((nextUrl) => { + void resolveCitationDocumentUrls(detail as ChatCitation).then((nextUrls) => { if (!active) return; - setResolvedRawFileUrl(nextUrl || ''); + setResolvedUrls(nextUrls); setIsResolvingFileUrl(false); }); return () => { active = false; }; - }, [canRenderPreview, detail, rawFileUrl]); + }, [canRenderPreview, detail]); if (!canRenderPreview) { return null; @@ -120,6 +121,7 @@ export function CitationDocumentPreviewContent({ fileName={fileName} fileType={fileType} fileUrl={fileUrl} + transcriptUrl={transcriptUrl} highlightBboxes={bboxes} targetBBox={targetBBox} compactMode={compactMode} @@ -149,7 +151,9 @@ export default function CitationDocumentPreviewDrawer({ const setChatMobileNavHidden = useSetRecoilState(store.chatMobileNavHiddenState); const detail = preview?.detail ?? null; const fileName = getCitationDocumentName(detail); - const [resolvedRawFileUrl, setResolvedRawFileUrl] = useState(() => getCitationDocumentUrl(detail)); + // Download always hands back the file the user uploaded, never the derived + // preview — otherwise a clip downloads as its transcript under an .mp4 name. + const [resolvedRawFileUrl, setResolvedRawFileUrl] = useState(''); const fileUrl = toAbsolutePreviewUrl(resolvedRawFileUrl); const canRenderPreview = !!preview && isRagCitation(preview.detail); @@ -197,14 +201,7 @@ export default function CitationDocumentPreviewDrawer({ useEffect(() => { let active = true; - const nextRawFileUrl = getCitationDocumentUrl(detail); - setResolvedRawFileUrl(nextRawFileUrl); - - if (nextRawFileUrl) { - return () => { - active = false; - }; - } + setResolvedRawFileUrl(''); if (!detail || !isRagCitation(detail)) { return () => { @@ -212,7 +209,7 @@ export default function CitationDocumentPreviewDrawer({ }; } - void resolveCitationDocumentUrl(detail).then((nextUrl) => { + void resolveCitationDownloadUrl(detail).then((nextUrl) => { if (!active) return; setResolvedRawFileUrl(nextUrl || ''); }); @@ -227,7 +224,7 @@ export default function CitationDocumentPreviewDrawer({ } const handleDownload = async () => { - const nextFileUrl = toAbsolutePreviewUrl(resolvedRawFileUrl || await resolveCitationDocumentUrl(detail)); + const nextFileUrl = toAbsolutePreviewUrl(resolvedRawFileUrl || await resolveCitationDownloadUrl(detail)); setResolvedRawFileUrl((current) => current || nextFileUrl); if (!nextFileUrl) return; const link = document.createElement('a'); diff --git a/src/frontend/client/src/components/Chat/Messages/Content/CitationReferencesDrawer.tsx b/src/frontend/client/src/components/Chat/Messages/Content/CitationReferencesDrawer.tsx index 99ac5f5dd9..c6c5c6fe78 100644 --- a/src/frontend/client/src/components/Chat/Messages/Content/CitationReferencesDrawer.tsx +++ b/src/frontend/client/src/components/Chat/Messages/Content/CitationReferencesDrawer.tsx @@ -4,6 +4,7 @@ import { Outlined } from 'bisheng-icons'; import { useSetRecoilState } from 'recoil'; import { getCitationDetail, resolveCitationDetails, type ChatCitation } from '~/api/chatApi'; import { useLocalize, useMediaQuery, usePrefersMobileLayout } from '~/hooks'; +import { useToastContext } from '~/Providers'; import store from '~/store'; import { cn } from '~/utils'; import { @@ -15,7 +16,7 @@ import { getCitationDocumentUrl, isRagCitation, normalizeCitationType, - resolveCitationDocumentUrl, + resolveCitationDownloadUrl, toAbsolutePreviewUrl, type CitationPreview, type CitationReferenceItem, @@ -63,6 +64,7 @@ type CitationDesktopView = 'list' | 'document-preview'; const CITATION_PANEL_EXPANDED_BREAKPOINT = 768; function SourceTypeBadge({ preview, type }: { preview: CitationPreview | null; type?: string }) { + const localize = useLocalize(); const isWeb = normalizeCitationType(preview?.type || type) === 'web'; return (
- {isWeb ? '网页' : '文档'} + {isWeb ? localize('com_citation.web') : localize('com_citation.document')}
); } @@ -109,10 +111,11 @@ function CitationReferenceCard({ hasError: boolean; onOpenDocumentPreview: (item: CitationReferenceItem, detail: ChatCitation) => void; }) { + const localize = useLocalize(); const preview = item.legacyPreview ?? buildCitationDocumentPreview(detail, item.data); const type = preview?.type || item.data.type; const isWeb = normalizeCitationType(type) === 'web'; - const title = preview?.title || '暂无标题'; + const title = preview?.title || localize('com_citation.untitled'); const canOpenDocument = !!detail && isRagCitation(detail, type); const { name: documentName, extension: documentExtension } = splitDocumentTitle(title, detail, preview); @@ -174,10 +177,10 @@ function CitationReferenceCard({ {isLoading ? ( - 加载溯源详情... + {localize('com_citation.loading_detail')} ) : hasError ? ( - 溯源详情加载失败 + {localize('com_citation.load_detail_failed')} ) : ( null )} @@ -189,13 +192,13 @@ function CitationReferenceCard({
- {preview?.sourceName || '网页'} + {preview?.sourceName || localize('com_citation.web')} {preview?.sourceMeta ? {preview.sourceMeta} : null} ) : ( <> - {preview?.sourceName || '政策文件'} + {preview?.sourceName || localize('com_citation.policy_document')} )}
@@ -222,7 +225,8 @@ export default function CitationReferencesDrawer({ onDesktopViewChange, }: CitationReferencesDrawerProps) { const localize = useLocalize(); - // <=768: 走抽屉(不内联分栏);<=576: 抽屉全屏覆盖 + const { showToast } = useToastContext(); + // <=768: use the drawer (no inline split); <=576: drawer covers full screen const isNarrowLayout = usePrefersMobileLayout(); const isPhoneViewport = useMediaQuery('(max-width: 576px)'); const isFullBleedMobile = isPhoneViewport; @@ -403,7 +407,8 @@ export default function CitationReferencesDrawer({ const isOpen = panelOnly ? true : isDesktopInlinePanel ? !!open : internalOpen; const isDesktopPreviewInline = isDesktopInlinePanel && desktopView === 'document-preview' && !!documentPreview; - // 仅全屏参考资料(≤576)隐藏 MobileNav;平板窄屏保留顶栏标题,抽屉 z-[120] 已高于 MobileNav z-[60] + // Only the full-screen references view (<=576) hides MobileNav; the tablet-width + // drawer keeps the top bar title, and its z-[120] already sits above MobileNav z-[60]. useEffect(() => { if (!isNarrowLayout || !isOpen || !isFullBleedMobile) { return; @@ -497,10 +502,13 @@ export default function CitationReferencesDrawer({ } const fileName = getCitationDocumentName(documentPreview.detail); - const fileUrl = toAbsolutePreviewUrl( - getCitationDocumentUrl(documentPreview.detail) || await resolveCitationDocumentUrl(documentPreview.detail), - ); + // The original upload, not the derived preview — a clip must download as the + // clip, not as the transcript that happens to render in the panel. + const fileUrl = toAbsolutePreviewUrl(await resolveCitationDownloadUrl(documentPreview.detail)); if (!fileUrl) { + // Nothing to download (the backend withholds file URLs from viewers who + // lack view_file) — say so rather than letting the click do nothing. + showToast({ message: localize('com_citation.no_download_url'), status: 'error' }); return; } @@ -518,7 +526,7 @@ export default function CitationReferencesDrawer({ documentPreview.detail, null, ) - : { name: '文档预览', extension: '' }; + : { name: localize('com_citation.document_preview'), extension: '' }; // The "centered reading card" layout (max-w-[464/480]) only makes sense for the // citation list. When previewing a document, both header and body fill the // panel so they line up flush — capping the header alone leaves the body @@ -542,7 +550,8 @@ export default function CitationReferencesDrawer({ ? cn( // Mobile keeps the divider; desktop drops it to match the workspace panel. 'border-b border-[#ECECEC] px-4', - // 竖直:侧栏/全屏均在顶栏内垂直居中;全屏保留安全区 + 顶 16px,并加底内边距平衡 + // Vertical: centered in the top bar for both side panel and full screen; + // full screen keeps the safe area + 16px top, balanced by bottom padding. isFullBleedMobile ? 'pb-3 pt-[calc(env(safe-area-inset-top,0px)+1rem)]' : 'py-3', @@ -567,7 +576,7 @@ export default function CitationReferencesDrawer({ ? 'size-8 rounded-md hover:bg-[#F2F3F5] hover:text-[#4E5969]' : 'h-7 w-7 rounded-lg text-[#8C8C8C] hover:bg-gray-100', )} - aria-label="关闭参考资料" + aria-label={localize('com_citation.close_references')} > @@ -576,7 +585,7 @@ export default function CitationReferencesDrawer({
- 暂无参考资料 + {localize('com_citation.no_references')}
)} @@ -617,7 +626,7 @@ export default function CitationReferencesDrawer({ setDocumentPreview(null); }} className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-[#8C8C8C] transition-colors hover:bg-gray-100" - aria-label="返回参考资料列表" + aria-label={localize('com_citation.back_to_references')} > @@ -642,7 +651,7 @@ export default function CitationReferencesDrawer({ desktopButtonSize, desktopDownloadButtonClass, )} - aria-label="下载文档" + aria-label={localize('com_citation.download_document')} > @@ -654,7 +663,7 @@ export default function CitationReferencesDrawer({ desktopButtonSize, desktopCloseButtonClass, )} - aria-label="关闭参考资料" + aria-label={localize('com_citation.close_references')} > @@ -673,7 +682,7 @@ export default function CitationReferencesDrawer({ <>
{panelContent}
@@ -717,7 +726,7 @@ export default function CitationReferencesDrawer({
- 参考资料 + {localize('com_msg_source_reference')}
@@ -730,7 +739,7 @@ export default function CitationReferencesDrawer({ isFullBleedMobile ? ( @@ -740,7 +749,7 @@ export default function CitationReferencesDrawer({ 'fixed inset-y-0 right-0 z-[130] flex min-h-0 w-[min(520px,calc(100vw-24px))] min-w-0 flex-col overflow-hidden bg-white shadow-[0_8px_24px_rgba(0,0,0,0.12)] animate-in slide-in-from-right duration-300', 'rounded-tl-lg', )} - aria-label="参考资料" + aria-label={localize('com_msg_source_reference')} onClick={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} > diff --git a/src/frontend/client/src/components/Chat/Messages/Content/citationUtils.ts b/src/frontend/client/src/components/Chat/Messages/Content/citationUtils.ts index 136bd80db2..1bdd5fc004 100644 --- a/src/frontend/client/src/components/Chat/Messages/Content/citationUtils.ts +++ b/src/frontend/client/src/components/Chat/Messages/Content/citationUtils.ts @@ -299,9 +299,19 @@ export function getCitationDocumentUrl(detail?: ChatCitation | null) { return getCitationDocumentPreviewUrl(detail); } -const inflightFileShareCache: Record> = {}; +/** The two addresses a knowledge file has. + * + * `originalUrl` is the file the user uploaded; `previewUrl` is the renderable + * stand-in the backend derived from it — the transcript of a clip, the PDF a + * pptx was converted to, the parsed markdown of a web page. Downloads must use + * the original (otherwise you hand someone a transcript named `.mp4`), while + * most viewers want the stand-in. + */ +export type CitationDocumentUrls = { originalUrl: string; previewUrl: string }; -export async function resolveCitationDocumentUrl(detail?: ChatCitation | null) { +const inflightFileShareCache: Record> = {}; + +export async function resolveCitationDocumentUrls(detail?: ChatCitation | null): Promise { const fileId = getCitationKnowledgeFileId(detail); if (fileId != null) { const cacheKey = String(fileId); @@ -310,9 +320,12 @@ export async function resolveCitationDocumentUrl(detail?: ChatCitation | null) { try { const res: any = await getFilePathApi(cacheKey); const data = res?.data ?? res; - return data?.preview_url || data?.original_url || ''; + return { + originalUrl: data?.original_url || '', + previewUrl: data?.preview_url || '', + }; } catch { - return ''; + return { originalUrl: '', previewUrl: '' }; } finally { // Drop after settle so a later open re-fetches a fresh signed URL // (signed URLs expire and we don't want to pin a dead one). @@ -320,11 +333,34 @@ export async function resolveCitationDocumentUrl(detail?: ChatCitation | null) { } })(); } - const url = await inflightFileShareCache[cacheKey]; - if (url) return url; + const urls = await inflightFileShareCache[cacheKey]; + if (urls.originalUrl || urls.previewUrl) return urls; } // Legacy fallback for non-knowledge or older payloads without documentId. - return getCitationDocumentPreviewUrl(detail); + const legacyUrl = getCitationDocumentPreviewUrl(detail); + return { originalUrl: legacyUrl, previewUrl: legacyUrl }; +} + +export async function resolveCitationDocumentUrl(detail?: ChatCitation | null) { + const { previewUrl, originalUrl } = await resolveCitationDocumentUrls(detail); + return previewUrl || originalUrl; +} + +/** Original file, for downloads — never the derived preview. */ +export async function resolveCitationDownloadUrl(detail?: ChatCitation | null) { + const { originalUrl, previewUrl } = await resolveCitationDocumentUrls(detail); + return originalUrl || previewUrl; +} + +const MEDIA_CITATION_EXTENSIONS = new Set([ + 'mp3', 'wav', 'm4a', 'aac', 'flac', 'ogg', + 'mp4', 'mov', 'avi', 'mkv', 'webm', +]); + +/** Whether the cited file is a clip. Decided from the file name, not the URL: + * a media file's preview URL points at its transcript (`.md`). */ +export function isMediaCitation(detail?: ChatCitation | null) { + return MEDIA_CITATION_EXTENSIONS.has(getCitationDocumentFileType(detail)); } export function toAbsolutePreviewUrl(url?: string | null) { diff --git a/src/frontend/client/src/locales/en/translation.json b/src/frontend/client/src/locales/en/translation.json index 92a6ca056b..1de52a6632 100644 --- a/src/frontend/client/src/locales/en/translation.json +++ b/src/frontend/client/src/locales/en/translation.json @@ -2162,5 +2162,17 @@ "version": "Version", "route": "Page", "user": "User" - } + }, + "com_citation.no_download_url": "No downloadable file address", + "com_citation.web": "Web", + "com_citation.document": "Document", + "com_citation.untitled": "Untitled", + "com_citation.loading_detail": "Loading source details...", + "com_citation.load_detail_failed": "Failed to load source details", + "com_citation.policy_document": "Policy document", + "com_citation.document_preview": "Document preview", + "com_citation.no_references": "No references", + "com_citation.close_references": "Close references", + "com_citation.back_to_references": "Back to reference list", + "com_citation.download_document": "Download document" } diff --git a/src/frontend/client/src/locales/ja/translation.json b/src/frontend/client/src/locales/ja/translation.json index 35666245d0..5a6fd122a4 100644 --- a/src/frontend/client/src/locales/ja/translation.json +++ b/src/frontend/client/src/locales/ja/translation.json @@ -2085,5 +2085,17 @@ "version": "バージョン", "route": "ページ", "user": "ユーザー" - } + }, + "com_citation.no_download_url": "ダウンロード可能なファイルアドレスがありません", + "com_citation.web": "ウェブ", + "com_citation.document": "ドキュメント", + "com_citation.untitled": "タイトルなし", + "com_citation.loading_detail": "出典の詳細を読み込み中...", + "com_citation.load_detail_failed": "出典の詳細の読み込みに失敗しました", + "com_citation.policy_document": "政策文書", + "com_citation.document_preview": "ドキュメントプレビュー", + "com_citation.no_references": "参考資料はありません", + "com_citation.close_references": "参考資料を閉じる", + "com_citation.back_to_references": "参考資料一覧に戻る", + "com_citation.download_document": "ドキュメントをダウンロード" } diff --git a/src/frontend/client/src/locales/zh-Hans/translation.json b/src/frontend/client/src/locales/zh-Hans/translation.json index eae6f1a0f4..c0fc2e55e7 100644 --- a/src/frontend/client/src/locales/zh-Hans/translation.json +++ b/src/frontend/client/src/locales/zh-Hans/translation.json @@ -2091,5 +2091,17 @@ "version": "版本", "route": "页面", "user": "用户" - } + }, + "com_citation.no_download_url": "暂无可下载文件地址", + "com_citation.web": "网页", + "com_citation.document": "文档", + "com_citation.untitled": "暂无标题", + "com_citation.loading_detail": "加载溯源详情...", + "com_citation.load_detail_failed": "溯源详情加载失败", + "com_citation.policy_document": "政策文件", + "com_citation.document_preview": "文档预览", + "com_citation.no_references": "暂无参考资料", + "com_citation.close_references": "关闭参考资料", + "com_citation.back_to_references": "返回参考资料列表", + "com_citation.download_document": "下载文档" } diff --git a/src/frontend/client/src/pages/knowledge/FilePreview/RichKnowledgePreview.tsx b/src/frontend/client/src/pages/knowledge/FilePreview/RichKnowledgePreview.tsx index e307936c8d..06d0214471 100644 --- a/src/frontend/client/src/pages/knowledge/FilePreview/RichKnowledgePreview.tsx +++ b/src/frontend/client/src/pages/knowledge/FilePreview/RichKnowledgePreview.tsx @@ -205,7 +205,9 @@ function MarkdownFromUrl({ fileUrl }: { fileUrl: string }) { return ; } -function MediaTranscriptTabs({ fileUrl }: { fileUrl: string }) { +/** Transcript pane of a media preview: 识别文本 / 入库文本 tabs over the parsed + * markdown. Exported so citation previews render the same pane. */ +export function MediaTranscriptTabs({ fileUrl }: { fileUrl: string }) { const localize = useLocalize(); const [activeTab, setActiveTab] = useState("recognized"); const [content, setContent] = useState(""); diff --git a/src/frontend/client/src/pages/knowledge/FilePreview/index.tsx b/src/frontend/client/src/pages/knowledge/FilePreview/index.tsx index b29d88ad83..4ff4c850ad 100644 --- a/src/frontend/client/src/pages/knowledge/FilePreview/index.tsx +++ b/src/frontend/client/src/pages/knowledge/FilePreview/index.tsx @@ -10,6 +10,8 @@ import { Sidebar } from "./Sidebar"; import { TopBar } from "./TopBar"; import { getViewerType, supportsPagination, supportsSidebar, supportsZoom } from "./viewers"; import { MediaPlayer } from "./MediaPlayer"; +import { MediaTranscriptTabs } from "./RichKnowledgePreview"; +import { cn } from "~/utils"; import { DocxViewer } from "./viewers/DocxViewer"; import { HtmlViewer } from "./viewers/HtmlViewer"; import { ImageViewer } from "./viewers/ImageViewer"; @@ -52,6 +54,10 @@ export interface FilePreviewProps { hideHeaderDownload?: boolean; /** Optional business-level download handler. Defaults to downloading fileUrl. */ onDownloadFile?: () => void; + /** Parsed-transcript URL for an audio/video file. When set, the media viewer + * shows the 识别文本 / 入库文本 pane next to the player, like the knowledge + * space does — the cited text lives in the transcript, not in the clip. */ + transcriptUrl?: string; } export default function FilePreview({ @@ -68,6 +74,7 @@ export default function FilePreview({ allowDownload = true, hideHeaderDownload = false, onDownloadFile, + transcriptUrl = "", }: FilePreviewProps) { const localize = useLocalize(); const viewerType = getViewerType(fileType); @@ -250,11 +257,12 @@ export default function FilePreview({ return ; case "audio": case "video": - // Same player the knowledge space uses, so a clip opened from a - // citation looks like the one opened from the file list. + // Same split the knowledge space uses: player on the left, the + // transcript the answer actually quoted on the right. Stacked on + // narrow screens; player-only when there is no transcript. return ( -
-
+
+
+ {transcriptUrl ? ( + <> +
+
+ +
+ + ) : null}
); default: diff --git a/src/frontend/platform/eslint-suppressions.json b/src/frontend/platform/eslint-suppressions.json index 483827b27d..30d04094a6 100644 --- a/src/frontend/platform/eslint-suppressions.json +++ b/src/frontend/platform/eslint-suppressions.json @@ -202,9 +202,6 @@ "src/components/bs-comp/chatComponent/CitationReferencesDrawer.tsx": { "@typescript-eslint/no-explicit-any": { "count": 1 - }, - "no-restricted-syntax": { - "count": 16 } }, "src/components/bs-comp/chatComponent/CitationSourceIcon.tsx": { @@ -2966,7 +2963,7 @@ "count": 2 }, "no-restricted-syntax": { - "count": 8 + "count": 2 } }, "src/pages/KnowledgePage/components/RuleFile.tsx": { diff --git a/src/frontend/platform/public/locales/en-US/bs.json b/src/frontend/platform/public/locales/en-US/bs.json index cf3e362fc0..01b53b4ccc 100644 --- a/src/frontend/platform/public/locales/en-US/bs.json +++ b/src/frontend/platform/public/locales/en-US/bs.json @@ -2090,6 +2090,23 @@ "noPreviewUrl": "No previewable file address", "documentPreview": "Document preview", "downloadDocument": "Download document", - "closeDocumentPreview": "Close document preview" + "closeDocumentPreview": "Close document preview", + "web": "Web", + "document": "Document", + "untitled": "Untitled", + "loadingDetail": "Loading source details...", + "loadDetailFailed": "Failed to load source details", + "policyDocument": "Policy document", + "references": "References", + "closeReferences": "Close references", + "backToReferences": "Back to reference list", + "noDownloadUrl": "No downloadable file address" + }, + "mediaPreview": { + "recognizedText": "Recognized text", + "entryText": "Stored text", + "webPreview": "Web preview", + "openSourcePage": "Open source page", + "noWebSnapshot": "No web snapshot yet — check the stored text or open the source page." } } diff --git a/src/frontend/platform/public/locales/ja/bs.json b/src/frontend/platform/public/locales/ja/bs.json index 57e16bf7f9..23fcb50e37 100644 --- a/src/frontend/platform/public/locales/ja/bs.json +++ b/src/frontend/platform/public/locales/ja/bs.json @@ -2035,6 +2035,23 @@ "noPreviewUrl": "プレビュー可能なファイルアドレスがありません", "documentPreview": "ドキュメントプレビュー", "downloadDocument": "ドキュメントをダウンロード", - "closeDocumentPreview": "ドキュメントプレビューを閉じる" + "closeDocumentPreview": "ドキュメントプレビューを閉じる", + "web": "ウェブ", + "document": "ドキュメント", + "untitled": "タイトルなし", + "loadingDetail": "出典の詳細を読み込み中...", + "loadDetailFailed": "出典の詳細の読み込みに失敗しました", + "policyDocument": "政策文書", + "references": "参考資料", + "closeReferences": "参考資料を閉じる", + "backToReferences": "参考資料一覧に戻る", + "noDownloadUrl": "ダウンロード可能なファイルアドレスがありません" + }, + "mediaPreview": { + "recognizedText": "認識テキスト", + "entryText": "登録テキスト", + "webPreview": "ウェブプレビュー", + "openSourcePage": "元のページを開く", + "noWebSnapshot": "ウェブスナップショットがありません。登録テキストを確認するか、元のページを開いてください。" } } diff --git a/src/frontend/platform/public/locales/zh-Hans/bs.json b/src/frontend/platform/public/locales/zh-Hans/bs.json index c647a92530..422c46809a 100644 --- a/src/frontend/platform/public/locales/zh-Hans/bs.json +++ b/src/frontend/platform/public/locales/zh-Hans/bs.json @@ -2035,6 +2035,23 @@ "noPreviewUrl": "暂无可预览文件地址", "documentPreview": "文档预览", "downloadDocument": "下载文档", - "closeDocumentPreview": "关闭文档预览" + "closeDocumentPreview": "关闭文档预览", + "web": "网页", + "document": "文档", + "untitled": "暂无标题", + "loadingDetail": "加载溯源详情...", + "loadDetailFailed": "溯源详情加载失败", + "policyDocument": "政策文件", + "references": "参考资料", + "closeReferences": "关闭参考资料", + "backToReferences": "返回参考资料列表", + "noDownloadUrl": "暂无可下载文件地址" + }, + "mediaPreview": { + "recognizedText": "识别文本", + "entryText": "入库文本", + "webPreview": "网页预览", + "openSourcePage": "打开原网页", + "noWebSnapshot": "暂无网页快照,请查看入库文本或打开原网页。" } } diff --git a/src/frontend/platform/src/components/bs-comp/chatComponent/CitationDocumentPreviewDrawer.tsx b/src/frontend/platform/src/components/bs-comp/chatComponent/CitationDocumentPreviewDrawer.tsx index 87e2f82429..ca979fba2e 100644 --- a/src/frontend/platform/src/components/bs-comp/chatComponent/CitationDocumentPreviewDrawer.tsx +++ b/src/frontend/platform/src/components/bs-comp/chatComponent/CitationDocumentPreviewDrawer.tsx @@ -8,6 +8,7 @@ import { FileIcon } from "@/components/bs-icons/file"; import { ExcelPreview } from "@bisheng/file-viewers"; import DocxPreview from "@/pages/KnowledgePage/components/DocxFileViewer"; import TxtFileViewer from "@/pages/KnowledgePage/components/TxtFileViewer"; +import { MediaTranscriptTabs } from "@/pages/KnowledgePage/components/RichPreviewFile"; import { getCitationDetail, type ChatCitation } from "@/controllers/API"; import { getCitationDocumentDownloadUrl, @@ -15,6 +16,7 @@ import { getCitationDocumentName, getCitationDocumentPreviewUrl, getCitationItemBBoxes, + isMediaCitation, isRagCitation, isRagCitationMissingPreviewUrl, toAbsolutePreviewUrl, @@ -85,6 +87,33 @@ function resolveFileType(detail: ChatCitation, rawUrl: string) { return name.split(".").pop()?.toLowerCase() || ""; } +/** Audio/video citation: the clip itself plus the transcript the answer quoted, + * the same pairing the knowledge space shows. */ +const VIDEO_CITATION_EXTENSIONS = new Set(["mp4", "mov", "avi", "mkv", "webm"]); + +function MediaPreview({ fileUrl, transcriptUrl, isVideo }: { fileUrl: string; transcriptUrl: string; isVideo: boolean }) { + return ( +
+
+
+ {isVideo ? ( +
+
+ {transcriptUrl ? ( +
+ +
+ ) : null} +
+ ); +} + function buildPdfLabels(bboxes: CitationPdfBBox[]) { const labels: Record = {}; bboxes.forEach((item, index) => { @@ -142,30 +171,6 @@ function renderPreviewContent({ case "doc": case "docx": return ; - // Audio/video citations play in place, the same way the knowledge space - // previews them — the cited text came from the transcript, but the file the - // user clicked is the clip. - case "mp3": - case "wav": - case "m4a": - case "aac": - case "flac": - case "ogg": - return ( -
-
- ); - case "mp4": - case "mov": - case "avi": - case "mkv": - case "webm": - return ( -
-
- ); case "png": case "jpg": case "jpeg": @@ -259,9 +264,16 @@ export function CitationDocumentPreviewContent({ const { itemId, locateChunk } = preview; const fileName = getCitationDocumentName(effectiveDetail); - const rawFileUrl = getCitationDocumentPreviewUrl(effectiveDetail); + const isMedia = isMediaCitation(effectiveDetail); + // A clip renders from the original file (the player), with its transcript — + // which is what the preview URL points at — beside it. Everything else + // renders from the preview stand-in. + const rawFileUrl = isMedia + ? getCitationDocumentDownloadUrl(effectiveDetail) + : getCitationDocumentPreviewUrl(effectiveDetail); const fileType = resolveFileType(effectiveDetail, rawFileUrl); const fileUrl = toAbsolutePreviewUrl(rawFileUrl); + const transcriptUrl = isMedia ? toAbsolutePreviewUrl(getCitationDocumentPreviewUrl(effectiveDetail)) : ""; const shouldLocateChunk = locateChunk && fileType === "pdf"; const bboxes: CitationPdfBBox[] = shouldLocateChunk ? getCitationItemBBoxes(effectiveDetail, itemId) : []; const targetBBox = bboxes[0] ?? null; @@ -273,6 +285,14 @@ export function CitationDocumentPreviewContent({ {t("citation.loadingPreview")}
+ ) : isMedia && fileUrl ? ( +
+ +
) : fileUrl ? (
{renderPreviewContent({ fileType, fileUrl, fileName, bboxes, targetBBox, t })} diff --git a/src/frontend/platform/src/components/bs-comp/chatComponent/CitationReferencesDrawer.tsx b/src/frontend/platform/src/components/bs-comp/chatComponent/CitationReferencesDrawer.tsx index 4bac4cc1a6..5019a91606 100644 --- a/src/frontend/platform/src/components/bs-comp/chatComponent/CitationReferencesDrawer.tsx +++ b/src/frontend/platform/src/components/bs-comp/chatComponent/CitationReferencesDrawer.tsx @@ -1,7 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { ChevronLeft, ChevronRight, Download, Loader2, X } from "lucide-react"; import { getCitationDetail, resolveCitationDetails, type ChatCitation } from "@/controllers/API"; import { cname } from "@/components/bs-ui/utils"; +import { toast } from "@/components/bs-ui/toast/use-toast"; import { buildCitationDocumentPreview, buildCitationReferenceItems, @@ -62,6 +64,7 @@ function useMediaQuery(query: string) { } function SourceTypeBadge({ preview, type }: { preview: CitationPreview | null; type?: string }) { + const { t } = useTranslation(); const isWeb = normalizeCitationType(preview?.type || type) === "web"; return (
- {isWeb ? "网页" : "文档"} + {isWeb ? t("citation.web") : t("citation.document")}
); } @@ -108,10 +111,11 @@ function CitationReferenceCard({ hasError: boolean; onOpenDocumentPreview: (item: CitationReferenceItem, detail: ChatCitation) => void; }) { + const { t } = useTranslation(); const preview = item.legacyPreview ?? buildCitationDocumentPreview(detail, item.data); const type = preview?.type || item.data.type; const isWeb = normalizeCitationType(type) === "web"; - const title = preview?.title || "暂无标题"; + const title = preview?.title || t("citation.untitled"); const canOpenDocument = !!detail && isRagCitation(detail, type); const { name: documentName, extension: documentExtension } = splitDocumentTitle(title, detail, preview); @@ -169,10 +173,10 @@ function CitationReferenceCard({ {isLoading ? ( - 加载溯源详情... + {t("citation.loadingDetail")} ) : hasError ? ( - 溯源详情加载失败 + {t("citation.loadDetailFailed")} ) : ( null )} @@ -184,13 +188,13 @@ function CitationReferenceCard({
- {preview?.sourceName || "网页"} + {preview?.sourceName || t("citation.web")} {preview?.sourceMeta ? {preview.sourceMeta} : null} ) : ( <> - {preview?.sourceName || "政策文件"} + {preview?.sourceName || t("citation.policyDocument")} )}
@@ -205,6 +209,7 @@ export default function CitationReferencesDrawer({ buttonClassName, allowRemoteCitationResolve = true, }: CitationReferencesDrawerProps) { + const { t } = useTranslation(); const [open, setOpen] = useState(false); const [detailMap, setDetailMap] = useState>(() => createCitationDetailMap(citations)); const [loadingMap, setLoadingMap] = useState>({}); @@ -428,14 +433,24 @@ export default function CitationReferencesDrawer({ setPanelView("document-preview"); }; - const handleDownloadDocument = () => { + const handleDownloadDocument = async () => { if (!documentPreview) { return; } - const fileName = getCitationDocumentName(documentPreview.detail); - const fileUrl = toAbsolutePreviewUrl(getCitationDocumentDownloadUrl(documentPreview.detail)); + const detail = documentPreview.detail; + const fileName = getCitationDocumentName(detail); + // The panel holds the citation as it arrived with the message, and that + // payload carries no file URL until it is resolved. The preview body + // resolves on its own, so the file showed up while this button silently + // did nothing — resolve here too before giving up. + let rawFileUrl = getCitationDocumentDownloadUrl(detail); + if (!rawFileUrl && detail?.citationId) { + rawFileUrl = getCitationDocumentDownloadUrl(await loadCitationDetail(detail.citationId)); + } + const fileUrl = toAbsolutePreviewUrl(rawFileUrl); if (!fileUrl) { + toast({ variant: "error", description: t("citation.noDownloadUrl") }); return; } @@ -450,7 +465,7 @@ export default function CitationReferencesDrawer({ const documentHeaderTitle = documentPreview ? splitDocumentTitle(getCitationDocumentName(documentPreview.detail), documentPreview.detail, null) - : { name: "文档预览", extension: "" }; + : { name: t("citation.documentPreview"), extension: "" }; const referenceListContent = ( <> @@ -459,7 +474,7 @@ export default function CitationReferencesDrawer({ isNarrowLayout ? "h-11 px-2" : "h-14 px-3", )}>
-

参考资料

+

{t("citation.references")}

{references.length} @@ -471,7 +486,7 @@ export default function CitationReferencesDrawer({ "inline-flex items-center justify-center text-[#A9AEB8] hover:bg-[#F2F3F5] hover:text-[#4E5969]", isNarrowLayout ? "size-8 rounded-md" : "size-6 rounded-[6px]", )} - aria-label="关闭参考资料" + aria-label={t("citation.closeReferences")} > @@ -506,7 +521,7 @@ export default function CitationReferencesDrawer({ setDocumentPreview(null); }} className="inline-flex size-6 shrink-0 items-center justify-center rounded-[6px] text-[#4E5969] hover:bg-[#F2F3F5]" - aria-label="返回参考资料列表" + aria-label={t("citation.backToReferences")} > @@ -528,7 +543,7 @@ export default function CitationReferencesDrawer({ type="button" onClick={handleDownloadDocument} className="inline-flex size-6 shrink-0 items-center justify-center rounded-[6px] text-[#024DE3] transition-colors hover:bg-[#F2F7FF]" - aria-label="下载文档" + aria-label={t("citation.downloadDocument")} > @@ -536,7 +551,7 @@ export default function CitationReferencesDrawer({ type="button" onClick={handleClosePanel} className="inline-flex size-6 shrink-0 items-center justify-center rounded-[6px] text-[#A9AEB8] transition-colors hover:bg-[#F7F8FA]" - aria-label="关闭参考资料" + aria-label={t("citation.closeReferences")} > @@ -569,7 +584,7 @@ export default function CitationReferencesDrawer({
- 参考资料 + {t("citation.references")}
@@ -578,7 +593,7 @@ export default function CitationReferencesDrawer({ isFullBleedMobile ? ( @@ -586,7 +601,7 @@ export default function CitationReferencesDrawer({
-
-
- - -
- {mediaTextUrl ? ( -
- -
- ) : null} -
+
); @@ -217,20 +234,20 @@ export default function RichPreviewFile({ file, previewData }: { file: any; prev onClick={() => setWebTab("html")} className={`h-8 rounded-md px-3 text-sm ${webTab === "html" ? "bg-primary text-white" : "bg-gray-100 text-gray-600"}`} > - 网页预览 + {t("mediaPreview.webPreview")} {sourceUrl ? ( - 打开原网页 + {t("mediaPreview.openSourcePage")} ) : null} @@ -238,7 +255,7 @@ export default function RichPreviewFile({ file, previewData }: { file: any; prev {webTab === "html" ? ( htmlUrl ? : (
- 暂无网页快照,请查看入库文本或打开原网页。 + {t("mediaPreview.noWebSnapshot")}
) ) : textUrl ? ( From 6127103f11147b56fd85e08f074a4337f74b5360 Mon Sep 17 00:00:00 2001 From: dolphin Date: Thu, 13 Aug 2026 12:57:11 +0800 Subject: [PATCH 17/19] style(citation): stack the media preview, player over transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The citation panel is ~520px wide, so splitting it into player and transcript columns left both halves cramped. Media now stacks: player on top, 识别文本 / 入库文本 below — matching what the platform panel already does. Only audio/video takes this layout; other formats are unchanged. --- .../client/src/pages/knowledge/FilePreview/index.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/frontend/client/src/pages/knowledge/FilePreview/index.tsx b/src/frontend/client/src/pages/knowledge/FilePreview/index.tsx index 4ff4c850ad..e70dcf9442 100644 --- a/src/frontend/client/src/pages/knowledge/FilePreview/index.tsx +++ b/src/frontend/client/src/pages/knowledge/FilePreview/index.tsx @@ -257,12 +257,12 @@ export default function FilePreview({ return ; case "audio": case "video": - // Same split the knowledge space uses: player on the left, the - // transcript the answer actually quoted on the right. Stacked on - // narrow screens; player-only when there is no transcript. + // Player on top, the transcript the answer actually quoted below + // it. Stacked rather than split: this preview lives in a narrow + // citation panel, where two columns leave both halves cramped. return ( -
-
+
+
{transcriptUrl ? ( <> -
+
From fab29b1502ae1bd8f3c1871a93103184471ac987 Mon Sep 17 00:00:00 2001 From: LineWalker Date: Thu, 13 Aug 2026 11:39:30 +0800 Subject: [PATCH 18/19] =?UTF-8?q?fix(linsight):=20glob=20=E8=AE=A4?= =?UTF-8?q?=E4=B8=8D=E5=87=BA=E6=8F=90=E7=A4=BA=E8=AF=8D=E8=87=AA=E5=B7=B1?= =?UTF-8?q?=E6=95=99=E7=9A=84=20/uploads/**/*.xlsx=20=E5=86=99=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 文件夹上传的指针块与 >40 文件的目录概览,都逐字告诉模型用 glob(如 "/uploads/**/*.xlsx")定位文件。这个写法一直返回 0 匹配: - ls 报的是带前导斜杠的工作区路径(/uploads/a/b.csv),但匹配是拿 去掉斜杠的对象键(uploads/a/b.csv)做的,fnmatch 对首字符不含糊, 所以提示词唯一教过的绝对写法从来没匹配上任何东西。 - fnmatch 没有 **,它只是一个能跨 / 的 *,于是 uploads/**/*.csv 要求 至少一层中间目录,直接漏掉直接躺在 uploads/ 下的文件。 失败形态是最糟的那种:大文件夹下概览块是模型拿到具体文件名的唯一 入口,glob 静默返回空,而同一段提示词下一句正好写着「不要假设文件 不存在」。grep(glob=...) 共用这段比较,一起修。 114 实测:修前 glob('/uploads/**/*.csv') 0 匹配、修后 3 匹配,与 glob('uploads/**/*.csv') 一致。 --- .../domain/services/workspace_backend.py | 41 +++++- .../linsight/test_workspace_glob_patterns.py | 127 ++++++++++++++++++ 2 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 src/backend/test/linsight/test_workspace_glob_patterns.py diff --git a/src/backend/bisheng/linsight/domain/services/workspace_backend.py b/src/backend/bisheng/linsight/domain/services/workspace_backend.py index 1f6601f691..806b863d22 100644 --- a/src/backend/bisheng/linsight/domain/services/workspace_backend.py +++ b/src/backend/bisheng/linsight/domain/services/workspace_backend.py @@ -604,9 +604,41 @@ def edit( return EditResult(path="/" + rel, occurrences=occurrences) # -- glob --------------------------------------------------------------- - def glob(self, pattern: str, path: str | None = None) -> GlobResult: + @staticmethod + def _glob_patterns(pattern: str) -> tuple[str, ...]: + """The spellings of ``pattern`` that should all mean the same thing. + + Two mismatches to absorb, both of which used to silently return zero + matches for patterns we ourselves tell the model to write: + + - **Leading slash.** ``ls`` reports ``/uploads/a/b.csv`` and every tool + argument in the prompt is written absolute, but matching happens against + the workspace-RELATIVE key (``uploads/a/b.csv``). ``fnmatch`` is literal + about that first character, so ``/uploads/**/*.xlsx`` — the exact + spelling in the folder-upload guidance — matched nothing. + - **``**`` spanning zero directories.** ``fnmatch`` has no ``**``; it + treats it as a plain ``*`` that happens to cross ``/``. So + ``uploads/**/*.csv`` demands at least one intermediate directory and + skips ``uploads/top.csv`` — surprising for a pattern whose whole point + is "anywhere under uploads". Collapsing ``**/`` gives that case its + own candidate. + """ + pat = (pattern or "").strip().lstrip("/") + candidates = [pat] + if "**/" in pat: + candidates.append(pat.replace("**/", "")) + return tuple(dict.fromkeys(c for c in candidates if c)) + + @classmethod + def _glob_matches(cls, rel_in_ws: str, pattern: str) -> bool: import fnmatch + return any( + fnmatch.fnmatch(rel_in_ws, pat) or fnmatch.fnmatch(os.path.basename(rel_in_ws), pat) + for pat in cls._glob_patterns(pattern) + ) + + def glob(self, pattern: str, path: str | None = None) -> GlobResult: base = self._ws_rel(path) if path else "" ls_res = self.ls(base) if ls_res.error is not None: @@ -616,7 +648,7 @@ def glob(self, pattern: str, path: str | None = None) -> GlobResult: for entry in ls_res.entries or []: rel = entry["path"] rel_in_ws = rel[len(prefix) :] if rel.startswith(prefix) else rel.lstrip("/") - if fnmatch.fnmatch(rel_in_ws, pattern) or fnmatch.fnmatch(os.path.basename(rel_in_ws), pattern): + if self._glob_matches(rel_in_ws, pattern): matches.append(entry) return GlobResult(matches=matches) @@ -630,14 +662,15 @@ def grep(self, pattern: str, path: str | None = None, glob: str | None = None) - return GrepResult(error=ls_res.error) prefix = f"/{WORKSPACE_PREFIX}/{self.svid}/" matches: list = [] - import fnmatch skipped_large = 0 skipped_binary = 0 for entry in ls_res.entries or []: full = entry["path"] rel_in_ws = full[len(prefix) :] if full.startswith(prefix) else full.lstrip("/") - if glob and not (fnmatch.fnmatch(rel_in_ws, glob) or fnmatch.fnmatch(os.path.basename(rel_in_ws), glob)): + # Same spelling tolerance as ``glob`` — an absolute filter must not + # silently narrow the scan to nothing. + if glob and not self._glob_matches(rel_in_ws, glob): continue # ``ls`` already reports the object size; use it to avoid downloading a # multi-MB original (uploads/ carries them since the dual-track write) diff --git a/src/backend/test/linsight/test_workspace_glob_patterns.py b/src/backend/test/linsight/test_workspace_glob_patterns.py new file mode 100644 index 0000000000..022e502a87 --- /dev/null +++ b/src/backend/test/linsight/test_workspace_glob_patterns.py @@ -0,0 +1,127 @@ +"""``glob`` must match the patterns we ourselves put in the prompt. + +The folder-upload guidance (``workbench_impl.prepare_file_list``) tells the model, +verbatim and twice — once in the pointer header, once as the closing line of the +>40-file directory overview — to locate files with:: + + glob(如 "/uploads/**/*.xlsx") + +That exact spelling used to return zero matches, for two independent reasons: + +1. ``ls`` reports workspace paths with a leading slash (``/uploads/a/b.csv``) but + matching runs against the workspace-relative object key (``uploads/a/b.csv``), + and ``fnmatch`` is literal about that first character. So every absolute + pattern — the only kind the prompt teaches — missed everything. +2. ``fnmatch`` has no ``**``: it is just a ``*`` that crosses ``/``, so + ``uploads/**/*.csv`` requires at least one intermediate directory and skips a + file sitting directly in ``uploads/``. + +The failure mode is the worst kind: a silent empty result on a large folder, +where the overview block is the model's ONLY route to individual file names. The +same prompt then says 不要假设文件不存在 — which is precisely what an empty glob +invites. ``grep(glob=...)`` shares the comparison and so shared the bug. +""" + +from __future__ import annotations + +import tempfile + +import pytest + +from bisheng.linsight.domain.services.workspace_backend import WORKSPACE_PREFIX, WorkspaceBackend +from test.linsight.test_workspace_backend import FakeMinioStorage + +TREE = [ + "uploads/年报/2024/Q1.csv", + "uploads/年报/2024/Q2.csv", + "uploads/年报/notes.txt", + "uploads/附件/Q1.csv", + "uploads/top.csv", # directly under uploads/, no intermediate directory + "output/report.md", +] + + +@pytest.fixture() +def backend(): + minio = FakeMinioStorage() + for rel in TREE: + minio.store[(minio.bucket, f"{WORKSPACE_PREFIX}/sv1/{rel}")] = b"name,amount\nalpha,1\n" + with tempfile.TemporaryDirectory() as d: + yield WorkspaceBackend(svid="sv1", minio=minio, file_dir=d) + + +def _paths(result) -> set[str]: + return {m["path"] for m in (result.matches or [])} + + +# --------------------------------------------------------------------------- +# The pattern the prompt actually teaches +# --------------------------------------------------------------------------- +def test_absolute_pattern_from_the_prompt_matches(backend): + """REGRESSION: `/uploads/**/*.csv` returned 0 matches, so a model that + followed the folder-upload guidance concluded the files were not there.""" + got = _paths(backend.glob("/uploads/**/*.csv")) + + assert "/uploads/年报/2024/Q1.csv" in got + assert "/uploads/附件/Q1.csv" in got + # ** must span zero directories too, or a file sitting at the folder root is + # invisible to the one pattern the user was told finds everything. + assert "/uploads/top.csv" in got + assert "/output/report.md" not in got + + +def test_absolute_and_relative_spellings_agree(backend): + assert _paths(backend.glob("/uploads/**/*.csv")) == _paths(backend.glob("uploads/**/*.csv")) + + +def test_absolute_pattern_without_a_wildcard_directory(backend): + """`/output/*.md` is the shape the code-interpreter guidance produces.""" + assert _paths(backend.glob("/output/*.md")) == {"/output/report.md"} + + +def test_bare_extension_pattern_still_matches_by_basename(backend): + got = _paths(backend.glob("*.csv")) + assert "/uploads/年报/2024/Q1.csv" in got + assert "/uploads/top.csv" in got + + +def test_a_pattern_that_matches_nothing_still_matches_nothing(backend): + assert _paths(backend.glob("/uploads/**/*.xlsx")) == set() + assert _paths(backend.glob("/nope/**/*.csv")) == set() + + +# --------------------------------------------------------------------------- +# grep shares the comparison, and shared the bug +# --------------------------------------------------------------------------- +def test_grep_glob_filter_accepts_an_absolute_pattern(backend): + res = backend.grep("alpha", glob="/uploads/**/*.csv") + + assert res.error is None + hit_paths = {m.path if hasattr(m, "path") else m["path"] for m in (res.matches or [])} + assert "/uploads/年报/2024/Q1.csv" in hit_paths + assert "/output/report.md" not in hit_paths + + +def test_grep_without_a_glob_is_unfiltered(backend): + res = backend.grep("alpha") + assert res.error is None + hit_paths = {m.path if hasattr(m, "path") else m["path"] for m in (res.matches or [])} + assert "/output/report.md" in hit_paths + + +# --------------------------------------------------------------------------- +# The pattern-normalisation helper on its own +# --------------------------------------------------------------------------- +@pytest.mark.parametrize( + ("pattern", "expected"), + [ + ("/uploads/**/*.csv", ("uploads/**/*.csv", "uploads/*.csv")), + ("uploads/**/*.csv", ("uploads/**/*.csv", "uploads/*.csv")), + ("/output/*.md", ("output/*.md",)), + ("*.csv", ("*.csv",)), + ("/", ()), + ("", ()), + ], +) +def test_glob_pattern_candidates(pattern, expected): + assert WorkspaceBackend._glob_patterns(pattern) == expected From f4cf5e4437cff1e29963383e48594886af0ef001 Mon Sep 17 00:00:00 2001 From: dolphin Date: Thu, 13 Aug 2026 14:33:00 +0800 Subject: [PATCH 19/19] fix(report): keep the template's formatting when filling placeholders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A placeholder that the template author underlined came out plain in the generated report, and so did the rest of that line. Replacement rebuilt the whole paragraph: it took `paragraph.text` — which flattens every run into one string, dropping all run-level formatting — then re-stamped the static text with the FIRST run's format and gave the substituted value whatever formatting the value payload carried, which for a report node is none at all. The rebuilt paragraph was also a bare `w:p` with seven properties copied over, so the paragraph style, numbering, borders and tab leaders went with it. Text values are now written into the run that already holds the placeholder, so its rPr — underline, bold, font, size — applies to the substituted text, and every other run and the paragraph itself are left untouched. Placeholders split across runs (Word does this routinely) are handled by spanning the runs they cover. Values carrying a table, image or heading still take the rebuild path: those cannot live inside a run. Extra text items and any trailing text clone the placeholder run's formatting rather than the first item's, so a value like [{"content": "a"}, {"content": "b", "bold": true}] inherits the underline for both parts and adds bold only to the second. --- .../workflow/nodes/report/docx_replace.py | 387 +++++++++++------- .../nodes/test_docx_replace_formatting.py | 145 +++++++ 2 files changed, 391 insertions(+), 141 deletions(-) create mode 100644 src/backend/test/workflow/nodes/test_docx_replace_formatting.py diff --git a/src/backend/bisheng/workflow/nodes/report/docx_replace.py b/src/backend/bisheng/workflow/nodes/report/docx_replace.py index 78ad9ee695..74b6ab6305 100644 --- a/src/backend/bisheng/workflow/nodes/report/docx_replace.py +++ b/src/backend/bisheng/workflow/nodes/report/docx_replace.py @@ -1,19 +1,21 @@ +import copy import re from io import BytesIO -from typing import List, Dict, Any, IO +from typing import IO, Any from docx import Document from docx.enum.style import WD_STYLE_TYPE from docx.oxml import OxmlElement -from docx.shared import Pt, Inches, RGBColor +from docx.shared import Inches, Pt, RGBColor from docx.table import _Cell from docx.text.paragraph import Paragraph +from docx.text.run import Run # Separator between the human-readable node name and the lookup key inside a # placeholder: ``{{display name|node_id.field}}``. The name is a snapshot taken # when the variable was inserted -- it exists purely so the template is readable # and is never used to resolve values (release-contract INV-8). -PLACEHOLDER_DISPLAY_SEPARATOR = '|' +PLACEHOLDER_DISPLAY_SEPARATOR = "|" def normalize_placeholder_key(raw: str) -> str: @@ -41,7 +43,7 @@ class DocxReplacer: def __init__(self, template_path: str | IO[bytes]): self.template_path = template_path self.doc = Document(template_path) - self.placeholder_pattern = re.compile(r'\{\{([^}]+)\}\}') + self.placeholder_pattern = re.compile(r"\{\{([^}]+)\}\}") self._init_style() def check_style(self, style_name: str, **kwargs): @@ -63,7 +65,7 @@ def _init_style(self): self.check_style("Heading 5", size=152400) self.check_style("Heading 6", bold=True) - def replace_and_save(self, variables: Dict[str, List[Dict[str, Any]]], output_path: str): + def replace_and_save(self, variables: dict[str, list[dict[str, Any]]], output_path: str): """ Replace the placeholders and save the document. @@ -84,14 +86,14 @@ def replace_and_save(self, variables: Dict[str, List[Dict[str, Any]]], output_pa self.doc.save(output_path) - def _process_table(self, table, variables: Dict[str, List[Dict[str, Any]]]): + def _process_table(self, table, variables: dict[str, list[dict[str, Any]]]): for row in table.rows: for cell in row.cells: self._process_paragraphs(cell.paragraphs, variables) for nested_table in cell.tables: self._process_table(nested_table, variables) - def _process_paragraphs(self, paragraphs: List[Paragraph], variables: Dict[str, List[Dict[str, Any]]]): + def _process_paragraphs(self, paragraphs: list[Paragraph], variables: dict[str, list[dict[str, Any]]]): i = 0 while i < len(paragraphs): paragraph = paragraphs[i] @@ -99,21 +101,138 @@ def _process_paragraphs(self, paragraphs: List[Paragraph], variables: Dict[str, matches = list(self.placeholder_pattern.finditer(text)) if matches: - insert_index = self._get_paragraph_index(paragraph) - self._replace_paragraph_placeholders(paragraph, matches, variables, insert_index) + # Plain-text values are written into the run that holds the + # placeholder, so the author's underline / font / size survive and + # the paragraph itself (style, numbering, borders) is never + # rebuilt. Anything block-level still takes the rebuild path. + if not ( + self._placeholders_are_inline(matches, variables) + and self._replace_inline_placeholders(paragraph, variables) + ): + insert_index = self._get_paragraph_index(paragraph) + self._replace_paragraph_placeholders(paragraph, matches, variables, insert_index) i += 1 + def _placeholders_are_inline( + self, + matches: list[re.Match], + variables: dict[str, list[dict[str, Any]]], + ) -> bool: + """Whether every placeholder in this paragraph resolves to plain text. + + A table, image or heading cannot live inside a run — those paragraphs + have to be split apart, which is what the rebuild path does. + """ + for match in matches: + items = variables.get(match.group(1)) + if items is None: + # Unresolved placeholder: left verbatim either way. + continue + if any(item.get("type") != "text" for item in items): + return False + return True + + def _replace_inline_placeholders( + self, + paragraph: Paragraph, + variables: dict[str, list[dict[str, Any]]], + ) -> bool: + """Rewrite text placeholders in place, one run at a time. + + Returns False when the placeholders cannot be located in the run text — + a placeholder split across a hyperlink, say — so the caller can fall + back to rebuilding the paragraph. + """ + runs = paragraph.runs + if not runs: + return False + + runs_text = "".join(run.text for run in runs) + matches = list(self.placeholder_pattern.finditer(runs_text)) + if not matches: + return False + + # Right to left: rewriting a later span never shifts an earlier offset. + for match in reversed(matches): + items = variables.get(match.group(1)) + if items is None: + continue + self._replace_run_span(paragraph, match.start(), match.end(), items) + return True + + def _replace_run_span( + self, + paragraph: Paragraph, + start: int, + end: int, + items: list[dict[str, Any]], + ): + """Replace the characters [start, end) of a paragraph's run text.""" + spans = [] + offset = 0 + for run in paragraph.runs: + spans.append((offset, offset + len(run.text), run)) + offset += len(run.text) + + touched = [ + (run_start, run_end, run) for run_start, run_end, run in spans if run_end > start and run_start < end + ] + if not touched: + return + + first_start, _, first_run = touched[0] + last_start, _, last_run = touched[-1] + prefix = first_run.text[: start - first_start] + suffix = last_run.text[end - last_start :] + + # Snapshot the placeholder run's formatting before writing to it: extra + # value items and the trailing text should inherit what the author put on + # the placeholder, not what the first item asks for. + template_element = copy.deepcopy(first_run._element) + + for _run_start, _run_end, run in touched[1:]: + run.text = "" + + text_items = items or [{"content": ""}] + first_run.text = prefix + text_items[0].get("content", "") + self._apply_run_format(first_run, text_items[0]) + + anchor = first_run._element + for item in text_items[1:]: + anchor = self._insert_run_after(paragraph, anchor, template_element, item.get("content", ""), item) + + if suffix: + if last_run is not first_run: + last_run.text = suffix + else: + self._insert_run_after(paragraph, anchor, template_element, suffix, {}) + + def _insert_run_after( + self, + paragraph: Paragraph, + anchor_element, + template_element, + text: str, + format_data: dict[str, Any], + ): + new_element = copy.deepcopy(template_element) + anchor_element.addnext(new_element) + run = Run(new_element, paragraph) + run.text = text + self._apply_run_format(run, format_data) + return new_element + def _get_paragraph_index(self, paragraph: Paragraph) -> int: parent = paragraph._element.getparent() return parent.index(paragraph._element) def _replace_paragraph_placeholders( - self, - paragraph: Paragraph, - matches: List[re.Match], - variables: Dict[str, List[Dict[str, Any]]], - insert_index: int + self, + paragraph: Paragraph, + matches: list[re.Match], + variables: dict[str, list[dict[str, Any]]], + insert_index: int, ): parent = paragraph._element.getparent() text = paragraph.text @@ -126,33 +245,17 @@ def _replace_paragraph_placeholders( start, end = match.span() if start > last_end: - segments.append({ - 'type': 'text_segment', - 'content': text[last_end:start], - 'paragraph': paragraph - }) + segments.append({"type": "text_segment", "content": text[last_end:start], "paragraph": paragraph}) if var_name in variables: - segments.append({ - 'type': 'variable', - 'content': variables[var_name], - 'paragraph': paragraph - }) + segments.append({"type": "variable", "content": variables[var_name], "paragraph": paragraph}) else: - segments.append({ - 'type': 'text_segment', - 'content': match.group(0), - 'paragraph': paragraph - }) + segments.append({"type": "text_segment", "content": match.group(0), "paragraph": paragraph}) last_end = end if last_end < len(text): - segments.append({ - 'type': 'text_segment', - 'content': text[last_end:], - 'paragraph': paragraph - }) + segments.append({"type": "text_segment", "content": text[last_end:], "paragraph": paragraph}) original_format = self._extract_paragraph_format(paragraph) original_run_format = self._extract_run_format(paragraph.runs[0] if paragraph.runs else None) @@ -163,113 +266,111 @@ def _replace_paragraph_placeholders( current_paragraph = None for segment in segments: - if segment['type'] == 'text_segment': + if segment["type"] == "text_segment": if current_paragraph is None: - current_paragraph = self._insert_paragraph_at_index( - parent, current_insert_index, original_format - ) + current_paragraph = self._insert_paragraph_at_index(parent, current_insert_index, original_format) current_insert_index += 1 - run = current_paragraph.add_run(segment['content']) + run = current_paragraph.add_run(segment["content"]) self._apply_run_format(run, original_run_format) - elif segment['type'] == 'variable': - for item in segment['content']: - item_type = item.get('type') + elif segment["type"] == "variable": + for item in segment["content"]: + item_type = item.get("type") - if item_type == 'text': + if item_type == "text": if current_paragraph is None: current_paragraph = self._insert_paragraph_at_index( parent, current_insert_index, original_format ) current_insert_index += 1 - run = current_paragraph.add_run(item['content']) + run = current_paragraph.add_run(item["content"]) self._apply_run_format(run, item) - elif item_type in ['table', 'image', 'heading']: + elif item_type in ["table", "image", "heading"]: if current_paragraph is not None and current_paragraph.text.strip(): current_paragraph = None - if item_type == 'table': + if item_type == "table": self._insert_table_at_index(parent, current_insert_index, item) - elif item_type == 'image': + elif item_type == "image": self._insert_image_at_index(parent, current_insert_index, item, original_format) - elif item_type == 'heading': + elif item_type == "heading": self._insert_heading_at_index(parent, current_insert_index, item) current_insert_index += 1 current_paragraph = None - def _extract_paragraph_format(self, paragraph: Paragraph) -> Dict[str, Any]: + def _extract_paragraph_format(self, paragraph: Paragraph) -> dict[str, Any]: return { - 'alignment': paragraph.alignment, - 'left_indent': paragraph.paragraph_format.left_indent, - 'right_indent': paragraph.paragraph_format.right_indent, - 'first_line_indent': paragraph.paragraph_format.first_line_indent, - 'space_before': paragraph.paragraph_format.space_before, - 'space_after': paragraph.paragraph_format.space_after, - 'line_spacing': paragraph.paragraph_format.line_spacing, + "alignment": paragraph.alignment, + "left_indent": paragraph.paragraph_format.left_indent, + "right_indent": paragraph.paragraph_format.right_indent, + "first_line_indent": paragraph.paragraph_format.first_line_indent, + "space_before": paragraph.paragraph_format.space_before, + "space_after": paragraph.paragraph_format.space_after, + "line_spacing": paragraph.paragraph_format.line_spacing, } - def _extract_run_format(self, run) -> Dict[str, Any]: + def _extract_run_format(self, run) -> dict[str, Any]: if run is None: return {} return { - 'bold': run.bold, - 'italic': run.italic, - 'underline': run.underline, - 'font_name': run.font.name, - 'font_size': run.font.size, - 'font_color': run.font.color.rgb if run.font.color.rgb else None, + "bold": run.bold, + "italic": run.italic, + "underline": run.underline, + "font_name": run.font.name, + "font_size": run.font.size, + "font_color": run.font.color.rgb if run.font.color.rgb else None, } - def _insert_paragraph_at_index(self, parent, index: int, format_dict: Dict[str, Any]) -> Paragraph: - p_element = OxmlElement('w:p') + def _insert_paragraph_at_index(self, parent, index: int, format_dict: dict[str, Any]) -> Paragraph: + p_element = OxmlElement("w:p") parent.insert(index, p_element) paragraph = Paragraph(p_element, self.doc) # 应用格式 - if format_dict.get('alignment') is not None: - paragraph.alignment = format_dict['alignment'] - if format_dict.get('left_indent') is not None: - paragraph.paragraph_format.left_indent = format_dict['left_indent'] - if format_dict.get('right_indent') is not None: - paragraph.paragraph_format.right_indent = format_dict['right_indent'] - if format_dict.get('first_line_indent') is not None: - paragraph.paragraph_format.first_line_indent = format_dict['first_line_indent'] - if format_dict.get('space_before') is not None: - paragraph.paragraph_format.space_before = format_dict['space_before'] - if format_dict.get('space_after') is not None: - paragraph.paragraph_format.space_after = format_dict['space_after'] - if format_dict.get('line_spacing') is not None: - paragraph.paragraph_format.line_spacing = format_dict['line_spacing'] + if format_dict.get("alignment") is not None: + paragraph.alignment = format_dict["alignment"] + if format_dict.get("left_indent") is not None: + paragraph.paragraph_format.left_indent = format_dict["left_indent"] + if format_dict.get("right_indent") is not None: + paragraph.paragraph_format.right_indent = format_dict["right_indent"] + if format_dict.get("first_line_indent") is not None: + paragraph.paragraph_format.first_line_indent = format_dict["first_line_indent"] + if format_dict.get("space_before") is not None: + paragraph.paragraph_format.space_before = format_dict["space_before"] + if format_dict.get("space_after") is not None: + paragraph.paragraph_format.space_after = format_dict["space_after"] + if format_dict.get("line_spacing") is not None: + paragraph.paragraph_format.line_spacing = format_dict["line_spacing"] return paragraph - def _apply_run_format(self, run, format_data: Dict[str, Any]): - if format_data.get('bold'): + def _apply_run_format(self, run, format_data: dict[str, Any]): + if format_data.get("bold"): run.bold = True - if format_data.get('italic'): + if format_data.get("italic"): run.italic = True - if format_data.get('underline'): + if format_data.get("underline"): run.underline = True - if format_data.get('font_size'): - if isinstance(format_data['font_size'], int): - run.font.size = Pt(format_data['font_size']) + if format_data.get("font_size"): + if isinstance(format_data["font_size"], int): + run.font.size = Pt(format_data["font_size"]) else: - run.font.size = format_data['font_size'] - if format_data.get('font_name'): - run.font.name = format_data['font_name'] - if format_data.get('color'): - if isinstance(format_data['color'], tuple) and len(format_data['color']) == 3: - run.font.color.rgb = RGBColor(*format_data['color']) - if format_data.get('font_color'): - run.font.color.rgb = format_data['font_color'] - - def _insert_table_at_index(self, parent, index: int, item: Dict[str, Any]): - data = item['content'] + run.font.size = format_data["font_size"] + if format_data.get("font_name"): + run.font.name = format_data["font_name"] + if format_data.get("color"): + if isinstance(format_data["color"], tuple) and len(format_data["color"]) == 3: + run.font.color.rgb = RGBColor(*format_data["color"]) + if format_data.get("font_color"): + run.font.color.rgb = format_data["font_color"] + + def _insert_table_at_index(self, parent, index: int, item: dict[str, Any]): + data = item["content"] rows = len(data) cols = len(data[0]) if rows > 0 else 0 @@ -278,8 +379,8 @@ def _insert_table_at_index(self, parent, index: int, item: Dict[str, Any]): table = self.doc.add_table(rows=0, cols=cols) - if item.get('style'): - table.style = item['style'] + if item.get("style"): + table.style = item["style"] for row_data in data: row = table.add_row() @@ -307,100 +408,104 @@ def _fill_cell(self, cell: _Cell, cell_content: Any): if cell.paragraphs: default_paragraph = cell.paragraphs[0] for run in default_paragraph.runs: - run.text = '' + run.text = "" else: default_paragraph = cell.add_paragraph() if isinstance(cell_content, dict): - if 'type' not in cell_content or 'content' not in cell_content: + if "type" not in cell_content or "content" not in cell_content: raise ValueError( - f"The cell element must contain the `type` and `content` fields, but got:{cell_content}") + f"The cell element must contain the `type` and `content` fields, but got:{cell_content}" + ) cell_content = [cell_content] elif isinstance(cell_content, list): for element in cell_content: - if not isinstance(element, dict) or 'type' not in element or 'content' not in element: + if not isinstance(element, dict) or "type" not in element or "content" not in element: raise ValueError( - f"The cell element must contain the `type` and `content` fields, but got:{element}") + f"The cell element must contain the `type` and `content` fields, but got:{element}" + ) else: raise ValueError(f"Not supported data type:{type(cell_content)}") current_paragraph = default_paragraph for element in cell_content: - element_type = element['type'] - if element_type == 'text': - run = current_paragraph.add_run(element['content']) + element_type = element["type"] + if element_type == "text": + run = current_paragraph.add_run(element["content"]) self._apply_run_format(run, element) - elif element_type == 'image': + elif element_type == "image": if current_paragraph.text.strip(): current_paragraph = cell.add_paragraph() self._add_image_to_paragraph(current_paragraph, element) current_paragraph = cell.add_paragraph() - elif element_type == 'paragraph': + elif element_type == "paragraph": current_paragraph = cell.add_paragraph() - if element.get('alignment'): - current_paragraph.alignment = element['alignment'] + if element.get("alignment"): + current_paragraph.alignment = element["alignment"] - if isinstance(element['content'], str): - run = current_paragraph.add_run(element['content']) + if isinstance(element["content"], str): + run = current_paragraph.add_run(element["content"]) self._apply_run_format(run, element) - elif isinstance(element['content'], list): - for text_item in element['content']: - if not isinstance(text_item, dict) or 'type' not in text_item: - raise ValueError(f"Paragraph content elements must include a `type` field; got:{text_item}") - if text_item['type'] == 'text': - run = current_paragraph.add_run(text_item['content']) + elif isinstance(element["content"], list): + for text_item in element["content"]: + if not isinstance(text_item, dict) or "type" not in text_item: + raise ValueError( + f"Paragraph content elements must include a `type` field; got:{text_item}" + ) + if text_item["type"] == "text": + run = current_paragraph.add_run(text_item["content"]) self._apply_run_format(run, text_item) - if element.get('alignment'): + if element.get("alignment"): for cell_paragraph in cell.paragraphs: - cell_paragraph.alignment = element['alignment'] + cell_paragraph.alignment = element["alignment"] - def _add_image_to_paragraph(self, paragraph: Paragraph, image_data: Dict[str, Any]): + def _add_image_to_paragraph(self, paragraph: Paragraph, image_data: dict[str, Any]): run = paragraph.add_run() try: - width = Inches(image_data.get('width', 2)) - height = Inches(image_data.get('height')) if image_data.get('height') else None + width = Inches(image_data.get("width", 2)) + height = Inches(image_data.get("height")) if image_data.get("height") else None - if isinstance(image_data['content'], str): + if isinstance(image_data["content"], str): # local file path if height: - run.add_picture(image_data['content'], width=width, height=height) + run.add_picture(image_data["content"], width=width, height=height) else: - run.add_picture(image_data['content'], width=width) - elif isinstance(image_data['content'], bytes): + run.add_picture(image_data["content"], width=width) + elif isinstance(image_data["content"], bytes): # bytes data - image_stream = BytesIO(image_data['content']) + image_stream = BytesIO(image_data["content"]) if height: run.add_picture(image_stream, width=width, height=height) else: run.add_picture(image_stream, width=width) except Exception as e: - paragraph.add_run(f"Image add failed: {str(e)}]") + paragraph.add_run(f"Image add failed: {e!s}]") # set alignment - if image_data.get('alignment'): - paragraph.alignment = image_data['alignment'] + if image_data.get("alignment"): + paragraph.alignment = image_data["alignment"] - def _insert_image_at_index(self, parent, index: int, item: Dict[str, Any], paragraph_format: Dict[str, Any]): + def _insert_image_at_index(self, parent, index: int, item: dict[str, Any], paragraph_format: dict[str, Any]): paragraph = self._insert_paragraph_at_index(parent, index, paragraph_format) self._add_image_to_paragraph(paragraph, item) - def _insert_heading_at_index(self, parent, index: int, item: Dict[str, Any]): - p_element = OxmlElement('w:p') + def _insert_heading_at_index(self, parent, index: int, item: dict[str, Any]): + p_element = OxmlElement("w:p") parent.insert(index, p_element) paragraph = Paragraph(p_element, self.doc) - level = item.get('level', 1) - paragraph.style = f'Heading {level}' + level = item.get("level", 1) + paragraph.style = f"Heading {level}" - run = paragraph.add_run(item['content']) + run = paragraph.add_run(item["content"]) self._apply_run_format(run, item) - def extract_variables(self) -> List[str]: + def extract_variables(self) -> list[str]: variables = [] seen = set() @@ -437,11 +542,11 @@ def extract_variables(self) -> List[str]: return variables - def _extract_vars_from_text(self, text: str) -> List[str]: + def _extract_vars_from_text(self, text: str) -> list[str]: matches = self.placeholder_pattern.findall(text) return matches - def _extract_vars_from_table(self, table) -> List[str]: + def _extract_vars_from_table(self, table) -> list[str]: variables = [] seen = set() diff --git a/src/backend/test/workflow/nodes/test_docx_replace_formatting.py b/src/backend/test/workflow/nodes/test_docx_replace_formatting.py new file mode 100644 index 0000000000..542259d361 --- /dev/null +++ b/src/backend/test/workflow/nodes/test_docx_replace_formatting.py @@ -0,0 +1,145 @@ +"""Report placeholders must keep the formatting the template author gave them. + +The replacement used to rebuild the whole paragraph from its plain text, which +dropped every run-level attribute (underline, bold, font) and the paragraph's own +style. Text values are now written into the run that holds the placeholder. +""" + +from io import BytesIO + +from docx import Document + +from bisheng.workflow.nodes.report.docx_replace import DocxReplacer + +PLACEHOLDER = "{{输入|input_bfa69.user_input}}" +KEY = "输入|input_bfa69.user_input" + + +def _render(build_template, variables) -> Document: + """Run a template through the replacer and return the rendered document.""" + template = BytesIO() + doc = Document() + build_template(doc) + doc.save(template) + + rendered = BytesIO() + DocxReplacer(BytesIO(template.getvalue())).replace_and_save(variables, rendered) + rendered.seek(0) + return Document(rendered) + + +def test_placeholder_run_formatting_survives(): + def build(doc): + paragraph = doc.add_paragraph() + paragraph.add_run("xxxxx. ") + underlined = paragraph.add_run(PLACEHOLDER) + underlined.underline = True + underlined.bold = True + paragraph.add_run(" xxxxx") + + rendered = _render(build, {KEY: [{"type": "text", "content": "有下划线"}]}) + runs = rendered.paragraphs[0].runs + + assert [run.text for run in runs] == ["xxxxx. ", "有下划线", " xxxxx"] + assert runs[1].underline is True + assert runs[1].bold is True + # The neighbours are untouched — they used to be re-stamped with run[0]'s format. + assert runs[0].underline is None + assert runs[2].underline is None + + +def test_placeholder_split_across_runs_is_replaced(): + """Word routinely splits a typed placeholder over several runs.""" + + def build(doc): + paragraph = doc.add_paragraph() + for chunk in ("{{输入|input_", "bfa69.user", "_input}}"): + run = paragraph.add_run(chunk) + run.underline = True + + rendered = _render(build, {KEY: [{"type": "text", "content": "拼接占位符"}]}) + paragraph = rendered.paragraphs[0] + + assert paragraph.text == "拼接占位符" + assert next(run for run in paragraph.runs if run.text).underline is True + + +def test_paragraph_style_survives(): + def build(doc): + paragraph = doc.add_paragraph(style="Quote") + paragraph.add_run(PLACEHOLDER) + + rendered = _render(build, {KEY: [{"type": "text", "content": "引用内容"}]}) + + assert rendered.paragraphs[0].style.name == "Quote" + assert rendered.paragraphs[0].text == "引用内容" + + +def test_value_items_inherit_placeholder_format_and_add_their_own(): + def build(doc): + paragraph = doc.add_paragraph() + run = paragraph.add_run(PLACEHOLDER) + run.underline = True + + rendered = _render( + build, + { + KEY: [ + {"type": "text", "content": "普通"}, + {"type": "text", "content": "加粗", "bold": True}, + ] + }, + ) + runs = [run for run in rendered.paragraphs[0].runs if run.text] + + assert [run.text for run in runs] == ["普通", "加粗"] + assert all(run.underline is True for run in runs) + assert runs[1].bold is True + assert runs[0].bold is None + + +def test_surrounding_text_in_the_same_run_is_kept(): + def build(doc): + run = doc.add_paragraph().add_run(f"前缀{PLACEHOLDER}后缀") + run.underline = True + + rendered = _render(build, {KEY: [{"type": "text", "content": "中间"}]}) + paragraph = rendered.paragraphs[0] + + assert paragraph.text == "前缀中间后缀" + assert all(run.underline is True for run in paragraph.runs if run.text) + + +def test_unresolved_placeholder_is_left_verbatim(): + def build(doc): + doc.add_paragraph().add_run(f"保留 {PLACEHOLDER}") + + rendered = _render(build, {"other": [{"type": "text", "content": "x"}]}) + + assert rendered.paragraphs[0].text == f"保留 {PLACEHOLDER}" + + +def test_block_content_still_splits_the_paragraph(): + """Tables cannot live inside a run, so those keep the rebuild path.""" + + def build(doc): + doc.add_paragraph().add_run(f"见下表:{PLACEHOLDER}") + + rendered = _render( + build, + { + KEY: [ + { + "type": "table", + "content": [ + [{"type": "text", "content": "列1"}, {"type": "text", "content": "列2"}], + [{"type": "text", "content": "值1"}, {"type": "text", "content": "值2"}], + ], + } + ] + }, + ) + + assert len(rendered.tables) == 1 + assert rendered.tables[0].rows[0].cells[0].text == "列1" + assert any("见下表:" in paragraph.text for paragraph in rendered.paragraphs)