mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
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:
co-authored by
Claude Sonnet 4.6
parent
e4b93c38d8
commit
c2261961c1
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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` - 启动画面组件
|
||||
|
||||
## 更新提醒
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export { AgentChatPage } from "./AgentChatPage";
|
||||
export { AgentSkillsPanel } from "./AgentSkillsPanel";
|
||||
@@ -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";
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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";
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
- 为已安装插件列表提供右键菜单
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export { PluginManager } from "./PluginManager";
|
||||
export { PluginInstallDialog } from "./PluginInstallDialog";
|
||||
export { PluginUninstallDialog } from "./PluginUninstallDialog";
|
||||
export { default } from "./PluginManager";
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,13 +0,0 @@
|
||||
/**
|
||||
* SubAgent 组件索引
|
||||
*/
|
||||
|
||||
export { SubAgentProgress } from "./SubAgentProgress";
|
||||
export type {
|
||||
SubAgentTask,
|
||||
SubAgentResult,
|
||||
SchedulerProgress,
|
||||
SchedulerEvent,
|
||||
SchedulerExecutionResult,
|
||||
SchedulerConfig,
|
||||
} from "@/hooks/useSubAgentScheduler";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 +0,0 @@
|
||||
export { WebSocketStatus } from "./WebSocketStatus";
|
||||
+13
-111
@@ -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
@@ -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 ===": "",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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`
|
||||
|
||||
## 更新提醒
|
||||
|
||||
任何文件变更后,请更新此文档和相关的上级文档。
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
```
|
||||
|
||||
## 更新提醒
|
||||
|
||||
任何文件变更后,请更新此文档和相关的上级文档。
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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" />;
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user