Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

540 lines
17 KiB
TypeScript

"use client";
/**
* Partner configuration panel: identity, soul, model, tool surface, and the
* provisioned asset library (knowledge bases / skills / notebooks copied
* into the partner workspace).
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { Loader2, Plus, Save, Trash2, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import PartnerModelSelect from "@/components/partners/PartnerModelSelect";
import { listLLMOptions, type LLMOption } from "@/lib/llm-options";
import type { LLMSelection } from "@/lib/unified-ws";
import {
addPartnerAssets,
getPartnerAssets,
getPartnerSoul,
getToolOptions,
removePartnerAsset,
savePartnerSoul,
updatePartner,
type PartnerAssets,
type PartnerInfo,
type ToolOptions,
} from "@/lib/partners-api";
import AssetPicker, {
type AssetSelection,
} from "@/components/partners/AssetPicker";
import ToolPicker from "@/components/partners/ToolPicker";
import FaceEditor, { type FaceValue } from "@/components/partners/FaceEditor";
import SoulEditor from "@/components/partners/SoulEditor";
function Section({
title,
description,
children,
action,
}: {
title: string;
description?: string;
children: React.ReactNode;
action?: React.ReactNode;
}) {
return (
<section className="rounded-xl border border-[var(--border)] p-4">
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<h3 className="text-[13px] font-medium text-[var(--foreground)]">
{title}
</h3>
{description && (
<p className="mt-0.5 text-[11.5px] text-[var(--muted-foreground)]">
{description}
</p>
)}
</div>
{action}
</div>
{children}
</section>
);
}
export default function PartnerConfigure({
partner,
onToast,
onUpdated,
}: {
partner: PartnerInfo;
onToast: (msg: string) => void;
onUpdated: () => void;
}) {
const { t } = useTranslation();
const partnerId = partner.partner_id;
// Identity
const [name, setName] = useState(partner.name);
const [description, setDescription] = useState(partner.description ?? "");
const [face, setFace] = useState<FaceValue>({
emoji: partner.emoji ?? "",
color: partner.color ?? "",
avatar: partner.avatar ?? "",
});
const [language, setLanguage] = useState(partner.language ?? "");
const [savingIdentity, setSavingIdentity] = useState(false);
// Soul
const [soul, setSoul] = useState("");
const [soulLoaded, setSoulLoaded] = useState(false);
const [savingSoul, setSavingSoul] = useState(false);
// Model
const [llmOptions, setLLMOptions] = useState<LLMOption[]>([]);
const [activeLLMDefault, setActiveLLMDefault] = useState<LLMSelection | null>(
null,
);
const [llmLoading, setLLMLoading] = useState(true);
const [llmError, setLLMError] = useState(false);
const [selection, setSelection] = useState<LLMSelection | null>(
partner.llm_selection ?? null,
);
const [backupSelection, setBackupSelection] = useState<LLMSelection | null>(
partner.backup_llm_selection ?? null,
);
// Tools
const [toolOptions, setToolOptions] = useState<ToolOptions | null>(null);
const [enabledTools, setEnabledTools] = useState<string[]>([]);
const [builtinTools, setBuiltinTools] = useState<string[]>([]);
const [mcpTools, setMcpTools] = useState<string[]>([]);
const [savingTools, setSavingTools] = useState(false);
// Assets
const [assets, setAssets] = useState<PartnerAssets | null>(null);
const [showAssetPicker, setShowAssetPicker] = useState(false);
const [pendingAssets, setPendingAssets] = useState<AssetSelection>({
knowledge_bases: [],
skills: [],
notebooks: [],
});
const [addingAssets, setAddingAssets] = useState(false);
useEffect(() => {
void getPartnerSoul(partnerId)
.then((content) => {
setSoul(content);
setSoulLoaded(true);
})
.catch(() => setSoulLoaded(true));
void getPartnerAssets(partnerId)
.then(setAssets)
.catch(() => {});
void (async () => {
try {
const payload = await listLLMOptions();
setLLMOptions(payload.options);
setActiveLLMDefault(payload.active);
setLLMError(false);
} catch {
setLLMError(true);
} finally {
setLLMLoading(false);
}
})();
void getToolOptions()
.then((options) => {
setToolOptions(options);
setEnabledTools(
partner.enabled_tools ?? options.tools.map((tool) => tool.name),
);
setBuiltinTools(
partner.builtin_tools ??
options.builtin_tools.map((tool) => tool.name),
);
setMcpTools(
partner.mcp_tools ?? options.mcp_tools.map((tool) => tool.name),
);
})
.catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [partnerId]);
const saveIdentity = async () => {
setSavingIdentity(true);
try {
await updatePartner(partnerId, {
name,
description,
emoji: face.emoji,
color: face.color,
avatar: face.avatar,
language,
});
onToast(t("Saved"));
onUpdated();
} catch (e) {
onToast(e instanceof Error ? e.message : t("Save failed"));
} finally {
setSavingIdentity(false);
}
};
const saveSoul = async () => {
setSavingSoul(true);
try {
await savePartnerSoul(partnerId, soul);
onToast(t("Soul saved"));
} catch (e) {
onToast(e instanceof Error ? e.message : t("Save failed"));
} finally {
setSavingSoul(false);
}
};
const saveModel = async (next: LLMSelection | null) => {
setSelection(next);
try {
await updatePartner(partnerId, { llm_selection: next });
onToast(t("Model updated — applies from the next message"));
onUpdated();
} catch (e) {
onToast(e instanceof Error ? e.message : t("Save failed"));
}
};
const saveBackupModel = async (next: LLMSelection | null) => {
setBackupSelection(next);
try {
await updatePartner(partnerId, { backup_llm_selection: next });
onToast(t("Model updated — applies from the next message"));
onUpdated();
} catch (e) {
onToast(e instanceof Error ? e.message : t("Save failed"));
}
};
const saveTools = async () => {
setSavingTools(true);
try {
await updatePartner(partnerId, {
enabled_tools: enabledTools,
builtin_tools: builtinTools,
mcp_tools: mcpTools,
});
onToast(t("Tools updated — applies from the next message"));
onUpdated();
} catch (e) {
onToast(e instanceof Error ? e.message : t("Save failed"));
} finally {
setSavingTools(false);
}
};
const submitAssets = async () => {
setAddingAssets(true);
try {
const result = await addPartnerAssets(partnerId, pendingAssets);
setAssets(result.assets);
if (result.errors.length > 0) {
onToast(
t("Some items failed: {{names}}", {
names: result.errors.map((e) => e.name).join(", "),
}),
);
} else {
onToast(t("Assets added"));
}
setPendingAssets({ knowledge_bases: [], skills: [], notebooks: [] });
setShowAssetPicker(false);
} catch (e) {
onToast(e instanceof Error ? e.message : t("Save failed"));
} finally {
setAddingAssets(false);
}
};
const removeAsset = useCallback(
async (assetType: "knowledge_base" | "skill" | "notebook", id: string) => {
try {
const result = await removePartnerAsset(partnerId, assetType, id);
setAssets(result.assets);
} catch (e) {
onToast(e instanceof Error ? e.message : t("Remove failed"));
}
},
[partnerId, onToast, t],
);
const assetRows = useMemo(() => {
if (!assets) return [];
return [
...assets.knowledge_bases.map((kb) => ({
type: "knowledge_base" as const,
id: kb.name,
label: kb.name,
kind: t("Knowledge base"),
})),
...assets.skills.map((skill) => ({
type: "skill" as const,
id: skill.name,
label: skill.name,
kind: t("Skill"),
})),
...assets.notebooks.map((nb) => ({
type: "notebook" as const,
id: nb.id,
label: nb.name || nb.id,
kind: t("Notebook"),
})),
];
}, [assets, t]);
const pendingCount =
pendingAssets.knowledge_bases.length +
pendingAssets.skills.length +
pendingAssets.notebooks.length;
return (
<div className="space-y-4">
<Section
title={t("Identity")}
action={
<button
type="button"
onClick={() => void saveIdentity()}
disabled={savingIdentity || !name.trim()}
className="inline-flex items-center gap-1.5 rounded-lg bg-[var(--primary)] px-3 py-1.5 text-[12px] font-medium text-[var(--primary-foreground)] disabled:opacity-40"
>
{savingIdentity ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Save className="h-3.5 w-3.5" />
)}
{t("Save")}
</button>
}
>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-[12px] font-medium">
{t("Name")}
</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-lg border border-[var(--border)] bg-transparent px-3 py-1.5 text-[13px] outline-none focus:border-[var(--ring)]"
/>
</div>
<div>
<label className="mb-1 block text-[12px] font-medium">
{t("Reply language")}
</label>
<select
value={language}
onChange={(e) => setLanguage(e.target.value)}
className="w-full rounded-lg border border-[var(--border)] bg-transparent px-3 py-1.5 text-[13px] outline-none focus:border-[var(--ring)]"
>
<option value="">{t("Auto (English)")}</option>
<option value="en">English</option>
<option value="zh">中文</option>
</select>
</div>
<div className="sm:col-span-2">
<label className="mb-1 block text-[12px] font-medium">
{t("Description")}
</label>
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full rounded-lg border border-[var(--border)] bg-transparent px-3 py-1.5 text-[13px] outline-none focus:border-[var(--ring)]"
/>
</div>
<div className="sm:col-span-2 mt-1">
<span className="mb-2 block text-[12px] font-medium">
{t("Face")}
</span>
<FaceEditor name={name} value={face} onChange={setFace} />
</div>
</div>
</Section>
<Section
title={t("Soul")}
description={t(
"The partner's persona — injected into every conversation.",
)}
action={
<button
type="button"
onClick={() => void saveSoul()}
disabled={savingSoul || !soulLoaded}
className="inline-flex items-center gap-1.5 rounded-lg bg-[var(--primary)] px-3 py-1.5 text-[12px] font-medium text-[var(--primary-foreground)] disabled:opacity-40"
>
{savingSoul ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Save className="h-3.5 w-3.5" />
)}
{t("Save")}
</button>
}
>
<SoulEditor value={soul} onChange={setSoul} heightClass="h-[280px]" />
</Section>
<Section
title={t("Model")}
description={t(
"The primary model answers every turn; if it fails outright, the turn is retried once on the backup.",
)}
>
<div className="max-w-md space-y-3">
<div>
<label className="mb-1 block text-[12px] font-medium">
{t("Primary model")}
</label>
<PartnerModelSelect
options={llmOptions}
activeDefault={activeLLMDefault}
value={selection}
loading={llmLoading}
error={llmError}
noneLabel={t("System default")}
onChange={(next) => void saveModel(next)}
/>
</div>
<div>
<label className="mb-1 block text-[12px] font-medium">
{t("Backup model")}
</label>
<PartnerModelSelect
options={llmOptions}
activeDefault={activeLLMDefault}
value={backupSelection}
loading={llmLoading}
error={llmError}
noneLabel={t("No backup")}
noneDetail={t("Failed turns are not retried.")}
onChange={(next) => void saveBackupModel(next)}
/>
</div>
</div>
</Section>
<Section
title={t("Tools")}
description={t("What this partner is allowed to use.")}
action={
<button
type="button"
onClick={() => void saveTools()}
disabled={savingTools || !toolOptions}
className="inline-flex items-center gap-1.5 rounded-lg bg-[var(--primary)] px-3 py-1.5 text-[12px] font-medium text-[var(--primary-foreground)] disabled:opacity-40"
>
{savingTools ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Save className="h-3.5 w-3.5" />
)}
{t("Save")}
</button>
}
>
<ToolPicker
options={toolOptions}
enabledTools={enabledTools}
builtinTools={builtinTools}
mcpTools={mcpTools}
onChangeEnabledTools={setEnabledTools}
onChangeBuiltinTools={setBuiltinTools}
onChangeMcpTools={setMcpTools}
/>
</Section>
<Section
title={t("Library")}
description={t(
"Knowledge bases, skills, and notebooks copied into this partner's workspace.",
)}
action={
<button
type="button"
onClick={() => setShowAssetPicker((v) => !v)}
className="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border)] px-3 py-1.5 text-[12px] font-medium text-[var(--foreground)] hover:border-[var(--ring)]"
>
{showAssetPicker ? (
<X className="h-3.5 w-3.5" />
) : (
<Plus className="h-3.5 w-3.5" />
)}
{showAssetPicker ? t("Cancel") : t("Add")}
</button>
}
>
{showAssetPicker && (
<div className="mb-4 rounded-xl border border-dashed border-[var(--border)] p-3.5">
<AssetPicker
value={pendingAssets}
onChange={setPendingAssets}
excluded={{
knowledge_bases:
assets?.knowledge_bases.map((kb) => kb.name) ?? [],
skills: assets?.skills.map((skill) => skill.name) ?? [],
notebooks: assets?.notebooks.map((nb) => nb.id) ?? [],
}}
/>
<button
type="button"
onClick={() => void submitAssets()}
disabled={addingAssets || pendingCount === 0}
className="mt-3 inline-flex items-center gap-1.5 rounded-lg bg-[var(--primary)] px-3 py-1.5 text-[12px] font-medium text-[var(--primary-foreground)] disabled:opacity-40"
>
{addingAssets ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Plus className="h-3.5 w-3.5" />
)}
{t("Copy into workspace")}
{pendingCount > 0 ? ` (${pendingCount})` : ""}
</button>
</div>
)}
{assetRows.length === 0 ? (
<p className="text-[12.5px] text-[var(--muted-foreground)]">
{t(
"Nothing assigned yet — this partner only knows what you tell it.",
)}
</p>
) : (
<ul className="divide-y divide-[var(--border)]">
{assetRows.map((row) => (
<li
key={`${row.type}:${row.id}`}
className="flex items-center justify-between py-1.5"
>
<span className="min-w-0 truncate text-[13px] text-[var(--foreground)]">
{row.label}
<span className="ml-2 text-[11px] text-[var(--muted-foreground)]">
{row.kind}
</span>
</span>
<button
type="button"
onClick={() => void removeAsset(row.type, row.id)}
className="rounded-md p-1.5 text-[var(--muted-foreground)] hover:bg-[var(--muted)] hover:text-red-500"
aria-label={t("Remove")}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</li>
))}
</ul>
)}
</Section>
</div>
);
}