mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 14:17:40 +00:00
refactor(ui): simplify agent detail page — remove Links, Shares tabs and Other Config section
Remove unused UI sections from agent detail: - Links tab + link-sections components + use-agent-links hook - Shares tab + use-agent-shares hook - "Quality & Advanced" config group + OtherConfigSection Also fix skills tab sort (ungranted non-system first) and harden use-contact-search with Array.isArray guard.
This commit is contained in:
1 parent
843b550651
commit
57754a569b
15 files changed
+14
-1025
No files matched your search
@@ -19,7 +19,6 @@ import {
|
||||
ContextPruningSection,
|
||||
SandboxSection,
|
||||
MemorySection,
|
||||
OtherConfigSection,
|
||||
ThinkingSection,
|
||||
WorkspaceSharingSection,
|
||||
} from "./config-sections";
|
||||
@@ -52,27 +51,11 @@ export function AgentConfigTab({ agent, onUpdate }: AgentConfigTabProps) {
|
||||
const otherObj = (agent.other_config ?? {}) as Record<string, unknown>;
|
||||
const initialThinkingLevel = (typeof otherObj.thinking_level === "string" ? otherObj.thinking_level : "off");
|
||||
const initialWsSharing = (otherObj.workspace_sharing ?? {}) as WorkspaceSharingConfig;
|
||||
// Strip all managed keys so they don't leak into the raw JSON editor.
|
||||
// General tab manages: emoji, self_evolve, description, skill_evolve, skill_nudge_interval
|
||||
// Config tab manages: workspace_sharing, thinking_level
|
||||
const {
|
||||
workspace_sharing: _ws, thinking_level: _tl,
|
||||
emoji: _emoji, self_evolve: _se, description: _desc,
|
||||
skill_evolve: _ske, skill_nudge_interval: _sni,
|
||||
...otherWithoutManaged
|
||||
} = otherObj;
|
||||
|
||||
const [wsSharing, setWsSharing] = useState<WorkspaceSharingConfig>(initialWsSharing);
|
||||
|
||||
const [thinkingLevel, setThinkingLevel] = useState(initialThinkingLevel);
|
||||
|
||||
const [otherEnabled, setOtherEnabled] = useState(
|
||||
agent.other_config != null && Object.keys(otherWithoutManaged).length > 0,
|
||||
);
|
||||
const [otherJson, setOtherJson] = useState(
|
||||
Object.keys(otherWithoutManaged).length > 0 ? JSON.stringify(otherWithoutManaged, null, 2) : "{}",
|
||||
);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
@@ -94,13 +77,10 @@ export function AgentConfigTab({ agent, onUpdate }: AgentConfigTabProps) {
|
||||
};
|
||||
// Preserve existing other_config fields not managed by this tab (e.g. emoji from General tab).
|
||||
const existing = (agent.other_config as Record<string, unknown> | null) ?? {};
|
||||
let otherBase: Record<string, unknown> = { ...existing };
|
||||
const otherBase: Record<string, unknown> = { ...existing };
|
||||
// Strip fields managed by this tab — they'll be re-added below from local state.
|
||||
delete otherBase.thinking_level;
|
||||
delete otherBase.workspace_sharing;
|
||||
if (otherEnabled) {
|
||||
try { Object.assign(otherBase, JSON.parse(otherJson)); } catch { /* keep existing */ }
|
||||
}
|
||||
if (thinkingLevel && thinkingLevel !== "off") {
|
||||
otherBase.thinking_level = thinkingLevel;
|
||||
}
|
||||
@@ -182,19 +162,6 @@ export function AgentConfigTab({ agent, onUpdate }: AgentConfigTabProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Advanced */}
|
||||
<ConfigGroupHeader
|
||||
title={t("configGroups.advanced")}
|
||||
description={t("configGroups.advancedDesc")}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<OtherConfigSection
|
||||
enabled={otherEnabled}
|
||||
value={otherJson}
|
||||
onToggle={(v: boolean) => { setOtherEnabled(v); if (!v) setOtherJson("{}"); }}
|
||||
onChange={setOtherJson}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<StickySaveBar
|
||||
onSave={handleSave}
|
||||
|
||||
@@ -10,8 +10,6 @@ import { useAgents } from "../hooks/use-agents";
|
||||
import { AgentGeneralTab } from "./agent-general-tab";
|
||||
import { AgentConfigTab } from "./agent-config-tab";
|
||||
import { AgentFilesTab } from "./agent-files-tab";
|
||||
import { AgentSharesTab } from "./agent-shares-tab";
|
||||
import { AgentLinksTab } from "./agent-links-tab";
|
||||
import { AgentSkillsTab } from "./agent-skills-tab";
|
||||
import { AgentInstancesTab } from "./agent-instances-tab";
|
||||
import { SummoningModal } from "../summoning-modal";
|
||||
@@ -155,8 +153,6 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
|
||||
<TabsTrigger value="general">{t("detail.tabs.general")}</TabsTrigger>
|
||||
<TabsTrigger value="config">{t("detail.tabs.config")}</TabsTrigger>
|
||||
<TabsTrigger value="files">{t("detail.tabs.files")}</TabsTrigger>
|
||||
<TabsTrigger value="shares">{t("detail.tabs.shares")}</TabsTrigger>
|
||||
<TabsTrigger value="links">{t("detail.tabs.links")}</TabsTrigger>
|
||||
<TabsTrigger value="skills">{t("detail.tabs.skills")}</TabsTrigger>
|
||||
{agent.agent_type === "predefined" && (
|
||||
<TabsTrigger value="instances">{t("detail.tabs.instances")}</TabsTrigger>
|
||||
@@ -182,14 +178,6 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="shares" className="mt-4">
|
||||
<AgentSharesTab agentId={agentId} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="links" className="mt-4">
|
||||
<AgentLinksTab agentId={agentId} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="skills" className="mt-4">
|
||||
<AgentSkillsTab agentId={agentId} />
|
||||
</TabsContent>
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
|
||||
import { useAgentLinks } from "../hooks/use-agent-links";
|
||||
import { useAgents } from "../hooks/use-agents";
|
||||
import type { AgentLinkData } from "@/types/agent";
|
||||
import { LinkCreateForm, LinkList, LinkEditDialog, linkTargetName } from "./link-sections";
|
||||
import { ROUTES } from "@/lib/constants";
|
||||
|
||||
interface AgentLinksTabProps {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
export function AgentLinksTab({ agentId }: AgentLinksTabProps) {
|
||||
const { t } = useTranslation("agents");
|
||||
const { links, loading, load, createLink, updateLink, deleteLink } =
|
||||
useAgentLinks(agentId);
|
||||
const { agents } = useAgents();
|
||||
|
||||
const [editLink, setEditLink] = useState<AgentLinkData | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<{
|
||||
id: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const agentOptions = useMemo(
|
||||
() =>
|
||||
agents
|
||||
.filter((a) => a.id !== agentId && a.agent_type === "predefined")
|
||||
.map((a) => ({
|
||||
value: a.id,
|
||||
label: a.display_name || a.agent_key,
|
||||
})),
|
||||
[agents, agentId],
|
||||
);
|
||||
|
||||
const handleStatusToggle = async (link: AgentLinkData) => {
|
||||
const newStatus = link.status === "active" ? "disabled" : "active";
|
||||
await updateLink(link.id, { status: newStatus });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6">
|
||||
<div className="flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 dark:border-amber-900 dark:bg-amber-950/30">
|
||||
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<p className="text-sm text-amber-800 dark:text-amber-300">
|
||||
{t("links.teamsHint").split("Agent Teams")[0]}
|
||||
<Link to={ROUTES.TEAMS} className="font-medium underline underline-offset-2 hover:text-amber-900 dark:hover:text-amber-200">
|
||||
Agent Teams
|
||||
</Link>
|
||||
{t("links.teamsHint").split("Agent Teams")[1]}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<LinkCreateForm agentOptions={agentOptions} onSubmit={createLink} />
|
||||
|
||||
<LinkList
|
||||
links={links}
|
||||
loading={loading}
|
||||
agentId={agentId}
|
||||
onStatusToggle={handleStatusToggle}
|
||||
onEdit={setEditLink}
|
||||
onDelete={(link) =>
|
||||
setDeleteTarget({ id: link.id, name: linkTargetName(link, agentId) })
|
||||
}
|
||||
/>
|
||||
|
||||
<LinkEditDialog
|
||||
link={editLink}
|
||||
onClose={() => setEditLink(null)}
|
||||
onSave={updateLink}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={() => setDeleteTarget(null)}
|
||||
title={t("links.deleteTitle")}
|
||||
description={t("links.deleteDesc", { name: deleteTarget?.name })}
|
||||
confirmLabel={t("delete.confirmLabel")}
|
||||
variant="destructive"
|
||||
onConfirm={async () => {
|
||||
if (deleteTarget) {
|
||||
try {
|
||||
await deleteLink(deleteTarget.id);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { Plus, Trash2, Users } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
import { useContactPicker } from "@/hooks/use-contact-picker";
|
||||
import { useContactResolver } from "@/hooks/use-contact-resolver";
|
||||
import { useAgentShares } from "../hooks/use-agent-shares";
|
||||
import type { ChannelContact } from "@/types/contact";
|
||||
|
||||
interface AgentSharesTabProps {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ value: "user" },
|
||||
{ value: "viewer" },
|
||||
] as const;
|
||||
|
||||
function roleBadgeVariant(role: string) {
|
||||
switch (role) {
|
||||
case "owner": return "success" as const;
|
||||
case "user": return "info" as const;
|
||||
default: return "outline" as const;
|
||||
}
|
||||
}
|
||||
|
||||
export function AgentSharesTab({ agentId }: AgentSharesTabProps) {
|
||||
const { t } = useTranslation("agents");
|
||||
const http = useHttp();
|
||||
const { shares, loading, addShare, revokeShare } = useAgentShares(agentId);
|
||||
const [newUserId, setNewUserId] = useState("");
|
||||
const [newRole, setNewRole] = useState("user");
|
||||
const [revokeTarget, setRevokeTarget] = useState<string | null>(null);
|
||||
|
||||
// Contact picker for the add form
|
||||
const listContacts = useCallback(
|
||||
async (search: string): Promise<ChannelContact[]> => {
|
||||
const res = await http.get<{ contacts: ChannelContact[] }>("/v1/contacts", {
|
||||
search,
|
||||
limit: "20",
|
||||
});
|
||||
return res.contacts ?? [];
|
||||
},
|
||||
[http],
|
||||
);
|
||||
const { options, searchContacts } = useContactPicker(listContacts);
|
||||
|
||||
// Resolve display names for existing shares
|
||||
const shareUserIDs = useMemo(() => shares.map((s) => s.user_id), [shares]);
|
||||
const { resolve } = useContactResolver(shareUserIDs);
|
||||
|
||||
const handleUserIdChange = (val: string) => {
|
||||
setNewUserId(val);
|
||||
searchContacts(val);
|
||||
};
|
||||
|
||||
const handleAddShare = async () => {
|
||||
if (!newUserId.trim()) return;
|
||||
try {
|
||||
await addShare(newUserId.trim(), newRole);
|
||||
setNewUserId("");
|
||||
setNewRole("user");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
{/* Add share form */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<h3 className="mb-3 text-sm font-medium">{t("shares.grantAccess")}</h3>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Label htmlFor="shareUserId">{t("shares.userId")}</Label>
|
||||
<Combobox
|
||||
value={newUserId}
|
||||
onChange={handleUserIdChange}
|
||||
options={options}
|
||||
placeholder={t("shares.userIdPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full space-y-1.5 sm:w-36">
|
||||
<Label>{t("shares.role")}</Label>
|
||||
<Select value={newRole} onValueChange={setNewRole}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ROLE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{t(`shares.role.${opt.value}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={handleAddShare} disabled={!newUserId.trim()} className="gap-1.5">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("shares.share")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Share list */}
|
||||
{loading && shares.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">{t("shares.loadingShares")}</div>
|
||||
) : shares.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
||||
<Users className="h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">{t("shares.noShares")}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("shares.noSharesDesc")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<div className="grid min-w-[300px] grid-cols-[1fr_100px_48px] items-center gap-2 border-b bg-muted/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
|
||||
<span>{t("shares.user")}</span>
|
||||
<span>{t("shares.role")}</span>
|
||||
<span />
|
||||
</div>
|
||||
{shares.map((share) => {
|
||||
const contact = resolve(share.user_id);
|
||||
const contactByGrant = share.granted_by ? resolve(share.granted_by) : null;
|
||||
return (
|
||||
<div
|
||||
key={share.user_id}
|
||||
className="grid min-w-[300px] grid-cols-[1fr_100px_48px] items-center gap-2 border-b px-4 py-3 last:border-0"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm font-medium">
|
||||
{contact?.display_name ?? share.user_id}
|
||||
</span>
|
||||
{contact?.display_name && (
|
||||
<span className="ml-1.5 text-xs text-muted-foreground font-mono">
|
||||
{share.user_id}
|
||||
</span>
|
||||
)}
|
||||
{contact?.username && (
|
||||
<span className="ml-1.5 text-xs text-muted-foreground">
|
||||
@{contact.username}
|
||||
</span>
|
||||
)}
|
||||
{share.granted_by && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
by {contactByGrant?.display_name ?? share.granted_by}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant={roleBadgeVariant(share.role)}>{share.role}</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setRevokeTarget(share.user_id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!revokeTarget}
|
||||
onOpenChange={() => setRevokeTarget(null)}
|
||||
title={t("shares.revokeTitle")}
|
||||
description={t("shares.revokeDesc", { userId: revokeTarget })}
|
||||
confirmLabel={t("shares.revoke")}
|
||||
variant="destructive"
|
||||
onConfirm={async () => {
|
||||
if (revokeTarget) {
|
||||
try {
|
||||
await revokeShare(revokeTarget);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setRevokeTarget(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,12 +31,18 @@ export function AgentSkillsTab({ agentId }: AgentSkillsTabProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [toggling, setToggling] = useState<string | null>(null);
|
||||
|
||||
const filtered = skills.filter(
|
||||
(s) =>
|
||||
s.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
s.slug.toLowerCase().includes(search.toLowerCase()) ||
|
||||
s.description.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
const filtered = skills
|
||||
.filter(
|
||||
(s) =>
|
||||
s.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
s.slug.toLowerCase().includes(search.toLowerCase()) ||
|
||||
s.description.toLowerCase().includes(search.toLowerCase()),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
// Priority: ungranted non-system (0) → system (1) → granted (2)
|
||||
const rank = (s: typeof a) => s.is_system ? 1 : s.granted ? 2 : 0;
|
||||
return rank(a) - rank(b);
|
||||
});
|
||||
|
||||
const handleToggle = async (skillId: string, granted: boolean) => {
|
||||
setToggling(skillId);
|
||||
|
||||
@@ -5,6 +5,5 @@ export { CompactionSection } from "./compaction-section";
|
||||
export { ContextPruningSection } from "./context-pruning-section";
|
||||
export { SandboxSection } from "./sandbox-section";
|
||||
export { MemorySection } from "./memory-section";
|
||||
export { OtherConfigSection } from "./other-config-section";
|
||||
export { ThinkingSection } from "./thinking-section";
|
||||
export { WorkspaceSharingSection } from "./workspace-sharing-section";
|
||||
@@ -1,59 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfigSection } from "./config-section";
|
||||
|
||||
interface OtherConfigSectionProps {
|
||||
enabled: boolean;
|
||||
value: string;
|
||||
onToggle: (v: boolean) => void;
|
||||
onChange: (v: string) => void;
|
||||
}
|
||||
|
||||
export function OtherConfigSection({ enabled, value, onToggle, onChange }: OtherConfigSectionProps) {
|
||||
const { t } = useTranslation("agents");
|
||||
const s = "configSections.otherConfig";
|
||||
const [validJson, setValidJson] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
JSON.parse(value || "{}");
|
||||
setValidJson(true);
|
||||
} catch {
|
||||
setValidJson(false);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const handleFormat = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
onChange(JSON.stringify(parsed, null, 2));
|
||||
} catch {
|
||||
// can't format invalid JSON
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfigSection
|
||||
title={t(`${s}.title`)}
|
||||
description={t(`${s}.description`)}
|
||||
enabled={enabled}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<Textarea
|
||||
className={`font-mono text-sm ${!validJson ? "border-destructive" : ""}`}
|
||||
rows={6}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder='{ "key": "value" }'
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" onClick={handleFormat} disabled={!validJson} className="h-7 px-2 text-xs">
|
||||
{t(`${s}.formatJson`)}
|
||||
</Button>
|
||||
{!validJson && <span className="text-xs text-destructive">{t(`${s}.invalidJson`)}</span>}
|
||||
</div>
|
||||
</ConfigSection>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export { LinkCreateForm } from "./link-create-form";
|
||||
export { LinkList } from "./link-list";
|
||||
export { LinkEditDialog } from "./link-edit-dialog";
|
||||
export {
|
||||
DIRECTION_OPTIONS,
|
||||
directionBadgeVariant,
|
||||
linkTargetName,
|
||||
buildSettings,
|
||||
parseCommaSeparated,
|
||||
joinArray,
|
||||
} from "./link-utils";
|
||||
@@ -1,175 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { AgentLinkSettings } from "@/types/agent";
|
||||
import { DIRECTION_OPTIONS, buildSettings } from "./link-utils";
|
||||
|
||||
interface LinkCreateFormProps {
|
||||
agentOptions: ComboboxOption[];
|
||||
onSubmit: (params: {
|
||||
targetAgent: string;
|
||||
direction: string;
|
||||
description?: string;
|
||||
maxConcurrent?: number;
|
||||
settings?: AgentLinkSettings;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
export function LinkCreateForm({ agentOptions, onSubmit }: LinkCreateFormProps) {
|
||||
const { t } = useTranslation("agents");
|
||||
const [targetAgent, setTargetAgent] = useState("");
|
||||
const [direction, setDirection] = useState("outbound");
|
||||
const [description, setDescription] = useState("");
|
||||
const [maxConcurrent, setMaxConcurrent] = useState("3");
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [userAllow, setUserAllow] = useState("");
|
||||
const [userDeny, setUserDeny] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const isValidTarget = agentOptions.some((o) => o.value === targetAgent);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!isValidTarget) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
await onSubmit({
|
||||
targetAgent,
|
||||
direction,
|
||||
description: description || undefined,
|
||||
maxConcurrent: parseInt(maxConcurrent, 10) || 3,
|
||||
settings: buildSettings(userAllow, userDeny),
|
||||
});
|
||||
setTargetAgent("");
|
||||
setDirection("outbound");
|
||||
setDescription("");
|
||||
setMaxConcurrent("3");
|
||||
setUserAllow("");
|
||||
setUserDeny("");
|
||||
setShowAdvanced(false);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border p-4">
|
||||
<h3 className="mb-3 text-sm font-medium">{t("links.createLink")}</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Label>{t("links.targetAgent")}</Label>
|
||||
<Combobox
|
||||
value={targetAgent}
|
||||
onChange={setTargetAgent}
|
||||
options={agentOptions}
|
||||
placeholder={t("links.selectAgent")}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full space-y-1.5 sm:w-44">
|
||||
<Label>{t("links.direction")}</Label>
|
||||
<Select value={direction} onValueChange={setDirection}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DIRECTION_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
<span className="flex flex-col">
|
||||
<span>{opt.label}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{DIRECTION_OPTIONS.find((o) => o.value === direction)?.desc}
|
||||
</p>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Label>{t("links.description")}</Label>
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t("links.descriptionPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full space-y-1.5 sm:w-32">
|
||||
<Label>{t("links.maxConcurrent")}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={maxConcurrent}
|
||||
onChange={(e) => setMaxConcurrent(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced settings */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showAdvanced ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("links.advancedSettings")}
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="space-y-3 rounded-md border border-dashed p-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("links.allowedUsers")}</Label>
|
||||
<Input
|
||||
value={userAllow}
|
||||
onChange={(e) => setUserAllow(e.target.value)}
|
||||
placeholder={t("links.allowedUsersPlaceholder")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("links.allowedUsersHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("links.deniedUsers")}</Label>
|
||||
<Input
|
||||
value={userDeny}
|
||||
onChange={(e) => setUserDeny(e.target.value)}
|
||||
placeholder={t("links.deniedUsersPlaceholder")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("links.deniedUsersHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={!isValidTarget || creating}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{creating ? t("links.creating") : t("links.createLink")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { AgentLinkData } from "@/types/agent";
|
||||
import { DIRECTION_OPTIONS, joinArray, buildSettings } from "./link-utils";
|
||||
|
||||
interface LinkEditDialogProps {
|
||||
link: AgentLinkData | null;
|
||||
onClose: () => void;
|
||||
onSave: (linkId: string, updates: Record<string, unknown>) => Promise<void>;
|
||||
}
|
||||
|
||||
export function LinkEditDialog({ link, onClose, onSave }: LinkEditDialogProps) {
|
||||
const { t } = useTranslation("agents");
|
||||
const [direction, setDirection] = useState("outbound");
|
||||
const [description, setDescription] = useState("");
|
||||
const [maxConcurrent, setMaxConcurrent] = useState("3");
|
||||
const [userAllow, setUserAllow] = useState("");
|
||||
const [userDeny, setUserDeny] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (link) {
|
||||
setDirection(link.direction);
|
||||
setDescription(link.description ?? "");
|
||||
setMaxConcurrent(String(link.max_concurrent));
|
||||
setUserAllow(joinArray(link.settings?.user_allow));
|
||||
setUserDeny(joinArray(link.settings?.user_deny));
|
||||
}
|
||||
}, [link]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!link) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(link.id, {
|
||||
direction,
|
||||
description,
|
||||
maxConcurrent: parseInt(maxConcurrent, 10) || 3,
|
||||
settings: buildSettings(userAllow, userDeny),
|
||||
});
|
||||
onClose();
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={!!link} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("links.editTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("links.editDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("links.direction")}</Label>
|
||||
<Select value={direction} onValueChange={setDirection}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DIRECTION_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{DIRECTION_OPTIONS.find((o) => o.value === direction)?.desc}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("links.description")}</Label>
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t("links.optionalDescription")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("links.maxConcurrent")}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={maxConcurrent}
|
||||
onChange={(e) => setMaxConcurrent(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("links.allowedUsers")}</Label>
|
||||
<Input
|
||||
value={userAllow}
|
||||
onChange={(e) => setUserAllow(e.target.value)}
|
||||
placeholder="user1, user2, ..."
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("links.allowedUsersHintShort")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("links.deniedUsers")}</Label>
|
||||
<Input
|
||||
value={userDeny}
|
||||
onChange={(e) => setUserDeny(e.target.value)}
|
||||
placeholder="user3, user4, ..."
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("links.deniedUsersHintShort")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={saving}>
|
||||
{t("files.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? t("config.saving") : t("general.saveChanges")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link2, Pencil, Trash2, Users } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { AgentLinkData } from "@/types/agent";
|
||||
import { directionBadgeVariant, effectiveDirection, linkTargetName } from "./link-utils";
|
||||
|
||||
interface LinkListProps {
|
||||
links: AgentLinkData[];
|
||||
loading: boolean;
|
||||
agentId: string;
|
||||
onStatusToggle: (link: AgentLinkData) => void;
|
||||
onEdit: (link: AgentLinkData) => void;
|
||||
onDelete: (link: AgentLinkData) => void;
|
||||
}
|
||||
|
||||
export function LinkList({
|
||||
links,
|
||||
loading,
|
||||
agentId,
|
||||
onStatusToggle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: LinkListProps) {
|
||||
const { t } = useTranslation("agents");
|
||||
|
||||
if (loading && links.length === 0) {
|
||||
return (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t("links.loadingLinks")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (links.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
||||
<Link2 className="h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">{t("links.noLinks")}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("links.noLinksDesc")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<div className="grid min-w-[500px] grid-cols-[1fr_100px_60px_60px_80px] items-center gap-2 border-b bg-muted/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
|
||||
<span>{t("links.columns.target")}</span>
|
||||
<span>{t("links.columns.direction")}</span>
|
||||
<span>{t("links.columns.status")}</span>
|
||||
<span>{t("links.columns.limit")}</span>
|
||||
<span />
|
||||
</div>
|
||||
{links.map((link) => (
|
||||
<div
|
||||
key={link.id}
|
||||
className="grid min-w-[500px] grid-cols-[1fr_100px_60px_60px_80px] items-center gap-2 border-b px-4 py-3 last:border-0"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{linkTargetName(link, agentId)}
|
||||
</span>
|
||||
{link.team_id && (
|
||||
<Link to={`/teams/${link.team_id}`}>
|
||||
<Badge variant="outline" className="gap-0.5 text-[10px] px-1.5 py-0 hover:bg-muted cursor-pointer">
|
||||
<Users className="h-3 w-3" />
|
||||
{link.team_name || "Team"}
|
||||
</Badge>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{link.target_description && link.source_agent_id === agentId && (
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{link.target_description}
|
||||
</p>
|
||||
)}
|
||||
{link.description && (
|
||||
<p className="truncate text-xs text-muted-foreground/70">
|
||||
{link.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant={directionBadgeVariant(effectiveDirection(link, agentId))}>
|
||||
{effectiveDirection(link, agentId)}
|
||||
</Badge>
|
||||
<Switch
|
||||
checked={link.status === "active"}
|
||||
onCheckedChange={() => onStatusToggle(link)}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{link.max_concurrent}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onEdit(link)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onDelete(link)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import type { AgentLinkData, AgentLinkSettings } from "@/types/agent";
|
||||
|
||||
export const DIRECTION_OPTIONS = [
|
||||
{ value: "outbound", label: "Outbound", desc: "This agent can delegate to the target" },
|
||||
{ value: "inbound", label: "Inbound", desc: "The target can delegate to this agent" },
|
||||
{ value: "bidirectional", label: "Bidirectional", desc: "Both agents can delegate to each other" },
|
||||
] as const;
|
||||
|
||||
export function directionBadgeVariant(direction: string) {
|
||||
switch (direction) {
|
||||
case "outbound": return "info" as const;
|
||||
case "inbound": return "warning" as const;
|
||||
case "bidirectional": return "success" as const;
|
||||
default: return "outline" as const;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseCommaSeparated(val: string): string[] {
|
||||
return val.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export function joinArray(arr?: string[]): string {
|
||||
return (arr ?? []).join(", ");
|
||||
}
|
||||
|
||||
export function linkTargetName(link: AgentLinkData, currentAgentId: string): string {
|
||||
if (link.source_agent_id === currentAgentId) {
|
||||
return link.target_display_name || link.target_agent_key || link.target_agent_id;
|
||||
}
|
||||
return link.source_agent_key || link.source_agent_id;
|
||||
}
|
||||
|
||||
export function effectiveDirection(link: AgentLinkData, currentAgentId: string): string {
|
||||
if (link.direction === "bidirectional") return "bidirectional";
|
||||
if (link.source_agent_id === currentAgentId) return link.direction;
|
||||
return link.direction === "outbound" ? "inbound" : "outbound";
|
||||
}
|
||||
|
||||
export function buildSettings(userAllow: string, userDeny: string): AgentLinkSettings | undefined {
|
||||
const allow = parseCommaSeparated(userAllow);
|
||||
const deny = parseCommaSeparated(userDeny);
|
||||
if (allow.length === 0 && deny.length === 0) return undefined;
|
||||
return {
|
||||
...(allow.length > 0 ? { user_allow: allow } : {}),
|
||||
...(deny.length > 0 ? { user_deny: deny } : {}),
|
||||
};
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useWs } from "@/hooks/use-ws";
|
||||
import { useAuthStore } from "@/stores/use-auth-store";
|
||||
import { Methods } from "@/api/protocol";
|
||||
import type { AgentLinkData, AgentLinkSettings } from "@/types/agent";
|
||||
|
||||
export function useAgentLinks(agentId: string) {
|
||||
const ws = useWs();
|
||||
const connected = useAuthStore((s) => s.connected);
|
||||
const [links, setLinks] = useState<AgentLinkData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!connected) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await ws.call<{ links: AgentLinkData[]; count: number }>(
|
||||
Methods.AGENTS_LINKS_LIST,
|
||||
{ agentId, direction: "all" },
|
||||
);
|
||||
setLinks(res.links ?? []);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [ws, agentId, connected]);
|
||||
|
||||
const createLink = useCallback(
|
||||
async (params: {
|
||||
targetAgent: string;
|
||||
direction: string;
|
||||
description?: string;
|
||||
maxConcurrent?: number;
|
||||
settings?: AgentLinkSettings;
|
||||
}) => {
|
||||
await ws.call(Methods.AGENTS_LINKS_CREATE, {
|
||||
sourceAgent: agentId,
|
||||
targetAgent: params.targetAgent,
|
||||
direction: params.direction,
|
||||
description: params.description,
|
||||
maxConcurrent: params.maxConcurrent,
|
||||
settings: params.settings,
|
||||
});
|
||||
load();
|
||||
},
|
||||
[ws, agentId, load],
|
||||
);
|
||||
|
||||
const updateLink = useCallback(
|
||||
async (linkId: string, updates: Record<string, unknown>) => {
|
||||
await ws.call(Methods.AGENTS_LINKS_UPDATE, { linkId, ...updates });
|
||||
load();
|
||||
},
|
||||
[ws, load],
|
||||
);
|
||||
|
||||
const deleteLink = useCallback(
|
||||
async (linkId: string) => {
|
||||
await ws.call(Methods.AGENTS_LINKS_DELETE, { linkId });
|
||||
load();
|
||||
},
|
||||
[ws, load],
|
||||
);
|
||||
|
||||
return { links, loading, load, createLink, updateLink, deleteLink };
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
import type { AgentShareData } from "@/types/agent";
|
||||
|
||||
export function useAgentShares(agentId: string) {
|
||||
const http = useHttp();
|
||||
const [shares, setShares] = useState<AgentShareData[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadShares = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await http.get<{ shares: AgentShareData[] }>(
|
||||
`/v1/agents/${agentId}/shares`,
|
||||
);
|
||||
setShares(res.shares ?? []);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [http, agentId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadShares();
|
||||
}, [loadShares]);
|
||||
|
||||
const addShare = useCallback(
|
||||
async (userId: string, role: string) => {
|
||||
await http.post(`/v1/agents/${agentId}/shares`, {
|
||||
user_id: userId,
|
||||
role,
|
||||
});
|
||||
loadShares();
|
||||
},
|
||||
[http, agentId, loadShares],
|
||||
);
|
||||
|
||||
const revokeShare = useCallback(
|
||||
async (userId: string) => {
|
||||
await http.delete(`/v1/agents/${agentId}/shares/${userId}`);
|
||||
loadShares();
|
||||
},
|
||||
[http, agentId, loadShares],
|
||||
);
|
||||
|
||||
return { shares, loading, refresh: loadShares, addShare, revokeShare };
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export function useContactSearch(search: string) {
|
||||
const params: Record<string, string> = { limit: "20" };
|
||||
if (debouncedSearch) params.search = debouncedSearch;
|
||||
const res = await http.get<{ contacts: ChannelContact[] }>("/v1/contacts", params);
|
||||
return res.contacts ?? [];
|
||||
return Array.isArray(res.contacts) ? res.contacts : [];
|
||||
},
|
||||
enabled: debouncedSearch.length >= 2,
|
||||
staleTime: 30_000,
|
||||
|
||||
Reference in new issue
Block a user