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.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]"
+ >
+
+
+
+
+
+ {mode === "image" || selectedModel?.supports_image_to_video ? (
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+ {test.running ? (
+
+ {t("video_generation.test_running_hint")}
+ {test.phase ? ` · ${test.phase}` : ""}
+
+ ) : null}
+
+
+ {test.error ? (
+
+
+ {test.error}
+
+ ) : null}
+
+ {test.result?.video?.url ? (
+
+ ) : null}
+
+
+
+ );
+}
+
+function SpecTable({
+ tableId,
+ title,
+ rows,
+}: {
+ tableId: string;
+ title: string;
+ rows: SpecRow[];
+}) {
+ const { t } = useTranslation();
+ const columns = useMemo[]>(
+ () => [
+ {
+ key: "name",
+ label: t("video_generation.table_param"),
+ render: (row) => {row.name},
+ },
+ {
+ key: "type",
+ label: t("video_generation.table_type"),
+ render: (row) => {row.type},
+ },
+ {
+ key: "required",
+ label: t("video_generation.table_required"),
+ render: (row) => (row.required ? t("common.yes") : t("common.no")),
+ },
+ {
+ key: "description",
+ label: t("video_generation.table_description"),
+ render: (row) => (
+
+ {t(`video_generation.${row.descriptionKey}`)}
+ {row.defaultValue ? ` (${t("video_generation.table_default")}: ${row.defaultValue})` : ""}
+
+ ),
+ },
+ ],
+ [t],
+ );
+
+ return (
+
+ {title}
+ row.name} />
+
+ );
+}
diff --git a/pages/video-generation/components/apiDocs.ts b/pages/video-generation/components/apiDocs.ts
new file mode 100644
index 00000000..6ab42ad2
--- /dev/null
+++ b/pages/video-generation/components/apiDocs.ts
@@ -0,0 +1,141 @@
+/**
+ * Static API reference shown on the video-generation page.
+ *
+ * Pure data: entries carry translation keys rather than translated strings, so this
+ * module stays free of React and i18n wiring.
+ *
+ * The two-step shape is deliberate and mirrors the upstream: a clip takes minutes
+ * to render, so the submit call answers with a request id and the caller polls.
+ */
+
+export type SpecRow = {
+ name: string;
+ type: string;
+ required: boolean;
+ descriptionKey: string;
+ defaultValue?: string;
+};
+
+export type VideoEndpointDoc = {
+ mode: "text" | "image";
+ titleKey: string;
+ descriptionKey: string;
+ method: string;
+ path: string;
+ contentType: string;
+ requestRows: SpecRow[];
+ responseRows: SpecRow[];
+ curl: string;
+};
+
+export const VIDEO_STATUS_PATH = "/v1/videos/{request_id}";
+
+const textToVideoCurl = [
+ "# 1. 提交生成任务,拿到 request_id",
+ "curl http://127.0.0.1:8317/v1/videos/generations \\",
+ ' -H "Authorization: Bearer $API_KEY" \\',
+ ' -H "Content-Type: application/json" \\',
+ " -d '{",
+ ' "model": "grok-imagine-video-1.5",',
+ ' "prompt": "日落时分的海浪,镜头缓慢拉远",',
+ ' "duration": 10,',
+ ' "aspect_ratio": "16:9",',
+ ' "resolution": "720p"',
+ " }'",
+ "",
+ "# 2. 轮询任务状态,status 为 done 时取 video.url",
+ 'curl http://127.0.0.1:8317/v1/videos/$REQUEST_ID \\',
+ ' -H "Authorization: Bearer $API_KEY"',
+].join("\n");
+
+const imageToVideoCurl = [
+ "# 图生视频:附上源图,模型以它作为首帧",
+ "curl http://127.0.0.1:8317/v1/videos/generations \\",
+ ' -H "Authorization: Bearer $API_KEY" \\',
+ ' -H "Content-Type: application/json" \\',
+ " -d '{",
+ ' "model": "grok-imagine-video-1.5",',
+ ' "prompt": "让瀑布流动起来,镜头缓慢拉远",',
+ ' "image": { "url": "https://example.com/still.png" },',
+ ' "duration": 12',
+ " }'",
+ "",
+ "# 同样轮询 request_id",
+ 'curl http://127.0.0.1:8317/v1/videos/$REQUEST_ID \\',
+ ' -H "Authorization: Bearer $API_KEY"',
+].join("\n");
+
+const sharedResponseRows: SpecRow[] = [
+ { name: "request_id", type: "string", required: false, descriptionKey: "response_request_id_desc" },
+ { name: "status", type: "string", required: false, descriptionKey: "response_status_desc" },
+ { name: "video.url", type: "string", required: false, descriptionKey: "response_video_url_desc" },
+ {
+ name: "video.duration",
+ type: "number",
+ required: false,
+ descriptionKey: "response_video_duration_desc",
+ },
+];
+
+export const VIDEO_ENDPOINT_DOCS: VideoEndpointDoc[] = [
+ {
+ mode: "text",
+ titleKey: "text_to_video_title",
+ descriptionKey: "text_to_video_desc",
+ method: "POST",
+ path: "/v1/videos/generations",
+ contentType: "application/json",
+ requestRows: [
+ { name: "model", type: "string", required: true, descriptionKey: "param_model_desc" },
+ { name: "prompt", type: "string", required: true, descriptionKey: "param_prompt_desc" },
+ {
+ name: "duration",
+ type: "number",
+ required: false,
+ descriptionKey: "param_duration_desc",
+ defaultValue: "6",
+ },
+ {
+ name: "aspect_ratio",
+ type: "string",
+ required: false,
+ descriptionKey: "param_aspect_ratio_desc",
+ defaultValue: "16:9",
+ },
+ {
+ name: "resolution",
+ type: "string",
+ required: false,
+ descriptionKey: "param_resolution_desc",
+ defaultValue: "480p",
+ },
+ ],
+ responseRows: sharedResponseRows,
+ curl: textToVideoCurl,
+ },
+ {
+ mode: "image",
+ titleKey: "image_to_video_title",
+ descriptionKey: "image_to_video_desc",
+ method: "POST",
+ path: "/v1/videos/generations",
+ contentType: "application/json",
+ requestRows: [
+ { name: "model", type: "string", required: true, descriptionKey: "param_model_desc" },
+ { name: "prompt", type: "string", required: true, descriptionKey: "param_image_prompt_desc" },
+ { name: "image", type: "object | string", required: true, descriptionKey: "param_image_desc" },
+ {
+ name: "duration",
+ type: "number",
+ required: false,
+ descriptionKey: "param_duration_desc",
+ defaultValue: "6",
+ },
+ ],
+ responseRows: sharedResponseRows,
+ curl: imageToVideoCurl,
+ },
+];
+
+export const VIDEO_ASPECT_RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4", "3:2", "2:3"];
+export const VIDEO_RESOLUTIONS = ["480p", "720p", "1080p"];
diff --git a/pages/video-generation/index.ts b/pages/video-generation/index.ts
new file mode 100644
index 00000000..9a7f7ff3
--- /dev/null
+++ b/pages/video-generation/index.ts
@@ -0,0 +1,2 @@
+export { VideoGenerationPage } from "./VideoGenerationPage";
+export { videoGenerationRoute } from "./route";
diff --git a/pages/video-generation/route.tsx b/pages/video-generation/route.tsx
new file mode 100644
index 00000000..ee25d2c8
--- /dev/null
+++ b/pages/video-generation/route.tsx
@@ -0,0 +1,18 @@
+import { preloadablePage } from "../preloadablePage";
+
+const { Page: VideoGenerationPage, preload: preloadVideoGenerationPage } = preloadablePage(() =>
+ import("./VideoGenerationPage").then((m) => ({
+ default: m.VideoGenerationPage,
+ })),
+);
+
+export const videoGenerationRoute = {
+ path: "/models/video-generation",
+ component: "video-generation",
+ element: ,
+ auth: true,
+ layout: "dashboard",
+ nav: { labelKey: "nav.videoGeneration" },
+ requiredPermission: "system.config.read",
+ preload: preloadVideoGenerationPage,
+};
diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json
index fb8039d7..dd0653d7 100644
--- a/scripts/file-size-baseline.json
+++ b/scripts/file-size-baseline.json
@@ -23,7 +23,7 @@
"pages/auth-files/hooks/useAuthFilesStatusState.ts": 1298,
"pages/end-users/EndUsersPage.tsx": 1126,
"pages/identity-fingerprint/IdentityFingerprintPage.tsx": 1880,
- "pages/image-generation/components/ImageGenerationPageContent.tsx": 1193,
+ "pages/image-generation/components/ImageGenerationPageContent.tsx": 1191,
"pages/models/ModelsPage.tsx": 1251,
"pages/providers/components/ProvidersPageContent.tsx": 1845
}
From ce8f26a898f74a4fac2d3b45dc68cbdf598a73f1 Mon Sep 17 00:00:00 2001
From: kittors
Date: Mon, 17 Aug 2026 22:42:50 +0800
Subject: [PATCH 3/6] test(video): cover the video page in a real browser
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The unit tests run in jsdom, which has no layout and no CSS, so neither the
highlighted snippet nor the model catalog wiring was actually exercised. A
browser spec caught the latter: the catch-all management mock shadowed the models
route (Playwright matches the most recently registered route first), leaving the
test panel permanently disabled — the same shape of failure a real deployment
would show if the endpoint 404'd.
Not tagged @critical, so it stays out of the per-PR smoke.
Co-Authored-By: Claude Opus 5
---
e2e/video-generation-page.spec.ts | 134 ++++++++++++++++++++++++++++++
1 file changed, 134 insertions(+)
create mode 100644 e2e/video-generation-page.spec.ts
diff --git a/e2e/video-generation-page.spec.ts b/e2e/video-generation-page.spec.ts
new file mode 100644
index 00000000..7cda0cba
--- /dev/null
+++ b/e2e/video-generation-page.spec.ts
@@ -0,0 +1,134 @@
+import { expect, test, type Page } from "@playwright/test";
+
+/**
+ * Renders the video models page against mocked management APIs.
+ *
+ * Not marked @critical: the page's behaviour is covered by unit tests, and this
+ * spec exists so the layout — highlighted curl block, endpoint switch, spec
+ * tables — is exercised in a real browser rather than only in jsdom.
+ */
+
+const VIDEO_MODEL = {
+ 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,
+};
+
+const seedAuth = async (page: Page) => {
+ await page.addInitScript(() => {
+ sessionStorage.setItem(
+ "code-proxy-admin-auth",
+ JSON.stringify({
+ apiBase: "http://127.0.0.1:8317",
+ managementKey: "cps_test",
+ rememberPassword: false,
+ expiresAt: Date.now() + 60_000,
+ }),
+ );
+ localStorage.setItem(
+ "cli-proxy-language",
+ JSON.stringify({ language: "zh-CN", state: { language: "zh-CN" } }),
+ );
+ });
+};
+
+const mockApis = async (page: Page) => {
+ const tenant = {
+ id: "t-system",
+ slug: "system",
+ name: "System Administration",
+ type: "system",
+ status: "active",
+ effective_status: "active",
+ expires_at: null,
+ description: "",
+ version: 1,
+ created_at: "",
+ updated_at: "",
+ };
+ await page.route("**/v0/auth/me", (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ principal: {
+ kind: "user_session",
+ user: {
+ id: "u-admin",
+ tenant_id: "t-system",
+ username: "admin",
+ display_name: "Super Administrator",
+ status: "active",
+ must_change_password: false,
+ last_login_at: null,
+ role_ids: ["r-platform-admin"],
+ role_codes: ["platform_super_admin"],
+ version: 1,
+ created_at: "",
+ updated_at: "",
+ },
+ home_tenant: tenant,
+ effective_tenant: tenant,
+ roles: [],
+ permissions: ["system.config.read", "dashboard.read"],
+ platform_admin: true,
+ },
+ }),
+ }),
+ );
+ // Order matters: Playwright tries the most recently registered route first, so
+ // the catch-all has to be registered before the specific one it must not shadow.
+ await page.route("**/v0/management/**", (route) =>
+ route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
+ );
+ await page.route("**/v0/management/video-generation/models", (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ models: [VIDEO_MODEL] }),
+ }),
+ );
+};
+
+test("renders the video call docs with a highlighted snippet", async ({ page }) => {
+ await seedAuth(page);
+ await mockApis(page);
+ await page.goto("/#/models/video-generation");
+
+ await expect(page.getByRole("heading", { name: "视频模型" })).toBeVisible();
+
+ const codeBlock = page.locator("[data-code-block]").first();
+ await expect(codeBlock).toContainText("curl http://127.0.0.1:8317/v1/videos/generations");
+ await expect(codeBlock).toContainText("/v1/videos/$REQUEST_ID");
+
+ // Highlighting must produce coloured token spans, not one flat text node.
+ const tokenColours = await codeBlock.evaluate((element) => {
+ const spans = [...element.querySelectorAll("span")];
+ return new Set(spans.map((span) => getComputedStyle(span).color)).size;
+ });
+ expect(tokenColours).toBeGreaterThan(2);
+
+ await page.getByRole("tab", { name: "图生视频" }).click();
+ await expect(page.locator("[data-code-block]").first()).toContainText('"image": { "url"');
+
+ await expect(page.getByText("请求参数")).toBeVisible();
+ await expect(page.getByText("返回结构")).toBeVisible();
+});
+
+test("enables the test panel from the served model catalog", async ({ page }) => {
+ await seedAuth(page);
+ await mockApis(page);
+ await page.goto("/#/models/video-generation");
+
+ // The button stays disabled until the catalog answers, which is what made the
+ // panel unreachable when the models call was shadowed by a catch-all mock.
+ const testButton = page.getByRole("button", { name: "测试生成" });
+ await expect(testButton).toBeEnabled();
+
+ await testButton.click();
+ await expect(page.getByText("测试视频生成")).toBeVisible();
+ await expect(page.getByText(VIDEO_MODEL.id, { exact: false }).first()).toBeVisible();
+});
From 26a37bf8ddb1286ed46c14f549df99e63285a858 Mon Sep 17 00:00:00 2001
From: kittors
Date: Mon, 17 Aug 2026 23:42:26 +0800
Subject: [PATCH 4/6] fix(video): grey out generation without a credential, and
give the menu its icon
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The page offered a live "generate" button regardless of whether the tenant had an
xAI account, so the only feedback was the router's "auth_not_found: no auth
available" — true, but not actionable. The models endpoint now reports per-tenant
availability, and the page disables the action and names what is missing.
Availability absent from the response (older server) does not disable anything.
The sidebar entry fell back to the generic circle because the icon map had no
"video" key; the seed menu has always asked for one.
Co-Authored-By: Claude Opus 5
---
.../src/endpoints/video-generation.ts | 10 ++++++
packages/i18n/src/locales/en.json | 4 ++-
packages/i18n/src/locales/zh-CN.json | 4 ++-
packages/ui/src/navigation/menuIconMap.ts | 2 ++
.../__tests__/VideoGenerationPage.test.tsx | 23 ++++++++++++
.../components/VideoGenerationPageContent.tsx | 36 +++++++++++++++----
6 files changed, 70 insertions(+), 9 deletions(-)
diff --git a/packages/api-client/src/endpoints/video-generation.ts b/packages/api-client/src/endpoints/video-generation.ts
index c83e9571..d975d2f7 100644
--- a/packages/api-client/src/endpoints/video-generation.ts
+++ b/packages/api-client/src/endpoints/video-generation.ts
@@ -12,10 +12,20 @@ export interface VideoGenerationModel {
supports_image_to_video: boolean;
max_duration_seconds?: number;
price_per_call?: number;
+ /** Credentials of the current tenant that can serve this model. */
+ channels?: string[];
+ /**
+ * False when the tenant has no credential for the model's provider. The page
+ * disables generation in that case: submitting would fail deep in the router
+ * with "auth_not_found: no auth available", which says nothing actionable.
+ */
+ available?: boolean;
}
export interface VideoGenerationModelsResponse {
models: VideoGenerationModel[];
+ /** Every usable channel across providers, flattened. */
+ channels?: string[];
}
export interface VideoGenerationTestRequest {
diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json
index d1fcc939..4bd38cdf 100644
--- a/packages/i18n/src/locales/en.json
+++ b/packages/i18n/src/locales/en.json
@@ -5488,6 +5488,8 @@
"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"
+ "result_open_original": "Open the original clip in a new tab",
+ "no_channel_hint": "This tenant has no usable xAI account, so video generation is unavailable. Add and enable a Grok account under AI Accounts first.",
+ "unavailable_suffix": "no account"
}
}
diff --git a/packages/i18n/src/locales/zh-CN.json b/packages/i18n/src/locales/zh-CN.json
index ecfc52c3..b1f43e41 100644
--- a/packages/i18n/src/locales/zh-CN.json
+++ b/packages/i18n/src/locales/zh-CN.json
@@ -5502,6 +5502,8 @@
"field_duration": "时长(秒,最多 {{max}})",
"field_aspect_ratio": "画面比例",
"field_resolution": "分辨率",
- "result_open_original": "在新标签页打开原始视频"
+ "result_open_original": "在新标签页打开原始视频",
+ "no_channel_hint": "当前租户下没有可用的 xAI 账号,无法生成视频。请先在「AI 账号」里添加并启用一个 Grok 账号。",
+ "unavailable_suffix": "无可用账号"
}
}
diff --git a/packages/ui/src/navigation/menuIconMap.ts b/packages/ui/src/navigation/menuIconMap.ts
index b547aee0..ae55af62 100644
--- a/packages/ui/src/navigation/menuIconMap.ts
+++ b/packages/ui/src/navigation/menuIconMap.ts
@@ -25,6 +25,7 @@ import {
Store,
UserRound,
UsersRound,
+ Video,
type LucideIcon,
} from "lucide-react";
@@ -58,6 +59,7 @@ const ICON_MAP: Record = {
store: Store,
"user-round": UserRound,
"users-round": UsersRound,
+ video: Video,
};
export function resolveMenuIcon(name: string | undefined | null): LucideIcon {
diff --git a/pages/video-generation/__tests__/VideoGenerationPage.test.tsx b/pages/video-generation/__tests__/VideoGenerationPage.test.tsx
index 2aa94f6f..a3abbe93 100644
--- a/pages/video-generation/__tests__/VideoGenerationPage.test.tsx
+++ b/pages/video-generation/__tests__/VideoGenerationPage.test.tsx
@@ -95,6 +95,29 @@ describe("VideoGenerationPage", () => {
expect((await screen.findAllByText("请填写提示词")).length).toBeGreaterThan(0);
});
+ // Screenshot regression: with no xAI credential the page still offered a live
+ // button, and the request died deep in the router with "auth_not_found".
+ test("disables generation when the tenant has no credential for the model", async () => {
+ getModelsMock().mockResolvedValue({
+ models: [{ ...videoModel, available: false, channels: [] }],
+ channels: [],
+ });
+ renderPage();
+
+ await waitFor(() =>
+ expect(screen.getByRole("button", { name: "测试生成" })).toBeDisabled(),
+ );
+ expect(screen.getByText(/没有可用的 xAI 账号/)).toBeInTheDocument();
+ });
+
+ test("keeps generation enabled when the server omits availability", async () => {
+ // An older server does not send the field; absence must not disable the page.
+ getModelsMock().mockResolvedValue({ models: [videoModel] });
+ renderPage();
+
+ await waitFor(() => expect(screen.getByRole("button", { name: "测试生成" })).toBeEnabled());
+ });
+
test("plays the clip once the task finishes", async () => {
const user = userEvent.setup();
getTaskMock().mockResolvedValue({
diff --git a/pages/video-generation/components/VideoGenerationPageContent.tsx b/pages/video-generation/components/VideoGenerationPageContent.tsx
index a4d038f1..a33e0037 100644
--- a/pages/video-generation/components/VideoGenerationPageContent.tsx
+++ b/pages/video-generation/components/VideoGenerationPageContent.tsx
@@ -94,6 +94,13 @@ export function VideoGenerationPageContent() {
[model, models],
);
const maxDuration = selectedModel?.max_duration_seconds || 15;
+ // A model the tenant has no credential for cannot be generated with. Saying so
+ // here — instead of letting the request fail with "auth_not_found" — is the
+ // difference between an actionable message and a dead end. `available` is
+ // undefined on an older server, which must not disable a working page.
+ const modelAvailable = selectedModel?.available !== false;
+ const anyModelAvailable = models.some((entry) => entry.available !== false);
+ const canGenerate = models.length > 0 && modelAvailable;
const pollTask = useCallback(
(taskId: string) => {
@@ -162,11 +169,14 @@ export function VideoGenerationPageContent() {
const modelOptions = useMemo(
() =>
- models.map((entry) => ({
- value: entry.id,
- label: entry.display_name ? `${entry.display_name} · ${entry.id}` : entry.id,
- })),
- [models],
+ models.map((entry) => {
+ const base = entry.display_name ? `${entry.display_name} · ${entry.id}` : entry.id;
+ return {
+ value: entry.id,
+ label: entry.available === false ? `${base} (${t("video_generation.unavailable_suffix")})` : base,
+ };
+ }),
+ [models, t],
);
return (
@@ -190,7 +200,7 @@ export function VideoGenerationPageContent() {
{t("video_generation.call_description")}
-