update
This commit is contained in:
1 parent
65e85135c1
commit
6f1de8047e
64 files changed
+1269
-515
No files matched your search
@@ -44,7 +44,7 @@ export function ActivityList({ activity }: { activity: ActivityRecord[] }) {
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
<MagnifyingGlass size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<input type="text" className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border border-border bg-card py-2.5 pl-8 pr-3.5 text-[13px] text-foreground placeholder:text-muted-foreground" value={search} onChange={(e) => setSearch(e.target.value)} placeholder={t("exec.searchActivity")} />
|
||||
<input type="text" aria-label={t("exec.searchActivity")} className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border border-border bg-card py-2.5 pl-8 pr-3.5 text-[13px] text-foreground placeholder:text-muted-foreground" value={search} onChange={(e) => setSearch(e.target.value)} placeholder={t("exec.searchActivity")} />
|
||||
</div>
|
||||
<Select value={entityFilter} onChange={setEntityFilter} options={ENTITY_TYPE_OPTIONS.map((v) => ({ value: v, label: v === "all" ? t("exec.allTypes") : v }))} />
|
||||
</div>
|
||||
|
||||
@@ -12,8 +12,8 @@ import { useAppStore } from "../../state/store"
|
||||
|
||||
function getTypeBadgeClass(type: string): string {
|
||||
if (type === "hire_agent") return "bg-accent text-primary"
|
||||
if (type === "approve_ceo_strategy") return "bg-yellow-100 dark:bg-yellow-900/50 text-yellow-600 dark:text-yellow-400"
|
||||
if (type === "secret_access") return "bg-red-100 dark:bg-red-900/50 text-destructive"
|
||||
if (type === "approve_ceo_strategy") return "bg-warning-muted text-warning"
|
||||
if (type === "secret_access") return "bg-destructive-muted text-destructive"
|
||||
if (type === "social_post") return "bg-accent text-primary"
|
||||
if (type === "deliverable_review") return "bg-accent text-primary"
|
||||
return "bg-muted text-muted-foreground"
|
||||
@@ -113,7 +113,7 @@ export function ApprovalInspector({ approval, agents, tasks, documents, companyI
|
||||
<div><span className="text-muted-foreground">Budget:</span> <span className="font-mono text-foreground">{formatMoney(proposedAgent?.budgetMonthlyUsd ?? (typeof reqSnap?.budgetMonthlyUsd === "number" ? reqSnap.budgetMonthlyUsd : 0))}/mo</span></div>
|
||||
{mgr ? <div><span className="text-muted-foreground">Reports to:</span> <span className="text-foreground">{mgr.name}</span></div> : null}
|
||||
</div></div>) })() : null}
|
||||
{approval.type === "approve_ceo_strategy" ? <div className="rounded-lg border border-[color:var(--destructive)] border-opacity-30 bg-yellow-100 dark:bg-yellow-900/50 px-4 py-3"><div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-yellow-600 dark:text-yellow-400">Organization Strategy</div><div className="whitespace-pre-wrap text-[12px] leading-relaxed text-foreground">{approval.payloadSummary}</div></div> : null}
|
||||
{approval.type === "approve_ceo_strategy" ? <div className="rounded-lg border border-[color:var(--destructive)] border-opacity-30 bg-warning-muted px-4 py-3"><div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-warning">Organization Strategy</div><div className="whitespace-pre-wrap text-[12px] leading-relaxed text-foreground">{approval.payloadSummary}</div></div> : null}
|
||||
{approval.type === "deliverable_review" ? <DeliverableReviewPanel approval={approval} documents={documents} agents={agents} /> : null}
|
||||
{isSocialPost && approval.payloadJson ? (
|
||||
<SocialDraftPreview
|
||||
|
||||
@@ -53,11 +53,11 @@ export function ApprovalList({ approvals, selectedApprovalId, onSelect }: { appr
|
||||
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 outline-none ${virtualRow.index === focusedIndex ? "ring-1 ring-[color:var(--primary)] ring-inset" : ""}`}
|
||||
>
|
||||
<button
|
||||
className={`relative w-full border-b border-border px-4 py-3 text-left transition duration-150 last:border-b-0 ${selectedApprovalId === approval.id ? "bg-yellow-100 dark:bg-yellow-900/50" : "bg-transparent hover:bg-muted"}`}
|
||||
className={`relative w-full border-b border-border px-4 py-3 text-left transition duration-150 last:border-b-0 ${selectedApprovalId === approval.id ? "bg-warning-muted" : "bg-transparent hover:bg-muted"}`}
|
||||
onClick={() => onSelect(approval.id)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{selectedApprovalId === approval.id ? <span className="absolute inset-y-0 left-0 w-[3px] bg-yellow-600 dark:bg-yellow-400" /> : null}
|
||||
{selectedApprovalId === approval.id ? <span className="absolute inset-y-0 left-0 w-[3px] bg-warning" /> : null}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className={`truncate text-[13px] text-foreground ${selectedApprovalId === approval.id ? "font-semibold" : "font-medium"}`}>{approval.payloadSummary}</div>
|
||||
<StatusPill status={approval.state} />
|
||||
|
||||
@@ -45,8 +45,8 @@ export function BudgetSummary({ agents, costs, companyId }: BudgetSummaryProps)
|
||||
const pct = status.amount > 0 ? (status.spent / status.amount) * 100 : 0
|
||||
const isWarning = pct > 80 && pct <= 100
|
||||
const isDanger = pct > 100
|
||||
const barColor = isDanger ? "bg-destructive" : isWarning ? "bg-yellow-600 dark:bg-yellow-400" : "bg-green-600 dark:bg-green-400"
|
||||
const nameColor = isDanger ? "text-destructive" : isWarning ? "text-yellow-600 dark:text-yellow-400" : "text-foreground"
|
||||
const barColor = isDanger ? "bg-destructive" : isWarning ? "bg-warning" : "bg-success"
|
||||
const nameColor = isDanger ? "text-destructive" : isWarning ? "text-warning" : "text-foreground"
|
||||
|
||||
const scopeLabel = status.scopeType === "company"
|
||||
? "Company"
|
||||
@@ -62,11 +62,11 @@ export function BudgetSummary({ agents, costs, companyId }: BudgetSummaryProps)
|
||||
{status.scopeType}
|
||||
</span>
|
||||
<div className={`text-[14px] font-medium ${nameColor}`}>{scopeLabel}</div>
|
||||
{status.hasOpenIncident ? <Warning size={14} weight="fill" className="text-yellow-600 dark:text-yellow-400" /> : null}
|
||||
{status.hasOpenIncident ? <Warning size={14} weight="fill" className="text-warning" /> : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-mono text-[12px] text-foreground">{formatMoney(status.spent / 100)} / {formatMoney(status.amount / 100)}</div>
|
||||
<span className={`font-mono text-[11px] font-semibold ${isDanger ? "text-destructive" : isWarning ? "text-yellow-600 dark:text-yellow-400" : "text-muted-foreground"}`}>{Math.round(pct)}%</span>
|
||||
<span className={`font-mono text-[11px] font-semibold ${isDanger ? "text-destructive" : isWarning ? "text-warning" : "text-muted-foreground"}`}>{Math.round(pct)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-[color:var(--border)]"><div className={`h-2 rounded-full ${barColor}`} style={{ width: `${Math.min(100, pct)}%` }} /></div>
|
||||
@@ -79,19 +79,19 @@ export function BudgetSummary({ agents, costs, companyId }: BudgetSummaryProps)
|
||||
{agents.map((agent) => {
|
||||
const pct = agent.budgetMonthlyUsd > 0 ? (agent.spentMonthlyUsd / agent.budgetMonthlyUsd) * 100 : 0
|
||||
const isWarning = pct > 80 && pct <= 100; const isDanger = pct > 100
|
||||
const barColor = isDanger ? "bg-destructive" : isWarning ? "bg-yellow-600 dark:bg-yellow-400" : "bg-green-600 dark:bg-green-400"
|
||||
const nameColor = isDanger ? "text-destructive" : isWarning ? "text-yellow-600 dark:text-yellow-400" : "text-foreground"
|
||||
const barColor = isDanger ? "bg-destructive" : isWarning ? "bg-warning" : "bg-success"
|
||||
const nameColor = isDanger ? "text-destructive" : isWarning ? "text-warning" : "text-foreground"
|
||||
return (
|
||||
<ListRow key={agent.id}>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`text-[14px] font-medium ${nameColor}`}>{agent.name}</div>
|
||||
{isDanger ? <Warning size={14} weight="fill" className="text-destructive" /> : null}
|
||||
{isWarning ? <Warning size={14} weight="fill" className="text-yellow-600 dark:text-yellow-400" /> : null}
|
||||
{isWarning ? <Warning size={14} weight="fill" className="text-warning" /> : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-mono text-[12px] text-foreground">{formatMoney(agent.spentMonthlyUsd)} / {formatMoney(agent.budgetMonthlyUsd)}</div>
|
||||
<span className={`font-mono text-[11px] font-semibold ${isDanger ? "text-destructive" : isWarning ? "text-yellow-600 dark:text-yellow-400" : "text-muted-foreground"}`}>{Math.round(pct)}%</span>
|
||||
<span className={`font-mono text-[11px] font-semibold ${isDanger ? "text-destructive" : isWarning ? "text-warning" : "text-muted-foreground"}`}>{Math.round(pct)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-[color:var(--border)]"><div className={`h-2 rounded-full ${barColor}`} style={{ width: `${Math.min(100, pct)}%` }} /></div>
|
||||
|
||||
@@ -100,7 +100,7 @@ export function CostList({ costs, agents, runs }: { costs: ProfileSnapshot["cost
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-mono tabular-nums text-[13px] text-foreground">{formatMoney(entry.amountUsd)}</div>
|
||||
{entry.unattributed ? <div className="text-[11px] text-yellow-600 dark:text-yellow-400">unattributed</div> : null}
|
||||
{entry.unattributed ? <div className="text-[11px] text-warning">unattributed</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,8 +11,8 @@ interface IncidentListProps {
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
open: "text-destructive bg-red-100 dark:bg-red-900/50",
|
||||
resolved: "text-green-600 dark:text-green-400 bg-green-100 dark:bg-green-900/50",
|
||||
open: "text-destructive bg-destructive-muted",
|
||||
resolved: "text-success bg-success-muted",
|
||||
auto_resolved_limit_increase: "text-primary bg-accent",
|
||||
auto_closed: "text-muted-foreground bg-muted",
|
||||
};
|
||||
@@ -93,8 +93,8 @@ export function IncidentList({ companyId, agents, projects }: IncidentListProps)
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.1em] ${
|
||||
isWarn
|
||||
? "text-yellow-600 dark:text-yellow-400 bg-yellow-100 dark:bg-yellow-900/50"
|
||||
: "text-destructive bg-red-100 dark:bg-red-900/50"
|
||||
? "text-warning bg-warning-muted"
|
||||
: "text-destructive bg-destructive-muted"
|
||||
}`}
|
||||
>
|
||||
{isWarn ? <Warning size={10} weight="bold" /> : null}
|
||||
|
||||
@@ -1,9 +1,52 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { FloppyDisk, Plus, X as XIcon } from "@phosphor-icons/react";
|
||||
import {
|
||||
CaretDown,
|
||||
CaretRight,
|
||||
FloppyDisk,
|
||||
Plus,
|
||||
SignIn,
|
||||
Trash,
|
||||
ArrowSquareOut,
|
||||
X as XIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { ConnectorRecord, ProfileSnapshot } from "@shared/types";
|
||||
import type { AppLocale } from "@shared/locale";
|
||||
import { unwrap } from "../../lib/desktop";
|
||||
import { ActionButton } from "../ui";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Compatible-API provider presets (mirrors ProvidersStep) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const COMPATIBLE_PROVIDERS = [
|
||||
{ id: "minimax", label: "MiniMax", baseUrl: "https://api.minimax.io/anthropic", docUrl: "https://platform.minimax.io/docs/token-plan/claude-code" },
|
||||
{ id: "minimax_cn", label: "MiniMax (CN)", baseUrl: "https://api.minimaxi.com/anthropic", docUrl: "https://platform.minimax.io/docs/token-plan/claude-code" },
|
||||
{ id: "kimi", label: "Kimi", baseUrl: "https://api.kimi.com/coding/", docUrl: "https://www.kimi.com/code/docs/en/more/third-party-agents.html" },
|
||||
{ id: "kimi_cn", label: "Kimi CN", baseUrl: "https://api.moonshot.cn/anthropic", docUrl: "https://platform.moonshot.cn/docs/guide/agent-support" },
|
||||
{ id: "glm", label: "GLM (ZhiPu)", baseUrl: "https://api.z.ai/api/anthropic", docUrl: "https://docs.z.ai/devpack/tool/claude" },
|
||||
{ id: "openrouter", label: "OpenRouter", baseUrl: "https://openrouter.ai/api", docUrl: "https://openrouter.ai/docs/guides/coding-agents/claude-code-integration" },
|
||||
{ id: "tencent", label: "Tencent Coding Plan", baseUrl: "https://api.lkeap.cloud.tencent.com/coding/anthropic", docUrl: "https://cloud.tencent.com/document/product/1772/128951" },
|
||||
{ id: "poe", label: "POE", baseUrl: "https://api.poe.com", docUrl: "https://creator.poe.com/docs/external-applications/claude-code" },
|
||||
{ id: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com/anthropic", docUrl: "https://api-docs.deepseek.com/guides/anthropic_api" },
|
||||
{ id: "alibaba", label: "Alibaba Coding Plan", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic", docUrl: "https://www.alibabacloud.com/help/en/model-studio/claude-code-coding-plan" },
|
||||
{ id: "alibaba_cn", label: "Alibaba Coding Plan (CN)", baseUrl: "https://coding.dashscope.aliyuncs.com/apps/anthropic", docUrl: "https://help.aliyun.com/zh/model-studio/claude-code-coding-plan" },
|
||||
{ id: "bailing", label: "BaiLing", baseUrl: "https://api.tbox.cn/api/anthropic", docUrl: "https://alipaytbox.yuque.com/sxs0ba/ling/best_practices_claudecode" },
|
||||
{ id: "nanogpt", label: "NanoGPT", baseUrl: "https://nano-gpt.com/api/v1", docUrl: "https://docs.nano-gpt.com/integrations/claude-code#manual-configuration-all-platforms" },
|
||||
{ id: "zhipu_cn", label: "ZhiPu (CN)", baseUrl: "https://open.bigmodel.cn/api/anthropic", docUrl: "https://docs.bigmodel.cn/cn/coding-plan/tool/claude" },
|
||||
{ id: "ollama", label: "Ollama (Local)", baseUrl: "http://localhost:11434", docUrl: "https://docs.ollama.com/integrations/claude-code" },
|
||||
{ id: "custom", label: "Custom", baseUrl: "", docUrl: "" },
|
||||
] as const;
|
||||
|
||||
const COMPAT_SLOTS = [
|
||||
"claude_compatible", "claude_compat_2", "claude_compat_3", "claude_compat_4", "claude_compat_5",
|
||||
"claude_compat_6", "claude_compat_7", "claude_compat_8", "claude_compat_9", "claude_compat_10",
|
||||
] as const;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Shared */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const INPUT_CLS =
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring w-full rounded-lg border border-border bg-card px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground";
|
||||
|
||||
interface ModelManagementProps {
|
||||
connectors: ProfileSnapshot["connectors"];
|
||||
@@ -11,194 +54,738 @@ interface ModelManagementProps {
|
||||
onSaved: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Check if a claude_compat connector has been configured (has env bindings). */
|
||||
function isConfigured(c: ConnectorRecord): boolean {
|
||||
return c.envBindingText.includes("ANTHROPIC_BASE_URL") || c.envBindingText.includes("ANTHROPIC_API_KEY");
|
||||
interface ModelEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
function getDisplayName(connector: ConnectorRecord): string {
|
||||
if (connector.id.startsWith("claude_compat") && connector.notes) return connector.notes;
|
||||
return connector.label;
|
||||
type ConnectorStatus = ProfileSnapshot["connectors"][number]["status"];
|
||||
|
||||
function parseEnvValue(text: string, key: string): string {
|
||||
const line = text.split(/\r?\n/).find((l) => l.startsWith(`${key}=`));
|
||||
return line ? line.slice(key.length + 1) : "";
|
||||
}
|
||||
|
||||
function CompatModelEditor({
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* StatusBadge */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function StatusBadge({ status, zh }: { status: ConnectorStatus; zh: boolean }) {
|
||||
if (status === "ready") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-success-muted px-2 py-0.5 text-[11px] font-medium text-success">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-success" />
|
||||
{zh ? "就绪" : "Ready"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "not_installed") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground" />
|
||||
{zh ? "未安装" : "Not installed"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "auth_required") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-warning-muted px-2 py-0.5 text-[11px] font-medium text-warning">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-warning" />
|
||||
{zh ? "需登录" : "Auth required"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "detected" || (status as string) === "checking") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||
<span className="h-3 w-3 animate-spin rounded-full border-[1.5px] border-muted-foreground border-t-primary" />
|
||||
{zh ? "检测中" : "Checking"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-caution-muted px-2 py-0.5 text-[11px] font-medium text-caution">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-caution" />
|
||||
{status.replaceAll("_", " ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* ProviderCard */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function ProviderCard({
|
||||
title,
|
||||
badge,
|
||||
expanded,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
badge: React.ReactNode;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="flex w-full cursor-pointer items-center justify-between px-4 py-3 transition hover:bg-muted/50"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{expanded ? (
|
||||
<CaretDown size={14} className="text-muted-foreground" />
|
||||
) : (
|
||||
<CaretRight size={14} className="text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-[13px] font-semibold text-foreground">{title}</span>
|
||||
</div>
|
||||
{badge}
|
||||
</button>
|
||||
{expanded && <div className="space-y-3 px-4 pb-4">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Toggle switch */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function ToggleSwitch({ checked, onChange, label }: { checked: boolean; onChange: () => void; label: string }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onChange}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors ${checked ? "bg-success" : "bg-muted-foreground/30"}`}
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform ${checked ? "translate-x-[18px]" : "translate-x-[3px]"}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Model toggle list */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function ModelToggleList({
|
||||
models,
|
||||
onToggle,
|
||||
onRemove,
|
||||
canRemove,
|
||||
zh,
|
||||
}: {
|
||||
models: ModelEntry[];
|
||||
onToggle: (id: string) => void;
|
||||
onRemove?: (id: string) => void;
|
||||
canRemove: boolean;
|
||||
zh: boolean;
|
||||
}) {
|
||||
if (models.length === 0) {
|
||||
return (
|
||||
<div className="text-[12px] text-muted-foreground">
|
||||
{zh ? "暂无模型" : "No models available"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const enabledCount = models.filter((m) => m.enabled).length;
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1.5 text-[12px] font-medium text-muted-foreground">
|
||||
{zh ? "模型" : "Models"} ({enabledCount}/{models.length} {zh ? "已启用" : "enabled"})
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{models.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="flex items-center justify-between rounded-md border border-border px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ToggleSwitch
|
||||
checked={m.enabled}
|
||||
onChange={() => onToggle(m.id)}
|
||||
label={`${m.enabled ? "Disable" : "Enable"} ${m.label}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-[12px] ${m.enabled ? "text-foreground" : "text-muted-foreground line-through"}`}
|
||||
>
|
||||
{m.label}
|
||||
</span>
|
||||
</div>
|
||||
{canRemove && onRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(m.id)}
|
||||
className="text-muted-foreground transition hover:text-destructive"
|
||||
aria-label={`Remove ${m.label}`}
|
||||
>
|
||||
<XIcon size={12} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Native provider card (Claude Code / Codex / Gemini) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function NativeProviderCard({
|
||||
connector,
|
||||
locale,
|
||||
zh,
|
||||
onSaved,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
connector: ConnectorRecord;
|
||||
locale: AppLocale;
|
||||
zh: boolean;
|
||||
onSaved: () => Promise<void>;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const [models, setModels] = useState<string[]>(() => {
|
||||
const raw = connector.model ?? "";
|
||||
return raw.split(",").map((m) => m.trim()).filter(Boolean);
|
||||
});
|
||||
const [newModel, setNewModel] = useState("");
|
||||
const [models, setModels] = useState<ModelEntry[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
const envKeyName = connector.id === "codex_local" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY";
|
||||
const [apiKey, setApiKey] = useState(() => parseEnvValue(connector.envBindingText, envKeyName));
|
||||
|
||||
useEffect(() => {
|
||||
const raw = connector.model ?? "";
|
||||
setModels(raw.split(",").map((m) => m.trim()).filter(Boolean));
|
||||
setDirty(false);
|
||||
}, [connector.model]);
|
||||
setApiKey(parseEnvValue(connector.envBindingText, envKeyName));
|
||||
}, [connector.envBindingText, envKeyName]);
|
||||
|
||||
function addModel() {
|
||||
const trimmed = newModel.trim();
|
||||
if (!trimmed || models.includes(trimmed)) return;
|
||||
setModels((prev) => [...prev, trimmed]);
|
||||
setNewModel("");
|
||||
setDirty(true);
|
||||
}
|
||||
const loadModels = useCallback(async () => {
|
||||
try {
|
||||
const [enabledResult, disabledResult] = await Promise.all([
|
||||
window.agentCompany.listConnectorModels(connector.id),
|
||||
window.agentCompany.getConnectorDisabledModels(connector.id),
|
||||
]);
|
||||
const enabled = enabledResult.ok ? enabledResult.data : [];
|
||||
const disabled = disabledResult.ok ? disabledResult.data : [];
|
||||
setModels([
|
||||
...enabled.map((m) => ({ ...m, enabled: true })),
|
||||
...disabled.map((m) => ({ ...m, enabled: false })),
|
||||
]);
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}, [connector.id]);
|
||||
|
||||
function removeModel(m: string) {
|
||||
setModels((prev) => prev.filter((x) => x !== m));
|
||||
setDirty(true);
|
||||
useEffect(() => {
|
||||
void loadModels();
|
||||
}, [loadModels]);
|
||||
|
||||
async function handleToggleModel(modelId: string) {
|
||||
const prev = models;
|
||||
const updated = models.map((m) =>
|
||||
m.id === modelId ? { ...m, enabled: !m.enabled } : m,
|
||||
);
|
||||
setModels(updated);
|
||||
const newDisabled = updated
|
||||
.filter((m) => !m.enabled)
|
||||
.map((m) => ({ id: m.id, label: m.label }));
|
||||
try {
|
||||
unwrap(
|
||||
await window.agentCompany.setConnectorDisabledModels({
|
||||
connectorId: connector.id,
|
||||
disabledModels: newDisabled,
|
||||
}),
|
||||
);
|
||||
await onSaved();
|
||||
} catch {
|
||||
setModels(prev);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
try {
|
||||
unwrap(await window.agentCompany.saveConnector({
|
||||
id: connector.id,
|
||||
command: connector.command,
|
||||
model: models.join(",") || null,
|
||||
envBindingText: connector.envBindingText,
|
||||
notes: connector.notes,
|
||||
}));
|
||||
setDirty(false);
|
||||
const lines: string[] = [];
|
||||
if (apiKey.trim()) lines.push(`${envKeyName}=${apiKey.trim()}`);
|
||||
unwrap(
|
||||
await window.agentCompany.saveConnector({
|
||||
id: connector.id,
|
||||
command: connector.command,
|
||||
model: connector.model,
|
||||
envBindingText: lines.join("\n"),
|
||||
notes: connector.notes,
|
||||
}),
|
||||
);
|
||||
await window.agentCompany.testConnector(connector.id);
|
||||
await onSaved();
|
||||
void loadModels();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
await window.agentCompany.openConnectorAuthTerminal(connector.id);
|
||||
}
|
||||
|
||||
const showApiKey = connector.id === "claude_local" || connector.id === "codex_local";
|
||||
|
||||
return (
|
||||
<ProviderCard
|
||||
title={connector.label}
|
||||
badge={<StatusBadge status={connector.status} zh={zh} />}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleLogin()}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted"
|
||||
>
|
||||
<SignIn size={14} />
|
||||
{zh ? "登录" : "Login"}
|
||||
</button>
|
||||
|
||||
{showApiKey ? (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
||||
{envKeyName}{" "}
|
||||
<span className="font-normal">({zh ? "可选" : "optional"})</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className={INPUT_CLS}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={connector.id === "codex_local" ? "sk-..." : "sk-ant-..."}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ModelToggleList
|
||||
models={models}
|
||||
onToggle={(id) => void handleToggleModel(id)}
|
||||
canRemove={false}
|
||||
zh={zh}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted disabled:opacity-40"
|
||||
>
|
||||
<FloppyDisk size={14} />
|
||||
{saving
|
||||
? zh
|
||||
? "保存中..."
|
||||
: "Saving..."
|
||||
: zh
|
||||
? "保存并检查"
|
||||
: "Save & Check"}
|
||||
</button>
|
||||
</ProviderCard>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Compatible provider card */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function CompatProviderCard({
|
||||
slotId,
|
||||
connector,
|
||||
zh,
|
||||
onSaved,
|
||||
expanded,
|
||||
onToggle,
|
||||
onCleared,
|
||||
}: {
|
||||
slotId: string;
|
||||
connector: ConnectorRecord | null;
|
||||
zh: boolean;
|
||||
onSaved: () => Promise<void>;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onCleared?: () => void;
|
||||
}) {
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>(() => {
|
||||
const saved = connector?.notes ?? "";
|
||||
const match = COMPATIBLE_PROVIDERS.find((p) => p.label === saved);
|
||||
return match?.id ?? COMPATIBLE_PROVIDERS[0].id;
|
||||
});
|
||||
|
||||
const [apiKey, setApiKey] = useState(() =>
|
||||
parseEnvValue(connector?.envBindingText ?? "", "ANTHROPIC_API_KEY"),
|
||||
);
|
||||
const [baseUrl, setBaseUrl] = useState<string>(
|
||||
() =>
|
||||
parseEnvValue(connector?.envBindingText ?? "", "ANTHROPIC_BASE_URL") ||
|
||||
COMPATIBLE_PROVIDERS[0].baseUrl,
|
||||
);
|
||||
const [models, setModels] = useState<ModelEntry[]>([]);
|
||||
const [newModel, setNewModel] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const status = connector?.status ?? "not_installed";
|
||||
const providerLabel =
|
||||
COMPATIBLE_PROVIDERS.find((p) => p.id === selectedProvider)?.label ??
|
||||
"Compatible API";
|
||||
const displayTitle = connector?.notes || providerLabel;
|
||||
|
||||
// Sync form state when connector changes externally (e.g. after save + refresh)
|
||||
useEffect(() => {
|
||||
setApiKey(parseEnvValue(connector?.envBindingText ?? "", "ANTHROPIC_API_KEY"));
|
||||
const savedBase = parseEnvValue(connector?.envBindingText ?? "", "ANTHROPIC_BASE_URL");
|
||||
if (savedBase) setBaseUrl(savedBase);
|
||||
const saved = connector?.notes ?? "";
|
||||
const match = COMPATIBLE_PROVIDERS.find((p) => p.label === saved);
|
||||
if (match) setSelectedProvider(match.id);
|
||||
}, [connector?.envBindingText, connector?.notes]);
|
||||
|
||||
const loadModels = useCallback(async () => {
|
||||
if (!connector) {
|
||||
setModels([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const raw = connector.model ?? "";
|
||||
const allModelIds = raw
|
||||
.split(",")
|
||||
.map((m) => m.trim())
|
||||
.filter(Boolean);
|
||||
const disabledResult = await window.agentCompany.getConnectorDisabledModels(
|
||||
connector.id,
|
||||
);
|
||||
const disabledSet = new Set(
|
||||
(disabledResult.ok ? disabledResult.data : []).map((m) => m.id),
|
||||
);
|
||||
setModels(
|
||||
allModelIds.map((id) => ({
|
||||
id,
|
||||
label: id,
|
||||
enabled: !disabledSet.has(id),
|
||||
})),
|
||||
);
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}, [connector?.id, connector?.model]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadModels();
|
||||
}, [loadModels]);
|
||||
|
||||
function handleProviderChange(providerId: string) {
|
||||
setSelectedProvider(providerId);
|
||||
const provider = COMPATIBLE_PROVIDERS.find((p) => p.id === providerId);
|
||||
if (provider) setBaseUrl(provider.baseUrl);
|
||||
}
|
||||
|
||||
function addModel() {
|
||||
const trimmed = newModel.trim();
|
||||
if (!trimmed || models.some((m) => m.id === trimmed)) return;
|
||||
setModels((prev) => [...prev, { id: trimmed, label: trimmed, enabled: true }]);
|
||||
setNewModel("");
|
||||
}
|
||||
|
||||
function removeModel(modelId: string) {
|
||||
setModels((prev) => prev.filter((m) => m.id !== modelId));
|
||||
}
|
||||
|
||||
async function handleToggleModel(modelId: string) {
|
||||
if (!connector) return;
|
||||
const prev = models;
|
||||
const updated = models.map((m) =>
|
||||
m.id === modelId ? { ...m, enabled: !m.enabled } : m,
|
||||
);
|
||||
setModels(updated);
|
||||
const newDisabled = updated
|
||||
.filter((m) => !m.enabled)
|
||||
.map((m) => ({ id: m.id, label: m.label }));
|
||||
try {
|
||||
unwrap(
|
||||
await window.agentCompany.setConnectorDisabledModels({
|
||||
connectorId: connector.id,
|
||||
disabledModels: newDisabled,
|
||||
}),
|
||||
);
|
||||
await onSaved();
|
||||
} catch {
|
||||
setModels(prev);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const lines: string[] = [];
|
||||
if (apiKey.trim()) lines.push(`ANTHROPIC_API_KEY=${apiKey.trim()}`);
|
||||
if (baseUrl.trim()) lines.push(`ANTHROPIC_BASE_URL=${baseUrl.trim()}`);
|
||||
const label =
|
||||
COMPATIBLE_PROVIDERS.find((p) => p.id === selectedProvider)?.label ?? "";
|
||||
const modelsCsv =
|
||||
models.map((m) => m.id).join(",") || null;
|
||||
unwrap(
|
||||
await window.agentCompany.saveConnector({
|
||||
id: slotId as ConnectorRecord["id"],
|
||||
command: connector?.command ?? "claude",
|
||||
model: modelsCsv,
|
||||
envBindingText: lines.join("\n"),
|
||||
notes: label,
|
||||
}),
|
||||
);
|
||||
await window.agentCompany.testConnector(slotId);
|
||||
await onSaved();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2 text-[13px] font-semibold text-foreground">{getDisplayName(connector)}</div>
|
||||
{models.length > 0 ? (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{models.map((m) => (
|
||||
<span
|
||||
key={m}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-border bg-muted px-2.5 py-1 text-[12px] text-foreground"
|
||||
>
|
||||
{m}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeModel(m)}
|
||||
className="ml-0.5 text-muted-foreground transition hover:text-destructive"
|
||||
aria-label={`Remove ${m}`}
|
||||
>
|
||||
<XIcon size={10} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-2 text-[12px] text-muted-foreground">
|
||||
{locale === "zh" ? "未配置模型" : "No models configured"}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
className="flex-1 rounded-lg border border-border bg-card px-3 py-2 text-[13px] text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
value={newModel}
|
||||
onChange={(e) => setNewModel(e.target.value)}
|
||||
placeholder={locale === "zh" ? "输入模型名称" : "model-name"}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addModel(); } }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addModel}
|
||||
disabled={!newModel.trim()}
|
||||
className="shrink-0 rounded-lg border border-border bg-card p-2 text-foreground transition hover:bg-muted disabled:opacity-40"
|
||||
aria-label="Add model"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
{dirty ? (
|
||||
<ActionButton
|
||||
label={locale === "zh" ? "保存" : "Save"}
|
||||
tone="accent"
|
||||
icon={FloppyDisk}
|
||||
onClick={() => void handleSave()}
|
||||
loading={saving}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NativeModelList({
|
||||
connector,
|
||||
locale,
|
||||
}: {
|
||||
connector: ConnectorRecord;
|
||||
locale: AppLocale;
|
||||
}) {
|
||||
const [models, setModels] = useState<Array<{ id: string; label: string }>>([]);
|
||||
|
||||
const loadModels = useCallback(async () => {
|
||||
async function handleClear() {
|
||||
if (!connector) return;
|
||||
try {
|
||||
const result = await window.agentCompany.listConnectorModels(connector.id);
|
||||
if (result.ok && result.data.length > 0) {
|
||||
setModels(result.data);
|
||||
}
|
||||
} catch { /* non-fatal */ }
|
||||
}, [connector.id]);
|
||||
|
||||
useEffect(() => { void loadModels(); }, [loadModels]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2 text-[13px] font-semibold text-foreground">{getDisplayName(connector)}</div>
|
||||
{models.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{models.map((m) => (
|
||||
<span
|
||||
key={m.id}
|
||||
className="inline-block rounded-md border border-border bg-muted px-2.5 py-1 text-[12px] text-foreground"
|
||||
>
|
||||
{m.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[12px] text-muted-foreground">
|
||||
{locale === "zh" ? "使用默认模型" : "Using default model"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelManagement({ connectors, locale, onSaved }: ModelManagementProps) {
|
||||
// Show native connectors that are ready + claude_compat connectors that are configured
|
||||
const visibleConnectors = connectors.filter((c) => {
|
||||
if (c.id.startsWith("claude_compat")) return isConfigured(c);
|
||||
return c.status === "ready";
|
||||
});
|
||||
|
||||
if (visibleConnectors.length === 0) {
|
||||
return (
|
||||
<div className="py-6 text-center text-[13px] text-muted-foreground">
|
||||
{locale === "zh" ? "暂无就绪的提供商" : "No ready providers"}
|
||||
</div>
|
||||
);
|
||||
unwrap(
|
||||
await window.agentCompany.saveConnector({
|
||||
id: connector.id,
|
||||
command: connector.command,
|
||||
model: null,
|
||||
envBindingText: "",
|
||||
notes: "",
|
||||
}),
|
||||
);
|
||||
unwrap(
|
||||
await window.agentCompany.setConnectorDisabledModels({
|
||||
connectorId: connector.id,
|
||||
disabledModels: [],
|
||||
}),
|
||||
);
|
||||
await onSaved();
|
||||
onCleared?.();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
const selectedProviderData = COMPATIBLE_PROVIDERS.find(
|
||||
(p) => p.id === selectedProvider,
|
||||
);
|
||||
const isConfigured =
|
||||
connector &&
|
||||
(connector.envBindingText.includes("ANTHROPIC_BASE_URL") ||
|
||||
connector.envBindingText.includes("ANTHROPIC_API_KEY"));
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{visibleConnectors.map((connector) =>
|
||||
connector.id.startsWith("claude_compat") ? (
|
||||
<CompatModelEditor key={connector.id} connector={connector} locale={locale} onSaved={onSaved} />
|
||||
) : (
|
||||
<NativeModelList key={connector.id} connector={connector} locale={locale} />
|
||||
),
|
||||
)}
|
||||
<ProviderCard
|
||||
title={displayTitle}
|
||||
badge={<StatusBadge status={status} zh={zh} />}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
||||
{zh ? "选择提供商" : "Select provider"}
|
||||
</label>
|
||||
<select
|
||||
className={INPUT_CLS}
|
||||
value={selectedProvider}
|
||||
onChange={(e) => handleProviderChange(e.target.value)}
|
||||
>
|
||||
{COMPATIBLE_PROVIDERS.map((p) => (
|
||||
<option key={p.id} value={p.id} className="bg-card text-foreground">
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedProviderData?.docUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.open(selectedProviderData.docUrl, "_blank")}
|
||||
className="inline-flex items-center gap-1 text-[12px] text-primary transition hover:underline"
|
||||
>
|
||||
<ArrowSquareOut size={12} />
|
||||
{zh ? "查看文档" : "View docs"}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
||||
ANTHROPIC_API_KEY
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className={INPUT_CLS}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
||||
ANTHROPIC_BASE_URL
|
||||
</label>
|
||||
<input
|
||||
className={INPUT_CLS}
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder="https://api.example.com/v1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ModelToggleList
|
||||
models={models}
|
||||
onToggle={(id) => void handleToggleModel(id)}
|
||||
onRemove={removeModel}
|
||||
canRemove
|
||||
zh={zh}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
||||
{zh ? "添加模型" : "Add model"}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className={INPUT_CLS}
|
||||
value={newModel}
|
||||
onChange={(e) => setNewModel(e.target.value)}
|
||||
placeholder={zh ? "输入模型名称" : "model-name"}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addModel();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addModel}
|
||||
disabled={!newModel.trim()}
|
||||
className="shrink-0 rounded-lg border border-border bg-card px-3 py-2 text-[12px] font-medium text-foreground transition hover:bg-muted disabled:opacity-40"
|
||||
aria-label={zh ? "添加模型" : "Add model"}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted disabled:opacity-40"
|
||||
>
|
||||
<FloppyDisk size={14} />
|
||||
{saving
|
||||
? zh
|
||||
? "保存中..."
|
||||
: "Saving..."
|
||||
: zh
|
||||
? "保存并检查"
|
||||
: "Save & Check"}
|
||||
</button>
|
||||
{isConfigured ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleClear()}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-destructive transition hover:bg-muted"
|
||||
>
|
||||
<Trash size={14} />
|
||||
{zh ? "清除配置" : "Clear"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</ProviderCard>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Main component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function ModelManagement({
|
||||
connectors,
|
||||
locale,
|
||||
onSaved,
|
||||
}: ModelManagementProps) {
|
||||
const zh = locale === "zh";
|
||||
const [expandedCard, setExpandedCard] = useState<string | null>(null);
|
||||
|
||||
const claudeConnector = connectors.find((c) => c.id === "claude_local");
|
||||
const codexConnector = connectors.find((c) => c.id === "codex_local");
|
||||
const geminiConnector = connectors.find((c) => c.id === "gemini_local");
|
||||
|
||||
const activeCompatSlots = COMPAT_SLOTS.filter((id) => {
|
||||
const c = connectors.find((conn) => conn.id === id);
|
||||
return (
|
||||
c &&
|
||||
(c.envBindingText.includes("ANTHROPIC_BASE_URL") ||
|
||||
c.envBindingText.includes("ANTHROPIC_API_KEY"))
|
||||
);
|
||||
});
|
||||
const nextAvailableSlot =
|
||||
COMPAT_SLOTS.find((id) => !activeCompatSlots.includes(id)) ?? null;
|
||||
const visibleCompatSlots = [...activeCompatSlots];
|
||||
if (nextAvailableSlot && !visibleCompatSlots.includes(nextAvailableSlot)) {
|
||||
visibleCompatSlots.push(nextAvailableSlot);
|
||||
}
|
||||
|
||||
const toggle = (card: string) =>
|
||||
setExpandedCard((prev) => (prev === card ? null : card));
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{claudeConnector ? (
|
||||
<NativeProviderCard
|
||||
connector={claudeConnector}
|
||||
zh={zh}
|
||||
onSaved={onSaved}
|
||||
expanded={expandedCard === "claude_local"}
|
||||
onToggle={() => toggle("claude_local")}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{codexConnector ? (
|
||||
<NativeProviderCard
|
||||
connector={codexConnector}
|
||||
zh={zh}
|
||||
onSaved={onSaved}
|
||||
expanded={expandedCard === "codex_local"}
|
||||
onToggle={() => toggle("codex_local")}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{geminiConnector ? (
|
||||
<NativeProviderCard
|
||||
connector={geminiConnector}
|
||||
zh={zh}
|
||||
onSaved={onSaved}
|
||||
expanded={expandedCard === "gemini_local"}
|
||||
onToggle={() => toggle("gemini_local")}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{visibleCompatSlots.map((slotId) => (
|
||||
<CompatProviderCard
|
||||
key={slotId}
|
||||
slotId={slotId}
|
||||
connector={connectors.find((c) => c.id === slotId) ?? null}
|
||||
zh={zh}
|
||||
onSaved={onSaved}
|
||||
expanded={expandedCard === slotId}
|
||||
onToggle={() => toggle(slotId)}
|
||||
onCleared={() => setExpandedCard(null)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -99,7 +99,7 @@ export function PolicyList({ companyId, agents, projects }: PolicyListProps) {
|
||||
{policies.map((policy) => {
|
||||
const util = getUtilization(policy.id);
|
||||
const pct = util?.pct ?? 0;
|
||||
const barColor = pct > 100 ? "bg-destructive" : pct > 80 ? "bg-yellow-600 dark:bg-yellow-400" : "bg-green-600 dark:bg-green-400";
|
||||
const barColor = pct > 100 ? "bg-destructive" : pct > 80 ? "bg-warning" : "bg-success";
|
||||
return (
|
||||
<ListRow key={policy.id} onClick={() => handleEdit(policy)}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
@@ -110,7 +110,7 @@ export function PolicyList({ companyId, agents, projects }: PolicyListProps) {
|
||||
</span>
|
||||
<span className="truncate text-[13px] font-medium text-foreground">{getScopeName(policy)}</span>
|
||||
{policy.hardStopEnabled ? <ShieldCheck size={14} className="shrink-0 text-primary" /> : null}
|
||||
{util?.hasIncident ? <Warning size={14} weight="fill" className="shrink-0 text-yellow-600 dark:text-yellow-400" /> : null}
|
||||
{util?.hasIncident ? <Warning size={14} weight="fill" className="shrink-0 text-warning" /> : null}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-3 text-[11px] text-muted-foreground">
|
||||
<span className="font-[family-name:var(--font-mono)]" style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
|
||||
@@ -41,6 +41,23 @@ export function SettingsPane({ snapshot, secrets, companyId, onSaved }: { snapsh
|
||||
<div className="rounded-lg border border-border px-4 py-3"><label className="flex items-center gap-3"><input type="checkbox" checked={company.autoApproveHires} onChange={async (e) => { setActionError(null); try { unwrap(await window.agentCompany.saveCompany({ id: company.id, name: company.name, description: company.description, status: company.status, autoApproveHires: e.target.checked, reviewDeliverables: company.reviewDeliverables })); await onSaved() } catch (err) { setActionError(describeActionError(err)) } }} className="h-4 w-4 rounded accent-[color:var(--primary)]" /><div><div className="text-[13px] font-medium text-foreground">{t("exec.autoApproveLabel")}</div><div className="mt-0.5 text-[11px] text-muted-foreground">{t("exec.autoApproveDesc")}</div></div></label></div>
|
||||
<div className="rounded-lg border border-border px-4 py-3"><label className="flex items-center gap-3"><input type="checkbox" checked={!company.reviewDeliverables} onChange={async (e) => { setActionError(null); try { unwrap(await window.agentCompany.saveCompany({ id: company.id, name: company.name, description: company.description, status: company.status, autoApproveHires: company.autoApproveHires, reviewDeliverables: !e.target.checked })); await onSaved() } catch (err) { setActionError(describeActionError(err)) } }} className="h-4 w-4 rounded accent-[color:var(--primary)]" /><div><div className="text-[13px] font-medium text-foreground">{t("exec.reviewDeliverablesLabel")}</div><div className="mt-0.5 text-[11px] text-muted-foreground">{t("exec.reviewDeliverablesDesc")}</div></div></label></div>
|
||||
</div></div>) })()}
|
||||
<div><div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Keyboard Shortcuts</div>
|
||||
<div className="space-y-1 border-y border-border py-3">
|
||||
{([
|
||||
[navigator.platform.includes("Mac") ? "⌘K" : "Ctrl+K", "Open command palette"],
|
||||
[navigator.platform.includes("Mac") ? "⌘Enter" : "Ctrl+Enter", "Send message"],
|
||||
["Escape", "Close modal / palette"],
|
||||
["↑ / ↓", "Navigate palette results"],
|
||||
["Enter", "Select palette result"],
|
||||
["Tab", "Move focus between elements"],
|
||||
] as const).map(([key, desc]) => (
|
||||
<div key={key} className="flex items-center justify-between px-3 py-1.5 text-[12px]">
|
||||
<span className="text-muted-foreground">{desc}</span>
|
||||
<kbd className="rounded border border-border bg-muted px-2 py-0.5 font-mono text-[11px] text-foreground">{key}</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div><div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.actions")}</div><div className="grid gap-3 md:grid-cols-2"><ActionButton label={t("exec.saveSettings")} tone="accent" onClick={() => void handleSaveSettings()} icon={CheckCircle} loading={loadingAction === "save"} /><ActionButton label={t("exec.checkForUpdates")} onClick={() => void handleCheckForUpdates()} icon={Download} loading={loadingAction === "check"} /><ActionButton label={t("exec.backupProfile")} onClick={() => void handleBackupProfile()} icon={ClockCounterClockwise} loading={loadingAction === "backup"} /><ActionButton label={t("exec.restoreProfile")} onClick={() => void handleRestoreProfile()} icon={ArrowClockwise} loading={loadingAction === "restore"} /></div></div>
|
||||
<div><div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretBackend")}</div><div className="border-y border-border py-3 text-[13px] text-muted-foreground">{snapshot.backend.secretBackend}</div></div>
|
||||
<div><div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secrets")} ({secrets.length})</div>{secrets.length === 0 ? <EmptyState title={t("exec.noSecretsStored")} /> : <div className="border-y border-border overflow-x-auto"><table className="w-full text-left text-[13px]"><thead><tr className="border-b border-border"><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretName")}</th><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretDescription")}</th><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretBackendCol")}</th><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretUpdated")}</th><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground" /></tr></thead><tbody>{secrets.map((s) => <tr key={s.id} className="border-b border-border last:border-b-0"><td className="px-4 py-3 font-medium text-foreground">{s.name}</td><td className="px-4 py-3 text-muted-foreground">{s.description || "\u2014"}</td><td className="px-4 py-3 font-mono text-muted-foreground">{s.backend}</td><td className="px-4 py-3 font-mono text-muted-foreground">{formatTime(s.updatedAt)}</td><td className="px-4 py-3"><ActionButton label="Delete" onClick={() => setConfirmDeleteSecretId(s.id)} icon={Trash} tone="danger" /></td></tr>)}</tbody></table></div>}</div>
|
||||
|
||||
@@ -121,7 +121,7 @@ export function SocialDraftPreview({ payloadJson, platform, isPending: _isPendin
|
||||
) : null}
|
||||
|
||||
{draft.scheduledAt ? (
|
||||
<div className="flex items-center gap-1.5 text-[13px] font-[family-name:var(--font-mono)] text-yellow-600 dark:text-yellow-400">
|
||||
<div className="flex items-center gap-1.5 text-[13px] font-[family-name:var(--font-mono)] text-warning">
|
||||
<Clock size={14} />
|
||||
Scheduled: {formatScheduledDate(draft.scheduledAt)}
|
||||
</div>
|
||||
|
||||
@@ -52,7 +52,7 @@ export function TaskComments({ taskId, companyId, agents, comments, onRefresh }:
|
||||
)}
|
||||
{submitError ? <InlineNotice message={submitError} /> : null}
|
||||
<div className="flex gap-2">
|
||||
<input className="flex-1 rounded-md border border-border bg-card px-3 py-2 text-[13px] text-foreground placeholder:text-muted-foreground focus:border-[color:var(--primary)] focus:outline-none" placeholder={t("exec.sendMessage")} value={newComment} onChange={(e) => setNewComment(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void handleSubmit() } }} />
|
||||
<input aria-label={t("exec.sendMessage")} className="flex-1 rounded-md border border-border bg-card px-3 py-2 text-[13px] text-foreground placeholder:text-muted-foreground focus:border-[color:var(--primary)] focus:outline-none" placeholder={t("exec.sendMessage")} value={newComment} onChange={(e) => setNewComment(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void handleSubmit() } }} />
|
||||
<ActionButton label={t("exec.send")} tone="accent" onClick={() => void handleSubmit()} icon={ArrowRight} loading={submitting} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react"
|
||||
import { AnimatePresence, motion } from "framer-motion"
|
||||
import { ListChecks } from "@phosphor-icons/react"
|
||||
import type { GoalRecord, ProfileSnapshot, ProjectRecord, WorkspaceRecord, TaskRecord } from "@shared/types"
|
||||
import { useT } from "../../i18n"
|
||||
@@ -20,15 +21,29 @@ export function TaskCreator({ companyId, goals, projects, agents, workspaces, on
|
||||
try { unwrap(await window.agentCompany.saveTask({ companyId, projectId: projectId || null, goalId: goalId || null, title, description, assigneeAgentId: assigneeAgentId || null, workspaceId: workspaceId || null, priority: priority as TaskRecord["priority"], status: "todo" })); setTitle(""); setDescription(""); await onSaved() } catch (e) { setSubmitError(describeActionError(e)) } finally { setSubmitting(false) }
|
||||
}
|
||||
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="grid min-w-0 gap-4 md:grid-cols-2">
|
||||
<Input label={t("exec.title")} value={title} onChange={setTitle} placeholder={t("exec.taskTitle")} />
|
||||
<Select label={t("exec.priority")} value={priority} onChange={setPriority} options={["critical","high","medium","low"].map((v) => ({ value: v, label: v }))} />
|
||||
<div className="md:col-span-2"><TextArea label="Description" value={description} onChange={setDescription} placeholder="Describe the task" rows={5} /></div>
|
||||
<Select label={t("exec.goal")} value={goalId} onChange={setGoalId} options={[{ value: "", label: t("exec.noGoal") }, ...goals.map((g) => ({ value: g.id, label: g.title }))]} />
|
||||
<Select label={t("exec.project")} value={projectId} onChange={setProjectId} options={[{ value: "", label: t("exec.noProject") }, ...projects.map((p) => ({ value: p.id, label: p.name }))]} />
|
||||
<Select label={t("exec.assignee")} value={assigneeAgentId} onChange={setAssigneeAgentId} options={[{ value: "", label: t("exec.unassigned") }, ...agents.map((a) => ({ value: a.id, label: a.name }))]} />
|
||||
<Select label={t("exec.workspace")} value={workspaceId} onChange={setWorkspaceId} options={[{ value: "", label: t("exec.noWorkspace") }, ...workspaces.map((w) => ({ value: w.id, label: w.name }))]} />
|
||||
<div className="md:col-span-2">
|
||||
<button type="button" onClick={() => setShowAdvanced(!showAdvanced)} className="flex items-center gap-1.5 text-[12px] font-medium text-muted-foreground transition hover:text-foreground">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" className={`transition-transform ${showAdvanced ? "rotate-90" : ""}`}><path d="M3 1l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" /></svg>
|
||||
{t("exec.goal")}, {t("exec.project")}, {t("exec.assignee")}, {t("exec.workspace")}
|
||||
</button>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{showAdvanced ? (
|
||||
<motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }} className="grid gap-4 overflow-hidden md:col-span-2 md:grid-cols-2">
|
||||
<Select label={t("exec.goal")} value={goalId} onChange={setGoalId} options={[{ value: "", label: t("exec.noGoal") }, ...goals.map((g) => ({ value: g.id, label: g.title }))]} />
|
||||
<Select label={t("exec.project")} value={projectId} onChange={setProjectId} options={[{ value: "", label: t("exec.noProject") }, ...projects.map((p) => ({ value: p.id, label: p.name }))]} />
|
||||
<Select label={t("exec.assignee")} value={assigneeAgentId} onChange={setAssigneeAgentId} options={[{ value: "", label: t("exec.unassigned") }, ...agents.map((a) => ({ value: a.id, label: a.name }))]} />
|
||||
<Select label={t("exec.workspace")} value={workspaceId} onChange={setWorkspaceId} options={[{ value: "", label: t("exec.noWorkspace") }, ...workspaces.map((w) => ({ value: w.id, label: w.name }))]} />
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
{submitError ? <div className="md:col-span-2"><InlineNotice message={submitError} /></div> : null}
|
||||
<div className="md:col-span-2"><ActionButton label={t("exec.createTask")} tone="accent" onClick={() => void handleCreateTask()} icon={ListChecks} loading={submitting} /></div>
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,7 @@ export function TaskInspector({ task, tasks, runs, agents, goals, projects, work
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ConfirmDialog open={confirmDelete} title={t("exec.deleteTask")} message={`${t("exec.deleteTaskConfirm").replace("{0}", task.title)}`} errorMessage={deleteError} onConfirm={() => void handleDelete()} onCancel={() => { setDeleteError(null); setConfirmDelete(false) }} />
|
||||
{budgetWarning > 0.8 && assignee ? <div className={`flex items-center gap-2 rounded-lg border px-4 py-3 text-[13px] font-medium ${budgetWarning > 1 ? "border-[color:var(--destructive)] bg-red-100 dark:bg-red-900/50 text-destructive" : "border-[color:var(--destructive)] bg-yellow-100 dark:bg-yellow-900/50 text-yellow-600 dark:text-yellow-400"}`}><Warning size={16} weight="fill" />{assignee.name} has used {Math.round(budgetWarning * 100)}% of their monthly budget ({formatMoney(assignee.spentMonthlyUsd)} / {formatMoney(assignee.budgetMonthlyUsd)})</div> : null}
|
||||
{budgetWarning > 0.8 && assignee ? <div className={`flex items-center gap-2 rounded-lg border px-4 py-3 text-[13px] font-medium ${budgetWarning > 1 ? "border-[color:var(--destructive)] bg-destructive-muted text-destructive" : "border-[color:var(--destructive)] bg-warning-muted text-warning"}`}><Warning size={16} weight="fill" />{assignee.name} has used {Math.round(budgetWarning * 100)}% of their monthly budget ({formatMoney(assignee.spentMonthlyUsd)} / {formatMoney(assignee.budgetMonthlyUsd)})</div> : null}
|
||||
{actionError ? <InlineNotice message={actionError} /> : null}
|
||||
{!actionError && actionNotice ? <InlineNotice message={actionNotice} tone="success" /> : null}
|
||||
{editing ? (
|
||||
@@ -65,7 +65,7 @@ export function TaskInspector({ task, tasks, runs, agents, goals, projects, work
|
||||
</div>
|
||||
{task.description ? <div className="text-[13px] leading-relaxed text-muted-foreground whitespace-pre-wrap">{task.description}</div> : null}
|
||||
{task.status === "in_review" && reviewer ? <div className="rounded-lg border border-[color:var(--primary)] bg-accent px-4 py-3"><div className="flex items-center gap-2 text-[13px] font-semibold text-primary"><Robot size={16} weight="fill" />Being reviewed by {reviewer.name}{reviewer.title ? ` (${reviewer.title})` : ""}</div><div className="mt-1 text-[13px] leading-relaxed text-primary">{assignee?.name} submitted this task for review. {reviewer.name} will approve, request revisions, or escalate.</div></div>
|
||||
: needsGovernance ? <div className="rounded-lg border border-[color:var(--destructive)] bg-yellow-100 dark:bg-yellow-900/50 px-4 py-3"><div className="flex items-center gap-2 text-[13px] font-semibold text-yellow-600 dark:text-yellow-400"><ShieldCheck size={16} weight="fill" />{task.status === "blocked" ? t("exec.governanceRequired") : t("exec.waitingOnReview")}</div><div className="mt-1 text-[13px] leading-relaxed text-muted-foreground">{task.status === "blocked" ? t("exec.blockedGovernance") : t("exec.reviewGovernance")}</div><div className="mt-3"><ActionButton label={task.status === "blocked" ? t("exec.createUnblockApproval") : t("exec.createReviewApproval")} onClick={() => void handleCreateApproval()} icon={ShieldCheck} loading={creatingApproval} /></div></div> : null}
|
||||
: needsGovernance ? <div className="rounded-lg border border-[color:var(--destructive)] bg-warning-muted px-4 py-3"><div className="flex items-center gap-2 text-[13px] font-semibold text-warning"><ShieldCheck size={16} weight="fill" />{task.status === "blocked" ? t("exec.governanceRequired") : t("exec.waitingOnReview")}</div><div className="mt-1 text-[13px] leading-relaxed text-muted-foreground">{task.status === "blocked" ? t("exec.blockedGovernance") : t("exec.reviewGovernance")}</div><div className="mt-3"><ActionButton label={task.status === "blocked" ? t("exec.createUnblockApproval") : t("exec.createReviewApproval")} onClick={() => void handleCreateApproval()} icon={ShieldCheck} loading={creatingApproval} /></div></div> : null}
|
||||
<div className="flex items-center gap-3">
|
||||
{activeRun ? <ActionButton label={t("exec.cancelRun")} onClick={() => void onCancelRun(activeRun.id)} icon={XCircle} tone="danger" /> : <>
|
||||
{["todo","in_progress","blocked"].includes(task.status) && <ActionButton label={t("exec.run")} onClick={() => void onRun(task.id)} icon={Play} tone="accent" />}
|
||||
@@ -80,19 +80,19 @@ export function TaskInspector({ task, tasks, runs, agents, goals, projects, work
|
||||
)}
|
||||
{(() => { const subtasks = tasks.filter((t) => t.parentId === task.id); if (subtasks.length === 0) return null; return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2"><ListChecks size={14} className="text-muted-foreground" /><span className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.subtasks")} ({subtasks.length})</span><span className="text-[10px] text-green-600 dark:text-green-400">{subtasks.filter((t) => t.status === "done").length}/{subtasks.length} done</span></div>
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted"><div className="h-full rounded-full bg-green-600 dark:bg-green-400 transition-all duration-300" style={{ width: `${subtasks.length > 0 ? (subtasks.filter((t) => t.status === "done").length / subtasks.length) * 100 : 0}%` }} /></div>
|
||||
<div className="flex items-center gap-2"><ListChecks size={14} className="text-muted-foreground" /><span className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.subtasks")} ({subtasks.length})</span><span className="text-[10px] text-success">{subtasks.filter((t) => t.status === "done").length}/{subtasks.length} done</span></div>
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted"><div className="h-full rounded-full bg-success transition-all duration-300" style={{ width: `${subtasks.length > 0 ? (subtasks.filter((t) => t.status === "done").length / subtasks.length) * 100 : 0}%` }} /></div>
|
||||
<div className="space-y-1">{subtasks.map((sub) => { const subAssignee = agentMap.get(sub.assigneeAgentId ?? ""); return (
|
||||
<div key={sub.id} className="flex items-center gap-2 rounded-lg px-3 py-2 text-[12px] bg-muted">
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${sub.status === "done" ? "bg-green-600 dark:bg-green-400" : sub.status === "in_progress" ? "bg-primary" : sub.status === "blocked" ? "bg-destructive" : sub.status === "in_review" ? "bg-yellow-600 dark:bg-yellow-400" : "bg-[color:var(--muted-foreground)]"}`} />
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${sub.status === "done" ? "bg-success" : sub.status === "in_progress" ? "bg-primary" : sub.status === "blocked" ? "bg-destructive" : sub.status === "in_review" ? "bg-warning" : "bg-[color:var(--muted-foreground)]"}`} />
|
||||
<span className={`flex-1 truncate ${sub.status === "done" ? "line-through text-muted-foreground" : "text-foreground"}`}>{sub.title}</span>
|
||||
{subAssignee ? <span className="shrink-0 text-[10px] text-muted-foreground">{subAssignee.name}</span> : null}<StatusPill status={sub.status} />
|
||||
</div>) })}</div></div>) })()}
|
||||
<TaskComments taskId={task.id} companyId={task.companyId} agents={agents} comments={comments} onRefresh={onRefresh} />
|
||||
{runs.length > 0 && <div className="space-y-2"><div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.runHistory")}</div><div className="space-y-2">{runs.slice(0, 8).map((run) => { const runAgent = agentMap.get(run.agentId); const isActive = run.status === "running" || run.status === "queued"; return (
|
||||
<div key={run.id} className={`rounded-lg border px-3 py-2.5 ${isActive ? "border-[color:var(--primary)] bg-green-100 dark:bg-green-900/50" : run.status === "failed" || run.status === "timed_out" ? "border-[color:var(--destructive)] border-opacity-30 bg-red-100 dark:bg-red-900/50" : "border-border bg-muted"}`}>
|
||||
<div className="flex items-center justify-between gap-2"><div className="min-w-0 flex-1"><div className="flex items-center gap-2">{isActive ? <span className="h-2 w-2 animate-pulse rounded-full bg-green-600 dark:bg-green-400" /> : null}<span className="truncate text-[13px] font-medium text-foreground">{run.summary || (isActive ? "Running..." : "Completed run")}</span></div><div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[10px] text-muted-foreground">{runAgent ? <span>{runAgent.name}</span> : null}<span>{run.connectorId.replace("_local", "")}</span>{run.model ? <span className="font-mono">{run.model}</span> : null}<span>{formatTime(run.startedAt)}</span>{run.costUsd != null && run.costUsd > 0 ? <span className="font-mono tabular-nums">{formatMoney(run.costUsd)}</span> : null}</div></div><StatusPill status={run.status} /></div>
|
||||
{run.errorMessage ? <div className="mt-1.5 rounded bg-red-100 dark:bg-red-900/50 px-2 py-1 text-[11px] text-destructive break-words">{run.errorMessage}</div> : null}
|
||||
<div key={run.id} className={`rounded-lg border px-3 py-2.5 ${isActive ? "border-[color:var(--primary)] bg-success-muted" : run.status === "failed" || run.status === "timed_out" ? "border-[color:var(--destructive)] border-opacity-30 bg-destructive-muted" : "border-border bg-muted"}`}>
|
||||
<div className="flex items-center justify-between gap-2"><div className="min-w-0 flex-1"><div className="flex items-center gap-2">{isActive ? <span className="h-2 w-2 animate-pulse rounded-full bg-success" /> : null}<span className="truncate text-[13px] font-medium text-foreground">{run.summary || (isActive ? "Running..." : "Completed run")}</span></div><div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[10px] text-muted-foreground">{runAgent ? <span>{runAgent.name}</span> : null}<span>{run.connectorId.replace("_local", "")}</span>{run.model ? <span className="font-mono">{run.model}</span> : null}<span>{formatTime(run.startedAt)}</span>{run.costUsd != null && run.costUsd > 0 ? <span className="font-mono tabular-nums">{formatMoney(run.costUsd)}</span> : null}</div></div><StatusPill status={run.status} /></div>
|
||||
{run.errorMessage ? <div className="mt-1.5 rounded bg-destructive-muted px-2 py-1 text-[11px] text-destructive break-words">{run.errorMessage}</div> : null}
|
||||
</div>) })}{runs.length > 8 && <div className="text-[11px] text-muted-foreground text-center">+{runs.length - 8} more runs</div>}</div></div>}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -27,11 +27,11 @@ export const ENTITY_TYPE_OPTIONS = [
|
||||
|
||||
export function WorkspaceStatePill({ state }: { state: string }) {
|
||||
const colorMap: Record<string, string> = {
|
||||
ready: "text-green-600 dark:text-green-400 bg-green-100 dark:bg-green-900/50",
|
||||
busy: "text-yellow-600 dark:text-yellow-400 bg-yellow-100 dark:bg-yellow-900/50",
|
||||
blocked: "text-destructive bg-red-100 dark:bg-red-900/50",
|
||||
ready: "text-success bg-success-muted",
|
||||
busy: "text-warning bg-warning-muted",
|
||||
blocked: "text-destructive bg-destructive-muted",
|
||||
missing: "text-muted-foreground bg-muted",
|
||||
error: "text-destructive bg-red-100 dark:bg-red-900/50",
|
||||
error: "text-destructive bg-destructive-muted",
|
||||
}
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.1em] ${colorMap[state] ?? colorMap.missing}`}>
|
||||
@@ -41,6 +41,6 @@ export function WorkspaceStatePill({ state }: { state: string }) {
|
||||
}
|
||||
|
||||
export function PriorityDot({ priority }: { priority: string }) {
|
||||
const color = priority === "critical" ? "bg-destructive" : priority === "high" ? "bg-yellow-600 dark:bg-yellow-400" : priority === "low" ? "bg-[color:var(--muted-foreground)]" : "bg-[color:var(--muted-foreground)]"
|
||||
const color = priority === "critical" ? "bg-destructive" : priority === "high" ? "bg-warning" : priority === "low" ? "bg-[color:var(--muted-foreground)]" : "bg-[color:var(--muted-foreground)]"
|
||||
return <span className={`inline-block h-2 w-2 shrink-0 rounded-full ${color}`} aria-label={`${priority} priority`} />
|
||||
}
|
||||
Reference in new issue
Block a user