From 9ba2357d450c41d0fdd5ee43cd3240707d104c62 Mon Sep 17 00:00:00 2001 From: kittors Date: Mon, 17 Aug 2026 20:49:20 +0800 Subject: [PATCH 1/6] fix(ui): stop repeating model names as tooltips and alias-only "real model ID" hints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three sources of the same complaint — the console showing an "alias" identical to the name already on screen. OverflowTooltip gated on `scrollWidth > clientWidth`. Both are rounded to integers, so a 188.4px label in a 188px box reports a 1px overflow while rendering in full with no ellipsis, and the tooltip opened repeating the visible text. Add a 1px tolerance, matching what scrollMetrics.ts already does for the same rounding. ModelTag set a native `title` equal to the model id, stacking a browser tooltip on top of the managed one with the same content. Only set it when a caller passes one; truncating call sites already wrap the tag in OverflowTooltip. The request log raised a "real model ID" hint whenever the upstream name differed as a string. An account alias only adds a routing segment — `ollama/…:0731` for upstream `…:0731` — so every row of an aliased provider carried the hint. Compare model identity instead. The backend now avoids recording those names, but logs are kept for months, so the UI normalizes historical rows too. Provider model chips move to OverflowTooltip on the same principle: when the chip fits, its tooltip only repeated the mapping already visible. Model plaza source summaries are deliberately unchanged: that line is catalog information about which id a source serves, not a per-row runtime hint. Extracting the model cell into RequestLogModelCell also brings requestLogsShared.tsx down from 1020 to 989 lines; baseline updated to lock it in. Co-Authored-By: Claude Opus 5 --- features/model-tags/index.tsx | 5 +- .../RequestLogModelCell.tsx | 59 +++++++++++++++ .../request-log-viewer/requestLogsShared.tsx | 35 +-------- packages/domain/src/index.ts | 1 + .../models/__tests__/modelIdentity.test.ts | 37 ++++++++++ packages/domain/src/models/modelIdentity.ts | 27 +++++++ packages/ui/src/overlays/Tooltip.tsx | 12 +++- .../__tests__/Tooltip.overflow.test.tsx | 71 +++++++++++++++++++ .../components/ProviderModelChips.tsx | 8 ++- .../__tests__/ProviderModelChips.test.tsx | 54 +++++++++++++- .../__tests__/RequestLogsPage.test.tsx | 28 ++++++++ scripts/file-size-baseline.json | 4 +- 12 files changed, 300 insertions(+), 41 deletions(-) create mode 100644 features/request-log-viewer/RequestLogModelCell.tsx create mode 100644 packages/domain/src/models/__tests__/modelIdentity.test.ts create mode 100644 packages/domain/src/models/modelIdentity.ts create mode 100644 packages/ui/src/overlays/__tests__/Tooltip.overflow.test.tsx diff --git a/features/model-tags/index.tsx b/features/model-tags/index.tsx index e9c53080..60432168 100644 --- a/features/model-tags/index.tsx +++ b/features/model-tags/index.tsx @@ -365,7 +365,10 @@ export function ModelTag({ return ( + + + ); +} + +/** + * Model column of the request log table. + * + * The hint dots only appear for a genuinely different model. An account alias that + * merely adds a routing prefix (`ollama/deepseek-v4-flash:0731` for upstream + * `deepseek-v4-flash:0731`) is the same model under two names, and announcing it as + * a "real model ID" was noise on every single row of an aliased provider. + */ +export function RequestLogModelCell({ row }: { row: RequestLogsRow }) { + const { t } = useTranslation(); + if (!row.model) { + return --; + } + + const label = row.displayModel || row.model; + return ( + + + + + {isDistinctModelIdentity(row.model, row.upstreamModel) ? ( + + ) : null} + {isDistinctModelIdentity(row.model, row.visionFallbackModel) ? ( + + ) : null} + + ); +} diff --git a/features/request-log-viewer/requestLogsShared.tsx b/features/request-log-viewer/requestLogsShared.tsx index 005594b5..0a1f5f57 100644 --- a/features/request-log-viewer/requestLogsShared.tsx +++ b/features/request-log-viewer/requestLogsShared.tsx @@ -16,7 +16,7 @@ import { SearchableCheckboxMultiSelect, Tabs, TabsList, TabsTrigger } from "@cod import type { SearchableCheckboxMultiSelectOption } from "@code-proxy/ui"; import { HoverTooltip, OverflowTooltip } from "@code-proxy/ui"; import { PaginationBar } from "@code-proxy/ui"; -import { ModelTag } from "@features/model-tags"; +import { RequestLogModelCell } from "./RequestLogModelCell"; export type TimeRange = 1 | 7 | 14 | 30; export type StatusFilterValue = "success" | "failed"; @@ -940,38 +940,7 @@ export function buildRequestLogsColumns( width: "w-44", headerClassName: CENTERED_REQUEST_LOG_HEADER_CLASS, cellClassName: "text-center", - render: (row) => - row.model ? ( - - - - - {row.upstreamModel && row.upstreamModel !== row.model ? ( - - - - ) : null} - {row.visionFallbackModel && row.visionFallbackModel !== row.model ? ( - - - - ) : null} - - ) : ( - -- - ), + render: (row) => , }, ); return identityColumn === "none" diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 13f4a145..d44bec16 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -6,6 +6,7 @@ export * from "./ccswitch/ccswitchImportSettings"; export * from "./auth-files/authFiles"; export * from "./auth-files/types"; export * from "./auth-files/zip"; +export * from "./models/modelIdentity"; export * from "./quota"; export * from "./usage"; export * from "./tenant-cache"; diff --git a/packages/domain/src/models/__tests__/modelIdentity.test.ts b/packages/domain/src/models/__tests__/modelIdentity.test.ts new file mode 100644 index 00000000..e75e4335 --- /dev/null +++ b/packages/domain/src/models/__tests__/modelIdentity.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "vitest"; +import { isDistinctModelIdentity, isSameModelIdentity } from "../modelIdentity"; + +describe("isSameModelIdentity", () => { + test.each([ + ["deepseek-v4-flash:0731", "deepseek-v4-flash:0731"], + ["ollama/deepseek-v4-flash:0731", "deepseek-v4-flash:0731"], + ["deepseek-v4-flash:0731", "ollama/deepseek-v4-flash:0731"], + ["cline-pass/deepseek-v4-flash", "deepseek-v4-flash"], + ["group/ollama/gpt-oss:20b", "gpt-oss:20b"], + ["Ollama/GPT-OSS:20B", "gpt-oss:20b"], + ])("treats %s and %s as one model", (requested, upstream) => { + expect(isSameModelIdentity(requested, upstream)).toBe(true); + }); + + test.each([ + ["fast", "claude-sonnet-4"], + ["gpt-oss:20b", "gpt-oss:120b"], + ["xdeepseek-v4-flash", "deepseek-v4-flash"], + ["deepseek-v4-flash", ""], + ["", "deepseek-v4-flash"], + ])("keeps %s and %s apart", (requested, upstream) => { + expect(isSameModelIdentity(requested, upstream)).toBe(false); + }); +}); + +describe("isDistinctModelIdentity", () => { + test("is false when either side is missing", () => { + expect(isDistinctModelIdentity("deepseek-v4-flash", "")).toBe(false); + expect(isDistinctModelIdentity(" ", "deepseek-v4-flash")).toBe(false); + }); + + test("is true only for a genuinely different upstream model", () => { + expect(isDistinctModelIdentity("ollama/gpt-oss:20b", "gpt-oss:20b")).toBe(false); + expect(isDistinctModelIdentity("fast", "claude-sonnet-4")).toBe(true); + }); +}); diff --git a/packages/domain/src/models/modelIdentity.ts b/packages/domain/src/models/modelIdentity.ts new file mode 100644 index 00000000..7c9c6bc9 --- /dev/null +++ b/packages/domain/src/models/modelIdentity.ts @@ -0,0 +1,27 @@ +/** + * Whether a requested model name and the model actually used upstream denote the + * same model. + * + * Provider aliases normally only add a routing segment — an Ollama Cloud account + * exposing `deepseek-v4-flash:0731` as `ollama/deepseek-v4-flash:0731` makes the + * request log carry the prefixed name and the upstream field the bare one. Those + * are one model under two names, so surfacing a "real model ID" hint for them is + * pure noise. Aliases that rename the model (`fast` -> `claude-sonnet-4`) stay + * different and remain worth showing. + * + * The backend stopped recording alias-only upstream names, but request logs are + * kept for months, so the UI normalizes historical rows the same way. + */ +export const isSameModelIdentity = (requested: string, upstream: string): boolean => { + const a = requested.trim().toLowerCase(); + const b = upstream.trim().toLowerCase(); + if (!a || !b) return false; + if (a === b) return true; + return a.endsWith(`/${b}`) || b.endsWith(`/${a}`); +}; + +/** Inverse of {@link isSameModelIdentity}, for "should we disclose this name?" checks. */ +export const isDistinctModelIdentity = (requested: string, upstream: string): boolean => + Boolean(requested.trim()) && + Boolean(upstream.trim()) && + !isSameModelIdentity(requested, upstream); diff --git a/packages/ui/src/overlays/Tooltip.tsx b/packages/ui/src/overlays/Tooltip.tsx index 2944181b..584a4aee 100644 --- a/packages/ui/src/overlays/Tooltip.tsx +++ b/packages/ui/src/overlays/Tooltip.tsx @@ -172,8 +172,18 @@ function resolveTooltipPosition({ }; } +// scrollWidth/clientWidth are rounded to integers, so text that fits with a +// sub-pixel remainder (a 188.4px label in a 188px box) reports a 1px overflow +// while rendering in full, with no ellipsis. Without this tolerance the tooltip +// pops up repeating the text already on screen — which reads as the UI showing +// an "alias" that is identical to the name next to it. +const OVERFLOW_TOLERANCE_PX = 1; + function isElementOverflowing(element: HTMLElement) { - return element.scrollWidth > element.clientWidth || element.scrollHeight > element.clientHeight; + return ( + element.scrollWidth - element.clientWidth > OVERFLOW_TOLERANCE_PX || + element.scrollHeight - element.clientHeight > OVERFLOW_TOLERANCE_PX + ); } function hasOverflowingContent(element: HTMLElement) { diff --git a/packages/ui/src/overlays/__tests__/Tooltip.overflow.test.tsx b/packages/ui/src/overlays/__tests__/Tooltip.overflow.test.tsx new file mode 100644 index 00000000..92af01d9 --- /dev/null +++ b/packages/ui/src/overlays/__tests__/Tooltip.overflow.test.tsx @@ -0,0 +1,71 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test } from "vitest"; +import { OverflowTooltip } from "../Tooltip"; + +// jsdom has no layout, so overflow has to be dictated element by element. +const mockMetrics = (metrics: { scrollWidth: number; clientWidth: number }) => { + const originals = { + scrollWidth: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollWidth"), + clientWidth: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientWidth"), + }; + Object.defineProperty(HTMLElement.prototype, "scrollWidth", { + configurable: true, + get: () => metrics.scrollWidth, + }); + Object.defineProperty(HTMLElement.prototype, "clientWidth", { + configurable: true, + get: () => metrics.clientWidth, + }); + return () => { + if (originals.scrollWidth) { + Object.defineProperty(HTMLElement.prototype, "scrollWidth", originals.scrollWidth); + } + if (originals.clientWidth) { + Object.defineProperty(HTMLElement.prototype, "clientWidth", originals.clientWidth); + } + }; +}; + +describe("OverflowTooltip", () => { + let restore: (() => void) | null = null; + + afterEach(() => { + restore?.(); + restore = null; + }); + + test("stays closed for a 1px rounding overflow", async () => { + // A 188.4px label in a 188px box: scrollWidth rounds up to 189 while the text + // still renders in full. Opening here repeats the visible text for no reason. + restore = mockMetrics({ scrollWidth: 189, clientWidth: 188 }); + const user = userEvent.setup(); + + render( + + ollama/deepseek-v4-flash:0731 + , + ); + + await user.hover(screen.getByText("ollama/deepseek-v4-flash:0731")); + + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + + test("still opens when the text is genuinely truncated", async () => { + restore = mockMetrics({ scrollWidth: 320, clientWidth: 188 }); + const user = userEvent.setup(); + + render( + + ollama/deepseek-v4-flash:0731 + , + ); + + await user.hover(screen.getByText("ollama/deepseek-v4-flash:0731")); + + expect(await screen.findByRole("tooltip")).toHaveTextContent( + "ollama/deepseek-v4-flash:0731", + ); + }); +}); diff --git a/pages/providers/components/ProviderModelChips.tsx b/pages/providers/components/ProviderModelChips.tsx index 808aaf9d..e00df146 100644 --- a/pages/providers/components/ProviderModelChips.tsx +++ b/pages/providers/components/ProviderModelChips.tsx @@ -1,5 +1,5 @@ import type { ProviderModel } from "@code-proxy/api-client"; -import { HoverTooltip } from "@code-proxy/ui"; +import { HoverTooltip, OverflowTooltip } from "@code-proxy/ui"; interface ProviderModelChipsProps { models: ProviderModel[]; @@ -31,7 +31,9 @@ export function ProviderModelChips({ {visible.map((model) => { const modelLabel = formatModelLabel(model, "→"); return ( - ")} placement="top" @@ -40,7 +42,7 @@ export function ProviderModelChips({ {modelLabel} - + ); })} {remaining > 0 ? ( diff --git a/pages/providers/components/__tests__/ProviderModelChips.test.tsx b/pages/providers/components/__tests__/ProviderModelChips.test.tsx index fa7c723e..baf0bceb 100644 --- a/pages/providers/components/__tests__/ProviderModelChips.test.tsx +++ b/pages/providers/components/__tests__/ProviderModelChips.test.tsx @@ -1,9 +1,47 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test } from "vitest"; import { ProviderModelChips } from "../ProviderModelChips"; +// jsdom reports every layout box as 0x0, so overflow-gated tooltips need explicit +// metrics to be exercised at all. +const mockChipOverflow = ({ + scrollWidth, + clientWidth, +}: { + scrollWidth: number; + clientWidth: number; +}) => { + const original = { + scrollWidth: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollWidth"), + clientWidth: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientWidth"), + }; + Object.defineProperty(HTMLElement.prototype, "scrollWidth", { + configurable: true, + get: () => scrollWidth, + }); + Object.defineProperty(HTMLElement.prototype, "clientWidth", { + configurable: true, + get: () => clientWidth, + }); + return () => { + if (original.scrollWidth) { + Object.defineProperty(HTMLElement.prototype, "scrollWidth", original.scrollWidth); + } + if (original.clientWidth) { + Object.defineProperty(HTMLElement.prototype, "clientWidth", original.clientWidth); + } + }; +}; + describe("ProviderModelChips", () => { + let restoreOverflow: (() => void) | null = null; + + afterEach(() => { + restoreOverflow?.(); + restoreOverflow = null; + }); + test("keeps overflow models behind the final summary chip", async () => { const user = userEvent.setup(); const models = [ @@ -34,6 +72,7 @@ describe("ProviderModelChips", () => { test("shows the full model mapping for visible truncated chips", async () => { const user = userEvent.setup(); + restoreOverflow = mockChipOverflow({ scrollWidth: 400, clientWidth: 120 }); render( { "very-long-upstream-model-name => very-long-downstream-alias", ); }); + + test("stays quiet when the chip is fully visible", async () => { + const user = userEvent.setup(); + // A chip that fits has nothing to add: repeating its text as a tooltip is the + // "why is it showing me the alias again?" noise this component used to emit. + restoreOverflow = mockChipOverflow({ scrollWidth: 120, clientWidth: 120 }); + + render(); + + await user.hover(screen.getByText("short-model → short-alias")); + + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); }); diff --git a/pages/request-logs/__tests__/RequestLogsPage.test.tsx b/pages/request-logs/__tests__/RequestLogsPage.test.tsx index 3ff5ab65..e1635b4e 100644 --- a/pages/request-logs/__tests__/RequestLogsPage.test.tsx +++ b/pages/request-logs/__tests__/RequestLogsPage.test.tsx @@ -396,6 +396,34 @@ describe("RequestLogsPage", () => { expect(await screen.findByRole("tooltip")).toHaveTextContent("Real model ID real-model"); }); + test("hides the real-model marker when the upstream name is only the alias prefix", async () => { + await i18n.changeLanguage("en"); + + mocks.getUsageLogs.mockResolvedValue( + responseWithRows([ + buildUsageLogItem({ + id: 1, + // How an Ollama Cloud account alias reaches the log: same model, two names. + model: "ollama/deepseek-v4-flash:0731", + upstream_model: "deepseek-v4-flash:0731", + vision_fallback_model: "", + }), + ]), + ); + + render( + + + + + , + ); + + const table = await screen.findByRole("table", { name: "Request Logs Table" }); + expect(within(table).getByText("ollama/deepseek-v4-flash:0731")).toBeInTheDocument(); + expect(within(table).queryByLabelText("Real model ID")).not.toBeInTheDocument(); + }); + test("renders empty state with normalized empty filter arrays", async () => { await i18n.changeLanguage("en"); diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json index bcdeba9f..fb8039d7 100644 --- a/scripts/file-size-baseline.json +++ b/scripts/file-size-baseline.json @@ -7,7 +7,7 @@ "features/log-content-viewer/components/LogContentModal.tsx": 1534, "features/model-availability/modelAvailability.ts": 1275, "features/oauth-login/components/OAuthLoginDialog.tsx": 912, - "features/request-log-viewer/requestLogsShared.tsx": 1020, + "features/request-log-viewer/requestLogsShared.tsx": 989, "features/routing-config-editor/RoutingConfigEditor.tsx": 2044, "features/visual-config-editor/useVisualConfig.ts": 957, "packages/domain/src/auth-files/authFiles.ts": 2181, @@ -25,6 +25,6 @@ "pages/identity-fingerprint/IdentityFingerprintPage.tsx": 1880, "pages/image-generation/components/ImageGenerationPageContent.tsx": 1193, "pages/models/ModelsPage.tsx": 1251, - "pages/providers/components/ProvidersPageContent.tsx": 1853 + "pages/providers/components/ProvidersPageContent.tsx": 1845 } } From 765aeeba88986805cf480d51ca152ea70e2c9cf9 Mon Sep 17 00:00:00 2001 From: kittors Date: Mon, 17 Aug 2026 22:35:29 +0800 Subject: [PATCH 2/6] feat(video): add the video models page and share the highlighted code block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things the console was missing. Curl examples rendered as flat, unstyled text. The repo already had a tiny hand-written shell tokenizer on the landing page, written precisely to avoid dragging react-syntax-highlighter's 790KB vendor chunk in for a single block. It moves to @code-proxy/ui as CodeBlock so the landing page, the image page and the new video page all read the same. The new Video Models page mirrors the image one: how-to-call docs with a text-to-video / image-to-video switch, request and response tables, and a test panel. The docs show both halves of the call — submit and poll — because the polling step is the one callers miss, and generation is asynchronous upstream. The test panel is synchronous to watch: the server task absorbs the polling, so the panel waits and then plays the clip. Image-page tests move from getByText on the snippet to assertions on the block's textContent: highlighting splits the text across token spans, which is also why CodeBlock carries a data-code-block hook. Co-Authored-By: Claude Opus 5 --- .../src/endpoints/video-generation.ts | 87 ++++ packages/api-client/src/index.ts | 2 + packages/i18n/src/locales/en.json | 52 ++- packages/i18n/src/locales/zh-CN.json | 52 ++- packages/ui/src/code/CodeBlock.tsx | 63 +++ .../ui/src/code}/highlightSnippet.ts | 0 packages/ui/src/index.ts | 3 + .../components/landing/LandingWorkflow.tsx | 8 +- .../__tests__/ImageGenerationPage.test.tsx | 11 +- .../components/ImageGenerationPageContent.tsx | 14 +- pages/registry.ts | 2 + .../video-generation/VideoGenerationPage.tsx | 5 + .../__tests__/VideoGenerationPage.test.tsx | 117 +++++ .../components/VideoGenerationPageContent.tsx | 421 ++++++++++++++++++ pages/video-generation/components/apiDocs.ts | 141 ++++++ pages/video-generation/index.ts | 2 + pages/video-generation/route.tsx | 18 + scripts/file-size-baseline.json | 2 +- 18 files changed, 982 insertions(+), 18 deletions(-) create mode 100644 packages/api-client/src/endpoints/video-generation.ts create mode 100644 packages/ui/src/code/CodeBlock.tsx rename {pages/api-key-lookup/components/landing => packages/ui/src/code}/highlightSnippet.ts (100%) create mode 100644 pages/video-generation/VideoGenerationPage.tsx create mode 100644 pages/video-generation/__tests__/VideoGenerationPage.test.tsx create mode 100644 pages/video-generation/components/VideoGenerationPageContent.tsx create mode 100644 pages/video-generation/components/apiDocs.ts create mode 100644 pages/video-generation/index.ts create mode 100644 pages/video-generation/route.tsx diff --git a/packages/api-client/src/endpoints/video-generation.ts b/packages/api-client/src/endpoints/video-generation.ts new file mode 100644 index 00000000..c83e9571 --- /dev/null +++ b/packages/api-client/src/endpoints/video-generation.ts @@ -0,0 +1,87 @@ +import { apiClient } from "../client/client"; + +const VIDEO_GENERATION_TASK_POLL_TIMEOUT_MS = 10 * 1000; + +/** A selectable video model, as reported by the server. */ +export interface VideoGenerationModel { + id: string; + provider: string; + display_name?: string; + description?: string; + /** True when the model can animate a source image, not just a prompt. */ + supports_image_to_video: boolean; + max_duration_seconds?: number; + price_per_call?: number; +} + +export interface VideoGenerationModelsResponse { + models: VideoGenerationModel[]; +} + +export interface VideoGenerationTestRequest { + model: string; + prompt: string; + /** Source image for image-to-video: an https URL or a data URI. */ + image?: string; + duration?: number; + aspect_ratio?: string; + resolution?: string; +} + +/** + * The finished upstream payload. The console renders `video.url`; the rest is kept + * so the raw response stays inspectable. + */ +export interface VideoGenerationTestResponse { + status?: string; + model?: string; + video?: { + url?: string; + duration?: number; + }; + request_id?: string; +} + +export type VideoGenerationTestTaskStatus = "queued" | "running" | "succeeded" | "failed"; + +export interface VideoGenerationTestTaskStartResponse { + task_id: string; + status: VideoGenerationTestTaskStatus; + phase?: string; + elapsed_ms?: number; +} + +export interface VideoGenerationTestTaskResponse extends VideoGenerationTestTaskStartResponse { + result?: VideoGenerationTestResponse; + error?: { + status?: number; + body?: { + error?: { + message?: string; + type?: string; + upstream?: unknown; + }; + }; + }; +} + +export const videoGenerationApi = { + getModels: (): Promise => { + return apiClient.get("/video-generation/models"); + }, + + startTestTask: ( + payload: VideoGenerationTestRequest, + ): Promise => { + return apiClient.post("/video-generation/test", payload); + }, + + // A clip takes minutes upstream, so the console task absorbs that wait and this + // poll only asks the server for the task's current phase. + getTestTask: (taskId: string): Promise => { + return apiClient.get( + `/video-generation/test/${encodeURIComponent(taskId)}`, + { timeoutMs: VIDEO_GENERATION_TASK_POLL_TIMEOUT_MS }, + ); + }, +}; diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index 78d13033..12481e5e 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -112,6 +112,8 @@ export { updateApi } from "./endpoints/update"; export type * from "./endpoints/update"; export { imageGenerationApi } from "./endpoints/image-generation"; export type * from "./endpoints/image-generation"; +export { videoGenerationApi } from "./endpoints/video-generation"; +export type * from "./endpoints/video-generation"; export { proxiesApi } from "./endpoints/proxies"; export type * from "./endpoints/proxies"; export { diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index 80eb599b..d1fcc939 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -185,7 +185,8 @@ "system_info": "Management Center Info", "monitor": "Monitor Center", "menuManagement": "Menu Management", - "content_moderation": "Content Moderation" + "content_moderation": "Content Moderation", + "videoGeneration": "Video Models" }, "dashboard": { "title": "Dashboard", @@ -2089,7 +2090,8 @@ "stale_route_description": "This frontend may not include the requested page. Hard refresh to load the latest version and try again.", "stale_route_shortcut": "Hard refresh: Cmd+Shift+R on macOS; Ctrl+Shift+R on Windows/Linux.", "stale_route_reload": "Reload page", - "nav_ip_access": "IP Access Control" + "nav_ip_access": "IP Access Control", + "nav_video_generation": "Video Models" }, "proxies": { "title": "Proxy Management", @@ -5441,5 +5443,51 @@ "portal_no_logins": "No sign-ins in the last 30 days", "chain_title": "Forwarding chain for this request (rightmost is the hop talking to this service)", "chain_hint": "Every hop must be declared before resolution reaches the real client on the left. Currently resolving to: {{client}}. A loopback result means one hop is still undeclared." + }, + "video_generation": { + "title": "Video Models", + "description": "See how to call Grok Imagine text-to-video and image-to-video, and verify the pipeline from the test panel.", + "call_title": "How to call", + "call_description": "Use an API key from the API Keys page. Generation is asynchronous: submit, then poll the returned request_id.", + "text_to_video_title": "Text to video", + "text_to_video_desc": "Prompt only. The model renders a first frame and animates it — good for creating from scratch.", + "image_to_video_title": "Image to video", + "image_to_video_desc": "Supply a source image as the first frame and describe the motion with a prompt.", + "request_params_title": "Request parameters", + "response_schema_title": "Response schema", + "table_param": "Field", + "table_type": "Type", + "table_required": "Required", + "table_description": "Description", + "table_default": "default", + "status_endpoint_hint": "Poll GET {{path}}. Read video.url once status is done; failed / expired are terminal.", + "param_model_desc": "Video model id, for example grok-imagine-video-1.5.", + "param_prompt_desc": "Describes the scene and camera motion to generate.", + "param_image_prompt_desc": "Describes how the source image should move, e.g. camera push or element motion.", + "param_image_desc": "Source image as {\"url\": \"...\"}. A plain URL string or data URI is also accepted and normalized server-side.", + "param_duration_desc": "Clip length in seconds; the ceiling depends on the model.", + "param_aspect_ratio_desc": "Aspect ratio, such as 16:9, 9:16 or 1:1.", + "param_resolution_desc": "Resolution: 480p, 720p or 1080p.", + "response_request_id_desc": "Job id returned on submission, used for polling.", + "response_status_desc": "Job status: pending, done, failed or expired.", + "response_video_url_desc": "URL of the finished clip.", + "response_video_duration_desc": "Length of the generated clip in seconds.", + "test_button": "Test generation", + "test_title": "Test video generation", + "test_submit": "Generate", + "test_running": "Generating", + "test_running_hint": "Video generation usually takes 1-3 minutes; keep this page open", + "test_failed_generic": "Video generation failed", + "test_model_required": "Select a video model first", + "test_prompt_required": "Enter a prompt", + "test_image_required": "Image-to-video needs a source image URL", + "field_model": "Model", + "field_prompt": "Prompt", + "field_prompt_placeholder": "e.g. ocean waves at sunset, camera slowly pulling back", + "field_image_url": "Source image URL (image-to-video)", + "field_duration": "Duration (seconds, max {{max}})", + "field_aspect_ratio": "Aspect ratio", + "field_resolution": "Resolution", + "result_open_original": "Open the original clip in a new tab" } } diff --git a/packages/i18n/src/locales/zh-CN.json b/packages/i18n/src/locales/zh-CN.json index 12f82a5d..ecfc52c3 100644 --- a/packages/i18n/src/locales/zh-CN.json +++ b/packages/i18n/src/locales/zh-CN.json @@ -187,7 +187,8 @@ "system_info": "中心信息", "monitor": "监控中心", "menuManagement": "菜单管理", - "content_moderation": "内容审核" + "content_moderation": "内容审核", + "videoGeneration": "视频模型" }, "dashboard": { "title": "仪表盘", @@ -2106,7 +2107,8 @@ "stale_route_description": "当前前端可能没有这个页面。请硬刷新后重试,以加载最新版本。", "stale_route_shortcut": "硬刷新:macOS 按 Cmd+Shift+R;Windows/Linux 按 Ctrl+Shift+R。", "stale_route_reload": "重新加载页面", - "nav_ip_access": "IP 访问控制" + "nav_ip_access": "IP 访问控制", + "nav_video_generation": "视频模型" }, "proxies": { "title": "代理管理", @@ -5455,5 +5457,51 @@ "portal_no_logins": "近 30 天没有登录记录", "chain_title": "当前请求的转发链(右侧为直连本服务的一跳)", "chain_hint": "每一跳都要声明为可信代理,解析才能走到最左侧的真实客户端。当前解析结果:{{client}}。若结果落在回环地址,说明还有一跳没声明。" + }, + "video_generation": { + "title": "视频模型", + "description": "查看 Grok Imagine 的文生视频 / 图生视频调用方式,并通过测试面板验证生成链路。", + "call_title": "调用方式", + "call_description": "使用 API Keys 页面配置的 API Key 调用视频接口;生成是异步的,提交后拿 request_id 轮询结果。", + "text_to_video_title": "文生视频", + "text_to_video_desc": "只给提示词,模型先生成首帧再动起来,适合从零创作。", + "image_to_video_title": "图生视频", + "image_to_video_desc": "提供一张源图作为首帧,用提示词描述镜头与动作。", + "request_params_title": "请求参数", + "response_schema_title": "返回结构", + "table_param": "字段", + "table_type": "类型", + "table_required": "必填", + "table_description": "说明", + "table_default": "默认", + "status_endpoint_hint": "轮询接口:GET {{path}};status 为 done 时取 video.url,failed / expired 表示终止。", + "param_model_desc": "视频模型 ID,例如 grok-imagine-video-1.5。", + "param_prompt_desc": "描述要生成的画面与镜头运动。", + "param_image_prompt_desc": "描述源图要如何动起来,例如镜头推拉、元素运动。", + "param_image_desc": "源图,传 {\"url\": \"...\"} 对象;也接受直接传 URL 字符串或 data URI,服务端会归一化。", + "param_duration_desc": "视频时长(秒),上限取决于模型。", + "param_aspect_ratio_desc": "画面比例,如 16:9、9:16、1:1。", + "param_resolution_desc": "分辨率:480p、720p、1080p。", + "response_request_id_desc": "提交成功后返回的任务 ID,用于轮询。", + "response_status_desc": "任务状态:pending、done、failed、expired。", + "response_video_url_desc": "生成完成后的视频地址。", + "response_video_duration_desc": "实际生成的视频时长(秒)。", + "test_button": "测试生成", + "test_title": "测试视频生成", + "test_submit": "开始生成", + "test_running": "生成中", + "test_running_hint": "视频生成通常需要 1-3 分钟,请保持页面打开", + "test_failed_generic": "视频生成失败", + "test_model_required": "请先选择视频模型", + "test_prompt_required": "请填写提示词", + "test_image_required": "图生视频需要提供源图地址", + "field_model": "模型", + "field_prompt": "提示词", + "field_prompt_placeholder": "例如:日落时分的海浪,镜头缓慢拉远", + "field_image_url": "源图地址(图生视频)", + "field_duration": "时长(秒,最多 {{max}})", + "field_aspect_ratio": "画面比例", + "field_resolution": "分辨率", + "result_open_original": "在新标签页打开原始视频" } } diff --git a/packages/ui/src/code/CodeBlock.tsx b/packages/ui/src/code/CodeBlock.tsx new file mode 100644 index 00000000..baefdd58 --- /dev/null +++ b/packages/ui/src/code/CodeBlock.tsx @@ -0,0 +1,63 @@ +import { useMemo, type ReactNode } from "react"; +import { highlightSnippet, TOKEN_CLASS, type SnippetLanguage } from "./highlightSnippet"; + +/** + * Dark, syntax-highlighted code block. + * + * Shared by the landing page and the media-model pages so a curl example reads the + * same everywhere. The tokenizer is the tiny hand-written one in highlightSnippet: + * pulling in react-syntax-highlighter for these fixed snippets would drag the + * 790KB vendor-markdown chunk into pages that need one code block. + */ +export function CodeBlock({ + code, + language = "shell", + label, + action, + className, +}: { + code: string; + language?: SnippetLanguage; + /** Small caption in the block header, e.g. "curl". */ + label?: ReactNode; + /** Optional trailing control, typically a copy button. */ + action?: ReactNode; + className?: string; +}) { + const highlighted = useMemo(() => highlightSnippet(code, language), [code, language]); + + return ( +
+ {label || action ? ( +
+ + {label} + + {action} +
+ ) : null} +
+        
+          {highlighted.map((tokens, lineIndex) => (
+            
+              {/* An empty line has no tokens; a zero-width space keeps its height. */}
+              {tokens.length === 0 ? "​" : null}
+              {tokens.map((token, tokenIndex) => (
+                
+                  {token.text}
+                
+              ))}
+            
+          ))}
+        
+      
+
+ ); +} diff --git a/pages/api-key-lookup/components/landing/highlightSnippet.ts b/packages/ui/src/code/highlightSnippet.ts similarity index 100% rename from pages/api-key-lookup/components/landing/highlightSnippet.ts rename to packages/ui/src/code/highlightSnippet.ts diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index a0236f4f..8f15f522 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -111,6 +111,9 @@ export { useLocalStorage } from "./hooks/useLocalStorage"; export { useResizeLayoutAnimation } from "./hooks/useResizeLayoutAnimation"; export { copyTextToClipboard } from "./utils/clipboard"; +export { CodeBlock } from "./code/CodeBlock"; +export { highlightSnippet, TOKEN_CLASS } from "./code/highlightSnippet"; +export type { CodeToken, SnippetLanguage, TokenKind } from "./code/highlightSnippet"; export { SecretRevealModal } from "./overlays/SecretRevealModal"; export { type ControlSize, diff --git a/pages/api-key-lookup/components/landing/LandingWorkflow.tsx b/pages/api-key-lookup/components/landing/LandingWorkflow.tsx index faf13d77..fd63edfd 100644 --- a/pages/api-key-lookup/components/landing/LandingWorkflow.tsx +++ b/pages/api-key-lookup/components/landing/LandingWorkflow.tsx @@ -1,8 +1,12 @@ import { useCallback, useMemo, useState } from "react"; import { motion } from "framer-motion"; import { Check, Copy } from "lucide-react"; -import { copyTextToClipboard } from "@code-proxy/ui"; -import { highlightSnippet, TOKEN_CLASS, type SnippetLanguage } from "./highlightSnippet"; +import { + copyTextToClipboard, + highlightSnippet, + TOKEN_CLASS, + type SnippetLanguage, +} from "@code-proxy/ui"; import { LANDING_EASE, useLandingFade } from "./landingMotion"; import { LandingSectionHead } from "./LandingSectionHead"; import type { LandingCopy } from "./landingCopy"; diff --git a/pages/image-generation/__tests__/ImageGenerationPage.test.tsx b/pages/image-generation/__tests__/ImageGenerationPage.test.tsx index 4d9f01a7..a745cb26 100644 --- a/pages/image-generation/__tests__/ImageGenerationPage.test.tsx +++ b/pages/image-generation/__tests__/ImageGenerationPage.test.tsx @@ -77,7 +77,11 @@ describe("ImageGenerationPage", () => { expect(screen.getByRole("tab", { name: "图生图" })).toBeInTheDocument(); expect(within(callCard as HTMLElement).getByText("POST")).toBeInTheDocument(); expect(within(callCard as HTMLElement).getByText("/v1/images/generations")).toBeInTheDocument(); - const textCurl = screen.getByText(/curl http:\/\/127\.0\.0\.1:8317\/v1\/images\/generations/); + // The snippet is syntax-highlighted, so its text is split across token spans: + // assert on the block's textContent rather than on a single text node. + const textCurl = document.querySelector("[data-code-block]") as HTMLElement; + expect(textCurl).not.toBeNull(); + expect(textCurl.textContent).toContain("curl http://127.0.0.1:8317/v1/images/generations"); expect( textCurl.compareDocumentPosition(screen.getByText("请求参数")) & Node.DOCUMENT_POSITION_FOLLOWING, @@ -98,8 +102,9 @@ describe("ImageGenerationPage", () => { expect(screen.getByText("size")).toBeInTheDocument(); expect(screen.getByText("quality")).toBeInTheDocument(); expect(screen.getByText("n")).toBeInTheDocument(); - expect(screen.getByText(/"size": "1024x1024"/)).toBeInTheDocument(); - expect(screen.getByText(/"quality": "high"/)).toBeInTheDocument(); + // Same reason as above: the highlighted snippet has no single node holding these. + expect(textCurl.textContent).toContain('"size": "1024x1024"'); + expect(textCurl.textContent).toContain('"quality": "high"'); expect(screen.queryByText("BaseURL")).not.toBeInTheDocument(); expect(screen.getByText(/Authorization: Bearer YOUR_API_KEY/)).toBeInTheDocument(); expect(within(callCard as HTMLElement).getByRole("button", { name: "测试生成" })).toBeEnabled(); diff --git a/pages/image-generation/components/ImageGenerationPageContent.tsx b/pages/image-generation/components/ImageGenerationPageContent.tsx index 985b0f16..5f63380a 100644 --- a/pages/image-generation/components/ImageGenerationPageContent.tsx +++ b/pages/image-generation/components/ImageGenerationPageContent.tsx @@ -3,6 +3,7 @@ import { ArrowUp, ChevronLeft, ChevronRight, CircleAlert, Plus, Trash2, X } from import { useTranslation } from "react-i18next"; import { imageGenerationApi } from "@code-proxy/api-client"; import { Button, COLUMN_WIDTH, surface } from "@code-proxy/ui"; +import { CodeBlock } from "@code-proxy/ui"; import { Card } from "@code-proxy/ui"; import { ImagePreviewOverlay } from "@code-proxy/ui"; import { Modal } from "@code-proxy/ui"; @@ -259,14 +260,11 @@ function EndpointCallDoc({ doc }: { doc: EndpointDoc }) { -
-
- curl -
-
-          {doc.curl}
-        
-
+ ); } diff --git a/pages/registry.ts b/pages/registry.ts index c5d0664d..12edc0ab 100644 --- a/pages/registry.ts +++ b/pages/registry.ts @@ -19,6 +19,7 @@ import { systemRoute } from "./system/route"; import { proxiesRoute } from "./proxies/route"; import { identityFingerprintRoute } from "./identity-fingerprint/route"; import { imageGenerationRoute } from "./image-generation/route"; +import { videoGenerationRoute } from "./video-generation/route"; import { ccswitchImportSettingsRoute } from "./ccswitch-import-settings/route"; import { apiKeyLookupRoute } from "./api-key-lookup/route"; import { apiKeyUsageRoute } from "./api-key-usage/route"; @@ -74,6 +75,7 @@ export const pageRoutes: PageRoute[] = [ proxiesRoute, identityFingerprintRoute, imageGenerationRoute, + videoGenerationRoute, ccswitchImportSettingsRoute, apiKeyLookupRoute, apiKeyUsageRoute, diff --git a/pages/video-generation/VideoGenerationPage.tsx b/pages/video-generation/VideoGenerationPage.tsx new file mode 100644 index 00000000..e8b5b818 --- /dev/null +++ b/pages/video-generation/VideoGenerationPage.tsx @@ -0,0 +1,5 @@ +import { VideoGenerationPageContent } from "./components/VideoGenerationPageContent"; + +export function VideoGenerationPage() { + return ; +} diff --git a/pages/video-generation/__tests__/VideoGenerationPage.test.tsx b/pages/video-generation/__tests__/VideoGenerationPage.test.tsx new file mode 100644 index 00000000..2aa94f6f --- /dev/null +++ b/pages/video-generation/__tests__/VideoGenerationPage.test.tsx @@ -0,0 +1,117 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import i18n from "@code-proxy/i18n"; +import { videoGenerationApi } from "@code-proxy/api-client"; +import { ThemeProvider, ToastProvider } from "@code-proxy/ui"; +import { VideoGenerationPage } from "../VideoGenerationPage"; + +const getModelsMock = () => videoGenerationApi.getModels as unknown as ReturnType; +const startTaskMock = () => videoGenerationApi.startTestTask as unknown as ReturnType; +const getTaskMock = () => videoGenerationApi.getTestTask as unknown as ReturnType; + +const videoModel = { + id: "grok-imagine-video-1.5", + provider: "xai", + display_name: "Grok Imagine Video", + description: "Grok Imagine text-to-video and image-to-video generation.", + supports_image_to_video: true, + max_duration_seconds: 15, +}; + +function renderPage() { + return render( + + + + + + + , + ); +} + +describe("VideoGenerationPage", () => { + beforeEach(async () => { + await i18n.changeLanguage("zh-CN"); + vi.restoreAllMocks(); + vi.spyOn(videoGenerationApi, "getModels"); + vi.spyOn(videoGenerationApi, "startTestTask"); + vi.spyOn(videoGenerationApi, "getTestTask"); + getModelsMock().mockResolvedValue({ models: [videoModel] }); + startTaskMock().mockResolvedValue({ task_id: "task-1", status: "queued" }); + getTaskMock().mockResolvedValue({ task_id: "task-1", status: "queued" }); + }); + + test("documents the two-step async call for text and image modes", async () => { + const user = userEvent.setup(); + renderPage(); + + expect(await screen.findByRole("heading", { name: "视频模型" })).toBeInTheDocument(); + + // The snippet is syntax-highlighted, so its text lives across token spans. + const codeBlock = document.querySelector("[data-code-block]") as HTMLElement; + expect(codeBlock.textContent).toContain("curl http://127.0.0.1:8317/v1/videos/generations"); + // The polling half is the part callers miss; it must be in the example. + expect(codeBlock.textContent).toContain("/v1/videos/$REQUEST_ID"); + + await user.click(screen.getByRole("tab", { name: "图生视频" })); + + await waitFor(() => { + const imageBlock = document.querySelector("[data-code-block]") as HTMLElement; + expect(imageBlock.textContent).toContain('"image": { "url"'); + }); + }); + + test("offers the catalog's video models and submits a generation task", async () => { + const user = userEvent.setup(); + renderPage(); + + await user.click(await screen.findByRole("button", { name: "测试生成" })); + + await waitFor(() => expect(screen.getByText("测试视频生成")).toBeInTheDocument()); + await user.type(screen.getByPlaceholderText(/日落时分的海浪/), "海浪"); + await user.click(screen.getByRole("button", { name: "开始生成" })); + + await waitFor(() => expect(startTaskMock()).toHaveBeenCalled()); + const payload = startTaskMock().mock.calls[0][0] as Record; + expect(payload.model).toBe("grok-imagine-video-1.5"); + expect(payload.prompt).toBe("海浪"); + expect(payload.duration).toBeGreaterThan(0); + }); + + test("refuses to submit without a prompt", async () => { + const user = userEvent.setup(); + renderPage(); + + await user.click(await screen.findByRole("button", { name: "测试生成" })); + await waitFor(() => expect(screen.getByText("测试视频生成")).toBeInTheDocument()); + await user.click(screen.getByRole("button", { name: "开始生成" })); + + expect(startTaskMock()).not.toHaveBeenCalled(); + // The toast renders both a visible node and a live-region copy for screen + // readers, so match on presence rather than uniqueness. + expect((await screen.findAllByText("请填写提示词")).length).toBeGreaterThan(0); + }); + + test("plays the clip once the task finishes", async () => { + const user = userEvent.setup(); + getTaskMock().mockResolvedValue({ + task_id: "task-1", + status: "succeeded", + result: { status: "done", video: { url: "https://vidgen.example/clip.mp4", duration: 6 } }, + }); + renderPage(); + + await user.click(await screen.findByRole("button", { name: "测试生成" })); + await waitFor(() => expect(screen.getByText("测试视频生成")).toBeInTheDocument()); + await user.type(screen.getByPlaceholderText(/日落时分的海浪/), "海浪"); + await user.click(screen.getByRole("button", { name: "开始生成" })); + + await waitFor(() => { + const video = document.querySelector("video"); + expect(video?.getAttribute("src")).toBe("https://vidgen.example/clip.mp4"); + }); + }); +}); diff --git a/pages/video-generation/components/VideoGenerationPageContent.tsx b/pages/video-generation/components/VideoGenerationPageContent.tsx new file mode 100644 index 00000000..a4d038f1 --- /dev/null +++ b/pages/video-generation/components/VideoGenerationPageContent.tsx @@ -0,0 +1,421 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { CircleAlert } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { + videoGenerationApi, + type VideoGenerationModel, + type VideoGenerationTestResponse, +} from "@code-proxy/api-client"; +import { + Button, + Card, + CodeBlock, + DataTable, + Modal, + Select, + Tabs, + TabsList, + TabsTrigger, + useToast, + type DataTableColumn, +} from "@code-proxy/ui"; +import { + VIDEO_ASPECT_RATIOS, + VIDEO_ENDPOINT_DOCS, + VIDEO_RESOLUTIONS, + VIDEO_STATUS_PATH, + type SpecRow, + type VideoEndpointDoc, +} from "./apiDocs"; + +const TASK_POLL_INTERVAL_MS = 2000; +const DEFAULT_DURATION = 6; + +type TestState = { + running: boolean; + phase: string; + result: VideoGenerationTestResponse | null; + error: string | null; +}; + +const emptyTestState: TestState = { running: false, phase: "", result: null, error: null }; + +export function VideoGenerationPageContent() { + const { t } = useTranslation(); + const { notify } = useToast(); + + const [models, setModels] = useState([]); + const [modelsError, setModelsError] = useState(null); + const [mode, setMode] = useState("text"); + const [testOpen, setTestOpen] = useState(false); + const [test, setTest] = useState(emptyTestState); + + const [model, setModel] = useState(""); + const [prompt, setPrompt] = useState(""); + const [imageUrl, setImageUrl] = useState(""); + const [duration, setDuration] = useState(DEFAULT_DURATION); + const [aspectRatio, setAspectRatio] = useState(VIDEO_ASPECT_RATIOS[0]); + const [resolution, setResolution] = useState(VIDEO_RESOLUTIONS[1]); + + const pollTimer = useRef(null); + + useEffect(() => { + let cancelled = false; + void videoGenerationApi + .getModels() + .then((response) => { + if (cancelled) return; + const items = response.models ?? []; + setModels(items); + setModel((current) => current || (items[0]?.id ?? "")); + }) + .catch((error: unknown) => { + if (cancelled) return; + setModelsError(error instanceof Error ? error.message : String(error)); + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect( + () => () => { + if (pollTimer.current !== null) window.clearTimeout(pollTimer.current); + }, + [], + ); + + const doc = useMemo( + () => VIDEO_ENDPOINT_DOCS.find((entry) => entry.mode === mode) ?? VIDEO_ENDPOINT_DOCS[0], + [mode], + ); + const selectedModel = useMemo( + () => models.find((entry) => entry.id === model), + [model, models], + ); + const maxDuration = selectedModel?.max_duration_seconds || 15; + + const pollTask = useCallback( + (taskId: string) => { + void videoGenerationApi + .getTestTask(taskId) + .then((task) => { + if (task.status === "succeeded") { + setTest({ running: false, phase: "", result: task.result ?? null, error: null }); + return; + } + if (task.status === "failed") { + const message = + task.error?.body?.error?.message ?? t("video_generation.test_failed_generic"); + setTest({ running: false, phase: "", result: null, error: message }); + return; + } + setTest((current) => ({ ...current, phase: task.phase ?? task.status })); + pollTimer.current = window.setTimeout(() => pollTask(taskId), TASK_POLL_INTERVAL_MS); + }) + .catch((error: unknown) => { + setTest({ + running: false, + phase: "", + result: null, + error: error instanceof Error ? error.message : String(error), + }); + }); + }, + [t], + ); + + const handleRunTest = useCallback(() => { + if (!model.trim()) { + notify({ type: "warning", message: t("video_generation.test_model_required") }); + return; + } + if (!prompt.trim()) { + notify({ type: "warning", message: t("video_generation.test_prompt_required") }); + return; + } + if (mode === "image" && !imageUrl.trim()) { + notify({ type: "warning", message: t("video_generation.test_image_required") }); + return; + } + + setTest({ running: true, phase: "queued", result: null, error: null }); + void videoGenerationApi + .startTestTask({ + model, + prompt, + duration, + aspect_ratio: aspectRatio, + resolution, + ...(mode === "image" && imageUrl.trim() ? { image: imageUrl.trim() } : {}), + }) + .then((task) => pollTask(task.task_id)) + .catch((error: unknown) => { + setTest({ + running: false, + phase: "", + result: null, + error: error instanceof Error ? error.message : String(error), + }); + }); + }, [aspectRatio, duration, imageUrl, mode, model, pollTask, prompt, resolution, notify, t]); + + const modelOptions = useMemo( + () => + models.map((entry) => ({ + value: entry.id, + label: entry.display_name ? `${entry.display_name} · ${entry.id}` : entry.id, + })), + [models], + ); + + return ( +
+
+

+ {t("video_generation.title")} +

+

+ {t("video_generation.description")} +

+
+ + +
+
+

+ {t("video_generation.call_title")} +

+

+ {t("video_generation.call_description")} +

+
+ +
+ + {modelsError ? ( +

+ + {modelsError} +

+ ) : null} + +
+ setMode(value as VideoEndpointDoc["mode"])}> + + {VIDEO_ENDPOINT_DOCS.map((entry) => ( + + {t(`video_generation.${entry.titleKey}`)} + + ))} + + +
+ +
+
+
+

+ {t(`video_generation.${doc.titleKey}`)} +

+

+ {t(`video_generation.${doc.descriptionKey}`)} +

+
+ + + {doc.method} + + {doc.path} + +
+
+ + + +

+ {t("video_generation.status_endpoint_hint", { path: VIDEO_STATUS_PATH })} +

+ + + +
+ + setTestOpen(false)} + title={t("video_generation.test_title")} + maxWidth="max-w-[720px]" + > +
+