{hasApiKey ? (
已配置
@@ -551,7 +860,7 @@ const ProviderSettingBody: React.FC
= ({
setApiKeyDraft(event.target.value);
}}
placeholder={
- apiKeyRequired ? "输入 API 密钥" : "本地服务可留空"
+ providerApiKeyRequired ? "输入 API 密钥" : "本地服务可留空"
}
className="h-12 rounded-[18px] border-slate-200 bg-white px-4 pr-11"
autoCapitalize="none"
@@ -625,30 +934,54 @@ const ProviderSettingBody: React.FC = ({
) : null}
- {suggestedApiModels.length > 0 ? (
+ {availableApiModels.length > 0 ? (
-
- 接口模型(点击添加)
+
+
+ 接口模型(显示 {suggestedApiModels.length} /{" "}
+ {availableApiModels.length} 个,点击添加)
+
+
setApiModelQuery(event.target.value)}
+ placeholder="筛选接口模型"
+ className="h-8 rounded-full border-slate-200 bg-white px-3 text-xs normal-case sm:w-[220px]"
+ autoCapitalize="none"
+ autoCorrect="off"
+ spellCheck={false}
+ data-testid="api-model-filter-input"
+ />
-
- {suggestedApiModels.map((modelId) => (
-
- ))}
+
+ {suggestedApiModels.length > 0 ? (
+
+ {suggestedApiModels.map((modelId) => (
+
+ ))}
+
+ ) : (
+
+ 没有匹配的接口模型。
+
+ )}
) : null}
@@ -769,7 +1102,7 @@ const ProviderSettingBody: React.FC
= ({
{modelList.length === 0
? "先添加一个模型,再测试连接。"
- : apiKeyRequired && !hasApiKey && !canUseDraftApiKey
+ : providerApiKeyRequired && !hasApiKey && !canUseDraftApiKey
? "先填写 API 密钥,再测试连接。"
: "当前暂不可测试。"}
diff --git a/src/components/api-key-provider/ProviderSetting.ui.test.tsx b/src/components/api-key-provider/ProviderSetting.ui.test.tsx
index 4ec60c0af..016b82e62 100644
--- a/src/components/api-key-provider/ProviderSetting.ui.test.tsx
+++ b/src/components/api-key-provider/ProviderSetting.ui.test.tsx
@@ -12,20 +12,10 @@ vi.mock("@/lib/api/modelRegistry", () => ({
fetchProviderModelsAuto: (...args: unknown[]) =>
mockFetchProviderModelsAuto(...args),
normalizeFetchProviderModelsSource: (result: {
- source: "Api" | "Catalog" | "CustomModels" | "LocalFallback";
+ source: "Api" | "Error";
models: unknown[];
error: string | null;
- }) => {
- if (
- result.source === "LocalFallback" &&
- typeof result.error === "string" &&
- result.error.includes("已保留当前 Provider 的自定义模型")
- ) {
- return "CustomModels";
- }
-
- return result.source;
- },
+ }) => result.source,
}));
import { ProviderSetting } from "./ProviderSetting";
@@ -103,6 +93,14 @@ function changeInput(input: HTMLInputElement, value: string) {
input.dispatchEvent(new Event("input", { bubbles: true }));
}
+function getApiModelSuggestionLabels(container: HTMLElement): string[] {
+ return Array.from(
+ container.querySelectorAll(
+ '[data-testid="api-model-suggestion"]',
+ ),
+ ).map((button) => button.textContent?.trim() ?? "");
+}
+
beforeEach(() => {
(
globalThis as typeof globalThis & {
@@ -167,6 +165,44 @@ describe("ProviderSetting", () => {
).not.toBeNull();
});
+ it("Lime Hub 未登录时应展示登录提示,不展示模型配置表单", async () => {
+ const onLogin = vi.fn();
+ const container = renderSetting(
+ createProvider({
+ id: "lime-hub",
+ name: "Lime 云端",
+ custom_models: [],
+ api_keys: [],
+ api_key_count: 0,
+ }),
+ {
+ authStatus: "login_required",
+ onLogin,
+ },
+ );
+ await flushEffects();
+
+ expect(
+ container.querySelector('[data-testid="provider-login-required"]'),
+ ).not.toBeNull();
+ expect(container.textContent ?? "").toContain("Lime 云端");
+ expect(container.textContent ?? "").toContain("需要登录");
+ expect(container.textContent ?? "").toContain(
+ "登录后会自动同步 Lime Hub 的可用模型",
+ );
+ expect(container.textContent ?? "").not.toContain("模型优先级");
+ expect(container.textContent ?? "").not.toContain("从接口获取");
+
+ await act(async () => {
+ container
+ .querySelector('[data-testid="provider-login-button"]')
+ ?.click();
+ await Promise.resolve();
+ });
+
+ expect(onLogin).toHaveBeenCalledTimes(1);
+ });
+
it("手动添加模型应直接更新 custom_models", async () => {
const onUpdate = vi.fn().mockResolvedValue(undefined);
const container = renderSetting(createProvider({ custom_models: [] }), {
@@ -200,11 +236,11 @@ describe("ProviderSetting", () => {
expect(container.textContent ?? "").toContain("deepseek-reasoner");
});
- it("接口获取只接受 Api 来源,不展示本地兜底模型", async () => {
+ it("接口获取失败时不展示错误来源夹带的非实时模型", async () => {
mockFetchProviderModelsAuto.mockResolvedValueOnce({
- source: "LocalFallback",
+ source: "Error",
models: [{ id: "wrong-fallback-model" }],
- error: "API 获取失败,已使用本地数据",
+ error: "API 获取失败,本地模型兜底已下线。",
});
const container = renderSetting(createProvider({ custom_models: [] }));
await flushEffects();
@@ -220,13 +256,286 @@ describe("ProviderSetting", () => {
});
expect(mockFetchProviderModelsAuto).toHaveBeenCalledWith("deepseek");
- expect(container.textContent ?? "").toContain("已忽略本地目录或兜底结果");
+ expect(container.textContent ?? "").toContain("API 获取失败");
expect(container.textContent ?? "").not.toContain("wrong-fallback-model");
expect(
container.querySelector('[data-testid="api-model-suggestions"]'),
).toBeNull();
});
+ it("Responses 图片入口不支持 /models 时应保留手动图片模型并显示确认态", async () => {
+ mockFetchProviderModelsAuto.mockResolvedValueOnce({
+ source: "Error",
+ models: [],
+ error: "当前 Responses 兼容入口未提供标准 /models 接口。",
+ diagnostic_hint: null,
+ });
+ const container = renderSetting(
+ createProvider({
+ id: "airgate-openai-images",
+ name: "OpenAI-gpt-images-2",
+ type: "openai",
+ api_host: "https://code.ylsagi.com/codex",
+ custom_models: ["gpt-images-2"],
+ }),
+ );
+ await flushEffects();
+
+ await act(async () => {
+ container
+ .querySelector('[data-testid="fetch-models-button"]')
+ ?.click();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ const status = container.querySelector(
+ '[data-testid="model-fetch-status"]',
+ );
+ expect(status?.textContent ?? "").toContain("已确认 Responses 图片模型");
+ expect(status?.textContent ?? "").toContain("gpt-images-2");
+ expect(status?.className ?? "").toContain("border-emerald-200");
+ expect(container.textContent ?? "").toContain("gpt-images-2");
+ expect(
+ container.querySelector('[data-testid="api-model-suggestions"]'),
+ ).toBeNull();
+ });
+
+ it("Responses 图片入口缺少声明模型时仍提示手动添加", async () => {
+ mockFetchProviderModelsAuto.mockResolvedValueOnce({
+ source: "Error",
+ models: [],
+ error: "当前 Responses 兼容入口未提供标准 /models 接口。",
+ diagnostic_hint: null,
+ });
+ const container = renderSetting(
+ createProvider({
+ id: "airgate-openai-images",
+ name: "OpenAI-gpt-images-2",
+ type: "openai-response",
+ api_host: "https://api.openai.com/v1",
+ custom_models: [],
+ }),
+ );
+ await flushEffects();
+
+ await act(async () => {
+ container
+ .querySelector('[data-testid="fetch-models-button"]')
+ ?.click();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ const status = container.querySelector(
+ '[data-testid="model-fetch-status"]',
+ );
+ expect(status?.textContent ?? "").toContain(
+ "该 Responses 图片入口不提供标准 /models 枚举",
+ );
+ expect(status?.textContent ?? "").toContain("请手动添加 gpt-images-2");
+ expect(status?.className ?? "").toContain("border-sky-200");
+ });
+
+ it("Responses 图片入口返回已声明图片模型时应显示确认态", async () => {
+ mockFetchProviderModelsAuto.mockResolvedValueOnce({
+ source: "Api",
+ models: [{ id: "gpt-images-2" }],
+ error: null,
+ diagnostic_hint:
+ "当前 Responses 图片入口不提供标准 /models 枚举;已使用 Provider 中声明的图片模型作为可用模型,并写入 10 天缓存。",
+ });
+ const container = renderSetting(
+ createProvider({
+ id: "airgate-openai-images",
+ name: "OpenAI-gpt-images-2",
+ type: "openai",
+ api_host: "https://code.ylsagi.com/codex",
+ custom_models: ["gpt-images-2"],
+ }),
+ );
+ await flushEffects();
+
+ await act(async () => {
+ container
+ .querySelector('[data-testid="fetch-models-button"]')
+ ?.click();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ const status = container.querySelector(
+ '[data-testid="model-fetch-status"]',
+ );
+ expect(status?.textContent ?? "").toContain("已确认 1 个模型");
+ expect(status?.textContent ?? "").toContain("已包含全部结果");
+ expect(status?.className ?? "").toContain("border-emerald-200");
+ expect(container.textContent ?? "").not.toContain(
+ "该 Responses 图片入口不提供标准 /models 枚举",
+ );
+ expect(
+ container.querySelector('[data-testid="api-model-suggestions"]'),
+ ).toBeNull();
+ });
+
+ it("Fal Provider 不支持 /models 时应保留手动模型并显示确认态", async () => {
+ mockFetchProviderModelsAuto.mockResolvedValueOnce({
+ source: "Error",
+ models: [],
+ error: "Fal 不提供标准 /models 枚举。",
+ diagnostic_hint: "请在 Provider 中手动添加 fal-ai/... 模型 ID。",
+ });
+ const container = renderSetting(
+ createProvider({
+ id: "fal",
+ name: "Fal",
+ type: "openai",
+ api_host: "https://fal.run/fal-ai",
+ custom_models: ["fal-ai/nano-banana-pro"],
+ }),
+ );
+ await flushEffects();
+
+ await act(async () => {
+ container
+ .querySelector('[data-testid="fetch-models-button"]')
+ ?.click();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ const status = container.querySelector(
+ '[data-testid="model-fetch-status"]',
+ );
+ expect(status?.textContent ?? "").toContain("已确认 Fal 模型");
+ expect(status?.textContent ?? "").toContain("fal-ai/nano-banana-pro");
+ expect(status?.className ?? "").toContain("border-emerald-200");
+ expect(container.textContent ?? "").toContain("fal-ai/nano-banana-pro");
+ expect(container.textContent ?? "").not.toContain("API 获取失败");
+ expect(
+ container.querySelector('[data-testid="api-model-suggestions"]'),
+ ).toBeNull();
+ });
+
+ it("Fal Provider 没有 API Key 时仍可确认手动声明模型", async () => {
+ mockFetchProviderModelsAuto.mockResolvedValueOnce({
+ source: "Api",
+ models: [{ id: "fal-ai/nano-banana-pro" }],
+ error: null,
+ });
+ const container = renderSetting(
+ createProvider({
+ id: "fal",
+ name: "Fal",
+ type: "openai",
+ api_host: "https://fal.run/fal-ai",
+ api_key_count: 0,
+ api_keys: [],
+ custom_models: ["fal-ai/nano-banana-pro"],
+ }),
+ );
+ await flushEffects();
+
+ const fetchButton = container.querySelector(
+ '[data-testid="fetch-models-button"]',
+ );
+ const connectionButton = container.querySelector(
+ '[data-testid="provider-test-connection-button"]',
+ );
+ expect(fetchButton?.disabled).toBe(false);
+ expect(connectionButton?.disabled).toBe(true);
+ expect(container.textContent ?? "").toContain("先填写 API 密钥,再测试连接");
+ expect(container.textContent ?? "").not.toContain("API 密钥(可选)");
+
+ await act(async () => {
+ fetchButton?.click();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(mockFetchProviderModelsAuto).toHaveBeenCalledWith("fal");
+ const status = container.querySelector(
+ '[data-testid="model-fetch-status"]',
+ );
+ expect(status?.textContent ?? "").toContain("已确认 1 个模型");
+ expect(status?.textContent ?? "").not.toContain("请先填写并保存 API 密钥");
+ });
+
+ it("Fal Provider 只有非 Fal 模型时应提示添加 fal-ai 模型", async () => {
+ mockFetchProviderModelsAuto.mockResolvedValueOnce({
+ source: "Error",
+ models: [],
+ error: "Fal 不提供标准 /models 枚举。",
+ diagnostic_hint:
+ "当前模型优先级没有可用 Fal 图片模型;请手动添加 fal-ai/nano-banana-pro。",
+ });
+ const container = renderSetting(
+ createProvider({
+ id: "fal",
+ name: "Fal",
+ type: "openai",
+ api_host: "https://fal.run/fal-ai",
+ custom_models: ["gpt-5.2-pro"],
+ }),
+ );
+ await flushEffects();
+
+ await act(async () => {
+ container
+ .querySelector('[data-testid="fetch-models-button"]')
+ ?.click();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ const status = container.querySelector(
+ '[data-testid="model-fetch-status"]',
+ );
+ expect(status?.textContent ?? "").toContain(
+ "当前模型优先级没有可用 Fal 图片模型",
+ );
+ expect(status?.textContent ?? "").toContain("fal-ai/nano-banana-pro");
+ expect(status?.className ?? "").toContain("border-sky-200");
+ expect(container.textContent ?? "").not.toContain("API 获取失败");
+ });
+
+ it("Fal Provider 命中旧缓存里的非 Fal 模型时不应显示确认态", async () => {
+ mockFetchProviderModelsAuto.mockResolvedValueOnce({
+ source: "Api",
+ models: [{ id: "gpt-5.2-pro" }],
+ error: null,
+ from_cache: true,
+ });
+ const container = renderSetting(
+ createProvider({
+ id: "fal",
+ name: "Fal",
+ type: "openai",
+ api_host: "https://fal.run/fal-ai",
+ custom_models: ["gpt-5.2-pro"],
+ }),
+ );
+ await flushEffects();
+
+ await act(async () => {
+ container
+ .querySelector('[data-testid="fetch-models-button"]')
+ ?.click();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ const status = container.querySelector(
+ '[data-testid="model-fetch-status"]',
+ );
+ expect(status?.textContent ?? "").toContain(
+ "当前没有可用 Fal 图片模型",
+ );
+ expect(status?.textContent ?? "").toContain("fal-ai/nano-banana-pro");
+ expect(status?.textContent ?? "").not.toContain("已确认 1 个模型");
+ expect(status?.className ?? "").toContain("border-sky-200");
+ });
+
it("接口获取成功后点击模型建议才加入优先级", async () => {
const onUpdate = vi.fn().mockResolvedValue(undefined);
mockFetchProviderModelsAuto.mockResolvedValueOnce({
@@ -269,6 +578,105 @@ describe("ProviderSetting", () => {
});
});
+ it("接口获取成功后应展示完整模型列表,并支持筛选长列表", async () => {
+ const apiModels = Array.from({ length: 12 }, (_, index) => ({
+ id: `provider-model-${String(index + 1).padStart(2, "0")}`,
+ }));
+ mockFetchProviderModelsAuto.mockResolvedValueOnce({
+ source: "Api",
+ models: apiModels,
+ error: null,
+ });
+ const container = renderSetting(createProvider({ custom_models: [] }));
+ await flushEffects();
+
+ await act(async () => {
+ container
+ .querySelector('[data-testid="fetch-models-button"]')
+ ?.click();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(container.textContent ?? "").toContain("接口返回 12 个模型");
+ expect(container.textContent ?? "").toContain("显示 12 / 12 个");
+ expect(getApiModelSuggestionLabels(container)).toHaveLength(12);
+ expect(getApiModelSuggestionLabels(container)).toContain(
+ "provider-model-12",
+ );
+
+ const filterInput = container.querySelector(
+ '[data-testid="api-model-filter-input"]',
+ );
+
+ await act(async () => {
+ changeInput(filterInput!, "model-12");
+ await Promise.resolve();
+ });
+
+ expect(container.textContent ?? "").toContain("显示 1 / 12 个");
+ expect(getApiModelSuggestionLabels(container)).toEqual([
+ "provider-model-12",
+ ]);
+ });
+
+ it("非系统 Provider 应展示删除配置按钮,并在确认后调用删除回调", async () => {
+ const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
+ const onDeleteProvider = vi.fn().mockResolvedValue(true);
+ const container = renderSetting(createProvider(), {
+ onDeleteProvider,
+ });
+ await flushEffects();
+
+ const button = container.querySelector(
+ '[data-testid="provider-delete-button"]',
+ );
+
+ expect(button).not.toBeNull();
+ expect(button?.textContent ?? "").toContain("删除配置");
+
+ await act(async () => {
+ button?.click();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(confirmSpy).toHaveBeenCalledWith(
+ "确认删除「DeepSeek」配置?此操作会移除该服务商和关联密钥。",
+ );
+ expect(onDeleteProvider).toHaveBeenCalledWith("deepseek");
+
+ confirmSpy.mockRestore();
+ });
+
+ it("系统 Provider 也应展示删除配置按钮,并说明只移除本地配置", async () => {
+ const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
+ const onDeleteProvider = vi.fn().mockResolvedValue(true);
+ const container = renderSetting(createProvider({ is_system: true }), {
+ onDeleteProvider,
+ });
+ await flushEffects();
+
+ const button = container.querySelector(
+ '[data-testid="provider-delete-button"]',
+ );
+
+ expect(button).not.toBeNull();
+
+ await act(async () => {
+ button?.click();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(confirmSpy).toHaveBeenCalledWith(
+ "确认删除「DeepSeek」配置?此操作会移除该服务商的已启用模型和本地 API 密钥,系统服务商入口仍可重新添加。",
+ );
+ expect(onDeleteProvider).toHaveBeenCalledWith("deepseek");
+
+ confirmSpy.mockRestore();
+ });
+
it("测试连接应先保存新密钥,并只显示简洁状态", async () => {
const onAddApiKey = vi.fn().mockResolvedValue(undefined);
const onTestConnection = vi.fn().mockResolvedValue({
diff --git a/src/components/api-key-provider/README.md b/src/components/api-key-provider/README.md
index 470d73884..47b62ca82 100644
--- a/src/components/api-key-provider/README.md
+++ b/src/components/api-key-provider/README.md
@@ -60,21 +60,21 @@ function ProviderSettingsPage() {
为避免“改一处坏一片”,Provider/模型映射采用分层策略:
1. **框架层(aster-rust)**:负责 Provider 工厂与别名归一,支持 `ASTER_PROVIDER_ALIAS_OVERRIDES` 做运行时覆盖。
-2. **应用后端层(Lime Tauri)**:`get_system_provider_catalog` 提供 Provider 元信息,`get_model_registry_provider_ids` 提供模型 Provider 真相集;真相源读取失败时直接报错。
-3. **应用前端层(UI)**:设置页只接受接口返回模型和用户手动添加模型;本地模型目录仅用于添加页服务商发现,不再作为右侧配置页兜底。
+2. **应用后端层(Lime Tauri)**:`get_system_provider_catalog` 提供 Provider 元信息;Provider 模型列表只来自实时 `/models` 接口或用户显式 `custom_models`。实时 `/models` 成功结果允许缓存 10 天,读取时先查缓存再访问上游,但缓存只保存接口结果,不恢复本地 catalog 兜底。`get_model_registry_provider_ids` 仅兼容返回空集合。
+3. **应用前端层(UI)**:设置页只接受接口返回模型和用户手动添加模型;已下线的本地模型目录不再参与添加页发现或右侧配置页兜底。
### 解析优先级
`resolveRegistryProviderId` 的核心规则:
1. codex 协议强制 `codex`
-2. Catalog 别名映射
+2. 系统 Provider Catalog 别名映射
3. 最小 legacy ID 映射
4. providerType 回退
5. 原始 providerId
-这层解析只负责模型目录真相源收敛,不负责 Prompt Cache 等运行时能力判断。
-例如 `anthropic-compatible -> anthropic` 仅表示可复用 Anthropic 模型注册表,不能据此推断官方 Anthropic 自动缓存能力。
+这层解析只负责系统 Provider 元信息收敛,不负责 Prompt Cache 等运行时能力判断。
+例如 `anthropic-compatible -> anthropic` 仅表示可复用 Anthropic Provider 元信息,不能据此推断官方 Anthropic 自动缓存能力。
## Prompt Cache 能力提示(当前事实源)
diff --git a/src/components/artifact/ArtifactRenderer.tsx b/src/components/artifact/ArtifactRenderer.tsx
index e8a95eaa7..fbb92dc8d 100644
--- a/src/components/artifact/ArtifactRenderer.tsx
+++ b/src/components/artifact/ArtifactRenderer.tsx
@@ -35,7 +35,10 @@ import {
resolveArtifactWritePhase,
} from "@/components/agent/chat/utils/messageArtifacts";
import { ErrorFallbackRenderer } from "./ErrorFallbackRenderer";
-import { CanvasAdapter } from "./CanvasAdapter";
+import {
+ CanvasAdapter,
+ type CanvasAdapterCanvasFactoryProps,
+} from "./CanvasAdapter";
import type { Artifact, ArtifactRendererProps } from "@/lib/artifact/types";
// ============================================================================
@@ -539,6 +542,8 @@ interface ArtifactRendererComponentProps extends ArtifactRendererProps {
className?: string;
/** 防抖延迟(毫秒),默认 100ms */
debounceDelay?: number;
+ /** Canvas Artifact 运行上下文,用于 current 工作台 project 保存与图层生成 */
+ canvasFactoryProps?: CanvasAdapterCanvasFactoryProps;
/** 是否隐藏内部工具栏 */
hideToolbar?: boolean;
/** 视图模式(用于代码预览) */
@@ -578,6 +583,7 @@ export const ArtifactRenderer: React.FC = memo(
viewMode,
previewSize = "desktop",
tone = "dark",
+ canvasFactoryProps,
}) => {
// 错误状态管理
const [renderError, setRenderError] = useState(null);
@@ -709,6 +715,7 @@ export const ArtifactRenderer: React.FC = memo(
artifact={debouncedArtifact}
isStreaming={isStreaming}
onContentChange={onContentChange}
+ canvasFactoryProps={canvasFactoryProps}
/>
{(isStreaming || isCompleting) && (
diff --git a/src/components/artifact/ArtifactRenderer.ui.test.tsx b/src/components/artifact/ArtifactRenderer.ui.test.tsx
index f0acc2c52..3deed0543 100644
--- a/src/components/artifact/ArtifactRenderer.ui.test.tsx
+++ b/src/components/artifact/ArtifactRenderer.ui.test.tsx
@@ -1,4 +1,4 @@
-import { act } from "react";
+import { act, type ComponentProps } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ArtifactRenderer } from "./ArtifactRenderer";
@@ -37,13 +37,18 @@ function createArtifact(overrides: Partial = {}): Artifact {
};
}
-function renderArtifact(artifact: Artifact) {
+function renderArtifact(
+ artifact: Artifact,
+ props: Partial> = {},
+) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
- root.render();
+ root.render(
+ ,
+ );
});
mountedRenderers.push({ container, root });
@@ -115,7 +120,7 @@ describe("ArtifactRenderer 空内容态", () => {
expect(container.textContent).toContain("workspace/index.ts");
});
- it("canvas:design 应直接委托到图层设计画布,不依赖轻量渲染器注册", async () => {
+ it("canvas:design 应先展示紧凑预览,不在工作台卡片内嵌完整编辑器", async () => {
const container = renderArtifact(
createArtifact({
type: "canvas:design",
@@ -158,6 +163,193 @@ describe("ArtifactRenderer 空内容态", () => {
expect(container.textContent).toContain("LayeredDesignDocument");
expect(container.textContent).toContain("Artifact 图层海报");
expect(container.textContent).toContain("标题层");
+ expect(container.textContent).not.toContain("生成全部图片层");
+ expect(container.textContent).not.toContain("导出设计工程");
+ });
+
+ it("canvas:design 完整编辑器应继承主应用项目上下文,支持工程目录保存和图层生成", async () => {
+ const container = renderArtifact(
+ createArtifact({
+ type: "canvas:design",
+ title: "design.json",
+ status: "complete",
+ content: JSON.stringify({
+ id: "design-artifact-context",
+ title: "主应用工程上下文",
+ canvas: { width: 1080, height: 1440 },
+ layers: [
+ {
+ id: "hero",
+ name: "主视觉",
+ type: "image",
+ assetId: "asset-hero",
+ x: 0,
+ y: 0,
+ width: 1080,
+ height: 1440,
+ zIndex: 1,
+ alphaMode: "none",
+ },
+ ],
+ assets: [
+ {
+ id: "asset-hero",
+ kind: "subject",
+ src: "",
+ width: 1080,
+ height: 1440,
+ hasAlpha: false,
+ createdAt: "2026-05-08T00:00:00.000Z",
+ },
+ ],
+ editHistory: [],
+ createdAt: "2026-05-08T00:00:00.000Z",
+ updatedAt: "2026-05-08T00:00:00.000Z",
+ }),
+ meta: {
+ filePath: ".lime/layered-designs/demo.layered-design/design.json",
+ filename: "design.json",
+ },
+ }),
+ {
+ canvasFactoryProps: {
+ projectRootPath: "/workspace/project",
+ projectId: "project-1",
+ contentId: "content-1",
+ imageGenerationProviderId: "fal",
+ imageGenerationModelId: "fal-ai/nano-banana-pro",
+ imageGenerationSelectionReady: true,
+ imageGenerationSelectionWarning: null,
+ },
+ },
+ );
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(container.textContent).not.toContain("生成全部图片层");
+ expect(container.textContent).not.toContain("打开最近工程");
+
+ const editButton = Array.from(container.querySelectorAll("button")).find(
+ (button) => button.getAttribute("title") === "在完整编辑器中打开",
+ );
+ expect(editButton).toBeTruthy();
+
+ act(() => {
+ editButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ const fullEditor = document.body.querySelector(
+ '[data-testid="canvas-full-editor"]',
+ );
+ expect(fullEditor).not.toBeNull();
+
+ const generateButton = Array.from(
+ fullEditor?.querySelectorAll("button") ?? [],
+ ).find((button) => button.textContent?.includes("生成全部图片层"));
+ const restoreButton = Array.from(
+ fullEditor?.querySelectorAll("button") ?? [],
+ ).find((button) => button.textContent?.includes("打开最近工程"));
+
+ expect(generateButton).toBeTruthy();
+ expect(generateButton?.getAttribute("title")).toBe(
+ "为所有待生成图片层创建图片任务",
+ );
+ expect(generateButton?.hasAttribute("disabled")).toBe(false);
+ expect(restoreButton?.getAttribute("title")).toBe(
+ "从项目工程目录打开最近保存的图层设计",
+ );
+ expect(restoreButton?.hasAttribute("disabled")).toBe(false);
+ });
+
+ it("canvas:design 的编辑按钮应打开不受预览卡片宽度限制的完整编辑器", async () => {
+ const container = renderArtifact(
+ createArtifact({
+ type: "canvas:design",
+ title: "design.json",
+ status: "complete",
+ content: JSON.stringify({
+ id: "design-artifact-full-editor",
+ title: "完整编辑器图层设计",
+ canvas: { width: 1080, height: 1440 },
+ layers: [
+ {
+ id: "headline",
+ name: "标题层",
+ type: "text",
+ text: "完整编辑器",
+ x: 120,
+ y: 120,
+ width: 840,
+ height: 120,
+ zIndex: 4,
+ },
+ ],
+ assets: [],
+ editHistory: [],
+ createdAt: "2026-05-08T00:00:00.000Z",
+ updatedAt: "2026-05-08T00:00:00.000Z",
+ }),
+ meta: {
+ filePath: ".lime/layered-designs/full.layered-design/design.json",
+ filename: "design.json",
+ },
+ }),
+ {
+ canvasFactoryProps: {
+ projectRootPath: "/workspace/project",
+ projectId: "project-1",
+ contentId: "content-1",
+ },
+ },
+ );
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ const editButton = Array.from(container.querySelectorAll("button")).find(
+ (button) => button.getAttribute("title") === "在完整编辑器中打开",
+ );
+ expect(editButton).toBeTruthy();
+
+ act(() => {
+ editButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ const fullEditor = document.body.querySelector(
+ '[data-testid="canvas-full-editor"]',
+ );
+ expect(fullEditor).not.toBeNull();
+ expect(fullEditor?.parentElement).toBe(document.body);
+ expect(fullEditor?.getAttribute("role")).toBe("dialog");
+ expect(fullEditor?.getAttribute("aria-modal")).toBe("true");
+ expect(document.body.style.overflow).toBe("hidden");
+ expect(fullEditor?.textContent).toContain("完整编辑器");
+ expect(fullEditor?.textContent).toContain("导出设计工程");
+ expect(fullEditor?.textContent).toContain("打开最近工程");
+
+ act(() => {
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
+ });
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(
+ document.body.querySelector('[data-testid="canvas-full-editor"]'),
+ ).toBeNull();
+ expect(document.body.style.overflow).toBe("");
});
it("失败且没有内容时应展示错误解释态", () => {
diff --git a/src/components/artifact/CanvasAdapter.tsx b/src/components/artifact/CanvasAdapter.tsx
index d79104b66..272f55977 100644
--- a/src/components/artifact/CanvasAdapter.tsx
+++ b/src/components/artifact/CanvasAdapter.tsx
@@ -5,8 +5,16 @@
* @requirements 12.1, 12.2, 12.3, 12.4, 12.5
*/
-import React, { memo, useCallback, useMemo, useState, useEffect } from "react";
-import { ExternalLink, Loader2 } from "lucide-react";
+import React, {
+ memo,
+ useCallback,
+ useEffect,
+ useMemo,
+ useState,
+ type ComponentProps,
+} from "react";
+import { createPortal } from "react-dom";
+import { Loader2, Maximize2, X } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Artifact } from "@/lib/artifact/types";
@@ -14,6 +22,7 @@ import type { Artifact } from "@/lib/artifact/types";
import {
CanvasFactory,
type CanvasStateUnion,
+ type DesignCanvasState,
} from "@/lib/workspace/workbenchCanvas";
// 工具函数导入
@@ -32,6 +41,19 @@ import {
/**
* Canvas 适配器 Props
*/
+export type CanvasAdapterCanvasFactoryProps = Pick<
+ ComponentProps,
+ | "projectRootPath"
+ | "projectId"
+ | "contentId"
+ | "imageGenerationProviderId"
+ | "imageGenerationModelId"
+ | "imageGenerationSelectionReady"
+ | "imageGenerationSelectionWarning"
+ | "designAnalyzeFlatImage"
+ | "designAnalyzerModelSlotConfigs"
+>;
+
interface CanvasAdapterProps {
/** 要渲染的 Artifact 对象 */
artifact: Artifact;
@@ -39,6 +61,8 @@ interface CanvasAdapterProps {
isStreaming?: boolean;
/** 内容变更回调 */
onContentChange?: (content: string) => void;
+ /** 由工作台注入的 Canvas 运行上下文,用于 project 保存和图层生成 */
+ canvasFactoryProps?: CanvasAdapterCanvasFactoryProps;
/** 自定义类名 */
className?: string;
}
@@ -82,6 +106,29 @@ const CanvasUnsupportedMessage: React.FC<{ canvasType: string }> = memo(
);
CanvasUnsupportedMessage.displayName = "CanvasUnsupportedMessage";
+function isDesignCanvasState(
+ state: CanvasStateUnion,
+): state is DesignCanvasState {
+ return state.type === "design";
+}
+
+function getLayerTypeLabel(type: string): string {
+ switch (type) {
+ case "image":
+ return "图片";
+ case "text":
+ return "文字";
+ case "shape":
+ return "形状";
+ case "effect":
+ return "效果";
+ case "group":
+ return "组";
+ default:
+ return "图层";
+ }
+}
+
// ============================================================================
// 主组件
// ============================================================================
@@ -104,7 +151,13 @@ CanvasUnsupportedMessage.displayName = "CanvasUnsupportedMessage";
* @requirements 12.1, 12.2, 12.3, 12.4, 12.5
*/
export const CanvasAdapter: React.FC = memo(
- ({ artifact, isStreaming = false, onContentChange, className }) => {
+ ({
+ artifact,
+ isStreaming = false,
+ onContentChange,
+ canvasFactoryProps,
+ className,
+ }) => {
// 获取 Canvas 类型
const canvasType = useMemo(
() => getCanvasTypeFromArtifact(artifact.type),
@@ -157,6 +210,28 @@ export const CanvasAdapter: React.FC = memo(
setIsFullEditorMode(false);
}, []);
+ useEffect(() => {
+ if (!isFullEditorMode || typeof document === "undefined") {
+ return;
+ }
+
+ const previousBodyOverflow = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ setIsFullEditorMode(false);
+ }
+ };
+
+ document.addEventListener("keydown", handleKeyDown);
+
+ return () => {
+ document.body.style.overflow = previousBodyOverflow;
+ document.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [isFullEditorMode]);
+
/**
* 打开完整编辑器模式
* @requirements 12.4
@@ -190,6 +265,173 @@ export const CanvasAdapter: React.FC = memo(
const icon =
CANVAS_TYPE_ICONS[canvasType as keyof typeof CANVAS_TYPE_ICONS];
+ const renderCanvasFactory = () => (
+
+ );
+ const renderDesignPreview = (state: DesignCanvasState) => {
+ const document = state.document;
+ const imageLayerCount = document.layers.filter(
+ (layer) => layer.type === "image" || layer.type === "effect",
+ ).length;
+ const generatedAssetCount = document.assets.filter(
+ (asset) =>
+ asset.params?.source === "image_generation_task" ||
+ Boolean(asset.provider || asset.modelId),
+ ).length;
+ const visibleLayers = document.layers.filter((layer) => layer.visible);
+ const layerSummary = [...document.layers]
+ .sort((a, b) => b.zIndex - a.zIndex)
+ .slice(0, 4);
+
+ return (
+
+
+
+
+ LayeredDesignDocument
+
+
+ {document.title}
+
+
+ {document.canvas.width} x {document.canvas.height} /{" "}
+ {document.layers.length} 个图层 / {imageLayerCount} 个图片层 /{" "}
+ {document.status}
+
+
+
+
+
+
显示图层
+
+ {visibleLayers.length}
+
+
+
+
资产
+
+ {document.assets.length}
+
+
+
+
已生成
+
+ {generatedAssetCount}
+
+
+
+
当前层
+
+ {document.layers.find(
+ (layer) => layer.id === state.selectedLayerId,
+ )?.name ||
+ document.layers[0]?.name ||
+ "未选择"}
+
+
+
+
+
+
+
+ 图层摘要
+
+
+ top {layerSummary.length}
+
+
+
+ {layerSummary.map((layer) => (
+
+
+
+ {layer.name}
+
+
+ {getLayerTypeLabel(layer.type)} / z {layer.zIndex}
+
+
+
+ {layer.visible ? "显示" : "隐藏"}
+
+
+ {layer.locked ? "锁定" : "可编辑"}
+
+
+ ))}
+
+
+
+
+ );
+ };
+ const fullEditorRoot =
+ typeof document === "undefined" ? null : document.body;
+ const fullEditor =
+ isFullEditorMode && fullEditorRoot
+ ? createPortal(
+
+
+
+ {icon}
+
+ {label} Canvas · 完整编辑器
+
+
+
+
+
+ {renderCanvasFactory()}
+
+
,
+ fullEditorRoot,
+ )
+ : null;
+
return (
{/* Canvas 信息头部 */}
@@ -206,26 +448,24 @@ export const CanvasAdapter: React.FC = memo(
)}
{/* Canvas 渲染区域 */}
-
+ {isDesignCanvasState(canvasState)
+ ? renderDesignPreview(canvasState)
+ : renderCanvasFactory()}
+ {fullEditor}
);
},
diff --git a/src/components/input-kit/ModelSelector.test.tsx b/src/components/input-kit/ModelSelector.test.tsx
index 52dcb7a54..0f116a359 100644
--- a/src/components/input-kit/ModelSelector.test.tsx
+++ b/src/components/input-kit/ModelSelector.test.tsx
@@ -59,6 +59,39 @@ interface MountedRoot {
const mountedRoots: MountedRoot[] = [];
+function createModelMetadata(id: string) {
+ return {
+ id,
+ display_name: id,
+ provider_id: "fal",
+ provider_name: "Fal",
+ family: null,
+ tier: "pro" as const,
+ capabilities: {
+ vision: false,
+ tools: false,
+ streaming: false,
+ json_mode: false,
+ function_calling: false,
+ reasoning: false,
+ },
+ pricing: null,
+ limits: {
+ context_length: null,
+ max_output_tokens: null,
+ requests_per_minute: null,
+ tokens_per_minute: null,
+ },
+ status: "active" as const,
+ release_date: null,
+ is_latest: false,
+ description: null,
+ source: "custom" as const,
+ created_at: 0,
+ updated_at: 0,
+ };
+}
+
function renderModelSelector(
props: Partial
> = {},
) {
@@ -291,6 +324,120 @@ describe("ModelSelector", () => {
expect(setModel).toHaveBeenCalledWith("gpt-5.2-codex");
});
+ it("切换供应商时应同步切换到该供应商的首个已配置模型,避免沿用旧模型", () => {
+ const setProviderType = vi.fn();
+ const setModel = vi.fn();
+ mockUseConfiguredProviders.mockReturnValue({
+ providers: [
+ {
+ key: "lime-hub",
+ label: "Lime 云端",
+ registryId: "lime-hub",
+ type: "openai",
+ providerId: "lime-hub",
+ apiHost: "https://llm.limeai.run",
+ customModels: ["gpt-5.5"],
+ },
+ {
+ key: "deepseek",
+ label: "DeepSeek",
+ registryId: "deepseek",
+ type: "openai",
+ providerId: "deepseek",
+ apiHost: "https://api.deepseek.com",
+ customModels: ["deepseek-chat", "deepseek-reasoner"],
+ },
+ ],
+ loading: false,
+ });
+ mockUseProviderModels.mockReturnValue({
+ modelIds: ["gpt-5.5"],
+ models: [createModelMetadata("gpt-5.5")],
+ loading: false,
+ error: null,
+ });
+
+ const { container } = renderModelSelector({
+ providerType: "lime-hub",
+ setProviderType,
+ model: "gpt-5.5",
+ setModel,
+ });
+
+ const trigger = container.querySelector(
+ 'button[role="combobox"]',
+ ) as HTMLButtonElement | null;
+ if (!trigger) {
+ throw new Error("未找到模型选择触发器");
+ }
+
+ act(() => {
+ trigger.click();
+ });
+
+ const deepseekButton = Array.from(
+ document.body.querySelectorAll("button"),
+ ).find((button) => button.textContent?.includes("DeepSeek"));
+ if (!deepseekButton) {
+ throw new Error("未找到 DeepSeek 供应商按钮");
+ }
+
+ act(() => {
+ deepseekButton.click();
+ });
+
+ expect(setProviderType).toHaveBeenCalledWith("deepseek");
+ expect(setModel).toHaveBeenCalledWith("deepseek-chat");
+ expect(setModel).not.toHaveBeenCalledWith("gpt-5.5");
+ });
+
+ it("模型过滤清空本地模型时应展示调用方提供的图片模型回退", () => {
+ mockUseConfiguredProviders.mockReturnValue({
+ providers: [
+ {
+ key: "fal",
+ label: "Fal",
+ registryId: "fal",
+ fallbackRegistryId: "openai",
+ type: "openai",
+ providerId: "fal",
+ apiHost: "https://fal.run/fal-ai",
+ customModels: ["gpt-5.2-pro"],
+ },
+ ],
+ loading: false,
+ });
+ mockUseProviderModels.mockReturnValue({
+ modelIds: ["gpt-5.2-pro"],
+ models: [createModelMetadata("gpt-5.2-pro")],
+ loading: false,
+ error: null,
+ });
+
+ const { container } = renderModelSelector({
+ providerType: "fal",
+ model: "",
+ modelFilter: (item) => item.id.startsWith("fal-ai/"),
+ getFallbackModels: () => [createModelMetadata("fal-ai/nano-banana-pro")],
+ });
+
+ const trigger = container.querySelector(
+ 'button[role="combobox"]',
+ ) as HTMLButtonElement | null;
+ if (!trigger) {
+ throw new Error("未找到模型选择触发器");
+ }
+
+ act(() => {
+ trigger.click();
+ });
+
+ const pageText = document.body.textContent || "";
+ expect(pageText).toContain("fal-ai/nano-banana-pro");
+ expect(pageText).not.toContain("gpt-5.2-pro");
+ expect(pageText).not.toContain("暂无可用模型");
+ });
+
it("展开后应显示模型的思考与多模态能力标签", () => {
const { container } = renderModelSelector();
@@ -390,6 +537,85 @@ describe("ModelSelector", () => {
expect(pageText).not.toContain("暂无可用模型");
});
+ it("Lime Hub 未登录时应在下拉框提示登录且不展示本地兜底模型", () => {
+ const onManageProviders = vi.fn();
+ mockUseConfiguredProviders.mockReturnValue({
+ providers: [
+ {
+ key: "lime-hub",
+ label: "Lime 云端",
+ registryId: "lime-hub",
+ type: "openai",
+ providerId: "lime-hub",
+ apiHost: "https://llm.limeai.run",
+ customModels: [],
+ authStatus: "login_required",
+ },
+ ],
+ loading: false,
+ });
+ mockUseProviderModels.mockReturnValue({
+ modelIds: ["gpt-5.5", "gpt-5.4"],
+ models: [createModelMetadata("gpt-5.5"), createModelMetadata("gpt-5.4")],
+ loading: false,
+ error: null,
+ });
+
+ const { container } = renderModelSelector({
+ providerType: "lime-hub",
+ model: "gpt-5.5",
+ onManageProviders,
+ });
+
+ expect(container.textContent).toContain("Lime 云端");
+ expect(container.textContent).toContain("需要登录");
+
+ const trigger = container.querySelector(
+ 'button[role="combobox"]',
+ ) as HTMLButtonElement | null;
+ if (!trigger) {
+ throw new Error("未找到模型选择触发器");
+ }
+
+ act(() => {
+ trigger.click();
+ });
+
+ expect(mockUseProviderModels).toHaveBeenCalledWith(
+ expect.objectContaining({
+ key: "lime-hub",
+ providerId: "lime-hub",
+ authStatus: "login_required",
+ }),
+ expect.objectContaining({
+ returnFullMetadata: true,
+ autoLoad: false,
+ }),
+ );
+
+ const pageText = document.body.textContent || "";
+ expect(pageText).toContain("云端模型");
+ expect(pageText).toContain("Lime 云端");
+ expect(pageText).toContain("需要登录");
+ expect(pageText).toContain("登录后会自动同步 Lime Hub 的可用模型");
+ expect(pageText).toContain("去登录");
+ expect(pageText).not.toContain("gpt-5.5");
+ expect(pageText).not.toContain("gpt-5.4");
+
+ const loginButton = Array.from(
+ document.body.querySelectorAll("button"),
+ ).find((button) => button.textContent?.includes("去登录"));
+ if (!loginButton) {
+ throw new Error("未找到去登录按钮");
+ }
+
+ act(() => {
+ loginButton.click();
+ });
+
+ expect(onManageProviders).toHaveBeenCalledTimes(1);
+ });
+
it("未知 anthropic-compatible Provider 应在选择器中展示显式缓存提示", () => {
mockUseConfiguredProviders.mockReturnValue({
providers: [
diff --git a/src/components/input-kit/ModelSelector.tsx b/src/components/input-kit/ModelSelector.tsx
index 85fe2990c..d5c3ff804 100644
--- a/src/components/input-kit/ModelSelector.tsx
+++ b/src/components/input-kit/ModelSelector.tsx
@@ -54,6 +54,16 @@ const THEME_LABEL_MAP: Record = {
general: "通用对话",
};
+function resolveProviderSelectionValue(provider: ConfiguredProvider): string {
+ return provider.providerId ?? provider.key;
+}
+
+function resolveInitialProviderModel(provider: ConfiguredProvider): string {
+ return (
+ provider.customModels?.find((modelId) => modelId.trim().length > 0) ?? ""
+ );
+}
+
export interface ModelSelectorProps {
providerType: string;
setProviderType: (type: string) => void;
@@ -77,6 +87,7 @@ export interface ModelSelectorProps {
model: EnhancedModelMetadata,
provider: ConfiguredProvider,
) => boolean;
+ getFallbackModels?: (provider: ConfiguredProvider) => EnhancedModelMetadata[];
emptyStateTitle?: string;
emptyStateDescription?: string;
}
@@ -101,6 +112,7 @@ export const ModelSelector: React.FC = ({
suppressAutoSelection = false,
providerFilter,
modelFilter,
+ getFallbackModels,
emptyStateTitle = "工具模型未配置",
emptyStateDescription = "配置工具模型以获得更好的对话标题和记忆管理。",
}) => {
@@ -199,6 +211,8 @@ export const ModelSelector: React.FC = ({
const selectedProvider = useMemo(() => {
return findConfiguredProviderBySelection(configuredProviders, providerType);
}, [configuredProviders, providerType]);
+ const selectedProviderLoginRequired =
+ selectedProvider?.authStatus === "login_required";
const selectedProviderVisible = useMemo(
() =>
selectedProvider
@@ -226,7 +240,7 @@ export const ModelSelector: React.FC = ({
selectedProvider,
{
returnFullMetadata: true,
- autoLoad: shouldLoadModels,
+ autoLoad: shouldLoadModels && !selectedProviderLoginRequired,
...providerModelLoadOptions,
},
);
@@ -235,13 +249,35 @@ export const ModelSelector: React.FC = ({
return filterModelsByTheme(activeTheme, providerModels);
}, [activeTheme, providerModels]);
const visibleModels = useMemo(() => {
- if (!selectedProvider || !modelFilter) {
- return filteredResult.models;
+ const baseModels =
+ selectedProvider && modelFilter
+ ? filteredResult.models.filter((item) =>
+ modelFilter(item, selectedProvider),
+ )
+ : filteredResult.models;
+
+ if (
+ baseModels.length > 0 ||
+ !selectedProvider ||
+ selectedProviderLoginRequired ||
+ !getFallbackModels
+ ) {
+ return baseModels;
}
- return filteredResult.models.filter((item) =>
- modelFilter(item, selectedProvider),
- );
- }, [filteredResult.models, modelFilter, selectedProvider]);
+
+ const fallbackModels = getFallbackModels(selectedProvider);
+ if (!selectedProvider || !modelFilter) {
+ return fallbackModels;
+ }
+
+ return fallbackModels.filter((item) => modelFilter(item, selectedProvider));
+ }, [
+ filteredResult.models,
+ getFallbackModels,
+ modelFilter,
+ selectedProvider,
+ selectedProviderLoginRequired,
+ ]);
const modelOptions = useMemo(
() =>
@@ -287,7 +323,6 @@ export const ModelSelector: React.FC = ({
() => modelOptions.filter((item) => item.compatibilityIssue).length,
[modelOptions],
);
-
useEffect(() => {
if (hasInitialized.current) return;
if (!shouldLoadProviders) return;
@@ -315,6 +350,7 @@ export const ModelSelector: React.FC = ({
useEffect(() => {
if (!shouldLoadModels) return;
if (!selectedProvider) return;
+ if (selectedProvider.authStatus === "login_required") return;
if (modelsLoading) return;
if (allowAutoModel || suppressAutoSelection) return;
@@ -388,9 +424,12 @@ export const ModelSelector: React.FC = ({
(allowAutoProvider && !providerType.trim()
? autoProviderLabel
: fallbackProviderLabel);
- const selectedModelLabel = showPlaceholderSelection
- ? placeholderLabel
- : model || (allowAutoModel ? autoModelLabel : "选择模型");
+ const selectedModelLabel =
+ !showPlaceholderSelection && selectedProviderLoginRequired
+ ? "需要登录"
+ : showPlaceholderSelection
+ ? placeholderLabel
+ : model || (allowAutoModel ? autoModelLabel : "选择模型");
const compactModelLabel = selectedModelLabel;
const normalizedTheme = (activeTheme || "").toLowerCase();
const activeThemeLabel =
@@ -639,11 +678,16 @@ export const ModelSelector: React.FC = ({
return (