mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 08:18:19 +00:00
feat(ui): refactor agent detail page — 3 tabs, heartbeat UI, compact header
- Reduce 5 tabs to 3 (Agent, Files, Instances) + Advanced Settings dialog - Team-style compact header with heartbeat countdown, advanced, delete buttons - New heartbeat UI: config dialog with channel/chatId picker, logs viewer - Personality section with large emoji + frontmatter textarea - Model & Budget merged section, inline skills toggles - Capabilities section with collapsible Memory/Tools/Subagents - Auto-refresh heartbeat when countdown reaches 0 (poll every 5s) - Shared useCountdown hook for mm:ss / hh:mm:ss display
This commit is contained in:
1 parent
96cfd1bf08
commit
1ae5c7641a
27 files changed
+2261
-1003
No files matched your search
@@ -152,6 +152,16 @@ export const Methods = {
|
||||
TEAMS_WORKSPACE_READ: "teams.workspace.read",
|
||||
TEAMS_WORKSPACE_DELETE: "teams.workspace.delete",
|
||||
|
||||
// Heartbeat
|
||||
HEARTBEAT_GET: "heartbeat.get",
|
||||
HEARTBEAT_SET: "heartbeat.set",
|
||||
HEARTBEAT_TOGGLE: "heartbeat.toggle",
|
||||
HEARTBEAT_TEST: "heartbeat.test",
|
||||
HEARTBEAT_LOGS: "heartbeat.logs",
|
||||
HEARTBEAT_CHECKLIST_GET: "heartbeat.checklist.get",
|
||||
HEARTBEAT_CHECKLIST_SET: "heartbeat.checklist.set",
|
||||
HEARTBEAT_TARGETS: "heartbeat.targets",
|
||||
|
||||
// Phase 3+ - NICE TO HAVE
|
||||
LOGS_TAIL: "logs.tail",
|
||||
} as const;
|
||||
@@ -218,6 +228,8 @@ export const Events = {
|
||||
// Skill dependency install (triggered by POST /v1/skills/install-deps)
|
||||
SKILL_DEPS_INSTALLING: "skill.deps.installing",
|
||||
SKILL_DEPS_INSTALLED: "skill.deps.installed",
|
||||
|
||||
HEARTBEAT: "heartbeat",
|
||||
} as const;
|
||||
|
||||
/** All event names relevant to team debug view */
|
||||
|
||||
@@ -7,6 +7,7 @@ interface StickySaveBarProps {
|
||||
saving: boolean;
|
||||
saved: boolean;
|
||||
error?: string | null;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
savingLabel?: string;
|
||||
savedLabel?: string;
|
||||
@@ -18,6 +19,7 @@ export function StickySaveBar({
|
||||
saving,
|
||||
saved,
|
||||
error,
|
||||
disabled,
|
||||
label,
|
||||
savingLabel,
|
||||
savedLabel,
|
||||
@@ -40,7 +42,7 @@ export function StickySaveBar({
|
||||
<Check className="h-3.5 w-3.5" /> {resolvedSavedLabel}
|
||||
</span>
|
||||
)}
|
||||
<Button onClick={onSave} disabled={saving}>
|
||||
<Button onClick={onSave} disabled={saving || disabled}>
|
||||
{!saving && <Save className="h-4 w-4" />}
|
||||
{saving ? resolvedSavingLabel : resolvedLabel}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
/** Format seconds into mm:ss or hh:mm:ss countdown. */
|
||||
export function formatCountdown(seconds: number): string {
|
||||
if (seconds <= 0) return "00:00";
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
const mm = String(m).padStart(2, "0");
|
||||
const ss = String(s).padStart(2, "0");
|
||||
if (h > 0) return `${String(h).padStart(2, "0")}:${mm}:${ss}`;
|
||||
return `${mm}:${ss}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that ticks a countdown from a target ISO date string.
|
||||
* Returns formatted string like "12m", "1h 5m", "now", or null if no target.
|
||||
* Ticks every `tickMs` (default 1s for mm:ss display).
|
||||
*/
|
||||
export function useCountdown(targetISO: string | undefined | null, tickMs = 1_000): string | null {
|
||||
const [now, setNow] = useState(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (!targetISO) return;
|
||||
setNow(Date.now()); // immediate sync on target change
|
||||
const id = setInterval(() => setNow(Date.now()), tickMs);
|
||||
return () => clearInterval(id);
|
||||
}, [targetISO, tickMs]);
|
||||
|
||||
if (!targetISO) return null;
|
||||
const diff = Math.max(0, Math.floor((new Date(targetISO).getTime() - now) / 1000));
|
||||
return formatCountdown(diff);
|
||||
}
|
||||
@@ -81,6 +81,7 @@
|
||||
},
|
||||
"detail": {
|
||||
"tabs": {
|
||||
"agent": "Agent",
|
||||
"general": "General",
|
||||
"config": "Config",
|
||||
"files": "Files",
|
||||
@@ -93,7 +94,14 @@
|
||||
"evolving": "Evolving",
|
||||
"static": "Static",
|
||||
"evolvingTooltipDetail": "Agent can self-evolve its communication style (SOUL.md). Identity and workflow stay fixed.",
|
||||
"staticTooltipDetail": "Agent style is static. Enable Self-Evolution in General tab to allow style adaptation."
|
||||
"staticTooltipDetail": "Agent style is static. Enable Self-Evolution in General tab to allow style adaptation.",
|
||||
"advanced": "Advanced",
|
||||
"personality": "Agent Personality",
|
||||
"modelBudget": "Model & Budget",
|
||||
"skills": "Skills",
|
||||
"evolution": "Evolution",
|
||||
"capabilities": "Capabilities",
|
||||
"llmSeesAs": "LLM sees this as"
|
||||
},
|
||||
"general": {
|
||||
"selfEvolution": "Self-Evolution",
|
||||
@@ -398,6 +406,66 @@
|
||||
"invalidJson": "Invalid JSON syntax"
|
||||
}
|
||||
},
|
||||
"heartbeat": {
|
||||
"title": "Heartbeat",
|
||||
"loading": "Loading heartbeat...",
|
||||
"notConfigured": "No heartbeat schedule configured. Set one up to run this agent on autopilot.",
|
||||
"setupButton": "Setup Heartbeat",
|
||||
"toggleEnabled": "Toggle heartbeat enabled",
|
||||
"every": "Every {{interval}}m",
|
||||
"lastRun": "Last",
|
||||
"nextRun": "Next",
|
||||
"runs": "Runs",
|
||||
"suppressed": "Suppressed",
|
||||
"configure": "Configure",
|
||||
"logs": "Logs",
|
||||
"configTitle": "Heartbeat Configuration",
|
||||
"enabled": "Enabled",
|
||||
"enabledHint": "When enabled, the agent runs on the configured interval.",
|
||||
"sectionTiming": "Timing",
|
||||
"interval": "Interval (seconds)",
|
||||
"intervalHint": "Minimum 300 seconds (5 minutes).",
|
||||
"maxRetries": "Max Retries",
|
||||
"maxRetriesHint": "0–10 retries on failure.",
|
||||
"ackMaxChars": "Ack Max Chars",
|
||||
"ackMaxCharsHint": "Max characters in the acknowledgement reply. 0 = no reply.",
|
||||
"sectionBehavior": "Behavior",
|
||||
"isolatedSession": "Isolated Session",
|
||||
"isolatedSessionHint": "Run in a fresh session, not continuing the last conversation.",
|
||||
"lightContext": "Light Context",
|
||||
"lightContextHint": "Skip loading full context files to reduce token usage.",
|
||||
"sectionSchedule": "Schedule",
|
||||
"activeHoursStart": "Active From (HH:MM)",
|
||||
"activeHoursEnd": "Active Until (HH:MM)",
|
||||
"timezone": "Timezone",
|
||||
"sectionDelivery": "Delivery",
|
||||
"channel": "Channel",
|
||||
"channelPlaceholder": "Select channel",
|
||||
"channelNone": "None (no delivery)",
|
||||
"channelHint": "e.g. telegram, feishu. Leave empty for no delivery.",
|
||||
"chatId": "Chat ID",
|
||||
"chatIdPlaceholder": "Select chat",
|
||||
"selectChannelFirst": "Select a channel first",
|
||||
"checklist": "Checklist (HEARTBEAT.md)",
|
||||
"checklistHint": "Instructions injected into each heartbeat run. Supports markdown.",
|
||||
"checklistLoading": "Loading checklist...",
|
||||
"checklistPlaceholder": "# Heartbeat Checklist\n\n- Check pending tasks\n- Report status\n",
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"notSet": "Not Set",
|
||||
"disabled": "Heartbeat disabled",
|
||||
"nextIn": "Next in {{time}}",
|
||||
"advancedSettings": "Advanced Settings",
|
||||
"scheduleHint": "Leave empty for 24/7 operation.",
|
||||
"testRun": "Test Run",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"logsTitle": "Heartbeat Logs",
|
||||
"refresh": "Refresh",
|
||||
"noLogs": "No heartbeat logs yet.",
|
||||
"logsPagination": "{{from}}–{{to}} of {{total}}"
|
||||
},
|
||||
"toast": {
|
||||
"created": "Agent created",
|
||||
"createFailed": "Failed to create agent",
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
},
|
||||
"detail": {
|
||||
"tabs": {
|
||||
"agent": "Agent",
|
||||
"general": "Chung",
|
||||
"config": "Cấu hình",
|
||||
"files": "Tệp",
|
||||
@@ -93,7 +94,14 @@
|
||||
"evolving": "Đang tiến hóa",
|
||||
"static": "Tĩnh",
|
||||
"evolvingTooltipDetail": "Agent có thể tự tiến hóa phong cách giao tiếp (SOUL.md). Nhận dạng và quy trình làm việc giữ nguyên.",
|
||||
"staticTooltipDetail": "Phong cách agent là tĩnh. Bật Tự Tiến Hóa trong tab Chung để cho phép thích nghi phong cách."
|
||||
"staticTooltipDetail": "Phong cách agent là tĩnh. Bật Tự Tiến Hóa trong tab Chung để cho phép thích nghi phong cách.",
|
||||
"advanced": "Nâng cao",
|
||||
"personality": "Cá tính Agent",
|
||||
"modelBudget": "Mô hình & Ngân sách",
|
||||
"skills": "Kỹ năng",
|
||||
"evolution": "Tiến hóa",
|
||||
"capabilities": "Khả năng",
|
||||
"llmSeesAs": "LLM nhìn thấy đây là"
|
||||
},
|
||||
"general": {
|
||||
"selfEvolution": "Tự tiến hóa",
|
||||
@@ -398,6 +406,66 @@
|
||||
"invalidJson": "Cú pháp JSON không hợp lệ"
|
||||
}
|
||||
},
|
||||
"heartbeat": {
|
||||
"title": "Heartbeat",
|
||||
"loading": "Đang tải heartbeat...",
|
||||
"notConfigured": "Chưa cấu hình lịch heartbeat. Thiết lập để agent chạy tự động.",
|
||||
"setupButton": "Thiết lập Heartbeat",
|
||||
"toggleEnabled": "Bật/tắt heartbeat",
|
||||
"every": "Mỗi {{interval}} phút",
|
||||
"lastRun": "Lần cuối",
|
||||
"nextRun": "Lần tới",
|
||||
"runs": "Lần chạy",
|
||||
"suppressed": "Bị bỏ qua",
|
||||
"configure": "Cấu hình",
|
||||
"logs": "Nhật ký",
|
||||
"configTitle": "Cấu hình Heartbeat",
|
||||
"enabled": "Kích hoạt",
|
||||
"enabledHint": "Khi bật, agent sẽ chạy theo chu kỳ đã cấu hình.",
|
||||
"sectionTiming": "Thời gian",
|
||||
"interval": "Chu kỳ (giây)",
|
||||
"intervalHint": "Tối thiểu 300 giây (5 phút).",
|
||||
"maxRetries": "Số lần thử lại",
|
||||
"maxRetriesHint": "0–10 lần thử lại khi thất bại.",
|
||||
"ackMaxChars": "Ký tự phản hồi tối đa",
|
||||
"ackMaxCharsHint": "Số ký tự tối đa trong phản hồi. 0 = không phản hồi.",
|
||||
"sectionBehavior": "Hành vi",
|
||||
"isolatedSession": "Phiên cô lập",
|
||||
"isolatedSessionHint": "Chạy trong phiên mới, không tiếp tục cuộc trò chuyện trước.",
|
||||
"lightContext": "Ngữ cảnh nhẹ",
|
||||
"lightContextHint": "Bỏ qua tải đầy đủ tệp ngữ cảnh để giảm token.",
|
||||
"sectionSchedule": "Lịch hoạt động",
|
||||
"activeHoursStart": "Bắt đầu (HH:MM)",
|
||||
"activeHoursEnd": "Kết thúc (HH:MM)",
|
||||
"timezone": "Múi giờ",
|
||||
"sectionDelivery": "Gửi kết quả",
|
||||
"channel": "Kênh",
|
||||
"channelPlaceholder": "Chọn kênh",
|
||||
"channelNone": "Không (không gửi)",
|
||||
"channelHint": "Ví dụ: telegram, feishu. Để trống nếu không gửi.",
|
||||
"chatId": "Chat ID",
|
||||
"chatIdPlaceholder": "Chọn chat",
|
||||
"selectChannelFirst": "Chọn kênh trước",
|
||||
"checklist": "Danh sách kiểm tra (HEARTBEAT.md)",
|
||||
"checklistHint": "Hướng dẫn được đưa vào mỗi lần chạy heartbeat. Hỗ trợ markdown.",
|
||||
"checklistLoading": "Đang tải danh sách kiểm tra...",
|
||||
"checklistPlaceholder": "# Danh sách kiểm tra Heartbeat\n\n- Kiểm tra các tác vụ đang chờ\n- Báo cáo trạng thái\n",
|
||||
"on": "Bật",
|
||||
"off": "Tắt",
|
||||
"notSet": "Chưa cài",
|
||||
"disabled": "Heartbeat đã tắt",
|
||||
"nextIn": "Còn {{time}}",
|
||||
"advancedSettings": "Cài đặt nâng cao",
|
||||
"scheduleHint": "Để trống để chạy 24/7.",
|
||||
"testRun": "Chạy thử",
|
||||
"cancel": "Hủy",
|
||||
"save": "Lưu",
|
||||
"saving": "Đang lưu...",
|
||||
"logsTitle": "Nhật ký Heartbeat",
|
||||
"refresh": "Làm mới",
|
||||
"noLogs": "Chưa có nhật ký heartbeat.",
|
||||
"logsPagination": "{{from}}–{{to}} / {{total}}"
|
||||
},
|
||||
"toast": {
|
||||
"created": "Đã tạo agent",
|
||||
"createFailed": "Không thể tạo agent",
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
},
|
||||
"detail": {
|
||||
"tabs": {
|
||||
"agent": "Agent",
|
||||
"general": "基本信息",
|
||||
"config": "配置",
|
||||
"files": "文件",
|
||||
@@ -93,7 +94,14 @@
|
||||
"evolving": "进化中",
|
||||
"static": "静态",
|
||||
"evolvingTooltipDetail": "Agent可以自我进化其沟通风格 (SOUL.md)。身份和工作流程保持不变。",
|
||||
"staticTooltipDetail": "Agent风格是静态的。在常规选项卡中启用自我进化以允许风格适应。"
|
||||
"staticTooltipDetail": "Agent风格是静态的。在常规选项卡中启用自我进化以允许风格适应。",
|
||||
"advanced": "高级",
|
||||
"personality": "Agent个性",
|
||||
"modelBudget": "模型与预算",
|
||||
"skills": "技能",
|
||||
"evolution": "进化",
|
||||
"capabilities": "能力",
|
||||
"llmSeesAs": "LLM会看到这个为"
|
||||
},
|
||||
"general": {
|
||||
"selfEvolution": "自我进化",
|
||||
@@ -398,6 +406,66 @@
|
||||
"invalidJson": "JSON 语法无效"
|
||||
}
|
||||
},
|
||||
"heartbeat": {
|
||||
"title": "心跳",
|
||||
"loading": "加载心跳配置中...",
|
||||
"notConfigured": "尚未配置心跳计划。设置后可让代理自动运行。",
|
||||
"setupButton": "设置心跳",
|
||||
"toggleEnabled": "启用/禁用心跳",
|
||||
"every": "每 {{interval}} 分钟",
|
||||
"lastRun": "上次",
|
||||
"nextRun": "下次",
|
||||
"runs": "运行次数",
|
||||
"suppressed": "已抑制",
|
||||
"configure": "配置",
|
||||
"logs": "日志",
|
||||
"configTitle": "心跳配置",
|
||||
"enabled": "启用",
|
||||
"enabledHint": "启用后,代理将按配置的周期运行。",
|
||||
"sectionTiming": "时间设置",
|
||||
"interval": "间隔(秒)",
|
||||
"intervalHint": "最短 300 秒(5 分钟)。",
|
||||
"maxRetries": "最大重试次数",
|
||||
"maxRetriesHint": "失败时重试 0–10 次。",
|
||||
"ackMaxChars": "响应最大字符数",
|
||||
"ackMaxCharsHint": "响应的最大字符数。0 = 不响应。",
|
||||
"sectionBehavior": "行为",
|
||||
"isolatedSession": "独立会话",
|
||||
"isolatedSessionHint": "在全新会话中运行,不延续上次对话。",
|
||||
"lightContext": "轻量上下文",
|
||||
"lightContextHint": "跳过加载完整上下文文件以减少令牌消耗。",
|
||||
"sectionSchedule": "活跃时段",
|
||||
"activeHoursStart": "开始时间(HH:MM)",
|
||||
"activeHoursEnd": "结束时间(HH:MM)",
|
||||
"timezone": "时区",
|
||||
"sectionDelivery": "推送设置",
|
||||
"channel": "渠道",
|
||||
"channelPlaceholder": "选择渠道",
|
||||
"channelNone": "无(不推送)",
|
||||
"channelHint": "例如 telegram、feishu。留空则不推送。",
|
||||
"chatId": "Chat ID",
|
||||
"chatIdPlaceholder": "选择会话",
|
||||
"selectChannelFirst": "请先选择渠道",
|
||||
"checklist": "检查清单(HEARTBEAT.md)",
|
||||
"checklistHint": "每次心跳运行时注入的指令,支持 Markdown。",
|
||||
"checklistLoading": "加载检查清单中...",
|
||||
"checklistPlaceholder": "# 心跳检查清单\n\n- 检查待处理任务\n- 报告状态\n",
|
||||
"on": "开启",
|
||||
"off": "关闭",
|
||||
"notSet": "未设置",
|
||||
"disabled": "心跳已关闭",
|
||||
"nextIn": "{{time}}后",
|
||||
"advancedSettings": "高级设置",
|
||||
"scheduleHint": "留空则24/7全天运行。",
|
||||
"testRun": "测试运行",
|
||||
"cancel": "取消",
|
||||
"save": "保存",
|
||||
"saving": "保存中...",
|
||||
"logsTitle": "心跳日志",
|
||||
"refresh": "刷新",
|
||||
"noLogs": "暂无心跳日志。",
|
||||
"logsPagination": "{{from}}–{{to}} / 共 {{total}}"
|
||||
},
|
||||
"toast": {
|
||||
"created": "代理已创建",
|
||||
"createFailed": "创建代理失败",
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Save, Settings } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ConfigGroupHeader } from "@/components/shared/config-group-header";
|
||||
import type {
|
||||
AgentData, CompactionConfig, ContextPruningConfig,
|
||||
SandboxConfig, WorkspaceSharingConfig,
|
||||
} from "@/types/agent";
|
||||
import {
|
||||
ThinkingSection, WorkspaceSharingSection, CompactionSection,
|
||||
ContextPruningSection, SandboxSection,
|
||||
} from "./config-sections";
|
||||
import { WorkspaceSection } from "./general-sections";
|
||||
|
||||
interface AgentAdvancedDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
agent: AgentData;
|
||||
onUpdate: (updates: Record<string, unknown>) => Promise<void>;
|
||||
}
|
||||
|
||||
export function AgentAdvancedDialog({ open, onOpenChange, agent, onUpdate }: AgentAdvancedDialogProps) {
|
||||
const { t } = useTranslation("agents");
|
||||
|
||||
const deriveState = (a: AgentData) => {
|
||||
const otherObj = (a.other_config ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
thinkingLevel: typeof otherObj.thinking_level === "string" ? otherObj.thinking_level : "off",
|
||||
wsSharing: (otherObj.workspace_sharing ?? {}) as WorkspaceSharingConfig,
|
||||
comp: a.compaction_config ?? {},
|
||||
pruneEnabled: a.context_pruning != null,
|
||||
prune: a.context_pruning ?? {},
|
||||
sbEnabled: a.sandbox_config != null,
|
||||
sb: a.sandbox_config ?? {},
|
||||
};
|
||||
};
|
||||
|
||||
const init = deriveState(agent);
|
||||
const [wsSharing, setWsSharing] = useState<WorkspaceSharingConfig>(init.wsSharing);
|
||||
const [thinkingLevel, setThinkingLevel] = useState(init.thinkingLevel);
|
||||
const [comp, setComp] = useState<CompactionConfig>(init.comp);
|
||||
const [pruneEnabled, setPruneEnabled] = useState(init.pruneEnabled);
|
||||
const [prune, setPrune] = useState<ContextPruningConfig>(init.prune);
|
||||
const [sbEnabled, setSbEnabled] = useState(init.sbEnabled);
|
||||
const [sb, setSb] = useState<SandboxConfig>(init.sb);
|
||||
|
||||
// Re-sync local state when dialog opens (picks up latest agent data from React Query)
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const s = deriveState(agent);
|
||||
setThinkingLevel(s.thinkingLevel);
|
||||
setWsSharing(s.wsSharing);
|
||||
setComp(s.comp);
|
||||
setPruneEnabled(s.pruneEnabled);
|
||||
setPrune(s.prune);
|
||||
setSbEnabled(s.sbEnabled);
|
||||
setSb(s.sb);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
// Only send the keys this dialog owns to avoid overwriting keys managed by
|
||||
// the overview tab. The backend does a full column replace, so we must read
|
||||
// the latest agent data and merge our keys into it.
|
||||
const existing = (agent.other_config as Record<string, unknown> | null) ?? {};
|
||||
const otherBase: Record<string, unknown> = { ...existing };
|
||||
delete otherBase.thinking_level;
|
||||
delete otherBase.workspace_sharing;
|
||||
if (thinkingLevel && thinkingLevel !== "off") {
|
||||
otherBase.thinking_level = thinkingLevel;
|
||||
}
|
||||
if (
|
||||
wsSharing.shared_dm || wsSharing.shared_group ||
|
||||
(wsSharing.shared_users?.length ?? 0) > 0 || wsSharing.share_memory
|
||||
) {
|
||||
otherBase.workspace_sharing = wsSharing;
|
||||
}
|
||||
await onUpdate({
|
||||
compaction_config: comp,
|
||||
context_pruning: pruneEnabled ? prune : null,
|
||||
sandbox_config: sbEnabled ? sb : null,
|
||||
other_config: otherBase,
|
||||
});
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof Error ? err.message : t("config.failedToSave"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] w-[95vw] flex flex-col sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
{t("detail.advanced")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Scrollable body */}
|
||||
<div className="overflow-y-auto min-h-0 -mx-4 px-4 sm:-mx-6 sm:px-6 space-y-4">
|
||||
{/* Workspace (read-only) */}
|
||||
<WorkspaceSection workspace={agent.workspace} />
|
||||
|
||||
{/* Workspace Sharing */}
|
||||
<WorkspaceSharingSection value={wsSharing} onChange={setWsSharing} />
|
||||
|
||||
{/* Thinking */}
|
||||
<ThinkingSection value={thinkingLevel} onChange={setThinkingLevel} />
|
||||
|
||||
{/* Performance */}
|
||||
<ConfigGroupHeader
|
||||
title={t("configGroups.performance")}
|
||||
description={t("configGroups.performanceDesc")}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<CompactionSection value={comp} onChange={setComp} />
|
||||
<ContextPruningSection
|
||||
enabled={pruneEnabled}
|
||||
value={prune}
|
||||
onToggle={(v) => { setPruneEnabled(v); if (!v) setPrune({}); }}
|
||||
onChange={setPrune}
|
||||
/>
|
||||
<SandboxSection
|
||||
enabled={sbEnabled}
|
||||
value={sb}
|
||||
onToggle={(v) => { setSbEnabled(v); if (!v) setSb({}); }}
|
||||
onChange={setSb}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex flex-col gap-2 pt-4 border-t shrink-0">
|
||||
{saveError && (
|
||||
<p className="text-sm text-destructive">{saveError}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||
{t("create.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{!saving && <Save className="h-4 w-4" />}
|
||||
{saving ? t("config.saving") : t("config.saveConfig")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ConfigGroupHeader } from "@/components/shared/config-group-header";
|
||||
import { StickySaveBar } from "@/components/shared/sticky-save-bar";
|
||||
import type {
|
||||
AgentData,
|
||||
SubagentsConfig,
|
||||
ToolPolicyConfig,
|
||||
CompactionConfig,
|
||||
ContextPruningConfig,
|
||||
SandboxConfig,
|
||||
MemoryConfig,
|
||||
WorkspaceSharingConfig,
|
||||
} from "@/types/agent";
|
||||
import {
|
||||
SubagentsSection,
|
||||
ToolPolicySection,
|
||||
CompactionSection,
|
||||
ContextPruningSection,
|
||||
SandboxSection,
|
||||
MemorySection,
|
||||
ThinkingSection,
|
||||
WorkspaceSharingSection,
|
||||
} from "./config-sections";
|
||||
|
||||
interface AgentConfigTabProps {
|
||||
agent: AgentData;
|
||||
onUpdate: (updates: Record<string, unknown>) => Promise<void>;
|
||||
}
|
||||
|
||||
export function AgentConfigTab({ agent, onUpdate }: AgentConfigTabProps) {
|
||||
const { t } = useTranslation("agents");
|
||||
|
||||
const [subEnabled, setSubEnabled] = useState(agent.subagents_config != null);
|
||||
const [sub, setSub] = useState<SubagentsConfig>(agent.subagents_config ?? {});
|
||||
|
||||
const [toolsEnabled, setToolsEnabled] = useState(agent.tools_config != null);
|
||||
const [tools, setTools] = useState<ToolPolicyConfig>(agent.tools_config ?? {});
|
||||
|
||||
const [comp, setComp] = useState<CompactionConfig>(agent.compaction_config ?? {});
|
||||
|
||||
const [pruneEnabled, setPruneEnabled] = useState(agent.context_pruning != null);
|
||||
const [prune, setPrune] = useState<ContextPruningConfig>(agent.context_pruning ?? {});
|
||||
|
||||
const [sbEnabled, setSbEnabled] = useState(agent.sandbox_config != null);
|
||||
const [sb, setSb] = useState<SandboxConfig>(agent.sandbox_config ?? {});
|
||||
|
||||
const [memEnabled, setMemEnabled] = useState(agent.memory_config != null);
|
||||
const [mem, setMem] = useState<MemoryConfig>(agent.memory_config ?? {});
|
||||
|
||||
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;
|
||||
|
||||
const [wsSharing, setWsSharing] = useState<WorkspaceSharingConfig>(initialWsSharing);
|
||||
|
||||
const [thinkingLevel, setThinkingLevel] = useState(initialThinkingLevel);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
setSaved(false);
|
||||
try {
|
||||
const updates: Record<string, unknown> = {
|
||||
subagents_config: subEnabled ? sub : null,
|
||||
tools_config: toolsEnabled
|
||||
? { profile: tools.profile, allow: tools.allow, deny: tools.deny, alsoAllow: tools.alsoAllow, byProvider: tools.byProvider }
|
||||
: {},
|
||||
compaction_config: comp,
|
||||
context_pruning: pruneEnabled ? prune : null,
|
||||
sandbox_config: sbEnabled ? sb : null,
|
||||
memory_config: memEnabled ? mem : null,
|
||||
};
|
||||
// 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) ?? {};
|
||||
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 (thinkingLevel && thinkingLevel !== "off") {
|
||||
otherBase.thinking_level = thinkingLevel;
|
||||
}
|
||||
if (wsSharing.shared_dm || wsSharing.shared_group || (wsSharing.shared_users?.length ?? 0) > 0 || wsSharing.share_memory) {
|
||||
otherBase.workspace_sharing = wsSharing;
|
||||
}
|
||||
updates.other_config = Object.keys(otherBase).length > 0 ? otherBase : {};
|
||||
await onUpdate(updates);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof Error ? err.message : t("config.failedToSave"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-4">
|
||||
{/* Workspace & Security */}
|
||||
<WorkspaceSharingSection
|
||||
value={wsSharing}
|
||||
onChange={setWsSharing}
|
||||
/>
|
||||
|
||||
{/* Core */}
|
||||
<ThinkingSection
|
||||
value={thinkingLevel}
|
||||
onChange={setThinkingLevel}
|
||||
/>
|
||||
|
||||
{/* Capabilities */}
|
||||
<ConfigGroupHeader
|
||||
title={t("configGroups.capabilities")}
|
||||
description={t("configGroups.capabilitiesDesc")}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<MemorySection
|
||||
enabled={memEnabled}
|
||||
value={mem}
|
||||
onToggle={(v: boolean) => { setMemEnabled(v); if (!v) setMem({}); }}
|
||||
onChange={setMem}
|
||||
/>
|
||||
<SubagentsSection
|
||||
enabled={subEnabled}
|
||||
value={sub}
|
||||
onToggle={(v: boolean) => { setSubEnabled(v); if (!v) setSub({}); }}
|
||||
onChange={setSub}
|
||||
/>
|
||||
<ToolPolicySection
|
||||
enabled={toolsEnabled}
|
||||
value={tools}
|
||||
onToggle={(v: boolean) => { setToolsEnabled(v); if (!v) setTools({}); }}
|
||||
onChange={setTools}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Performance */}
|
||||
<ConfigGroupHeader
|
||||
title={t("configGroups.performance")}
|
||||
description={t("configGroups.performanceDesc")}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<CompactionSection
|
||||
value={comp}
|
||||
onChange={setComp}
|
||||
/>
|
||||
<ContextPruningSection
|
||||
enabled={pruneEnabled}
|
||||
value={prune}
|
||||
onToggle={(v: boolean) => { setPruneEnabled(v); if (!v) setPrune({}); }}
|
||||
onChange={setPrune}
|
||||
/>
|
||||
<SandboxSection
|
||||
enabled={sbEnabled}
|
||||
value={sb}
|
||||
onToggle={(v: boolean) => { setSbEnabled(v); if (!v) setSb({}); }}
|
||||
onChange={setSb}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<StickySaveBar
|
||||
onSave={handleSave}
|
||||
saving={saving}
|
||||
saved={saved}
|
||||
error={saveError}
|
||||
label={t("config.saveConfig")}
|
||||
savingLabel={t("config.saving")}
|
||||
savedLabel={t("config.saved")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
|
||||
import type { AgentData } from "@/types/agent";
|
||||
|
||||
interface AgentDangerTabProps {
|
||||
agent: AgentData;
|
||||
onDelete: () => Promise<void>;
|
||||
onDeleted: () => void;
|
||||
}
|
||||
|
||||
export function AgentDangerTab({ agent, onDelete, onDeleted }: AgentDangerTabProps) {
|
||||
const { t } = useTranslation("agents");
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-destructive">{t("delete.title")}</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("delete.description")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="shrink-0 gap-2"
|
||||
disabled={agent.is_default}
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{t("delete.title")}
|
||||
</Button>
|
||||
</div>
|
||||
{agent.is_default && (
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
{t("delete.defaultCannotDelete")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmOpen}
|
||||
onOpenChange={setConfirmOpen}
|
||||
title={t("delete.title")}
|
||||
description={t("delete.detailDescription", { name: agent.display_name || agent.agent_key })}
|
||||
confirmLabel={t("delete.confirmLabel")}
|
||||
variant="destructive"
|
||||
onConfirm={async () => {
|
||||
await onDelete();
|
||||
setConfirmOpen(false);
|
||||
onDeleted();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +1,36 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { ArrowLeft, Bot, Star, Trash2, Sparkles } from "lucide-react";
|
||||
import { useAgentDetail } from "../hooks/use-agent-detail";
|
||||
import { useAgents } from "../hooks/use-agents";
|
||||
import { AgentGeneralTab } from "./agent-general-tab";
|
||||
import { AgentConfigTab } from "./agent-config-tab";
|
||||
import { useAgentHeartbeat } from "../hooks/use-agent-heartbeat";
|
||||
import { AgentHeader } from "./agent-header";
|
||||
import { AgentOverviewTab } from "./agent-overview-tab";
|
||||
import { AgentFilesTab } from "./agent-files-tab";
|
||||
import { AgentSkillsTab } from "./agent-skills-tab";
|
||||
import { AgentInstancesTab } from "./agent-instances-tab";
|
||||
import { AgentAdvancedDialog } from "./agent-advanced-dialog";
|
||||
import { HeartbeatConfigDialog } from "./heartbeat-config-dialog";
|
||||
import { SummoningModal } from "../summoning-modal";
|
||||
import { ConfirmDeleteDialog } from "@/components/shared/confirm-delete-dialog";
|
||||
import { DetailPageSkeleton } from "@/components/shared/loading-skeleton";
|
||||
import { agentDisplayName } from "./agent-display-utils";
|
||||
|
||||
interface AgentDetailPageProps {
|
||||
agentId: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
function agentDisplayName(agent: { display_name?: string; agent_key: string }, unnamedLabel: string) {
|
||||
if (agent.display_name) return agent.display_name;
|
||||
if (UUID_RE.test(agent.agent_key)) return unnamedLabel;
|
||||
return agent.agent_key;
|
||||
}
|
||||
|
||||
function agentSubtitle(agent: { display_name?: string; agent_key: string; id: string }) {
|
||||
if (!agent.display_name && agent.agent_key === agent.id) return null;
|
||||
if (UUID_RE.test(agent.agent_key)) return agent.agent_key.slice(0, 8) + "…";
|
||||
return agent.agent_key;
|
||||
}
|
||||
|
||||
export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
|
||||
const { t } = useTranslation("agents");
|
||||
const { agent, files, loading, updateAgent, getFile, setFile, regenerateAgent, resummonAgent, refresh } =
|
||||
useAgentDetail(agentId);
|
||||
const { deleteAgent: deleteAgentById } = useAgents();
|
||||
const hb = useAgentHeartbeat(agentId);
|
||||
const [summoningOpen, setSummoningOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState("general");
|
||||
const [activeTab, setActiveTab] = useState("agent");
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [heartbeatOpen, setHeartbeatOpen] = useState(false);
|
||||
|
||||
const handleRegenerate = async (prompt: string) => {
|
||||
await regenerateAgent(prompt);
|
||||
@@ -60,137 +48,65 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
|
||||
};
|
||||
|
||||
if (loading || !agent) {
|
||||
return <DetailPageSkeleton tabs={6} />;
|
||||
return <DetailPageSkeleton tabs={3} />;
|
||||
}
|
||||
|
||||
const title = agentDisplayName(agent, t("card.unnamedAgent"));
|
||||
const subtitle = agentSubtitle(agent);
|
||||
const emoji = typeof (agent.other_config as Record<string, unknown> | null)?.emoji === "string"
|
||||
? (agent.other_config as Record<string, unknown>).emoji as string
|
||||
: "";
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="p-4 sm:p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-start gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={onBack} className="mt-0.5 shrink-0">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
{emoji ? <span className="text-2xl leading-none">{emoji}</span> : <Bot className="h-6 w-6" />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="truncate text-xl font-semibold">{title}</h2>
|
||||
{agent.is_default && (
|
||||
<Star className="h-4 w-4 shrink-0 fill-amber-400 text-amber-400" />
|
||||
)}
|
||||
<Badge variant={agent.status === "active" ? "success" : agent.status === "summon_failed" ? "destructive" : "secondary"}>
|
||||
{agent.status === "summon_failed" ? t("detail.summonFailed") : agent.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground">
|
||||
{subtitle && (
|
||||
<>
|
||||
<span className="font-mono text-xs">{subtitle}</span>
|
||||
<span className="text-border">|</span>
|
||||
</>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="outline" className="text-[11px]">{agent.agent_type}</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-[260px] text-xs">
|
||||
{agent.agent_type === "predefined"
|
||||
? t("card.predefinedTooltip")
|
||||
: t("card.openTooltip")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{agent.agent_type === "predefined" && (() => {
|
||||
const evolving = Boolean((agent.other_config as Record<string, unknown> | null)?.self_evolve);
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant={evolving ? "default" : "outline"}
|
||||
className={`text-[11px] ${evolving ? "bg-violet-100 text-violet-700 hover:bg-violet-100 dark:bg-violet-900/30 dark:text-violet-300" : "text-muted-foreground"}`}
|
||||
>
|
||||
<Sparkles className="mr-0.5 h-3 w-3" />
|
||||
{evolving ? t("detail.evolving") : t("detail.static")}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-[240px] text-xs">
|
||||
{evolving
|
||||
? t("detail.evolvingTooltipDetail")
|
||||
: t("detail.staticTooltipDetail")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})()}
|
||||
{agent.provider && (
|
||||
<>
|
||||
<span className="text-border">|</span>
|
||||
<span>{agent.provider} / {agent.model}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<AgentHeader
|
||||
agent={agent}
|
||||
heartbeat={hb.config}
|
||||
onBack={onBack}
|
||||
onDelete={() => setDeleteOpen(true)}
|
||||
onAdvanced={() => setAdvancedOpen(true)}
|
||||
onHeartbeat={() => setHeartbeatOpen(true)}
|
||||
/>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="max-w-4xl rounded-xl border bg-card p-3 shadow-sm sm:p-4">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="w-full justify-start overflow-x-auto overflow-y-hidden">
|
||||
<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="skills">{t("detail.tabs.skills")}</TabsTrigger>
|
||||
{agent.agent_type === "predefined" && (
|
||||
<TabsTrigger value="instances">{t("detail.tabs.instances")}</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
<div className="p-3 sm:p-4">
|
||||
<div className="max-w-4xl">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="w-full justify-start overflow-x-auto overflow-y-hidden">
|
||||
<TabsTrigger value="agent">{t("detail.tabs.agent")}</TabsTrigger>
|
||||
<TabsTrigger value="files">{t("detail.tabs.files")}</TabsTrigger>
|
||||
{agent.agent_type === "predefined" && (
|
||||
<TabsTrigger value="instances">{t("detail.tabs.instances")}</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="general" className="mt-4">
|
||||
<AgentGeneralTab agent={agent} onUpdate={updateAgent} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="config" className="mt-4">
|
||||
<AgentConfigTab agent={agent} onUpdate={updateAgent} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="files" className="mt-4">
|
||||
<AgentFilesTab
|
||||
agent={agent}
|
||||
files={files}
|
||||
onGetFile={getFile}
|
||||
onSetFile={setFile}
|
||||
onRegenerate={handleRegenerate}
|
||||
onResummon={handleResummon}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="skills" className="mt-4">
|
||||
<AgentSkillsTab agentId={agentId} />
|
||||
</TabsContent>
|
||||
|
||||
{agent.agent_type === "predefined" && (
|
||||
<TabsContent value="instances" className="mt-4">
|
||||
<AgentInstancesTab agentId={agentId} />
|
||||
<TabsContent value="agent" className="mt-4">
|
||||
<AgentOverviewTab key={agent.id + "-" + agent.updated_at} agent={agent} onUpdate={updateAgent} heartbeat={hb} />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
</Tabs>
|
||||
<TabsContent value="files" className="mt-4">
|
||||
<AgentFilesTab
|
||||
agent={agent}
|
||||
files={files}
|
||||
onGetFile={getFile}
|
||||
onSetFile={setFile}
|
||||
onRegenerate={handleRegenerate}
|
||||
onResummon={handleResummon}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{agent.agent_type === "predefined" && (
|
||||
<TabsContent value="instances" className="mt-4">
|
||||
<AgentInstancesTab agentId={agentId} />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AgentAdvancedDialog
|
||||
key={agent.id}
|
||||
open={advancedOpen}
|
||||
onOpenChange={setAdvancedOpen}
|
||||
agent={agent}
|
||||
onUpdate={updateAgent}
|
||||
/>
|
||||
|
||||
<SummoningModal
|
||||
open={summoningOpen}
|
||||
onOpenChange={handleSummoningClose}
|
||||
@@ -200,6 +116,21 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
|
||||
onResummon={async () => { await resummonAgent(); }}
|
||||
/>
|
||||
|
||||
{heartbeatOpen && (
|
||||
<HeartbeatConfigDialog
|
||||
open={heartbeatOpen}
|
||||
onOpenChange={setHeartbeatOpen}
|
||||
config={hb.config}
|
||||
saving={hb.saving}
|
||||
update={hb.update}
|
||||
test={hb.test}
|
||||
getChecklist={hb.getChecklist}
|
||||
setChecklist={hb.setChecklist}
|
||||
fetchTargets={hb.fetchTargets}
|
||||
refresh={hb.refresh}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDeleteDialog
|
||||
open={deleteOpen}
|
||||
onOpenChange={setDeleteOpen}
|
||||
@@ -214,6 +145,5 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Matches a standard UUID v4 string. */
|
||||
export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/** Returns the display name for an agent, falling back to agent_key or unnamedLabel. */
|
||||
export function agentDisplayName(
|
||||
agent: { display_name?: string; agent_key: string },
|
||||
unnamedLabel: string,
|
||||
): string {
|
||||
if (agent.display_name) return agent.display_name;
|
||||
if (UUID_RE.test(agent.agent_key)) return unnamedLabel;
|
||||
return agent.agent_key;
|
||||
}
|
||||
|
||||
/** Returns a shortened agent key for subtitle display (truncates UUIDs). */
|
||||
export function agentKeyDisplay(agentKey: string): string {
|
||||
return UUID_RE.test(agentKey) ? agentKey.slice(0, 8) + "…" : agentKey;
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { Save, Check, AlertCircle, Sparkles, Info, DollarSign, GraduationCap } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { AgentData } from "@/types/agent";
|
||||
import { IdentitySection, LlmConfigSection, WorkspaceSection } from "./general-sections";
|
||||
|
||||
interface AgentGeneralTabProps {
|
||||
agent: AgentData;
|
||||
onUpdate: (updates: Record<string, unknown>) => Promise<void>;
|
||||
}
|
||||
|
||||
export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
|
||||
const { t } = useTranslation("agents");
|
||||
|
||||
// Identity
|
||||
const [displayName, setDisplayName] = useState(agent.display_name ?? "");
|
||||
const [frontmatter, setFrontmatter] = useState(agent.frontmatter ?? "");
|
||||
const [status, setStatus] = useState(agent.status);
|
||||
const [isDefault, setIsDefault] = useState(agent.is_default);
|
||||
|
||||
// LLM
|
||||
const [provider, setProvider] = useState(agent.provider);
|
||||
const [model, setModel] = useState(agent.model);
|
||||
const [contextWindow, setContextWindow] = useState(agent.context_window || 200000);
|
||||
const [maxToolIterations, setMaxToolIterations] = useState(agent.max_tool_iterations || 20);
|
||||
const [llmSaveBlocked, setLlmSaveBlocked] = useState(false);
|
||||
|
||||
// Budget (stored in cents, displayed in dollars)
|
||||
const [budgetDollars, setBudgetDollars] = useState(
|
||||
agent.budget_monthly_cents ? String(agent.budget_monthly_cents / 100) : "",
|
||||
);
|
||||
|
||||
// Self-evolve & skill-evolve (predefined agents only)
|
||||
const otherCfg = (agent.other_config ?? {}) as Record<string, unknown>;
|
||||
const [emoji, setEmoji] = useState(typeof otherCfg.emoji === "string" ? otherCfg.emoji : "");
|
||||
const [selfEvolve, setSelfEvolve] = useState(Boolean(otherCfg.self_evolve));
|
||||
const [skillEvolve, setSkillEvolve] = useState(Boolean(otherCfg.skill_evolve));
|
||||
const [skillNudgeInterval, setSkillNudgeInterval] = useState(
|
||||
typeof otherCfg.skill_nudge_interval === "number" ? otherCfg.skill_nudge_interval : 15,
|
||||
);
|
||||
|
||||
// Save state
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const handleSaveBlockedChange = useCallback((blocked: boolean) => {
|
||||
setLlmSaveBlocked(blocked);
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
setSaved(false);
|
||||
try {
|
||||
const updatedOtherConfig = {
|
||||
...otherCfg,
|
||||
self_evolve: selfEvolve,
|
||||
skill_evolve: skillEvolve,
|
||||
skill_nudge_interval: skillEvolve ? skillNudgeInterval : undefined,
|
||||
emoji: emoji.trim() || undefined,
|
||||
};
|
||||
const budgetCents = budgetDollars ? Math.round(parseFloat(budgetDollars) * 100) : null;
|
||||
await onUpdate({
|
||||
display_name: displayName,
|
||||
frontmatter: frontmatter || null,
|
||||
provider,
|
||||
model,
|
||||
context_window: contextWindow,
|
||||
max_tool_iterations: maxToolIterations,
|
||||
status,
|
||||
is_default: isDefault,
|
||||
other_config: updatedOtherConfig,
|
||||
budget_monthly_cents: budgetCents,
|
||||
});
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof Error ? err.message : t("general.failedToSave"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6">
|
||||
<IdentitySection
|
||||
agentKey={agent.agent_key}
|
||||
emoji={emoji}
|
||||
onEmojiChange={setEmoji}
|
||||
displayName={displayName}
|
||||
onDisplayNameChange={setDisplayName}
|
||||
frontmatter={frontmatter}
|
||||
onFrontmatterChange={setFrontmatter}
|
||||
status={status}
|
||||
onStatusChange={setStatus}
|
||||
isDefault={isDefault}
|
||||
onIsDefaultChange={setIsDefault}
|
||||
/>
|
||||
|
||||
{/* Self-Evolve (predefined agents only) — right after Identity */}
|
||||
{agent.agent_type === "predefined" && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Sparkles className="h-4 w-4 text-violet-500" />
|
||||
<h3 className="text-sm font-medium">{t("general.selfEvolution")}</h3>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="self-evolve" className="text-sm font-normal">
|
||||
{t("general.selfEvolutionLabel")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("general.selfEvolutionHint")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="self-evolve"
|
||||
checked={selfEvolve}
|
||||
onCheckedChange={setSelfEvolve}
|
||||
/>
|
||||
</div>
|
||||
{selfEvolve && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-violet-200 bg-violet-50 px-3 py-2 text-xs text-violet-700 dark:border-violet-800 dark:bg-violet-950/30 dark:text-violet-300">
|
||||
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>{t("general.selfEvolutionInfo")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Skill Learning (predefined agents only) — right after Self-Evolve */}
|
||||
{agent.agent_type === "predefined" && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<GraduationCap className="h-4 w-4 text-amber-500" />
|
||||
<h3 className="text-sm font-medium">{t("general.skillLearning")}</h3>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="skill-evolve" className="text-sm font-normal">
|
||||
{t("general.skillLearningLabel")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("general.skillLearningHint")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="skill-evolve"
|
||||
checked={skillEvolve}
|
||||
onCheckedChange={setSkillEvolve}
|
||||
/>
|
||||
</div>
|
||||
{skillEvolve && (
|
||||
<>
|
||||
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
|
||||
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>{t("general.skillLearningInfo")}</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="nudge-interval" className="text-xs font-normal text-muted-foreground">
|
||||
{t("general.skillNudgeIntervalLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="nudge-interval"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={skillNudgeInterval}
|
||||
onChange={(e) => setSkillNudgeInterval(Number(e.target.value) || 0)}
|
||||
className="max-w-[120px] text-base md:text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("general.skillNudgeIntervalHint")}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<LlmConfigSection
|
||||
provider={provider}
|
||||
onProviderChange={setProvider}
|
||||
model={model}
|
||||
onModelChange={setModel}
|
||||
contextWindow={contextWindow}
|
||||
onContextWindowChange={setContextWindow}
|
||||
maxToolIterations={maxToolIterations}
|
||||
onMaxToolIterationsChange={setMaxToolIterations}
|
||||
savedProvider={agent.provider}
|
||||
savedModel={agent.model}
|
||||
onSaveBlockedChange={handleSaveBlockedChange}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<WorkspaceSection workspace={agent.workspace} />
|
||||
|
||||
{/* Budget */}
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<DollarSign className="h-4 w-4 text-emerald-500" />
|
||||
<h3 className="text-sm font-medium">{t("general.budget")}</h3>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="budget" className="text-sm font-normal">
|
||||
{t("general.budgetLabel")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("general.budgetHint")}
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
id="budget"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
value={budgetDollars}
|
||||
onChange={(e) => setBudgetDollars(e.target.value)}
|
||||
className="max-w-[200px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Save */}
|
||||
{saveError && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{saved && (
|
||||
<span className="flex items-center gap-1 text-sm text-success">
|
||||
<Check className="h-3.5 w-3.5" /> {t("general.saved")}
|
||||
</span>
|
||||
)}
|
||||
<Button onClick={handleSave} disabled={saving || llmSaveBlocked}>
|
||||
{!saving && <Save className="h-4 w-4" />}
|
||||
{saving ? t("general.saving") : t("general.saveChanges")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { ArrowLeft, Bot, Heart, Settings, Sparkles, Star, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AgentData } from "@/types/agent";
|
||||
import type { HeartbeatConfig } from "@/pages/agents/hooks/use-agent-heartbeat";
|
||||
import { useCountdown } from "@/hooks/use-countdown";
|
||||
import { agentDisplayName, agentKeyDisplay } from "./agent-display-utils";
|
||||
|
||||
interface AgentHeaderProps {
|
||||
agent: AgentData;
|
||||
heartbeat: HeartbeatConfig | null;
|
||||
onBack: () => void;
|
||||
onDelete: () => void;
|
||||
onAdvanced: () => void;
|
||||
onHeartbeat: () => void;
|
||||
}
|
||||
|
||||
export function AgentHeader({ agent, heartbeat, onBack, onDelete, onAdvanced, onHeartbeat }: AgentHeaderProps) {
|
||||
const { t } = useTranslation("agents");
|
||||
|
||||
const otherCfg = (agent.other_config ?? {}) as Record<string, unknown>;
|
||||
const emoji = typeof otherCfg.emoji === "string" ? otherCfg.emoji : "";
|
||||
const selfEvolve = Boolean(otherCfg.self_evolve);
|
||||
const title = agentDisplayName(agent, t("card.unnamedAgent"));
|
||||
const keyDisplay = agentKeyDisplay(agent.agent_key);
|
||||
|
||||
const hbConfigured = heartbeat != null;
|
||||
const hbEnabled = heartbeat?.enabled ?? false;
|
||||
const countdown = useCountdown(hbEnabled ? heartbeat?.nextRunAt : null);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="sticky top-0 z-10 flex items-center gap-2 border-b bg-card px-3 py-2 landscape-compact sm:px-4 sm:gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={onBack} className="shrink-0 size-9">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Emoji avatar */}
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary sm:h-12 sm:w-12">
|
||||
{emoji
|
||||
? <span className="text-xl leading-none sm:text-2xl">{emoji}</span>
|
||||
: <Bot className="h-5 w-5 sm:h-6 sm:w-6" />}
|
||||
</div>
|
||||
|
||||
{/* Agent info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<h2 className="truncate text-base font-semibold">{title}</h2>
|
||||
{agent.is_default && (
|
||||
<Star className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-400" />
|
||||
)}
|
||||
<Badge
|
||||
variant={
|
||||
agent.status === "active"
|
||||
? "success"
|
||||
: agent.status === "summon_failed"
|
||||
? "destructive"
|
||||
: "secondary"
|
||||
}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{agent.status === "summon_failed" ? t("detail.summonFailed") : agent.status}
|
||||
</Badge>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="outline" className="text-[10px]">{agent.agent_type}</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-[260px] text-xs">
|
||||
{agent.agent_type === "predefined" ? t("card.predefinedTooltip") : t("card.openTooltip")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{agent.agent_type === "predefined" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant={selfEvolve ? "default" : "outline"}
|
||||
className={`text-[10px] ${selfEvolve ? "bg-violet-100 text-violet-700 hover:bg-violet-100 dark:bg-violet-900/30 dark:text-violet-300" : "text-muted-foreground"}`}
|
||||
>
|
||||
<Sparkles className="mr-0.5 h-2.5 w-2.5" />
|
||||
{selfEvolve ? t("detail.evolving") : t("detail.static")}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-[240px] text-xs">
|
||||
{selfEvolve ? t("detail.evolvingTooltipDetail") : t("detail.staticTooltipDetail")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
|
||||
<span className="font-mono text-[11px]">{keyDisplay}</span>
|
||||
{agent.provider && (
|
||||
<>
|
||||
<span className="text-border">·</span>
|
||||
<span>{agent.provider} / {agent.model}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Heartbeat action */}
|
||||
<Button variant="ghost" size="sm" onClick={onHeartbeat} className="shrink-0 gap-1.5 size-9 sm:w-auto sm:px-3">
|
||||
<Heart className={`h-4 w-4 ${hbEnabled ? "fill-rose-500 text-rose-500 animate-pulse" : hbConfigured ? "text-rose-400" : "text-muted-foreground"}`} />
|
||||
<span className={`hidden sm:inline ${hbEnabled ? "text-rose-600 dark:text-rose-400" : ""}`}>
|
||||
{!hbConfigured
|
||||
? t("heartbeat.notSet")
|
||||
: !hbEnabled
|
||||
? t("heartbeat.off")
|
||||
: countdown
|
||||
? t("heartbeat.nextIn", { time: countdown })
|
||||
: t("heartbeat.on")}
|
||||
</span>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onAdvanced} className="shrink-0 gap-1.5 size-9 sm:w-auto sm:px-3">
|
||||
<Settings className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t("detail.advanced")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
className="shrink-0 gap-1.5 size-9 sm:w-auto sm:px-3 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t("delete.title")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
AgentData, MemoryConfig, SubagentsConfig, ToolPolicyConfig,
|
||||
} from "@/types/agent";
|
||||
import { StickySaveBar } from "@/components/shared/sticky-save-bar";
|
||||
import { PersonalitySection } from "./overview-sections/personality-section";
|
||||
import { ModelBudgetSection } from "./overview-sections/model-budget-section";
|
||||
import { SkillsSection } from "./overview-sections/skills-section";
|
||||
import { EvolutionSection } from "./overview-sections/evolution-section";
|
||||
import { CapabilitiesSection } from "./overview-sections/capabilities-section";
|
||||
import { HeartbeatCard } from "./overview-sections/heartbeat-card";
|
||||
import type { UseAgentHeartbeatReturn } from "../hooks/use-agent-heartbeat";
|
||||
|
||||
interface AgentOverviewTabProps {
|
||||
agent: AgentData;
|
||||
onUpdate: (updates: Record<string, unknown>) => Promise<void>;
|
||||
heartbeat: UseAgentHeartbeatReturn;
|
||||
}
|
||||
|
||||
export function AgentOverviewTab({ agent, onUpdate, heartbeat }: AgentOverviewTabProps) {
|
||||
const { t } = useTranslation("agents");
|
||||
|
||||
const otherCfg = (agent.other_config ?? {}) as Record<string, unknown>;
|
||||
|
||||
// Personality
|
||||
const [emoji, setEmoji] = useState(typeof otherCfg.emoji === "string" ? otherCfg.emoji : "");
|
||||
const [displayName, setDisplayName] = useState(agent.display_name ?? "");
|
||||
const [frontmatter, setFrontmatter] = useState(agent.frontmatter ?? "");
|
||||
const [status, setStatus] = useState(agent.status);
|
||||
const [isDefault, setIsDefault] = useState(agent.is_default);
|
||||
|
||||
// Model & Budget
|
||||
const [provider, setProvider] = useState(agent.provider);
|
||||
const [model, setModel] = useState(agent.model);
|
||||
const [contextWindow, setContextWindow] = useState(agent.context_window || 200000);
|
||||
const [maxToolIterations, setMaxToolIterations] = useState(agent.max_tool_iterations || 20);
|
||||
const [budgetDollars, setBudgetDollars] = useState(
|
||||
agent.budget_monthly_cents ? String(agent.budget_monthly_cents / 100) : "",
|
||||
);
|
||||
// Evolution (predefined only)
|
||||
const [selfEvolve, setSelfEvolve] = useState(Boolean(otherCfg.self_evolve));
|
||||
const [skillEvolve, setSkillEvolve] = useState(Boolean(otherCfg.skill_evolve));
|
||||
const [skillNudgeInterval, setSkillNudgeInterval] = useState(
|
||||
typeof otherCfg.skill_nudge_interval === "number" ? otherCfg.skill_nudge_interval : 15,
|
||||
);
|
||||
|
||||
// Capabilities
|
||||
const [memEnabled, setMemEnabled] = useState(agent.memory_config != null);
|
||||
const [mem, setMem] = useState<MemoryConfig>(agent.memory_config ?? {});
|
||||
const [subEnabled, setSubEnabled] = useState(agent.subagents_config != null);
|
||||
const [sub, setSub] = useState<SubagentsConfig>(agent.subagents_config ?? {});
|
||||
const [toolsEnabled, setToolsEnabled] = useState(agent.tools_config != null);
|
||||
const [tools, setTools] = useState<ToolPolicyConfig>(agent.tools_config ?? {});
|
||||
|
||||
// Save state
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [llmSaveBlocked, setLlmSaveBlocked] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
setSaved(false);
|
||||
try {
|
||||
const updatedOtherConfig = {
|
||||
...otherCfg,
|
||||
emoji: emoji.trim() || undefined,
|
||||
self_evolve: selfEvolve,
|
||||
skill_evolve: skillEvolve,
|
||||
skill_nudge_interval: skillEvolve ? skillNudgeInterval : undefined,
|
||||
};
|
||||
const budgetCents = budgetDollars ? Math.round(parseFloat(budgetDollars) * 100) : null;
|
||||
await onUpdate({
|
||||
display_name: displayName,
|
||||
frontmatter: frontmatter || null,
|
||||
provider,
|
||||
model,
|
||||
context_window: contextWindow,
|
||||
max_tool_iterations: maxToolIterations,
|
||||
status,
|
||||
is_default: isDefault,
|
||||
other_config: updatedOtherConfig,
|
||||
budget_monthly_cents: budgetCents,
|
||||
memory_config: memEnabled ? mem : null,
|
||||
subagents_config: subEnabled ? sub : null,
|
||||
tools_config: toolsEnabled
|
||||
? { profile: tools.profile, allow: tools.allow, deny: tools.deny, alsoAllow: tools.alsoAllow, byProvider: tools.byProvider }
|
||||
: {},
|
||||
});
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof Error ? err.message : t("general.failedToSave"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PersonalitySection
|
||||
agentKey={agent.agent_key}
|
||||
emoji={emoji}
|
||||
onEmojiChange={setEmoji}
|
||||
displayName={displayName}
|
||||
onDisplayNameChange={setDisplayName}
|
||||
frontmatter={frontmatter}
|
||||
onFrontmatterChange={setFrontmatter}
|
||||
status={status}
|
||||
onStatusChange={setStatus}
|
||||
isDefault={isDefault}
|
||||
onIsDefaultChange={setIsDefault}
|
||||
/>
|
||||
|
||||
<ModelBudgetSection
|
||||
provider={provider}
|
||||
onProviderChange={setProvider}
|
||||
model={model}
|
||||
onModelChange={setModel}
|
||||
contextWindow={contextWindow}
|
||||
onContextWindowChange={setContextWindow}
|
||||
maxToolIterations={maxToolIterations}
|
||||
onMaxToolIterationsChange={setMaxToolIterations}
|
||||
savedProvider={agent.provider}
|
||||
savedModel={agent.model}
|
||||
budgetDollars={budgetDollars}
|
||||
onBudgetDollarsChange={setBudgetDollars}
|
||||
onSaveBlockedChange={setLlmSaveBlocked}
|
||||
/>
|
||||
|
||||
<HeartbeatCard heartbeat={heartbeat} />
|
||||
|
||||
<SkillsSection agentId={agent.id} />
|
||||
|
||||
{agent.agent_type === "predefined" && (
|
||||
<EvolutionSection
|
||||
selfEvolve={selfEvolve}
|
||||
onSelfEvolveChange={setSelfEvolve}
|
||||
skillEvolve={skillEvolve}
|
||||
onSkillEvolveChange={setSkillEvolve}
|
||||
skillNudgeInterval={skillNudgeInterval}
|
||||
onSkillNudgeIntervalChange={setSkillNudgeInterval}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CapabilitiesSection
|
||||
memEnabled={memEnabled}
|
||||
mem={mem}
|
||||
onMemToggle={setMemEnabled}
|
||||
onMemChange={setMem}
|
||||
subEnabled={subEnabled}
|
||||
sub={sub}
|
||||
onSubToggle={setSubEnabled}
|
||||
onSubChange={setSub}
|
||||
toolsEnabled={toolsEnabled}
|
||||
tools={tools}
|
||||
onToolsToggle={setToolsEnabled}
|
||||
onToolsChange={setTools}
|
||||
/>
|
||||
|
||||
<StickySaveBar
|
||||
onSave={handleSave}
|
||||
saving={saving}
|
||||
saved={saved}
|
||||
error={saveError}
|
||||
disabled={llmSaveBlocked}
|
||||
label={t("general.saveChanges")}
|
||||
savingLabel={t("general.saving")}
|
||||
savedLabel={t("general.saved")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { TableSkeleton } from "@/components/shared/loading-skeleton";
|
||||
import { EmptyState } from "@/components/shared/empty-state";
|
||||
import { SearchInput } from "@/components/shared/search-input";
|
||||
import { Zap } from "lucide-react";
|
||||
import { useAgentSkills } from "../hooks/use-agent-skills";
|
||||
|
||||
interface AgentSkillsTabProps {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
const visibilityVariant = (v: string) => {
|
||||
switch (v) {
|
||||
case "public":
|
||||
return "success";
|
||||
case "internal":
|
||||
return "secondary";
|
||||
case "private":
|
||||
return "outline";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
};
|
||||
|
||||
export function AgentSkillsTab({ agentId }: AgentSkillsTabProps) {
|
||||
const { t } = useTranslation("agents");
|
||||
const { skills, loading, grantSkill, revokeSkill } = useAgentSkills(agentId);
|
||||
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()),
|
||||
)
|
||||
.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);
|
||||
try {
|
||||
if (granted) {
|
||||
await revokeSkill(skillId);
|
||||
} else {
|
||||
await grantSkill(skillId);
|
||||
}
|
||||
} finally {
|
||||
setToggling(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && skills.length === 0) {
|
||||
return <TableSkeleton />;
|
||||
}
|
||||
|
||||
if (!loading && skills.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Zap}
|
||||
title={t("skills.noSkillsAvailable")}
|
||||
description={t("skills.noSkillsDesc")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("skills.skillsGranted", {
|
||||
granted: skills.filter((s) => s.granted).length,
|
||||
total: skills.length,
|
||||
})}
|
||||
</p>
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder={t("skills.filterSkills")}
|
||||
className="w-full sm:w-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="divide-y rounded-lg border">
|
||||
{filtered.map((skill) => (
|
||||
<div key={skill.id} className="flex items-center justify-between gap-4 px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{skill.name}</span>
|
||||
<Badge variant={visibilityVariant(skill.visibility)} className="text-[10px]">
|
||||
{skill.visibility}
|
||||
</Badge>
|
||||
{skill.is_system && (
|
||||
<Badge variant="outline" className="border-blue-500 text-blue-600 text-[10px]">
|
||||
{t("skills.system")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{skill.description && (
|
||||
<p className="mt-0.5 truncate text-sm text-muted-foreground">{skill.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{skill.is_system ? (
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">{t("skills.alwaysAvailable")}</span>
|
||||
) : (
|
||||
<Switch
|
||||
checked={skill.granted}
|
||||
disabled={toggling === skill.id}
|
||||
onCheckedChange={() => handleToggle(skill.id, skill.granted)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
{t("skills.noSkillsMatch")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
/** Extract the first emoji (single grapheme cluster) from a string, or empty. */
|
||||
function extractSingleEmoji(str: string): string {
|
||||
// Match first emoji (handles compound emoji like flags, skin tones)
|
||||
const match = str.match(/\p{Emoji_Presentation}(\u200D\p{Emoji_Presentation})*/u)
|
||||
?? str.match(/\p{Extended_Pictographic}(\uFE0F?\u200D\p{Extended_Pictographic})*/u);
|
||||
return match?.[0] ?? "";
|
||||
}
|
||||
|
||||
interface IdentitySectionProps {
|
||||
agentKey: string;
|
||||
emoji?: string;
|
||||
onEmojiChange?: (v: string) => void;
|
||||
displayName: string;
|
||||
onDisplayNameChange: (v: string) => void;
|
||||
frontmatter: string;
|
||||
onFrontmatterChange: (v: string) => void;
|
||||
status: string;
|
||||
onStatusChange: (v: string) => void;
|
||||
isDefault: boolean;
|
||||
onIsDefaultChange: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export function IdentitySection({
|
||||
agentKey,
|
||||
emoji,
|
||||
onEmojiChange,
|
||||
displayName,
|
||||
onDisplayNameChange,
|
||||
frontmatter,
|
||||
onFrontmatterChange,
|
||||
status,
|
||||
onStatusChange,
|
||||
isDefault,
|
||||
onIsDefaultChange,
|
||||
}: IdentitySectionProps) {
|
||||
const { t } = useTranslation("agents");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyAgentKey = async () => {
|
||||
await navigator.clipboard.writeText(agentKey);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">{t("identity.title")}</h3>
|
||||
<div className="space-y-4 rounded-lg border p-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("identity.agentKey")}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input value={agentKey} disabled className="font-mono text-sm" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={copyAgentKey}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("identity.agentKeyHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="displayName">{t("identity.displayName")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="emoji"
|
||||
value={emoji ?? ""}
|
||||
onChange={(e) => {
|
||||
const picked = extractSingleEmoji(e.target.value);
|
||||
onEmojiChange?.(picked);
|
||||
}}
|
||||
onFocus={(e) => e.target.select()}
|
||||
placeholder="🤖"
|
||||
className="w-14 shrink-0 text-center text-base md:text-lg"
|
||||
title={t("identity.emojiHint")}
|
||||
/>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => onDisplayNameChange(e.target.value)}
|
||||
placeholder={t("identity.displayNamePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("identity.displayNameHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="frontmatter">{t("identity.expertiseSummary")}</Label>
|
||||
<Input
|
||||
id="frontmatter"
|
||||
value={frontmatter}
|
||||
onChange={(e) => onFrontmatterChange(e.target.value)}
|
||||
placeholder={t("identity.expertiseSummaryPlaceholder")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("identity.expertiseSummaryHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("identity.status")}</Label>
|
||||
<Select value={status} onValueChange={onStatusChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">{t("identity.active")}</SelectItem>
|
||||
<SelectItem value="inactive">{t("identity.inactive")}</SelectItem>
|
||||
{status === "summon_failed" && (
|
||||
<SelectItem value="summon_failed" disabled>
|
||||
{t("identity.summonFailed")}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-end pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={isDefault} onCheckedChange={onIsDefaultChange} />
|
||||
<Label>{t("identity.defaultAgent")}</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1 @@
|
||||
export { IdentitySection } from "./identity-section";
|
||||
export { LlmConfigSection } from "./llm-config-section";
|
||||
export { WorkspaceSection } from "./workspace-section";
|
||||
@@ -1,77 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ProviderModelSelect } from "@/components/shared/provider-model-select";
|
||||
|
||||
interface LlmConfigSectionProps {
|
||||
provider: string;
|
||||
onProviderChange: (v: string) => void;
|
||||
model: string;
|
||||
onModelChange: (v: string) => void;
|
||||
contextWindow: number;
|
||||
onContextWindowChange: (v: number) => void;
|
||||
maxToolIterations: number;
|
||||
onMaxToolIterationsChange: (v: number) => void;
|
||||
savedProvider: string;
|
||||
savedModel: string;
|
||||
/** Called when verification status changes. True = save should be blocked. */
|
||||
onSaveBlockedChange?: (blocked: boolean) => void;
|
||||
}
|
||||
|
||||
export function LlmConfigSection({
|
||||
provider,
|
||||
onProviderChange,
|
||||
model,
|
||||
onModelChange,
|
||||
contextWindow,
|
||||
onContextWindowChange,
|
||||
maxToolIterations,
|
||||
onMaxToolIterationsChange,
|
||||
savedProvider,
|
||||
savedModel,
|
||||
onSaveBlockedChange,
|
||||
}: LlmConfigSectionProps) {
|
||||
const { t } = useTranslation("agents");
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">{t("llmConfig.title")}</h3>
|
||||
<div className="space-y-4 rounded-lg border p-4">
|
||||
<ProviderModelSelect
|
||||
provider={provider}
|
||||
onProviderChange={onProviderChange}
|
||||
model={model}
|
||||
onModelChange={onModelChange}
|
||||
savedProvider={savedProvider}
|
||||
savedModel={savedModel}
|
||||
onSaveBlockedChange={onSaveBlockedChange}
|
||||
providerTip="LLM provider name. Must match a configured provider."
|
||||
modelTip="Model ID to use."
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contextWindow">{t("llmConfig.contextWindow")}</Label>
|
||||
<Input
|
||||
id="contextWindow"
|
||||
type="number"
|
||||
value={contextWindow || ""}
|
||||
onChange={(e) => onContextWindowChange(Number(e.target.value) || 0)}
|
||||
placeholder="200000"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("llmConfig.contextWindowHint")}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxToolIterations">{t("llmConfig.maxToolIterations")}</Label>
|
||||
<Input
|
||||
id="maxToolIterations"
|
||||
type="number"
|
||||
value={maxToolIterations || ""}
|
||||
onChange={(e) => onMaxToolIterationsChange(Number(e.target.value) || 0)}
|
||||
placeholder="25"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("llmConfig.maxToolIterationsHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Play, Loader2, ChevronDown, Heart, Clock, Send, FileText } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useMinLoading } from "@/hooks/use-min-loading";
|
||||
import { useChannels } from "@/pages/channels/hooks/use-channels";
|
||||
import { useUiStore } from "@/stores/use-ui-store";
|
||||
import type { HeartbeatConfig, DeliveryTarget } from "@/pages/agents/hooks/use-agent-heartbeat";
|
||||
|
||||
interface HeartbeatConfigDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
config: HeartbeatConfig | null;
|
||||
saving: boolean;
|
||||
update: (params: Partial<HeartbeatConfig>) => Promise<void>;
|
||||
test: () => Promise<void>;
|
||||
getChecklist: () => Promise<string>;
|
||||
setChecklist: (content: string) => Promise<void>;
|
||||
fetchTargets: () => Promise<DeliveryTarget[]>;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function HeartbeatConfigDialog({
|
||||
open, onOpenChange, config, saving, update, test, getChecklist, setChecklist, fetchTargets, refresh,
|
||||
}: HeartbeatConfigDialogProps) {
|
||||
const { t } = useTranslation("agents");
|
||||
const { channels: availableChannels } = useChannels();
|
||||
const channelNames = Object.keys(availableChannels);
|
||||
const userTz = useUiStore((s) => s.timezone);
|
||||
const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const defaultTz = userTz && userTz !== "auto" ? userTz : browserTz;
|
||||
|
||||
// Form state
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [intervalMin, setIntervalMin] = useState(30);
|
||||
const [ackMaxChars, setAckMaxChars] = useState(300);
|
||||
const [maxRetries, setMaxRetries] = useState(2);
|
||||
const [isolatedSession, setIsolatedSession] = useState(true);
|
||||
const [lightContext, setLightContext] = useState(false);
|
||||
const [activeHoursStart, setActiveHoursStart] = useState("");
|
||||
const [activeHoursEnd, setActiveHoursEnd] = useState("");
|
||||
const [timezone, setTimezone] = useState("");
|
||||
const [channel, setChannel] = useState("");
|
||||
const [chatId, setChatId] = useState("");
|
||||
const [checklist, setChecklistState] = useState("");
|
||||
const [originalChecklist, setOriginalChecklist] = useState("");
|
||||
const [checklistLoading, setChecklistLoading] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [targets, setTargets] = useState<DeliveryTarget[]>([]);
|
||||
|
||||
const [testRunning, setTestRunning] = useState(false);
|
||||
const showTestSpin = useMinLoading(testRunning, 600);
|
||||
|
||||
const loadChecklist = useCallback(async () => {
|
||||
setChecklistLoading(true);
|
||||
try {
|
||||
const content = await getChecklist();
|
||||
setChecklistState(content);
|
||||
setOriginalChecklist(content);
|
||||
} catch { /* ignore */ } finally {
|
||||
setChecklistLoading(false);
|
||||
}
|
||||
}, [getChecklist]);
|
||||
|
||||
// Sync form state only when dialog opens (false→true).
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (config) {
|
||||
setEnabled(config.enabled);
|
||||
setIntervalMin(Math.round(config.intervalSec / 60));
|
||||
setAckMaxChars(config.ackMaxChars);
|
||||
setMaxRetries(config.maxRetries);
|
||||
setIsolatedSession(config.isolatedSession);
|
||||
setLightContext(config.lightContext);
|
||||
setActiveHoursStart(config.activeHoursStart ?? "");
|
||||
setActiveHoursEnd(config.activeHoursEnd ?? "");
|
||||
setTimezone(config.timezone || defaultTz);
|
||||
setChannel(config.channel ?? "");
|
||||
setChatId(config.chatId ?? "");
|
||||
} else {
|
||||
// First-time setup defaults
|
||||
setTimezone(defaultTz);
|
||||
}
|
||||
setShowAdvanced(false);
|
||||
loadChecklist();
|
||||
fetchTargets().then(setTargets).catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const handleTest = async () => {
|
||||
setTestRunning(true);
|
||||
try { await test(); } finally { setTestRunning(false); }
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const clampedMin = Math.max(5, intervalMin);
|
||||
await update({
|
||||
enabled,
|
||||
intervalSec: clampedMin * 60,
|
||||
ackMaxChars: ackMaxChars,
|
||||
maxRetries: maxRetries,
|
||||
isolatedSession: isolatedSession,
|
||||
lightContext: lightContext,
|
||||
activeHoursStart: activeHoursStart || undefined,
|
||||
activeHoursEnd: activeHoursEnd || undefined,
|
||||
timezone: timezone || undefined,
|
||||
channel: channel || undefined,
|
||||
chatId: chatId || undefined,
|
||||
});
|
||||
if (checklist !== originalChecklist) {
|
||||
await setChecklist(checklist);
|
||||
setOriginalChecklist(checklist);
|
||||
}
|
||||
await refresh();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] w-[95vw] flex flex-col sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Heart className="h-4 w-4 text-rose-500" />
|
||||
{t("heartbeat.configTitle")}
|
||||
<Badge variant={enabled ? "success" : "secondary"} className="text-[10px]">
|
||||
{enabled ? t("heartbeat.on") : t("heartbeat.off")}
|
||||
</Badge>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Scrollable body — standard pattern: -mx-4 px-4 */}
|
||||
<div className="overflow-y-auto min-h-0 -mx-4 px-4 sm:-mx-6 sm:px-6 space-y-4 overscroll-contain">
|
||||
|
||||
{/* ── Enable + Interval (top priority) ── */}
|
||||
<div className="flex items-center justify-between gap-4 rounded-lg border p-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium">{t("heartbeat.enabled")}</span>
|
||||
<p className="text-xs text-muted-foreground">{t("heartbeat.enabledHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
type="number"
|
||||
min={5}
|
||||
value={intervalMin}
|
||||
onChange={(e) => setIntervalMin(Math.max(5, Number(e.target.value) || 5))}
|
||||
className="w-16 text-center text-base md:text-sm"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">min</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Delivery — WHERE it sends ── */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Send className="h-3.5 w-3.5 text-blue-500" />
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t("heartbeat.sectionDelivery")}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t("heartbeat.channel")}</Label>
|
||||
{channelNames.length > 0 ? (
|
||||
<Select
|
||||
value={channel || "__none__"}
|
||||
onValueChange={(v) => { setChannel(v === "__none__" ? "" : v); setChatId(""); }}
|
||||
>
|
||||
<SelectTrigger className="text-base md:text-sm">
|
||||
<SelectValue placeholder={t("heartbeat.channelPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">{t("heartbeat.channelNone")}</SelectItem>
|
||||
{channelNames.map((ch) => (
|
||||
<SelectItem key={ch} value={ch}>{ch}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
placeholder="telegram"
|
||||
value={channel}
|
||||
onChange={(e) => setChannel(e.target.value)}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t("heartbeat.chatId")}</Label>
|
||||
{(() => {
|
||||
if (!channel) {
|
||||
return (
|
||||
<Input
|
||||
placeholder={t("heartbeat.selectChannelFirst")}
|
||||
disabled
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
);
|
||||
}
|
||||
const filtered = targets.filter((t) => t.channel === channel);
|
||||
if (filtered.length > 0) {
|
||||
return (
|
||||
<Select
|
||||
value={chatId || "__none__"}
|
||||
onValueChange={(v) => setChatId(v === "__none__" ? "" : v)}
|
||||
>
|
||||
<SelectTrigger className="text-base md:text-sm">
|
||||
<SelectValue placeholder={t("heartbeat.chatIdPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">{t("heartbeat.channelNone")}</SelectItem>
|
||||
{filtered.map((tgt) => (
|
||||
<SelectItem key={tgt.chatId} value={tgt.chatId}>
|
||||
{tgt.title ? `${tgt.title} (${tgt.chatId})` : tgt.chatId}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
placeholder="-100123456789"
|
||||
value={chatId}
|
||||
onChange={(e) => setChatId(e.target.value)}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Schedule — WHEN it runs ── */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-3.5 w-3.5 text-amber-500" />
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t("heartbeat.sectionSchedule")}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="hb-start" className="text-xs">{t("heartbeat.activeHoursStart")}</Label>
|
||||
<Input
|
||||
id="hb-start"
|
||||
placeholder="08:00"
|
||||
value={activeHoursStart}
|
||||
onChange={(e) => setActiveHoursStart(e.target.value)}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="hb-end" className="text-xs">{t("heartbeat.activeHoursEnd")}</Label>
|
||||
<Input
|
||||
id="hb-end"
|
||||
placeholder="22:00"
|
||||
value={activeHoursEnd}
|
||||
onChange={(e) => setActiveHoursEnd(e.target.value)}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="hb-tz" className="text-xs">{t("heartbeat.timezone")}</Label>
|
||||
<Input
|
||||
id="hb-tz"
|
||||
placeholder="Asia/Ho_Chi_Minh"
|
||||
value={timezone}
|
||||
onChange={(e) => setTimezone(e.target.value)}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("heartbeat.scheduleHint")}</p>
|
||||
</div>
|
||||
|
||||
{/* ── Checklist — WHAT the agent does ── */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-3.5 w-3.5 text-emerald-500" />
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t("heartbeat.checklist")}
|
||||
</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("heartbeat.checklistHint")}</p>
|
||||
{checklistLoading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground py-4 justify-center">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
{t("heartbeat.checklistLoading")}
|
||||
</div>
|
||||
) : (
|
||||
<Textarea
|
||||
value={checklist}
|
||||
onChange={(e) => setChecklistState(e.target.value)}
|
||||
placeholder={t("heartbeat.checklistPlaceholder")}
|
||||
rows={10}
|
||||
className="text-base md:text-sm font-mono resize-y"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Advanced (collapsible) ── */}
|
||||
<div className="rounded-lg border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="flex w-full items-center justify-between px-3 py-2 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<span>{t("heartbeat.advancedSettings")}</span>
|
||||
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${showAdvanced ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="border-t px-3 py-3 space-y-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="hb-ack" className="text-xs">{t("heartbeat.ackMaxChars")}</Label>
|
||||
<Input
|
||||
id="hb-ack"
|
||||
type="number"
|
||||
min={0}
|
||||
value={ackMaxChars}
|
||||
onChange={(e) => setAckMaxChars(Number(e.target.value))}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">{t("heartbeat.ackMaxCharsHint")}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="hb-retries" className="text-xs">{t("heartbeat.maxRetries")}</Label>
|
||||
<Input
|
||||
id="hb-retries"
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
value={maxRetries}
|
||||
onChange={(e) => setMaxRetries(Number(e.target.value))}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">{t("heartbeat.maxRetriesHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<span className="text-xs font-medium">{t("heartbeat.isolatedSession")}</span>
|
||||
<p className="text-[11px] text-muted-foreground">{t("heartbeat.isolatedSessionHint")}</p>
|
||||
</div>
|
||||
<Switch checked={isolatedSession} onCheckedChange={setIsolatedSession} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<span className="text-xs font-medium">{t("heartbeat.lightContext")}</span>
|
||||
<p className="text-[11px] text-muted-foreground">{t("heartbeat.lightContextHint")}</p>
|
||||
</div>
|
||||
<Switch checked={lightContext} onCheckedChange={setLightContext} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom padding for scroll */}
|
||||
<div className="h-1" />
|
||||
</div>
|
||||
|
||||
{/* Footer — fixed at bottom */}
|
||||
<div className="flex items-center justify-between gap-2 border-t pt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleTest}
|
||||
disabled={showTestSpin || saving}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{showTestSpin
|
||||
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
: <Play className="h-3.5 w-3.5" />}
|
||||
{t("heartbeat.testRun")}
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>
|
||||
{t("heartbeat.cancel")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={saving}>
|
||||
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
{saving ? t("heartbeat.saving") : t("heartbeat.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useMinLoading } from "@/hooks/use-min-loading";
|
||||
import type { HeartbeatLog } from "@/pages/agents/hooks/use-agent-heartbeat";
|
||||
import { formatRelativeTime, formatDate } from "@/lib/format";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
type LogStatus = "ok" | "error" | "suppressed" | "skipped";
|
||||
|
||||
function statusVariant(status: string): "success" | "destructive" | "secondary" | "outline" {
|
||||
switch (status as LogStatus) {
|
||||
case "ok": return "success";
|
||||
case "error": return "destructive";
|
||||
case "suppressed": return "secondary";
|
||||
default: return "outline";
|
||||
}
|
||||
}
|
||||
|
||||
interface HeartbeatLogsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
fetchLogs: (limit: number, offset: number) => Promise<{ logs: HeartbeatLog[]; total: number }>;
|
||||
}
|
||||
|
||||
export function HeartbeatLogsDialog({
|
||||
open, onOpenChange, fetchLogs,
|
||||
}: HeartbeatLogsDialogProps) {
|
||||
const { t } = useTranslation("agents");
|
||||
|
||||
const [logs, setLogs] = useState<HeartbeatLog[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const showSpin = useMinLoading(fetching, 600);
|
||||
|
||||
const load = useCallback(
|
||||
async (newOffset: number) => {
|
||||
setFetching(true);
|
||||
try {
|
||||
const res = await fetchLogs(PAGE_SIZE, newOffset);
|
||||
setLogs(res.logs);
|
||||
setTotal(res.total);
|
||||
setOffset(newOffset);
|
||||
} catch {
|
||||
// toast handled inside hook if needed
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
},
|
||||
[fetchLogs],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setOffset(0);
|
||||
load(0);
|
||||
}
|
||||
}, [open, load]);
|
||||
|
||||
const hasPrev = offset > 0;
|
||||
const hasNext = offset + PAGE_SIZE < total;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] w-[95vw] sm:max-w-3xl flex flex-col gap-0 p-0">
|
||||
<DialogHeader className="px-4 pt-4 pb-3 sm:px-6 sm:pt-6 flex-row items-center justify-between gap-2">
|
||||
<DialogTitle>{t("heartbeat.logsTitle")}</DialogTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => load(offset)}
|
||||
disabled={showSpin}
|
||||
className="shrink-0"
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${showSpin ? "animate-spin" : ""}`} />
|
||||
{t("heartbeat.refresh")}
|
||||
</Button>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="overflow-y-auto min-h-0 max-h-[60vh] -mx-0 px-4 sm:px-6 overscroll-contain">
|
||||
{fetching && logs.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<p className="py-12 text-center text-sm text-muted-foreground">
|
||||
{t("heartbeat.noLogs")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2 py-1">
|
||||
{logs.map((log) => (
|
||||
<div key={log.id} className="rounded-md border p-3 text-sm space-y-1">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground" title={formatDate(log.ran_at)}>
|
||||
{formatRelativeTime(log.ran_at)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{log.duration_ms != null && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{log.duration_ms}ms
|
||||
</span>
|
||||
)}
|
||||
<Badge variant={statusVariant(log.status)} className="text-[10px]">
|
||||
{log.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{log.summary && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">{log.summary}</p>
|
||||
)}
|
||||
{log.skip_reason && (
|
||||
<p className="text-xs text-muted-foreground italic">{log.skip_reason}</p>
|
||||
)}
|
||||
{log.error && (
|
||||
<p className="text-xs text-destructive">{log.error}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between gap-2 px-4 py-3 sm:px-6 border-t text-xs text-muted-foreground">
|
||||
<span>
|
||||
{total > 0
|
||||
? t("heartbeat.logsPagination", {
|
||||
from: offset + 1,
|
||||
to: Math.min(offset + PAGE_SIZE, total),
|
||||
total,
|
||||
})
|
||||
: ""}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => load(offset - PAGE_SIZE)}
|
||||
disabled={!hasPrev || fetching}
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => load(offset + PAGE_SIZE)}
|
||||
disabled={!hasNext || fetching}
|
||||
>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { MemoryConfig, SubagentsConfig, ToolPolicyConfig } from "@/types/agent";
|
||||
import { MemorySection, SubagentsSection, ToolPolicySection } from "../config-sections";
|
||||
import { ConfigGroupHeader } from "@/components/shared/config-group-header";
|
||||
|
||||
interface CapabilitiesSectionProps {
|
||||
memEnabled: boolean;
|
||||
mem: MemoryConfig;
|
||||
onMemToggle: (v: boolean) => void;
|
||||
onMemChange: (v: MemoryConfig) => void;
|
||||
|
||||
subEnabled: boolean;
|
||||
sub: SubagentsConfig;
|
||||
onSubToggle: (v: boolean) => void;
|
||||
onSubChange: (v: SubagentsConfig) => void;
|
||||
|
||||
toolsEnabled: boolean;
|
||||
tools: ToolPolicyConfig;
|
||||
onToolsToggle: (v: boolean) => void;
|
||||
onToolsChange: (v: ToolPolicyConfig) => void;
|
||||
}
|
||||
|
||||
export function CapabilitiesSection({
|
||||
memEnabled, mem, onMemToggle, onMemChange,
|
||||
subEnabled, sub, onSubToggle, onSubChange,
|
||||
toolsEnabled, tools, onToolsToggle, onToolsChange,
|
||||
}: CapabilitiesSectionProps) {
|
||||
const { t } = useTranslation("agents");
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<ConfigGroupHeader
|
||||
title={t("detail.capabilities")}
|
||||
description={t("configGroups.capabilitiesDesc")}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<MemorySection
|
||||
enabled={memEnabled}
|
||||
value={mem}
|
||||
onToggle={(v) => { onMemToggle(v); if (!v) onMemChange({}); }}
|
||||
onChange={onMemChange}
|
||||
/>
|
||||
<SubagentsSection
|
||||
enabled={subEnabled}
|
||||
value={sub}
|
||||
onToggle={(v) => { onSubToggle(v); if (!v) onSubChange({}); }}
|
||||
onChange={onSubChange}
|
||||
/>
|
||||
<ToolPolicySection
|
||||
enabled={toolsEnabled}
|
||||
value={tools}
|
||||
onToggle={(v) => { onToolsToggle(v); if (!v) onToolsChange({}); }}
|
||||
onChange={onToolsChange}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GraduationCap, Info, Sparkles } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
interface EvolutionSectionProps {
|
||||
selfEvolve: boolean;
|
||||
onSelfEvolveChange: (v: boolean) => void;
|
||||
skillEvolve: boolean;
|
||||
onSkillEvolveChange: (v: boolean) => void;
|
||||
skillNudgeInterval: number;
|
||||
onSkillNudgeIntervalChange: (v: number) => void;
|
||||
}
|
||||
|
||||
export function EvolutionSection({
|
||||
selfEvolve, onSelfEvolveChange,
|
||||
skillEvolve, onSkillEvolveChange,
|
||||
skillNudgeInterval, onSkillNudgeIntervalChange,
|
||||
}: EvolutionSectionProps) {
|
||||
const { t } = useTranslation("agents");
|
||||
|
||||
return (
|
||||
<section className="space-y-4 rounded-lg border p-3 sm:p-4">
|
||||
<h3 className="text-sm font-medium">{t("detail.evolution")}</h3>
|
||||
|
||||
{/* Self-Evolve */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-violet-500 shrink-0" />
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="self-evolve" className="text-sm font-normal cursor-pointer">
|
||||
{t("general.selfEvolutionLabel")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">{t("general.selfEvolutionHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch id="self-evolve" checked={selfEvolve} onCheckedChange={onSelfEvolveChange} />
|
||||
</div>
|
||||
{selfEvolve && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-violet-200 bg-violet-50 px-3 py-2 text-xs text-violet-700 dark:border-violet-800 dark:bg-violet-950/30 dark:text-violet-300">
|
||||
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>{t("general.selfEvolutionInfo")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Skill Learning */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<GraduationCap className="h-4 w-4 text-amber-500 shrink-0" />
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="skill-evolve" className="text-sm font-normal cursor-pointer">
|
||||
{t("general.skillLearningLabel")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">{t("general.skillLearningHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch id="skill-evolve" checked={skillEvolve} onCheckedChange={onSkillEvolveChange} />
|
||||
</div>
|
||||
{skillEvolve && (
|
||||
<>
|
||||
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
|
||||
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>{t("general.skillLearningInfo")}</span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="nudge-interval" className="text-xs font-normal text-muted-foreground">
|
||||
{t("general.skillNudgeIntervalLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="nudge-interval"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={skillNudgeInterval}
|
||||
onChange={(e) => onSkillNudgeIntervalChange(Number(e.target.value) || 0)}
|
||||
className="max-w-[120px] text-base md:text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("general.skillNudgeIntervalHint")}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useState } from "react";
|
||||
import { Heart, Settings, List, Activity } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { UseAgentHeartbeatReturn } from "@/pages/agents/hooks/use-agent-heartbeat";
|
||||
import { HeartbeatConfigDialog } from "../heartbeat-config-dialog";
|
||||
import { HeartbeatLogsDialog } from "../heartbeat-logs-dialog";
|
||||
import { formatRelativeTime } from "@/lib/format";
|
||||
|
||||
interface HeartbeatCardProps {
|
||||
heartbeat: UseAgentHeartbeatReturn;
|
||||
}
|
||||
|
||||
export function HeartbeatCard({ heartbeat }: HeartbeatCardProps) {
|
||||
const { t } = useTranslation("agents");
|
||||
const { config, loading, saving, toggle, update, test, getChecklist, setChecklist, fetchTargets, refresh, fetchLogs } = heartbeat;
|
||||
const [configOpen, setConfigOpen] = useState(false);
|
||||
const [logsOpen, setLogsOpen] = useState(false);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
|
||||
{t("heartbeat.loading")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Heart className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{t("heartbeat.title")}</span>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => setConfigOpen(true)}>
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
{t("heartbeat.setupButton")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{t("heartbeat.notConfigured")}</p>
|
||||
</div>
|
||||
{configOpen && (
|
||||
<HeartbeatConfigDialog
|
||||
open={configOpen} onOpenChange={setConfigOpen}
|
||||
config={config} saving={saving} update={update} test={test}
|
||||
getChecklist={getChecklist} setChecklist={setChecklist} fetchTargets={fetchTargets} refresh={refresh}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const intervalMin = Math.round(config.intervalSec / 60);
|
||||
const activeHours =
|
||||
config.activeHoursStart && config.activeHoursEnd
|
||||
? `${config.activeHoursStart}–${config.activeHoursEnd}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Heart className={`h-4 w-4 ${config.enabled ? "fill-rose-500 text-rose-500 animate-pulse" : "text-rose-400"}`} />
|
||||
<span className="text-sm font-medium">{t("heartbeat.title")}</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.enabled}
|
||||
disabled={saving}
|
||||
onCheckedChange={(enabled) => toggle(enabled)}
|
||||
aria-label={t("heartbeat.toggleEnabled")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
<div className="flex flex-wrap gap-x-2 gap-y-0.5">
|
||||
<span>
|
||||
<Activity className="inline h-3 w-3 mr-0.5" />
|
||||
{t("heartbeat.every", { interval: intervalMin })}
|
||||
</span>
|
||||
{activeHours && <span>{activeHours}</span>}
|
||||
{config.channel && (
|
||||
<span>{config.channel}{config.chatId ? `/${config.chatId}` : ""}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5">
|
||||
{config.lastRunAt && (
|
||||
<span>
|
||||
{t("heartbeat.lastRun")}: {formatRelativeTime(config.lastRunAt)}
|
||||
{config.lastStatus && ` (${config.lastStatus})`}
|
||||
</span>
|
||||
)}
|
||||
{config.nextRunAt && config.enabled && (
|
||||
<span>{t("heartbeat.nextRun")}: {formatRelativeTime(config.nextRunAt)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<span>{t("heartbeat.runs")}: {config.runCount}</span>
|
||||
<span>{t("heartbeat.suppressed")}: {config.suppressCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => setConfigOpen(true)}>
|
||||
<Settings className="h-3 w-3" />
|
||||
{t("heartbeat.configure")}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => setLogsOpen(true)}>
|
||||
<List className="h-3 w-3" />
|
||||
{t("heartbeat.logs")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{configOpen && (
|
||||
<HeartbeatConfigDialog
|
||||
open={configOpen} onOpenChange={setConfigOpen}
|
||||
config={config} saving={saving} update={update} test={test}
|
||||
getChecklist={getChecklist} setChecklist={setChecklist} fetchTargets={fetchTargets} refresh={refresh}
|
||||
/>
|
||||
)}
|
||||
{logsOpen && (
|
||||
<HeartbeatLogsDialog open={logsOpen} onOpenChange={setLogsOpen} fetchLogs={fetchLogs} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DollarSign } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ProviderModelSelect } from "@/components/shared/provider-model-select";
|
||||
|
||||
interface ModelBudgetSectionProps {
|
||||
provider: string;
|
||||
onProviderChange: (v: string) => void;
|
||||
model: string;
|
||||
onModelChange: (v: string) => void;
|
||||
contextWindow: number;
|
||||
onContextWindowChange: (v: number) => void;
|
||||
maxToolIterations: number;
|
||||
onMaxToolIterationsChange: (v: number) => void;
|
||||
savedProvider: string;
|
||||
savedModel: string;
|
||||
budgetDollars: string;
|
||||
onBudgetDollarsChange: (v: string) => void;
|
||||
onSaveBlockedChange?: (blocked: boolean) => void;
|
||||
}
|
||||
|
||||
export function ModelBudgetSection({
|
||||
provider, onProviderChange, model, onModelChange,
|
||||
contextWindow, onContextWindowChange,
|
||||
maxToolIterations, onMaxToolIterationsChange,
|
||||
savedProvider, savedModel,
|
||||
budgetDollars, onBudgetDollarsChange,
|
||||
onSaveBlockedChange,
|
||||
}: ModelBudgetSectionProps) {
|
||||
const { t } = useTranslation("agents");
|
||||
|
||||
const handleSaveBlockedChange = useCallback((blocked: boolean) => {
|
||||
onSaveBlockedChange?.(blocked);
|
||||
}, [onSaveBlockedChange]);
|
||||
|
||||
return (
|
||||
<section className="space-y-3 rounded-lg border p-3 sm:p-4 overflow-hidden">
|
||||
<h3 className="text-sm font-medium">{t("detail.modelBudget")}</h3>
|
||||
|
||||
<ProviderModelSelect
|
||||
provider={provider}
|
||||
onProviderChange={onProviderChange}
|
||||
model={model}
|
||||
onModelChange={onModelChange}
|
||||
savedProvider={savedProvider}
|
||||
savedModel={savedModel}
|
||||
onSaveBlockedChange={handleSaveBlockedChange}
|
||||
providerTip="LLM provider name. Must match a configured provider."
|
||||
modelTip="Model ID to use."
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="contextWindow" className="text-xs">{t("llmConfig.contextWindow")}</Label>
|
||||
<Input
|
||||
id="contextWindow"
|
||||
type="number"
|
||||
value={contextWindow || ""}
|
||||
onChange={(e) => onContextWindowChange(Number(e.target.value) || 0)}
|
||||
placeholder="200000"
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("llmConfig.contextWindowHint")}</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="maxToolIterations" className="text-xs">{t("llmConfig.maxToolIterations")}</Label>
|
||||
<Input
|
||||
id="maxToolIterations"
|
||||
type="number"
|
||||
value={maxToolIterations || ""}
|
||||
onChange={(e) => onMaxToolIterationsChange(Number(e.target.value) || 0)}
|
||||
placeholder="25"
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("llmConfig.maxToolIterationsHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="budget" className="text-xs">
|
||||
<span className="flex items-center gap-1">
|
||||
<DollarSign className="h-3 w-3 text-emerald-500" />
|
||||
{t("general.budgetLabel")}
|
||||
</span>
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">$</span>
|
||||
<Input
|
||||
id="budget"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
value={budgetDollars}
|
||||
onChange={(e) => onBudgetDollarsChange(e.target.value)}
|
||||
className="max-w-[200px] text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("general.budgetHint")}</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Bot, Check, Copy } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
/** Extract the first emoji grapheme cluster from a string, or return empty. */
|
||||
function extractSingleEmoji(str: string): string {
|
||||
const match = str.match(/\p{Emoji_Presentation}(\u200D\p{Emoji_Presentation})*/u)
|
||||
?? str.match(/\p{Extended_Pictographic}(\uFE0F?\u200D\p{Extended_Pictographic})*/u);
|
||||
return match?.[0] ?? "";
|
||||
}
|
||||
|
||||
interface PersonalitySectionProps {
|
||||
agentKey: string;
|
||||
emoji: string;
|
||||
onEmojiChange: (v: string) => void;
|
||||
displayName: string;
|
||||
onDisplayNameChange: (v: string) => void;
|
||||
frontmatter: string;
|
||||
onFrontmatterChange: (v: string) => void;
|
||||
status: string;
|
||||
onStatusChange: (v: string) => void;
|
||||
isDefault: boolean;
|
||||
onIsDefaultChange: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export function PersonalitySection({
|
||||
agentKey, emoji, onEmojiChange, displayName, onDisplayNameChange,
|
||||
frontmatter, onFrontmatterChange, status, onStatusChange, isDefault, onIsDefaultChange,
|
||||
}: PersonalitySectionProps) {
|
||||
const { t } = useTranslation("agents");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [emojiEditing, setEmojiEditing] = useState(false);
|
||||
|
||||
const copyAgentKey = async () => {
|
||||
await navigator.clipboard.writeText(agentKey);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-4 rounded-lg border p-3 sm:p-4 overflow-hidden">
|
||||
<h3 className="text-sm font-medium">{t("detail.personality")}</h3>
|
||||
|
||||
{/* 2-column: emoji left, fields right */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[auto_1fr]">
|
||||
{/* Emoji large preview */}
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEmojiEditing(true)}
|
||||
className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
|
||||
title={t("identity.emojiHint")}
|
||||
>
|
||||
{emoji
|
||||
? <span className="text-2xl leading-none">{emoji}</span>
|
||||
: <Bot className="h-6 w-6 text-muted-foreground" />}
|
||||
</button>
|
||||
{emojiEditing ? (
|
||||
<Input
|
||||
autoFocus
|
||||
value={emoji}
|
||||
onChange={(e) => onEmojiChange(extractSingleEmoji(e.target.value))}
|
||||
onBlur={() => setEmojiEditing(false)}
|
||||
placeholder="🤖"
|
||||
className="w-14 text-center text-base md:text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground">{t("identity.emoji")}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fields */}
|
||||
<div className="min-w-0 space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="displayName" className="text-xs">{t("identity.displayName")}</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => onDisplayNameChange(e.target.value)}
|
||||
placeholder={t("identity.displayNamePlaceholder")}
|
||||
className="text-base font-medium md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="frontmatter" className="text-xs">{t("identity.expertiseSummary")}</Label>
|
||||
<Textarea
|
||||
id="frontmatter"
|
||||
value={frontmatter}
|
||||
onChange={(e) => onFrontmatterChange(e.target.value)}
|
||||
placeholder={t("identity.expertiseSummaryPlaceholder")}
|
||||
rows={3}
|
||||
className="text-base resize-none md:text-sm"
|
||||
/>
|
||||
{frontmatter && (
|
||||
<p className="text-xs text-muted-foreground italic truncate">
|
||||
{t("detail.llmSeesAs")}: "{frontmatter}"
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status + Default on same row */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">{t("identity.status")}</Label>
|
||||
<Select value={status} onValueChange={onStatusChange}>
|
||||
<SelectTrigger className="text-base md:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">{t("identity.active")}</SelectItem>
|
||||
<SelectItem value="inactive">{t("identity.inactive")}</SelectItem>
|
||||
{status === "summon_failed" && (
|
||||
<SelectItem value="summon_failed" disabled>{t("identity.summonFailed")}</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-end pb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch id="isDefault" checked={isDefault} onCheckedChange={onIsDefaultChange} />
|
||||
<Label htmlFor="isDefault" className="text-sm font-normal">{t("identity.defaultAgent")}</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Key read-only */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">{t("identity.agentKey")}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex-1 rounded-md border bg-muted/50 px-3 py-1.5 font-mono text-xs text-muted-foreground truncate">
|
||||
{agentKey}
|
||||
</span>
|
||||
<Button variant="ghost" size="icon" className="shrink-0 size-7" onClick={copyAgentKey}>
|
||||
{copied
|
||||
? <Check className="h-3 w-3 text-green-500" />
|
||||
: <Copy className="h-3 w-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Zap } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { SearchInput } from "@/components/shared/search-input";
|
||||
import { TableSkeleton } from "@/components/shared/loading-skeleton";
|
||||
import { useAgentSkills } from "../../hooks/use-agent-skills";
|
||||
|
||||
interface SkillsSectionProps {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
const visibilityVariant = (v: string) => {
|
||||
switch (v) {
|
||||
case "public": return "success";
|
||||
case "internal": return "secondary";
|
||||
default: return "outline";
|
||||
}
|
||||
};
|
||||
|
||||
export function SkillsSection({ agentId }: SkillsSectionProps) {
|
||||
const { t } = useTranslation("agents");
|
||||
const { skills, loading, grantSkill, revokeSkill } = useAgentSkills(agentId);
|
||||
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()),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const rank = (s: typeof a) => s.granted ? 0 : s.is_system ? 1 : 2;
|
||||
return rank(a) - rank(b);
|
||||
});
|
||||
|
||||
const handleToggle = async (skillId: string, granted: boolean) => {
|
||||
setToggling(skillId);
|
||||
try {
|
||||
if (granted) await revokeSkill(skillId);
|
||||
else await grantSkill(skillId);
|
||||
} finally {
|
||||
setToggling(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-3 rounded-lg border p-3 sm:p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-4 w-4 text-amber-500" />
|
||||
<h3 className="text-sm font-medium">{t("detail.skills")}</h3>
|
||||
{!loading && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({skills.filter((s) => s.granted).length}/{skills.length})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder={t("skills.filterSkills")}
|
||||
className="w-40 sm:w-52"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading && skills.length === 0 ? (
|
||||
<TableSkeleton />
|
||||
) : skills.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic px-1">{t("skills.noSkillsAvailable")}</p>
|
||||
) : (
|
||||
<div className="divide-y rounded-lg border max-h-[300px] overflow-y-auto overscroll-contain">
|
||||
{filtered.map((skill) => (
|
||||
<div key={skill.id} className="flex items-center justify-between gap-3 px-3 py-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium truncate">{skill.name}</span>
|
||||
<Badge variant={visibilityVariant(skill.visibility)} className="text-[10px] shrink-0">
|
||||
{skill.visibility}
|
||||
</Badge>
|
||||
{skill.is_system && (
|
||||
<Badge variant="outline" className="border-blue-500 text-blue-600 text-[10px] shrink-0">
|
||||
{t("skills.system")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{skill.description && (
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">{skill.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{skill.is_system ? (
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap shrink-0">
|
||||
{t("skills.alwaysAvailable")}
|
||||
</span>
|
||||
) : (
|
||||
<Switch
|
||||
checked={skill.granted}
|
||||
disabled={toggling === skill.id}
|
||||
onCheckedChange={() => handleToggle(skill.id, skill.granted)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
{t("skills.noSkillsMatch")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { toast } from "@/stores/use-toast-store";
|
||||
import { useWs } from "@/hooks/use-ws";
|
||||
import { Methods } from "@/api/protocol";
|
||||
|
||||
/** Matches Go AgentHeartbeat JSON tags (camelCase). */
|
||||
export interface HeartbeatConfig {
|
||||
id: string;
|
||||
agentId: string;
|
||||
enabled: boolean;
|
||||
intervalSec: number;
|
||||
ackMaxChars: number;
|
||||
maxRetries: number;
|
||||
isolatedSession: boolean;
|
||||
lightContext: boolean;
|
||||
activeHoursStart?: string;
|
||||
activeHoursEnd?: string;
|
||||
timezone?: string;
|
||||
channel?: string;
|
||||
chatId?: string;
|
||||
nextRunAt?: string;
|
||||
lastRunAt?: string;
|
||||
lastStatus?: string;
|
||||
lastError?: string;
|
||||
runCount: number;
|
||||
suppressCount: number;
|
||||
}
|
||||
|
||||
export interface DeliveryTarget {
|
||||
channel: string;
|
||||
chatId: string;
|
||||
title?: string;
|
||||
kind: string; // "dm" | "group"
|
||||
}
|
||||
|
||||
export interface HeartbeatLog {
|
||||
id: string;
|
||||
status: string;
|
||||
summary?: string;
|
||||
error?: string;
|
||||
duration_ms?: number;
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
skip_reason?: string;
|
||||
ran_at: string;
|
||||
}
|
||||
|
||||
/** Return type of useAgentHeartbeat, for passing as props. */
|
||||
export type UseAgentHeartbeatReturn = ReturnType<typeof useAgentHeartbeat>;
|
||||
|
||||
export function useAgentHeartbeat(agentId: string) {
|
||||
const ws = useWs();
|
||||
const [config, setConfig] = useState<HeartbeatConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!ws.isConnected || !agentId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await ws.call<{ heartbeat: HeartbeatConfig | null }>(
|
||||
Methods.HEARTBEAT_GET,
|
||||
{ agentId },
|
||||
);
|
||||
setConfig(res.heartbeat);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to load heartbeat";
|
||||
setError(msg);
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [ws, agentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ws.isConnected && agentId) {
|
||||
refresh();
|
||||
}
|
||||
}, [ws.isConnected, agentId, refresh]);
|
||||
|
||||
// Auto-refresh when countdown reaches 0: poll every 5s until backend returns a new nextRunAt.
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
useEffect(() => {
|
||||
// Only poll when enabled and nextRunAt is in the past (or just passed).
|
||||
const nextRunAt = config?.nextRunAt;
|
||||
const enabled = config?.enabled;
|
||||
if (!enabled || !nextRunAt || !ws.isConnected) {
|
||||
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
|
||||
return;
|
||||
}
|
||||
|
||||
const nextMs = new Date(nextRunAt).getTime();
|
||||
const nowMs = Date.now();
|
||||
if (nextMs > nowMs) {
|
||||
// Still in the future — schedule poll to start when it expires.
|
||||
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
|
||||
const delay = nextMs - nowMs + 1000; // 1s after expiry
|
||||
const timeout = setTimeout(() => {
|
||||
// Start polling.
|
||||
refresh();
|
||||
pollRef.current = setInterval(() => refresh(), 5000);
|
||||
}, delay);
|
||||
return () => { clearTimeout(timeout); if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } };
|
||||
}
|
||||
|
||||
// Already expired — start polling immediately.
|
||||
if (!pollRef.current) {
|
||||
refresh();
|
||||
pollRef.current = setInterval(() => refresh(), 5000);
|
||||
}
|
||||
|
||||
return () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } };
|
||||
}, [config?.enabled, config?.nextRunAt, ws.isConnected, refresh]);
|
||||
|
||||
const toggle = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
if (!agentId) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await ws.call(Methods.HEARTBEAT_TOGGLE, { agentId, enabled });
|
||||
setConfig((prev) => (prev ? { ...prev, enabled } : prev));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to toggle heartbeat";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[ws, agentId],
|
||||
);
|
||||
|
||||
const update = useCallback(
|
||||
async (params: Partial<HeartbeatConfig>) => {
|
||||
if (!agentId) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await ws.call<{ heartbeat: HeartbeatConfig }>(
|
||||
Methods.HEARTBEAT_SET,
|
||||
{ agentId, ...params },
|
||||
);
|
||||
setConfig(res.heartbeat);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to save heartbeat";
|
||||
setError(msg);
|
||||
toast.error(msg);
|
||||
throw err;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[ws, agentId],
|
||||
);
|
||||
|
||||
const test = useCallback(async () => {
|
||||
if (!agentId) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await ws.call(Methods.HEARTBEAT_TEST, { agentId });
|
||||
toast.success("Test run triggered");
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to trigger test";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [ws, agentId]);
|
||||
|
||||
const fetchLogs = useCallback(
|
||||
async (limit = 20, offset = 0): Promise<{ logs: HeartbeatLog[]; total: number }> => {
|
||||
if (!agentId) return { logs: [], total: 0 };
|
||||
const res = await ws.call<{ logs: HeartbeatLog[]; total: number }>(
|
||||
Methods.HEARTBEAT_LOGS,
|
||||
{ agentId, limit, offset },
|
||||
);
|
||||
return { logs: res.logs ?? [], total: res.total ?? 0 };
|
||||
},
|
||||
[ws, agentId],
|
||||
);
|
||||
|
||||
const getChecklist = useCallback(async (): Promise<string> => {
|
||||
if (!agentId) return "";
|
||||
const res = await ws.call<{ content: string }>(
|
||||
Methods.HEARTBEAT_CHECKLIST_GET,
|
||||
{ agentId },
|
||||
);
|
||||
return res.content ?? "";
|
||||
}, [ws, agentId]);
|
||||
|
||||
const setChecklist = useCallback(
|
||||
async (content: string) => {
|
||||
if (!agentId) return;
|
||||
await ws.call(Methods.HEARTBEAT_CHECKLIST_SET, { agentId, content });
|
||||
},
|
||||
[ws, agentId],
|
||||
);
|
||||
|
||||
const fetchTargets = useCallback(async (): Promise<DeliveryTarget[]> => {
|
||||
if (!agentId || !ws.isConnected) return [];
|
||||
const res = await ws.call<{ targets: DeliveryTarget[] }>(
|
||||
Methods.HEARTBEAT_TARGETS, { agentId },
|
||||
);
|
||||
return res.targets ?? [];
|
||||
}, [ws, agentId]);
|
||||
|
||||
return {
|
||||
config,
|
||||
loading,
|
||||
saving,
|
||||
error,
|
||||
toggle,
|
||||
update,
|
||||
test,
|
||||
fetchLogs,
|
||||
getChecklist,
|
||||
setChecklist,
|
||||
fetchTargets,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user