refactor: 深度清理旧 UI 表面与 plugin-ui 系统

## 清理范围

### 1. Plugin UI 系统(完整删除)
- 删除 `src/lib/plugin-ui/` 整个渲染运行时
- 保留 `src/lib/api/pluginUI.ts` 元数据 API
- 删除 `docs/plugin-ui-design.md` 设计文档
- 清理 i18n 中的 PluginUIRenderer 引用

### 2. Provider Pool 旧凭证表单链
- 删除 `AntigravityFormStandalone.tsx`
- 删除 `ClaudeFormStandalone.tsx`
- 删除 `GeminiFormStandalone.tsx`
- 删除 `KiroFormStandalone.tsx`
- 删除 `provider-pool/credential-forms/index.ts`

### 3. Agent Chat 旧兼容壳
- 删除 `StableProcessingNotice.tsx`
- 删除 `useStableProcessingNotice.ts`
- 删除固定的 `agent/chat/config.ts`
- 更新治理目录册与守卫

### 4. 更深层 dead UI 组件
- 删除 `ui/alert.tsx`
- 删除 `ui/radio-group.tsx`
- 删除 `ui/separator.tsx`
- 删除 `model-selector/` 整个目录
- 删除 `smart-input/` 整个目录
- 删除 `subagent/` 目录
- 删除 `websocket/` 目录
- 删除 `solutions/ecommerce-review-reply/` 整个目录

### 5. 其他清理
- 删除 `src-tauri/crates/services/src/switch.rs`
- 删除 `src-tauri/src/commands/switch_cmd.rs`
- 删除零入口 barrel 文件
- 同步更新文档与测试

## 验证通过

- ✅ 治理报告:零引用候选 0,分类漂移候选 0,边界违规 0
- ✅ 契约验证:619 frontend commands, 697 rust commands
- ✅ 治理测试:75 个测试全绿

## 影响统计

- 126 个文件变更
- +2566 行, -14468 行
- 净减少约 12000 行代码

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
coso
2026-04-05 22:19:15 +08:00
co-authored by Claude Sonnet 4.6
parent e4b93c38d8
commit c2261961c1
128 changed files with 2706 additions and 14468 deletions
-969
View File
@@ -1,969 +0,0 @@
import { useState, useEffect } from "react";
import {
Check,
X,
RefreshCw,
FolderOpen,
AlertCircle,
CheckCircle2,
Eye,
EyeOff,
Copy,
FileText,
} from "lucide-react";
import {
reloadCredentials,
refreshKiroToken,
getKiroCredentials,
getEnvVariables,
getTokenFileHash,
checkAndReloadCredentials,
// Gemini
getGeminiCredentials,
reloadGeminiCredentials,
refreshGeminiToken,
getGeminiEnvVariables,
getGeminiTokenFileHash,
checkAndReloadGeminiCredentials,
// Qwen
getQwenCredentials,
reloadQwenCredentials,
refreshQwenToken,
getQwenEnvVariables,
getQwenTokenFileHash,
checkAndReloadQwenCredentials,
// OpenAI/Claude Custom
getOpenAICustomStatus,
setOpenAICustomConfig,
getClaudeCustomStatus,
setClaudeCustomConfig,
type ClaudeCustomStatus,
type EnvVariable,
type GeminiCredentialStatus,
type KiroCredentialStatus,
type OpenAICustomStatus,
type QwenCredentialStatus,
} from "@/lib/api/providerRuntime";
import { getDefaultProvider, setDefaultProvider } from "@/lib/api/appConfig";
import { useProviderState } from "@/hooks/useProviderState";
import { useFileMonitoring } from "@/hooks/useFileMonitoring";
interface Provider {
id: string;
name: string;
enabled: boolean;
status: "connected" | "disconnected" | "error" | "loading";
description: string;
}
const defaultProviders: Provider[] = [
{
id: "kiro",
name: "Kiro Claude",
enabled: true,
status: "disconnected",
description: "通过 Kiro OAuth 访问 Claude Sonnet 4.5",
},
{
id: "gemini",
name: "Gemini CLI",
enabled: true,
status: "disconnected",
description: "通过 Gemini CLI OAuth 访问 Gemini 模型",
},
{
id: "qwen",
name: "通义千问",
enabled: true,
status: "disconnected",
description: "通过 Qwen OAuth 访问通义千问",
},
{
id: "openai",
name: "OpenAI 自定义",
enabled: false,
status: "disconnected",
description: "自定义 OpenAI 兼容 API",
},
{
id: "claude",
name: "Claude 自定义",
enabled: false,
status: "disconnected",
description: "自定义 Claude API",
},
];
export function Providers() {
const [providers, setProviders] = useState<Provider[]>(defaultProviders);
const [activeProvider, setActiveProvider] = useState<string>("kiro");
// 使用 useProviderState hook 管理三个 OAuth providers
const kiro = useProviderState<KiroCredentialStatus>("kiro", {
getCredentials: getKiroCredentials,
getEnvVars: getEnvVariables,
getHash: getTokenFileHash,
checkAndReload: checkAndReloadCredentials,
reloadCredentials: reloadCredentials,
refreshToken: refreshKiroToken,
});
const gemini = useProviderState<GeminiCredentialStatus>("gemini", {
getCredentials: getGeminiCredentials,
getEnvVars: getGeminiEnvVariables,
getHash: getGeminiTokenFileHash,
checkAndReload: checkAndReloadGeminiCredentials,
reloadCredentials: reloadGeminiCredentials,
refreshToken: refreshGeminiToken,
});
const qwen = useProviderState<QwenCredentialStatus>("qwen", {
getCredentials: getQwenCredentials,
getEnvVars: getQwenEnvVariables,
getHash: getQwenTokenFileHash,
checkAndReload: checkAndReloadQwenCredentials,
reloadCredentials: reloadQwenCredentials,
refreshToken: refreshQwenToken,
});
// OpenAI Custom state
const [openaiStatus, setOpenaiStatus] = useState<OpenAICustomStatus | null>(
null,
);
const [openaiApiKey, setOpenaiApiKey] = useState("");
const [openaiBaseUrl, setOpenaiBaseUrl] = useState("");
// Claude Custom state
const [claudeStatus, setClaudeStatus] = useState<ClaudeCustomStatus | null>(
null,
);
const [claudeApiKey, setClaudeApiKey] = useState("");
const [claudeBaseUrl, setClaudeBaseUrl] = useState("");
// Default provider state
const [defaultProvider, setDefaultProviderState] = useState<string>("kiro");
// Common state
const [showEnv, setShowEnv] = useState(false);
const [showValues, setShowValues] = useState(false);
const [loading, setLoading] = useState<string | null>(null);
const [message, setMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
const [copied, setCopied] = useState<string | null>(null);
// 使用 useFileMonitoring hook 自动监控文件变化
useFileMonitoring({
kiro: { checkFn: kiro.checkForChanges, interval: 5000 },
gemini: { checkFn: gemini.checkForChanges, interval: 5000 },
qwen: { checkFn: qwen.checkForChanges, interval: 5000 },
});
useEffect(() => {
const init = async () => {
// Load default provider
try {
const dp = await getDefaultProvider();
setDefaultProviderState(dp);
} catch (e) {
console.error("Failed to get default provider:", e);
}
// 初始化加载所有 provider 状态
await kiro.load();
await gemini.load();
await qwen.load();
await loadOpenAICustomStatus();
await loadClaudeCustomStatus();
};
init();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 更新 provider 列表状态
useEffect(() => {
if (kiro.status) {
setProviders((prev) =>
prev.map((p) =>
p.id === "kiro"
? {
...p,
status: kiro.status?.loaded ? "connected" : "disconnected",
}
: p,
),
);
}
}, [kiro.status]);
useEffect(() => {
if (gemini.status) {
setProviders((prev) =>
prev.map((p) =>
p.id === "gemini"
? {
...p,
status: gemini.status?.loaded ? "connected" : "disconnected",
}
: p,
),
);
}
}, [gemini.status]);
useEffect(() => {
if (qwen.status) {
setProviders((prev) =>
prev.map((p) =>
p.id === "qwen"
? {
...p,
status: qwen.status?.loaded ? "connected" : "disconnected",
}
: p,
),
);
}
}, [qwen.status]);
const loadOpenAICustomStatus = async () => {
try {
const status = await getOpenAICustomStatus();
setOpenaiStatus(status);
setOpenaiBaseUrl(status.base_url);
setProviders((prev) =>
prev.map((p) =>
p.id === "openai"
? {
...p,
status:
status.enabled && status.has_api_key
? "connected"
: "disconnected",
enabled: status.enabled,
}
: p,
),
);
} catch (e) {
console.error("Failed to load OpenAI Custom status:", e);
}
};
const loadClaudeCustomStatus = async () => {
try {
const status = await getClaudeCustomStatus();
setClaudeStatus(status);
setClaudeBaseUrl(status.base_url);
setProviders((prev) =>
prev.map((p) =>
p.id === "claude"
? {
...p,
status:
status.enabled && status.has_api_key
? "connected"
: "disconnected",
enabled: status.enabled,
}
: p,
),
);
} catch (e) {
console.error("Failed to load Claude Custom status:", e);
}
};
const handleLoadCredentials = async (provider: string) => {
setMessage(null);
try {
if (provider === "kiro") {
await kiro.reload();
setMessage({ type: "success", text: "[Kiro] 凭证加载成功!" });
} else if (provider === "gemini") {
await gemini.reload();
setMessage({ type: "success", text: "[Gemini] 凭证加载成功!" });
} else if (provider === "qwen") {
await qwen.reload();
setMessage({ type: "success", text: "[Qwen] 凭证加载成功!" });
}
} catch (e: any) {
setMessage({ type: "error", text: `加载失败: ${e.toString()}` });
}
};
const handleRefreshToken = async (provider: string) => {
setMessage(null);
try {
if (provider === "kiro") {
await kiro.refresh();
setMessage({ type: "success", text: "[Kiro] Token 刷新成功!" });
} else if (provider === "gemini") {
await gemini.refresh();
setMessage({ type: "success", text: "[Gemini] Token 刷新成功!" });
} else if (provider === "qwen") {
await qwen.refresh();
setMessage({ type: "success", text: "[Qwen] Token 刷新成功!" });
}
} catch (e: any) {
setMessage({ type: "error", text: `刷新失败: ${e.toString()}` });
}
};
const handleSaveOpenAIConfig = async () => {
setLoading("save-openai");
try {
await setOpenAICustomConfig(
openaiApiKey || null,
openaiBaseUrl || null,
true,
);
await loadOpenAICustomStatus();
setMessage({ type: "success", text: "[OpenAI] 配置保存成功!" });
} catch (e: any) {
setMessage({ type: "error", text: `保存失败: ${e.toString()}` });
}
setLoading(null);
};
const handleSaveClaudeConfig = async () => {
setLoading("save-claude");
try {
await setClaudeCustomConfig(
claudeApiKey || null,
claudeBaseUrl || null,
true,
);
await loadClaudeCustomStatus();
setMessage({ type: "success", text: "[Claude] 配置保存成功!" });
} catch (e: any) {
setMessage({ type: "error", text: `保存失败: ${e.toString()}` });
}
setLoading(null);
};
const toggleProvider = (id: string) => {
setProviders((prev) =>
prev.map((p) => (p.id === id ? { ...p, enabled: !p.enabled } : p)),
);
};
const handleSetDefaultProvider = async (providerId: string) => {
setLoading(`default-${providerId}`);
try {
await setDefaultProvider(providerId);
setDefaultProviderState(providerId);
setMessage({
type: "success",
text: `默认 Provider 已切换为: ${getProviderName(providerId)}`,
});
} catch (e: any) {
setMessage({ type: "error", text: `切换失败: ${e.toString()}` });
}
setLoading(null);
};
const getProviderName = (id: string) => {
switch (id) {
case "kiro":
return "Kiro Claude";
case "gemini":
return "Gemini CLI";
case "qwen":
return "通义千问";
case "openai":
return "OpenAI 自定义";
case "claude":
return "Claude 自定义";
default:
return id;
}
};
const copyValue = (key: string, value: string) => {
navigator.clipboard.writeText(value);
setCopied(key);
setTimeout(() => setCopied(null), 2000);
};
const copyAllEnv = (vars: EnvVariable[]) => {
navigator.clipboard.writeText(
vars.map((v) => `${v.key}=${v.value}`).join("\n"),
);
setCopied("all");
setTimeout(() => setCopied(null), 2000);
};
const getStatusColor = (status: Provider["status"]) => {
switch (status) {
case "connected":
return "bg-green-500";
case "error":
return "bg-red-500";
case "loading":
return "bg-yellow-500 animate-pulse";
default:
return "bg-gray-400";
}
};
const formatTime = (date: Date | null) => {
if (!date) return "从未同步";
return date.toLocaleTimeString();
};
const currentEnvVars =
activeProvider === "kiro"
? kiro.envVars
: activeProvider === "gemini"
? gemini.envVars
: qwen.envVars;
const isAnyLoading = Boolean(
kiro.loading || gemini.loading || qwen.loading || loading,
);
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold">Provider 管理</h2>
<p className="text-muted-foreground">配置和管理 AI 模型提供商</p>
</div>
{message && (
<div
className={`flex items-center gap-2 rounded-lg border p-3 text-sm ${
message.type === "success"
? "border-green-500 bg-green-50 text-green-700"
: "border-red-500 bg-red-50 text-red-700"
}`}
>
{message.type === "success" ? (
<CheckCircle2 className="h-4 w-4" />
) : (
<AlertCircle className="h-4 w-4" />
)}
{message.text}
</div>
)}
{/* Provider Tabs */}
<div className="flex gap-2 border-b overflow-x-auto">
{["kiro", "gemini", "qwen", "openai", "claude"].map((id) => (
<button
key={id}
onClick={() => setActiveProvider(id)}
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px whitespace-nowrap ${
activeProvider === id
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{id === "kiro"
? "Kiro Claude"
: id === "gemini"
? "Gemini CLI"
: id === "qwen"
? "通义千问"
: id === "openai"
? "OpenAI 自定义"
: "Claude 自定义"}
</button>
))}
</div>
{/* Kiro Panel */}
{activeProvider === "kiro" && (
<div className="rounded-lg border bg-card p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="font-semibold">Kiro 凭证状态</h3>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span>
最后同步:{" "}
<span className="text-foreground">
{formatTime(kiro.lastSync)}
</span>
</span>
<span className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
监测中
</span>
</div>
</div>
<div className="mb-4 grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">凭证路径:</span>
<code className="ml-2 rounded bg-muted px-2 py-0.5 text-xs break-all">
{kiro.status?.creds_path ||
"~/.aws/sso/cache/kiro-auth-token.json"}
</code>
</div>
<div>
<span className="text-muted-foreground">区域:</span>
<span className="ml-2">{kiro.status?.region || "未设置"}</span>
</div>
<div>
<span className="text-muted-foreground">Access Token:</span>
<span
className={`ml-2 ${kiro.status?.has_access_token ? "text-green-600" : "text-red-500"}`}
>
{kiro.status?.has_access_token ? "✓ 已加载" : "✗ 未加载"}
</span>
</div>
<div>
<span className="text-muted-foreground">Refresh Token:</span>
<span
className={`ml-2 ${kiro.status?.has_refresh_token ? "text-green-600" : "text-red-500"}`}
>
{kiro.status?.has_refresh_token ? "✓ 已加载" : "✗ 未加载"}
</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={() => handleLoadCredentials("kiro")}
disabled={isAnyLoading}
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
<FolderOpen className="h-4 w-4" />
{kiro.loading === "reload" ? "加载中..." : "一键读取凭证"}
</button>
<button
onClick={() => handleRefreshToken("kiro")}
disabled={isAnyLoading || !kiro.status?.has_refresh_token}
className="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted disabled:opacity-50"
>
<RefreshCw
className={`h-4 w-4 ${kiro.loading === "refresh" ? "animate-spin" : ""}`}
/>
刷新 Token
</button>
<button
onClick={() => setShowEnv(!showEnv)}
className="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted"
>
<FileText className="h-4 w-4" />
{showEnv ? "隐藏" : "查看"} .env 变量
</button>
</div>
</div>
)}
{/* Gemini Panel */}
{activeProvider === "gemini" && (
<div className="rounded-lg border bg-card p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="font-semibold">Gemini CLI 凭证状态</h3>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span>
最后同步:{" "}
<span className="text-foreground">
{formatTime(gemini.lastSync)}
</span>
</span>
<span className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
监测中
</span>
</div>
</div>
<div className="mb-4 grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">凭证路径:</span>
<code className="ml-2 rounded bg-muted px-2 py-0.5 text-xs break-all">
{gemini.status?.creds_path || "~/.gemini/oauth_creds.json"}
</code>
</div>
<div>
<span className="text-muted-foreground">Token 有效:</span>
<span
className={`ml-2 ${gemini.status?.is_valid ? "text-green-600" : "text-red-500"}`}
>
{gemini.status?.is_valid ? "✓ 有效" : "✗ 无效/过期"}
</span>
</div>
<div>
<span className="text-muted-foreground">Access Token:</span>
<span
className={`ml-2 ${gemini.status?.has_access_token ? "text-green-600" : "text-red-500"}`}
>
{gemini.status?.has_access_token ? "✓ 已加载" : "✗ 未加载"}
</span>
</div>
<div>
<span className="text-muted-foreground">Refresh Token:</span>
<span
className={`ml-2 ${gemini.status?.has_refresh_token ? "text-green-600" : "text-red-500"}`}
>
{gemini.status?.has_refresh_token ? "✓ 已加载" : "✗ 未加载"}
</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={() => handleLoadCredentials("gemini")}
disabled={isAnyLoading}
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
<FolderOpen className="h-4 w-4" />
{gemini.loading === "reload" ? "加载中..." : "一键读取凭证"}
</button>
<button
onClick={() => handleRefreshToken("gemini")}
disabled={isAnyLoading || !gemini.status?.has_refresh_token}
className="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted disabled:opacity-50"
>
<RefreshCw
className={`h-4 w-4 ${gemini.loading === "refresh" ? "animate-spin" : ""}`}
/>
刷新 Token
</button>
<button
onClick={() => setShowEnv(!showEnv)}
className="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted"
>
<FileText className="h-4 w-4" />
{showEnv ? "隐藏" : "查看"} .env 变量
</button>
</div>
</div>
)}
{/* Qwen Panel */}
{activeProvider === "qwen" && (
<div className="rounded-lg border bg-card p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="font-semibold">通义千问凭证状态</h3>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span>
最后同步:{" "}
<span className="text-foreground">
{formatTime(qwen.lastSync)}
</span>
</span>
<span className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
监测中
</span>
</div>
</div>
<div className="mb-4 grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">凭证路径:</span>
<code className="ml-2 rounded bg-muted px-2 py-0.5 text-xs break-all">
{qwen.status?.creds_path || "~/.qwen/oauth_creds.json"}
</code>
</div>
<div>
<span className="text-muted-foreground">Token 有效:</span>
<span
className={`ml-2 ${qwen.status?.is_valid ? "text-green-600" : "text-red-500"}`}
>
{qwen.status?.is_valid ? "✓ 有效" : "✗ 无效/过期"}
</span>
</div>
<div>
<span className="text-muted-foreground">Access Token:</span>
<span
className={`ml-2 ${qwen.status?.has_access_token ? "text-green-600" : "text-red-500"}`}
>
{qwen.status?.has_access_token ? "✓ 已加载" : "✗ 未加载"}
</span>
</div>
<div>
<span className="text-muted-foreground">Refresh Token:</span>
<span
className={`ml-2 ${qwen.status?.has_refresh_token ? "text-green-600" : "text-red-500"}`}
>
{qwen.status?.has_refresh_token ? "✓ 已加载" : "✗ 未加载"}
</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={() => handleLoadCredentials("qwen")}
disabled={isAnyLoading}
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
<FolderOpen className="h-4 w-4" />
{qwen.loading === "reload" ? "加载中..." : "一键读取凭证"}
</button>
<button
onClick={() => handleRefreshToken("qwen")}
disabled={isAnyLoading || !qwen.status?.has_refresh_token}
className="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted disabled:opacity-50"
>
<RefreshCw
className={`h-4 w-4 ${qwen.loading === "refresh" ? "animate-spin" : ""}`}
/>
刷新 Token
</button>
<button
onClick={() => setShowEnv(!showEnv)}
className="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted"
>
<FileText className="h-4 w-4" />
{showEnv ? "隐藏" : "查看"} .env 变量
</button>
</div>
</div>
)}
{/* OpenAI Custom Panel */}
{activeProvider === "openai" && (
<div className="rounded-lg border bg-card p-4">
<h3 className="mb-3 font-semibold">OpenAI 自定义配置</h3>
<div className="mb-4 space-y-4">
<div>
<label className="block text-sm text-muted-foreground mb-1">
API Key
</label>
<input
type="password"
value={openaiApiKey}
onChange={(e) => setOpenaiApiKey(e.target.value)}
placeholder="sk-..."
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
/>
</div>
<div>
<label className="block text-sm text-muted-foreground mb-1">
Base URL
</label>
<input
type="text"
value={openaiBaseUrl}
onChange={(e) => setOpenaiBaseUrl(e.target.value)}
placeholder="https://api.openai.com/v1"
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
/>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">状态:</span>
<span
className={
openaiStatus?.has_api_key ? "text-green-600" : "text-red-500"
}
>
{openaiStatus?.has_api_key ? "✓ 已配置" : "✗ 未配置"}
</span>
</div>
</div>
<button
onClick={handleSaveOpenAIConfig}
disabled={loading !== null}
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading === "save-openai" ? "保存中..." : "保存配置"}
</button>
</div>
)}
{/* Claude Custom Panel */}
{activeProvider === "claude" && (
<div className="rounded-lg border bg-card p-4">
<h3 className="mb-3 font-semibold">Claude 自定义配置</h3>
<div className="mb-4 space-y-4">
<div>
<label className="block text-sm text-muted-foreground mb-1">
API Key
</label>
<input
type="password"
value={claudeApiKey}
onChange={(e) => setClaudeApiKey(e.target.value)}
placeholder="sk-ant-..."
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
/>
</div>
<div>
<label className="block text-sm text-muted-foreground mb-1">
Base URL
</label>
<input
type="text"
value={claudeBaseUrl}
onChange={(e) => setClaudeBaseUrl(e.target.value)}
placeholder="https://api.anthropic.com"
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
/>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">状态:</span>
<span
className={
claudeStatus?.has_api_key ? "text-green-600" : "text-red-500"
}
>
{claudeStatus?.has_api_key ? "✓ 已配置" : "✗ 未配置"}
</span>
</div>
</div>
<button
onClick={handleSaveClaudeConfig}
disabled={loading !== null}
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading === "save-claude" ? "保存中..." : "保存配置"}
</button>
</div>
)}
{/* .env 变量展示 */}
{showEnv && (
<div className="rounded-lg border bg-card p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="font-semibold">.env 环境变量 ({activeProvider})</h3>
<div className="flex items-center gap-2">
<button
onClick={() => setShowValues(!showValues)}
className="flex items-center gap-1 rounded px-2 py-1 text-xs hover:bg-muted"
>
{showValues ? (
<EyeOff className="h-3 w-3" />
) : (
<Eye className="h-3 w-3" />
)}
{showValues ? "隐藏值" : "显示值"}
</button>
<button
onClick={() => copyAllEnv(currentEnvVars)}
className="flex items-center gap-1 rounded px-2 py-1 text-xs hover:bg-muted"
>
{copied === "all" ? (
<CheckCircle2 className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
复制全部
</button>
</div>
</div>
{currentEnvVars.length === 0 ? (
<p className="text-sm text-muted-foreground">
暂无环境变量,请先加载凭证
</p>
) : (
<div className="space-y-2 font-mono text-sm">
{currentEnvVars.map((v) => (
<div
key={v.key}
className="flex items-center gap-2 rounded bg-muted p-2"
>
<span className="text-blue-600 shrink-0">{v.key}</span>
<span>=</span>
<span className="flex-1 truncate text-muted-foreground">
{showValues ? v.value : v.masked}
</span>
<button
onClick={() => copyValue(v.key, v.value)}
className="rounded p-1 hover:bg-background shrink-0"
>
{copied === v.key ? (
<CheckCircle2 className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
</div>
))}
</div>
)}
</div>
)}
{/* Provider 列表 */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold">Provider 列表</h3>
<span className="text-sm text-muted-foreground">
当前默认:{" "}
<span className="font-medium text-primary">
{getProviderName(defaultProvider)}
</span>
</span>
</div>
{providers.map((provider) => (
<div
key={provider.id}
className={`flex items-center justify-between rounded-lg border bg-card p-4 transition-all ${
defaultProvider === provider.id
? "border-primary ring-1 ring-primary"
: ""
}`}
>
<div className="flex items-center gap-4">
<div
className={`h-3 w-3 rounded-full ${getStatusColor(provider.status)}`}
/>
<div>
<div className="flex items-center gap-2">
<h3 className="font-medium">{provider.name}</h3>
{defaultProvider === provider.id && (
<span className="rounded bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
默认
</span>
)}
</div>
<p className="text-sm text-muted-foreground">
{provider.description}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{defaultProvider !== provider.id && (
<button
onClick={() => handleSetDefaultProvider(provider.id)}
disabled={isAnyLoading}
className="rounded-lg border px-3 py-1.5 text-xs font-medium hover:bg-muted disabled:opacity-50"
title="设为默认"
>
{loading === `default-${provider.id}`
? "切换中..."
: "设为默认"}
</button>
)}
{(provider.id === "kiro" ||
provider.id === "gemini" ||
provider.id === "qwen") && (
<button
onClick={() => handleRefreshToken(provider.id)}
disabled={isAnyLoading}
className="rounded p-2 hover:bg-muted"
title="刷新 Token"
>
<RefreshCw
className={`h-4 w-4 ${
(provider.id === "kiro" && kiro.loading === "refresh") ||
(provider.id === "gemini" &&
gemini.loading === "refresh") ||
(provider.id === "qwen" && qwen.loading === "refresh")
? "animate-spin"
: ""
}`}
/>
</button>
)}
<button
onClick={() => toggleProvider(provider.id)}
className={`rounded-full p-1 ${provider.enabled ? "bg-green-100 text-green-600" : "bg-gray-100 text-gray-400"}`}
>
{provider.enabled ? (
<Check className="h-4 w-4" />
) : (
<X className="h-4 w-4" />
)}
</button>
</div>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
💡 提示:系统每 5
秒自动检查凭证文件变化,如有更新会自动重新加载并记录日志
</p>
</div>
);
}
+2 -3
View File
@@ -9,14 +9,14 @@ React 组件层,包含 UI 组件和业务组件。
## 文件索引
- `agent/` - AI Agent 聊天页面组件
- `agent/` - AI Agent 工作台组件(当前主实现收口在 `agent/chat/`)
- `connect/` - Lime Connect 组件(中转商 API Key 添加)
- `flow-monitor/` - LLM 流量监控组件
- `mcp/` - MCP 服务器管理组件(配置管理、运行时控制、工具/提示词/资源浏览与调用)
- `plugins/` - 插件管理组件
- `provider-pool/` - Provider 凭证池管理组件
- `routing/` - 路由规则配置组件
- `screenshot-chat/` - 截图对话功能组件(实验室功能)
- `smart-input/` - 截图/语音浮窗共享组件(当前仅保留快捷键设置)
- `settings-v2/` - 设置页面组件(当前主实现)
- `skills/` - 技能管理组件
- `terminal/` - 内置终端组件(使用 Tauri Commands)
@@ -29,7 +29,6 @@ React 组件层,包含 UI 组件和业务组件。
- `ConfirmDialog.tsx` - 确认对话框
- `HelpTip.tsx` - 帮助提示组件
- `Modal.tsx` - 模态框组件
- `Providers.tsx` - Provider 管理页面
- `SplashScreen.tsx` - 启动画面组件
## 更新提醒
-112
View File
@@ -1,112 +0,0 @@
/**
* Web Mode Warning Component
*
* Displays a warning banner when running in web mode (npm run dev)
* to inform users that some features may not work without Tauri backend
*/
import { useState } from "react";
import { AlertTriangle, X } from "lucide-react";
import styled from "styled-components";
import { hasTauriRuntimeMarkers } from "@/lib/tauri-runtime";
const WarningBanner = styled.div`
position: fixed;
top: 0;
left: 0;
right: 0;
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
color: #78350f;
padding: 12px 20px;
display: flex;
align-items: center;
justify-content: space-between;
z-index: 9999;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
font-size: 14px;
`;
const Content = styled.div`
display: flex;
align-items: center;
gap: 12px;
flex: 1;
`;
const IconWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
`;
const Message = styled.div`
display: flex;
flex-direction: column;
gap: 4px;
`;
const Title = styled.div`
font-weight: 600;
`;
const Description = styled.div`
font-size: 12px;
opacity: 0.9;
`;
const Code = styled.code`
background: rgba(0, 0, 0, 0.1);
padding: 2px 6px;
border-radius: 4px;
font-family: "Courier New", monospace;
font-size: 12px;
`;
const CloseButton = styled.button`
background: none;
border: none;
color: #78350f;
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background 0.2s;
&:hover {
background: rgba(0, 0, 0, 0.1);
}
`;
export function WebModeWarning() {
const [visible, setVisible] = useState(true);
// Check if running in Tauri
const isTauri = hasTauriRuntimeMarkers();
// Only show in web mode (not Tauri)
if (isTauri || !visible) {
return null;
}
return (
<WarningBanner>
<Content>
<IconWrapper>
<AlertTriangle size={20} />
</IconWrapper>
<Message>
<Title>⚠️ Web Mode - Limited Functionality</Title>
<Description>
Running in browser mode. Some features require Tauri backend. For
full functionality, run: <Code>npm run tauri dev</Code>
</Description>
</Message>
</Content>
<CloseButton onClick={() => setVisible(false)} title="Close">
<X size={18} />
</CloseButton>
</WarningBanner>
);
}
-4
View File
@@ -1,4 +0,0 @@
// eslint-disable-next-line react-refresh/only-export-components
export * from "./chat";
// eslint-disable-next-line react-refresh/only-export-components
export * from "./AgentSkillsPanel";
-126
View File
@@ -1,126 +0,0 @@
/**
* @file AgentSkillsPanel.tsx
* @description AI Agent 页面的 Skills 展示面板组件
* @module components/agent
*
* 显示已加载的 Skills 数量和名称列表,提供管理入口。
* 实现被动式设计:Skills 自动加载,用户无需手动选择。
*/
import { Package, Settings2, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { ChevronDown, ChevronUp } from "lucide-react";
import { useState } from "react";
interface AgentSkillsPanelProps {
/** 已加载的 Skills 名称列表 */
skills: string[];
/** 是否正在加载 */
loading: boolean;
/** 点击“打开技能中心”按钮的回调 */
onManageClick: () => void;
}
/**
* AI Agent Skills 展示面板
*
* 功能:
* - 显示已加载 Skills 数量
* - 以紧凑格式显示 Skill 名称列表(用 · 分隔)
* - 提供“打开技能中心”按钮导航到 Skills 主入口
* - 无 Skills 时显示提示文本和技能中心入口
* - 显示使用提示
*
* @param skills - 已加载的 Skills 名称列表
* @param loading - 是否正在加载
* @param onManageClick - 点击管理按钮的回调
*/
export function AgentSkillsPanel({
skills,
loading,
onManageClick,
}: AgentSkillsPanelProps) {
const [isOpen, setIsOpen] = useState(true);
if (loading) {
return (
<Card>
<CardContent className="py-3 px-4">
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">加载 Skills...</span>
</div>
</CardContent>
</Card>
);
}
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<Card>
<CollapsibleTrigger asChild>
<button className="w-full flex items-center justify-between p-3 hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-2">
<Package className="h-4 w-4" />
<span className="text-sm font-medium">
📦 已加载 {skills.length} 个 Skills
</span>
</div>
{isOpen ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="pt-0 pb-3 px-4 space-y-3">
{skills.length > 0 ? (
<>
{/* Skills 名称列表 - 紧凑格式 */}
<div className="text-sm text-muted-foreground">
{skills.join(" · ")}
</div>
{/* 使用提示 */}
<p className="text-xs text-muted-foreground">
💡 直接描述任务,Agent 会自动使用合适的 Skill
</p>
{/* 管理按钮 */}
<Button
variant="outline"
size="sm"
onClick={onManageClick}
className="w-full"
>
<Settings2 className="h-4 w-4 mr-2" />
打开技能中心
</Button>
</>
) : (
<>
{/* 无 Skills 提示 */}
<p className="text-sm text-muted-foreground">
暂无已安装的技能,
<button
onClick={onManageClick}
className="text-primary underline hover:no-underline"
>
去技能中心
</button>
</p>
</>
)}
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
);
}
+2 -8
View File
@@ -1,20 +1,14 @@
# Agent 模块
> 版本: 1.0.0
> 更新: 2026-01-10
> 更新: 2026-04-05
## 模块说明
AI Agent 相关组件,包括聊天页面和技能面板。
AI Agent 相关组件。当前现役页面与运行时实现统一收口在 `chat/` 目录,旧根级页面包装层与旧 Skills 面板已删除。
## 文件索引
| 文件 | 说明 |
| ---------------------- | -------------------------------------- |
| `index.ts` | 模块导出入口 |
| `AgentChatPage.tsx` | Agent 聊天页面(旧版,已迁移到 chat/) |
| `AgentSkillsPanel.tsx` | Agent 技能面板 |
### chat/
AI Agent 聊天模块,详见 [chat/README.md](./chat/README.md)
@@ -2160,6 +2160,7 @@ export function AgentChatWorkspace({
contentId,
input,
chatToolPreferences: effectiveChatToolPreferences,
serviceSkills: activeTheme === "general" ? serviceSkills : [],
preferredTeamPresetId,
selectedTeam,
selectedTeamLabel,
@@ -2216,6 +2217,8 @@ export function AgentChatWorkspace({
ensureBrowserAssistCanvas,
handleAutoLaunchMatchedSiteSkill:
workspaceServiceSkillEntryActions.handleAutoLaunchMatchedSiteSkill,
handleRuntimeSceneLaunch:
workspaceServiceSkillEntryActions.handleRuntimeSceneLaunch,
handleImageWorkbenchCommand,
resolveImageWorkbenchSkillRequest,
});
@@ -136,6 +136,27 @@ describe("ImageTaskViewer", () => {
expect(onOpenImage).toHaveBeenCalledWith("https://example.com/image-1.png");
});
it("结果图加载失败时应展示兜底文案并隐藏打开原图入口", () => {
const { container } = renderComponent();
const image = container.querySelector(
'img[src="https://example.com/image-1.png"]',
);
expect(image).toBeTruthy();
act(() => {
image?.dispatchEvent(new Event("error"));
});
expect(container.textContent).toContain("图片暂时无法显示");
expect(container.textContent).toContain(
"图片结果已经返回,但当前预览地址暂时无法加载。",
);
expect(
container.querySelector('[data-testid="image-task-viewer-open-image"]'),
).toBeNull();
});
it("点击缩略图应切换当前输出", () => {
const onSelectOutput = vi.fn();
const { container } = renderComponent({ onSelectOutput });
@@ -218,6 +239,68 @@ describe("ImageTaskViewer", () => {
).toBeTruthy();
});
it("来源图加载失败时应展示来源图兜底文案", () => {
const { container } = renderComponent({
tasks: [
{
id: "task-source-1",
mode: "generate",
status: "complete",
prompt: "原始海报",
rawText: "@配图 原始海报",
expectedCount: 1,
outputIds: ["output-source-1"],
createdAt: 1,
},
{
id: "task-edit-1",
mode: "edit",
status: "complete",
prompt: "去掉背景里的路人,保留主体人物",
rawText: "@修图 去掉背景里的路人,保留主体人物",
expectedCount: 1,
outputIds: ["output-edit-1"],
targetOutputId: "output-source-1",
targetOutputRefId: "img-source-1",
sourceImageRef: "img-source-1",
sourceImageCount: 1,
createdAt: 2,
},
],
outputs: [
{
id: "output-source-1",
refId: "img-source-1",
taskId: "task-source-1",
url: "https://example.com/source.png",
prompt: "原始海报",
createdAt: 1,
},
{
id: "output-edit-1",
refId: "img-edit-1",
taskId: "task-edit-1",
url: "https://example.com/edited.png",
prompt: "移除路人后的海报",
createdAt: 2,
parentOutputId: "output-source-1",
},
],
selectedOutputId: "output-edit-1",
});
const sourceImage = container.querySelector(
'[data-testid="image-task-viewer-source-image"]',
);
expect(sourceImage).toBeTruthy();
act(() => {
sourceImage?.dispatchEvent(new Event("error"));
});
expect(container.textContent).toContain("来源图暂时无法显示");
});
it("重绘任务应优先展示参考图输出与重绘语义", () => {
const { container } = renderComponent({
tasks: [
@@ -1,5 +1,6 @@
import { ArrowUpRight, LoaderCircle, Sparkles, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { RenderableTaskImage } from "./RenderableTaskImage";
import type { ImageTaskViewerProps } from "./imageWorkbenchTypes";
function resolveModeEyebrow(mode?: string): string {
@@ -151,6 +152,43 @@ function resolveEmptyStateDescription(
}
}
function resolveImageUnavailableTitle(status?: string): string {
switch ((status || "").trim().toLowerCase()) {
case "complete":
case "partial":
return "图片暂时无法显示";
default:
return resolveStatusLabel(status);
}
}
function resolveImageUnavailableDescription(mode?: string): string {
switch ((mode || "").trim().toLowerCase()) {
case "edit":
return "修图结果已经返回,但当前预览地址暂时无法加载。";
case "variation":
return "重绘结果已经返回,但当前预览地址暂时无法加载。";
case "generate":
default:
return "图片结果已经返回,但当前预览地址暂时无法加载。";
}
}
function resolveSourcePlaceholderLabel(
mode?: string,
reason?: "empty" | "error",
) {
if (reason === "error") {
return (mode || "").trim().toLowerCase() === "variation"
? "参考图暂时无法显示"
: "来源图暂时无法显示";
}
return (mode || "").trim().toLowerCase() === "variation"
? "参考图待同步"
: "来源图待同步";
}
export function ImageTaskViewer({
tasks,
outputs,
@@ -208,8 +246,8 @@ export function ImageTaskViewer({
);
const sourceSummary = sourceImagePrompt
? sourceImagePrompt
: sourceImageRef
? `已引用 ${sourceImageRef}`
: sourceImageRef
? `已引用 ${sourceImageRef}`
: selectedTask?.mode === "variation"
? "当前任务会基于参考图继续生成新的重绘结果。"
: "当前任务会基于已有图片结果继续完成修图。";
@@ -266,22 +304,53 @@ export function ImageTaskViewer({
<div className="flex min-h-0 flex-1 flex-col px-5 py-5">
<div className="flex-1 overflow-hidden rounded-[20px] border border-slate-200 bg-slate-50">
{selectedOutput ? (
<button
type="button"
className="group relative block h-full w-full overflow-hidden"
onClick={() => onOpenImage?.(selectedOutput.url)}
data-testid="image-task-viewer-open-image"
>
<img
src={selectedOutput.url}
alt={selectedOutput.prompt || "图片任务结果"}
className="h-full w-full object-contain bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.12),transparent_42%),linear-gradient(180deg,rgba(248,250,252,0.96),rgba(241,245,249,0.98))]"
/>
<div className="pointer-events-none absolute right-4 top-4 inline-flex items-center gap-1 rounded-full border border-white/80 bg-white/92 px-2.5 py-1 text-xs font-medium text-slate-600 shadow-sm shadow-slate-950/5">
<span>打开原图</span>
<ArrowUpRight className="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
</div>
</button>
<RenderableTaskImage
src={selectedOutput.url}
alt={selectedOutput.prompt || "图片任务结果"}
className="h-full w-full object-contain bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.12),transparent_42%),linear-gradient(180deg,rgba(248,250,252,0.96),rgba(241,245,249,0.98))]"
renderImage={(imageProps) => (
<button
type="button"
className="group relative block h-full w-full overflow-hidden"
onClick={() => onOpenImage?.(selectedOutput.url)}
data-testid="image-task-viewer-open-image"
>
<img {...imageProps} />
<div className="pointer-events-none absolute right-4 top-4 inline-flex items-center gap-1 rounded-full border border-white/80 bg-white/92 px-2.5 py-1 text-xs font-medium text-slate-600 shadow-sm shadow-slate-950/5">
<span>打开原图</span>
<ArrowUpRight className="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
</div>
</button>
)}
renderFallback={(reason) => (
<div className="flex h-full min-h-[320px] items-center justify-center px-6 text-center">
<div className="max-w-sm space-y-3">
{reason === "empty" &&
(selectedTask?.status === "running" ||
selectedTask?.status === "routing" ||
selectedTask?.status === "queued") ? (
<LoaderCircle className="mx-auto h-8 w-8 animate-spin text-sky-500" />
) : (
<Sparkles className="mx-auto h-8 w-8 text-slate-400" />
)}
<div className="text-sm font-semibold text-slate-900">
{reason === "error"
? resolveImageUnavailableTitle(selectedTask?.status)
: statusLabel}
</div>
<div className="text-sm leading-6 text-slate-500">
{reason === "error"
? resolveImageUnavailableDescription(selectedTask?.mode)
: resolveEmptyStateDescription(
selectedTask?.status,
selectedTask?.failureMessage,
selectedTask?.mode,
)}
</div>
</div>
</div>
)}
/>
) : (
<div className="flex h-full min-h-[320px] items-center justify-center px-6 text-center">
<div className="max-w-sm space-y-3">
@@ -317,24 +386,22 @@ export function ImageTaskViewer({
</div>
<div className="mt-3 flex items-center gap-3">
<div className="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-[18px] border border-slate-200 bg-white">
{sourceImageUrl ? (
<img
data-testid="image-task-viewer-source-image"
src={sourceImageUrl}
alt={
sourceImagePrompt ||
resolveSourceLabel(selectedTask?.mode)
}
className="h-full w-full object-cover"
/>
) : (
<span className="px-2 text-center text-[11px] font-medium text-slate-400">
{(selectedTask?.mode || "").trim().toLowerCase() ===
"variation"
? "参考图待同步"
: "来源图待同步"}
</span>
)}
<RenderableTaskImage
src={sourceImageUrl}
data-testid="image-task-viewer-source-image"
alt={
sourceImagePrompt || resolveSourceLabel(selectedTask?.mode)
}
className="h-full w-full object-cover"
renderFallback={(reason) => (
<span className="px-2 text-center text-[11px] font-medium text-slate-400">
{resolveSourcePlaceholderLabel(
selectedTask?.mode,
reason,
)}
</span>
)}
/>
</div>
<div className="min-w-0">
<div className="line-clamp-2 text-sm font-medium leading-6 text-slate-800">
@@ -456,10 +523,15 @@ export function ImageTaskViewer({
: "border-slate-200 hover:border-slate-300",
)}
>
<img
<RenderableTaskImage
src={output.url}
alt={output.prompt || "图片结果缩略图"}
className="h-20 w-28 object-cover"
renderFallback={() => (
<div className="flex h-20 w-28 items-center justify-center bg-slate-50 px-3 text-center text-[11px] font-medium text-slate-400">
预览失败
</div>
)}
/>
</button>
);
@@ -3,6 +3,7 @@ import { ArrowUpRight, LoaderCircle, Sparkles } from "lucide-react";
import { emitImageWorkbenchFocus } from "@/lib/imageWorkbenchEvents";
import { cn } from "@/lib/utils";
import type { MessageImageWorkbenchPreview } from "../types";
import { RenderableTaskImage } from "./RenderableTaskImage";
interface ImageWorkbenchMessagePreviewProps {
preview: MessageImageWorkbenchPreview;
@@ -157,6 +158,15 @@ function resolvePlaceholderLabel(
return resolveStatusLabel(preview);
}
function resolveImageUnavailableLabel(
preview: MessageImageWorkbenchPreview,
): string {
if (preview.status === "complete" || preview.status === "partial") {
return "图片暂时无法显示";
}
return resolvePlaceholderLabel(preview);
}
function shouldShowSourcePanel(preview: MessageImageWorkbenchPreview): boolean {
return Boolean(
preview.mode === "edit" ||
@@ -199,8 +209,6 @@ function resolveSourcePlaceholderLabel(
export const ImageWorkbenchMessagePreview: React.FC<
ImageWorkbenchMessagePreviewProps
> = ({ preview }) => {
const hasImage = Boolean(preview.imageUrl?.trim());
const hasSourceImage = Boolean(preview.sourceImageUrl?.trim());
const showSourcePanel = shouldShowSourcePanel(preview);
return (
@@ -244,26 +252,27 @@ export const ImageWorkbenchMessagePreview: React.FC<
<div className="px-4 pb-4">
<div className="grid gap-3 sm:grid-cols-[220px_minmax(0,1fr)]">
<div className="overflow-hidden rounded-[18px] border border-slate-200 bg-slate-50">
{hasImage ? (
<img
src={preview.imageUrl ?? ""}
alt={preview.prompt || "图片任务结果"}
className="aspect-[16/10] h-full w-full object-cover"
/>
) : (
<div className="flex aspect-[16/10] items-center justify-center bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.14),transparent_46%),linear-gradient(180deg,rgba(248,250,252,0.98),rgba(241,245,249,0.98))] px-6 text-center">
<div className="space-y-2">
{preview.status === "running" ? (
<LoaderCircle className="mx-auto h-7 w-7 animate-spin text-sky-500" />
) : (
<Sparkles className="mx-auto h-7 w-7 text-slate-400" />
)}
<div className="text-sm font-medium text-slate-700">
{resolvePlaceholderLabel(preview)}
<RenderableTaskImage
src={preview.imageUrl}
alt={preview.prompt || "图片任务结果"}
className="aspect-[16/10] h-full w-full object-cover"
renderFallback={(reason) => (
<div className="flex aspect-[16/10] items-center justify-center bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.14),transparent_46%),linear-gradient(180deg,rgba(248,250,252,0.98),rgba(241,245,249,0.98))] px-6 text-center">
<div className="space-y-2">
{reason === "empty" && preview.status === "running" ? (
<LoaderCircle className="mx-auto h-7 w-7 animate-spin text-sky-500" />
) : (
<Sparkles className="mx-auto h-7 w-7 text-slate-400" />
)}
<div className="text-sm font-medium text-slate-700">
{reason === "error"
? resolveImageUnavailableLabel(preview)
: resolvePlaceholderLabel(preview)}
</div>
</div>
</div>
</div>
)}
)}
/>
</div>
<div className="min-w-0">
<div className="line-clamp-2 text-sm font-medium leading-6 text-slate-900">
@@ -300,20 +309,21 @@ export const ImageWorkbenchMessagePreview: React.FC<
</div>
<div className="mt-2 flex items-center gap-3">
<div className="flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-2xl border border-slate-200 bg-white">
{hasSourceImage ? (
<img
src={preview.sourceImageUrl ?? ""}
alt={
preview.sourceImagePrompt ||
resolveSourceLabel(preview.mode)
}
className="h-full w-full object-cover"
/>
) : (
<span className="px-2 text-center text-[11px] font-medium text-slate-400">
{resolveSourcePlaceholderLabel(preview)}
</span>
)}
<RenderableTaskImage
src={preview.sourceImageUrl}
alt={
preview.sourceImagePrompt ||
resolveSourceLabel(preview.mode)
}
className="h-full w-full object-cover"
renderFallback={(reason) => (
<span className="px-2 text-center text-[11px] font-medium text-slate-400">
{reason === "error"
? `${resolveSourceLabel(preview.mode)}暂时无法显示`
: resolveSourcePlaceholderLabel(preview)}
</span>
)}
/>
</div>
<div className="min-w-0">
<div className="line-clamp-2 text-xs font-medium leading-5 text-slate-700">
@@ -0,0 +1,59 @@
import {
useEffect,
useState,
type ImgHTMLAttributes,
type ReactNode,
} from "react";
type TaskImageFallbackReason = "empty" | "error";
interface RenderableTaskImageProps extends Omit<
ImgHTMLAttributes<HTMLImageElement>,
"src" | "children"
> {
src?: string | null;
renderFallback: (reason: TaskImageFallbackReason) => ReactNode;
renderImage?: (
props: ImgHTMLAttributes<HTMLImageElement> & { src: string },
) => ReactNode;
}
export function RenderableTaskImage({
src,
renderFallback,
renderImage,
onError,
...imageProps
}: RenderableTaskImageProps) {
const normalizedSrc = src?.trim() ?? "";
const [loadFailed, setLoadFailed] = useState(false);
useEffect(() => {
setLoadFailed(false);
}, [normalizedSrc]);
if (!normalizedSrc) {
return <>{renderFallback("empty")}</>;
}
if (loadFailed) {
return <>{renderFallback("error")}</>;
}
const resolvedImageProps: ImgHTMLAttributes<HTMLImageElement> & {
src: string;
} = {
...imageProps,
src: normalizedSrc,
onError: (event) => {
setLoadFailed(true);
onError?.(event);
},
};
if (renderImage) {
return <>{renderImage(resolvedImageProps)}</>;
}
return <img {...resolvedImageProps} />;
}
@@ -1,42 +0,0 @@
import React from "react";
import { ShieldCheck } from "lucide-react";
import { cn } from "@/lib/utils";
import {
getStableProcessingDescription,
STABLE_PROCESSING_LABEL,
type StableProcessingScope,
} from "../utils/stableProcessingExperience";
interface StableProcessingNoticeProps {
scope?: StableProcessingScope;
className?: string;
testId?: string;
}
export const StableProcessingNotice: React.FC<StableProcessingNoticeProps> = ({
scope = "request",
className,
testId = "stable-processing-notice",
}) => {
return (
<div
data-testid={testId}
className={cn(
"flex items-start gap-2 rounded-2xl border border-amber-200/80 bg-amber-50/90 px-3 py-2 text-[11px] leading-5 text-amber-900",
className,
)}
>
<div className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-white/80 text-amber-700">
<ShieldCheck className="h-3.5 w-3.5" />
</div>
<div className="min-w-0">
<div className="font-semibold text-amber-800">
{STABLE_PROCESSING_LABEL}
</div>
<div className="text-amber-700">
{getStableProcessingDescription(scope)}
</div>
</div>
</div>
);
};
-34
View File
@@ -1,34 +0,0 @@
/**
* Agent 后端配置
*
* 现在只使用 Aster 后端,保留配置接口以便未来扩展
*/
export type AgentBackend = "aster";
// 默认使用 Aster 后端
const STORAGE_KEY = "lime_agent_backend";
/**
* 获取当前 Agent 后端
* 现在固定返回 aster
*/
export function getAgentBackend(): AgentBackend {
return "aster";
}
/**
* 设置 Agent 后端
* 保留接口但不再生效
*/
export function setAgentBackend(_backend: AgentBackend): void {
localStorage.setItem(STORAGE_KEY, "aster");
}
/**
* 是否使用 Aster 后端
* 现在固定返回 true
*/
export function useAsterBackend(): boolean {
return true;
}
@@ -1,86 +0,0 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { resolveStableProcessingProviderGroup } from "../utils/stableProcessingExperience";
interface UseStableProcessingNoticeParams {
providerType?: string | null;
model?: string | null;
autoHideMs?: number;
}
export const STABLE_PROCESSING_NOTICE_AUTO_HIDE_MS = 3000;
const shownStableProcessingNoticeKeys = new Set<string>();
export function resetStableProcessingNoticeMemoryForTest() {
shownStableProcessingNoticeKeys.clear();
}
function getStableProcessingNoticeKey({
providerType,
model,
}: Pick<UseStableProcessingNoticeParams, "providerType" | "model">) {
return resolveStableProcessingProviderGroup({ providerType, model });
}
export function useStableProcessingNotice({
providerType,
model,
autoHideMs = STABLE_PROCESSING_NOTICE_AUTO_HIDE_MS,
}: UseStableProcessingNoticeParams) {
const noticeKey = useMemo(
() => getStableProcessingNoticeKey({ providerType, model }),
[providerType, model],
);
const [visible, setVisible] = useState(
() =>
Boolean(noticeKey) &&
!(noticeKey ? shownStableProcessingNoticeKeys.has(noticeKey) : false),
);
const lastNoticeKeyRef = useRef<string | null>(noticeKey);
const hideTimerRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (hideTimerRef.current !== null) {
window.clearTimeout(hideTimerRef.current);
}
};
}, []);
useEffect(() => {
if (hideTimerRef.current !== null) {
window.clearTimeout(hideTimerRef.current);
hideTimerRef.current = null;
}
if (!noticeKey) {
lastNoticeKeyRef.current = null;
setVisible(false);
return;
}
if (lastNoticeKeyRef.current !== noticeKey) {
lastNoticeKeyRef.current = noticeKey;
setVisible(!shownStableProcessingNoticeKeys.has(noticeKey));
}
if (shownStableProcessingNoticeKeys.has(noticeKey)) {
return;
}
shownStableProcessingNoticeKeys.add(noticeKey);
hideTimerRef.current = window.setTimeout(() => {
setVisible(false);
hideTimerRef.current = null;
}, autoHideMs);
return () => {
if (hideTimerRef.current !== null) {
window.clearTimeout(hideTimerRef.current);
hideTimerRef.current = null;
}
};
}, [autoHideMs, noticeKey]);
return visible;
}
@@ -31,6 +31,7 @@ function buildRemoteCatalog(): SkillCatalog {
itemCount: 2,
},
],
entries: [],
items: [
{
...seeded.items[0]!,
@@ -68,6 +69,7 @@ function buildCloudCatalog(): SkillCatalog {
itemCount: 1,
},
],
entries: [],
items: [
{
...seeded.items[1]!,
@@ -104,6 +106,7 @@ function buildLegacySiteCatalog(): SkillCatalog {
itemCount: 1,
},
],
entries: [],
items: [
{
...baseSkill,
@@ -5,6 +5,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CharacterMention } from "./CharacterMention";
import type { Character } from "@/lib/api/memory";
import type { Skill } from "@/lib/api/skills";
import {
clearSkillCatalogCache,
getSeededSkillCatalog,
saveSkillCatalog,
type SkillCatalog,
} from "@/lib/api/skillCatalog";
import { filterMentionableServiceSkills } from "@/components/agent/chat/service-skills/entryAdapter";
import type { ServiceSkillHomeItem } from "@/components/agent/chat/service-skills/types";
import type { BuiltinInputCommand } from "./builtinCommands";
@@ -147,6 +153,8 @@ beforeEach(() => {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
window.localStorage.clear();
clearSkillCatalogCache();
});
afterEach(() => {
@@ -158,6 +166,8 @@ afterEach(() => {
});
mounted.container.remove();
}
window.localStorage.clear();
clearSkillCatalogCache();
vi.clearAllMocks();
});
@@ -344,6 +354,36 @@ function createServiceSkill(
};
}
function buildCatalogWithSceneEntry(): SkillCatalog {
const seeded = getSeededSkillCatalog();
return {
...seeded,
tenantId: "tenant-scene-demo",
version: "tenant-scene-demo-2026-04-05",
syncedAt: "2026-04-05T12:00:00.000Z",
entries: [
...seeded.entries,
{
id: "scene:campaign-launch",
kind: "scene",
title: "新品发布场景",
summary: "把链接解析、配图和封面串成一条产品链路。",
sceneKey: "campaign-launch",
commandPrefix: "/campaign-launch",
aliases: ["launch", "campaign"],
executionKind: "scene",
renderContract: {
resultKind: "tool_timeline",
detailKind: "scene_detail",
supportsStreaming: true,
supportsTimeline: true,
},
},
],
};
}
describe("CharacterMention", () => {
it("输入 @ 当次应弹出提及面板(不依赖受控 value 同步)", async () => {
const container = renderHarness({
@@ -397,7 +437,7 @@ describe("CharacterMention", () => {
expect(onSelectBuiltinCommand).toHaveBeenCalledWith(
expect.objectContaining({
key: "image",
key: "image_generate",
commandPrefix: "@配图",
}),
);
@@ -679,6 +719,23 @@ describe("CharacterMention", () => {
expect(document.body.textContent).toContain("/review");
});
it("统一目录中的 scene 应出现在 slash 面板里", async () => {
act(() => {
saveSkillCatalog(buildCatalogWithSceneEntry(), "bootstrap_sync");
});
const container = renderHarness();
const textarea = getTextarea(container);
await typeSlashAndWait(textarea, "/camp");
expect(document.body.textContent).toContain("场景组合");
expect(document.body.textContent).toContain("/campaign-launch");
expect(document.body.textContent).toContain(
"把链接解析、配图和封面串成一条产品链路。",
);
});
it("slash 面板选择 Lime 命令时应回填到输入框", async () => {
const onChangeSpy = vi.fn<(value: string) => void>();
const container = renderHarness({
@@ -700,6 +757,31 @@ describe("CharacterMention", () => {
expect(onChangeSpy).toHaveBeenCalledWith("/compact ");
});
it("slash 面板选择服务端 scene 时应回填场景命令", async () => {
act(() => {
saveSkillCatalog(buildCatalogWithSceneEntry(), "bootstrap_sync");
});
const onChangeSpy = vi.fn<(value: string) => void>();
const container = renderHarness({
onChangeSpy,
});
const textarea = getTextarea(container);
await typeSlashAndWait(textarea, "/camp");
const sceneButton = Array.from(
document.body.querySelectorAll("button"),
).find((button) => button.textContent?.includes("/campaign-launch"));
expect(sceneButton).toBeTruthy();
act(() => {
sceneButton?.click();
});
expect(onChangeSpy).toHaveBeenCalledWith("/campaign-launch ");
});
it("slash 面板选择已安装技能时应直接回填 slash skill", async () => {
const onChangeSpy = vi.fn<(value: string) => void>();
const container = renderHarness({
@@ -15,6 +15,10 @@ import React, {
import { createPortal } from "react-dom";
import type { Character } from "@/lib/api/memory";
import type { Skill } from "@/lib/api/skills";
import {
getSkillCatalog,
subscribeSkillCatalogChanged,
} from "@/lib/api/skillCatalog";
import { filterMentionableServiceSkills } from "@/components/agent/chat/service-skills/entryAdapter";
import type { ServiceSkillHomeItem } from "@/components/agent/chat/service-skills/types";
import { toast } from "sonner";
@@ -23,8 +27,13 @@ import {
type CodexSlashCommandDefinition,
} from "../commands";
import {
INPUTBAR_BUILTIN_COMMANDS,
filterBuiltinCommands,
filterRuntimeSceneSlashCommands,
listBuiltinCommandsFromSkillCatalog,
listRuntimeSceneSlashCommandsFromSkillCatalog,
type BuiltinInputCommand,
type RuntimeSceneSlashCommand,
} from "./builtinCommands";
import {
LazyCharacterMentionPanel,
@@ -135,6 +144,12 @@ export function CharacterMention({
const [showMentions, setShowMentions] = useState(false);
const [mentionQuery, setMentionQuery] = useState("");
const [triggerMode, setTriggerMode] = useState<TriggerMode>("mention");
const [runtimeBuiltinCommands, setRuntimeBuiltinCommands] = useState<
BuiltinInputCommand[]
>(INPUTBAR_BUILTIN_COMMANDS);
const [runtimeSceneCommands, setRuntimeSceneCommands] = useState<
RuntimeSceneSlashCommand[]
>([]);
const [panelAnchor, setPanelAnchor] = useState({
top: 0,
left: 0,
@@ -150,8 +165,8 @@ export function CharacterMention({
});
const filteredBuiltinCommands = useMemo(
() => filterBuiltinCommands(mentionQuery),
[mentionQuery],
() => filterBuiltinCommands(mentionQuery, runtimeBuiltinCommands),
[mentionQuery, runtimeBuiltinCommands],
);
const filteredServiceSkills = useMemo(
() => filterMentionableServiceSkills(serviceSkills, mentionQuery),
@@ -161,6 +176,10 @@ export function CharacterMention({
() => filterCodexSlashCommands(mentionQuery),
[mentionQuery],
);
const filteredRuntimeSceneCommands = useMemo(
() => filterRuntimeSceneSlashCommands(mentionQuery, runtimeSceneCommands),
[mentionQuery, runtimeSceneCommands],
);
const filteredCharacters = useMemo(() => {
if (!mentionQuery) return characters;
@@ -222,6 +241,44 @@ export function CharacterMention({
});
}, [inputRef]);
useEffect(() => {
let cancelled = false;
const syncCatalog = async () => {
try {
const catalog = await getSkillCatalog();
if (cancelled) {
return;
}
const nextBuiltinCommands = listBuiltinCommandsFromSkillCatalog(catalog);
const nextRuntimeScenes =
listRuntimeSceneSlashCommandsFromSkillCatalog(catalog);
setRuntimeBuiltinCommands(
nextBuiltinCommands.length > 0
? nextBuiltinCommands
: INPUTBAR_BUILTIN_COMMANDS,
);
setRuntimeSceneCommands(nextRuntimeScenes);
} catch {
if (cancelled) {
return;
}
setRuntimeBuiltinCommands(INPUTBAR_BUILTIN_COMMANDS);
setRuntimeSceneCommands([]);
}
};
void syncCatalog();
const unsubscribe = subscribeSkillCatalogChanged(() => {
void syncCatalog();
});
return () => {
cancelled = true;
unsubscribe();
};
}, []);
useEffect(() => {
const textarea = inputRef.current;
if (!textarea) return;
@@ -492,6 +549,36 @@ export function CharacterMention({
}, 0);
};
const handleSelectRuntimeSceneCommand = (
command: RuntimeSceneSlashCommand,
) => {
const textarea = inputRef.current;
if (!textarea) return;
const currentValue = textarea.value || value;
const cursorPos = textarea.selectionStart ?? currentValue.length;
const textAfterCursor = currentValue.slice(cursorPos);
const activeTrigger = resolveActiveTrigger(currentValue, cursorPos);
if (!activeTrigger || activeTrigger.mode !== "slash") {
return;
}
const newValue =
currentValue.slice(0, activeTrigger.triggerIndex) +
`${command.commandPrefix} ` +
textAfterCursor;
onChange(newValue);
setShowMentions(false);
setTimeout(() => {
textarea.focus();
const newCursorPos =
activeTrigger.triggerIndex + command.commandPrefix.length + 1;
textarea.setSelectionRange(newCursorPos, newCursorPos);
}, 0);
};
useEffect(() => {
const textarea = inputRef.current;
if (!textarea || !showMentions) return;
@@ -575,18 +662,20 @@ export function CharacterMention({
<LazyCharacterMentionPanel
mode={triggerMode}
mentionQuery={mentionQuery}
builtinCommands={filteredBuiltinCommands}
slashCommands={filteredSlashCommands}
mentionServiceSkills={filteredServiceSkills}
filteredCharacters={filteredCharacters}
builtinCommands={filteredBuiltinCommands}
slashCommands={filteredSlashCommands}
sceneCommands={filteredRuntimeSceneCommands}
mentionServiceSkills={filteredServiceSkills}
filteredCharacters={filteredCharacters}
installedSkills={installedSkills}
availableSkills={availableSkills}
commandRef={commandRef}
onQueryChange={setMentionQuery}
onSelectBuiltinCommand={handleSelectBuiltinCommand}
onSelectServiceSkill={handleSelectServiceSkill}
onSelectSlashCommand={handleSelectSlashCommand}
onSelectCharacter={handleSelectCharacter}
onSelectBuiltinCommand={handleSelectBuiltinCommand}
onSelectServiceSkill={handleSelectServiceSkill}
onSelectSlashCommand={handleSelectSlashCommand}
onSelectSceneCommand={handleSelectRuntimeSceneCommand}
onSelectCharacter={handleSelectCharacter}
onSelectInstalledSkill={handleSelectInstalledSkill}
onSelectAvailableSkill={handleSelectAvailableSkill}
onNavigateToSettings={
@@ -18,7 +18,10 @@ import type { Skill } from "@/lib/api/skills";
import { resolveServiceSkillEntryDescription } from "@/components/agent/chat/service-skills/entryAdapter";
import type { ServiceSkillHomeItem } from "@/components/agent/chat/service-skills/types";
import type { CodexSlashCommandDefinition } from "../commands";
import type { BuiltinInputCommand } from "./builtinCommands";
import type {
BuiltinInputCommand,
RuntimeSceneSlashCommand,
} from "./builtinCommands";
interface MentionServiceSkillGroup {
key: string;
@@ -88,6 +91,7 @@ interface CharacterMentionPanelProps {
mentionQuery: string;
builtinCommands: BuiltinInputCommand[];
slashCommands: CodexSlashCommandDefinition[];
sceneCommands: RuntimeSceneSlashCommand[];
mentionServiceSkills: ServiceSkillHomeItem[];
filteredCharacters: Character[];
installedSkills: Skill[];
@@ -97,6 +101,7 @@ interface CharacterMentionPanelProps {
onSelectBuiltinCommand: (command: BuiltinInputCommand) => void;
onSelectServiceSkill: (skill: ServiceSkillHomeItem) => void;
onSelectSlashCommand: (command: CodexSlashCommandDefinition) => void;
onSelectSceneCommand: (command: RuntimeSceneSlashCommand) => void;
onSelectCharacter: (character: Character) => void;
onSelectInstalledSkill: (skill: Skill) => void;
onSelectAvailableSkill: (skill: Skill) => void;
@@ -108,6 +113,7 @@ export const CharacterMentionPanel: React.FC<CharacterMentionPanelProps> = ({
mentionQuery,
builtinCommands,
slashCommands,
sceneCommands,
mentionServiceSkills,
filteredCharacters,
installedSkills,
@@ -117,6 +123,7 @@ export const CharacterMentionPanel: React.FC<CharacterMentionPanelProps> = ({
onSelectBuiltinCommand,
onSelectServiceSkill,
onSelectSlashCommand,
onSelectSceneCommand,
onSelectCharacter,
onSelectInstalledSkill,
onSelectAvailableSkill,
@@ -130,8 +137,10 @@ export const CharacterMentionPanel: React.FC<CharacterMentionPanelProps> = ({
);
const visibleCharacters = mode === "mention" ? filteredCharacters : [];
const visibleSlashCommands = mode === "slash" ? slashCommands : [];
const visibleSceneCommands = mode === "slash" ? sceneCommands : [];
const hasFilteredResults =
visibleSlashCommands.length > 0 ||
visibleSceneCommands.length > 0 ||
visibleBuiltinCommands.length > 0 ||
visibleServiceSkillGroups.length > 0 ||
visibleCharacters.length > 0 ||
@@ -191,6 +200,25 @@ export const CharacterMentionPanel: React.FC<CharacterMentionPanelProps> = ({
))}
</CommandGroup>
) : null}
{visibleSceneCommands.length > 0 ? (
<CommandGroup heading="场景组合">
{visibleSceneCommands.map((command) => (
<CommandItem
key={command.entryId ?? command.key}
onSelect={() => onSelectSceneCommand(command)}
className="cursor-pointer"
>
<Zap className="mr-2 h-4 w-4 text-sky-600" />
<div className="flex-1">
<div className="font-medium">{command.commandPrefix}</div>
<div className="text-xs text-muted-foreground line-clamp-1">
{command.description}
</div>
</div>
</CommandItem>
))}
</CommandGroup>
) : null}
{visibleBuiltinCommands.length > 0 ? (
<CommandGroup heading="内建命令">
{visibleBuiltinCommands.map((command) => (
@@ -126,6 +126,7 @@ export const SkillSelectorContent: React.FC<SkillSelectorContentProps> = ({
mode="mention"
mentionQuery={query}
builtinCommands={[] satisfies BuiltinInputCommand[]}
sceneCommands={[]}
slashCommands={[]}
mentionServiceSkills={mentionServiceSkills}
filteredCharacters={[]}
@@ -135,6 +136,7 @@ export const SkillSelectorContent: React.FC<SkillSelectorContentProps> = ({
onQueryChange={onQueryChange}
onSelectBuiltinCommand={() => undefined}
onSelectServiceSkill={(skill) => onSelectServiceSkill?.(skill)}
onSelectSceneCommand={() => undefined}
onSelectSlashCommand={() => undefined}
onSelectCharacter={() => undefined}
onSelectInstalledSkill={onSelectInstalledSkill}
@@ -1,101 +1,145 @@
import {
getSeededSkillCatalog,
listSkillCatalogCommandEntries,
listSkillCatalogSceneEntries,
type SkillCatalog,
type SkillCatalogCommandEntry,
type SkillCatalogSceneEntry,
} from "@/lib/api/skillCatalog";
export interface BuiltinInputCommand {
key:
| "image"
| "cover_generate"
| "image_edit"
| "image_variation"
| "video_generate"
| "transcription_generate"
| "url_parse";
key: string;
label: string;
mentionLabel: string;
commandPrefix: string;
description: string;
aliases: string[];
entryId?: string;
}
export const INPUTBAR_BUILTIN_COMMANDS: BuiltinInputCommand[] = [
{
key: "image",
label: "配图",
mentionLabel: "配图",
commandPrefix: "@配图",
description: "根据文字描述生成新的图片结果",
aliases: ["image", "img", "图片", "生图"],
},
{
key: "cover_generate",
label: "封面",
mentionLabel: "封面",
commandPrefix: "@封面",
description: "根据主题生成平台封面图任务",
aliases: ["cover", "fengmian", "封面", "封面图", "头图"],
},
{
key: "image_edit",
label: "修图",
mentionLabel: "修图",
commandPrefix: "@修图",
description: "编辑已有图片并生成新的结果图",
aliases: ["edit", "xiutu", "修图", "改图", "图片编辑"],
},
{
key: "image_variation",
label: "重绘",
mentionLabel: "重绘",
commandPrefix: "@重绘",
description: "基于已有图片或参考图继续重绘新的结果图",
aliases: ["variation", "variant", "zhonghui", "重绘", "图片重绘", "变体"],
},
{
key: "video_generate",
label: "视频",
mentionLabel: "视频",
commandPrefix: "@视频",
description: "根据文字描述提交视频生成任务",
aliases: ["video", "shipin", "视频", "短视频", "生成视频"],
},
{
key: "transcription_generate",
label: "转写",
mentionLabel: "转写",
commandPrefix: "@转写",
description: "把音频或视频来源提交为转写任务",
aliases: ["transcribe", "zhuanxie", "转写", "逐字稿", "字幕", "语音转文字"],
},
{
key: "url_parse",
label: "链接解析",
mentionLabel: "链接解析",
commandPrefix: "@链接解析",
description: "解析网页链接并提交为可追踪的文本任务",
aliases: [
"url",
"url_parse",
"链接",
"链接解析",
"网页读取",
"网页解析",
],
},
];
export interface RuntimeSceneSlashCommand {
key: string;
label: string;
commandPrefix: string;
description: string;
aliases: string[];
entryId?: string;
}
export function filterBuiltinCommands(query: string): BuiltinInputCommand[] {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) {
return INPUTBAR_BUILTIN_COMMANDS;
function normalizeSearchText(value: string): string {
return value.trim().toLowerCase();
}
function collectBuiltinCommandHaystacks(command: BuiltinInputCommand): string[] {
return [
command.label,
command.mentionLabel,
command.commandPrefix,
command.description,
...command.aliases,
];
}
function collectSceneCommandHaystacks(command: RuntimeSceneSlashCommand): string[] {
return [
command.label,
command.commandPrefix,
command.description,
...command.aliases,
];
}
function resolveMentionTriggerPrefix(
entry: SkillCatalogCommandEntry,
): string | null {
const mentionTrigger = entry.triggers.find((trigger) => trigger.mode === "mention");
return mentionTrigger?.prefix?.trim() || null;
}
function toBuiltinInputCommand(
entry: SkillCatalogCommandEntry,
): BuiltinInputCommand | null {
const commandPrefix = resolveMentionTriggerPrefix(entry);
if (!commandPrefix) {
return null;
}
return INPUTBAR_BUILTIN_COMMANDS.filter((command) => {
const haystacks = [
command.label,
command.mentionLabel,
command.commandPrefix,
command.description,
...command.aliases,
];
return haystacks.some((value) =>
return {
key: entry.commandKey,
label: entry.title,
mentionLabel: entry.title,
commandPrefix,
description: entry.summary,
aliases: entry.aliases ?? [],
entryId: entry.id,
};
}
function toRuntimeSceneSlashCommand(
entry: SkillCatalogSceneEntry,
): RuntimeSceneSlashCommand | null {
const commandPrefix = entry.commandPrefix.trim();
if (!commandPrefix.startsWith("/")) {
return null;
}
return {
key: entry.sceneKey,
label: entry.title,
commandPrefix,
description: entry.summary,
aliases: entry.aliases ?? [],
entryId: entry.id,
};
}
export function listBuiltinCommandsFromSkillCatalog(
catalog: SkillCatalog,
): BuiltinInputCommand[] {
return listSkillCatalogCommandEntries(catalog)
.map((entry) => toBuiltinInputCommand(entry))
.filter((entry): entry is BuiltinInputCommand => Boolean(entry));
}
export function listRuntimeSceneSlashCommandsFromSkillCatalog(
catalog: SkillCatalog,
): RuntimeSceneSlashCommand[] {
return listSkillCatalogSceneEntries(catalog)
.map((entry) => toRuntimeSceneSlashCommand(entry))
.filter((entry): entry is RuntimeSceneSlashCommand => Boolean(entry));
}
export const INPUTBAR_BUILTIN_COMMANDS: BuiltinInputCommand[] =
listBuiltinCommandsFromSkillCatalog(getSeededSkillCatalog());
export function filterBuiltinCommands(
query: string,
commands: BuiltinInputCommand[] = INPUTBAR_BUILTIN_COMMANDS,
): BuiltinInputCommand[] {
const normalizedQuery = normalizeSearchText(query);
if (!normalizedQuery) {
return commands;
}
return commands.filter((command) =>
collectBuiltinCommandHaystacks(command).some((value) =>
value.toLowerCase().includes(normalizedQuery),
);
});
),
);
}
export function filterRuntimeSceneSlashCommands(
query: string,
commands: RuntimeSceneSlashCommand[],
): RuntimeSceneSlashCommand[] {
const normalizedQuery = normalizeSearchText(query);
if (!normalizedQuery) {
return commands;
}
return commands.filter((command) =>
collectSceneCommandHaystacks(command).some((value) =>
value.toLowerCase().includes(normalizedQuery),
),
);
}
@@ -108,27 +108,9 @@ function resolveImageWorkbenchModeLabel(mode: ImageWorkbenchTaskMode): string {
return "图片生成";
}
function resolveImageWorkbenchSkillLabel(mode: ImageWorkbenchTaskMode): string {
if (mode === "edit") {
return "图片编辑技能";
}
if (mode === "variation") {
return "图片重绘技能";
}
return "配图技能";
}
function resolveImageWorkbenchCommandLabel(mode: ImageWorkbenchTaskMode): string {
if (mode === "edit") {
return "@修图";
}
if (mode === "variation") {
return "@重绘";
}
return "@配图";
}
function resolveImageWorkbenchProgressLabel(mode: ImageWorkbenchTaskMode): string {
function resolveImageWorkbenchProgressLabel(
mode: ImageWorkbenchTaskMode,
): string {
if (mode === "edit") {
return "修图";
}
@@ -140,12 +122,12 @@ function resolveImageWorkbenchProgressLabel(mode: ImageWorkbenchTaskMode): strin
function resolveImageWorkbenchActionVerb(mode: ImageWorkbenchTaskMode): string {
if (mode === "edit") {
return "梳理来源图与编辑要求,再通过图片编辑技能提交异步修图任务";
return "整理来源图与编辑要求,并创建异步修图任务";
}
if (mode === "variation") {
return "梳理参考图与重绘要求,再通过图片重绘技能提交异步图片任务";
return "整理参考图与重绘要求,并创建异步图片任务";
}
return "梳理画面主题、尺寸和出图数量,再通过配图技能提交异步图片任务";
return "整理画面主题、尺寸和出图数量,并创建异步图片任务";
}
function formatToolArguments(value: Record<string, unknown>): string {
@@ -172,38 +154,6 @@ function resolveImageWorkbenchAnalysisText(
}
}
function buildImageWorkbenchSkillToolCall(
params: BuildImageWorkbenchProcessDescriptorParams,
): AgentToolCallState {
const endedAt = params.endedAt ?? params.startedAt;
const prompt = collapseWhitespace(params.prompt) || "当前图片任务";
const rawText =
collapseWhitespace(params.rawText || "") ||
`${resolveImageWorkbenchCommandLabel(params.mode)} ${
params.mode === "variation" ? "重绘 " : params.mode === "generate" ? "生成 " : ""
}${prompt}`.trim();
return {
id: `${params.taskId}:skill`,
name: "skill",
arguments: formatToolArguments({
name: resolveImageWorkbenchSkillLabel(params.mode),
command: rawText,
prompt,
}),
status: "completed",
result: {
success: true,
output:
params.status === "running"
? `已完成${resolveImageWorkbenchProgressLabel(params.mode)}需求解析,正在提交异步图片任务。`
: `已完成${resolveImageWorkbenchProgressLabel(params.mode)}需求解析,并保留本轮执行上下文。`,
},
startTime: params.startedAt,
endTime: endedAt,
};
}
function resolveImageWorkbenchTaskToolStatus(
status: ImageWorkbenchMessageStatus,
): AgentToolCallState["status"] {
@@ -227,7 +177,8 @@ function buildImageWorkbenchTaskToolResult(
params.successCount ??
(params.status === "complete" ? params.count || 1 : undefined);
const images =
params.imageUrl && (params.status === "complete" || params.status === "partial")
params.imageUrl &&
(params.status === "complete" || params.status === "partial")
? [{ src: params.imageUrl, origin: "tool_payload" as const }]
: undefined;
@@ -253,7 +204,8 @@ function buildImageWorkbenchTaskToolResult(
default:
return {
success: false,
output: params.failureMessage?.trim() || "图片任务执行失败,未返回可用结果。",
output:
params.failureMessage?.trim() || "图片任务执行失败,未返回可用结果。",
error: params.failureMessage?.trim() || "图片任务执行失败",
};
}
@@ -278,7 +230,8 @@ function buildImageWorkbenchTaskToolCall(
status,
result: buildImageWorkbenchTaskToolResult(params),
startTime: params.startedAt,
endTime: status === "running" ? undefined : params.endedAt ?? params.startedAt,
endTime:
status === "running" ? undefined : (params.endedAt ?? params.startedAt),
};
}
@@ -288,20 +241,15 @@ export function buildImageWorkbenchProcessDescriptor(
toolCalls: AgentToolCallState[];
contentParts: ContentPart[];
} {
const skillToolCall = buildImageWorkbenchSkillToolCall(params);
const taskToolCall = buildImageWorkbenchTaskToolCall(params);
return {
toolCalls: [skillToolCall, taskToolCall],
toolCalls: [taskToolCall],
contentParts: [
{
type: "text",
text: resolveImageWorkbenchAnalysisText(params),
},
{
type: "tool_use",
toolCall: skillToolCall,
},
{
type: "tool_use",
toolCall: taskToolCall,
@@ -309,10 +309,6 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => {
content: "图片任务已创建,正在准备执行。",
isThinking: true,
toolCalls: [
expect.objectContaining({
name: "skill",
status: "completed",
}),
expect.objectContaining({
name: "limeCreateImageGenerationTask",
status: "running",
@@ -322,12 +318,6 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => {
expect.objectContaining({
type: "text",
}),
expect.objectContaining({
type: "tool_use",
toolCall: expect.objectContaining({
name: "skill",
}),
}),
expect.objectContaining({
type: "tool_use",
toolCall: expect.objectContaining({
@@ -391,10 +381,6 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => {
expect.objectContaining({
content: "图片任务正在生成中。",
toolCalls: [
expect.objectContaining({
name: "skill",
status: "completed",
}),
expect.objectContaining({
name: "limeCreateImageGenerationTask",
status: "running",
@@ -426,10 +412,6 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => {
content: "图片任务已完成,共生成 1 张。",
isThinking: false,
toolCalls: [
expect.objectContaining({
name: "skill",
status: "completed",
}),
expect.objectContaining({
name: "limeCreateImageGenerationTask",
status: "completed",
@@ -242,10 +242,6 @@ describe("useWorkspaceImageWorkbenchActionRuntime", () => {
content: "图片任务已创建,正在准备执行。",
isThinking: true,
toolCalls: [
expect.objectContaining({
name: "skill",
status: "completed",
}),
expect.objectContaining({
name: "limeCreateImageGenerationTask",
status: "running",
@@ -31,6 +31,7 @@ const mockSetChatToolPreferences = vi.fn();
const mockSetRuntimeTeamDispatchPreview = vi.fn();
const mockEnsureBrowserAssistCanvas = vi.fn(async () => true);
const mockHandleAutoLaunchMatchedSiteSkill = vi.fn(async () => undefined);
const mockHandleRuntimeSceneLaunch = vi.fn(async () => false);
const mockHandleImageWorkbenchCommand = vi.fn<
HookProps["handleImageWorkbenchCommand"]
>(async () => true);
@@ -203,6 +204,8 @@ function mountHook(initialProps?: Partial<HookProps>): HookHarness {
mockEnsureBrowserAssistCanvas as HookProps["ensureBrowserAssistCanvas"],
handleAutoLaunchMatchedSiteSkill:
mockHandleAutoLaunchMatchedSiteSkill as HookProps["handleAutoLaunchMatchedSiteSkill"],
handleRuntimeSceneLaunch:
mockHandleRuntimeSceneLaunch as HookProps["handleRuntimeSceneLaunch"],
handleImageWorkbenchCommand:
mockHandleImageWorkbenchCommand as HookProps["handleImageWorkbenchCommand"],
resolveImageWorkbenchSkillRequest:
@@ -245,6 +248,7 @@ describe("useWorkspaceSendActions", () => {
vi.clearAllMocks();
mockHandleImageWorkbenchCommand.mockResolvedValue(true);
mockHandleRuntimeSceneLaunch.mockResolvedValue(false);
mockResolveImageWorkbenchSkillRequest.mockReturnValue(null);
});
@@ -766,6 +770,29 @@ describe("useWorkspaceSendActions", () => {
}
});
it("/scene-key 命中运行时场景时应走统一 scene 启动入口,而不是继续发送普通消息", async () => {
mockHandleRuntimeSceneLaunch.mockResolvedValueOnce(true);
const harness = mountHook({
input: "/campaign-launch 帮我做一版新品活动启动方案",
});
try {
await act(async () => {
const started = await harness.getValue().handleSend();
expect(started).toBe(true);
});
expect(mockHandleRuntimeSceneLaunch).toHaveBeenCalledTimes(1);
expect(mockHandleRuntimeSceneLaunch).toHaveBeenCalledWith(
"/campaign-launch 帮我做一版新品活动启动方案",
);
expect(mockSendMessage).not.toHaveBeenCalled();
expect(mockHandleAutoLaunchMatchedSiteSkill).not.toHaveBeenCalled();
} finally {
harness.unmount();
}
});
it("已携带 service_skill_launch metadata 时不应再被前端二次命中站点技能或浏览器前置引导", async () => {
const mockMaybeStartBrowserTaskPreflight = vi.fn(() => false);
const harness = mountHook({
@@ -358,6 +358,7 @@ interface UseWorkspaceSendActionsParams {
handleAutoLaunchMatchedSiteSkill: (
match: AutoMatchedSiteSkill<ServiceSkillHomeItem>,
) => Promise<void>;
handleRuntimeSceneLaunch: (rawText: string) => Promise<boolean>;
handleImageWorkbenchCommand: (input: {
rawText: string;
parsedCommand: ParsedImageWorkbenchCommand;
@@ -445,6 +446,7 @@ export function useWorkspaceSendActions({
setRuntimeTeamDispatchPreview,
ensureBrowserAssistCanvas,
handleAutoLaunchMatchedSiteSkill,
handleRuntimeSceneLaunch,
handleImageWorkbenchCommand,
resolveImageWorkbenchSkillRequest,
}: UseWorkspaceSendActionsParams) {
@@ -631,6 +633,14 @@ export function useWorkspaceSendActions({
};
}
if (
!sendOptions?.purpose &&
sourceText.trim().startsWith("/") &&
(await handleRuntimeSceneLaunch(sourceText))
) {
return { kind: "done", result: true };
}
const trimmedSourceText = sourceText.trim();
if (
activeTheme === "general" &&
@@ -735,6 +745,7 @@ export function useWorkspaceSendActions({
mentionedCharacters,
projectId,
resolveSendBoundary,
handleRuntimeSceneLaunch,
serviceSkills,
workspaceRequestMetadataBase,
],
@@ -13,6 +13,8 @@ const mockGetServiceSkillRun = vi.fn();
const mockIsTerminalServiceSkillRunStatus = vi.fn();
const mockCreateContent = vi.fn();
const mockListProjects = vi.fn();
const mockGetSkillCatalog = vi.fn();
const mockListSkillCatalogSceneEntries = vi.fn();
const mockRecordServiceSkillAutomationLink = vi.fn();
const mockSiteGetAdapterLaunchReadiness = vi.fn();
const mockToastSuccess = vi.fn();
@@ -55,6 +57,12 @@ vi.mock("@/lib/api/project", () => ({
},
}));
vi.mock("@/lib/api/skillCatalog", () => ({
getSkillCatalog: () => mockGetSkillCatalog(),
listSkillCatalogSceneEntries: (catalog: unknown) =>
mockListSkillCatalogSceneEntries(catalog),
}));
vi.mock("@/lib/webview-api", () => ({
siteGetAdapterLaunchReadiness: (...args: unknown[]) =>
mockSiteGetAdapterLaunchReadiness(...args),
@@ -217,6 +225,7 @@ function createScheduledServiceSkill(): ServiceSkillHomeItem {
function createCloudServiceSkill(): ServiceSkillHomeItem {
return {
id: "cloud-video-dubbing",
skillKey: "campaign-launch",
title: "云端视频配音",
summary: "把视频文案与素材提交到云端,生成一版可继续加工的配音结果。",
category: "视频创作",
@@ -264,6 +273,7 @@ function renderHook(props?: Partial<HookProps>) {
contentId: "content-current",
input: "请结合当前上下文继续",
chatToolPreferences: DEFAULT_CHAT_TOOL_PREFERENCES,
serviceSkills: [],
onNavigate: vi.fn(),
recordServiceSkillUsage: vi.fn(),
};
@@ -321,6 +331,15 @@ beforeEach(() => {
id: "content-created-by-service-skill",
});
mockListProjects.mockResolvedValue([createProject()]);
mockGetSkillCatalog.mockResolvedValue({
version: "test-catalog",
tenantId: "tenant-test",
syncedAt: "2026-04-05T00:00:00.000Z",
groups: [],
items: [],
entries: [],
});
mockListSkillCatalogSceneEntries.mockReturnValue([]);
mockRecordServiceSkillAutomationLink.mockReset();
mockToastSuccess.mockReset();
mockToastError.mockReset();
@@ -626,6 +645,117 @@ describe("useWorkspaceServiceSkillEntryActions", () => {
);
});
it("/scene-key 应从统一目录解析到 cloud scene 技能并复用现有云端启动链", async () => {
const onNavigate = vi.fn();
const recordServiceSkillUsage = vi.fn();
mockListSkillCatalogSceneEntries.mockReturnValue([
{
id: "scene:campaign-launch",
kind: "scene",
title: "活动启动场景",
summary: "围绕活动目标生成启动方案。",
sceneKey: "campaign-launch",
commandPrefix: "/campaign-launch",
linkedSkillId: "cloud-video-dubbing",
},
]);
mockCreateServiceSkillRun.mockResolvedValue({
id: "service-skill-run-scene-1",
status: "success",
outputSummary: "活动启动方案已生成",
outputText: "# 活动启动方案\n\n第一版方案",
finishedAt: "2026-04-05T10:00:00.000Z",
});
const { render, getValue } = renderHook({
onNavigate,
recordServiceSkillUsage,
serviceSkills: [createCloudServiceSkill()],
});
await render();
let handled = false;
await act(async () => {
handled = await getValue().handleRuntimeSceneLaunch(
"/campaign-launch 帮我做一版新品活动启动方案",
);
});
expect(handled).toBe(true);
expect(mockCreateServiceSkillRun).toHaveBeenCalledWith(
"cloud-video-dubbing",
expect.stringContaining("[技能任务] 云端视频配音"),
);
expect(mockCreateServiceSkillRun).toHaveBeenCalledWith(
"cloud-video-dubbing",
expect.stringContaining("[补充要求] 帮我做一版新品活动启动方案"),
);
expect(recordServiceSkillUsage).toHaveBeenCalledWith({
skillId: "cloud-video-dubbing",
runnerType: "instant",
});
expect(onNavigate).toHaveBeenCalledWith(
"agent",
expect.objectContaining({
contentId: "content-created-by-service-skill",
}),
);
});
it("/scene-key 云端提交失败时应自动回退到本地工作区", async () => {
const onNavigate = vi.fn();
const recordServiceSkillUsage = vi.fn();
mockListSkillCatalogSceneEntries.mockReturnValue([
{
id: "scene:campaign-launch",
kind: "scene",
title: "活动启动场景",
summary: "围绕活动目标生成启动方案。",
sceneKey: "campaign-launch",
commandPrefix: "/campaign-launch",
linkedSkillId: "cloud-video-dubbing",
},
]);
mockCreateServiceSkillRun.mockRejectedValue(
new Error("缺少 OEM 云端 Session Token,请先完成登录或注入会话。"),
);
const { render, getValue } = renderHook({
onNavigate,
recordServiceSkillUsage,
serviceSkills: [createCloudServiceSkill()],
});
await render();
let handled = false;
await act(async () => {
handled = await getValue().handleRuntimeSceneLaunch(
"/campaign-launch 帮我做一版新品活动启动方案",
);
});
expect(handled).toBe(true);
expect(onNavigate).toHaveBeenCalledWith(
"agent",
expect.objectContaining({
projectId: "project-1",
contentId: "content-current",
autoRunInitialPromptOnMount: true,
initialUserPrompt: expect.stringContaining("[技能任务] 云端视频配音"),
}),
);
expect(recordServiceSkillUsage).toHaveBeenCalledWith({
skillId: "cloud-video-dubbing",
runnerType: "instant",
});
expect(mockToastInfo).toHaveBeenCalledWith(
"云端视频配音 云端暂不可用,已切换到本地工作区继续。",
{
id: "toast-loading",
},
);
});
it("普通技能进入工作区时应在保留 seed metadata 的同时注入当前 Team", async () => {
const onNavigate = vi.fn();
const { render, getValue } = renderHook({
@@ -2,6 +2,11 @@ import { useCallback, useState } from "react";
import { toast } from "sonner";
import { siteGetAdapterLaunchReadiness } from "@/lib/webview-api";
import { createAutomationJob } from "@/lib/api/automation";
import {
getSkillCatalog,
listSkillCatalogSceneEntries,
type SkillCatalogSceneEntry,
} from "@/lib/api/skillCatalog";
import {
createServiceSkillRun,
getServiceSkillRun,
@@ -26,7 +31,10 @@ import {
resolveWorkspaceEntry,
type WorkspaceEntryPayload,
} from "../workspaceEntry";
import { composeServiceSkillPrompt } from "../service-skills/promptComposer";
import {
createDefaultServiceSkillSlotValues,
composeServiceSkillPrompt,
} from "../service-skills/promptComposer";
import {
buildServiceSkillAutomationAgentTurnPayloadContext,
buildServiceSkillAutomationInitialValues,
@@ -89,6 +97,7 @@ function normalizeOptionalText(value?: string | null): string | undefined {
interface ServiceSkillLaunchOptions {
launchUserInput?: string | null;
fallbackToWorkspaceOnCloudSubmitFailure?: boolean;
}
function resolveServiceSkillLaunchUserInput(
@@ -102,6 +111,77 @@ function resolveServiceSkillLaunchUserInput(
return normalizeOptionalText(currentInput);
}
interface ParsedRuntimeSceneCommand {
sceneKey: string;
userInput: string;
}
function normalizeCommandToken(value?: string | null): string {
if (typeof value !== "string") {
return "";
}
return value.trim().replace(/^\/+/, "").toLowerCase();
}
function parseRuntimeSceneCommand(
rawText: string,
): ParsedRuntimeSceneCommand | null {
const sceneMatch = rawText.trim().match(/^\/([a-zA-Z0-9_-]+)\s*([\s\S]*)$/);
if (!sceneMatch) {
return null;
}
const [, sceneKey, userInput] = sceneMatch;
return {
sceneKey,
userInput: userInput?.trim() || "",
};
}
function matchesRuntimeSceneEntry(
entry: SkillCatalogSceneEntry,
sceneKey: string,
): boolean {
const normalizedSceneKey = normalizeCommandToken(sceneKey);
if (!normalizedSceneKey) {
return false;
}
if (normalizeCommandToken(entry.sceneKey) === normalizedSceneKey) {
return true;
}
if (normalizeCommandToken(entry.commandPrefix) === normalizedSceneKey) {
return true;
}
return (entry.aliases ?? []).some(
(alias) => normalizeCommandToken(alias) === normalizedSceneKey,
);
}
function resolveRuntimeSceneSkill(
serviceSkills: ServiceSkillHomeItem[],
entry: SkillCatalogSceneEntry,
): ServiceSkillHomeItem | null {
const normalizedSceneKey = normalizeCommandToken(entry.sceneKey);
if (!normalizedSceneKey) {
return null;
}
return (
serviceSkills.find((skill) => skill.id === entry.linkedSkillId) ||
serviceSkills.find(
(skill) => normalizeCommandToken(skill.skillKey) === normalizedSceneKey,
) ||
serviceSkills.find(
(skill) => normalizeCommandToken(skill.id) === normalizedSceneKey,
) ||
null
);
}
function buildServiceSkillCloudResultBody(
skill: ServiceSkillHomeItem,
run: ServiceSkillRun,
@@ -209,6 +289,7 @@ interface UseWorkspaceServiceSkillEntryActionsParams {
contentId?: string | null;
input: string;
chatToolPreferences: ChatToolPreferences;
serviceSkills: ServiceSkillHomeItem[];
preferredTeamPresetId?: string | null;
selectedTeam?: TeamDefinition | null;
selectedTeamLabel?: string | null;
@@ -227,6 +308,7 @@ export function useWorkspaceServiceSkillEntryActions({
contentId,
input,
chatToolPreferences,
serviceSkills,
preferredTeamPresetId,
selectedTeam,
selectedTeamLabel,
@@ -685,12 +767,14 @@ export function useWorkspaceServiceSkillEntryActions({
if (skill.executionLocation === "cloud_required") {
const toastId = toast.loading(`正在提交 ${skill.title} 到云端...`);
let runCreated = false;
try {
setServiceSkillDialogOpen(false);
setSelectedServiceSkill(null);
let run = await createServiceSkillRun(skill.id, prompt);
runCreated = true;
recordServiceSkillCloudRun(skill.id, run);
recordServiceSkillUsage({
skillId: skill.id,
@@ -762,6 +846,47 @@ export function useWorkspaceServiceSkillEntryActions({
},
);
} catch (error) {
if (
options?.fallbackToWorkspaceOnCloudSubmitFailure &&
!runCreated
) {
let workspacePayload: WorkspaceEntryPayload;
try {
workspacePayload = await prepareServiceSkillWorkspacePayload(
skill,
prompt,
);
} catch (workspaceError) {
toast.error(
`提交云端运行失败:${getErrorMessage(error)};本地回退失败:${getErrorMessage(workspaceError)}`,
{
id: toastId,
},
);
return;
}
const entered = navigateToServiceSkillWorkspace(workspacePayload);
if (!entered) {
toast.error(
`提交云端运行失败:${getErrorMessage(error)};进入本地工作区失败,请稍后重试。`,
{
id: toastId,
},
);
return;
}
recordServiceSkillUsage({
skillId: skill.id,
runnerType: skill.runnerType,
});
toast.info(`${skill.title} 云端暂不可用,已切换到本地工作区继续。`, {
id: toastId,
});
return;
}
toast.error(`提交云端运行失败:${getErrorMessage(error)}`, {
id: toastId,
});
@@ -808,6 +933,41 @@ export function useWorkspaceServiceSkillEntryActions({
],
);
const handleRuntimeSceneLaunch = useCallback(
async (rawText: string): Promise<boolean> => {
const parsedSceneCommand = parseRuntimeSceneCommand(rawText);
if (!parsedSceneCommand) {
return false;
}
const catalog = await getSkillCatalog();
const sceneEntry = listSkillCatalogSceneEntries(catalog).find((entry) =>
matchesRuntimeSceneEntry(entry, parsedSceneCommand.sceneKey),
);
if (!sceneEntry) {
return false;
}
const matchedSkill = resolveRuntimeSceneSkill(serviceSkills, sceneEntry);
if (!matchedSkill) {
return false;
}
await handleServiceSkillLaunch(
matchedSkill,
createDefaultServiceSkillSlotValues(matchedSkill),
{
launchUserInput:
normalizeOptionalText(parsedSceneCommand.userInput) ?? undefined,
fallbackToWorkspaceOnCloudSubmitFailure: true,
},
);
return true;
},
[handleServiceSkillLaunch, serviceSkills],
);
const handleAutoLaunchMatchedSiteSkill = useCallback(
async (match: AutoMatchedSiteSkill<ServiceSkillHomeItem>) => {
await handleServiceSkillLaunch(match.skill, match.slotValues, {
@@ -996,6 +1156,7 @@ export function useWorkspaceServiceSkillEntryActions({
handleServiceSkillSelect,
handleServiceSkillDialogOpenChange,
handleServiceSkillLaunch,
handleRuntimeSceneLaunch,
handleAutoLaunchMatchedSiteSkill,
handleServiceSkillBrowserRuntimeLaunch,
handleServiceSkillAutomationSetup,
-2
View File
@@ -1,2 +0,0 @@
export { AgentChatPage } from "./AgentChatPage";
export { AgentSkillsPanel } from "./AgentSkillsPanel";
-16
View File
@@ -1,16 +0,0 @@
/**
* @file tabs 导出
* @module components/image-gen/tabs
*/
export { AiImageGenTab } from "./AiImageGenTab";
export type { AiImageGenTabProps } from "./AiImageGenTab";
export { ImageSearchTab } from "./ImageSearchTab";
export type { ImageSearchTabProps } from "./ImageSearchTab";
export { LocalImageTab } from "./LocalImageTab";
export type { LocalImageTabProps } from "./LocalImageTab";
export { MyGalleryTab } from "./MyGalleryTab";
export type { MyGalleryTabProps } from "./MyGalleryTab";
+19 -4
View File
@@ -8,8 +8,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { __imageGenFalTestUtils } from "./useImageGen";
import { silenceConsole } from "./test-utils";
const { buildFalInput, requestImageFromFal, resolveFalEndpointModelCandidates } =
__imageGenFalTestUtils;
const {
buildFalInput,
requestImageFromFal,
resolveFalEndpointModelCandidates,
} = __imageGenFalTestUtils;
function createJsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
@@ -85,6 +88,18 @@ describe("useImageGen Fal 调用链路", () => {
expect(payload).not.toHaveProperty("image_urls");
});
it("1792x1024 应映射为 Fal 支持的 16:9,而不是 7:4", () => {
const payload = buildFalInput(
"a spring cafe",
[],
"1792x1024",
"fal-ai/nano-banana-pro",
true,
) as Record<string, unknown>;
expect(payload.aspect_ratio).toBe("16:9");
});
it("Fal Host 带 /fal-ai 历史路径时应自动归一化,避免重复拼接", async () => {
fetchMock.mockResolvedValueOnce(
createJsonResponse({
@@ -189,10 +204,10 @@ describe("useImageGen Fal 调用链路", () => {
);
const editPayload = JSON.parse(
((fetchMock.mock.calls[0]?.[1] as { body?: string })?.body ?? "{}"),
(fetchMock.mock.calls[0]?.[1] as { body?: string })?.body ?? "{}",
) as Record<string, unknown>;
const basePayload = JSON.parse(
((fetchMock.mock.calls[2]?.[1] as { body?: string })?.body ?? "{}"),
(fetchMock.mock.calls[2]?.[1] as { body?: string })?.body ?? "{}",
) as Record<string, unknown>;
expect(editPayload).toMatchObject({
+49 -8
View File
@@ -192,7 +192,10 @@ async function fetchWithManagedAbort(
): Promise<Response> {
const timeoutMs = options?.timeoutMs ?? 0;
const abortController = new AbortController();
const cleanupExternalAbort = bindAbortSignal(abortController, options?.signal);
const cleanupExternalAbort = bindAbortSignal(
abortController,
options?.signal,
);
const timeoutHandle =
timeoutMs > 0
? setTimeout(() => {
@@ -241,7 +244,9 @@ async function attemptFalQueueCancellation(
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[ImageGen][fal/queue-cancel] cancel request failed: ${message}`);
console.warn(
`[ImageGen][fal/queue-cancel] cancel request failed: ${message}`,
);
}
}
@@ -804,7 +809,42 @@ function sizeToAspectRatio(size: string): string | null {
}
const gcd = computeGreatestCommonDivisor(width, height);
return `${Math.round(width / gcd)}:${Math.round(height / gcd)}`;
const exactRatio = `${Math.round(width / gcd)}:${Math.round(height / gcd)}`;
const supportedAspectRatios = [
["21:9", 21 / 9],
["16:9", 16 / 9],
["3:2", 3 / 2],
["4:3", 4 / 3],
["5:4", 5 / 4],
["1:1", 1],
["4:5", 4 / 5],
["3:4", 3 / 4],
["2:3", 2 / 3],
["9:16", 9 / 16],
] as const;
if (supportedAspectRatios.some(([label]) => label === exactRatio)) {
return exactRatio;
}
const numericRatio = width / height;
const nearest = supportedAspectRatios.reduce<
readonly [string, number] | null
>((best, current) => {
if (!best) {
return current;
}
const bestDiff = Math.abs(numericRatio - best[1]);
const currentDiff = Math.abs(numericRatio - current[1]);
return currentDiff < bestDiff ? current : best;
}, null);
if (!nearest) {
return null;
}
return Math.abs(numericRatio - nearest[1]) <= 0.08 ? nearest[0] : "auto";
}
function collectTextFromUnknown(value: unknown): string[] {
@@ -1983,7 +2023,8 @@ function isFalProviderLike(provider: {
const normalizedHost = provider.api_host.trim().toLowerCase();
return (
normalizedHost.includes("fal.run") || normalizedHost.includes("queue.fal.run")
normalizedHost.includes("fal.run") ||
normalizedHost.includes("queue.fal.run")
);
}
@@ -2261,7 +2302,9 @@ export function useImageGen(options: UseImageGenOptions = {}) {
() =>
Boolean(preferredProviderId) &&
!providersLoading &&
!availableProviders.some((provider) => provider.id === preferredProviderId),
!availableProviders.some(
(provider) => provider.id === preferredProviderId,
),
[availableProviders, preferredProviderId, providersLoading],
);
@@ -2782,9 +2825,7 @@ export function useImageGen(options: UseImageGenOptions = {}) {
});
}
throw canceled
? new Error(IMAGE_GENERATION_CANCELED_MESSAGE)
: error;
throw canceled ? new Error(IMAGE_GENERATION_CANCELED_MESSAGE) : error;
} finally {
if (generationRunIdRef.current === generationRunId) {
generationAbortControllerRef.current = null;
-206
View File
@@ -1,206 +0,0 @@
/**
* @file PanelLayout.tsx
* @description 分屏布局组件
* @module components/layout/PanelLayout
*
* 使用 react-resizable-panels 实现可调整的分屏布局。
*/
import React from "react";
import {
Panel,
Group as ResizablePanelGroup,
Separator as ResizableHandle,
} from "react-resizable-panels";
import type { PanelNode, Block, BlockViewModel } from "@/lib/blocks/types";
import { blockRegistry } from "@/lib/blocks/registry";
/** 布局属性 */
export interface PanelLayoutProps {
/** 根节点 */
rootNode: PanelNode;
/** 块映射 */
blocks: Map<string, Block>;
/** 活动块 ID */
activeBlockId?: string;
/** 最大化块 ID */
magnifiedBlockId?: string;
/** 块聚焦回调 */
onBlockFocus?: (blockId: string) => void;
/** 块关闭回调 */
onBlockClose?: (blockId: string) => void;
/** 块最大化回调 */
onBlockMagnify?: (blockId: string) => void;
/** 面板大小变化回调 */
onPanelResize?: (nodeId: string, size: number) => void;
}
/** 调整手柄组件 */
const ResizeHandle: React.FC<{ orientation: "horizontal" | "vertical" }> = ({
orientation,
}) => (
<ResizableHandle
className={`panel-resize-handle ${orientation === "horizontal" ? "horizontal" : "vertical"}`}
>
<div className="panel-resize-handle-inner" />
</ResizableHandle>
);
/** 块渲染器 */
const BlockRenderer: React.FC<{
block: Block;
viewModel: BlockViewModel;
visible: boolean;
}> = ({ block, viewModel, visible }) => {
const Component = blockRegistry.getComponent(block.type);
if (!Component) {
return (
<div className="flex items-center justify-center h-full text-gray-500">
未知的块类型: {block.type}
</div>
);
}
return <Component block={block} viewModel={viewModel} visible={visible} />;
};
/** 面板节点渲染器 */
const PanelNodeRenderer: React.FC<{
node: PanelNode;
blocks: Map<string, Block>;
activeBlockId?: string;
magnifiedBlockId?: string;
onBlockFocus?: (blockId: string) => void;
onBlockClose?: (blockId: string) => void;
onBlockMagnify?: (blockId: string) => void;
onPanelResize?: (nodeId: string, size: number) => void;
}> = ({
node,
blocks,
activeBlockId,
magnifiedBlockId,
onBlockFocus,
onBlockClose,
onBlockMagnify,
onPanelResize,
}) => {
// 块节点
if (node.type === "block" && node.blockId) {
const block = blocks.get(node.blockId);
if (!block) {
return (
<div className="flex items-center justify-center h-full text-gray-500">
块不存在
</div>
);
}
const viewModel: BlockViewModel = {
blockId: block.id,
isFocused: activeBlockId === block.id,
isMagnified: magnifiedBlockId === block.id,
focus: () => onBlockFocus?.(block.id),
toggleMagnify: () => onBlockMagnify?.(block.id),
close: () => onBlockClose?.(block.id),
};
return (
<BlockRenderer
block={block}
viewModel={viewModel}
visible={!magnifiedBlockId || magnifiedBlockId === block.id}
/>
);
}
// 组节点
if (node.type === "group" && node.children && node.children.length > 0) {
const orientation = node.direction ?? "horizontal";
return (
<ResizablePanelGroup orientation={orientation} className="panel-group">
{node.children.map((child, index) => (
<React.Fragment key={child.id}>
{index > 0 && <ResizeHandle orientation={orientation} />}
<Panel
defaultSize={child.size ?? 100 / node.children!.length}
minSize={10}
onResize={(size) => onPanelResize?.(child.id, size.asPercentage)}
>
<PanelNodeRenderer
node={child}
blocks={blocks}
activeBlockId={activeBlockId}
magnifiedBlockId={magnifiedBlockId}
onBlockFocus={onBlockFocus}
onBlockClose={onBlockClose}
onBlockMagnify={onBlockMagnify}
onPanelResize={onPanelResize}
/>
</Panel>
</React.Fragment>
))}
</ResizablePanelGroup>
);
}
// 空节点
return (
<div className="flex items-center justify-center h-full text-gray-500">
空面板
</div>
);
};
/**
* 分屏布局组件
*/
export const PanelLayout: React.FC<PanelLayoutProps> = ({
rootNode,
blocks,
activeBlockId,
magnifiedBlockId,
onBlockFocus,
onBlockClose,
onBlockMagnify,
onPanelResize,
}) => {
// 如果有最大化的块,只显示该块
if (magnifiedBlockId) {
const block = blocks.get(magnifiedBlockId);
if (block) {
const viewModel: BlockViewModel = {
blockId: block.id,
isFocused: true,
isMagnified: true,
focus: () => onBlockFocus?.(block.id),
toggleMagnify: () => onBlockMagnify?.(block.id),
close: () => onBlockClose?.(block.id),
};
return (
<div className="panel-layout magnified">
<BlockRenderer block={block} viewModel={viewModel} visible={true} />
</div>
);
}
}
return (
<div className="panel-layout">
<PanelNodeRenderer
node={rootNode}
blocks={blocks}
activeBlockId={activeBlockId}
magnifiedBlockId={magnifiedBlockId}
onBlockFocus={onBlockFocus}
onBlockClose={onBlockClose}
onBlockMagnify={onBlockMagnify}
onPanelResize={onPanelResize}
/>
</div>
);
};
export default PanelLayout;
-55
View File
@@ -1,55 +0,0 @@
import { useEffect, useState } from 'react';
import { getFeedbackStats, type FeedbackStats as FeedbackStatsData } from '@/lib/api/memoryFeedback';
interface FeedbackStatsProps {
sessionId: string;
}
export function FeedbackStats({ sessionId }: FeedbackStatsProps) {
const [stats, setStats] = useState<FeedbackStatsData | null>(null);
useEffect(() => {
let cancelled = false;
if (!sessionId) {
setStats(null);
return;
}
getFeedbackStats(sessionId)
.then((result) => {
if (!cancelled) {
setStats(result);
}
})
.catch(() => {
if (!cancelled) {
setStats(null);
}
});
return () => {
cancelled = true;
};
}, [sessionId]);
if (!stats) return null;
return (
<div className="grid grid-cols-4 gap-4">
<StatCard label="总数" value={stats.total} />
<StatCard label="批准" value={stats.approve_count} />
<StatCard label="拒绝" value={stats.reject_count} />
<StatCard label="批准率" value={`${(stats.approval_rate * 100).toFixed(1)}%`} />
</div>
);
}
function StatCard({ label, value }: { label: string; value: number | string }) {
return (
<div className="rounded-lg border p-4">
<div className="text-sm text-muted-foreground">{label}</div>
<div className="text-2xl font-bold">{value}</div>
</div>
);
}
-30
View File
@@ -1,30 +0,0 @@
import { Button } from '@/components/ui/button';
import { recordFeedback } from '@/lib/api/memoryFeedback';
import { toast } from 'sonner';
interface MemoryFeedbackProps {
memoryId: string;
sessionId: string;
}
export function MemoryFeedback({ memoryId, sessionId }: MemoryFeedbackProps) {
const handleFeedback = async (action: 'approve' | 'reject') => {
try {
await recordFeedback(memoryId, action, sessionId);
toast.success(action === 'approve' ? '已批准' : '已拒绝');
} catch (error) {
toast.error('反馈失败: ' + error);
}
};
return (
<div className="flex gap-2">
<Button size="sm" variant="ghost" onClick={() => handleFeedback('approve')}>
✓
</Button>
<Button size="sm" variant="ghost" onClick={() => handleFeedback('reject')}>
✗
</Button>
</div>
);
}
@@ -1,391 +0,0 @@
/**
* 增强版模型列表组件
*
* 使用 model_registry 数据,支持搜索、收藏、分组等功能
*/
import { useState, useMemo } from "react";
import {
Check,
AlertCircle,
Loader2,
Star,
Search,
ChevronDown,
ChevronRight,
Eye,
Wrench,
Brain,
DollarSign,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { EnhancedModelMetadata } from "@/lib/types/modelRegistry";
interface EnhancedModelListProps {
/** 模型列表 */
models: EnhancedModelMetadata[];
/** 选中的模型 ID */
selectedModelId?: string;
/** 选择模型回调 */
onSelectModel?: (model: EnhancedModelMetadata) => void;
/** 收藏模型回调 */
onToggleFavorite?: (modelId: string) => void;
/** 收藏的模型 ID 集合 */
favorites?: Set<string>;
/** 是否加载中 */
loading?: boolean;
/** 错误信息 */
error?: string | null;
/** 是否按 Provider 分组 */
groupByProvider?: boolean;
/** 是否显示搜索框 */
showSearch?: boolean;
/** 是否显示定价信息 */
showPricing?: boolean;
/** 自定义类名 */
className?: string;
}
export function EnhancedModelList({
models,
selectedModelId,
onSelectModel,
onToggleFavorite,
favorites = new Set(),
loading = false,
error = null,
groupByProvider = true,
showSearch = true,
showPricing = false,
className,
}: EnhancedModelListProps) {
const [searchQuery, setSearchQuery] = useState("");
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
new Set(["favorites"]),
);
// 过滤模型
const filteredModels = useMemo(() => {
if (!searchQuery.trim()) return models;
const query = searchQuery.toLowerCase();
return models.filter(
(m) =>
m.id.toLowerCase().includes(query) ||
m.display_name.toLowerCase().includes(query) ||
m.provider_name.toLowerCase().includes(query) ||
m.family?.toLowerCase().includes(query),
);
}, [models, searchQuery]);
// 按 Provider 分组
const groupedModels = useMemo(() => {
if (!groupByProvider) {
return { all: filteredModels };
}
const groups: Record<string, EnhancedModelMetadata[]> = {};
// 先添加收藏组
const favoriteModels = filteredModels.filter((m) => favorites.has(m.id));
if (favoriteModels.length > 0) {
groups["favorites"] = favoriteModels;
}
// 按 Provider 分组
for (const model of filteredModels) {
if (!groups[model.provider_id]) {
groups[model.provider_id] = [];
}
groups[model.provider_id].push(model);
}
return groups;
}, [filteredModels, groupByProvider, favorites]);
// 切换分组展开状态
const toggleGroup = (groupId: string) => {
setExpandedGroups((prev) => {
const next = new Set(prev);
if (next.has(groupId)) {
next.delete(groupId);
} else {
next.add(groupId);
}
return next;
});
};
if (loading) {
return (
<div className={cn("flex items-center justify-center py-8", className)}>
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">加载模型列表...</span>
</div>
);
}
if (error) {
return (
<div
className={cn(
"flex items-center justify-center py-8 text-destructive",
className,
)}
>
<AlertCircle className="h-5 w-5 mr-2" />
<span>{error}</span>
</div>
);
}
if (models.length === 0) {
return (
<div className={cn("text-center py-8 text-muted-foreground", className)}>
<p>暂无可用模型</p>
<p className="text-sm mt-1">请等待模型数据加载</p>
</div>
);
}
return (
<div className={cn("space-y-3", className)}>
{/* 搜索框 */}
{showSearch && (
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
placeholder="搜索模型..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded-lg border bg-background pl-10 pr-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</div>
)}
{/* 模型列表 */}
<div className="space-y-2">
{Object.entries(groupedModels).map(([groupId, groupModels]) => {
const isExpanded = expandedGroups.has(groupId);
const groupName = getGroupName(groupId, groupModels[0]);
return (
<div key={groupId} className="border rounded-lg overflow-hidden">
{/* 分组头部 */}
{groupByProvider && (
<button
type="button"
onClick={() => toggleGroup(groupId)}
className="w-full flex items-center justify-between px-3 py-2 bg-muted/50 hover:bg-muted transition-colors"
>
<div className="flex items-center gap-2">
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
{groupId === "favorites" && (
<Star className="h-4 w-4 text-yellow-500 fill-yellow-500" />
)}
<span className="font-medium text-sm">{groupName}</span>
</div>
<span className="text-xs text-muted-foreground">
{groupModels.length} 个模型
</span>
</button>
)}
{/* 模型列表 */}
{(!groupByProvider || isExpanded) && (
<div className="divide-y">
{groupModels.map((model) => (
<ModelItem
key={model.id}
model={model}
isSelected={model.id === selectedModelId}
isFavorite={favorites.has(model.id)}
onSelect={() => onSelectModel?.(model)}
onToggleFavorite={() => onToggleFavorite?.(model.id)}
showPricing={showPricing}
/>
))}
</div>
)}
</div>
);
})}
</div>
{/* 无搜索结果 */}
{filteredModels.length === 0 && searchQuery && (
<div className="text-center py-8 text-muted-foreground">
<p>未找到匹配的模型</p>
<p className="text-sm mt-1">尝试其他搜索词</p>
</div>
)}
</div>
);
}
/** 单个模型项 */
function ModelItem({
model,
isSelected,
isFavorite,
onSelect,
onToggleFavorite,
showPricing,
}: {
model: EnhancedModelMetadata;
isSelected: boolean;
isFavorite: boolean;
onSelect: () => void;
onToggleFavorite: () => void;
showPricing: boolean;
}) {
return (
<div
className={cn(
"flex items-center justify-between px-3 py-2.5 hover:bg-muted/30 transition-colors cursor-pointer",
isSelected && "bg-primary/5",
)}
onClick={onSelect}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
{/* 选中指示器 */}
<div
className={cn(
"w-4 h-4 rounded-full border-2 flex-shrink-0 flex items-center justify-center",
isSelected
? "border-primary bg-primary"
: "border-muted-foreground",
)}
>
{isSelected && <Check className="h-3 w-3 text-primary-foreground" />}
</div>
{/* 模型信息 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">
{model.display_name}
</span>
{model.is_latest && (
<span className="text-xs px-1.5 py-0.5 rounded bg-primary/10 text-primary">
最新
</span>
)}
<TierBadge tier={model.tier} />
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{model.id}</span>
{model.limits.context_length && (
<>
<span>·</span>
<span>{formatContextLength(model.limits.context_length)}</span>
</>
)}
</div>
</div>
</div>
{/* 能力标签和操作 */}
<div className="flex items-center gap-2 flex-shrink-0">
{/* 能力图标 */}
<div className="flex items-center gap-1">
{model.capabilities.vision && (
<span title="支持视觉">
<Eye className="h-3.5 w-3.5 text-blue-500" />
</span>
)}
{model.capabilities.tools && (
<span title="支持工具">
<Wrench className="h-3.5 w-3.5 text-green-500" />
</span>
)}
{model.capabilities.reasoning && (
<span title="支持推理">
<Brain className="h-3.5 w-3.5 text-purple-500" />
</span>
)}
</div>
{/* 定价 */}
{showPricing && model.pricing && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<DollarSign className="h-3 w-3" />
<span>{model.pricing.input_per_million?.toFixed(2) || "?"}</span>
</div>
)}
{/* 收藏按钮 */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggleFavorite();
}}
className="p-1 rounded hover:bg-muted transition-colors"
title={isFavorite ? "取消收藏" : "收藏"}
>
<Star
className={cn(
"h-4 w-4",
isFavorite
? "text-yellow-500 fill-yellow-500"
: "text-muted-foreground",
)}
/>
</button>
</div>
</div>
);
}
/** 服务等级徽章 */
function TierBadge({ tier }: { tier: string }) {
const config = {
mini: {
label: "Mini",
color:
"bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300",
},
pro: {
label: "Pro",
color: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
},
max: {
label: "Max",
color:
"bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300",
},
}[tier] || { label: tier, color: "bg-gray-100 text-gray-700" };
return (
<span className={cn("text-xs px-1.5 py-0.5 rounded", config.color)}>
{config.label}
</span>
);
}
/** 获取分组名称 */
function getGroupName(
groupId: string,
firstModel: EnhancedModelMetadata,
): string {
if (groupId === "favorites") return "收藏";
if (groupId === "all") return "全部模型";
return firstModel?.provider_name || groupId;
}
/** 格式化上下文长度 */
function formatContextLength(length: number): string {
if (length >= 1000000) {
return `${(length / 1000000).toFixed(1)}M`;
}
if (length >= 1000) {
return `${(length / 1000).toFixed(0)}K`;
}
return String(length);
}
@@ -1,59 +0,0 @@
/**
* 模式切换组件 - 简单模式/专家模式
*/
import { Settings2, Wand2 } from "lucide-react";
import { cn } from "@/lib/utils";
export type SelectionMode = "simple" | "expert";
interface ModeToggleProps {
/** 当前模式 */
mode: SelectionMode;
/** 模式变化回调 */
onModeChange: (mode: SelectionMode) => void;
/** 是否禁用 */
disabled?: boolean;
/** 自定义类名 */
className?: string;
}
export function ModeToggle({
mode,
onModeChange,
disabled = false,
className,
}: ModeToggleProps) {
return (
<div className={cn("flex items-center gap-2", className)}>
<button
type="button"
onClick={() => onModeChange("simple")}
disabled={disabled}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm transition-colors",
mode === "simple"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
<Wand2 className="h-4 w-4" />
<span>简单</span>
</button>
<button
type="button"
onClick={() => onModeChange("expert")}
disabled={disabled}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm transition-colors",
mode === "expert"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
<Settings2 className="h-4 w-4" />
<span>专家</span>
</button>
</div>
);
}
-162
View File
@@ -1,162 +0,0 @@
/**
* 模型列表组件 - 显示可用模型
*/
import { Check, AlertCircle, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import type { AvailableModel } from "@/lib/api/orchestrator";
interface ModelListProps {
/** 模型列表 */
models: AvailableModel[];
/** 选中的模型 ID */
selectedModelId?: string;
/** 选择模型回调 */
onSelectModel?: (model: AvailableModel) => void;
/** 是否加载中 */
loading?: boolean;
/** 错误信息 */
error?: string | null;
/** 自定义类名 */
className?: string;
}
export function ModelList({
models,
selectedModelId,
onSelectModel,
loading = false,
error = null,
className,
}: ModelListProps) {
if (loading) {
return (
<div className={cn("flex items-center justify-center py-8", className)}>
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">加载模型列表...</span>
</div>
);
}
if (error) {
return (
<div
className={cn(
"flex items-center justify-center py-8 text-destructive",
className,
)}
>
<AlertCircle className="h-5 w-5 mr-2" />
<span>{error}</span>
</div>
);
}
if (models.length === 0) {
return (
<div className={cn("text-center py-8 text-muted-foreground", className)}>
<p>暂无可用模型</p>
<p className="text-sm mt-1">请先添加凭证</p>
</div>
);
}
return (
<div className={cn("space-y-2", className)}>
{models.map((model) => {
const isSelected = model.id === selectedModelId;
return (
<button
key={`${model.id}-${model.credential_id}`}
type="button"
onClick={() => onSelectModel?.(model)}
className={cn(
"w-full flex items-center justify-between p-3 rounded-lg border transition-colors",
"hover:bg-muted/50",
isSelected ? "border-primary bg-primary/5" : "border-border",
!model.is_healthy && "opacity-60",
)}
>
<div className="flex items-center gap-3">
{/* 选中指示器 */}
<div
className={cn(
"w-4 h-4 rounded-full border-2 flex items-center justify-center",
isSelected
? "border-primary bg-primary"
: "border-muted-foreground",
)}
>
{isSelected && (
<Check className="h-3 w-3 text-primary-foreground" />
)}
</div>
{/* 模型信息 */}
<div className="text-left">
<div className="font-medium text-sm">{model.display_name}</div>
<div className="text-xs text-muted-foreground">
{model.provider_type}
{model.context_length &&
` · ${formatContextLength(model.context_length)}`}
</div>
</div>
</div>
{/* 状态和能力标签 */}
<div className="flex items-center gap-2">
{model.supports_vision && (
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300">
视觉
</span>
)}
{model.supports_tools && (
<span className="text-xs px-1.5 py-0.5 rounded bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300">
工具
</span>
)}
{!model.is_healthy && (
<span className="text-xs px-1.5 py-0.5 rounded bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300">
不健康
</span>
)}
{model.current_load !== undefined && (
<LoadIndicator load={model.current_load} />
)}
</div>
</button>
);
})}
</div>
);
}
/** 负载指示器 */
function LoadIndicator({ load }: { load: number }) {
const color =
load < 30 ? "bg-green-500" : load < 70 ? "bg-yellow-500" : "bg-red-500";
return (
<div className="flex items-center gap-1">
<div className="w-8 h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn("h-full rounded-full", color)}
style={{ width: `${load}%` }}
/>
</div>
<span className="text-xs text-muted-foreground">{load}%</span>
</div>
);
}
/** 格式化上下文长度 */
function formatContextLength(length: number): string {
if (length >= 1000000) {
return `${(length / 1000000).toFixed(1)}M`;
}
if (length >= 1000) {
return `${(length / 1000).toFixed(0)}K`;
}
return String(length);
}
@@ -1,201 +0,0 @@
/**
* 统一模型选择器组件
*
* 整合简单模式(Mini/Pro/Max)和专家模式(直接选择模型)
*/
import { useState } from "react";
import { RefreshCw, Activity } from "lucide-react";
import { cn } from "@/lib/utils";
import { TierSelector } from "./TierSelector";
import { ModeToggle, type SelectionMode } from "./ModeToggle";
import { ModelList } from "./ModelList";
import {
useOrchestrator,
useModelSelection,
type ServiceTier,
type AvailableModel,
type SelectionResult,
} from "@/lib/api/orchestrator";
interface ModelSelectorProps {
/** 初始模式 */
initialMode?: SelectionMode;
/** 初始等级 */
initialTier?: ServiceTier;
/** 选择模型回调 */
onSelect?: (result: SelectionResult) => void;
/** 是否显示模式切换 */
showModeToggle?: boolean;
/** 是否显示统计信息 */
showStats?: boolean;
/** 紧凑模式 */
compact?: boolean;
/** 自定义类名 */
className?: string;
}
export function ModelSelector({
initialMode = "simple",
initialTier = "pro",
onSelect,
showModeToggle = true,
showStats = true,
compact = false,
className,
}: ModelSelectorProps) {
const [mode, setMode] = useState<SelectionMode>(initialMode);
const [selectedModel, setSelectedModel] = useState<AvailableModel | null>(
null,
);
// 使用编排器状态
const {
initialized: _initialized,
loading: orchestratorLoading,
error: orchestratorError,
poolStats,
refreshStats,
} = useOrchestrator();
// 使用模型选择
const {
tier,
setTier,
models,
loading: modelsLoading,
error: modelsError,
selectModel,
refreshModels,
} = useModelSelection(initialTier);
// 简单模式下自动选择模型
const handleTierChange = async (newTier: ServiceTier) => {
setTier(newTier);
if (mode === "simple") {
try {
const result = await selectModel({ tier: newTier });
onSelect?.(result);
} catch (err) {
console.error("模型选择失败:", err);
}
}
};
// 专家模式下手动选择模型
const handleModelSelect = async (model: AvailableModel) => {
setSelectedModel(model);
try {
const result = await selectModel({
tier,
preferred_provider: model.provider_type,
});
onSelect?.(result);
} catch (err) {
console.error("模型选择失败:", err);
}
};
// 刷新数据
const handleRefresh = () => {
refreshStats();
refreshModels();
};
const loading = orchestratorLoading || modelsLoading;
const error = orchestratorError || modelsError;
return (
<div className={cn("space-y-4", className)}>
{/* 头部:模式切换和刷新 */}
<div className="flex items-center justify-between">
{showModeToggle && (
<ModeToggle mode={mode} onModeChange={setMode} disabled={loading} />
)}
<div className="flex items-center gap-2">
{/* 统计信息 */}
{showStats && poolStats && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Activity className="h-3 w-3" />
<span>
{poolStats.healthy_count}/{poolStats.total_count} 可用
</span>
</div>
)}
{/* 刷新按钮 */}
<button
type="button"
onClick={handleRefresh}
disabled={loading}
className="p-1.5 rounded-md hover:bg-muted transition-colors"
title="刷新"
>
<RefreshCw
className={cn(
"h-4 w-4 text-muted-foreground",
loading && "animate-spin",
)}
/>
</button>
</div>
</div>
{/* 等级选择器 */}
<TierSelector
value={tier}
onChange={handleTierChange}
disabled={loading}
modelCounts={
poolStats
? {
mini: poolStats.mini_count,
pro: poolStats.pro_count,
max: poolStats.max_count,
}
: undefined
}
compact={compact}
/>
{/* 专家模式:显示模型列表 */}
{mode === "expert" && (
<ModelList
models={models}
selectedModelId={selectedModel?.id}
onSelectModel={handleModelSelect}
loading={modelsLoading}
error={modelsError}
/>
)}
{/* 简单模式:显示当前选择 */}
{mode === "simple" && selectedModel && (
<div className="p-3 rounded-lg bg-muted/50 border">
<div className="text-sm font-medium">
{selectedModel.display_name}
</div>
<div className="text-xs text-muted-foreground">
{selectedModel.provider_type}
</div>
</div>
)}
{/* 错误提示 */}
{error && (
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/20 text-destructive text-sm">
{error}
</div>
)}
</div>
);
}
// 导出子组件
export { TierSelector } from "./TierSelector";
export { ModeToggle } from "./ModeToggle";
export { ModelList } from "./ModelList";
export type { SelectionMode } from "./ModeToggle";
@@ -1,144 +0,0 @@
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
mockUseConfiguredProviders,
mockUseProviderModels,
} = vi.hoisted(() => ({
mockUseConfiguredProviders: vi.fn(),
mockUseProviderModels: vi.fn(),
}));
vi.mock("@/hooks/useConfiguredProviders", () => ({
useConfiguredProviders: () => mockUseConfiguredProviders(),
}));
vi.mock("@/hooks/useProviderModels", () => ({
useProviderModels: (...args: unknown[]) => mockUseProviderModels(...args),
}));
import { ProviderModelSelector } from "./ProviderModelSelector";
interface MountedRoot {
root: Root;
container: HTMLDivElement;
}
const mountedRoots: MountedRoot[] = [];
function renderSelector(
props: Partial<React.ComponentProps<typeof ProviderModelSelector>> = {},
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const mergedProps: React.ComponentProps<typeof ProviderModelSelector> = {
onSelect: vi.fn(),
initialProviderId: "custom-codex",
...props,
};
act(() => {
root.render(<ProviderModelSelector {...mergedProps} />);
});
mountedRoots.push({ root, container });
return { container };
}
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
vi.clearAllMocks();
mockUseConfiguredProviders.mockReturnValue({
providers: [
{
key: "custom-codex",
label: "Codex Custom",
registryId: "custom-codex",
fallbackRegistryId: "codex",
type: "codex",
providerId: "custom-codex",
apiHost: "https://api.openai.com/v1",
},
],
loading: false,
});
mockUseProviderModels.mockReturnValue({
models: [
{
id: "gpt-5.3-codex",
display_name: "GPT-5.3 Codex",
capabilities: {
vision: true,
tools: true,
streaming: true,
json_mode: true,
function_calling: true,
reasoning: true,
},
is_latest: true,
},
{
id: "gpt-5.2-codex",
display_name: "GPT-5.2 Codex",
capabilities: {
vision: true,
tools: true,
streaming: true,
json_mode: true,
function_calling: true,
reasoning: true,
},
is_latest: false,
},
],
loading: false,
error: null,
});
});
afterEach(() => {
while (mountedRoots.length > 0) {
const mounted = mountedRoots.pop();
if (!mounted) break;
act(() => {
mounted.root.unmount();
});
mounted.container.remove();
}
});
describe("ProviderModelSelector", () => {
it("支持实时拉取的 API Key Provider 应使用真实模型目录", () => {
renderSelector();
expect(mockUseProviderModels).toHaveBeenCalledWith(
expect.objectContaining({ key: "custom-codex" }),
expect.objectContaining({
returnFullMetadata: true,
liveFetchOnly: true,
hasApiKey: true,
}),
);
});
it("应隐藏 codex 不兼容模型并展示兼容提示", () => {
const { container } = renderSelector();
expect(container.textContent).toContain(
"已隐藏 1 个当前登录态不兼容的模型",
);
expect(container.textContent).not.toContain("GPT-5.3 Codex");
expect(container.textContent).toContain("GPT-5.2 Codex");
});
});
@@ -1,341 +0,0 @@
/**
* @file ProviderModelSelector 组件
* @description 双栏模型选择器:左侧 Provider 列表,右侧模型列表
* @module components/model-selector/ProviderModelSelector
*/
import React, { useState, useMemo, useCallback, useEffect } from "react";
import { cn } from "@/lib/utils";
import {
Check,
ChevronRight,
Eye,
Wrench,
Brain,
Loader2,
AlertCircle,
} from "lucide-react";
import {
useConfiguredProviders,
type ConfiguredProvider,
} from "@/hooks/useConfiguredProviders";
import { useProviderModels } from "@/hooks/useProviderModels";
import { getProviderLabel } from "@/lib/constants/providerMappings";
import type { EnhancedModelMetadata } from "@/lib/types/modelRegistry";
import { getProviderModelCompatibilityIssue } from "@/components/agent/chat/utils/providerModelCompatibility";
import { resolveProviderModelLoadOptions } from "@/lib/model/providerModelLoadOptions";
// ============================================================================
// 类型定义
// ============================================================================
export interface ProviderModelSelectorProps {
/** 选择模型回调 */
onSelect?: (model: EnhancedModelMetadata, providerId: string) => void;
/** 初始选中的 Provider */
initialProviderId?: string;
/** 初始选中的模型 */
initialModelId?: string;
/** 自定义类名 */
className?: string;
}
// ============================================================================
// 子组件
// ============================================================================
interface ProviderItemProps {
provider: ConfiguredProvider;
isSelected: boolean;
onClick: () => void;
}
/** Provider 列表项 */
const ProviderItem: React.FC<ProviderItemProps> = ({
provider,
isSelected,
onClick,
}) => {
return (
<button
type="button"
onClick={onClick}
className={cn(
"w-full flex items-center justify-between px-3 py-2 text-sm rounded-md transition-colors",
isSelected
? "bg-primary text-primary-foreground"
: "hover:bg-muted text-foreground",
)}
data-testid={`provider-item-${provider.key}`}
>
<div className="flex items-center gap-2 min-w-0">
<ChevronRight
className={cn(
"h-4 w-4 flex-shrink-0 transition-transform",
isSelected && "rotate-90",
)}
/>
<span className="truncate">{provider.label}</span>
</div>
</button>
);
};
interface ModelItemProps {
model: EnhancedModelMetadata;
isSelected: boolean;
onClick: () => void;
}
/** 模型列表项 */
const ModelItem: React.FC<ModelItemProps> = ({
model,
isSelected,
onClick,
}) => {
return (
<button
type="button"
onClick={onClick}
className={cn(
"w-full flex items-center justify-between px-3 py-2 text-sm rounded-md transition-colors",
isSelected
? "bg-primary/10 border border-primary"
: "hover:bg-muted border border-transparent",
)}
data-testid={`model-item-${model.id}`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{model.display_name}</span>
{model.is_latest && (
<span className="text-[10px] bg-green-100 text-green-700 px-1 py-0.5 rounded">
最新
</span>
)}
</div>
<div className="text-xs text-muted-foreground truncate">{model.id}</div>
</div>
{/* 能力标签 */}
<div className="flex items-center gap-1.5 ml-2">
{model.capabilities.vision && (
<span title="支持视觉">
<Eye className="h-3.5 w-3.5 text-blue-500" />
</span>
)}
{model.capabilities.tools && (
<span title="支持工具">
<Wrench className="h-3.5 w-3.5 text-orange-500" />
</span>
)}
{model.capabilities.reasoning && (
<span title="支持推理">
<Brain className="h-3.5 w-3.5 text-purple-500" />
</span>
)}
{isSelected && <Check className="h-4 w-4 text-primary ml-1" />}
</div>
</button>
);
};
// ============================================================================
// 主组件
// ============================================================================
/**
* 双栏模型选择器组件
*
* 左侧显示已配置凭证的 Provider 列表(单选)
* 右侧显示选中 Provider 对应的模型列表(单选)
*
* @example
* ```tsx
* <ProviderModelSelector
* onSelect={(model, providerId) => {
* console.log("选中模型:", model.display_name);
* }}
* />
* ```
*/
export const ProviderModelSelector: React.FC<ProviderModelSelectorProps> = ({
onSelect,
initialProviderId,
initialModelId,
className,
}) => {
// 状态
const [selectedProviderId, setSelectedProviderId] = useState<string | null>(
initialProviderId || null,
);
const [selectedModelId, setSelectedModelId] = useState<string | null>(
initialModelId || null,
);
// 获取已配置的 Provider 列表(使用共享 hook)
const { providers: configuredProviders, loading: providersLoading } =
useConfiguredProviders();
// 获取当前选中的 Provider
const selectedProvider = useMemo(() => {
return configuredProviders.find((p) => p.key === selectedProviderId);
}, [configuredProviders, selectedProviderId]);
const providerModelLoadOptions = useMemo(
() =>
resolveProviderModelLoadOptions({
providerId: selectedProvider?.providerId,
providerType: selectedProvider?.type,
apiHost: selectedProvider?.apiHost,
}),
[selectedProvider?.apiHost, selectedProvider?.providerId, selectedProvider?.type],
);
// 获取模型列表(使用共享 hook,返回完整元数据)
const {
models: filteredModels,
loading: modelsLoading,
error: modelsError,
} = useProviderModels(selectedProvider, {
returnFullMetadata: true,
...providerModelLoadOptions,
});
const compatibleModels = useMemo(
() =>
filteredModels.filter(
(model) =>
!getProviderModelCompatibilityIssue({
providerType: selectedProvider?.key || "",
configuredProviderType: selectedProvider?.type,
model: model.id,
}),
),
[filteredModels, selectedProvider?.key, selectedProvider?.type],
);
const incompatibleModelCount = useMemo(
() => filteredModels.length - compatibleModels.length,
[compatibleModels.length, filteredModels.length],
);
// 默认选中第一个 Provider
useEffect(() => {
if (!selectedProviderId && configuredProviders.length > 0) {
setSelectedProviderId(configuredProviders[0].key);
}
}, [selectedProviderId, configuredProviders]);
// 选择 Provider
const handleSelectProvider = useCallback((providerId: string) => {
setSelectedProviderId(providerId);
setSelectedModelId(null); // 切换 Provider 时清除模型选择
}, []);
// 选择模型
const handleSelectModel = useCallback(
(model: EnhancedModelMetadata) => {
setSelectedModelId(model.id);
if (selectedProviderId) {
onSelect?.(model, selectedProviderId);
}
},
[selectedProviderId, onSelect],
);
const isLoading = providersLoading || modelsLoading;
// 空状态
if (!isLoading && configuredProviders.length === 0) {
return (
<div
className={cn(
"flex flex-col items-center justify-center py-12 text-muted-foreground",
className,
)}
data-testid="provider-model-selector-empty"
>
<AlertCircle className="h-12 w-12 mb-4 opacity-50" />
<p className="text-sm">暂无已配置的 Provider</p>
<p className="text-xs mt-1">请先在凭证池中添加凭证</p>
</div>
);
}
return (
<div
className={cn("flex border rounded-lg overflow-hidden", className)}
data-testid="provider-model-selector"
>
{/* 左侧:Provider 列表 */}
<div className="w-48 border-r bg-muted/30 flex flex-col">
<div className="px-3 py-2 border-b bg-muted/50">
<h4 className="text-sm font-medium">Providers</h4>
<p className="text-xs text-muted-foreground">已配置凭证的</p>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{providersLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : (
configuredProviders.map((provider) => (
<ProviderItem
key={provider.key}
provider={provider}
isSelected={selectedProviderId === provider.key}
onClick={() => handleSelectProvider(provider.key)}
/>
))
)}
</div>
</div>
{/* 右侧:模型列表 */}
<div className="flex-1 flex flex-col min-w-0">
<div className="px-3 py-2 border-b bg-muted/50">
<h4 className="text-sm font-medium">Models</h4>
<p className="text-xs text-muted-foreground">
{selectedProvider
? `${getProviderLabel(selectedProvider.key)} 的模型`
: "请选择 Provider"}
</p>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{modelsLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : modelsError ? (
<div className="flex flex-col items-center justify-center py-8 text-red-500">
<AlertCircle className="h-8 w-8 mb-2" />
<p className="text-sm">{modelsError}</p>
</div>
) : compatibleModels.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<p className="text-sm">暂无模型数据</p>
</div>
) : (
<>
{incompatibleModelCount > 0 ? (
<div className="px-1 py-1 text-xs text-amber-600">
已隐藏 {incompatibleModelCount} 个当前登录态不兼容的模型
</div>
) : null}
{compatibleModels.map((model) => (
<ModelItem
key={model.id}
model={model}
isSelected={selectedModelId === model.id}
onClick={() => handleSelectModel(model)}
/>
))}
</>
)}
</div>
</div>
</div>
);
};
export default ProviderModelSelector;
@@ -1,159 +0,0 @@
/**
* 服务等级选择器 - Mini/Pro/Max 三档选择
*
* 提供类似 v0 的简洁模式选择体验
*/
import React from "react";
import { Zap, Sparkles, Crown } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ServiceTier } from "@/lib/api/orchestrator";
interface TierOption {
id: ServiceTier;
label: string;
description: string;
icon: React.ReactNode;
color: string;
bgColor: string;
borderColor: string;
}
const tierOptions: TierOption[] = [
{
id: "mini",
label: "Mini",
description: "快速响应",
icon: <Zap className="h-4 w-4" />,
color: "text-green-600 dark:text-green-400",
bgColor: "bg-green-50 dark:bg-green-950",
borderColor: "border-green-200 dark:border-green-800",
},
{
id: "pro",
label: "Pro",
description: "均衡性能",
icon: <Sparkles className="h-4 w-4" />,
color: "text-blue-600 dark:text-blue-400",
bgColor: "bg-blue-50 dark:bg-blue-950",
borderColor: "border-blue-200 dark:border-blue-800",
},
{
id: "max",
label: "Max",
description: "最强能力",
icon: <Crown className="h-4 w-4" />,
color: "text-purple-600 dark:text-purple-400",
bgColor: "bg-purple-50 dark:bg-purple-950",
borderColor: "border-purple-200 dark:border-purple-800",
},
];
interface TierSelectorProps {
/** 当前选中的等级 */
value: ServiceTier;
/** 等级变化回调 */
onChange: (tier: ServiceTier) => void;
/** 是否禁用 */
disabled?: boolean;
/** 各等级的模型数量 */
modelCounts?: {
mini: number;
pro: number;
max: number;
};
/** 紧凑模式 */
compact?: boolean;
/** 自定义类名 */
className?: string;
}
export function TierSelector({
value,
onChange,
disabled = false,
modelCounts,
compact = false,
className,
}: TierSelectorProps) {
return (
<div className={cn("flex gap-2", compact ? "gap-1" : "gap-2", className)}>
{tierOptions.map((option) => {
const isSelected = value === option.id;
const count = modelCounts?.[option.id];
const hasModels = count === undefined || count > 0;
return (
<button
key={option.id}
type="button"
onClick={() => onChange(option.id)}
disabled={disabled || !hasModels}
className={cn(
"flex-1 rounded-lg border px-3 py-2 transition-all",
"focus:outline-none focus:ring-2 focus:ring-offset-2",
compact ? "px-2 py-1.5" : "px-3 py-2",
isSelected
? cn(
option.borderColor,
option.bgColor,
option.color,
"ring-2 ring-offset-1",
option.id === "mini" && "ring-green-500/50",
option.id === "pro" && "ring-blue-500/50",
option.id === "max" && "ring-purple-500/50",
)
: cn(
"border-border hover:bg-muted",
!hasModels && "opacity-50 cursor-not-allowed",
),
)}
>
<div className="flex items-center justify-center gap-1.5">
<span
className={cn(
isSelected ? option.color : "text-muted-foreground",
)}
>
{option.icon}
</span>
<span
className={cn(
"font-medium",
compact ? "text-xs" : "text-sm",
isSelected ? option.color : "text-foreground",
)}
>
{option.label}
</span>
{count !== undefined && (
<span
className={cn(
"text-xs",
isSelected ? option.color : "text-muted-foreground",
)}
>
({count})
</span>
)}
</div>
{!compact && (
<p
className={cn(
"text-xs mt-0.5",
isSelected ? option.color : "text-muted-foreground",
)}
>
{option.description}
</p>
)}
</button>
);
})}
</div>
);
}
// eslint-disable-next-line react-refresh/only-export-components
export { tierOptions };
export type { TierOption };
-13
View File
@@ -1,13 +0,0 @@
/**
* 模型选择器组件导出
*/
export { ModelSelector } from "./ModelSelector";
export { TierSelector, tierOptions } from "./TierSelector";
export { ModeToggle } from "./ModeToggle";
export { ModelList } from "./ModelList";
export { ProviderModelSelector } from "./ProviderModelSelector";
export type { SelectionMode } from "./ModeToggle";
export type { TierOption } from "./TierSelector";
export type { ProviderModelSelectorProps } from "./ProviderModelSelector";
+5 -11
View File
@@ -1,17 +1,11 @@
import { describe, expect, it } from "vitest";
import { onboardingPlugins, userProfiles } from "./constants";
import * as onboardingConstants from "./constants";
describe("onboarding constants", () => {
it("开发者引导与配置管理描述应使用 current 品牌表述", () => {
const developerProfile = userProfiles.find((item) => item.id === "developer");
const configSwitchPlugin = onboardingPlugins.find(
(item) => item.id === "config-switch",
);
expect(developerProfile?.description).toContain("Claude、Codex、Gemini");
expect(developerProfile?.description).not.toContain("Claude Code");
expect(configSwitchPlugin?.description).toContain("Claude、Codex、Gemini");
expect(configSwitchPlugin?.description).not.toContain("Claude Code");
it("不再暴露旧插件安装流常量", () => {
expect("userProfiles" in onboardingConstants).toBe(false);
expect("onboardingPlugins" in onboardingConstants).toBe(false);
expect(onboardingConstants.ONBOARDING_VERSION).toBe("1.1.0");
});
});
+1 -60
View File
@@ -2,73 +2,14 @@
* 初次安装引导 - 常量配置
*/
import { Code, User, FileCode } from "lucide-react";
import type { LucideIcon } from "lucide-react";
/**
* 用户群体类型
*/
export type UserProfile = "developer" | "general";
/**
* 用户群体配置
*/
export interface UserProfileConfig {
id: UserProfile;
name: string;
description: string;
icon: LucideIcon;
defaultPlugins: string[];
}
/**
* 引导插件配置
*/
export interface OnboardingPlugin {
id: string;
name: string;
description: string;
icon: LucideIcon;
downloadUrl: string;
}
/**
* 用户群体列表
*/
export const userProfiles: UserProfileConfig[] = [
{
id: "developer",
name: "程序员",
description: "使用 Claude、Codex、Gemini 等 AI 编程工具",
icon: Code,
defaultPlugins: ["config-switch"],
},
{
id: "general",
name: "普通用户",
description: "日常使用 AI 聊天和其他功能",
icon: User,
defaultPlugins: [],
},
];
/**
* 可安装插件列表
*/
export const onboardingPlugins: OnboardingPlugin[] = [
{
id: "config-switch",
name: "配置管理",
description: "一键切换 API 配置,支持 Claude、Codex、Gemini 等客户端",
icon: FileCode,
downloadUrl:
"https://github.com/aiclientproxy/config-switch/releases/latest/download/config-switch-plugin.zip",
},
];
/**
* 引导版本号 - 用于控制是否重新显示引导
* 更新此版本号会触发已完成引导的用户重新看到引导
* 当前引导只保留语音体验流程,不再包含旧插件安装链路
*/
export const ONBOARDING_VERSION = "1.1.0";
@@ -66,7 +66,7 @@ export function CompleteStep({ onFinish }: CompleteStepProps) {
<Subtitle>Lime 已准备就绪,您可以开始使用了。</Subtitle>
<TipsMessage>
提示:您可以在左侧导航栏的"插件中心"随时安装插件
提示:后续可在设置中继续调整语音输入和快捷键。
</TipsMessage>
<Button size="lg" onClick={onFinish}>
@@ -1,386 +0,0 @@
/**
* 初次安装引导 - 安装进度
*/
import { useState, useEffect, useCallback, useRef } from "react";
import styled from "styled-components";
import { safeInvoke } from "@/lib/dev-bridge";
import { safeListen } from "@/lib/dev-bridge";
import type { UnlistenFn } from "@tauri-apps/api/event";
import { Check, X, Loader2 } from "lucide-react";
import { Progress } from "@/components/ui/progress";
import { onboardingPlugins } from "../constants";
const Container = styled.div`
display: flex;
flex-direction: column;
align-items: center;
padding: 32px 24px;
`;
const Title = styled.h2`
font-size: 24px;
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 8px;
text-align: center;
`;
const Subtitle = styled.p`
font-size: 14px;
color: hsl(var(--muted-foreground));
margin-bottom: 32px;
text-align: center;
`;
const PluginList = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
max-width: 500px;
`;
const PluginRow = styled.div`
display: flex;
align-items: center;
gap: 16px;
`;
const IconWrapper = styled.div<{ $status: string }>`
width: 40px;
height: 40px;
border-radius: 10px;
background: ${({ $status }) => {
switch ($status) {
case "complete":
return "hsl(142.1 76.2% 36.3%)";
case "failed":
return "hsl(0 84.2% 60.2%)";
default:
return "hsl(var(--muted))";
}
}};
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.3s;
svg {
width: 20px;
height: 20px;
color: ${({ $status }) =>
$status === "complete" || $status === "failed"
? "white"
: "hsl(var(--foreground))"};
}
`;
const PluginInfo = styled.div`
flex: 1;
min-width: 0;
`;
const PluginName = styled.div`
font-size: 14px;
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 4px;
`;
const PluginStatus = styled.div<{ $status: string }>`
font-size: 12px;
color: ${({ $status }) => {
switch ($status) {
case "complete":
return "hsl(142.1 76.2% 36.3%)";
case "failed":
return "hsl(0 84.2% 60.2%)";
default:
return "hsl(var(--muted-foreground))";
}
}};
`;
const ProgressWrapper = styled.div`
width: 100px;
flex-shrink: 0;
`;
const OverallProgress = styled.div`
width: 100%;
max-width: 500px;
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid hsl(var(--border));
`;
const OverallLabel = styled.div`
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 12px;
color: hsl(var(--muted-foreground));
`;
/**
* 插件安装状态
*/
export interface PluginInstallState {
pluginId: string;
status: "pending" | "downloading" | "installing" | "complete" | "failed";
progress: number;
message: string;
error?: string;
}
/**
* 安装进度事件
*/
interface InstallProgress {
stage: string;
percent: number;
message: string;
}
/**
* 安装结果
*/
interface InstallResult {
success: boolean;
plugin?: {
id: string;
name: string;
};
error?: string;
}
interface InstallProgressStepProps {
selectedPlugins: string[];
onComplete: (results: PluginInstallState[]) => void;
}
export function InstallProgressStep({
selectedPlugins,
onComplete,
}: InstallProgressStepProps) {
const [installStates, setInstallStates] = useState<PluginInstallState[]>([]);
const [isInstalling, setIsInstalling] = useState(false);
const hasStarted = useRef(false);
// 初始化安装状态
useEffect(() => {
if (selectedPlugins.length === 0) {
onComplete([]);
return;
}
setInstallStates(
selectedPlugins.map((id) => ({
pluginId: id,
status: "pending",
progress: 0,
message: "等待安装...",
})),
);
}, [selectedPlugins, onComplete]);
// 顺序安装插件
const installPlugins = useCallback(async () => {
if (isInstalling || selectedPlugins.length === 0) return;
setIsInstalling(true);
let unlisten: UnlistenFn | null = null;
for (let i = 0; i < selectedPlugins.length; i++) {
const pluginId = selectedPlugins[i];
const plugin = onboardingPlugins.find((p) => p.id === pluginId);
if (!plugin) continue;
try {
// 监听当前插件的进度
unlisten = await safeListen<InstallProgress>(
"plugin-install-progress",
(event) => {
setInstallStates((prev) =>
prev.map((state) =>
state.pluginId === pluginId
? {
...state,
status:
event.payload.stage === "complete"
? "complete"
: event.payload.stage === "failed"
? "failed"
: "downloading",
progress: event.payload.percent,
message: event.payload.message,
}
: state,
),
);
},
);
// 更新状态为下载中
setInstallStates((prev) =>
prev.map((state) =>
state.pluginId === pluginId
? { ...state, status: "downloading", message: "准备下载..." }
: state,
),
);
// 调用安装 API
const result = await safeInvoke<InstallResult>(
"install_plugin_from_url",
{
url: plugin.downloadUrl,
},
);
// 取消监听
if (unlisten) {
unlisten();
unlisten = null;
}
// 更新结果状态
setInstallStates((prev) =>
prev.map((state) =>
state.pluginId === pluginId
? {
...state,
status: result.success ? "complete" : "failed",
progress: 100,
message: result.success
? "安装成功"
: result.error || "安装失败",
error: result.error,
}
: state,
),
);
} catch (e) {
// 取消监听
if (unlisten) {
unlisten();
unlisten = null;
}
setInstallStates((prev) =>
prev.map((state) =>
state.pluginId === pluginId
? {
...state,
status: "failed",
progress: 100,
message: "安装出错",
error: e instanceof Error ? e.message : String(e),
}
: state,
),
);
}
}
setIsInstalling(false);
}, [selectedPlugins, isInstalling]);
// 开始安装
useEffect(() => {
if (
installStates.length > 0 &&
!isInstalling &&
!hasStarted.current &&
installStates.every((s) => s.status === "pending")
) {
hasStarted.current = true;
installPlugins();
}
}, [installStates, isInstalling, installPlugins]);
// 检查是否全部完成
useEffect(() => {
if (
installStates.length > 0 &&
installStates.every(
(s) => s.status === "complete" || s.status === "failed",
)
) {
// 延迟一点调用 onComplete,让用户看到最终状态
const timer = setTimeout(() => {
onComplete(installStates);
}, 1000);
return () => clearTimeout(timer);
}
}, [installStates, onComplete]);
// 计算总体进度
const completedCount = installStates.filter(
(s) => s.status === "complete" || s.status === "failed",
).length;
const overallProgress =
selectedPlugins.length > 0
? Math.round((completedCount / selectedPlugins.length) * 100)
: 0;
const getStatusIcon = (state: PluginInstallState) => {
const plugin = onboardingPlugins.find((p) => p.id === state.pluginId);
const Icon = plugin?.icon;
switch (state.status) {
case "complete":
return <Check />;
case "failed":
return <X />;
case "downloading":
case "installing":
return <Loader2 className="animate-spin" />;
default:
return Icon ? <Icon /> : null;
}
};
return (
<Container>
<Title>正在安装插件</Title>
<Subtitle>请稍候,正在为您安装选中的插件...</Subtitle>
<PluginList>
{installStates.map((state) => {
const plugin = onboardingPlugins.find((p) => p.id === state.pluginId);
return (
<PluginRow key={state.pluginId}>
<IconWrapper $status={state.status}>
{getStatusIcon(state)}
</IconWrapper>
<PluginInfo>
<PluginName>{plugin?.name || state.pluginId}</PluginName>
<PluginStatus $status={state.status}>
{state.message}
</PluginStatus>
</PluginInfo>
<ProgressWrapper>
<Progress value={state.progress} />
</ProgressWrapper>
</PluginRow>
);
})}
</PluginList>
<OverallProgress>
<OverallLabel>
<span>总体进度</span>
<span>
{completedCount} / {selectedPlugins.length}
</span>
</OverallLabel>
<Progress value={overallProgress} />
</OverallProgress>
</Container>
);
}
@@ -1,189 +0,0 @@
/**
* 初次安装引导 - 插件选择
*/
import styled from "styled-components";
import { Checkbox } from "@/components/ui/checkbox";
import { onboardingPlugins } from "../constants";
const Container = styled.div`
display: flex;
flex-direction: column;
align-items: center;
padding: 32px 24px;
`;
const Title = styled.h2`
font-size: 24px;
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 8px;
text-align: center;
`;
const Subtitle = styled.p`
font-size: 14px;
color: hsl(var(--muted-foreground));
margin-bottom: 24px;
text-align: center;
`;
const PluginList = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 500px;
`;
const PluginCard = styled.label<{ $selected?: boolean }>`
display: flex;
align-items: flex-start;
gap: 16px;
padding: 16px;
border-radius: 12px;
border: 1px solid
${({ $selected }) =>
$selected ? "hsl(var(--primary))" : "hsl(var(--border))"};
background: ${({ $selected }) =>
$selected ? "hsl(var(--primary) / 0.05)" : "hsl(var(--card))"};
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: hsl(var(--primary) / 0.5);
}
`;
const CheckboxWrapper = styled.div`
padding-top: 2px;
`;
const IconWrapper = styled.div<{ $selected?: boolean }>`
width: 40px;
height: 40px;
border-radius: 10px;
background: ${({ $selected }) =>
$selected ? "hsl(var(--primary))" : "hsl(var(--muted))"};
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.2s;
svg {
width: 20px;
height: 20px;
color: ${({ $selected }) =>
$selected ? "hsl(var(--primary-foreground))" : "hsl(var(--foreground))"};
}
`;
const PluginInfo = styled.div`
flex: 1;
min-width: 0;
`;
const PluginName = styled.div`
font-size: 14px;
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 4px;
`;
const PluginDescription = styled.div`
font-size: 12px;
color: hsl(var(--muted-foreground));
line-height: 1.5;
`;
const SelectAllRow = styled.div`
display: flex;
justify-content: flex-end;
width: 100%;
max-width: 500px;
margin-bottom: 8px;
`;
const SelectAllButton = styled.button`
font-size: 12px;
color: hsl(var(--primary));
background: none;
border: none;
cursor: pointer;
padding: 4px 8px;
&:hover {
text-decoration: underline;
}
`;
interface PluginSelectStepProps {
selectedPlugins: string[];
onSelectionChange: (plugins: string[]) => void;
}
export function PluginSelectStep({
selectedPlugins,
onSelectionChange,
}: PluginSelectStepProps) {
const handleToggle = (pluginId: string) => {
if (selectedPlugins.includes(pluginId)) {
onSelectionChange(selectedPlugins.filter((id) => id !== pluginId));
} else {
onSelectionChange([...selectedPlugins, pluginId]);
}
};
const handleSelectAll = () => {
if (selectedPlugins.length === onboardingPlugins.length) {
onSelectionChange([]);
} else {
onSelectionChange(onboardingPlugins.map((p) => p.id));
}
};
const isAllSelected = selectedPlugins.length === onboardingPlugins.length;
return (
<Container>
<Title>选择要安装的插件</Title>
<Subtitle>您可以根据需要选择插件,或稍后在插件中心安装</Subtitle>
<SelectAllRow>
<SelectAllButton onClick={handleSelectAll}>
{isAllSelected ? "取消全选" : "全选"}
</SelectAllButton>
</SelectAllRow>
<PluginList>
{onboardingPlugins.map((plugin) => {
const isSelected = selectedPlugins.includes(plugin.id);
const Icon = plugin.icon;
return (
<PluginCard
key={plugin.id}
$selected={isSelected}
onClick={() => handleToggle(plugin.id)}
>
<CheckboxWrapper>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggle(plugin.id)}
/>
</CheckboxWrapper>
<IconWrapper $selected={isSelected}>
<Icon />
</IconWrapper>
<PluginInfo>
<PluginName>{plugin.name}</PluginName>
<PluginDescription>{plugin.description}</PluginDescription>
</PluginInfo>
</PluginCard>
);
})}
</PluginList>
</Container>
);
}
@@ -4,7 +4,7 @@
import styled from "styled-components";
import { Button } from "@/components/ui/button";
import { Cpu, Puzzle, Mic } from "lucide-react";
import { Cpu, LayoutDashboard, Mic } from "lucide-react";
const Container = styled.div`
display: flex;
@@ -150,11 +150,11 @@ export function WelcomeStep({ onNext, onSkip }: WelcomeStepProps) {
<FeatureCard>
<FeatureIcon>
<Puzzle />
<LayoutDashboard />
</FeatureIcon>
<FeatureTitle>插件扩展</FeatureTitle>
<FeatureTitle>持续工作区</FeatureTitle>
<FeatureDescription>
丰富的插件生态,按需安装扩展功能
围绕同一目标持续整理对话、素材和结果,减少来回切换。
</FeatureDescription>
</FeatureCard>
@@ -0,0 +1,81 @@
import { act, type ReactElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CompleteStep } from "./CompleteStep";
import { WelcomeStep } from "./WelcomeStep";
interface Mounted {
container: HTMLDivElement;
root: Root;
}
const mounted: Mounted[] = [];
function renderStep(element: ReactElement): HTMLDivElement {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(element);
});
mounted.push({ container, root });
return container;
}
function getText(container: HTMLElement): string {
return (container.textContent ?? "").replace(/\s+/g, " ").trim();
}
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
while (mounted.length > 0) {
const target = mounted.pop();
if (!target) {
break;
}
act(() => {
target.root.unmount();
});
target.container.remove();
}
});
describe("onboarding steps copy", () => {
it("欢迎页应只展示现役能力文案", () => {
const container = renderStep(
<WelcomeStep onNext={vi.fn()} onSkip={vi.fn()} />,
);
const text = getText(container);
expect(text).toContain("欢迎使用 Lime");
expect(text).toContain("持续工作区");
expect(text).toContain(
"围绕同一目标持续整理对话、素材和结果,减少来回切换。",
);
expect(text).toContain("语音交互");
expect(text).not.toContain("插件扩展");
expect(text).not.toContain("按需安装");
expect(text).not.toContain("丰富的插件生态");
});
it("完成页不再引导去插件中心安装插件", () => {
const container = renderStep(<CompleteStep onFinish={vi.fn()} />);
const text = getText(container);
expect(text).toContain("设置完成!");
expect(text).toContain("提示:后续可在设置中继续调整语音输入和快捷键。");
expect(text).not.toContain("插件中心");
expect(text).not.toContain("安装插件");
});
});
@@ -1,182 +0,0 @@
import { useEffect, useState } from "react";
import {
ExternalLink,
Loader2,
MonitorSmartphone,
RefreshCw,
SquareTerminal,
} from "lucide-react";
interface OpenClawDashboardFrameProps {
dashboardUrl: string | null;
loading: boolean;
reloadToken: number;
running: boolean;
windowBusy?: boolean;
onOpenExternal: () => void;
onOpenWindow?: () => void;
onReload: () => void;
}
export function OpenClawDashboardFrame({
dashboardUrl,
loading,
reloadToken,
running,
windowBusy = false,
onOpenExternal,
onOpenWindow,
onReload,
}: OpenClawDashboardFrameProps) {
const [frameLoading, setFrameLoading] = useState(false);
const [frameBlocked, setFrameBlocked] = useState(false);
useEffect(() => {
setFrameLoading(running && Boolean(dashboardUrl));
setFrameBlocked(false);
}, [dashboardUrl, reloadToken, running]);
useEffect(() => {
if (!frameLoading) {
return;
}
const timer = window.setTimeout(() => {
setFrameBlocked(true);
setFrameLoading(false);
}, 8000);
return () => window.clearTimeout(timer);
}, [frameLoading]);
return (
<section className="rounded-2xl border bg-card p-6 shadow-sm space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-lg font-semibold">Dashboard</h2>
<p className="mt-1 text-sm text-muted-foreground">
默认在当前页面内嵌显示,同时支持单独打开。
</p>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={onReload}
disabled={!running || loading}
className="inline-flex items-center gap-2 rounded-lg border px-4 py-2 text-sm hover:bg-muted disabled:opacity-60"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
刷新内嵌页
</button>
<button
type="button"
onClick={onOpenExternal}
disabled={!running || !dashboardUrl}
className="inline-flex items-center gap-2 rounded-lg border px-4 py-2 text-sm hover:bg-muted disabled:opacity-60"
>
<ExternalLink className="h-4 w-4" />
单独打开
</button>
</div>
</div>
{!running ? (
<div className="flex min-h-[520px] flex-col items-center justify-center rounded-xl border border-dashed bg-background/50 px-6 py-10 text-center">
<SquareTerminal className="h-10 w-10 text-muted-foreground" />
<h3 className="mt-4 text-base font-medium">Dashboard 暂不可用</h3>
<p className="mt-2 max-w-2xl text-sm text-muted-foreground">
请先完成配置同步并启动 Gateway,启动成功后会在这里直接显示 Dashboard
页面。
</p>
</div>
) : !dashboardUrl ? (
<div className="flex min-h-[520px] items-center justify-center rounded-xl border border-dashed bg-background/50 text-sm text-muted-foreground">
正在准备 Dashboard 地址...
</div>
) : (
<div className="overflow-hidden rounded-xl border bg-background">
<div className="flex items-center justify-between border-b bg-muted/40 px-4 py-2 text-xs text-muted-foreground">
<span>当前地址</span>
<span className="max-w-[70%] truncate">{dashboardUrl}</span>
</div>
<div className="relative h-[720px] bg-white">
{(loading || frameLoading) && !frameBlocked && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/70 backdrop-blur-sm">
<div className="inline-flex items-center gap-2 rounded-full border bg-card px-4 py-2 text-sm text-muted-foreground shadow-sm">
<Loader2 className="h-4 w-4 animate-spin" />
Dashboard 加载中...
</div>
</div>
)}
{frameBlocked && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/90 px-6">
<div className="max-w-xl rounded-2xl border bg-card p-6 text-center shadow-sm">
<h3 className="text-base font-semibold">内嵌模式加载失败</h3>
<p className="mt-2 text-sm leading-7 text-muted-foreground">
Dashboard 很可能被目标页的鉴权、Cookie 或 iframe
策略拦截,因此在当前页面内无法稳定显示。
</p>
<div className="mt-4 flex flex-wrap items-center justify-center gap-3">
{onOpenWindow ? (
<button
type="button"
onClick={onOpenWindow}
disabled={windowBusy}
className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground disabled:opacity-60"
>
{windowBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MonitorSmartphone className="h-4 w-4" />
)}
打开桌面面板
</button>
) : null}
<button
type="button"
onClick={onOpenExternal}
className="inline-flex items-center gap-2 rounded-lg border px-4 py-2 text-sm hover:bg-muted"
>
<ExternalLink className="h-4 w-4" />
系统浏览器打开
</button>
<button
type="button"
onClick={onReload}
className="inline-flex items-center gap-2 rounded-lg border px-4 py-2 text-sm hover:bg-muted"
>
<RefreshCw className="h-4 w-4" />
再试一次
</button>
</div>
</div>
</div>
)}
<iframe
key={`${dashboardUrl}-${reloadToken}`}
title="OpenClaw Dashboard"
src={dashboardUrl}
className="h-full w-full"
allow="clipboard-read; clipboard-write"
onLoad={() => {
setFrameBlocked(false);
setFrameLoading(false);
}}
onError={() => {
setFrameBlocked(true);
setFrameLoading(false);
}}
/>
</div>
</div>
)}
</section>
);
}
export default OpenClawDashboardFrame;
-8
View File
@@ -10,7 +10,6 @@
| `PluginManager.tsx` | 插件管理主组件,显示插件列表和状态 |
| `PluginInstallDialog.tsx` | 插件安装对话框,支持本地文件和 URL 安装 |
| `PluginUninstallDialog.tsx` | 插件卸载确认对话框 |
| `PluginUIRenderer.tsx` | 插件 UI 渲染器,根据 pluginId 渲染对应的插件 UI |
| `PluginItemContextMenu.tsx` | 插件项右键菜单,支持启用/禁用、打开目录、卸载等操作 |
| `index.ts` | 模块导出 |
@@ -56,13 +55,6 @@
- 调用后端卸载命令
- 刷新插件列表
### PluginUIRenderer
- 根据 pluginId 渲染对应的插件 UI 组件
- 支持内置插件组件映射 (machine-id-tool -> MachineIdTool)
- 显示友好的错误提示(插件未找到、加载失败)
- 导出 Page 类型定义,支持动态插件路由
### PluginItemContextMenu
- 为已安装插件列表提供右键菜单
-4
View File
@@ -1,4 +0,0 @@
export { PluginManager } from "./PluginManager";
export { PluginInstallDialog } from "./PluginInstallDialog";
export { PluginUninstallDialog } from "./PluginUninstallDialog";
export { default } from "./PluginManager";
-7
View File
@@ -1,7 +0,0 @@
/**
* 项目组件当前主链导出
*/
export { CreateProjectDialog } from "./CreateProjectDialog";
export { ProjectSelector } from "./ProjectSelector";
export type { ProjectSelectorProps } from "./ProjectSelector";
@@ -1,460 +0,0 @@
/**
* 增强版模型列表页面
*
* 使用 model_registry 数据,支持搜索、收藏、分组等功能
*/
import { useState, useMemo, useEffect, useRef, useCallback } from "react";
import {
Cpu,
RefreshCw,
Copy,
Check,
Search,
Star,
Clock,
Filter,
Eye,
Wrench,
Brain,
DollarSign,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useModelRegistry } from "@/hooks/useModelRegistry";
import type {
EnhancedModelMetadata,
ModelTier,
} from "@/lib/types/modelRegistry";
export function EnhancedModelsTab() {
const {
models,
preferences,
loading,
error,
lastSyncAt,
refresh,
search,
toggleFavorite,
groupedByProvider,
} = useModelRegistry();
const [searchQuery, setSearchQuery] = useState("");
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("");
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
const [selectedTier, setSelectedTier] = useState<ModelTier | null>(null);
const [copied, setCopied] = useState<string | null>(null);
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false);
const [displayLimit, setDisplayLimit] = useState(50); // 初始显示 50 个
// 防抖搜索
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleSearchChange = useCallback((value: string) => {
setSearchQuery(value);
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
setDebouncedSearchQuery(value);
}, 150);
}, []);
// 当筛选条件变化时,重置显示数量
useEffect(() => {
setDisplayLimit(50);
}, [debouncedSearchQuery, selectedProvider, selectedTier, showFavoritesOnly]);
// 搜索和过滤
const filteredModels = useMemo(() => {
let result = debouncedSearchQuery ? search(debouncedSearchQuery) : models;
if (selectedProvider) {
result = result.filter((m) => m.provider_id === selectedProvider);
}
if (selectedTier) {
result = result.filter((m) => m.tier === selectedTier);
}
if (showFavoritesOnly) {
result = result.filter((m) => preferences.get(m.id)?.is_favorite);
}
return result;
}, [
debouncedSearchQuery,
models,
selectedProvider,
selectedTier,
showFavoritesOnly,
preferences,
search,
]);
// 分页显示的模型
const displayedModels = useMemo(() => {
return filteredModels.slice(0, displayLimit);
}, [filteredModels, displayLimit]);
const hasMore = filteredModels.length > displayLimit;
// 缓存 providers 列表,避免每次渲染都重新计算
const providers = useMemo(
() => Array.from(groupedByProvider.keys()),
[groupedByProvider],
);
const copyModelId = (id: string) => {
navigator.clipboard.writeText(id);
setCopied(id);
setTimeout(() => setCopied(null), 2000);
};
const formatSyncTime = (timestamp: number | null) => {
if (!timestamp) return "从未同步";
return new Date(timestamp * 1000).toLocaleString("zh-CN");
};
return (
<div className="space-y-6">
{error && (
<div className="rounded-lg border border-red-500 bg-red-50 dark:bg-red-950 p-4 text-red-700 dark:text-red-300">
{error}
</div>
)}
{/* 头部信息 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="h-4 w-4" />
<span>上次同步: {formatSyncTime(lastSyncAt)}</span>
</div>
<button
onClick={refresh}
disabled={loading}
className="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted disabled:opacity-50"
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
刷新
</button>
</div>
{/* 搜索和过滤 */}
<div className="flex flex-col gap-4">
<div className="flex items-center gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
placeholder="搜索模型名称、ID、Provider..."
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
className="w-full rounded-lg border bg-background pl-10 pr-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</div>
<button
onClick={() => setShowFavoritesOnly(!showFavoritesOnly)}
className={cn(
"flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-colors",
showFavoritesOnly
? "bg-yellow-100 border-yellow-300 text-yellow-700 dark:bg-yellow-900 dark:border-yellow-700 dark:text-yellow-300"
: "hover:bg-muted",
)}
>
<Star
className={cn(
"h-4 w-4",
showFavoritesOnly && "fill-yellow-500 text-yellow-500",
)}
/>
收藏
</button>
</div>
{/* Provider 过滤 */}
<div className="flex flex-wrap gap-2">
<button
onClick={() => setSelectedProvider(null)}
className={cn(
"rounded-lg px-3 py-1.5 text-sm font-medium transition-colors",
!selectedProvider
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-muted/80",
)}
>
全部 ({models.length})
</button>
{providers.map((providerId) => {
const providerModels = groupedByProvider.get(providerId) || [];
const providerName = providerModels[0]?.provider_name || providerId;
return (
<button
key={providerId}
onClick={() =>
setSelectedProvider(
selectedProvider === providerId ? null : providerId,
)
}
className={cn(
"rounded-lg px-3 py-1.5 text-sm font-medium transition-colors",
selectedProvider === providerId
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-muted/80",
)}
>
{providerName} ({providerModels.length})
</button>
);
})}
</div>
{/* Tier 过滤 */}
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">等级:</span>
{(["mini", "pro", "max"] as ModelTier[]).map((tier) => (
<button
key={tier}
onClick={() =>
setSelectedTier(selectedTier === tier ? null : tier)
}
className={cn(
"rounded-lg px-3 py-1 text-xs font-medium transition-colors",
selectedTier === tier
? getTierButtonActiveClass(tier)
: "bg-muted hover:bg-muted/80",
)}
>
{tier.toUpperCase()}
</button>
))}
</div>
</div>
{/* 模型列表 */}
<div className="rounded-lg border bg-card">
<div className="border-b px-4 py-3">
<div className="flex items-center justify-between">
<span className="font-medium">模型列表</span>
<span className="text-sm text-muted-foreground">
{hasMore
? `显示 ${displayedModels.length} / ${filteredModels.length} 个模型`
: `${filteredModels.length} 个模型`}
</span>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : filteredModels.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Cpu className="h-12 w-12 mb-2 opacity-50" />
<p>暂无模型数据</p>
</div>
) : (
<>
<div className="divide-y max-h-[600px] overflow-y-auto">
{displayedModels.map((model) => (
<ModelRow
key={model.id}
model={model}
isFavorite={preferences.get(model.id)?.is_favorite || false}
usageCount={preferences.get(model.id)?.usage_count || 0}
copied={copied === model.id}
onCopy={() => copyModelId(model.id)}
onToggleFavorite={() => toggleFavorite(model.id)}
/>
))}
</div>
{hasMore && (
<div className="border-t px-4 py-3 flex justify-center">
<button
onClick={() => setDisplayLimit((prev) => prev + 50)}
className="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted"
>
加载更多 (还有 {filteredModels.length - displayLimit} 个)
</button>
</div>
)}
</>
)}
</div>
{/* 使用说明 */}
<div className="rounded-lg border bg-card p-4">
<h3 className="mb-2 font-semibold">使用说明</h3>
<div className="space-y-2 text-sm text-muted-foreground">
<p>• 模型数据来自 models.dev API 和本地配置</p>
<p>• 点击星标可收藏常用模型,收藏的模型会优先显示</p>
<p>• 支持按 Provider、服务等级筛选模型</p>
</div>
</div>
</div>
);
}
/** 单个模型行 */
function ModelRow({
model,
isFavorite,
usageCount,
copied,
onCopy,
onToggleFavorite,
}: {
model: EnhancedModelMetadata;
isFavorite: boolean;
usageCount: number;
copied: boolean;
onCopy: () => void;
onToggleFavorite: () => void;
}) {
return (
<div className="flex items-center justify-between px-4 py-3 hover:bg-muted/50">
<div className="flex items-center gap-3 flex-1 min-w-0">
<Cpu className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<code className="font-medium truncate">{model.id}</code>
<TierBadge tier={model.tier} />
{model.is_latest && (
<span className="text-xs px-1.5 py-0.5 rounded bg-primary/10 text-primary">
最新
</span>
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
<span>{model.provider_name}</span>
{model.limits.context_length && (
<>
<span>·</span>
<span>{formatContextLength(model.limits.context_length)}</span>
</>
)}
{usageCount > 0 && (
<>
<span>·</span>
<span>使用 {usageCount} 次</span>
</>
)}
</div>
</div>
</div>
{/* 能力图标 */}
<div className="flex items-center gap-1 mr-3">
{model.capabilities.vision && (
<span title="支持视觉">
<Eye className="h-3.5 w-3.5 text-blue-500" />
</span>
)}
{model.capabilities.tools && (
<span title="支持工具">
<Wrench className="h-3.5 w-3.5 text-green-500" />
</span>
)}
{model.capabilities.reasoning && (
<span title="支持推理">
<Brain className="h-3.5 w-3.5 text-purple-500" />
</span>
)}
</div>
{/* 定价 */}
{model.pricing && model.pricing.input_per_million && (
<div className="flex items-center gap-1 text-xs text-muted-foreground mr-3">
<DollarSign className="h-3 w-3" />
<span>{model.pricing.input_per_million.toFixed(2)}</span>
</div>
)}
{/* 操作按钮 */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
console.log("[EnhancedModelsTab] Toggle favorite:", model.id);
onToggleFavorite();
}}
className="p-2 rounded-lg hover:bg-muted transition-colors cursor-pointer"
title={isFavorite ? "取消收藏" : "收藏"}
>
<Star
className={cn(
"h-5 w-5",
isFavorite
? "text-yellow-500 fill-yellow-500"
: "text-muted-foreground hover:text-yellow-400",
)}
/>
</button>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onCopy();
}}
className="p-2 rounded-lg hover:bg-muted transition-colors cursor-pointer"
title="复制模型 ID"
>
{copied ? (
<Check className="h-5 w-5 text-green-500" />
) : (
<Copy className="h-5 w-5 text-muted-foreground hover:text-foreground" />
)}
</button>
</div>
</div>
);
}
/** 服务等级徽章 */
function TierBadge({ tier }: { tier: string }) {
const config = {
mini: {
label: "Mini",
color:
"bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300",
},
pro: {
label: "Pro",
color: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
},
max: {
label: "Max",
color:
"bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300",
},
}[tier] || {
label: tier,
color: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300",
};
return (
<span className={cn("text-xs px-1.5 py-0.5 rounded", config.color)}>
{config.label}
</span>
);
}
/** 获取 Tier 按钮激活状态的样式 */
function getTierButtonActiveClass(tier: ModelTier): string {
const classes = {
mini: "bg-green-100 text-green-700 border-green-300 dark:bg-green-900 dark:text-green-300 dark:border-green-700",
pro: "bg-blue-100 text-blue-700 border-blue-300 dark:bg-blue-900 dark:text-blue-300 dark:border-blue-700",
max: "bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-900 dark:text-purple-300 dark:border-purple-700",
};
return classes[tier] || "bg-primary text-primary-foreground";
}
/** 格式化上下文长度 */
function formatContextLength(length: number): string {
if (length >= 1000000) {
return `${(length / 1000000).toFixed(1)}M`;
}
if (length >= 1000) {
return `${(length / 1000).toFixed(0)}K`;
}
return String(length);
}
@@ -1,22 +0,0 @@
/**
* @file ModelRegistryTab 组件
* @description 模型库 Tab,显示所有可用模型
* @module components/provider-pool/ModelRegistryTab
*/
import { EnhancedModelsTab } from "./EnhancedModelsTab";
/**
* 模型库 Tab 组件
*
* 复用 Provider Pool 的增强模型列表组件
*/
export function ModelRegistryTab() {
return (
<div className="min-h-[400px]" data-testid="model-registry-section">
<EnhancedModelsTab />
</div>
);
}
export default ModelRegistryTab;
@@ -1,159 +0,0 @@
/**
* Antigravity 凭证添加表单(自包含版本)
*
* 这是 AntigravityForm 的包装组件,内部管理所有状态,
* 适合在插件中独立使用。
*
* @module components/provider-pool/credential-forms/AntigravityFormStandalone
*/
import { useState, useCallback } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { AntigravityForm } from "./AntigravityForm";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
/** Antigravity 凭证文件默认路径 */
const ANTIGRAVITY_DEFAULT_CREDS_PATH = "~/.antigravity/oauth_creds.json";
interface AntigravityFormStandaloneProps {
/** 添加成功回调 */
onSuccess: () => void;
/** 取消回调 */
onCancel?: () => void;
/** 初始名称 */
initialName?: string;
}
/**
* 自包含的 Antigravity 凭证添加表单
*
* 内部管理所有状态,只需要提供 onSuccess 和 onCancel 回调
*/
export function AntigravityFormStandalone({
onSuccess,
onCancel,
initialName = "",
}: AntigravityFormStandaloneProps) {
const [name, setName] = useState(initialName);
const [credsFilePath, setCredsFilePath] = useState(
ANTIGRAVITY_DEFAULT_CREDS_PATH,
);
const [projectId, setProjectId] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSelectFile = useCallback(async () => {
try {
const selected = await open({
multiple: false,
filters: [{ name: "JSON", extensions: ["json"] }],
});
if (selected) {
setCredsFilePath(selected as string);
}
} catch (e) {
console.error("Failed to open file dialog:", e);
}
}, []);
const handleSuccess = useCallback(() => {
onSuccess();
}, [onSuccess]);
// 使用 AntigravityForm hook 获取渲染函数和提交方法
const antigravityForm = AntigravityForm({
name,
credsFilePath,
setCredsFilePath,
projectId,
setProjectId,
onSelectFile: handleSelectFile,
loading,
setLoading,
setError,
onSuccess: handleSuccess,
});
// 处理提交
const handleSubmit = useCallback(() => {
if (antigravityForm.mode === "file") {
antigravityForm.handleFileSubmit();
} else if (antigravityForm.mode === "login") {
antigravityForm.handleGetAuthUrl();
}
}, [antigravityForm]);
// 是否显示提交按钮
const showSubmitButton = antigravityForm.mode === "file";
// login 模式显示获取授权 URL 按钮
const showLoginButton =
antigravityForm.mode === "login" && !antigravityForm.waitingForCallback;
return (
<div className="space-y-4">
{/* 名称输入 */}
<div>
<label className="mb-1 block text-sm font-medium">名称 (可选)</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="给这个凭证起个名字..."
disabled={loading}
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
/>
</div>
{/* Antigravity 表单内容 */}
{antigravityForm.render()}
{/* 错误提示 */}
{error && (
<div className="rounded-lg border border-red-300 bg-red-50 dark:bg-red-900/20 p-3 text-sm text-red-700 dark:text-red-300">
{error}
</div>
)}
{/* 按钮区域 */}
<div className="flex justify-end gap-2 pt-2">
{onCancel && (
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={loading}
>
取消
</Button>
)}
{showLoginButton && (
<Button type="button" onClick={handleSubmit} disabled={loading}>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
获取中...
</>
) : (
"获取授权 URL"
)}
</Button>
)}
{showSubmitButton && (
<Button type="button" onClick={handleSubmit} disabled={loading}>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
添加中...
</>
) : (
"添加凭证"
)}
</Button>
)}
</div>
</div>
);
}
export default AntigravityFormStandalone;
@@ -1,374 +0,0 @@
/**
* Claude 凭证添加表单(自包含版本)
*
* 支持多种认证方式:
* 1. Cookie 授权 - 使用 sessionKey 自动完成 OAuth 流程
* 2. OAuth 登录 - 通过授权 URL 手动复制授权码
* 3. 文件导入 - 导入已有的凭证文件
*
* @module components/provider-pool/credential-forms/ClaudeFormStandalone
*/
import { useState, useCallback, useEffect } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { onClaudeOAuthAuthUrl } from "@/lib/api/providerAuthEvents";
import { providerPoolApi } from "@/lib/api/providerPool";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Loader2, Cookie, Key, FileJson, Upload } from "lucide-react";
import { OAuthUrlDisplay } from "./OAuthUrlDisplay";
type AuthMode = "cookie" | "login" | "file";
interface ClaudeFormStandaloneProps {
/** 添加成功回调 */
onSuccess: () => void;
/** 取消回调 */
onCancel?: () => void;
/** 初始名称 */
initialName?: string;
/** 认证类型(预留扩展) */
authType?: string;
}
/**
* 自包含的 Claude 凭证添加表单
*/
export function ClaudeFormStandalone({
onSuccess,
onCancel,
initialName = "",
authType: _authType,
}: ClaudeFormStandaloneProps) {
const [mode, setMode] = useState<AuthMode>("cookie");
const [name, setName] = useState(initialName);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// OAuth 状态
const [authUrl, setAuthUrl] = useState<string | null>(null);
const [waitingForCallback, setWaitingForCallback] = useState(false);
// Cookie 状态
const [sessionKey, setSessionKey] = useState("");
const [isSetupToken, setIsSetupToken] = useState(false);
// 文件导入状态
const [credsFilePath, setCredsFilePath] = useState("");
// 监听后端发送的授权 URL 事件
useEffect(() => {
let unlisten: (() => void) | undefined;
const setupListener = async () => {
unlisten = await onClaudeOAuthAuthUrl((payload) => {
setAuthUrl(payload.auth_url);
});
};
setupListener();
return () => {
if (unlisten) unlisten();
};
}, []);
// 获取授权 URL 并启动服务器等待回调
const handleGetAuthUrl = useCallback(async () => {
setLoading(true);
setError(null);
setAuthUrl(null);
setWaitingForCallback(true);
try {
await providerPoolApi.getClaudeOAuthAuthUrlAndWait(
name.trim() || undefined,
);
onSuccess();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setWaitingForCallback(false);
} finally {
setLoading(false);
}
}, [name, onSuccess]);
// Cookie 自动授权
const handleCookieSubmit = useCallback(async () => {
if (!sessionKey.trim()) {
setError("请输入 sessionKey");
return;
}
setLoading(true);
setError(null);
try {
await providerPoolApi.claudeOAuthWithCookie(
sessionKey.trim(),
isSetupToken,
name.trim() || undefined,
);
onSuccess();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, [sessionKey, isSetupToken, name, onSuccess]);
// 选择文件
const handleSelectFile = useCallback(async () => {
try {
const selected = await open({
multiple: false,
filters: [{ name: "JSON", extensions: ["json"] }],
});
if (selected) {
setCredsFilePath(selected as string);
}
} catch (e) {
console.error("Failed to open file dialog:", e);
}
}, []);
// 文件导入提交
const handleFileSubmit = useCallback(async () => {
if (!credsFilePath) {
setError("请选择凭证文件");
return;
}
setLoading(true);
setError(null);
try {
await providerPoolApi.addClaudeOAuth(
credsFilePath,
name.trim() || undefined,
);
onSuccess();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, [credsFilePath, name, onSuccess]);
return (
<div className="space-y-4">
{/* 名称输入 */}
<div>
<label className="mb-1 block text-sm font-medium">名称 (可选)</label>
<Input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="给这个凭证起个名字..."
disabled={loading}
/>
</div>
{/* 模式选择器 */}
<div className="flex gap-2">
<button
type="button"
onClick={() => setMode("cookie")}
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors ${
mode === "cookie"
? "border-amber-500 bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300"
: "hover:bg-muted"
}`}
>
<Cookie className="h-4 w-4" />
Cookie 授权
</button>
<button
type="button"
onClick={() => setMode("login")}
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors ${
mode === "login"
? "border-amber-500 bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300"
: "hover:bg-muted"
}`}
>
<Key className="h-4 w-4" />
OAuth 登录
</button>
<button
type="button"
onClick={() => setMode("file")}
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors ${
mode === "file"
? "border-amber-500 bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300"
: "hover:bg-muted"
}`}
>
<FileJson className="h-4 w-4" />
导入文件
</button>
</div>
{/* Cookie 授权表单 */}
{mode === "cookie" && (
<div className="space-y-4">
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950/30">
<p className="text-sm text-amber-700 dark:text-amber-300">
使用浏览器 Cookie 中的 sessionKey 自动完成 OAuth
授权,无需手动复制授权码。
</p>
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
获取方式:在 claude.ai 登录后,打开开发者工具 → Application →
Cookies → 复制 sessionKey 的值
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium">
sessionKey <span className="text-red-500">*</span>
</label>
<Textarea
value={sessionKey}
onChange={(e) => setSessionKey(e.target.value)}
placeholder="粘贴从浏览器 Cookie 中获取的 sessionKey..."
className="font-mono"
rows={3}
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isSetupToken"
checked={isSetupToken}
onChange={(e) => setIsSetupToken(e.target.checked)}
className="h-4 w-4 rounded border-gray-300"
/>
<label
htmlFor="isSetupToken"
className="text-sm text-muted-foreground"
>
Setup Token 模式(只需推理权限,无 refresh_token)
</label>
</div>
</div>
)}
{/* OAuth 登录表单 */}
{mode === "login" && (
<div className="space-y-4">
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950/30">
<p className="text-sm text-amber-700 dark:text-amber-300">
点击下方按钮获取授权 URL,然后复制到浏览器(支持指纹浏览器)完成
Claude 登录。
</p>
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
授权成功后会自动完成,无需手动复制授权码。
</p>
</div>
<OAuthUrlDisplay
authUrl={authUrl}
waitingForCallback={waitingForCallback}
colorScheme="amber"
/>
</div>
)}
{/* 文件导入表单 */}
{mode === "file" && (
<div className="space-y-4">
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950/30">
<p className="text-sm text-amber-700 dark:text-amber-300">
导入已有的 Claude OAuth 凭证文件。
</p>
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
默认路径: ~/.claude/oauth.json 或 Claude CLI 的凭证文件
</p>
</div>
<div className="flex gap-2">
<Input
type="text"
value={credsFilePath}
onChange={(e) => setCredsFilePath(e.target.value)}
placeholder="选择 oauth.json 或 oauth_creds.json..."
className="flex-1"
/>
<Button type="button" variant="outline" onClick={handleSelectFile}>
<Upload className="h-4 w-4" />
</Button>
</div>
</div>
)}
{/* 错误提示 */}
{error && (
<div className="rounded-lg border border-red-300 bg-red-50 dark:bg-red-900/20 p-3 text-sm text-red-700 dark:text-red-300">
{error}
</div>
)}
{/* 按钮区域 */}
<div className="flex justify-end gap-2 pt-2">
{onCancel && (
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={loading}
>
取消
</Button>
)}
{mode === "cookie" && (
<Button
type="button"
onClick={handleCookieSubmit}
disabled={loading || !sessionKey.trim()}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
授权中...
</>
) : (
"添加凭证"
)}
</Button>
)}
{mode === "login" && !authUrl && (
<Button type="button" onClick={handleGetAuthUrl} disabled={loading}>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
获取中...
</>
) : (
"获取授权 URL"
)}
</Button>
)}
{mode === "file" && (
<Button
type="button"
onClick={handleFileSubmit}
disabled={loading || !credsFilePath}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
导入中...
</>
) : (
"导入凭证"
)}
</Button>
)}
</div>
</div>
);
}
export default ClaudeFormStandalone;
@@ -1,471 +0,0 @@
/**
* Gemini 凭证添加表单(自包含版本)
*
* 支持两种认证方式:
* 1. Google OAuth - 使用 Google 账户授权
* 2. API Key - 使用 Google AI Studio API Key
*
* @module components/provider-pool/credential-forms/GeminiFormStandalone
*/
import { useState, useCallback, useEffect } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { onGeminiAuthUrl } from "@/lib/api/providerAuthEvents";
import { providerPoolApi } from "@/lib/api/providerPool";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Loader2,
Key,
KeyRound,
Copy,
Check,
ExternalLink,
Upload,
} from "lucide-react";
type AuthMethod = "oauth" | "api_key";
interface GeminiFormStandaloneProps {
/** 添加成功回调 */
onSuccess: () => void;
/** 取消回调 */
onCancel?: () => void;
/** 初始名称 */
initialName?: string;
/** 初始认证方式 */
initialAuthMethod?: AuthMethod;
}
/**
* 自包含的 Gemini 凭证添加表单
*
* 内部管理所有状态,只需要提供 onSuccess 和 onCancel 回调
*/
export function GeminiFormStandalone({
onSuccess,
onCancel,
initialName = "",
initialAuthMethod = "oauth",
}: GeminiFormStandaloneProps) {
const [authMethod, setAuthMethod] = useState<AuthMethod>(initialAuthMethod);
const [name, setName] = useState(initialName);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// OAuth 状态
const [authUrl, setAuthUrl] = useState<string | null>(null);
const [sessionId, setSessionId] = useState<string | null>(null);
const [authCode, setAuthCode] = useState("");
const [copied, setCopied] = useState(false);
const [exchanging, setExchanging] = useState(false);
// 文件导入状态
const [credsFilePath, setCredsFilePath] = useState("");
const [projectId, setProjectId] = useState("");
// API Key 状态
const [apiKey, setApiKey] = useState("");
const [baseUrl, setBaseUrl] = useState("");
// 监听后端发送的授权 URL 事件
useEffect(() => {
let unlisten: (() => void) | undefined;
const setupListener = async () => {
unlisten = await onGeminiAuthUrl((payload) => {
console.log("[Gemini OAuth] 收到授权 URL 事件:", payload);
setAuthUrl(payload.auth_url);
setSessionId(payload.session_id);
});
};
setupListener();
return () => {
if (unlisten) unlisten();
};
}, []);
// 获取授权 URL
const handleGetAuthUrl = useCallback(async () => {
setLoading(true);
setError(null);
setAuthUrl(null);
setSessionId(null);
setAuthCode("");
try {
await providerPoolApi.getGeminiAuthUrlAndWait(name.trim() || undefined);
} catch (e) {
const errorMsg = e instanceof Error ? e.message : String(e);
if (errorMsg.includes("AUTH_URL:")) {
const urlMatch = errorMsg.match(/AUTH_URL:(.+?)(?:\s|$)/);
if (urlMatch) {
setAuthUrl(urlMatch[1]);
}
} else {
setError(errorMsg);
}
} finally {
setLoading(false);
}
}, [name]);
// 用 code 交换 token
const handleExchangeCode = useCallback(async () => {
if (!authCode.trim()) {
setError("请输入授权码");
return;
}
setExchanging(true);
setError(null);
try {
await providerPoolApi.exchangeGeminiCode(
authCode.trim(),
sessionId || undefined,
name.trim() || undefined,
);
onSuccess();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setExchanging(false);
}
}, [authCode, sessionId, name, onSuccess]);
// 复制 URL
const handleCopyUrl = useCallback(async () => {
if (authUrl) {
await navigator.clipboard.writeText(authUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
}, [authUrl]);
// 选择文件
const handleSelectFile = useCallback(async () => {
try {
const selected = await open({
multiple: false,
filters: [{ name: "JSON", extensions: ["json"] }],
});
if (selected) {
setCredsFilePath(selected as string);
}
} catch (e) {
console.error("Failed to open file dialog:", e);
}
}, []);
// 文件导入提交
const handleFileSubmit = useCallback(async () => {
if (!credsFilePath) {
setError("请选择凭证文件");
return;
}
setLoading(true);
setError(null);
try {
await providerPoolApi.addGeminiOAuth(
credsFilePath,
projectId.trim() || undefined,
name.trim() || undefined,
);
onSuccess();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, [credsFilePath, projectId, name, onSuccess]);
// API Key 提交
const handleApiKeySubmit = useCallback(async () => {
if (!apiKey.trim()) {
setError("请输入 API Key");
return;
}
setLoading(true);
setError(null);
try {
await providerPoolApi.addGeminiApiKey(
apiKey.trim(),
baseUrl.trim() || undefined,
undefined,
name.trim() || undefined,
);
onSuccess();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, [apiKey, baseUrl, name, onSuccess]);
return (
<div className="space-y-4">
{/* 名称输入 */}
<div>
<label className="mb-1 block text-sm font-medium">名称 (可选)</label>
<Input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="给这个凭证起个名字..."
disabled={loading || exchanging}
/>
</div>
{/* 认证方式选择 */}
<Tabs
value={authMethod}
onValueChange={(v) => setAuthMethod(v as AuthMethod)}
>
<TabsList className="grid grid-cols-2">
<TabsTrigger value="oauth" className="flex items-center gap-2">
<Key className="h-4 w-4" />
Google OAuth
</TabsTrigger>
<TabsTrigger value="api_key" className="flex items-center gap-2">
<KeyRound className="h-4 w-4" />
API Key
</TabsTrigger>
</TabsList>
{/* OAuth 认证 */}
<TabsContent value="oauth" className="space-y-4 mt-4">
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950/30">
<p className="text-sm text-blue-700 dark:text-blue-300">
点击下方按钮获取授权 URL,然后复制到浏览器完成 Google 登录。
</p>
<p className="mt-2 text-xs text-blue-600 dark:text-blue-400">
授权成功后,复制页面显示的授权码粘贴到下方输入框。
</p>
</div>
{!authUrl ? (
<Button
onClick={handleGetAuthUrl}
disabled={loading}
className="w-full"
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
获取授权 URL...
</>
) : (
<>
<ExternalLink className="h-4 w-4 mr-2" />
获取授权 URL
</>
)}
</Button>
) : (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">授权 URL</span>
<button
onClick={handleCopyUrl}
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-blue-600 hover:bg-blue-100 dark:text-blue-400 dark:hover:bg-blue-900/30"
>
{copied ? (
<>
<Check className="h-3 w-3" />
已复制
</>
) : (
<>
<Copy className="h-3 w-3" />
复制
</>
)}
</button>
</div>
<div className="rounded-lg border bg-muted/50 p-3">
<p className="break-all text-xs text-muted-foreground">
{authUrl.length > 100
? `${authUrl.slice(0, 100)}...`
: authUrl}
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
授权码 <span className="text-red-500">*</span>
</label>
<Input
type="text"
value={authCode}
onChange={(e) => setAuthCode(e.target.value)}
placeholder="粘贴浏览器页面显示的授权码..."
/>
<p className="text-xs text-muted-foreground">
在浏览器中完成授权后,复制页面显示的授权码
</p>
</div>
<Button
onClick={handleExchangeCode}
disabled={exchanging || !authCode.trim()}
className="w-full"
>
{exchanging ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
验证中...
</>
) : (
"验证授权码"
)}
</Button>
</div>
)}
{/* 文件导入选项 */}
<div className="border-t pt-4">
<p className="text-sm text-muted-foreground mb-3">
或者导入已有的凭证文件:
</p>
<div className="space-y-3">
<div className="flex gap-2">
<Input
type="text"
value={credsFilePath}
onChange={(e) => setCredsFilePath(e.target.value)}
placeholder="选择 oauth_creds.json..."
className="flex-1"
/>
<Button
type="button"
variant="outline"
onClick={handleSelectFile}
>
<Upload className="h-4 w-4" />
</Button>
</div>
<Input
type="text"
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
placeholder="Project ID (可选)"
/>
<Button
onClick={handleFileSubmit}
disabled={loading || !credsFilePath}
variant="outline"
className="w-full"
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
导入中...
</>
) : (
"导入凭证文件"
)}
</Button>
</div>
</div>
</TabsContent>
{/* API Key 认证 */}
<TabsContent value="api_key" className="space-y-4 mt-4">
<div className="rounded-lg border border-green-200 bg-green-50 p-4 dark:border-green-800 dark:bg-green-950/30">
<p className="text-sm text-green-700 dark:text-green-300">
使用 Google AI Studio 的 API Key 进行认证。
</p>
<p className="mt-2 text-xs text-green-600 dark:text-green-400">
从{" "}
<a
href="https://aistudio.google.com/app/apikey"
target="_blank"
rel="noopener noreferrer"
className="underline hover:no-underline"
>
Google AI Studio
</a>{" "}
获取 API Key。
</p>
</div>
<div className="space-y-3">
<div>
<label className="mb-1 block text-sm font-medium">
API Key <span className="text-red-500">*</span>
</label>
<Input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="AIzaSy..."
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium">
Base URL (可选)
</label>
<Input
type="text"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="https://generativelanguage.googleapis.com"
/>
<p className="mt-1 text-xs text-muted-foreground">
留空使用官方 API
</p>
</div>
</div>
</TabsContent>
</Tabs>
{/* 错误提示 */}
{error && (
<div className="rounded-lg border border-red-300 bg-red-50 dark:bg-red-900/20 p-3 text-sm text-red-700 dark:text-red-300">
{error}
</div>
)}
{/* 按钮区域 */}
<div className="flex justify-end gap-2 pt-2">
{onCancel && (
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={loading || exchanging}
>
取消
</Button>
)}
{authMethod === "api_key" && (
<Button
type="button"
onClick={handleApiKeySubmit}
disabled={loading || !apiKey.trim()}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
添加中...
</>
) : (
"添加凭证"
)}
</Button>
)}
</div>
</div>
);
}
export default GeminiFormStandalone;
@@ -1,135 +0,0 @@
/**
* Kiro 凭证添加表单(自包含版本)
*
* 这是 KiroForm 的包装组件,内部管理所有状态,
* 适合在插件中独立使用。
*
* @module components/provider-pool/credential-forms/KiroFormStandalone
*/
import { useState, useCallback } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { KiroForm } from "./KiroForm";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
/** Kiro 凭证文件默认路径 */
const KIRO_DEFAULT_CREDS_PATH = "~/.aws/sso/cache/kiro-auth-token.json";
interface KiroFormStandaloneProps {
/** 添加成功回调 */
onSuccess: () => void;
/** 取消回调 */
onCancel?: () => void;
/** 初始名称 */
initialName?: string;
}
/**
* 自包含的 Kiro 凭证添加表单
*
* 内部管理所有状态,只需要提供 onSuccess 和 onCancel 回调
*/
export function KiroFormStandalone({
onSuccess,
onCancel,
initialName = "",
}: KiroFormStandaloneProps) {
const [name, setName] = useState(initialName);
// 设置默认凭证文件路径
const [credsFilePath, setCredsFilePath] = useState(KIRO_DEFAULT_CREDS_PATH);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSelectFile = useCallback(async () => {
try {
const selected = await open({
multiple: false,
filters: [{ name: "JSON", extensions: ["json"] }],
});
if (selected) {
setCredsFilePath(selected as string);
}
} catch (e) {
console.error("Failed to open file dialog:", e);
}
}, []);
const handleSuccess = useCallback(() => {
onSuccess();
}, [onSuccess]);
// 使用 KiroForm hook 获取渲染函数和提交方法
const kiroForm = KiroForm({
name,
credsFilePath,
setCredsFilePath,
onSelectFile: handleSelectFile,
loading,
setLoading,
setError,
onSuccess: handleSuccess,
});
// 处理提交
const handleSubmit = useCallback(() => {
if (kiroForm.mode === "json") {
kiroForm.handleJsonSubmit();
} else if (kiroForm.mode === "file") {
kiroForm.handleFileSubmit();
}
}, [kiroForm]);
return (
<div className="space-y-4">
{/* 名称输入 */}
<div>
<label className="mb-1 block text-sm font-medium">名称 (可选)</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="给这个凭证起个名字..."
disabled={loading}
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
/>
</div>
{/* Kiro 表单内容 */}
{kiroForm.render()}
{/* 错误提示 */}
{error && (
<div className="rounded-lg border border-red-300 bg-red-50 dark:bg-red-900/20 p-3 text-sm text-red-700 dark:text-red-300">
{error}
</div>
)}
{/* 按钮区域 */}
<div className="flex justify-end gap-2 pt-2">
{onCancel && (
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={loading}
>
取消
</Button>
)}
<Button type="button" onClick={handleSubmit} disabled={loading}>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
添加中...
</>
) : (
"添加凭证"
)}
</Button>
</div>
</div>
);
}
export default KiroFormStandalone;
@@ -1,12 +0,0 @@
/**
* 凭证表单组件导出
*/
export * from "./types";
export * from "./ModeSelector";
export * from "./OAuthUrlDisplay";
export * from "./FileImportForm";
export * from "./AntigravityForm";
export * from "./CodexForm";
export * from "./ClaudeOAuthForm";
export * from "./GeminiForm";
-106
View File
@@ -1,106 +0,0 @@
/**
* @file ChatInput.tsx
* @description 聊天输入框组件,支持文本输入和发送
* @module components/smart-input/ChatInput
*/
import React, { useRef } from "react";
import { BaseComposer } from "@/components/input-kit";
import { CharacterMention } from "@/components/agent/chat/skill-selection/CharacterMention";
import { SkillBadge } from "@/components/agent/chat/skill-selection/SkillBadge";
import { useActiveSkill } from "@/components/agent/chat/skill-selection/useActiveSkill";
import type { ChatInputProps } from "./types";
/**
* 聊天输入框组件
*
* 提供文本输入框和发送按钮,支持 Enter 键发送
*
* 需求:
* - 4.3: 悬浮窗口应提供文本输入框供用户输入问题
* - 4.4: 当用户按下 Enter 或点击发送时,悬浮窗口应将图片和文本发送给 AI
*/
export const ChatInput: React.FC<ChatInputProps> = ({
value,
onChange,
onSend,
disabled = false,
isLoading = false,
placeholder = "输入问题...",
skills = [],
}) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { activeSkill, setActiveSkill, wrapTextWithSkill, clearActiveSkill } =
useActiveSkill();
const handleSend = () => {
const text = activeSkill ? wrapTextWithSkill(value) : undefined;
onSend(text);
clearActiveSkill();
};
return (
<BaseComposer
text={value}
setText={onChange}
onSend={handleSend}
disabled={disabled || isLoading}
placeholder={placeholder}
textareaRef={textareaRef}
autoFocus
maxAutoHeight={80}
rows={1}
>
{({ textareaProps, onPrimaryAction, isPrimaryDisabled }) => (
<div className="smart-input-input-area">
{/* CharacterMention */}
{skills.length > 0 && (
<CharacterMention
characters={[]}
skills={skills}
inputRef={textareaRef}
value={value}
onChange={onChange}
onSelectSkill={setActiveSkill}
/>
)}
{/* Skill Badge */}
{activeSkill && (
<SkillBadge skill={activeSkill} onClear={clearActiveSkill} />
)}
<textarea
ref={textareaRef}
{...textareaProps}
className="smart-input-input resize-none"
/>
<button
className="smart-input-send-btn"
onClick={onPrimaryAction}
disabled={isPrimaryDisabled}
title="发送 (Enter)"
>
{isLoading ? (
<span
className="smart-input-loading-spinner"
style={{ width: 16, height: 16 }}
/>
) : (
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="22" y1="2" x2="11" y2="13" />
<polygon points="22 2 15 22 11 13 2 9 22 2" />
</svg>
)}
</button>
</div>
)}
</BaseComposer>
);
};
export default ChatInput;
-104
View File
@@ -1,104 +0,0 @@
/**
* @file ChatMessages.tsx
* @description 消息列表组件,显示用户和 AI 的对话消息
* @module components/smart-input/ChatMessages
*/
import React, { useRef, useEffect } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { ChatMessagesProps, ChatMessage } from "./types";
import "./smart-input.css";
/**
* 单条消息组件
*/
const MessageItem: React.FC<{ message: ChatMessage }> = ({ message }) => {
const isUser = message.role === "user";
return (
<div
className={`smart-input-message ${isUser ? "smart-input-message-user" : "smart-input-message-assistant"}`}
>
{/* 用户消息显示图片 */}
{isUser && message.image && (
<div className="smart-input-message-image">
<img
src={`data:${message.image.mediaType};base64,${message.image.data}`}
alt="截图"
className="smart-input-message-thumbnail"
/>
</div>
)}
{/* 消息内容 */}
<div className="smart-input-message-content">
{message.isThinking ? (
<div className="smart-input-thinking">
<span className="smart-input-loading-spinner" />
<span>{message.thinkingContent || "思考中..."}</span>
</div>
) : isUser ? (
<p>{message.content}</p>
) : (
<div className="smart-input-markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{message.content}
</ReactMarkdown>
</div>
)}
</div>
{/* 时间戳 */}
<div className="smart-input-message-time">
{new Date(message.timestamp).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
})}
</div>
</div>
);
};
/**
* 消息列表组件
*
* 显示用户消息和 AI 回复,支持 Markdown 渲染和自动滚动
*
* 需求:
* - 4.5: 悬浮窗口应在可滚动区域显示 AI 回复
* - 5.4: 当 AI 回复时,悬浮窗口应以 Markdown 格式渲染回复内容
*/
export const ChatMessages: React.FC<ChatMessagesProps> = ({
messages,
className = "",
}) => {
const containerRef = useRef<HTMLDivElement>(null);
// 自动滚动到底部
useEffect(() => {
if (containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}, [messages]);
if (messages.length === 0) {
return (
<div className={`smart-input-messages ${className}`}>
<div className="smart-input-placeholder">
输入问题,开始与 AI 讨论截图内容
</div>
</div>
);
}
return (
<div ref={containerRef} className={`smart-input-messages ${className}`}>
{messages.map((message) => (
<MessageItem key={message.id} message={message} />
))}
</div>
);
};
export default ChatMessages;
-152
View File
@@ -1,152 +0,0 @@
# 截图对话组件 (screenshot-chat)
截图对话功能的前端组件模块,提供截图预览、对话输入、消息展示等功能。
## 文件索引
| 文件 | 描述 |
| -------------------------- | ----------------------------------- |
| `index.ts` | 模块导出入口 |
| `types.ts` | 类型定义(配置、消息、组件 Props) |
| `useScreenshotChat.ts` | 核心 Hook,管理消息、图片和 AI 通信 |
| `ScreenshotPreview.tsx` | 截图预览组件,支持缩放和拖拽查看 |
| `ChatInput.tsx` | 聊天输入框组件,支持 Enter 发送 |
| `ChatMessages.tsx` | 消息列表组件,支持 Markdown 渲染 |
| `ScreenshotChatWindow.tsx` | 悬浮窗主组件,组合所有子组件 |
| `ShortcutSettings.tsx` | 快捷键设置组件,支持录制模式 |
| `screenshot-chat.css` | 截图对话组件样式 |
## 组件说明
### ScreenshotChatWindow
悬浮窗主组件,组合截图预览、消息列表和输入框。
**功能特性:**
- 组合 ScreenshotPreview, ChatInput, ChatMessages
- 支持 ESC 键关闭窗口
- 支持窗口拖动(通过 header 区域)
- 显示错误信息和重试按钮
**Props:**
- `imagePath`: 截图文件路径
- `onClose`: 关闭窗口回调(可选)
### ScreenshotPreview
截图预览组件,用于在悬浮对话窗口中显示截图。
**功能特性:**
- 显示截图图片
- 支持滚轮缩放 (50% - 300%)
- 支持拖拽平移(放大后)
- 工具栏提供缩放和重置按钮
**Props:**
- `src`: 图片路径或 Base64 编码
- `alt`: 图片 alt 文本(可选)
- `className`: 自定义类名(可选)
- `maxHeight`: 最大高度,默认 300px(可选)
### ChatInput
聊天输入框组件。
**功能特性:**
- 文本输入框
- 发送按钮
- Enter 键发送支持
- 加载状态显示
**Props:**
- `value`: 输入框值
- `onChange`: 值变化回调
- `onSend`: 发送消息回调
- `disabled`: 是否禁用(可选)
- `isLoading`: 是否正在加载(可选)
- `placeholder`: 占位符文本(可选)
### ChatMessages
消息列表组件。
**功能特性:**
- 显示用户消息和 AI 回复
- Markdown 渲染支持
- 自动滚动到最新消息
- 显示消息时间戳
**Props:**
- `messages`: 消息列表
- `className`: 自定义类名(可选)
### ShortcutSettings
快捷键设置组件,用于在设置页面中配置截图快捷键。
**功能特性:**
- 显示当前快捷键(用户友好格式)
- 快捷键录制模式
- 保存/取消按钮
- 错误提示
**Props:**
- `currentShortcut`: 当前快捷键
- `onShortcutChange`: 快捷键变更回调
- `onValidate`: 验证快捷键回调(可选)
- `disabled`: 是否禁用(可选)
## Hook 说明
### useScreenshotChat
核心 Hook,管理截图对话的状态和 AI 通信。
**返回值:**
- `messages`: 消息列表
- `isLoading`: 是否正在加载
- `error`: 错误信息
- `imagePath`: 当前截图路径
- `imageBase64`: 当前截图的 Base64 编码
- `sendMessage(message)`: 发送消息到 AI
- `setImagePath(path)`: 设置截图路径
- `clearMessages()`: 清空消息历史
- `clearError()`: 清除错误
- `retry()`: 重试上一条消息
## 依赖关系
- 使用项目统一的 CSS 变量(terminal 主题)
- 使用 `@tauri-apps/api/core` 进行 Tauri 通信
- 使用 `react-markdown` 和 `remark-gfm` 进行 Markdown 渲染
- 使用 `@/lib/api/agentRuntime` 发起 Agent/Aster 请求
- 使用 `@/lib/api/agentProtocol` 解析 AI 流式事件
- 使用 `@/lib/api/project` 获取默认工作区
## 相关需求
- 需求 4.1: 悬浮窗口以无边框、置顶的方式打开
- 需求 4.2: 悬浮窗口应显示截图预览
- 需求 4.3: 悬浮窗口应提供文本输入框
- 需求 4.4: 支持 Enter 键发送
- 需求 4.5: 在可滚动区域显示 AI 回复
- 需求 4.6: 支持 ESC 关闭
- 需求 4.7: 支持窗口拖动
- 需求 5.1: 将图片编码为 base64
- 需求 5.2: 使用现有的 Agent API 进行 AI 通信
- 需求 5.3: 显示加载指示器
- 需求 5.4: 以 Markdown 格式渲染回复内容
- 需求 5.5: 显示错误信息并提供重试选项
- 需求 6.3: 显示当前快捷键和修改按钮
- 需求 6.4: 支持快捷键录制模式
@@ -1,174 +0,0 @@
/**
* @file SmartInputPreview.tsx
* @description 截图预览组件,用于悬浮对话窗口中显示截图
* @module components/smart-input/SmartInputPreview
*/
import React, { useState, useCallback } from "react";
/** 截图预览属性 */
export interface SmartInputPreviewProps {
/** 图片路径或 Base64 编码 */
src: string;
/** 图片 alt 文本 */
alt?: string;
/** 自定义类名 */
className?: string;
/** 最大高度 */
maxHeight?: number;
}
/**
* 截图预览组件
* 支持缩放和拖拽查看截图
*/
export const SmartInputPreview: React.FC<SmartInputPreviewProps> = ({
src,
alt = "截图预览",
className = "",
maxHeight = 300,
}) => {
const [scale, setScale] = useState(1);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
// 滚轮缩放
const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
setScale((prev) => Math.max(0.5, Math.min(3, prev + delta)));
}, []);
// 拖拽开始
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (scale > 1) {
setIsDragging(true);
setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y });
}
},
[scale, position],
);
// 拖拽中
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (isDragging) {
setPosition({
x: e.clientX - dragStart.x,
y: e.clientY - dragStart.y,
});
}
},
[isDragging, dragStart],
);
// 拖拽结束
const handleMouseUp = useCallback(() => {
setIsDragging(false);
}, []);
// 重置缩放和位置
const handleReset = useCallback(() => {
setScale(1);
setPosition({ x: 0, y: 0 });
}, []);
// 放大
const handleZoomIn = useCallback(() => {
setScale((prev) => Math.min(3, prev + 0.25));
}, []);
// 缩小
const handleZoomOut = useCallback(() => {
setScale((prev) => Math.max(0.5, prev - 0.25));
}, []);
return (
<div className={`screenshot-preview ${className}`}>
{/* 工具栏 */}
<div className="screenshot-preview-toolbar">
<button
onClick={handleZoomOut}
title="缩小"
className="screenshot-preview-btn"
>
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
<line x1="8" y1="11" x2="14" y2="11" />
</svg>
</button>
<span className="screenshot-preview-scale">
{Math.round(scale * 100)}%
</span>
<button
onClick={handleZoomIn}
title="放大"
className="screenshot-preview-btn"
>
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
<line x1="11" y1="8" x2="11" y2="14" />
<line x1="8" y1="11" x2="14" y2="11" />
</svg>
</button>
<button
onClick={handleReset}
title="重置"
className="screenshot-preview-btn"
>
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="1 4 1 10 7 10" />
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
</svg>
</button>
</div>
{/* 图片容器 */}
<div
className="screenshot-preview-container"
style={{ maxHeight: `${maxHeight}px` }}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<img
src={src}
alt={alt}
className="screenshot-preview-image"
style={{
transform: `translate(${position.x}px, ${position.y}px) scale(${scale})`,
transition: isDragging ? "none" : "transform 0.1s ease",
cursor: scale > 1 ? (isDragging ? "grabbing" : "grab") : "default",
}}
draggable={false}
/>
</div>
</div>
);
};
export default SmartInputPreview;
@@ -1,184 +0,0 @@
/**
* @file SmartInputWindow.tsx
* @description 截图对话悬浮窗主组件
* @module components/smart-input/SmartInputWindow
*/
import React, { useState, useEffect, useCallback } from "react";
import { closeScreenshotChatWindow } from "@/lib/api/screenshotChat";
import { SmartInputPreview } from "./SmartInputPreview";
import { ChatInput } from "./ChatInput";
import { ChatMessages } from "./ChatMessages";
import { useSmartInput } from "./useSmartInput";
import type { SmartInputWindowProps } from "./types";
import { skillsApi, type Skill } from "@/lib/api/skills";
import "./smart-input.css";
/**
* 截图对话悬浮窗主组件
*
* 组合截图预览、消息列表和输入框,提供完整的对话界面
*
* 需求:
* - 4.1: 当截图完成时,悬浮窗口应以无边框、置顶的方式打开
* - 4.6: 当用户按下 ESC 或点击窗口外部时,悬浮窗口应关闭
* - 4.7: 悬浮窗口应支持用户拖动
*/
export const SmartInputWindow: React.FC<SmartInputWindowProps> = ({
imagePath,
onClose,
}) => {
const [inputValue, setInputValue] = useState("");
const [skills, setSkills] = useState<Skill[]>([]);
const {
messages,
isLoading,
error,
imageBase64,
sendMessage,
setImagePath,
clearError,
retry,
} = useSmartInput();
// 加载图片
useEffect(() => {
if (imagePath) {
setImagePath(imagePath);
}
}, [imagePath, setImagePath]);
// 加载技能列表
useEffect(() => {
skillsApi
.getAll("lime")
.then(setSkills)
.catch((err) => console.error("加载技能列表失败:", err));
}, []);
// 处理关闭窗口
const handleClose = useCallback(async () => {
try {
await closeScreenshotChatWindow();
} catch (err) {
console.error("关闭窗口失败:", err);
}
onClose?.();
}, [onClose]);
// 处理 ESC 键关闭
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
handleClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [handleClose]);
// 处理发送消息
const handleSend = useCallback(
async (textOverride?: string) => {
const message = textOverride || inputValue;
if (!message.trim()) return;
setInputValue("");
await sendMessage(message);
},
[inputValue, sendMessage],
);
// 构建图片 src
const imageSrc = imageBase64
? `data:image/png;base64,${imageBase64}`
: imagePath;
return (
<div className="smart-input-page">
{/* 窗口头部 - 可拖动区域 */}
<div className="smart-input-header">
<span className="smart-input-title">截图对话</span>
<button
className="smart-input-close-btn"
onClick={handleClose}
title="关闭 (ESC)"
>
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
{/* 截图预览区域 */}
{imageSrc && (
<div className="smart-input-preview">
<SmartInputPreview src={imageSrc} maxHeight={200} />
</div>
)}
{/* 对话区域 */}
<div className="smart-input-conversation">
{/* 错误提示 */}
{error && (
<div className="smart-input-error">
<div className="smart-input-error-content">
<p style={{ color: "#f43f5e", marginBottom: 8 }}>{error}</p>
<button className="smart-input-retry-btn" onClick={retry}>
<svg
className="w-3 h-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="1 4 1 10 7 10" />
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
</svg>
重试
</button>
<button
className="smart-input-retry-btn"
onClick={clearError}
style={{ marginLeft: 8 }}
>
关闭
</button>
</div>
</div>
)}
{/* 消息列表 */}
<ChatMessages messages={messages} />
{/* 输入区域 */}
<ChatInput
value={inputValue}
onChange={setInputValue}
onSend={handleSend}
isLoading={isLoading}
disabled={!imageBase64}
placeholder={imageBase64 ? "输入问题..." : "正在加载图片..."}
skills={skills}
/>
</div>
{/* 调试信息(开发模式) */}
{import.meta.env.DEV && (
<div className="smart-input-debug">
路径: {imagePath} | Base64: {imageBase64 ? "已加载" : "未加载"} |
消息数: {messages.length}
</div>
)}
</div>
);
};
export default SmartInputWindow;
-33
View File
@@ -1,33 +0,0 @@
/**
* @file index.ts
* @description 截图对话模块导出入口
* @module components/smart-input
*/
// 类型导出
export type {
SmartInputConfig,
MessageImage,
ChatMessage,
SmartInputState,
UseSmartInputReturn,
SmartInputPreviewProps,
ChatInputProps,
ChatMessagesProps,
SmartInputWindowProps,
} from "./types";
export type { ShortcutSettingsProps } from "./ShortcutSettings";
// 组件导出
export { SmartInputPreview } from "./SmartInputPreview";
export { ChatInput } from "./ChatInput";
export { ChatMessages } from "./ChatMessages";
export { SmartInputWindow } from "./SmartInputWindow";
export { ShortcutSettings } from "./ShortcutSettings";
// Hook 导出
export { useSmartInput, readImageAsBase64 } from "./useSmartInput";
// 默认导出主组件
export { SmartInputWindow as default } from "./SmartInputWindow";
-643
View File
@@ -1,643 +0,0 @@
/**
* @file smart-input.css
* @description 截图对话组件样式 - 参考 Claude 的简洁设计
* @module components/smart-input
*/
/* ============================================================================
* 悬浮输入框容器
* ============================================================================ */
.screenshot-floating-container {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
padding-bottom: 80px;
background: transparent;
}
/* 背景遮罩 - 半透明模糊效果 */
.screenshot-floating-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
/* ============================================================================
* 悬浮输入框
* ============================================================================ */
.screenshot-floating-input-wrapper {
position: relative;
z-index: 10;
display: flex;
align-items: center;
gap: 8px;
width: 600px;
max-width: calc(100vw - 40px);
padding: 8px 12px;
background: white;
border-radius: 24px;
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.15),
0 0 0 1px rgba(0, 0, 0, 0.05);
}
/* 图片标签按钮 */
.screenshot-image-tag {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
background: #f3f4f6;
border: none;
border-radius: 16px;
color: #6b7280;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.screenshot-image-tag:hover {
background: #e5e7eb;
color: #374151;
}
.screenshot-image-tag svg {
color: #9ca3af;
}
/* 输入框 */
.screenshot-floating-input {
flex: 1;
padding: 8px 4px;
border: none;
background: transparent;
font-size: 15px;
color: #1f2937;
outline: none;
}
.screenshot-floating-input::placeholder {
color: #9ca3af;
}
.screenshot-floating-input:disabled {
opacity: 0.6;
}
/* 右侧按钮组 */
.screenshot-floating-actions {
display: flex;
align-items: center;
gap: 8px;
}
/* 下拉按钮 */
.screenshot-action-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
background: transparent;
border: none;
border-radius: 8px;
color: #6b7280;
font-size: 13px;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.screenshot-action-btn:hover {
background: #f3f4f6;
color: #374151;
}
/* 发送按钮 */
.screenshot-send-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: #d1d5db;
border: none;
border-radius: 50%;
color: white;
cursor: not-allowed;
transition: all 0.15s ease;
}
.screenshot-send-btn.active {
background: #f97316;
cursor: pointer;
}
.screenshot-send-btn.active:hover {
background: #ea580c;
}
/* ============================================================================
* 图片预览弹窗
* ============================================================================ */
.screenshot-preview-modal {
position: fixed;
z-index: 100;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-width: 90vw;
max-height: 80vh;
background: white;
border-radius: 12px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
overflow: hidden;
}
.screenshot-preview-close {
position: absolute;
top: 8px;
right: 8px;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: rgba(0, 0, 0, 0.5);
border: none;
border-radius: 50%;
color: white;
cursor: pointer;
transition: all 0.15s ease;
}
.screenshot-preview-close:hover {
background: rgba(0, 0, 0, 0.7);
}
.screenshot-preview-image {
display: block;
max-width: 90vw;
max-height: 80vh;
object-fit: contain;
}
/* ============================================================================
* 深色模式支持
* ============================================================================ */
@media (prefers-color-scheme: dark) {
.screenshot-floating-input-wrapper {
background: #1f2937;
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.1);
}
.screenshot-image-tag {
background: #374151;
color: #d1d5db;
}
.screenshot-image-tag:hover {
background: #4b5563;
color: #f3f4f6;
}
.screenshot-image-tag svg {
color: #9ca3af;
}
.screenshot-floating-input {
color: #f3f4f6;
}
.screenshot-floating-input::placeholder {
color: #6b7280;
}
.screenshot-action-btn {
color: #9ca3af;
}
.screenshot-action-btn:hover {
background: #374151;
color: #f3f4f6;
}
.screenshot-send-btn {
background: #4b5563;
}
.screenshot-preview-modal {
background: #1f2937;
}
}
/* ============================================================================
* 旧版样式保留(兼容)
* ============================================================================ */
.screenshot-preview {
display: flex;
flex-direction: column;
border-radius: 8px;
overflow: hidden;
background-color: var(--terminal-bg, #1a1b26);
border: 1px solid var(--terminal-border, #3b4261);
}
.screenshot-preview-toolbar {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 6px 8px;
background-color: var(--terminal-tab-bg, #24283b);
border-bottom: 1px solid var(--terminal-border, #3b4261);
}
.screenshot-preview-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 4px;
color: var(--terminal-muted, #565f89);
background: transparent;
border: none;
cursor: pointer;
transition: all 0.15s ease;
}
.screenshot-preview-btn:hover {
background-color: var(--terminal-tab-hover-bg, #414868);
color: var(--terminal-fg, #c0caf5);
}
.screenshot-preview-scale {
min-width: 50px;
text-align: center;
font-size: 12px;
color: var(--terminal-muted, #565f89);
font-variant-numeric: tabular-nums;
}
.screenshot-preview-container {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background-color: var(--terminal-bg, #1a1b26);
}
/* ============================================================================
* 截图对话页面
* ============================================================================ */
.smart-input-page {
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
background-color: var(--terminal-bg, #1a1b26);
color: var(--terminal-fg, #c0caf5);
overflow: hidden;
}
/* 窗口头部 - 拖动区域 */
.smart-input-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background-color: var(--terminal-tab-bg, #24283b);
border-bottom: 1px solid var(--terminal-border, #3b4261);
-webkit-app-region: drag;
user-select: none;
}
.smart-input-title {
font-size: 13px;
font-weight: 500;
color: var(--terminal-fg, #c0caf5);
}
.smart-input-close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 4px;
color: var(--terminal-muted, #565f89);
background: transparent;
border: none;
cursor: pointer;
transition: all 0.15s ease;
-webkit-app-region: no-drag;
}
.smart-input-close-btn:hover {
background-color: #f43f5e;
color: white;
}
/* 截图预览区域 */
.smart-input-preview {
padding: 12px;
border-bottom: 1px solid var(--terminal-border, #3b4261);
}
/* 对话区域 */
.smart-input-conversation {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.smart-input-messages {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.smart-input-placeholder {
text-align: center;
color: var(--terminal-muted, #565f89);
font-size: 13px;
padding: 24px;
}
/* 输入区域 */
.smart-input-input-area {
display: flex;
gap: 8px;
padding: 12px;
border-top: 1px solid var(--terminal-border, #3b4261);
background-color: var(--terminal-tab-bg, #24283b);
}
.smart-input-input {
flex: 1;
padding: 8px 12px;
border-radius: 6px;
border: 1px solid var(--terminal-border, #3b4261);
background-color: var(--terminal-bg, #1a1b26);
color: var(--terminal-fg, #c0caf5);
font-size: 13px;
outline: none;
transition: border-color 0.15s ease;
}
.smart-input-input:focus {
border-color: var(--terminal-accent, #7aa2f7);
}
.smart-input-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.smart-input-send-btn {
padding: 8px 16px;
border-radius: 6px;
border: none;
background-color: var(--terminal-accent, #7aa2f7);
color: white;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.smart-input-send-btn:hover:not(:disabled) {
background-color: var(--terminal-accent-hover, #5d8bea);
}
.smart-input-send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 错误状态 */
.smart-input-error {
display: flex;
align-items: center;
justify-content: center;
}
.smart-input-error-content {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 24px;
}
/* 加载状态 */
.smart-input-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--terminal-muted, #565f89);
}
.smart-input-loading-spinner {
width: 32px;
height: 32px;
border: 3px solid var(--terminal-border, #3b4261);
border-top-color: var(--terminal-accent, #7aa2f7);
border-radius: 50%;
animation: smart-input-spin 0.8s linear infinite;
}
@keyframes smart-input-spin {
to {
transform: rotate(360deg);
}
}
/* 调试信息 */
.smart-input-debug {
padding: 4px 12px;
background-color: var(--terminal-tab-bg, #24283b);
border-top: 1px solid var(--terminal-border, #3b4261);
font-size: 10px;
color: var(--terminal-muted, #565f89);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ============================================================================
* 消息列表组件
* ============================================================================ */
.smart-input-message {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 16px;
padding: 8px 12px;
border-radius: 8px;
max-width: 85%;
}
.smart-input-message-user {
align-self: flex-end;
background-color: var(--terminal-accent, #7aa2f7);
color: white;
}
.smart-input-message-assistant {
align-self: flex-start;
background-color: var(--terminal-tab-bg, #24283b);
border: 1px solid var(--terminal-border, #3b4261);
}
.smart-input-message-image {
margin-bottom: 8px;
}
.smart-input-message-thumbnail {
max-width: 120px;
max-height: 80px;
border-radius: 4px;
object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.smart-input-message-content {
font-size: 13px;
line-height: 1.5;
}
.smart-input-message-content p {
margin: 0;
}
.smart-input-message-time {
font-size: 10px;
opacity: 0.6;
align-self: flex-end;
}
/* 思考中状态 */
.smart-input-thinking {
display: flex;
align-items: center;
gap: 8px;
color: var(--terminal-muted, #565f89);
font-style: italic;
}
.smart-input-thinking .smart-input-loading-spinner {
width: 14px;
height: 14px;
border-width: 2px;
}
/* Markdown 渲染样式 */
.smart-input-markdown {
font-size: 13px;
line-height: 1.6;
}
.smart-input-markdown p {
margin: 0 0 8px 0;
}
.smart-input-markdown p:last-child {
margin-bottom: 0;
}
.smart-input-markdown code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
padding: 2px 4px;
border-radius: 3px;
background-color: rgba(0, 0, 0, 0.2);
}
.smart-input-markdown pre {
margin: 8px 0;
padding: 8px;
border-radius: 4px;
background-color: rgba(0, 0, 0, 0.3);
overflow-x: auto;
}
.smart-input-markdown pre code {
padding: 0;
background: transparent;
}
.smart-input-markdown ul,
.smart-input-markdown ol {
margin: 8px 0;
padding-left: 20px;
}
.smart-input-markdown li {
margin-bottom: 4px;
}
.smart-input-markdown strong {
font-weight: 600;
}
.smart-input-markdown em {
font-style: italic;
}
.smart-input-markdown a {
color: var(--terminal-accent, #7aa2f7);
text-decoration: none;
}
.smart-input-markdown a:hover {
text-decoration: underline;
}
.smart-input-markdown blockquote {
margin: 8px 0;
padding-left: 12px;
border-left: 3px solid var(--terminal-accent, #7aa2f7);
color: var(--terminal-muted, #565f89);
}
/* 重试按钮 */
.smart-input-retry-btn {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 8px;
padding: 4px 8px;
border-radius: 4px;
border: 1px solid var(--terminal-border, #3b4261);
background: transparent;
color: var(--terminal-muted, #565f89);
font-size: 12px;
cursor: pointer;
transition: all 0.15s ease;
}
.smart-input-retry-btn:hover {
background-color: var(--terminal-tab-hover-bg, #414868);
color: var(--terminal-fg, #c0caf5);
}
-156
View File
@@ -1,156 +0,0 @@
import type { Skill } from "@/lib/api/skills";
/**
* @file types.ts
* @description 截图对话模块类型定义
* @module components/smart-input/types
*/
// ============================================================
// 配置类型
// ============================================================
/**
* 截图对话功能配置
* 需求: 1.1 - 实验室功能应提供 screenshot_chat.enabled 布尔开关
*/
export interface SmartInputConfig {
/** 是否启用截图对话功能 */
enabled: boolean;
/** 触发截图的全局快捷键 */
shortcut: string;
}
// ============================================================
// 消息类型
// ============================================================
/**
* 消息图片
* 需求: 5.1 - 截图对话模块应将图片编码为 base64
*/
export interface MessageImage {
/** Base64 编码的图片数据 */
data: string;
/** 媒体类型,如 "image/png" */
mediaType: string;
}
/**
* 聊天消息
* 需求: 4.5 - 悬浮窗口应在可滚动区域显示 AI 回复
*/
export interface ChatMessage {
/** 消息唯一标识 */
id: string;
/** 消息角色:用户或助手 */
role: "user" | "assistant";
/** 消息文本内容 */
content: string;
/** 附带的图片(用户消息可能包含截图) */
image?: MessageImage;
/** 消息时间戳 */
timestamp: number;
/** 是否正在思考中(助手消息) */
isThinking?: boolean;
/** 思考中的提示文本 */
thinkingContent?: string;
}
// ============================================================
// Hook 状态类型
// ============================================================
/**
* 截图对话 Hook 状态
*/
export interface SmartInputState {
/** 消息列表 */
messages: ChatMessage[];
/** 是否正在加载 */
isLoading: boolean;
/** 错误信息 */
error: string | null;
/** 当前截图路径 */
imagePath: string | null;
/** 当前截图的 Base64 编码 */
imageBase64: string | null;
}
/**
* 截图对话 Hook 返回值
*/
export interface UseSmartInputReturn extends SmartInputState {
/** 发送消息到 AI */
sendMessage: (message: string) => Promise<void>;
/** 设置截图路径 */
setImagePath: (path: string) => void;
/** 清空消息历史 */
clearMessages: () => void;
/** 清除错误 */
clearError: () => void;
/** 重试上一条消息 */
retry: () => Promise<void>;
}
// ============================================================
// 组件 Props 类型
// ============================================================
/**
* 截图预览组件属性
* 需求: 4.2 - 悬浮窗口应显示截图预览
*/
export interface SmartInputPreviewProps {
/** 图片路径或 Base64 编码 */
src: string;
/** 图片 alt 文本 */
alt?: string;
/** 自定义类名 */
className?: string;
/** 最大高度 */
maxHeight?: number;
}
/**
* 聊天输入框组件属性
* 需求: 4.3, 4.4 - 悬浮窗口应提供文本输入框,支持 Enter 发送
*/
export interface ChatInputProps {
/** 输入框值 */
value: string;
/** 值变化回调 */
onChange: (value: string) => void;
/** 发送消息回调(可接受 textOverride) */
onSend: (textOverride?: string) => void;
/** 是否禁用 */
disabled?: boolean;
/** 是否正在加载 */
isLoading?: boolean;
/** 占位符文本 */
placeholder?: string;
/** 技能列表 */
skills?: Skill[];
}
/**
* 消息列表组件属性
* 需求: 4.5, 5.4 - 显示 AI 回复,支持 Markdown 渲染
*/
export interface ChatMessagesProps {
/** 消息列表 */
messages: ChatMessage[];
/** 自定义类名 */
className?: string;
}
/**
* 悬浮窗主组件属性
* 需求: 4.1, 4.6, 4.7 - 无边框置顶窗口,支持 ESC 关闭和拖动
*/
export interface SmartInputWindowProps {
/** 截图路径 */
imagePath: string;
/** 关闭窗口回调 */
onClose?: () => void;
}
-336
View File
@@ -1,336 +0,0 @@
/**
* @file useSmartInput.ts
* @description 截图对话核心 Hook,管理消息、图片和 AI 通信
* @module components/smart-input/useSmartInput
*/
import { useState, useCallback, useRef } from "react";
import { safeInvoke, safeListen } from "@/lib/dev-bridge";
import type { UnlistenFn } from "@tauri-apps/api/event";
import { toast } from "sonner";
import { requireDefaultProjectId } from "@/lib/api/project";
import {
createAgentRuntimeSession,
submitAgentRuntimeTurn,
} from "@/lib/api/agentRuntime";
import type { ChatMessage, MessageImage, UseSmartInputReturn } from "./types";
import {
createSubmitTurnRequestFromAgentOp,
parseAgentEvent,
type AgentEvent,
} from "@/lib/api/agentProtocol";
const DEFAULT_SMART_INPUT_PROVIDER = "claude";
const DEFAULT_SMART_INPUT_MODEL = "claude-sonnet-4-5";
/**
* 读取图片文件并转换为 Base64
* 需求: 5.1 - 截图对话模块应将图片编码为 base64
*
* @param imagePath - 图片文件路径
* @returns Base64 编码的图片数据
*/
export async function readImageAsBase64(imagePath: string): Promise<string> {
try {
const base64 = await safeInvoke<string>("read_image_as_base64", {
path: imagePath,
});
return base64;
} catch (error) {
console.error("读取图片失败:", error);
throw new Error(`读取图片失败: ${error}`);
}
}
/**
* 截图对话 Hook
*
* 提供截图对话功能的核心状态管理和 AI 通信能力
*
* 需求:
* - 5.1: 将图片编码为 base64
* - 5.2: 使用现有的 Agent API 进行 AI 通信
* - 5.3: 显示加载指示器
* - 5.5: 显示错误信息并提供重试选项
*/
export function useSmartInput(): UseSmartInputReturn {
// 状态
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [imagePath, setImagePathState] = useState<string | null>(null);
const [imageBase64, setImageBase64] = useState<string | null>(null);
// 用于重试的最后一条消息
const lastMessageRef = useRef<string | null>(null);
// 会话 ID
const sessionIdRef = useRef<string | null>(null);
const workspaceIdRef = useRef<string | null>(null);
/**
* 设置截图路径并加载图片
*/
const setImagePath = useCallback(async (path: string) => {
setImagePathState(path);
setError(null);
try {
const base64 = await readImageAsBase64(path);
setImageBase64(base64);
} catch (err) {
const errorMsg = err instanceof Error ? err.message : "加载图片失败";
setError(errorMsg);
toast.error(errorMsg);
}
}, []);
/**
* 创建或获取会话
*/
const ensureSession = useCallback(async (): Promise<string | null> => {
if (sessionIdRef.current) {
return sessionIdRef.current;
}
try {
let workspaceId = workspaceIdRef.current;
if (!workspaceId) {
workspaceId =
await requireDefaultProjectId("未找到默认工作区,请先创建或选择项目");
workspaceIdRef.current = workspaceId;
}
const createdSessionId = await createAgentRuntimeSession(
workspaceId,
);
sessionIdRef.current = createdSessionId;
return createdSessionId;
} catch (err) {
console.error("创建会话失败:", err);
return null;
}
}, []);
/**
* 发送消息到 AI
* 需求: 5.2 - 使用现有的 Agent API 进行 AI 通信
*/
const sendMessage = useCallback(
async (message: string) => {
if (!message.trim()) return;
if (!imageBase64) {
setError("请先加载截图");
return;
}
// 保存消息用于重试
lastMessageRef.current = message;
setError(null);
setIsLoading(true);
// 创建用户消息
const userMsg: ChatMessage = {
id: crypto.randomUUID(),
role: "user",
content: message,
image:
messages.length === 0
? { data: imageBase64, mediaType: "image/png" }
: undefined,
timestamp: Date.now(),
};
// 创建助手消息占位符
const assistantMsgId = crypto.randomUUID();
const assistantMsg: ChatMessage = {
id: assistantMsgId,
role: "assistant",
content: "",
timestamp: Date.now(),
isThinking: true,
thinkingContent: "思考中...",
};
setMessages((prev) => [...prev, userMsg, assistantMsg]);
let accumulatedContent = "";
let unlisten: UnlistenFn | null = null;
try {
// 确保有会话
const sessionId = await ensureSession();
if (!sessionId) {
throw new Error("无法创建会话");
}
// 创建唯一事件名称
const eventName = `screenshot_chat_stream_${assistantMsgId}`;
// 设置事件监听器
unlisten = await safeListen<AgentEvent>(eventName, (event) => {
const data = parseAgentEvent(event.payload);
if (!data) return;
switch (data.type) {
case "text_delta":
accumulatedContent += data.text;
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantMsgId
? {
...msg,
content: accumulatedContent,
isThinking: false,
thinkingContent: undefined,
}
: msg,
),
);
break;
case "done":
case "final_done":
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantMsgId
? {
...msg,
isThinking: false,
content: accumulatedContent || "(无响应)",
}
: msg,
),
);
setIsLoading(false);
if (unlisten) {
unlisten();
unlisten = null;
}
break;
case "error":
setError(data.message);
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantMsgId
? {
...msg,
isThinking: false,
content: `错误: ${data.message}`,
}
: msg,
),
);
setIsLoading(false);
if (unlisten) {
unlisten();
unlisten = null;
}
break;
}
});
// 准备图片数据(只在第一条消息时发送图片)
const images: MessageImage[] =
messages.length === 0
? [{ data: imageBase64, mediaType: "image/png" }]
: [];
const workspaceId = workspaceIdRef.current;
if (!workspaceId) {
throw new Error("缺少默认工作区,无法发送截图对话请求");
}
// 发送流式请求(使用 Aster Agent)
await submitAgentRuntimeTurn(
createSubmitTurnRequestFromAgentOp({
type: "user_input",
text: message,
sessionId,
eventName,
workspaceId,
images:
images.length > 0
? images.map((img) => ({
data: img.data,
media_type: img.mediaType,
}))
: undefined,
preferences: {
providerPreference: DEFAULT_SMART_INPUT_PROVIDER,
modelPreference: DEFAULT_SMART_INPUT_MODEL,
},
}),
);
} catch (err) {
console.error("发送消息失败:", err);
const errorMsg = err instanceof Error ? err.message : "发送失败";
setError(errorMsg);
toast.error(errorMsg);
// 移除失败的助手消息
setMessages((prev) => prev.filter((msg) => msg.id !== assistantMsgId));
setIsLoading(false);
if (unlisten) {
unlisten();
}
}
},
[imageBase64, messages.length, ensureSession],
);
/**
* 清空消息历史
*/
const clearMessages = useCallback(() => {
setMessages([]);
sessionIdRef.current = null;
lastMessageRef.current = null;
}, []);
/**
* 清除错误
*/
const clearError = useCallback(() => {
setError(null);
}, []);
/**
* 重试上一条消息
* 需求: 5.5 - 显示错误信息并提供重试选项
*/
const retry = useCallback(async () => {
if (lastMessageRef.current) {
// 移除最后一条失败的助手消息
setMessages((prev) => {
const lastMsg = prev[prev.length - 1];
if (
lastMsg?.role === "assistant" &&
lastMsg.content.startsWith("错误:")
) {
return prev.slice(0, -1);
}
return prev;
});
await sendMessage(lastMessageRef.current);
}
}, [sendMessage]);
return {
messages,
isLoading,
error,
imagePath,
imageBase64,
sendMessage,
setImagePath,
clearMessages,
clearError,
retry,
};
}
export default useSmartInput;
@@ -1,176 +0,0 @@
/**
* SubAgent 执行进度组件
*
* 显示 SubAgent 调度器的执行进度
*/
import React from "react";
import {
SchedulerProgress,
SchedulerEvent,
} from "@/hooks/useSubAgentScheduler";
interface SubAgentProgressProps {
progress: SchedulerProgress | null;
events: SchedulerEvent[];
isRunning: boolean;
onCancel?: () => void;
}
/**
* 进度条组件
*/
const ProgressBar: React.FC<{ percentage: number; className?: string }> = ({
percentage,
className = "",
}) => (
<div className={`w-full bg-gray-200 rounded-full h-2.5 ${className}`}>
<div
className="bg-blue-600 h-2.5 rounded-full transition-all duration-300"
style={{ width: `${Math.min(100, percentage)}%` }}
/>
</div>
);
/**
* 状态徽章
*/
const StatusBadge: React.FC<{ status: string; count: number }> = ({
status,
count,
}) => {
const colors: Record<string, string> = {
completed: "bg-green-100 text-green-800",
failed: "bg-red-100 text-red-800",
running: "bg-blue-100 text-blue-800",
pending: "bg-gray-100 text-gray-800",
skipped: "bg-yellow-100 text-yellow-800",
};
return (
<span
className={`px-2 py-1 text-xs font-medium rounded ${colors[status] || colors.pending}`}
>
{status}: {count}
</span>
);
};
/**
* 事件日志项
*/
const EventLogItem: React.FC<{ event: SchedulerEvent }> = ({ event }) => {
const getEventContent = () => {
switch (event.type) {
case "started":
return `🚀 开始执行 ${event.totalTasks} 个任务`;
case "queueRejected":
return `🚫 任务被拒绝: 请求 ${event.requested},队列上限 ${event.limit}`;
case "taskStarted":
return `▶️ 任务 ${event.taskId} (${event.taskType}) 开始`;
case "taskCompleted":
return `✅ 任务 ${event.taskId} 完成 (${event.durationMs}ms)`;
case "taskTimedOut":
return `⏱️ 任务 ${event.taskId} 超时 (${event.timeoutMs}ms)`;
case "taskFailed":
return `❌ 任务 ${event.taskId} 失败: ${event.error}`;
case "taskRetry":
return `🔄 任务 ${event.taskId} 重试 #${event.retryCount}`;
case "taskSkipped":
return `⏭️ 任务 ${event.taskId} 跳过: ${event.reason}`;
case "completed":
return `🏁 执行${event.success ? "成功" : "失败"} (${event.durationMs}ms)`;
case "cancelled":
return `🛑 执行已取消`;
default:
return null;
}
};
const content = getEventContent();
if (!content) return null;
return <div className="text-sm text-gray-600 py-1">{content}</div>;
};
/**
* SubAgent 进度组件
*/
export const SubAgentProgress: React.FC<SubAgentProgressProps> = ({
progress,
events,
isRunning,
onCancel,
}) => {
if (!progress && events.length === 0) {
return null;
}
return (
<div className="bg-white rounded-lg shadow p-4 space-y-4">
{/* 标题和取消按钮 */}
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-gray-900">SubAgent 执行进度</h3>
{isRunning && onCancel && (
<button
onClick={onCancel}
className="px-3 py-1 text-sm text-red-600 hover:text-red-800 hover:bg-red-50 rounded"
>
取消
</button>
)}
</div>
{/* 进度条 */}
{progress && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600">
{progress.completed + progress.failed + progress.skipped} /{" "}
{progress.total}
</span>
<span className="text-gray-600">
{progress.percentage.toFixed(1)}%
</span>
</div>
<ProgressBar percentage={progress.percentage} />
</div>
)}
{/* 状态统计 */}
{progress && (
<div className="flex flex-wrap gap-2">
<StatusBadge status="completed" count={progress.completed} />
<StatusBadge status="running" count={progress.running} />
<StatusBadge status="pending" count={progress.pending} />
<StatusBadge status="failed" count={progress.failed} />
<StatusBadge status="skipped" count={progress.skipped} />
</div>
)}
{/* 当前运行的任务 */}
{progress && progress.currentTasks.length > 0 && (
<div className="text-sm">
<span className="text-gray-500">正在执行: </span>
<span className="text-blue-600">
{progress.currentTasks.join(", ")}
</span>
</div>
)}
{/* 事件日志 */}
{events.length > 0 && (
<div className="border-t pt-3">
<h4 className="text-sm font-medium text-gray-700 mb-2">执行日志</h4>
<div className="max-h-40 overflow-y-auto space-y-1">
{events.slice(-10).map((event, index) => (
<EventLogItem key={index} event={event} />
))}
</div>
</div>
)}
</div>
);
};
export default SubAgentProgress;
-13
View File
@@ -1,13 +0,0 @@
/**
* SubAgent 组件索引
*/
export { SubAgentProgress } from "./SubAgentProgress";
export type {
SubAgentTask,
SubAgentResult,
SchedulerProgress,
SchedulerEvent,
SchedulerExecutionResult,
SchedulerConfig,
} from "@/hooks/useSubAgentScheduler";
-21
View File
@@ -1,21 +0,0 @@
import { AlertTriangle, ExternalLink } from "lucide-react";
export function ExperimentalBanner() {
return (
<div className="flex items-center gap-2 px-3 py-2 bg-yellow-50 border border-yellow-200 rounded-md text-xs text-yellow-800">
<AlertTriangle className="h-3 w-3 shrink-0" />
<span>
实验功能,不影响核心使用。问题反馈:
<a
href="https://github.com/aiclientproxy/lime/issues"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 text-yellow-700 hover:text-yellow-900 underline ml-1"
>
GitHub Issue
<ExternalLink className="h-2.5 w-2.5" />
</a>
</span>
</div>
);
}
-64
View File
@@ -1,64 +0,0 @@
/**
* Alert 组件
*
* 用于显示重要信息、警告或错误提示
*/
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
));
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
));
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription };
-64
View File
@@ -1,64 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
interface RadioGroupProps {
value?: string;
onValueChange?: (value: string) => void;
className?: string;
children?: React.ReactNode;
}
interface RadioGroupItemProps {
value: string;
id?: string;
className?: string;
children?: React.ReactNode;
}
const RadioGroupContext = React.createContext<{
value?: string;
onValueChange?: (value: string) => void;
}>({});
const RadioGroup = React.forwardRef<HTMLDivElement, RadioGroupProps>(
({ className, value, onValueChange, children, ...props }, ref) => {
return (
<RadioGroupContext.Provider value={{ value, onValueChange }}>
<div
ref={ref}
className={cn("grid gap-2", className)}
role="radiogroup"
{...props}
>
{children}
</div>
</RadioGroupContext.Provider>
);
},
);
RadioGroup.displayName = "RadioGroup";
const RadioGroupItem = React.forwardRef<HTMLInputElement, RadioGroupItemProps>(
({ className, value, id, ...props }, ref) => {
const context = React.useContext(RadioGroupContext);
return (
<input
ref={ref}
type="radio"
id={id}
value={value}
checked={context.value === value}
onChange={() => context.onValueChange?.(value)}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
);
},
);
RadioGroupItem.displayName = "RadioGroupItem";
export { RadioGroup, RadioGroupItem };
-31
View File
@@ -1,31 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & {
orientation?: "horizontal" | "vertical";
decorative?: boolean;
}
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref,
) => (
<div
ref={ref}
role={decorative ? "none" : "separator"}
aria-orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className,
)}
{...props}
/>
),
);
Separator.displayName = "Separator";
export { Separator };
@@ -1,232 +0,0 @@
import React, { useEffect, useState } from "react";
import { safeInvoke } from "@/lib/dev-bridge";
import {
Wifi,
WifiOff,
Users,
MessageSquare,
AlertCircle,
RefreshCw,
ChevronDown,
ChevronUp,
} from "lucide-react";
interface WsServiceStatus {
enabled: boolean;
active_connections: number;
total_connections: number;
total_messages: number;
total_errors: number;
}
interface WsConnectionInfo {
id: string;
connected_at: string;
client_info: string | null;
request_count: number;
}
export function WebSocketStatus() {
const [status, setStatus] = useState<WsServiceStatus | null>(null);
const [connections, setConnections] = useState<WsConnectionInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showConnections, setShowConnections] = useState(false);
const fetchStatus = async () => {
try {
const wsStatus = await safeInvoke<WsServiceStatus>(
"get_websocket_status",
);
setStatus(wsStatus);
if (wsStatus.active_connections > 0) {
const wsConnections = await safeInvoke<WsConnectionInfo[]>(
"get_websocket_connections",
);
setConnections(wsConnections);
} else {
setConnections([]);
}
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchStatus();
const interval = setInterval(fetchStatus, 5000);
return () => clearInterval(interval);
}, []);
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleString();
};
const truncateId = (id: string) => {
return id.length > 8 ? `${id.slice(0, 8)}...` : id;
};
if (loading) {
return (
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 text-muted-foreground">
<RefreshCw className="h-4 w-4 animate-spin" />
<span>加载中...</span>
</div>
</div>
);
}
if (error) {
return (
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 text-red-500">
<AlertCircle className="h-4 w-4" />
<span>加载失败: {error}</span>
</div>
</div>
);
}
if (!status) {
return null;
}
return (
<div className="space-y-4">
{/* 状态概览 */}
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold flex items-center gap-2">
{status.enabled ? (
<Wifi className="h-4 w-4 text-green-500" />
) : (
<WifiOff className="h-4 w-4 text-muted-foreground" />
)}
WebSocket 服务
</h3>
<button
onClick={fetchStatus}
className="p-1 hover:bg-muted rounded"
title="刷新"
>
<RefreshCw className="h-4 w-4" />
</button>
</div>
<div className="grid grid-cols-4 gap-4">
<StatCard
icon={Users}
label="活跃连接"
value={status.active_connections}
highlight={status.active_connections > 0}
/>
<StatCard
icon={Users}
label="总连接数"
value={status.total_connections}
/>
<StatCard
icon={MessageSquare}
label="总消息数"
value={status.total_messages}
/>
<StatCard
icon={AlertCircle}
label="错误数"
value={status.total_errors}
highlight={status.total_errors > 0}
highlightColor="text-red-500"
/>
</div>
</div>
{/* 连接列表 */}
{status.active_connections > 0 && (
<div className="rounded-lg border bg-card">
<button
onClick={() => setShowConnections(!showConnections)}
className="w-full p-4 flex items-center justify-between hover:bg-muted/50"
>
<span className="font-semibold">
活跃连接 ({status.active_connections})
</span>
{showConnections ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
{showConnections && (
<div className="border-t">
{connections.length === 0 ? (
<div className="p-4 text-center text-muted-foreground">
暂无连接数据
</div>
) : (
<div className="divide-y">
{connections.map((conn) => (
<div
key={conn.id}
className="p-4 flex items-center justify-between"
>
<div>
<div className="font-mono text-sm">
{truncateId(conn.id)}
</div>
<div className="text-xs text-muted-foreground">
{conn.client_info || "未知客户端"}
</div>
</div>
<div className="text-right">
<div className="text-sm">{conn.request_count} 请求</div>
<div className="text-xs text-muted-foreground">
{formatDate(conn.connected_at)}
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
)}
</div>
);
}
function StatCard({
icon: Icon,
label,
value,
highlight = false,
highlightColor = "text-green-500",
}: {
icon: React.ElementType;
label: string;
value: number;
highlight?: boolean;
highlightColor?: string;
}) {
return (
<div className="text-center">
<div className="flex items-center justify-center gap-1 text-muted-foreground mb-1">
<Icon className="h-3 w-3" />
<span className="text-xs">{label}</span>
</div>
<div className={`text-xl font-bold ${highlight ? highlightColor : ""}`}>
{value}
</div>
</div>
);
}
export default WebSocketStatus;
-1
View File
@@ -1 +0,0 @@
export { WebSocketStatus } from "./WebSocketStatus";
+13 -111
View File
@@ -355,30 +355,17 @@
"// === Onboarding Wizard (src/components/onboarding/steps/*.tsx) ===": "",
"初次安装引导 - 欢迎页": "First-time Setup - Welcome",
"欢迎使用 Lime": "Welcome to Lime",
"AI API 聚合代理": "AI API Aggregation Proxy",
"让我们花一分钟时间,根据您的使用场景推荐合适的插件,提升您的使用体验。": "Let's take a minute to recommend suitable plugins based on your use case to enhance your experience.",
"多凭证池管理,自动轮换": "Multi-credential pool management with auto-rotation",
"支持 Claude、OpenAI、Gemini 等主流 API": "Supports mainstream APIs like Claude, OpenAI, Gemini",
"插件扩展,按需安装": "Plugin extensions, install on demand",
"Lime 是一款强大的 AI 客户端代理,帮助您轻松管理和使用多种 AI 服务。": "Lime is a powerful AI client proxy that helps you manage and use multiple AI services with ease.",
"支持 Claude、OpenAI、Gemini 等主流 AI 模型": "Supports mainstream AI models such as Claude, OpenAI, and Gemini",
"持续工作区": "Persistent workspace",
"围绕同一目标持续整理对话、素材和结果,减少来回切换。": "Keep conversations, materials, and results organized around the same goal to reduce context switching.",
"语音交互": "Voice interaction",
"便捷的语音输入,提升对话效率": "Use voice input to keep conversations moving faster.",
"跳过引导": "Skip Guide",
"开始设置": "Start Setup",
"初次安装引导 - 用户群体选择": "First-time Setup - User Profile",
"您是哪类用户?": "What type of user are you?",
"我们将根据您的选择推荐合适的插件": "We will recommend suitable plugins based on your selection",
"初次安装引导 - 插件选择": "First-time Setup - Plugin Selection",
"选择要安装的插件": "Select Plugins to Install",
"已为程序员推荐配置管理和 Flow Monitor 插件": "Configuration management and Flow Monitor plugins recommended for developers",
"您可以根据需要选择插件,或稍后在插件中心安装": "You can select plugins as needed, or install them later in the Plugin Center",
"取消全选": "Deselect All",
"全选": "Select All",
"推荐": "Recommended",
"初次安装引导 - 安装进度": "First-time Setup - Installation Progress",
"等待安装...": "Waiting to install...",
"准备下载...": "Preparing to download...",
"安装出错": "Installation error",
"正在安装插件": "Installing Plugins",
"请稍候,正在为您安装选中的插件...": "Please wait, installing selected plugins...",
"总体进度": "Overall Progress",
"开始使用": "Get started",
"// === Provider Pool Sub-components ===": "",
"配置 Google Vertex AI API Key 和模型别名": "Configure Google Vertex AI API Key and model aliases",
"暂无 Vertex AI 凭证": "No Vertex AI credentials",
@@ -497,31 +484,16 @@
"此中转商不在官方注册表中,请确认您信任此来源后再添加。": "This relay provider is not in the official registry, please confirm you trust this source before adding.",
"API Key 信息展示组件": "API Key Information Display Component",
"名称": "Name",
"// === Model Selector Components (src/components/model-selector/*.tsx) ===": "",
"服务等级选择器 - Mini/Pro/Max 三档选择": "Service Tier Selector - Mini/Pro/Max Three-tier Selection",
"提供类似 v0 的简洁模式选择体验": "Provides v0-like simple mode selection experience",
"快速响应": "Fast Response",
"均衡性能": "Balanced Performance",
"最强能力": "Maximum Capability",
"当前选中的等级": "Currently Selected Tier",
"等级变化回调": "Tier Change Callback",
"是否禁用": "Is Disabled",
"各等级的模型数量": "Model Count per Tier",
"紧凑模式": "Compact Mode",
"ProviderModelSelector 组件": "ProviderModelSelector Component",
"双栏模型选择器:左侧 Provider 列表,右侧模型列表": "Two-column model selector: Provider list on left, model list on right",
"选择模型回调": "Select Model Callback",
"初始选中的 Provider": "Initially Selected Provider",
"初始选中的模型": "Initially Selected Model",
"已配置的 Provider 信息": "Configured Provider Information",
"常量": "Constants",
"OAuth 凭证类型到 Provider ID 的映射": "OAuth Credential Type to Provider ID Mapping",
"API Key Provider 类型到 Registry ID 的映射": "API Key Provider Type to Registry ID Mapping",
"Provider 显示名称": "Provider Display Names",
"阿里云": "Alibaba Cloud",
"自定义": "Custom",
"别名 Provider 列表(使用别名配置而非标准模型注册表)": "Alias Provider List (uses alias configuration instead of standard model registry)",
"Provider ID 到模型注册表 Provider ID 的映射(用于过滤模型)": "Provider ID to Model Registry Provider ID Mapping (for filtering models)",
"子组件": "Sub-components",
"Provider 列表项": "Provider List Item",
"模型列表项": "Model List Item",
@@ -531,13 +503,11 @@
"支持工具": "Supports Tools",
"支持推理": "Supports Reasoning",
"双栏模型选择器组件": "Two-column Model Selector Component",
"左侧显示已配置凭证的 Provider 列表(单选)": "Left side shows Provider list with configured credentials (single selection)",
"// === App.tsx ===": "",
"保留以供后续错误处理": "Reserved for future error handling",
"加载失败": "Failed to load",
"前缀": "Prefix",
"确认弹窗": "Confirmation Dialog",
"// === components\\agent\\AgentSkillsPanel.tsx ===": "",
"加载": "Load",
"已加载": "Loaded",
"名称列表": "Name List",
@@ -1710,39 +1680,17 @@
"同步到哪些应用": "Sync to which apps",
"同步到": "Sync to",
"服务器吗": "server",
"// === components\\model-selector\\EnhancedModelList.tsx ===": "",
"加载模型列表": "Load ModelList",
"暂无可用模型": "No available models",
"请等待模型数据加载": "Please wait for model data to load",
"无搜索结果": "No search results",
"未找到匹配的模型": "No matching models found",
"尝试其他搜索词": "Try other search terms",
"选中指示器": "Selection indicator",
"模型信息": "Model information",
"能力标签和操作": "Capability tags and actions",
"全部模型": "All models",
"// === components\\model-selector\\ModelList.tsx ===": "",
"请先添加凭证": "Please add credentials first",
"状态和能力标签": "Status and capability tags",
"视觉": "Vision",
"// === components\\model-selector\\ModelSelector.tsx ===": "",
"模型选择失败": "Model selection failed",
"模式切换和刷新": "Mode toggle and refresh",
"统计信息": "Statistics",
"可用": "Available",
"刷新按钮": "Refresh button",
"等级选择器": "Tier selector",
"专家模式": "Expert mode",
"显示模型列表": "Show model list",
"简单模式": "Simple mode",
"显示当前选择": "Show current selection",
"// === components\\model-selector\\ModeToggle.tsx ===": "",
"简单": "Simple",
"专家": "Expert",
"// === components\\model-selector\\ProviderModelSelector.tsx ===": "",
"时清除模型选择": "Clear model selection when",
"左侧": "Left",
"已配置凭证的": "With configured credentials",
"右侧": "Right",
"的模型": "models",
"请选择": "Please select",
@@ -1768,38 +1716,14 @@
"下一步": "Next",
"// === components\\onboarding\\steps\\CompleteStep.tsx ===": "",
"设置完成": "Setup complete",
"所有插件已成功安装": "All plugins installed successfully",
"您可以开始使用": "You can start using",
"个插件": "plugins",
"个安装失败": "installation failed",
"您可以稍后在插件中心重试": "You can retry later in Plugin Center",
"您已跳过插件安装": "You skipped plugin installation",
"可以稍后在插件中心安装需要的插件": "Can install needed plugins later in Plugin Center",
"您可以在左侧导航栏的": "You can find in the left navigation bar",
"随时安装插件": "install plugins anytime",
"Lime 已准备就绪,您可以开始使用了。": "Lime is ready. You can start using it now.",
"提示:后续可在设置中继续调整语音输入和快捷键。": "Tip: You can continue adjusting voice input and shortcuts in Settings later.",
"开始使用": "Get started",
"// === components\\onboarding\\steps\\InstallProgressStep.tsx ===": "",
"等待安装": "Waiting to install",
"准备下载": "Preparing download",
"请稍候": "Please wait",
"正在为您安装选中的插件": "Installing selected plugins for you",
"// === components\\onboarding\\steps\\PluginSelectStep.tsx ===": "",
"已为程序员推荐配置管理和": "Configuration management recommended for programmers and",
"您可以根据需要选择插件": "You can select plugins as needed",
"或稍后在插件中心安装": "or install later in Plugin Center",
"// === components\\onboarding\\steps\\UserProfileStep.tsx ===": "",
"您是哪类用户": "What type of user are you",
"// === components\\onboarding\\steps\\WelcomeStep.tsx ===": "",
"欢迎使用": "Welcome to",
"聚合代理": "Aggregation proxy",
"让我们花一分钟时间": "Let's take a minute",
"根据您的使用场景推荐合适的插件": "to recommend suitable plugins based on your use case",
"提升您的使用体验": "to enhance your experience",
"多凭证池管理": "Multi-credential pool management",
"自动轮换": "Auto-rotation",
"等主流": "And other mainstream",
"插件扩展": "Plugin extensions",
"按需安装": "Install on demand",
"持续工作区": "Persistent workspace",
"围绕同一目标持续整理对话、素材和结果,减少来回切换。": "Keep conversations, materials, and results organized around the same goal to reduce context switching.",
"语音交互": "Voice interaction",
"便捷的语音输入,提升对话效率": "Use voice input to keep conversations moving faster.",
"// === components\\plugins\\OAuthPluginContainer.tsx ===": "",
"认证类型": "Authentication type",
"最后使用": "Last used",
@@ -1890,7 +1814,6 @@
"未知来源": "Unknown source",
"// === components\\plugins\\PluginsPage.tsx ===": "",
"管理和配置": "Manage and configure",
"// === components\\plugins\\PluginUIRenderer.test.tsx ===": "",
"内置插件组件渲染": "Built-in plugin component rendering",
"应该正确渲染": "Should render correctly",
"未知插件处理": "Unknown plugin handler",
@@ -1902,7 +1825,6 @@
"大小写敏感性": "Case sensitivity",
"应该区分大小写": "Should be case sensitive",
"应该显示未找到": "Should display not found",
"// === components\\plugins\\PluginUIRenderer.tsx ===": "",
"无法加载插件": "Cannot load plugin",
"的用户界面": "user interface",
"请检查插件是否已正确安装": "Please check if plugin is installed correctly",
@@ -2232,7 +2154,6 @@
"完成登录": "Complete login",
"授权成功后": "After successful authorization",
"凭证将自动保存并添加到凭证池": "Credentials will be automatically saved and added to credential pool",
"// === components\\provider-pool\\credential-forms\\AntigravityFormStandalone.tsx ===": "",
"名称输入": "Name input",
"表单内容": "Form content",
"按钮区域": "Button area",
@@ -2245,7 +2166,6 @@
"指纹": "Fingerprint",
"需安装": "Requires installation",
"不可用警告图标": "Unavailable warning icon",
"// === components\\provider-pool\\credential-forms\\ClaudeFormStandalone.tsx ===": "",
"授权表单": "Authorization form",
"使用浏览器": "Use browser",
"中的": "in",
@@ -2283,7 +2203,6 @@
"复制页面显示的授权码": "Copy authorization code displayed on page",
"提交按钮": "Submit button",
"验证授权码": "Verify authorization code",
"// === components\\provider-pool\\credential-forms\\GeminiFormStandalone.tsx ===": "",
"认证方式选择": "Authentication method selection",
"认证": "Authentication",
"文件导入选项": "File import option",
@@ -2570,7 +2489,6 @@
"// === components\\provider-pool\\VertexAISection.tsx ===": "",
"和模型别名": " and ModelAlias",
"添加模型别名": "Add model alias",
"// === components\\Providers.tsx ===": "",
"访问通义千问": "Access Tongyi Qianwen",
"凭证加载成功": "Credentials loaded successfully",
"配置保存成功": "Configuration saved successfully",
@@ -3179,14 +3097,6 @@
"个插件工具": "plugin tools",
"推荐插件区域": "Recommended plugins area",
"插件安装对话框": "Plugin installation dialog",
"// === components\\websocket\\WebSocketStatus.tsx ===": "",
"活跃连接": "Active connections",
"总连接数": "Total connections",
"总消息数": "Total messages",
"错误数": "Error count",
"连接列表": "Connection list",
"暂无连接数据": "No connection data",
"未知客户端": "Unknown client",
"// === hooks\\useConnectCallback.ts ===": "",
"回调发送": "Callback sent",
"发送回调失败": "Failed to send callback",
@@ -3412,28 +3322,22 @@
"插件导出": "Plugin exports",
"没有默认导出": "No default export",
"加载插件失败": "Failed to Loaded plugins",
"// === lib\\plugin-loader\\PluginUIRenderer.tsx ===": "",
"插件加载失败": "Failed to pluginLoad",
"没有找到有效的组件导出": "No valid component export found",
"读取插件": "Read plugin",
"文件失败": "Failed to file",
"请通过命令行或": "Please use via command line or",
"使用此插件": "Use this plugin",
"// === lib\\plugin-ui\\ComponentRegistry.ts ===": "",
"无效的组件名称": "Invalid component name",
"必须以字母开头且只包含字母数字": "Must start with a letter and contain only alphanumeric characters",
"组件": "Component",
"将被覆盖": "Will be overwritten",
"// === lib\\plugin-ui\\components\\display.tsx ===": "",
"未知图标": "Unknown icon",
"// === lib\\plugin-ui\\PluginUIContainer.tsx ===": "",
"该插件没有提供": "This plugin does not provide",
"// === lib\\plugin-ui\\PluginUIRenderer.tsx ===": "",
"模板数据绑定": "Template data binding",
"不是数组": "Is not an array",
"未注册的组件类型": "Unregistered component type",
"未知组件": "Unknown component",
"// === lib\\plugin-ui\\SurfaceManager.test.ts ===": "",
"属性": "Property",
"注册一致性": "Registration consistency",
"注册的": "Registered",
@@ -3444,9 +3348,7 @@
"后应该无法查询到": "Should not be queryable after",
"清理插件时应该删除该插件的所有": "Should delete all of this plugin when cleaning",
"多个插件可以注册不同的": "Multiple plugins can register different",
"// === lib\\plugin-ui\\types.ts ===": "",
"权重": "Weight",
"// === lib\\plugin-ui\\usePluginUI.ts ===": "",
"监听事件失败": "Failed to listen to event",
"处理操作失败": "Failed to HandleActions",
"// === lib\\utils\\apiKeyMask.test.ts ===": "",
+13 -111
View File
@@ -330,30 +330,17 @@
"// === Onboarding Wizard (src/components/onboarding/steps/*.tsx) ===": "",
"初次安装引导 - 欢迎页": "初次安装引导 - 欢迎页",
"欢迎使用 Lime": "欢迎使用 Lime",
"AI API 聚合代理": "AI API 聚合代理",
"让我们花一分钟时间,根据您的使用场景推荐合适的插件,提升您的使用体验。": "让我们花一分钟时间,根据您的使用场景推荐合适的插件,提升您的使用体验。",
"多凭证池管理,自动轮换": "多凭证池管理,自动轮换",
"支持 Claude、OpenAI、Gemini 等主流 API": "支持 Claude、OpenAI、Gemini 等主流 API",
"插件扩展,按需安装": "插件扩展,按需安装",
"Lime 是一款强大的 AI 客户端代理,帮助您轻松管理和使用多种 AI 服务。": "Lime 是一款强大的 AI 客户端代理,帮助您轻松管理和使用多种 AI 服务。",
"支持 Claude、OpenAI、Gemini 等主流 AI 模型": "支持 Claude、OpenAI、Gemini 等主流 AI 模型",
"持续工作区": "持续工作区",
"围绕同一目标持续整理对话、素材和结果,减少来回切换。": "围绕同一目标持续整理对话、素材和结果,减少来回切换。",
"语音交互": "语音交互",
"便捷的语音输入,提升对话效率": "便捷的语音输入,提升对话效率",
"跳过引导": "跳过引导",
"开始设置": "开始设置",
"初次安装引导 - 用户群体选择": "初次安装引导 - 用户群体选择",
"您是哪类用户?": "您是哪类用户?",
"我们将根据您的选择推荐合适的插件": "我们将根据您的选择推荐合适的插件",
"初次安装引导 - 插件选择": "初次安装引导 - 插件选择",
"选择要安装的插件": "选择要安装的插件",
"已为程序员推荐配置管理和 Flow Monitor 插件": "已为程序员推荐配置管理和 Flow Monitor 插件",
"您可以根据需要选择插件,或稍后在插件中心安装": "您可以根据需要选择插件,或稍后在插件中心安装",
"取消全选": "取消全选",
"全选": "全选",
"推荐": "推荐",
"初次安装引导 - 安装进度": "初次安装引导 - 安装进度",
"等待安装...": "等待安装...",
"准备下载...": "准备下载...",
"安装出错": "安装出错",
"正在安装插件": "正在安装插件",
"请稍候,正在为您安装选中的插件...": "请稍候,正在为您安装选中的插件...",
"总体进度": "总体进度",
"开始使用": "开始使用",
"// === Provider Pool Sub-components ===": "",
"配置 Google Vertex AI API Key 和模型别名": "配置 Google Vertex AI API Key 和模型别名",
"暂无 Vertex AI 凭证": "暂无 Vertex AI 凭证",
@@ -472,31 +459,16 @@
"此中转商不在官方注册表中,请确认您信任此来源后再添加。": "此中转商不在官方注册表中,请确认您信任此来源后再添加。",
"API Key 信息展示组件": "API Key 信息展示组件",
"名称": "名称",
"// === Model Selector Components (src/components/model-selector/*.tsx) ===": "",
"服务等级选择器 - Mini/Pro/Max 三档选择": "服务等级选择器 - Mini/Pro/Max 三档选择",
"提供类似 v0 的简洁模式选择体验": "提供类似 v0 的简洁模式选择体验",
"快速响应": "快速响应",
"均衡性能": "均衡性能",
"最强能力": "最强能力",
"当前选中的等级": "当前选中的等级",
"等级变化回调": "等级变化回调",
"是否禁用": "是否禁用",
"各等级的模型数量": "各等级的模型数量",
"紧凑模式": "紧凑模式",
"ProviderModelSelector 组件": "ProviderModelSelector 组件",
"双栏模型选择器:左侧 Provider 列表,右侧模型列表": "双栏模型选择器:左侧 Provider 列表,右侧模型列表",
"选择模型回调": "选择模型回调",
"初始选中的 Provider": "初始选中的 Provider",
"初始选中的模型": "初始选中的模型",
"已配置的 Provider 信息": "已配置的 Provider 信息",
"常量": "常量",
"OAuth 凭证类型到 Provider ID 的映射": "OAuth 凭证类型到 Provider ID 的映射",
"API Key Provider 类型到 Registry ID 的映射": "API Key Provider 类型到 Registry ID 的映射",
"Provider 显示名称": "Provider 显示名称",
"阿里云": "阿里云",
"自定义": "自定义",
"别名 Provider 列表(使用别名配置而非标准模型注册表)": "别名 Provider 列表(使用别名配置而非标准模型注册表)",
"Provider ID 到模型注册表 Provider ID 的映射(用于过滤模型)": "Provider ID 到模型注册表 Provider ID 的映射(用于过滤模型)",
"子组件": "子组件",
"Provider 列表项": "Provider 列表项",
"模型列表项": "模型列表项",
@@ -506,13 +478,11 @@
"支持工具": "支持工具",
"支持推理": "支持推理",
"双栏模型选择器组件": "双栏模型选择器组件",
"左侧显示已配置凭证的 Provider 列表(单选)": "左侧显示已配置凭证的 Provider 列表(单选)",
"// === App.tsx ===": "",
"保留以供后续错误处理": "保留以供后续错误处理",
"加载失败": "加载失败",
"前缀": "前缀",
"确认弹窗": "确认弹窗",
"// === components\\agent\\AgentSkillsPanel.tsx ===": "",
"加载": "加载",
"已加载": "已加载",
"名称列表": "名称列表",
@@ -1692,39 +1662,17 @@
"同步到哪些应用": "同步到哪些应用",
"同步到": "同步到",
"服务器吗": "服务器吗",
"// === components\\model-selector\\EnhancedModelList.tsx ===": "",
"加载模型列表": "加载模型列表",
"暂无可用模型": "暂无可用模型",
"请等待模型数据加载": "请等待模型数据加载",
"无搜索结果": "无搜索结果",
"未找到匹配的模型": "未找到匹配的模型",
"尝试其他搜索词": "尝试其他搜索词",
"选中指示器": "选中指示器",
"模型信息": "模型信息",
"能力标签和操作": "能力标签和操作",
"全部模型": "全部模型",
"// === components\\model-selector\\ModelList.tsx ===": "",
"请先添加凭证": "请先添加凭证",
"状态和能力标签": "状态和能力标签",
"视觉": "视觉",
"// === components\\model-selector\\ModelSelector.tsx ===": "",
"模型选择失败": "模型选择失败",
"模式切换和刷新": "模式切换和刷新",
"统计信息": "统计信息",
"可用": "可用",
"刷新按钮": "刷新按钮",
"等级选择器": "等级选择器",
"专家模式": "专家模式",
"显示模型列表": "显示模型列表",
"简单模式": "简单模式",
"显示当前选择": "显示当前选择",
"// === components\\model-selector\\ModeToggle.tsx ===": "",
"简单": "简单",
"专家": "专家",
"// === components\\model-selector\\ProviderModelSelector.tsx ===": "",
"时清除模型选择": "时清除模型选择",
"左侧": "左侧",
"已配置凭证的": "已配置凭证的",
"右侧": "右侧",
"的模型": "的模型",
"请选择": "请选择",
@@ -1750,38 +1698,14 @@
"下一步": "下一步",
"// === components\\onboarding\\steps\\CompleteStep.tsx ===": "",
"设置完成": "设置完成",
"所有插件已成功安装": "所有插件已成功安装",
"您可以开始使用": "您可以开始使用",
"个插件": "个插件",
"个安装失败": "个安装失败",
"您可以稍后在插件中心重试": "您可以稍后在插件中心重试",
"您已跳过插件安装": "您已跳过插件安装",
"可以稍后在插件中心安装需要的插件": "可以稍后在插件中心安装需要的插件",
"您可以在左侧导航栏的": "您可以在左侧导航栏的",
"随时安装插件": "随时安装插件",
"Lime 已准备就绪,您可以开始使用了。": "Lime 已准备就绪,您可以开始使用了。",
"提示:后续可在设置中继续调整语音输入和快捷键。": "提示:后续可在设置中继续调整语音输入和快捷键。",
"开始使用": "开始使用",
"// === components\\onboarding\\steps\\InstallProgressStep.tsx ===": "",
"等待安装": "等待安装",
"准备下载": "准备下载",
"请稍候": "请稍候",
"正在为您安装选中的插件": "正在为您安装选中的插件",
"// === components\\onboarding\\steps\\PluginSelectStep.tsx ===": "",
"已为程序员推荐配置管理和": "已为程序员推荐配置管理和",
"您可以根据需要选择插件": "您可以根据需要选择插件",
"或稍后在插件中心安装": "或稍后在插件中心安装",
"// === components\\onboarding\\steps\\UserProfileStep.tsx ===": "",
"您是哪类用户": "您是哪类用户",
"// === components\\onboarding\\steps\\WelcomeStep.tsx ===": "",
"欢迎使用": "欢迎使用",
"聚合代理": "聚合代理",
"让我们花一分钟时间": "让我们花一分钟时间",
"根据您的使用场景推荐合适的插件": "根据您的使用场景推荐合适的插件",
"提升您的使用体验": "提升您的使用体验",
"多凭证池管理": "多凭证池管理",
"自动轮换": "自动轮换",
"等主流": "等主流",
"插件扩展": "插件扩展",
"按需安装": "按需安装",
"持续工作区": "持续工作区",
"围绕同一目标持续整理对话、素材和结果,减少来回切换。": "围绕同一目标持续整理对话、素材和结果,减少来回切换。",
"语音交互": "语音交互",
"便捷的语音输入,提升对话效率": "便捷的语音输入,提升对话效率",
"// === components\\plugins\\OAuthPluginContainer.tsx ===": "",
"认证类型": "认证类型",
"最后使用": "最后使用",
@@ -1872,7 +1796,6 @@
"未知来源": "未知来源",
"// === components\\plugins\\PluginsPage.tsx ===": "",
"管理和配置": "管理和配置",
"// === components\\plugins\\PluginUIRenderer.test.tsx ===": "",
"内置插件组件渲染": "内置插件组件渲染",
"应该正确渲染": "应该正确渲染",
"未知插件处理": "未知插件处理",
@@ -1884,7 +1807,6 @@
"大小写敏感性": "大小写敏感性",
"应该区分大小写": "应该区分大小写",
"应该显示未找到": "应该显示未找到",
"// === components\\plugins\\PluginUIRenderer.tsx ===": "",
"无法加载插件": "无法加载插件",
"的用户界面": "的用户界面",
"请检查插件是否已正确安装": "请检查插件是否已正确安装",
@@ -2214,7 +2136,6 @@
"完成登录": "完成登录",
"授权成功后": "授权成功后",
"凭证将自动保存并添加到凭证池": "凭证将自动保存并添加到凭证池",
"// === components\\provider-pool\\credential-forms\\AntigravityFormStandalone.tsx ===": "",
"名称输入": "名称输入",
"表单内容": "表单内容",
"按钮区域": "按钮区域",
@@ -2227,7 +2148,6 @@
"指纹": "指纹",
"需安装": "需安装",
"不可用警告图标": "不可用警告图标",
"// === components\\provider-pool\\credential-forms\\ClaudeFormStandalone.tsx ===": "",
"授权表单": "授权表单",
"使用浏览器": "使用浏览器",
"中的": "中的",
@@ -2265,7 +2185,6 @@
"复制页面显示的授权码": "复制页面显示的授权码",
"提交按钮": "提交按钮",
"验证授权码": "验证授权码",
"// === components\\provider-pool\\credential-forms\\GeminiFormStandalone.tsx ===": "",
"认证方式选择": "认证方式选择",
"认证": "认证",
"文件导入选项": "文件导入选项",
@@ -2552,7 +2471,6 @@
"// === components\\provider-pool\\VertexAISection.tsx ===": "",
"和模型别名": "和模型别名",
"添加模型别名": "添加模型别名",
"// === components\\Providers.tsx ===": "",
"访问通义千问": "访问通义千问",
"凭证加载成功": "凭证加载成功",
"配置保存成功": "配置保存成功",
@@ -3161,14 +3079,6 @@
"个插件工具": "个插件工具",
"推荐插件区域": "推荐插件区域",
"插件安装对话框": "插件安装对话框",
"// === components\\websocket\\WebSocketStatus.tsx ===": "",
"活跃连接": "活跃连接",
"总连接数": "总连接数",
"总消息数": "总消息数",
"错误数": "错误数",
"连接列表": "连接列表",
"暂无连接数据": "暂无连接数据",
"未知客户端": "未知客户端",
"// === hooks\\useConnectCallback.ts ===": "",
"回调发送": "回调发送",
"发送回调失败": "发送回调失败",
@@ -3394,28 +3304,22 @@
"插件导出": "插件导出",
"没有默认导出": "没有默认导出",
"加载插件失败": "加载插件失败",
"// === lib\\plugin-loader\\PluginUIRenderer.tsx ===": "",
"插件加载失败": "插件加载失败",
"没有找到有效的组件导出": "没有找到有效的组件导出",
"读取插件": "读取插件",
"文件失败": "文件失败",
"请通过命令行或": "请通过命令行或",
"使用此插件": "使用此插件",
"// === lib\\plugin-ui\\ComponentRegistry.ts ===": "",
"无效的组件名称": "无效的组件名称",
"必须以字母开头且只包含字母数字": "必须以字母开头且只包含字母数字",
"组件": "组件",
"将被覆盖": "将被覆盖",
"// === lib\\plugin-ui\\components\\display.tsx ===": "",
"未知图标": "未知图标",
"// === lib\\plugin-ui\\PluginUIContainer.tsx ===": "",
"该插件没有提供": "该插件没有提供",
"// === lib\\plugin-ui\\PluginUIRenderer.tsx ===": "",
"模板数据绑定": "模板数据绑定",
"不是数组": "不是数组",
"未注册的组件类型": "未注册的组件类型",
"未知组件": "未知组件",
"// === lib\\plugin-ui\\SurfaceManager.test.ts ===": "",
"属性": "属性",
"注册一致性": "注册一致性",
"注册的": "注册的",
@@ -3426,9 +3330,7 @@
"后应该无法查询到": "后应该无法查询到",
"清理插件时应该删除该插件的所有": "清理插件时应该删除该插件的所有",
"多个插件可以注册不同的": "多个插件可以注册不同的",
"// === lib\\plugin-ui\\types.ts ===": "",
"权重": "权重",
"// === lib\\plugin-ui\\usePluginUI.ts ===": "",
"监听事件失败": "监听事件失败",
"处理操作失败": "处理操作失败",
"// === lib\\utils\\apiKeyMask.test.ts ===": "",
-9
View File
@@ -23,15 +23,6 @@
- `provider.ts` - API Key Provider 系统类型定义(Requirements 5.1)
- `errors/` - 错误处理模块
- `playwrightErrors.ts` - Playwright 登录错误处理(Requirements 5.1, 5.2, 5.3, 5.4)
- `plugin-ui/` - 插件 UI 系统(基于 A2UI 设计理念)
- `types.ts` - 类型定义
- `ComponentRegistry.ts` - 组件注册表
- `DataStore.ts` - 数据存储
- `SurfaceManager.ts` - Surface 管理器
- `PluginUIRenderer.tsx` - 核心渲染器
- `PluginUIContainer.tsx` - 容器组件
- `usePluginUI.ts` - React Hook
- `components/` - 标准组件实现
- `tauri/` - Tauri 命令封装
- `utils/` - 通用工具函数
- `apiKeyValidation.ts` - API Key 格式验证(Requirements 3.8)
-69
View File
@@ -1,69 +0,0 @@
/**
* 电商差评回复 API
*
* 封装电商差评回复相关的 Tauri 命令调用
*/
import { safeInvoke } from "@/lib/dev-bridge";
/**
* 电商差评回复请求参数
*/
export interface EcommerceReviewReplyRequest {
/** 电商平台 */
platform: "taobao" | "jd" | "pinduoduo";
/** 差评链接 */
reviewUrl: string;
/** 回复语气 */
tone: "polite" | "sincere" | "professional";
/** 回复长度 */
length: "short" | "medium" | "long";
/** 自定义模板 (可选) */
template?: string;
/** AI 模型 (可选) */
model?: string;
/** 执行 ID (可选) */
executionId?: string;
}
/**
* Skill 执行结果
*/
export interface SkillExecutionResult {
/** 是否成功 */
success: boolean;
/** 最终输出 */
output?: string;
/** 错误信息 */
error?: string;
/** 已完成的步骤结果 */
stepsCompleted: Array<{
stepId: string;
stepName: string;
success: boolean;
output?: string;
error?: string;
}>;
}
/**
* 电商差评回复 API
*/
export const ecommerceReviewReplyApi = {
/**
* 执行电商差评回复
*
* @param request - 请求参数
* @returns 执行结果
*/
async executeReviewReply(
request: EcommerceReviewReplyRequest,
): Promise<SkillExecutionResult> {
return safeInvoke(
"execute_ecommerce_review_reply",
request as unknown as Record<string, unknown>,
);
},
};
export default ecommerceReviewReplyApi;
-43
View File
@@ -1,43 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { safeInvoke } from "@/lib/dev-bridge";
import { getFeedbackStats, recordFeedback } from "./memoryFeedback";
vi.mock("@/lib/dev-bridge", () => ({
safeInvoke: vi.fn(),
}));
describe("memoryFeedback API", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("应代理反馈记录与统计查询", async () => {
vi.mocked(safeInvoke)
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce({
total: 10,
approve_count: 7,
reject_count: 2,
modify_count: 1,
approval_rate: 0.7,
});
await expect(
recordFeedback("memory-1", "approve", "session-1"),
).resolves.toBeUndefined();
await expect(getFeedbackStats("session-1")).resolves.toEqual(
expect.objectContaining({ total: 10, approval_rate: 0.7 }),
);
expect(safeInvoke).toHaveBeenNthCalledWith(1, "unified_memory_feedback", {
request: {
memory_id: "memory-1",
action: { type: "approve" },
session_id: "session-1",
},
});
expect(safeInvoke).toHaveBeenNthCalledWith(2, "get_memory_feedback_stats", {
session_id: "session-1",
});
});
});
-37
View File
@@ -1,37 +0,0 @@
import { safeInvoke } from "@/lib/dev-bridge";
export interface FeedbackRequest {
memory_id: string;
action: "approve" | "reject" | { type: "modify"; changes: string };
session_id: string;
}
export interface FeedbackStats {
total: number;
approve_count: number;
reject_count: number;
modify_count: number;
approval_rate: number;
}
export async function recordFeedback(
memoryId: string,
action: "approve" | "reject",
sessionId: string,
): Promise<void> {
return safeInvoke<void>("unified_memory_feedback", {
request: {
memory_id: memoryId,
action: { type: action },
session_id: sessionId,
},
});
}
export async function getFeedbackStats(
sessionId: string,
): Promise<FeedbackStats> {
return safeInvoke<FeedbackStats>("get_memory_feedback_stats", {
session_id: sessionId,
});
}
+48
View File
@@ -3,6 +3,8 @@ import {
clearSkillCatalogCache,
getSeededSkillCatalog,
getSkillCatalog,
listSkillCatalogCommandEntries,
listSkillCatalogSceneEntries,
saveSkillCatalog,
type SkillCatalog,
} from "./skillCatalog";
@@ -31,6 +33,7 @@ function buildLegacyCatalogWithSiteEntries(): SkillCatalog {
itemCount: 1,
},
],
entries: [],
items: [
{
...generalSkill,
@@ -114,4 +117,49 @@ describe("skillCatalog", () => {
const stored = window.localStorage.getItem("lime:skill-catalog:v1");
expect(stored).not.toContain("legacy-site-skill");
});
it("应从统一目录中暴露 command 与 scene 扩展入口", async () => {
const seeded = await getSkillCatalog();
expect(
listSkillCatalogCommandEntries(seeded).map((entry) => entry.commandKey),
).toEqual(
expect.arrayContaining([
"image_generate",
"cover_generate",
"video_generate",
"transcription_generate",
"url_parse",
]),
);
const remoteCatalog: SkillCatalog = {
...buildLegacyCatalogWithSiteEntries(),
entries: [
{
id: "scene:campaign-launch",
kind: "scene",
title: "新品发布场景",
summary: "把链接解析、配图与封面串成一个可复用场景。",
sceneKey: "campaign-launch",
commandPrefix: "/campaign-launch",
aliases: ["launch", "campaign"],
executionKind: "scene",
renderContract: {
resultKind: "tool_timeline",
detailKind: "scene_detail",
supportsStreaming: true,
supportsTimeline: true,
},
},
],
};
saveSkillCatalog(remoteCatalog, "bootstrap_sync");
const catalog = await getSkillCatalog();
expect(
listSkillCatalogSceneEntries(catalog).map((entry) => entry.sceneKey),
).toContain("campaign-launch");
});
});
+614
View File
@@ -8,6 +8,7 @@ import {
type ServiceSkillExecutorBinding,
type ServiceSkillItem,
type ServiceSkillSiteCapabilityBinding,
type ServiceSkillSurfaceScope,
} from "./serviceSkills";
export type SkillCatalogExecutionKind =
@@ -22,6 +23,85 @@ export interface SkillCatalogExecution {
siteAdapterBinding?: ServiceSkillSiteCapabilityBinding;
}
export type SkillCatalogEntryKind = "skill" | "command" | "scene";
export type SkillCatalogRenderResultKind =
| "text"
| "tool_timeline"
| "image_gallery"
| "artifact"
| "form"
| "table_report";
export type SkillCatalogRenderDetailKind =
| "json"
| "task_detail"
| "artifact_detail"
| "media_detail"
| "scene_detail";
export interface SkillCatalogRenderContract {
resultKind: SkillCatalogRenderResultKind;
detailKind: SkillCatalogRenderDetailKind;
supportsStreaming?: boolean;
supportsTimeline?: boolean;
}
export type SkillCatalogCommandTriggerMode = "mention" | "slash";
export interface SkillCatalogCommandTrigger {
mode: SkillCatalogCommandTriggerMode;
prefix: string;
}
export interface SkillCatalogSkillEntry {
id: string;
kind: "skill";
title: string;
summary: string;
skillId: string;
groupKey: string;
aliases?: string[];
surfaceScopes?: ServiceSkillSurfaceScope[];
execution: SkillCatalogExecution;
renderContract?: SkillCatalogRenderContract;
}
export interface SkillCatalogCommandEntry {
id: string;
kind: "command";
title: string;
summary: string;
commandKey: string;
aliases?: string[];
surfaceScopes?: ServiceSkillSurfaceScope[];
triggers: SkillCatalogCommandTrigger[];
binding?: {
skillId?: string;
executionKind?: SkillCatalogExecutionKind | "task_queue" | "server_api" | "cli";
};
renderContract?: SkillCatalogRenderContract;
}
export interface SkillCatalogSceneEntry {
id: string;
kind: "scene";
title: string;
summary: string;
sceneKey: string;
commandPrefix: string;
aliases?: string[];
surfaceScopes?: ServiceSkillSurfaceScope[];
linkedSkillId?: string;
executionKind?: SkillCatalogExecutionKind | "scene";
renderContract?: SkillCatalogRenderContract;
}
export type SkillCatalogEntry =
| SkillCatalogSkillEntry
| SkillCatalogCommandEntry
| SkillCatalogSceneEntry;
export interface SkillCatalogGroup {
key: string;
title: string;
@@ -43,6 +123,7 @@ export interface SkillCatalog {
syncedAt: string;
groups: SkillCatalogGroup[];
items: SkillCatalogItem[];
entries: SkillCatalogEntry[];
}
export type SkillCatalogChangeSource =
@@ -126,6 +207,134 @@ const SEEDED_SKILL_GROUP_PRESETS = [
},
] as const;
const SEEDED_SKILL_CATALOG_VERSION = "client-seed-skill-catalog-2026-04-04";
const SEEDED_COMMAND_ENTRY_DEFINITIONS = [
{
id: "command:image_generate",
title: "配图",
summary: "根据文字描述生成新的图片结果。",
commandKey: "image_generate",
aliases: ["image", "img", "图片", "生图"],
triggers: [{ mode: "mention", prefix: "@配图" }],
binding: {
skillId: "image_generate",
executionKind: "task_queue" as const,
},
renderContract: {
resultKind: "image_gallery" as const,
detailKind: "media_detail" as const,
supportsStreaming: true,
supportsTimeline: true,
},
},
{
id: "command:cover_generate",
title: "封面",
summary: "根据主题生成平台封面图任务。",
commandKey: "cover_generate",
aliases: ["cover", "fengmian", "封面", "封面图", "头图"],
triggers: [{ mode: "mention", prefix: "@封面" }],
binding: {
skillId: "cover_generate",
executionKind: "task_queue" as const,
},
renderContract: {
resultKind: "image_gallery" as const,
detailKind: "media_detail" as const,
supportsStreaming: true,
supportsTimeline: true,
},
},
{
id: "command:image_edit",
title: "修图",
summary: "编辑已有图片并生成新的结果图。",
commandKey: "image_edit",
aliases: ["edit", "xiutu", "修图", "改图", "图片编辑"],
triggers: [{ mode: "mention", prefix: "@修图" }],
binding: {
skillId: "image_generate",
executionKind: "task_queue" as const,
},
renderContract: {
resultKind: "image_gallery" as const,
detailKind: "media_detail" as const,
supportsStreaming: true,
supportsTimeline: true,
},
},
{
id: "command:image_variation",
title: "重绘",
summary: "基于已有图片或参考图继续重绘新的结果图。",
commandKey: "image_variation",
aliases: ["variation", "variant", "zhonghui", "重绘", "图片重绘", "变体"],
triggers: [{ mode: "mention", prefix: "@重绘" }],
binding: {
skillId: "image_generate",
executionKind: "task_queue" as const,
},
renderContract: {
resultKind: "image_gallery" as const,
detailKind: "media_detail" as const,
supportsStreaming: true,
supportsTimeline: true,
},
},
{
id: "command:video_generate",
title: "视频",
summary: "根据文字描述提交视频生成任务。",
commandKey: "video_generate",
aliases: ["video", "shipin", "视频", "短视频", "生成视频"],
triggers: [{ mode: "mention", prefix: "@视频" }],
binding: {
skillId: "video_generate",
executionKind: "task_queue" as const,
},
renderContract: {
resultKind: "tool_timeline" as const,
detailKind: "media_detail" as const,
supportsStreaming: true,
supportsTimeline: true,
},
},
{
id: "command:transcription_generate",
title: "转写",
summary: "把音频或视频来源提交为转写任务。",
commandKey: "transcription_generate",
aliases: ["transcribe", "zhuanxie", "转写", "逐字稿", "字幕", "语音转文字"],
triggers: [{ mode: "mention", prefix: "@转写" }],
binding: {
skillId: "transcription_generate",
executionKind: "task_queue" as const,
},
renderContract: {
resultKind: "artifact" as const,
detailKind: "artifact_detail" as const,
supportsStreaming: true,
supportsTimeline: true,
},
},
{
id: "command:url_parse",
title: "链接解析",
summary: "解析网页链接并提交为可追踪的文本任务。",
commandKey: "url_parse",
aliases: ["url", "url_parse", "链接", "链接解析", "网页读取", "网页解析"],
triggers: [{ mode: "mention", prefix: "@链接解析" }],
binding: {
skillId: "url_parse",
executionKind: "task_queue" as const,
},
renderContract: {
resultKind: "artifact" as const,
detailKind: "artifact_detail" as const,
supportsStreaming: true,
supportsTimeline: true,
},
},
] as const;
const SITE_GROUP_TITLE_OVERRIDES: Record<string, string> = {
github: "GitHub",
zhihu: "知乎",
@@ -154,6 +363,7 @@ function cloneSkillCatalog(catalog: SkillCatalog): SkillCatalog {
: undefined,
},
})),
entries: (catalog.entries || []).map((entry) => cloneJsonValue(entry)),
};
}
@@ -188,6 +398,226 @@ function resolveSkillCatalogExecutionKind(
}
}
function parseCommandBindingExecutionKind(
value: unknown,
):
| SkillCatalogExecutionKind
| "task_queue"
| "server_api"
| "cli"
| undefined {
const normalized = normalizeText(value);
if (
normalized === "native_skill" ||
normalized === "agent_turn" ||
normalized === "automation_job" ||
normalized === "cloud_scene" ||
normalized === "site_adapter" ||
normalized === "task_queue" ||
normalized === "server_api" ||
normalized === "cli"
) {
return normalized;
}
return undefined;
}
function parseSceneExecutionKind(
value: unknown,
): SkillCatalogSceneEntry["executionKind"] | undefined {
const normalized = normalizeText(value);
if (
normalized === "native_skill" ||
normalized === "agent_turn" ||
normalized === "automation_job" ||
normalized === "cloud_scene" ||
normalized === "site_adapter" ||
normalized === "scene"
) {
return normalized;
}
return undefined;
}
function normalizeSearchAliases(value: unknown): string[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const aliases = value
.map((item) => normalizeText(item))
.filter((item): item is string => Boolean(item));
return aliases.length > 0 ? aliases : undefined;
}
function normalizeSurfaceScopes(
value: unknown,
): ServiceSkillSurfaceScope[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const scopes = value.filter(
(item): item is ServiceSkillSurfaceScope =>
item === "home" || item === "mention" || item === "workspace",
);
return scopes.length > 0 ? scopes : undefined;
}
function parseRenderContract(value: unknown): SkillCatalogRenderContract | undefined {
if (!isPlainRecord(value)) {
return undefined;
}
const resultKind = normalizeText(value.resultKind);
const detailKind = normalizeText(value.detailKind);
if (
resultKind !== "text" &&
resultKind !== "tool_timeline" &&
resultKind !== "image_gallery" &&
resultKind !== "artifact" &&
resultKind !== "form" &&
resultKind !== "table_report"
) {
return undefined;
}
if (
detailKind !== "json" &&
detailKind !== "task_detail" &&
detailKind !== "artifact_detail" &&
detailKind !== "media_detail" &&
detailKind !== "scene_detail"
) {
return undefined;
}
return {
resultKind,
detailKind,
supportsStreaming:
typeof value.supportsStreaming === "boolean"
? value.supportsStreaming
: undefined,
supportsTimeline:
typeof value.supportsTimeline === "boolean"
? value.supportsTimeline
: undefined,
};
}
function buildSkillEntryFromCatalogItem(item: SkillCatalogItem): SkillCatalogSkillEntry {
return {
id: `skill:${item.id}`,
kind: "skill",
title: item.title,
summary: item.summary,
skillId: item.id,
groupKey: item.groupKey,
aliases: item.aliases,
surfaceScopes: item.surfaceScopes,
execution: cloneJsonValue(item.execution),
renderContract:
item.defaultArtifactKind === "table_report"
? {
resultKind: "table_report",
detailKind: "artifact_detail",
supportsStreaming: true,
}
: item.defaultArtifactKind
? {
resultKind: "artifact",
detailKind: "artifact_detail",
supportsStreaming: true,
}
: {
resultKind: "text",
detailKind: "json",
supportsStreaming: true,
},
};
}
function buildSceneEntryFromCatalogItem(
item: SkillCatalogItem,
): SkillCatalogSceneEntry | null {
if (item.execution.kind !== "cloud_scene") {
return null;
}
const sceneKey = normalizeText(item.skillKey) ?? normalizeText(item.id);
if (!sceneKey) {
return null;
}
return {
id: `scene:${sceneKey}`,
kind: "scene",
title: item.title,
summary: item.summary,
sceneKey,
commandPrefix: `/${sceneKey}`,
aliases: item.aliases,
surfaceScopes: item.surfaceScopes,
linkedSkillId: item.id,
executionKind: item.execution.kind,
renderContract: {
resultKind: "tool_timeline",
detailKind: "scene_detail",
supportsStreaming: true,
supportsTimeline: true,
},
};
}
function buildSeededCommandEntries(): SkillCatalogCommandEntry[] {
return SEEDED_COMMAND_ENTRY_DEFINITIONS.map((definition) => ({
id: definition.id,
kind: "command",
title: definition.title,
summary: definition.summary,
commandKey: definition.commandKey,
aliases: [...definition.aliases],
surfaceScopes: ["mention", "workspace"],
triggers: definition.triggers.map((trigger) => ({ ...trigger })),
binding: definition.binding ? { ...definition.binding } : undefined,
renderContract: definition.renderContract
? { ...definition.renderContract }
: undefined,
}));
}
function buildLegacyCatalogEntries(items: SkillCatalogItem[]): SkillCatalogEntry[] {
const entries: SkillCatalogEntry[] = [];
const seenIds = new Set<string>();
buildSeededCommandEntries().forEach((entry) => {
if (seenIds.has(entry.id)) {
return;
}
seenIds.add(entry.id);
entries.push(entry);
});
items.forEach((item) => {
const skillEntry = buildSkillEntryFromCatalogItem(item);
if (!seenIds.has(skillEntry.id)) {
seenIds.add(skillEntry.id);
entries.push(skillEntry);
}
const sceneEntry = buildSceneEntryFromCatalogItem(item);
if (sceneEntry && !seenIds.has(sceneEntry.id)) {
seenIds.add(sceneEntry.id);
entries.push(sceneEntry);
}
});
return entries;
}
function resolveSeededSkillGroupKey(item: ServiceSkillItem): string {
return resolveAdapterGroupKey(item.siteCapabilityBinding?.adapterName);
}
@@ -232,6 +662,7 @@ function buildSeededSkillCatalog(): SkillCatalog {
syncedAt: seeded.syncedAt,
groups,
items,
entries: buildLegacyCatalogEntries(items),
};
}
@@ -334,11 +765,27 @@ function shouldExposeSkillCatalogItem(item: SkillCatalogItem): boolean {
function normalizeSkillCatalog(catalog: SkillCatalog): SkillCatalog {
const filteredItems = catalog.items.filter(shouldExposeSkillCatalogItem);
const normalizedGroups = mergeCatalogGroups(catalog.groups, filteredItems);
const normalizedEntries =
catalog.entries.length > 0
? catalog.entries.filter((entry) => {
if (entry.kind === "skill") {
return filteredItems.some((item) => entry.skillId === item.id);
}
if (entry.kind === "scene") {
return (
!entry.linkedSkillId ||
filteredItems.some((item) => item.id === entry.linkedSkillId)
);
}
return true;
})
: buildLegacyCatalogEntries(filteredItems);
return cloneSkillCatalog({
...catalog,
groups: normalizedGroups,
items: filteredItems,
entries: normalizedEntries,
});
}
@@ -453,6 +900,122 @@ function parseSkillCatalogGroup(value: unknown): SkillCatalogGroup | null {
};
}
function parseSkillCatalogCommandTrigger(
value: unknown,
): SkillCatalogCommandTrigger | null {
if (!isPlainRecord(value)) {
return null;
}
const mode = normalizeText(value.mode);
const prefix = normalizeText(value.prefix);
if (!prefix || (mode !== "mention" && mode !== "slash")) {
return null;
}
return {
mode,
prefix,
};
}
function parseSkillCatalogEntry(value: unknown): SkillCatalogEntry | null {
if (!isPlainRecord(value)) {
return null;
}
const id = normalizeText(value.id);
const kind = normalizeText(value.kind);
const title = normalizeText(value.title);
const summary = normalizeText(value.summary);
if (!id || !kind || !title || !summary) {
return null;
}
if (kind === "command") {
const commandKey = normalizeText(value.commandKey);
const triggers = Array.isArray(value.triggers)
? value.triggers
.map((trigger) => parseSkillCatalogCommandTrigger(trigger))
.filter((trigger): trigger is SkillCatalogCommandTrigger =>
Boolean(trigger),
)
: [];
if (!commandKey || triggers.length === 0) {
return null;
}
const binding = isPlainRecord(value.binding)
? {
skillId: normalizeText(value.binding.skillId) ?? undefined,
executionKind: parseCommandBindingExecutionKind(
value.binding.executionKind,
),
}
: undefined;
return {
id,
kind: "command",
title,
summary,
commandKey,
aliases: normalizeSearchAliases(value.aliases),
surfaceScopes: normalizeSurfaceScopes(value.surfaceScopes),
triggers,
binding,
renderContract: parseRenderContract(value.renderContract),
};
}
if (kind === "scene") {
const sceneKey = normalizeText(value.sceneKey);
const commandPrefix = normalizeText(value.commandPrefix);
if (!sceneKey || !commandPrefix) {
return null;
}
return {
id,
kind: "scene",
title,
summary,
sceneKey,
commandPrefix,
aliases: normalizeSearchAliases(value.aliases),
surfaceScopes: normalizeSurfaceScopes(value.surfaceScopes),
linkedSkillId: normalizeText(value.linkedSkillId) ?? undefined,
executionKind: parseSceneExecutionKind(value.executionKind),
renderContract: parseRenderContract(value.renderContract),
};
}
if (kind === "skill") {
const skillId = normalizeText(value.skillId);
const groupKey = normalizeText(value.groupKey);
const execution = parseSkillCatalogExecution(value.execution);
if (!skillId || !groupKey || !execution) {
return null;
}
return {
id,
kind: "skill",
title,
summary,
skillId,
groupKey,
aliases: normalizeSearchAliases(value.aliases),
surfaceScopes: normalizeSurfaceScopes(value.surfaceScopes),
execution,
renderContract: parseRenderContract(value.renderContract),
};
}
return null;
}
export function parseSkillCatalog(value: unknown): SkillCatalog | null {
if (!isPlainRecord(value)) {
return null;
@@ -486,12 +1049,23 @@ export function parseSkillCatalog(value: unknown): SkillCatalog | null {
items.push(parsed);
}
const entriesSource = Array.isArray(value.entries) ? value.entries : [];
const entries: SkillCatalogEntry[] = [];
for (const entry of entriesSource) {
const parsed = parseSkillCatalogEntry(entry);
if (!parsed) {
return null;
}
entries.push(parsed);
}
return normalizeSkillCatalog({
version,
tenantId,
syncedAt,
groups,
items,
entries,
});
}
@@ -577,6 +1151,16 @@ function shouldRefreshSeededSkillCatalog(
}
}
for (const seededEntry of seeded.entries) {
const cachedEntry = cached.entries.find((entry) => entry.id === seededEntry.id);
if (
!cachedEntry ||
JSON.stringify(cachedEntry) !== JSON.stringify(seededEntry)
) {
return true;
}
}
return false;
}
@@ -630,6 +1214,36 @@ export function getSeededSkillCatalog(): SkillCatalog {
return cloneSkillCatalog(SEEDED_SKILL_CATALOG);
}
export function listSkillCatalogEntries(
catalog: SkillCatalog,
): SkillCatalogEntry[] {
return (catalog.entries || []).map((entry) => cloneJsonValue(entry));
}
export function listSkillCatalogCommandEntries(
catalog: SkillCatalog,
): SkillCatalogCommandEntry[] {
return listSkillCatalogEntries(catalog).filter(
(entry): entry is SkillCatalogCommandEntry => entry.kind === "command",
);
}
export function listSkillCatalogSceneEntries(
catalog: SkillCatalog,
): SkillCatalogSceneEntry[] {
return listSkillCatalogEntries(catalog).filter(
(entry): entry is SkillCatalogSceneEntry => entry.kind === "scene",
);
}
export function listSkillCatalogSkillEntries(
catalog: SkillCatalog,
): SkillCatalogSkillEntry[] {
return listSkillCatalogEntries(catalog).filter(
(entry): entry is SkillCatalogSkillEntry => entry.kind === "skill",
);
}
export function isSeededSkillCatalog(catalog: SkillCatalog): boolean {
return (
catalog.tenantId === SEEDED_SKILL_CATALOG.tenantId &&
+213 -5
View File
@@ -245,6 +245,20 @@
"targets": ["src/components/settings-v2/shared/language/LanguageSelector.tsx"],
"allowedPaths": []
},
{
"id": "experimental-banner-entry",
"classification": "dead-candidate",
"description": "已删除的旧 ExperimentalBanner 独立提示组件入口,当前前端主链不再暴露该实验提示壳",
"targets": ["src/components/ui/ExperimentalBanner.tsx"],
"allowedPaths": []
},
{
"id": "panel-layout-entry",
"classification": "dead-candidate",
"description": "已删除的旧分屏 PanelLayout 布局组件入口,当前工作区主链不再暴露该独立布局壳",
"targets": ["src/components/layout/PanelLayout.tsx"],
"allowedPaths": []
},
{
"id": "stores-root-barrel-entry",
"classification": "dead-candidate",
@@ -348,6 +362,141 @@
],
"allowedPaths": []
},
{
"id": "provider-pool-model-registry-tabs",
"classification": "dead-candidate",
"description": "已删除的 provider-pool 旧模型库 Tab 与增强模型列表页,当前前端主链不再暴露该独立模型浏览页面",
"targets": [
"src/components/provider-pool/EnhancedModelsTab.tsx",
"src/components/provider-pool/ModelRegistryTab.tsx"
],
"allowedPaths": []
},
{
"id": "provider-pool-standalone-credential-forms",
"classification": "dead-candidate",
"description": "已删除的 provider-pool 独立凭证表单与旧 barrel 入口,当前凭证池主链统一停留在 AddCredentialModal 内嵌表单",
"targets": [
"src/components/provider-pool/credential-forms/AntigravityFormStandalone.tsx",
"src/components/provider-pool/credential-forms/ClaudeFormStandalone.tsx",
"src/components/provider-pool/credential-forms/GeminiFormStandalone.tsx",
"src/components/provider-pool/credential-forms/KiroFormStandalone.tsx",
"src/components/provider-pool/credential-forms/index.ts"
],
"allowedPaths": []
},
{
"id": "agent-chat-fixed-backend-config-entry",
"classification": "dead-candidate",
"description": "已删除的 Agent Chat 固定后端 compat 配置壳,当前聊天运行时不再暴露旧后端切换心智",
"targets": ["src/components/agent/chat/config.ts"],
"allowedPaths": []
},
{
"id": "stable-processing-notice-entry",
"classification": "dead-candidate",
"description": "已删除的稳定处理中提示组件与 Hook,当前聊天主链不再暴露该独立短暂提示壳",
"targets": [
"src/components/agent/chat/components/StableProcessingNotice.tsx",
"src/components/agent/chat/hooks/useStableProcessingNotice.ts"
],
"allowedPaths": []
},
{
"id": "ui-radio-group-entry",
"classification": "dead-candidate",
"description": "已删除的零入口 RadioGroup UI primitive,当前前端主链不再暴露该独立单选组壳",
"targets": ["src/components/ui/radio-group.tsx"],
"allowedPaths": []
},
{
"id": "ui-separator-entry",
"classification": "dead-candidate",
"description": "已删除的零入口 Separator UI primitive,当前前端主链不再暴露该独立分隔线壳",
"targets": ["src/components/ui/separator.tsx"],
"allowedPaths": []
},
{
"id": "plugin-ui-frontend-runtime-surface",
"classification": "dead-candidate",
"description": "已删除的前端 plugin-ui 渲染链,当前插件主链只保留 pluginUI 元数据 API 与插件管理页,不再保留独立渲染器运行时",
"targets": [
"src/lib/plugin-ui/ComponentRegistry.ts",
"src/lib/plugin-ui/DataStore.ts",
"src/lib/plugin-ui/PluginUIContainer.tsx",
"src/lib/plugin-ui/PluginUIRenderer.tsx",
"src/lib/plugin-ui/README.md",
"src/lib/plugin-ui/SurfaceManager.test.ts",
"src/lib/plugin-ui/SurfaceManager.ts",
"src/lib/plugin-ui/components/README.md",
"src/lib/plugin-ui/components/data.tsx",
"src/lib/plugin-ui/components/display.tsx",
"src/lib/plugin-ui/components/index.ts",
"src/lib/plugin-ui/components/input.tsx",
"src/lib/plugin-ui/components/layout.tsx",
"src/lib/plugin-ui/components/registry.ts",
"src/lib/plugin-ui/index.ts",
"src/lib/plugin-ui/types.ts",
"src/lib/plugin-ui/usePluginUI.ts"
],
"allowedPaths": []
},
{
"id": "ui-alert-entry",
"classification": "dead-candidate",
"description": "已删除的零入口 Alert UI primitive,当前前端主链不再暴露该独立警示框壳",
"targets": ["src/components/ui/alert.tsx"],
"allowedPaths": []
},
{
"id": "websocket-status-widget-entry",
"classification": "dead-candidate",
"description": "已删除的旧 WebSocket 状态独立组件入口,当前前端主链不再暴露该独立状态面板",
"targets": ["src/components/websocket/WebSocketStatus.tsx"],
"allowedPaths": []
},
{
"id": "subagent-progress-display-surface",
"classification": "dead-candidate",
"description": "已删除的旧 SubAgent 独立进度展示组件与 barrel,当前 compat 主链不再暴露该单独展示壳",
"targets": [
"src/components/subagent/SubAgentProgress.tsx",
"src/components/subagent/index.ts"
],
"allowedPaths": []
},
{
"id": "openclaw-dashboard-frame-entry",
"classification": "dead-candidate",
"description": "已删除的旧 OpenClaw Dashboard 内嵌 frame 壳,当前主链已收口到独立 Dashboard 页面与外部打开模式",
"targets": ["src/components/openclaw/OpenClawDashboardFrame.tsx"],
"allowedPaths": []
},
{
"id": "ecommerce-review-reply-frontend-surface",
"classification": "dead-candidate",
"description": "已删除的电商差评回复旧前端页面与 API 网关,当前前端主链不再暴露该独立解决方案页",
"targets": [
"src/solutions/ecommerce-review-reply/GuideStep.tsx",
"src/solutions/ecommerce-review-reply/Results.tsx",
"src/solutions/ecommerce-review-reply/Tasks.tsx",
"src/solutions/ecommerce-review-reply/index.tsx",
"src/lib/api/ecommerce-review-reply.ts"
],
"allowedPaths": []
},
{
"id": "memory-feedback-frontend-surface",
"classification": "dead-candidate",
"description": "已删除的记忆反馈旧前端组件与 API 网关,当前记忆页面主链不再暴露该独立反馈侧链",
"targets": [
"src/components/memory/MemoryFeedback.tsx",
"src/components/memory/FeedbackStats.tsx",
"src/lib/api/memoryFeedback.ts",
"src/lib/api/memoryFeedback.test.ts"
],
"allowedPaths": []
},
{
"id": "legacy-questionnaire-a2ui-entry",
"classification": "dead-candidate",
@@ -872,11 +1021,7 @@
"classification": "compat",
"description": "旧 SubAgent scheduler Hook 只允许停留在 compat 展示层",
"targets": ["src/hooks/useSubAgentScheduler.ts"],
"allowedPaths": [
"src/components/agent/chat/hooks/useCompatSubagentRuntime.ts",
"src/components/subagent/SubAgentProgress.tsx",
"src/components/subagent/index.ts"
]
"allowedPaths": ["src/components/agent/chat/hooks/useCompatSubagentRuntime.ts"]
},
{
"id": "team-subagent-scheduler-api",
@@ -914,6 +1059,26 @@
"src/components/agent/chat/workspace/useWorkspaceA2UIRuntime.ts",
"src/components/agent/chat/workspace/useWorkspaceA2UISubmitActions.ts"
]
},
{
"id": "onboarding-legacy-plugin-install-surfaces",
"classification": "dead-candidate",
"description": "已删除的旧 onboarding 插件选择与安装进度步骤",
"targets": [
"src/components/onboarding/steps/PluginSelectStep.tsx",
"src/components/onboarding/steps/InstallProgressStep.tsx"
],
"allowedPaths": []
},
{
"id": "provider-switch-legacy-rust-modules",
"classification": "dead-candidate",
"description": "已删除的旧 Provider Switch Rust 命令与服务模块",
"targets": [
"src-tauri/src/commands/switch_cmd.rs",
"src-tauri/crates/services/src/switch.rs"
],
"allowedPaths": []
}
],
"commands": [
@@ -1209,6 +1374,24 @@
"description": "旧 execute_subagent_tasks/cancel_subagent_tasks 只允许通过 compat API 网关暴露",
"commands": ["execute_subagent_tasks", "cancel_subagent_tasks"],
"allowedPaths": ["src/lib/api/subAgentScheduler.ts"]
},
{
"id": "provider-switch-legacy-commands",
"classification": "dead-candidate",
"description": "已下线的旧 Provider Switch 命令边界",
"commands": [
"get_switch_providers",
"get_current_switch_provider",
"add_switch_provider",
"update_switch_provider",
"delete_switch_provider",
"switch_provider",
"import_default_config",
"read_live_provider_settings",
"check_config_sync_status",
"sync_from_external_config"
],
"allowedPaths": []
}
],
"frontendText": [
@@ -1275,6 +1458,19 @@
],
"allowedPaths": []
},
{
"id": "frontend-onboarding-legacy-plugin-install-surfaces",
"classification": "dead-candidate",
"description": "前端不允许恢复旧 onboarding 插件选择与 config-switch 推荐安装链路",
"patterns": [
"config-switch",
"onboardingPlugins",
"userProfiles",
"PluginSelectStep",
"InstallProgressStep"
],
"allowedPaths": []
},
{
"id": "settings-chat-appearance-legacy-tab",
"classification": "dead-candidate",
@@ -3068,6 +3264,18 @@
"patterns": ["\".lime/AGENT.md\"", "\".lime/instructions.md\""],
"includePathPrefixes": ["src-tauri/crates/agent/src/prompt"],
"allowedPaths": []
},
{
"id": "rust-provider-switch-legacy-surfaces",
"classification": "dead-candidate",
"description": "Rust 不允许恢复旧 Provider Switch 模块与辅助同步实现",
"patterns": [
"SwitchService",
"commands::switch_cmd::",
"pub mod switch_cmd;",
"pub mod switch;"
],
"allowedPaths": []
}
],
"rustTextCounts": [
@@ -161,6 +161,189 @@ describe("legacySurfaceCatalog", () => {
]);
});
it("应记录已删除的 provider-pool 旧模型库页面簇", () => {
const monitor = legacySurfaceCatalogJson.imports.find(
(entry) => entry.id === "provider-pool-model-registry-tabs",
);
expect(monitor).toBeTruthy();
expect(monitor?.classification).toBe("dead-candidate");
expect(monitor?.allowedPaths).toEqual([]);
expect(monitor?.targets).toEqual([
"src/components/provider-pool/EnhancedModelsTab.tsx",
"src/components/provider-pool/ModelRegistryTab.tsx",
]);
});
it("应记录已删除的 provider-pool 独立凭证表单与 barrel 入口", () => {
const monitor = legacySurfaceCatalogJson.imports.find(
(entry) => entry.id === "provider-pool-standalone-credential-forms",
);
expect(monitor).toBeTruthy();
expect(monitor?.classification).toBe("dead-candidate");
expect(monitor?.allowedPaths).toEqual([]);
expect(monitor?.targets).toEqual([
"src/components/provider-pool/credential-forms/AntigravityFormStandalone.tsx",
"src/components/provider-pool/credential-forms/ClaudeFormStandalone.tsx",
"src/components/provider-pool/credential-forms/GeminiFormStandalone.tsx",
"src/components/provider-pool/credential-forms/KiroFormStandalone.tsx",
"src/components/provider-pool/credential-forms/index.ts",
]);
});
it("应记录已删除的 Agent Chat 固定后端 compat 配置壳", () => {
const monitor = legacySurfaceCatalogJson.imports.find(
(entry) => entry.id === "agent-chat-fixed-backend-config-entry",
);
expect(monitor).toBeTruthy();
expect(monitor?.classification).toBe("dead-candidate");
expect(monitor?.allowedPaths).toEqual([]);
expect(monitor?.targets).toEqual(["src/components/agent/chat/config.ts"]);
});
it("应记录已删除的稳定处理中提示组件与 Hook", () => {
const monitor = legacySurfaceCatalogJson.imports.find(
(entry) => entry.id === "stable-processing-notice-entry",
);
expect(monitor).toBeTruthy();
expect(monitor?.classification).toBe("dead-candidate");
expect(monitor?.allowedPaths).toEqual([]);
expect(monitor?.targets).toEqual([
"src/components/agent/chat/components/StableProcessingNotice.tsx",
"src/components/agent/chat/hooks/useStableProcessingNotice.ts",
]);
});
it("应记录已删除的零入口 RadioGroup UI primitive", () => {
const monitor = legacySurfaceCatalogJson.imports.find(
(entry) => entry.id === "ui-radio-group-entry",
);
expect(monitor).toBeTruthy();
expect(monitor?.classification).toBe("dead-candidate");
expect(monitor?.allowedPaths).toEqual([]);
expect(monitor?.targets).toEqual(["src/components/ui/radio-group.tsx"]);
});
it("应记录已删除的零入口 Separator UI primitive", () => {
const monitor = legacySurfaceCatalogJson.imports.find(
(entry) => entry.id === "ui-separator-entry",
);
expect(monitor).toBeTruthy();
expect(monitor?.classification).toBe("dead-candidate");
expect(monitor?.allowedPaths).toEqual([]);
expect(monitor?.targets).toEqual(["src/components/ui/separator.tsx"]);
});
it("应记录已删除的前端 plugin-ui 渲染链", () => {
const monitor = legacySurfaceCatalogJson.imports.find(
(entry) => entry.id === "plugin-ui-frontend-runtime-surface",
);
expect(monitor).toBeTruthy();
expect(monitor?.classification).toBe("dead-candidate");
expect(monitor?.allowedPaths).toEqual([]);
expect(monitor?.targets).toEqual(
expect.arrayContaining([
"src/lib/plugin-ui/ComponentRegistry.ts",
"src/lib/plugin-ui/DataStore.ts",
"src/lib/plugin-ui/PluginUIContainer.tsx",
"src/lib/plugin-ui/PluginUIRenderer.tsx",
"src/lib/plugin-ui/SurfaceManager.ts",
"src/lib/plugin-ui/index.ts",
"src/lib/plugin-ui/usePluginUI.ts",
]),
);
});
it("应记录已删除的零入口 Alert UI primitive", () => {
const monitor = legacySurfaceCatalogJson.imports.find(
(entry) => entry.id === "ui-alert-entry",
);
expect(monitor).toBeTruthy();
expect(monitor?.classification).toBe("dead-candidate");
expect(monitor?.allowedPaths).toEqual([]);
expect(monitor?.targets).toEqual(["src/components/ui/alert.tsx"]);
});
it("应记录已删除的旧 WebSocket 状态组件入口", () => {
const monitor = legacySurfaceCatalogJson.imports.find(
(entry) => entry.id === "websocket-status-widget-entry",
);
expect(monitor).toBeTruthy();
expect(monitor?.classification).toBe("dead-candidate");
expect(monitor?.allowedPaths).toEqual([]);
expect(monitor?.targets).toEqual([
"src/components/websocket/WebSocketStatus.tsx",
]);
});
it("应记录已删除的旧 SubAgent 展示壳入口", () => {
const monitor = legacySurfaceCatalogJson.imports.find(
(entry) => entry.id === "subagent-progress-display-surface",
);
expect(monitor).toBeTruthy();
expect(monitor?.classification).toBe("dead-candidate");
expect(monitor?.allowedPaths).toEqual([]);
expect(monitor?.targets).toEqual([
"src/components/subagent/SubAgentProgress.tsx",
"src/components/subagent/index.ts",
]);
});
it("应记录已删除的 OpenClaw Dashboard 内嵌 frame 旧入口", () => {
const monitor = legacySurfaceCatalogJson.imports.find(
(entry) => entry.id === "openclaw-dashboard-frame-entry",
);
expect(monitor).toBeTruthy();
expect(monitor?.classification).toBe("dead-candidate");
expect(monitor?.allowedPaths).toEqual([]);
expect(monitor?.targets).toEqual([
"src/components/openclaw/OpenClawDashboardFrame.tsx",
]);
});
it("应记录已删除的电商差评回复旧前端页面与 API 网关", () => {
const monitor = legacySurfaceCatalogJson.imports.find(
(entry) => entry.id === "ecommerce-review-reply-frontend-surface",
);
expect(monitor).toBeTruthy();
expect(monitor?.classification).toBe("dead-candidate");
expect(monitor?.allowedPaths).toEqual([]);
expect(monitor?.targets).toEqual([
"src/solutions/ecommerce-review-reply/GuideStep.tsx",
"src/solutions/ecommerce-review-reply/Results.tsx",
"src/solutions/ecommerce-review-reply/Tasks.tsx",
"src/solutions/ecommerce-review-reply/index.tsx",
"src/lib/api/ecommerce-review-reply.ts",
]);
});
it("应记录已删除的记忆反馈旧前端侧链", () => {
const monitor = legacySurfaceCatalogJson.imports.find(
(entry) => entry.id === "memory-feedback-frontend-surface",
);
expect(monitor).toBeTruthy();
expect(monitor?.classification).toBe("dead-candidate");
expect(monitor?.allowedPaths).toEqual([]);
expect(monitor?.targets).toEqual([
"src/components/memory/MemoryFeedback.tsx",
"src/components/memory/FeedbackStats.tsx",
"src/lib/api/memoryFeedback.ts",
"src/lib/api/memoryFeedback.test.ts",
]);
});
it("应记录已删除的旧问卷 A2UI 模块命名入口", () => {
const monitor = legacySurfaceCatalogJson.imports.find(
(entry) => entry.id === "legacy-questionnaire-a2ui-entry",
@@ -260,6 +443,44 @@ describe("legacySurfaceCatalog", () => {
]);
});
it("应记录已删除的 ExperimentalBanner 旧提示组件入口", () => {
const monitor = legacySurfaceCatalogJson.imports.find(
(entry) => entry.id === "experimental-banner-entry",
);
expect(monitor).toBeTruthy();
expect(monitor?.classification).toBe("dead-candidate");
expect(monitor?.allowedPaths).toEqual([]);
expect(monitor?.targets).toEqual([
"src/components/ui/ExperimentalBanner.tsx",
]);
});
it("应记录已删除的 PanelLayout 旧分屏布局入口", () => {
const monitor = legacySurfaceCatalogJson.imports.find(
(entry) => entry.id === "panel-layout-entry",
);
expect(monitor).toBeTruthy();
expect(monitor?.classification).toBe("dead-candidate");
expect(monitor?.allowedPaths).toEqual([]);
expect(monitor?.targets).toEqual([
"src/components/layout/PanelLayout.tsx",
]);
});
it("应收窄旧 SubAgent scheduler Hook 的 compat 白名单", () => {
const monitor = legacySurfaceCatalogJson.imports.find(
(entry) => entry.id === "team-subagent-scheduler-hook",
);
expect(monitor).toBeTruthy();
expect(monitor?.classification).toBe("compat");
expect(monitor?.allowedPaths).toEqual([
"src/components/agent/chat/hooks/useCompatSubagentRuntime.ts",
]);
});
it("应限制 compat subagent runtime 桥的引用边界", () => {
const monitor = legacySurfaceCatalogJson.imports.find(
(entry) => entry.id === "team-subagent-runtime-compat-bridge",
-106
View File
@@ -1,106 +0,0 @@
/**
* @file 组件注册表
* @description 管理插件 UI 可用的组件类型,实现组件白名单机制
* @module lib/plugin-ui/ComponentRegistry
*/
import type {
ComponentRenderer,
ComponentRegistryEntry,
ComponentTypeName,
} from "./types";
/**
* 组件注册表
* 管理所有可用的 UI 组件,插件只能使用已注册的组件
*/
export class ComponentRegistry {
private registry: Map<string, ComponentRegistryEntry> = new Map();
/**
* 注册组件
* @param typeName - 组件类型名称
* @param renderer - 组件渲染器
* @param schema - 可选的组件属性 schema
*/
register(
typeName: ComponentTypeName | string,
renderer: ComponentRenderer,
schema?: Record<string, unknown>,
): void {
if (!/^[a-zA-Z][a-zA-Z0-9]*$/.test(typeName)) {
throw new Error(
`[ComponentRegistry] 无效的组件名称 '${typeName}',必须以字母开头且只包含字母数字`,
);
}
if (this.registry.has(typeName)) {
console.warn(`[ComponentRegistry] 组件 '${typeName}' 已存在,将被覆盖`);
}
this.registry.set(typeName, { renderer, schema });
}
/**
* 获取组件渲染器
* @param typeName - 组件类型名称
* @returns 组件渲染器,如果未注册则返回 undefined
*/
get(typeName: string): ComponentRenderer | undefined {
return this.registry.get(typeName)?.renderer;
}
/**
* 获取组件 schema
* @param typeName - 组件类型名称
* @returns 组件 schema
*/
getSchema(typeName: string): Record<string, unknown> | undefined {
return this.registry.get(typeName)?.schema;
}
/**
* 检查组件是否已注册
* @param typeName - 组件类型名称
*/
has(typeName: string): boolean {
return this.registry.has(typeName);
}
/**
* 获取所有已注册的组件类型名称
*/
getRegisteredTypes(): string[] {
return Array.from(this.registry.keys());
}
/**
* 批量注册组件
* @param components - 组件映射
*/
registerAll(components: Record<string, ComponentRenderer>): void {
for (const [typeName, renderer] of Object.entries(components)) {
this.register(typeName, renderer);
}
}
/**
* 注销组件
* @param typeName - 组件类型名称
*/
unregister(typeName: string): boolean {
return this.registry.delete(typeName);
}
/**
* 清空注册表
*/
clear(): void {
this.registry.clear();
}
}
/**
* 全局组件注册表实例
*/
export const componentRegistry = new ComponentRegistry();
-215
View File
@@ -1,215 +0,0 @@
/**
* @file 插件 UI 数据存储
* @description 管理 Surface 的数据模型,支持路径访问和更新
* @module lib/plugin-ui/DataStore
*/
import type { DataEntry, DataPath } from "./types";
/**
* 将 DataEntry 数组转换为普通对象
*/
export function dataEntriesToObject(
entries: DataEntry[],
): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const entry of entries) {
if (entry.valueString !== undefined) {
result[entry.key] = entry.valueString;
} else if (entry.valueNumber !== undefined) {
result[entry.key] = entry.valueNumber;
} else if (entry.valueBoolean !== undefined) {
result[entry.key] = entry.valueBoolean;
} else if (entry.valueArray !== undefined) {
result[entry.key] = entry.valueArray.map(
(item) => dataEntriesToObject([item])[item.key],
);
} else if (entry.valueMap !== undefined) {
result[entry.key] = dataEntriesToObject(entry.valueMap);
}
}
return result;
}
/**
* 解析数据路径
* @param path - JSONPath 格式路径,如 '/user/name' 或 'name'
* @returns 路径段数组
*/
export function parsePath(path: DataPath): string[] {
if (!path || path === "/") return [];
// 移除开头的 /
const normalized = path.startsWith("/") ? path.slice(1) : path;
return normalized.split("/").filter(Boolean);
}
/**
* 从数据模型中获取指定路径的值
* @param data - 数据模型
* @param path - 数据路径
* @returns 路径对应的值
*/
export function getValueByPath(
data: Record<string, unknown>,
path: DataPath,
): unknown {
const segments = parsePath(path);
let current: unknown = data;
for (const segment of segments) {
if (current === null || current === undefined) {
return undefined;
}
if (typeof current === "object") {
if (Array.isArray(current)) {
const index = parseInt(segment, 10);
if (isNaN(index)) return undefined;
current = current[index];
} else {
current = (current as Record<string, unknown>)[segment];
}
} else {
return undefined;
}
}
return current;
}
/**
* 在数据模型中设置指定路径的值
* @param data - 数据模型
* @param path - 数据路径
* @param value - 要设置的值
* @returns 更新后的数据模型(新对象)
*/
export function setValueByPath(
data: Record<string, unknown>,
path: DataPath,
value: unknown,
): Record<string, unknown> {
const segments = parsePath(path);
if (segments.length === 0) {
// 替换整个数据模型
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
return { ...(value as Record<string, unknown>) };
}
return data;
}
// 深拷贝并更新
const result = JSON.parse(JSON.stringify(data)) as Record<string, unknown>;
let current: Record<string, unknown> = result;
for (let i = 0; i < segments.length - 1; i++) {
const segment = segments[i];
const nextSegment = segments[i + 1];
const isNextArray = !isNaN(parseInt(nextSegment, 10));
if (current[segment] === undefined) {
current[segment] = isNextArray ? [] : {};
}
current = current[segment] as Record<string, unknown>;
}
const lastSegment = segments[segments.length - 1];
current[lastSegment] = value;
return result;
}
/**
* 合并数据更新到现有数据模型
* @param data - 现有数据模型
* @param path - 更新路径
* @param entries - 数据条目
* @returns 更新后的数据模型
*/
export function mergeDataUpdate(
data: Record<string, unknown>,
path: DataPath | undefined,
entries: DataEntry[],
): Record<string, unknown> {
const newData = dataEntriesToObject(entries);
if (!path || path === "/") {
// 合并到根
return { ...data, ...newData };
}
// 合并到指定路径
const existingValue = getValueByPath(data, path);
const mergedValue =
typeof existingValue === "object" && existingValue !== null
? { ...(existingValue as Record<string, unknown>), ...newData }
: newData;
return setValueByPath(data, path, mergedValue);
}
/**
* 数据存储类
* 管理单个 Surface 的数据模型
*/
export class DataStore {
private data: Record<string, unknown> = {};
private listeners: Set<(data: Record<string, unknown>) => void> = new Set();
/**
* 获取完整数据模型
*/
getData(): Record<string, unknown> {
return this.data;
}
/**
* 获取指定路径的值
*/
getValue(path: DataPath): unknown {
return getValueByPath(this.data, path);
}
/**
* 设置指定路径的值
*/
setValue(path: DataPath, value: unknown): void {
this.data = setValueByPath(this.data, path, value);
this.notifyListeners();
}
/**
* 应用数据更新
*/
applyUpdate(path: DataPath | undefined, entries: DataEntry[]): void {
this.data = mergeDataUpdate(this.data, path, entries);
this.notifyListeners();
}
/**
* 重置数据模型
*/
reset(initialData?: Record<string, unknown>): void {
this.data = initialData ?? {};
this.notifyListeners();
}
/**
* 订阅数据变化
*/
subscribe(listener: (data: Record<string, unknown>) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notifyListeners(): void {
for (const listener of this.listeners) {
listener(this.data);
}
}
}
-87
View File
@@ -1,87 +0,0 @@
/**
* @file 插件 UI 容器组件
* @description 封装插件 UI 渲染的完整容器,包含加载状态和错误处理
* @module lib/plugin-ui/PluginUIContainer
*/
import React from "react";
import { Loader2, AlertCircle, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { PluginUIRenderer } from "./PluginUIRenderer";
import { usePluginUI } from "./usePluginUI";
import type { PluginId } from "./types";
interface PluginUIContainerProps {
/** 插件 ID */
pluginId: PluginId;
/** 自定义类名 */
className?: string;
/** 空状态提示 */
emptyMessage?: string;
}
/**
* 插件 UI 容器
* 自动管理插件 UI 的加载、渲染和错误处理
*/
export const PluginUIContainer: React.FC<PluginUIContainerProps> = ({
pluginId,
className,
emptyMessage = "该插件没有提供 UI",
}) => {
const { surfaces, loading, error, handleAction, refresh } = usePluginUI({
pluginId,
});
// 加载状态
if (loading) {
return (
<div className={`flex items-center justify-center p-8 ${className}`}>
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">加载插件 UI...</span>
</div>
);
}
// 错误状态
if (error) {
return (
<div
className={`flex flex-col items-center justify-center p-8 ${className}`}
>
<AlertCircle className="h-8 w-8 text-red-500 mb-2" />
<p className="text-red-600 mb-4">{error}</p>
<Button variant="outline" size="sm" onClick={refresh}>
<RefreshCw className="h-4 w-4 mr-2" />
重试
</Button>
</div>
);
}
// 空状态
if (surfaces.length === 0) {
return (
<div
className={`flex items-center justify-center p-8 text-muted-foreground ${className}`}
>
{emptyMessage}
</div>
);
}
// 渲染所有 Surface
return (
<div className={`space-y-4 ${className}`}>
{surfaces.map((surface) => (
<PluginUIRenderer
key={surface.surfaceId}
surface={surface}
onAction={handleAction}
/>
))}
</div>
);
};
export default PluginUIContainer;
-258
View File
@@ -1,258 +0,0 @@
/**
* @file 插件 UI 渲染器
* @description 核心渲染组件,将声明式 UI 定义渲染为 React 组件
* @module lib/plugin-ui/PluginUIRenderer
*/
import React, { useCallback, useRef } from "react";
import { componentRegistry } from "./ComponentRegistry";
import { getValueByPath } from "./DataStore";
import type {
SurfaceState,
ComponentId,
ChildrenDef,
BoundValue,
UserAction,
ComponentRendererProps,
} from "./types";
interface PluginUIRendererProps {
/** Surface 状态 */
surface: SurfaceState;
/** 用户操作回调 */
onAction: (action: UserAction) => void;
/** 自定义类名 */
className?: string;
}
/**
* 插件 UI 渲染器
* 递归渲染 Surface 中的组件树
*/
export const PluginUIRenderer: React.FC<PluginUIRendererProps> = ({
surface,
onAction,
className,
}) => {
/**
* 解析绑定值
* @param bound - 绑定值定义
* @param itemData - 列表项数据(用于模板渲染)
*/
const resolveValue = useCallback(
<T,>(bound: BoundValue<T>, itemData?: unknown): T | undefined => {
if (!bound) return undefined;
// 字面值
if ("literalString" in bound && !("path" in bound)) {
return bound.literalString as T;
}
if ("literalNumber" in bound && !("path" in bound)) {
return bound.literalNumber as T;
}
if ("literalBoolean" in bound && !("path" in bound)) {
return bound.literalBoolean as T;
}
if ("literalArray" in bound && !("path" in bound)) {
return bound.literalArray as T;
}
// 路径绑定
if ("path" in bound) {
const path = bound.path as string;
// 相对路径(用于模板渲染)
if (itemData !== undefined && !path.startsWith("/")) {
if (typeof itemData === "object" && itemData !== null) {
return (itemData as Record<string, unknown>)[path] as T;
}
return itemData as T;
}
// 绝对路径
const value = getValueByPath(surface.dataModel, path);
if (value !== undefined) {
return value as T;
}
// 如果路径没有值,返回字面值作为默认值
if ("literalString" in bound) return bound.literalString as T;
if ("literalNumber" in bound) return bound.literalNumber as T;
if ("literalBoolean" in bound) return bound.literalBoolean as T;
}
return undefined;
},
[surface.dataModel],
);
/**
* 渲染子组件列表 - 使用 ref 解决循环依赖
*/
const renderChildrenRef =
useRef<(children: ChildrenDef, itemData?: unknown) => React.ReactNode[]>();
/**
* 渲染单个子组件
*/
const renderChild = useCallback(
(childId: ComponentId, itemData?: unknown): React.ReactNode => {
const childDef = surface.components.get(childId);
if (!childDef) {
console.warn(`[PluginUI] 组件 '${childId}' 未找到`);
return null;
}
return (
<ComponentRenderer
key={childId}
componentDef={childDef}
surface={surface}
onAction={onAction}
resolveValue={(bound) => resolveValue(bound, itemData)}
renderChild={(id) => renderChild(id, itemData)}
renderChildren={(children) =>
renderChildrenRef.current?.(children, itemData) ?? []
}
itemData={itemData}
/>
);
},
[surface, onAction, resolveValue],
);
/**
* 渲染子组件列表
*/
const renderChildren = useCallback(
(children: ChildrenDef, itemData?: unknown): React.ReactNode[] => {
// 显式列表
if (children.explicitList) {
return children.explicitList.map((childId) =>
renderChild(childId, itemData),
);
}
// 模板渲染
if (children.template) {
const { componentId, dataBinding } = children.template;
const listData = getValueByPath(surface.dataModel, dataBinding);
if (!Array.isArray(listData)) {
console.warn(`[PluginUI] 模板数据绑定 '${dataBinding}' 不是数组`);
return [];
}
return listData.map((item, index) => (
<React.Fragment key={index}>
{renderChild(componentId, item)}
</React.Fragment>
));
}
return [];
},
[surface.dataModel, renderChild],
);
// 更新 ref
renderChildrenRef.current = renderChildren;
// 如果 Surface 未就绪,显示加载状态
if (!surface.isReady || !surface.rootId) {
return (
<div className={className}>
<div className="flex items-center justify-center p-4 text-muted-foreground">
加载中...
</div>
</div>
);
}
// 渲染根组件
return (
<div className={className} style={getSurfaceStyles(surface)}>
{renderChild(surface.rootId)}
</div>
);
};
/**
* 获取 Surface 样式
*/
function getSurfaceStyles(surface: SurfaceState): React.CSSProperties {
const styles: Record<string, string | number> = {};
if (surface.styles.primaryColor) {
styles["--primary-color"] = surface.styles.primaryColor;
}
if (surface.styles.font) {
styles["fontFamily"] = surface.styles.font;
}
if (surface.styles.borderRadius !== undefined) {
styles["--border-radius"] = `${surface.styles.borderRadius}px`;
}
return styles as React.CSSProperties;
}
/**
* 单个组件渲染器
*/
interface ComponentRendererInternalProps
extends Omit<
ComponentRendererProps,
"resolveValue" | "renderChild" | "renderChildren"
> {
resolveValue: <T>(bound: BoundValue<T>) => T | undefined;
renderChild: (childId: ComponentId) => React.ReactNode;
renderChildren: (children: ChildrenDef) => React.ReactNode[];
itemData?: unknown;
}
const ComponentRenderer: React.FC<ComponentRendererInternalProps> = ({
componentDef,
surface,
onAction,
resolveValue,
renderChild,
renderChildren,
}) => {
// 获取组件类型
const componentType = Object.keys(componentDef.component)[0];
// 从注册表获取渲染器
const Renderer = componentRegistry.get(componentType);
if (!Renderer) {
console.warn(`[PluginUI] 未注册的组件类型: ${componentType}`);
return (
<div className="p-2 text-sm text-red-500 bg-red-50 rounded">
未知组件: {componentType}
</div>
);
}
// 应用 weight 样式
const style: React.CSSProperties = {};
if (componentDef.weight !== undefined) {
style.flexGrow = componentDef.weight;
}
return (
<div style={style}>
<Renderer
componentDef={componentDef}
surface={surface}
onAction={onAction}
resolveValue={resolveValue}
renderChild={renderChild}
renderChildren={renderChildren}
/>
</div>
);
};
export default PluginUIRenderer;
-53
View File
@@ -1,53 +0,0 @@
# plugin-ui
<!-- 一旦我所属的文件夹有所变化,请更新我 -->
## 架构说明
基于 A2UI 设计理念的声明式插件 UI 系统。
核心思想:
- **安全如数据,表达如代码**:插件只能声明 UI 结构,不能执行任意代码
- **声明式 JSON 格式**:插件通过 JSON 描述 UI 意图,宿主应用负责渲染
- **组件目录(Catalog)机制**:预定义可用组件集,插件只能使用目录中的组件
- **数据绑定分离**:UI 结构与数据模型分离,支持增量更新
## 文件索引
- `types.ts` - 类型定义(组件、消息、状态等)
- `ComponentRegistry.ts` - 组件注册表,管理可用组件白名单
- `DataStore.ts` - 数据存储,支持路径访问和更新
- `SurfaceManager.ts` - Surface 管理器,处理 UI 消息
- `PluginUIRenderer.tsx` - 核心渲染器组件
- `PluginUIContainer.tsx` - 封装容器,含加载/错误状态
- `usePluginUI.ts` - React Hook,管理插件 UI 状态
- `index.ts` - 导出入口
- `components/` - 标准组件实现
- `layout.tsx` - 布局组件(Row, Column, Card, Tabs)
- `display.tsx` - 展示组件(Text, Icon, Badge, Progress 等)
- `input.tsx` - 输入组件(Button, TextField, Switch, Select)
- `data.tsx` - 数据组件(List, KeyValue, Alert)
- `index.ts` - 组件导出
## 使用示例
```tsx
import { PluginUIContainer } from '@/lib/plugin-ui';
function PluginDetailPage({ pluginId }) {
return (
<div>
<h2>插件详情</h2>
<PluginUIContainer pluginId={pluginId} />
</div>
);
}
```
## 设计文档
详细设计见 `docs/plugin-ui-design.md`
## 更新提醒
任何文件变更后,请更新此文档和相关的上级文档。
-271
View File
@@ -1,271 +0,0 @@
/**
* @file SurfaceManager 属性测试
* @description 测试插件 UI Surface 注册一致性
* @module lib/plugin-ui/SurfaceManager.test
*
* **Feature: machine-id-plugin-migration, 属性 1: 插件 UI Surface 注册一致性**
* **Validates: Requirements 1.2, 3.1**
*/
import { describe, expect } from "vitest";
import { test } from "@fast-check/vitest";
import * as fc from "fast-check";
import { SurfaceManager } from "./SurfaceManager";
import type { ServerMessage } from "./types";
describe("SurfaceManager", () => {
/**
* 属性 1: 插件 UI Surface 注册一致性
*
* *对于任意*已安装的插件,如果其 manifest 包含 UI 配置,
* 则该插件必须出现在其声明的所有 surfaces 中。
*
* **Validates: Requirements 1.2, 3.1**
*/
describe("属性 1: 插件 UI Surface 注册一致性", () => {
// 生成有效的标识符(字母数字)
const identifierArb = fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9_-]{0,20}$/);
test.prop([identifierArb, identifierArb, identifierArb])(
"注册的 Surface 应该可以通过 pluginId 查询到",
(pluginId, surfaceId, rootId) => {
// 每次迭代创建新的 manager
const manager = new SurfaceManager();
// 发送 surfaceUpdate 创建 surface
const updateMessage: ServerMessage = {
surfaceUpdate: {
surfaceId,
components: [
{
id: rootId,
component: { Text: { text: { literalString: "test" } } },
},
],
},
};
manager.processMessage(pluginId, updateMessage);
// 发送 beginRendering 标记为就绪
const beginMessage: ServerMessage = {
beginRendering: {
surfaceId,
root: rootId,
},
};
manager.processMessage(pluginId, beginMessage);
// 验证 surface 可以通过 pluginId 查询到
const surfaces = manager.getSurfacesByPlugin(pluginId);
const found = surfaces.some((s) => s.surfaceId === surfaceId);
expect(found).toBe(true);
},
);
test.prop([identifierArb, identifierArb, identifierArb])(
"注册的 Surface 应该可以通过 surfaceId 直接获取",
(pluginId, surfaceId, rootId) => {
// 每次迭代创建新的 manager
const manager = new SurfaceManager();
// 发送 surfaceUpdate 创建 surface
const updateMessage: ServerMessage = {
surfaceUpdate: {
surfaceId,
components: [
{
id: rootId,
component: { Text: { text: { literalString: "test" } } },
},
],
},
};
manager.processMessage(pluginId, updateMessage);
// 验证 surface 可以直接获取
const surface = manager.getSurface(surfaceId);
expect(surface).toBeDefined();
expect(surface?.pluginId).toBe(pluginId);
expect(surface?.surfaceId).toBe(surfaceId);
},
);
test.prop([
identifierArb,
identifierArb,
fc.array(
fc.record({
id: identifierArb,
text: fc.string({ minLength: 0, maxLength: 100 }),
}),
{ minLength: 1, maxLength: 5 },
),
])("Surface 更新应该正确累积组件", (pluginId, surfaceId, components) => {
// 每次迭代创建新的 manager
const manager = new SurfaceManager();
// 逐个添加组件
for (const comp of components) {
const message: ServerMessage = {
surfaceUpdate: {
surfaceId,
components: [
{
id: comp.id,
component: { Text: { text: { literalString: comp.text } } },
},
],
},
};
manager.processMessage(pluginId, message);
}
// 验证所有组件都被注册
const surface = manager.getSurface(surfaceId);
expect(surface).toBeDefined();
for (const comp of components) {
expect(surface?.components.has(comp.id)).toBe(true);
}
});
test.prop([identifierArb, identifierArb, identifierArb])(
"删除 Surface 后应该无法查询到",
(pluginId, surfaceId, rootId) => {
// 每次迭代创建新的 manager
const manager = new SurfaceManager();
// 先注册 surface
const updateMessage: ServerMessage = {
surfaceUpdate: {
surfaceId,
components: [
{
id: rootId,
component: { Text: { text: { literalString: "test" } } },
},
],
},
};
manager.processMessage(pluginId, updateMessage);
// 验证 surface 存在
expect(manager.getSurface(surfaceId)).toBeDefined();
// 删除 surface
const deleteMessage: ServerMessage = {
deleteSurface: {
surfaceId,
},
};
manager.processMessage(pluginId, deleteMessage);
// 验证 surface 已被删除
expect(manager.getSurface(surfaceId)).toBeUndefined();
},
);
test.prop([identifierArb, fc.integer({ min: 1, max: 5 })])(
"清理插件时应该删除该插件的所有 Surface",
(pluginId, surfaceCount) => {
// 每次迭代创建新的 manager
const manager = new SurfaceManager();
const surfaceIds = Array.from(
{ length: surfaceCount },
(_, i) => `surface-${i}`,
);
// 为同一个插件注册多个 surfaces
for (const surfaceId of surfaceIds) {
const message: ServerMessage = {
surfaceUpdate: {
surfaceId,
components: [
{
id: "root",
component: { Text: { text: { literalString: "test" } } },
},
],
},
};
manager.processMessage(pluginId, message);
}
// 验证所有 surfaces 都存在
for (const surfaceId of surfaceIds) {
expect(manager.getSurface(surfaceId)).toBeDefined();
}
// 清理插件
manager.clearPlugin(pluginId);
// 验证所有 surfaces 都被删除
for (const surfaceId of surfaceIds) {
expect(manager.getSurface(surfaceId)).toBeUndefined();
}
},
);
test.prop([
identifierArb,
identifierArb,
fc.uniqueArray(identifierArb, { minLength: 1, maxLength: 3 }),
])("多个插件可以注册不同的 Surface", (pluginId1, pluginId2, surfaceIds) => {
// 假设两个不同的插件
fc.pre(pluginId1 !== pluginId2);
// 每次迭代创建新的 manager
const manager = new SurfaceManager();
// 为第一个插件注册 surfaces
for (let i = 0; i < surfaceIds.length; i++) {
const surfaceId = `${pluginId1}-${surfaceIds[i]}`;
const message: ServerMessage = {
surfaceUpdate: {
surfaceId,
components: [
{
id: "root",
component: { Text: { text: { literalString: "test" } } },
},
],
},
};
manager.processMessage(pluginId1, message);
}
// 为第二个插件注册 surfaces
for (let i = 0; i < surfaceIds.length; i++) {
const surfaceId = `${pluginId2}-${surfaceIds[i]}`;
const message: ServerMessage = {
surfaceUpdate: {
surfaceId,
components: [
{
id: "root",
component: { Text: { text: { literalString: "test" } } },
},
],
},
};
manager.processMessage(pluginId2, message);
}
// 验证每个插件只能查询到自己的 surfaces
const surfaces1 = manager.getSurfacesByPlugin(pluginId1);
const surfaces2 = manager.getSurfacesByPlugin(pluginId2);
expect(surfaces1.length).toBe(surfaceIds.length);
expect(surfaces2.length).toBe(surfaceIds.length);
// 验证 surfaces 属于正确的插件
for (const surface of surfaces1) {
expect(surface.pluginId).toBe(pluginId1);
}
for (const surface of surfaces2) {
expect(surface.pluginId).toBe(pluginId2);
}
});
});
});
-233
View File
@@ -1,233 +0,0 @@
/**
* @file Surface 管理器
* @description 管理插件的 UI Surface 状态
* @module lib/plugin-ui/SurfaceManager
*/
import type {
SurfaceId,
PluginId,
ComponentId,
ComponentDef,
SurfaceState,
SurfaceStyles,
ServerMessage,
DataEntry,
} from "./types";
import { DataStore } from "./DataStore";
/**
* Surface 管理器
* 管理所有插件的 UI Surface
*/
export class SurfaceManager {
private surfaces: Map<SurfaceId, SurfaceState> = new Map();
private dataStores: Map<SurfaceId, DataStore> = new Map();
private listeners: Set<(surfaces: Map<SurfaceId, SurfaceState>) => void> =
new Set();
/**
* 处理服务端消息
*/
processMessage(pluginId: PluginId, message: ServerMessage): void {
if ("surfaceUpdate" in message) {
this.handleSurfaceUpdate(
pluginId,
message.surfaceUpdate.surfaceId,
message.surfaceUpdate.components,
);
} else if ("dataModelUpdate" in message) {
this.handleDataModelUpdate(
message.dataModelUpdate.surfaceId,
message.dataModelUpdate.path,
message.dataModelUpdate.contents,
);
} else if ("beginRendering" in message) {
this.handleBeginRendering(
pluginId,
message.beginRendering.surfaceId,
message.beginRendering.root,
message.beginRendering.styles,
);
} else if ("deleteSurface" in message) {
this.handleDeleteSurface(message.deleteSurface.surfaceId);
}
}
/**
* 处理 Surface 更新
*/
private handleSurfaceUpdate(
pluginId: PluginId,
surfaceId: SurfaceId,
components: ComponentDef[],
): void {
let surface = this.surfaces.get(surfaceId);
if (!surface) {
// 创建新 Surface
surface = {
surfaceId,
pluginId,
rootId: null,
components: new Map(),
dataModel: {},
styles: {},
isReady: false,
};
this.surfaces.set(surfaceId, surface);
this.dataStores.set(surfaceId, new DataStore());
}
// 更新组件
for (const comp of components) {
surface.components.set(comp.id, comp);
}
this.notifyListeners();
}
/**
* 处理数据模型更新
*/
private handleDataModelUpdate(
surfaceId: SurfaceId,
path: string | undefined,
contents: DataEntry[],
): void {
const surface = this.surfaces.get(surfaceId);
const dataStore = this.dataStores.get(surfaceId);
if (!surface || !dataStore) {
console.warn(`[SurfaceManager] Surface '${surfaceId}' 不存在`);
return;
}
// 更新数据存储
dataStore.applyUpdate(path, contents);
// 同步到 surface 状态
surface.dataModel = dataStore.getData();
this.notifyListeners();
}
/**
* 处理开始渲染
*/
private handleBeginRendering(
pluginId: PluginId,
surfaceId: SurfaceId,
rootId: ComponentId,
styles?: SurfaceStyles,
): void {
let surface = this.surfaces.get(surfaceId);
if (!surface) {
// 如果 Surface 不存在,创建一个
surface = {
surfaceId,
pluginId,
rootId: null,
components: new Map(),
dataModel: {},
styles: {},
isReady: false,
};
this.surfaces.set(surfaceId, surface);
this.dataStores.set(surfaceId, new DataStore());
}
surface.rootId = rootId;
surface.isReady = true;
if (styles) {
surface.styles = { ...surface.styles, ...styles };
}
this.notifyListeners();
}
/**
* 处理删除 Surface
*/
private handleDeleteSurface(surfaceId: SurfaceId): void {
this.surfaces.delete(surfaceId);
this.dataStores.delete(surfaceId);
this.notifyListeners();
}
/**
* 获取 Surface 状态
*/
getSurface(surfaceId: SurfaceId): SurfaceState | undefined {
return this.surfaces.get(surfaceId);
}
/**
* 获取插件的所有 Surface
*/
getSurfacesByPlugin(pluginId: PluginId): SurfaceState[] {
return Array.from(this.surfaces.values()).filter(
(s) => s.pluginId === pluginId,
);
}
/**
* 获取所有 Surface
*/
getAllSurfaces(): Map<SurfaceId, SurfaceState> {
return new Map(this.surfaces);
}
/**
* 获取 Surface 的数据存储
*/
getDataStore(surfaceId: SurfaceId): DataStore | undefined {
return this.dataStores.get(surfaceId);
}
/**
* 订阅 Surface 变化
*/
subscribe(
listener: (surfaces: Map<SurfaceId, SurfaceState>) => void,
): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
/**
* 清理插件的所有 Surface
*/
clearPlugin(pluginId: PluginId): void {
for (const [surfaceId, surface] of this.surfaces) {
if (surface.pluginId === pluginId) {
this.surfaces.delete(surfaceId);
this.dataStores.delete(surfaceId);
}
}
this.notifyListeners();
}
/**
* 清理所有 Surface
*/
clear(): void {
this.surfaces.clear();
this.dataStores.clear();
this.notifyListeners();
}
private notifyListeners(): void {
const snapshot = new Map(this.surfaces);
for (const listener of this.listeners) {
listener(snapshot);
}
}
}
/**
* 全局 Surface 管理器实例
*/
export const surfaceManager = new SurfaceManager();
-48
View File
@@ -1,48 +0,0 @@
# components
<!-- 一旦我所属的文件夹有所变化,请更新我 -->
## 架构说明
插件 UI 标准组件实现。
所有组件基于 shadcn/ui 和 TailwindCSS 构建,提供一致的视觉风格。
## 文件索引
- `layout.tsx` - 布局组件
- `Row` - 水平布局
- `Column` - 垂直布局
- `Card` - 卡片容器
- `Tabs` - 标签页
- `display.tsx` - 展示组件
- `Text` - 文本
- `Icon` - 图标
- `Badge` - 徽章
- `Progress` - 进度条
- `Spinner` - 加载指示器
- `Empty` - 空状态
- `Divider` - 分隔线
- `input.tsx` - 输入组件
- `Button` - 按钮
- `TextField` - 文本输入
- `Switch` - 开关
- `Select` - 下拉选择
- `data.tsx` - 数据展示组件
- `List` - 列表(支持模板渲染)
- `KeyValue` - 键值对
- `Alert` - 提示框
- `index.ts` - 导出入口
## 扩展组件
可通过 `componentRegistry.register()` 注册自定义组件:
```tsx
import { componentRegistry } from '@/lib/plugin-ui';
componentRegistry.register('CustomCard', CustomCardRenderer);
```
## 更新提醒
任何文件变更后,请更新此文档和相关的上级文档。
-134
View File
@@ -1,134 +0,0 @@
/**
* @file 数据展示组件
* @description List, KeyValue, Alert 等数据展示组件实现
* @module lib/plugin-ui/components/data
*/
import React from "react";
import { cn } from "@/lib/utils";
import {
Alert as UIAlert,
AlertDescription,
AlertTitle,
} from "@/components/ui/alert";
import { Info, CheckCircle, AlertTriangle, AlertCircle } from "lucide-react";
import type {
ComponentRendererProps,
ListComponent,
KeyValueComponent,
AlertComponent,
AlertType,
Alignment,
} from "../types";
/**
* 对齐方式映射
*/
const alignmentMap: Record<Alignment, string> = {
start: "items-start",
center: "items-center",
end: "items-end",
stretch: "items-stretch",
};
/**
* List 组件 - 列表容器,支持模板渲染
*/
export const ListRenderer: React.FC<ComponentRendererProps> = ({
componentDef,
renderChildren,
}) => {
const props = (componentDef.component as ListComponent).List;
const {
children,
direction = "vertical",
alignment = "stretch",
gap = 8,
} = props;
const isHorizontal = direction === "horizontal";
return (
<div
className={cn(
"flex",
isHorizontal ? "flex-row flex-wrap" : "flex-col",
alignmentMap[alignment],
)}
style={{ gap: `${gap}px` }}
>
{renderChildren(children)}
</div>
);
};
/**
* KeyValue 组件 - 键值对展示
*/
export const KeyValueRenderer: React.FC<ComponentRendererProps> = ({
componentDef,
resolveValue,
}) => {
const props = (componentDef.component as KeyValueComponent).KeyValue;
const { items } = props;
return (
<div className="space-y-2">
{items.map((item, index) => {
const keyText = resolveValue(item.key) || "";
const valueText = resolveValue(item.value) || "";
return (
<div key={index} className="flex justify-between items-center py-1">
<span className="text-sm text-muted-foreground">{keyText}</span>
<span className="text-sm font-medium">{valueText}</span>
</div>
);
})}
</div>
);
};
/**
* Alert 图标映射
*/
const alertIconMap: Record<AlertType, React.FC<{ className?: string }>> = {
info: Info,
success: CheckCircle,
warning: AlertTriangle,
error: AlertCircle,
};
/**
* Alert 样式映射
*/
const alertStyleMap: Record<AlertType, string> = {
info: "border-blue-200 bg-blue-50 text-blue-800 [&>svg]:text-blue-500",
success: "border-green-200 bg-green-50 text-green-800 [&>svg]:text-green-500",
warning:
"border-yellow-200 bg-yellow-50 text-yellow-800 [&>svg]:text-yellow-500",
error: "border-red-200 bg-red-50 text-red-800 [&>svg]:text-red-500",
};
/**
* Alert 组件
*/
export const AlertRenderer: React.FC<ComponentRendererProps> = ({
componentDef,
resolveValue,
}) => {
const props = (componentDef.component as AlertComponent).Alert;
const { message, type, title } = props;
const messageText = resolveValue(message) || "";
const titleText = title ? resolveValue(title) : undefined;
const IconComponent = alertIconMap[type];
return (
<UIAlert className={alertStyleMap[type]}>
<IconComponent className="h-4 w-4" />
{titleText && <AlertTitle>{titleText}</AlertTitle>}
<AlertDescription>{messageText}</AlertDescription>
</UIAlert>
);
};
-259
View File
@@ -1,259 +0,0 @@
/**
* @file 展示组件
* @description Text, Icon, Badge, Progress 等展示组件实现
* @module lib/plugin-ui/components/display
*/
import React from "react";
import { cn } from "@/lib/utils";
import { Badge as UIBadge } from "@/components/ui/badge";
import { Progress as UIProgress } from "@/components/ui/progress";
import {
Plus,
Check,
X,
Trash2,
Edit,
RefreshCw,
Search,
Settings,
Info,
AlertTriangle,
AlertCircle,
ChevronDown,
ChevronUp,
ChevronLeft,
ChevronRight,
Power,
PowerOff,
Folder,
File,
Copy,
Play,
Pause,
Square,
Download,
Upload,
Loader2,
type LucideIcon,
} from "lucide-react";
import type {
ComponentRendererProps,
TextComponent,
IconComponent,
BadgeComponent,
ProgressComponent,
SpinnerComponent,
EmptyComponent,
DividerComponent,
TextVariant,
BadgeVariant,
IconName,
} from "../types";
/**
* 文本变体样式映射
*/
const textVariantStyles: Record<TextVariant, string> = {
h1: "text-4xl font-bold",
h2: "text-3xl font-semibold",
h3: "text-2xl font-semibold",
h4: "text-xl font-medium",
h5: "text-lg font-medium",
body: "text-base",
caption: "text-sm text-muted-foreground",
};
/**
* Text 组件
*/
export const TextRenderer: React.FC<ComponentRendererProps> = ({
componentDef,
resolveValue,
}) => {
const props = (componentDef.component as TextComponent).Text;
const { text, variant = "body" } = props;
const textContent = resolveValue(text) || "";
return <span className={textVariantStyles[variant]}>{textContent}</span>;
};
/**
* 图标映射
*/
const iconMap: Record<IconName, LucideIcon> = {
add: Plus,
check: Check,
close: X,
delete: Trash2,
edit: Edit,
refresh: RefreshCw,
search: Search,
settings: Settings,
info: Info,
warning: AlertTriangle,
error: AlertCircle,
chevronDown: ChevronDown,
chevronUp: ChevronUp,
chevronLeft: ChevronLeft,
chevronRight: ChevronRight,
power: Power,
powerOff: PowerOff,
folder: Folder,
file: File,
copy: Copy,
play: Play,
pause: Pause,
stop: Square,
download: Download,
upload: Upload,
};
/**
* Icon 组件
*/
export const IconRenderer: React.FC<ComponentRendererProps> = ({
componentDef,
resolveValue,
}) => {
const props = (componentDef.component as IconComponent).Icon;
const { name, size = 16, color } = props;
const iconName = resolveValue(name) as IconName;
const IconComponent = iconMap[iconName];
if (!IconComponent) {
console.warn(`[PluginUI] 未知图标: ${iconName}`);
return null;
}
return (
<IconComponent className={cn("inline-block")} size={size} color={color} />
);
};
/**
* Badge 变体样式映射
*/
const badgeVariantMap: Record<
BadgeVariant,
"default" | "secondary" | "destructive" | "outline"
> = {
default: "default",
success: "default",
warning: "secondary",
error: "destructive",
info: "outline",
};
const badgeColorMap: Record<BadgeVariant, string> = {
default: "",
success: "bg-green-100 text-green-800 hover:bg-green-100",
warning: "bg-yellow-100 text-yellow-800 hover:bg-yellow-100",
error: "",
info: "bg-blue-100 text-blue-800 hover:bg-blue-100",
};
/**
* Badge 组件
*/
export const BadgeRenderer: React.FC<ComponentRendererProps> = ({
componentDef,
resolveValue,
}) => {
const props = (componentDef.component as BadgeComponent).Badge;
const { text, variant } = props;
const textContent = resolveValue(text) || "";
const variantValue = (
variant ? resolveValue(variant) : "default"
) as BadgeVariant;
return (
<UIBadge
variant={badgeVariantMap[variantValue]}
className={badgeColorMap[variantValue]}
>
{textContent}
</UIBadge>
);
};
/**
* Progress 组件
*/
export const ProgressRenderer: React.FC<ComponentRendererProps> = ({
componentDef,
resolveValue,
}) => {
const props = (componentDef.component as ProgressComponent).Progress;
const { value, max = 100 } = props;
const numValue = resolveValue(value) || 0;
const percentage = (numValue / max) * 100;
return <UIProgress value={percentage} className="w-full" />;
};
/**
* Spinner 组件
*/
export const SpinnerRenderer: React.FC<ComponentRendererProps> = ({
componentDef,
}) => {
const props = (componentDef.component as SpinnerComponent).Spinner;
const { size = 24 } = props;
return (
<Loader2
className="animate-spin text-muted-foreground"
style={{ width: size, height: size }}
/>
);
};
/**
* Empty 组件
*/
export const EmptyRenderer: React.FC<ComponentRendererProps> = ({
componentDef,
resolveValue,
}) => {
const props = (componentDef.component as EmptyComponent).Empty;
const { icon, title, description } = props;
const IconComponent = icon ? iconMap[icon] : null;
const titleText = title ? resolveValue(title) : undefined;
const descText = description ? resolveValue(description) : undefined;
return (
<div className="flex flex-col items-center justify-center py-8 text-center">
{IconComponent && (
<IconComponent className="h-12 w-12 text-muted-foreground/50 mb-4" />
)}
{titleText && (
<p className="text-lg font-medium text-muted-foreground">{titleText}</p>
)}
{descText && (
<p className="text-sm text-muted-foreground/70 mt-1">{descText}</p>
)}
</div>
);
};
/**
* Divider 组件
*/
export const DividerRenderer: React.FC<ComponentRendererProps> = ({
componentDef,
}) => {
const props = (componentDef.component as DividerComponent).Divider;
const { axis = "horizontal" } = props;
if (axis === "vertical") {
return <div className="w-px h-full bg-border" />;
}
return <div className="h-px w-full bg-border" />;
};
-30
View File
@@ -1,30 +0,0 @@
/**
* @file 组件导出入口
* @description 导出所有标准组件并注册到组件注册表
* @module lib/plugin-ui/components
*/
import { componentRegistry as globalRegistry } from "../ComponentRegistry";
import { componentRegistry as standardComponents } from "./registry";
/**
* 注册所有标准组件到全局注册表
*/
export function registerStandardComponents(): void {
globalRegistry.registerAll(standardComponents);
}
// 导出各类组件
export {
layoutComponents,
displayComponents,
inputComponents,
dataComponents,
componentRegistry as standardComponents,
} from "./registry";
// 导出各个渲染器
export * from "./layout";
export * from "./display";
export * from "./input";
export * from "./data";
-264
View File
@@ -1,264 +0,0 @@
/**
* @file 输入组件
* @description Button, TextField, Switch, Select 等输入组件实现
* @module lib/plugin-ui/components/input
*/
import React, { useCallback } from "react";
import { Button as UIButton } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch as UISwitch } from "@/components/ui/switch";
import {
Select as UISelect,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type {
ComponentRendererProps,
ButtonComponent,
TextFieldComponent,
SwitchComponent,
SelectComponent,
UserAction,
ButtonVariant,
} from "../types";
/**
* 按钮变体映射
*/
const buttonVariantMap: Record<
ButtonVariant,
"default" | "destructive" | "outline" | "secondary" | "ghost"
> = {
default: "default",
primary: "default",
secondary: "secondary",
destructive: "destructive",
outline: "outline",
ghost: "ghost",
};
/**
* Button 组件
*/
export const ButtonRenderer: React.FC<ComponentRendererProps> = ({
componentDef,
surface,
onAction,
resolveValue,
renderChild,
}) => {
const props = (componentDef.component as ButtonComponent).Button;
const { child, action, variant = "default", disabled } = props;
const isDisabled = disabled ? resolveValue(disabled) : false;
const handleClick = useCallback(() => {
// 解析 action context
const context: Record<string, unknown> = {};
if (action.context) {
for (const item of action.context) {
context[item.key] = resolveValue(item.value);
}
}
const userAction: UserAction = {
name: action.name,
surfaceId: surface.surfaceId,
sourceComponentId: componentDef.id,
context,
timestamp: new Date().toISOString(),
};
onAction(userAction);
}, [action, surface.surfaceId, componentDef.id, onAction, resolveValue]);
return (
<UIButton
variant={buttonVariantMap[variant]}
disabled={isDisabled}
onClick={handleClick}
>
{renderChild(child)}
</UIButton>
);
};
/**
* TextField 组件
*/
export const TextFieldRenderer: React.FC<ComponentRendererProps> = ({
componentDef,
surface,
onAction,
resolveValue,
}) => {
const props = (componentDef.component as TextFieldComponent).TextField;
const { label, value, placeholder, type = "text", disabled } = props;
const labelText = resolveValue(label) || "";
const valueText = value ? resolveValue(value) || "" : "";
const placeholderText = placeholder ? resolveValue(placeholder) : undefined;
const isDisabled = disabled ? resolveValue(disabled) : false;
// 获取绑定路径用于更新
const valuePath = value && "path" in value ? value.path : null;
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (!valuePath) return;
const userAction: UserAction = {
name: "__data_update__",
surfaceId: surface.surfaceId,
sourceComponentId: componentDef.id,
context: {
path: valuePath,
value: e.target.value,
},
timestamp: new Date().toISOString(),
};
onAction(userAction);
},
[valuePath, surface.surfaceId, componentDef.id, onAction],
);
const inputId = `input-${componentDef.id}`;
return (
<div className="space-y-2">
<Label htmlFor={inputId}>{labelText}</Label>
<Input
id={inputId}
type={type}
value={valueText}
placeholder={placeholderText}
disabled={isDisabled}
onChange={handleChange}
/>
</div>
);
};
/**
* Switch 组件
*/
export const SwitchRenderer: React.FC<ComponentRendererProps> = ({
componentDef,
surface,
onAction,
resolveValue,
}) => {
const props = (componentDef.component as SwitchComponent).Switch;
const { label, checked, disabled } = props;
const labelText = resolveValue(label) || "";
const isChecked = resolveValue(checked) || false;
const isDisabled = disabled ? resolveValue(disabled) : false;
// 获取绑定路径用于更新
const checkedPath = "path" in checked ? checked.path : null;
const handleChange = useCallback(
(newChecked: boolean) => {
if (!checkedPath) return;
const userAction: UserAction = {
name: "__data_update__",
surfaceId: surface.surfaceId,
sourceComponentId: componentDef.id,
context: {
path: checkedPath,
value: newChecked,
},
timestamp: new Date().toISOString(),
};
onAction(userAction);
},
[checkedPath, surface.surfaceId, componentDef.id, onAction],
);
const switchId = `switch-${componentDef.id}`;
return (
<div className="flex items-center space-x-2">
<UISwitch
id={switchId}
checked={isChecked}
disabled={isDisabled}
onCheckedChange={handleChange}
/>
<Label htmlFor={switchId}>{labelText}</Label>
</div>
);
};
/**
* Select 组件
*/
export const SelectRenderer: React.FC<ComponentRendererProps> = ({
componentDef,
surface,
onAction,
resolveValue,
}) => {
const props = (componentDef.component as SelectComponent).Select;
const { label, value, options, disabled } = props;
const labelText = label ? resolveValue(label) : undefined;
const selectedValue = resolveValue(value) || "";
const isDisabled = disabled ? resolveValue(disabled) : false;
// 获取绑定路径用于更新
const valuePath = "path" in value ? value.path : null;
const handleChange = useCallback(
(newValue: string) => {
if (!valuePath) return;
const userAction: UserAction = {
name: "__data_update__",
surfaceId: surface.surfaceId,
sourceComponentId: componentDef.id,
context: {
path: valuePath,
value: newValue,
},
timestamp: new Date().toISOString(),
};
onAction(userAction);
},
[valuePath, surface.surfaceId, componentDef.id, onAction],
);
return (
<div className="space-y-2">
{labelText && <Label>{labelText}</Label>}
<UISelect
value={selectedValue}
disabled={isDisabled}
onValueChange={handleChange}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((option, index) => {
const optionLabel = resolveValue(option.label) || option.value;
return (
<SelectItem key={index} value={option.value}>
{optionLabel}
</SelectItem>
);
})}
</SelectContent>
</UISelect>
</div>
);
};
-175
View File
@@ -1,175 +0,0 @@
/**
* @file 布局组件
* @description Row, Column, Card, Tabs 等布局组件实现
* @module lib/plugin-ui/components/layout
*/
import React from "react";
import { cn } from "@/lib/utils";
import {
Card as UICard,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import {
Tabs as UITabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import type {
ComponentRendererProps,
RowComponent,
ColumnComponent,
CardComponent,
TabsComponent,
Distribution,
Alignment,
} from "../types";
/**
* 分布方式映射到 CSS justify-content
*/
const distributionMap: Record<Distribution, string> = {
start: "justify-start",
center: "justify-center",
end: "justify-end",
spaceBetween: "justify-between",
spaceAround: "justify-around",
spaceEvenly: "justify-evenly",
};
/**
* 对齐方式映射到 CSS align-items
*/
const alignmentMap: Record<Alignment, string> = {
start: "items-start",
center: "items-center",
end: "items-end",
stretch: "items-stretch",
};
/**
* Row 组件 - 水平布局
*/
export const RowRenderer: React.FC<ComponentRendererProps> = ({
componentDef,
renderChildren,
}) => {
const props = (componentDef.component as RowComponent).Row;
const {
children,
distribution = "start",
alignment = "center",
gap = 8,
} = props;
return (
<div
className={cn(
"flex flex-row",
distributionMap[distribution],
alignmentMap[alignment],
)}
style={{ gap: `${gap}px` }}
>
{renderChildren(children)}
</div>
);
};
/**
* Column 组件 - 垂直布局
*/
export const ColumnRenderer: React.FC<ComponentRendererProps> = ({
componentDef,
renderChildren,
}) => {
const props = (componentDef.component as ColumnComponent).Column;
const {
children,
distribution = "start",
alignment = "stretch",
gap = 8,
} = props;
return (
<div
className={cn(
"flex flex-col",
distributionMap[distribution],
alignmentMap[alignment],
)}
style={{ gap: `${gap}px` }}
>
{renderChildren(children)}
</div>
);
};
/**
* Card 组件 - 卡片容器
*/
export const CardRenderer: React.FC<ComponentRendererProps> = ({
componentDef,
resolveValue,
renderChild,
}) => {
const props = (componentDef.component as CardComponent).Card;
const { child, title, description } = props;
const titleText = title ? resolveValue(title) : undefined;
const descText = description ? resolveValue(description) : undefined;
const hasHeader = titleText || descText;
return (
<UICard>
{hasHeader && (
<CardHeader>
{titleText && <CardTitle>{titleText}</CardTitle>}
{descText && <CardDescription>{descText}</CardDescription>}
</CardHeader>
)}
<CardContent className={hasHeader ? "" : "pt-6"}>
{renderChild(child)}
</CardContent>
</UICard>
);
};
/**
* Tabs 组件 - 标签页
*/
export const TabsRenderer: React.FC<ComponentRendererProps> = ({
componentDef,
resolveValue,
renderChild,
}) => {
const props = (componentDef.component as TabsComponent).Tabs;
const { items, defaultTab } = props;
const defaultValue = defaultTab || items[0]?.id;
return (
<UITabs defaultValue={defaultValue} className="w-full">
<TabsList>
{items.map((item) => {
const tabTitle = resolveValue(item.title) || item.id;
return (
<TabsTrigger key={item.id} value={item.id}>
{tabTitle}
</TabsTrigger>
);
})}
</TabsList>
{items.map((item) => (
<TabsContent key={item.id} value={item.id}>
{renderChild(item.child)}
</TabsContent>
))}
</UITabs>
);
};

Some files were not shown because too many files have changed in this diff Show More