diff --git a/console/package-lock.json b/console/package-lock.json index 97fc665e4..64a4f56e5 100644 --- a/console/package-lock.json +++ b/console/package-lock.json @@ -13332,7 +13332,7 @@ }, "node_modules/typescript": { "version": "5.8.3", - "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.8.3.tgz", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, "license": "Apache-2.0", diff --git a/console/src/api/index.ts b/console/src/api/index.ts index fc3515551..bddeb89f1 100644 --- a/console/src/api/index.ts +++ b/console/src/api/index.ts @@ -17,9 +17,11 @@ import { workspaceApi } from "./modules/workspace"; import { localModelApi } from "./modules/localModel"; import { ollamaModelApi } from "./modules/ollamaModel"; import { mcpApi } from "./modules/mcp"; +import { acpApi } from "./modules/acp"; import { tokenUsageApi } from "./modules/tokenUsage"; import { toolsApi } from "./modules/tools"; import { securityApi } from "./modules/security"; +import { approvalApi } from "./modules/approval"; export const api = { // Root @@ -64,6 +66,9 @@ export const api = { // MCP Clients ...mcpApi, + // ACP + ...acpApi, + // Token Usage ...tokenUsageApi, // Tools @@ -71,6 +76,9 @@ export const api = { // Security ...securityApi, + + // Approvals + ...approvalApi, }; export default api; diff --git a/console/src/api/modules/acp.ts b/console/src/api/modules/acp.ts new file mode 100644 index 000000000..2f0557805 --- /dev/null +++ b/console/src/api/modules/acp.ts @@ -0,0 +1,27 @@ +import { request } from "../request"; +import type { ACPConfig, ParsedExternalAgent } from "../types"; + +export const acpApi = { + /** + * Get ACP configuration + */ + getACPConfig: () => request("/config/acp"), + + /** + * Update ACP configuration + */ + updateACPConfig: (config: ACPConfig) => + request("/config/acp", { + method: "PUT", + body: JSON.stringify(config), + }), + + /** + * Parse external agent text to extract configuration + */ + parseExternalAgentText: (text: string) => + request("/config/acp/parse-text", { + method: "POST", + body: JSON.stringify({ text }), + }), +}; diff --git a/console/src/api/modules/approval.ts b/console/src/api/modules/approval.ts new file mode 100644 index 000000000..b163abd89 --- /dev/null +++ b/console/src/api/modules/approval.ts @@ -0,0 +1,37 @@ +import { request } from "../request"; + +export interface PendingApproval { + request_id: string; + session_id: string; + user_id: string; + channel: string; + tool_name: string; + status: string; + created_at: number; + result_summary: string; + findings_count: number; + extra: Record; +} + +export const approvalApi = { + getPendingApproval: (sessionId: string) => + request( + `/approvals/pending?session_id=${encodeURIComponent(sessionId)}`, + ), + + approveRequest: (requestId: string) => + request( + `/approvals/${encodeURIComponent(requestId)}/approve`, + { + method: "POST", + }, + ), + + denyRequest: (requestId: string) => + request( + `/approvals/${encodeURIComponent(requestId)}/deny`, + { + method: "POST", + }, + ), +}; diff --git a/console/src/api/types/acp.ts b/console/src/api/types/acp.ts new file mode 100644 index 000000000..4e446556f --- /dev/null +++ b/console/src/api/types/acp.ts @@ -0,0 +1,51 @@ +/** + * ACP (Agent Client Protocol) types + */ + +/** Configuration for a single ACP harness */ +export interface ACPHarnessConfig { + /** Whether this harness is enabled */ + enabled: boolean; + /** Command to launch the harness */ + command: string; + /** Arguments for the command */ + args: string[]; + /** Environment variables for the harness process */ + env: Record; +} + +/** ACP (Agent Client Protocol) configuration */ +export interface ACPConfig { + /** Global switch to enable/disable ACP functionality */ + enabled: boolean; + /** Whether to require user approval before executing ACP tasks */ + require_approval: boolean; + /** Directory to save ACP session states */ + save_dir: string; + /** Available ACP harnesses */ + harnesses: Record; +} + +/** Harness info with key for UI display */ +export interface ACPHarnessInfo extends ACPHarnessConfig { + /** Unique harness key identifier */ + key: string; + /** Harness display name */ + name: string; +} + +/** Parsed external agent configuration from text */ +export interface ParsedExternalAgent { + /** Whether external agent is enabled */ + enabled: boolean; + /** Harness identifier (e.g., 'opencode', 'qwen') */ + harness: string | null; + /** Whether to keep session alive */ + keep_session: boolean; + /** Working directory for the agent */ + cwd: string | null; + /** Existing session ID to resume */ + existing_session_id: string | null; + /** Cleaned prompt text */ + prompt: string | null; +} diff --git a/console/src/api/types/chat.ts b/console/src/api/types/chat.ts index 5846b0686..d58aef35a 100644 --- a/console/src/api/types/chat.ts +++ b/console/src/api/types/chat.ts @@ -1,5 +1,6 @@ export interface ChatSpec { id: string; // Chat UUID identifier + name?: string; // Optional chat display name session_id: string; // Session identifier (channel:user_id format) user_id: string; // User identifier channel: string; // Channel name, default: "default" diff --git a/console/src/api/types/index.ts b/console/src/api/types/index.ts index 91db60d3c..5329ccaad 100644 --- a/console/src/api/types/index.ts +++ b/console/src/api/types/index.ts @@ -5,6 +5,7 @@ export * from "./chat"; export * from "./cronjob"; export * from "./env"; export * from "./mcp"; +export * from "./acp"; export * from "./provider"; export * from "./skill"; export * from "./workspace"; diff --git a/console/src/components/MarkdownCopy/MarkdownCopy.tsx b/console/src/components/MarkdownCopy/MarkdownCopy.tsx index 555728dde..2f917374a 100644 --- a/console/src/components/MarkdownCopy/MarkdownCopy.tsx +++ b/console/src/components/MarkdownCopy/MarkdownCopy.tsx @@ -77,8 +77,8 @@ export function MarkdownCopy({ localShowMarkdown && !(editable && !textareaProps.disabled) ? content : editable - ? editContent - : content; + ? editContent + : content; if (!contentToCopy) return; diff --git a/console/src/layouts/MainLayout/index.tsx b/console/src/layouts/MainLayout/index.tsx index d8f39a17d..4c64b0f60 100644 --- a/console/src/layouts/MainLayout/index.tsx +++ b/console/src/layouts/MainLayout/index.tsx @@ -15,6 +15,7 @@ import SkillsPage from "../../pages/Agent/Skills"; import ToolsPage from "../../pages/Agent/Tools"; import WorkspacePage from "../../pages/Agent/Workspace"; import MCPPage from "../../pages/Agent/MCP"; +import ACPPage from "../../pages/Agent/ACP"; import ModelsPage from "../../pages/Settings/Models"; import EnvironmentsPage from "../../pages/Settings/Environments"; import SecurityPage from "../../pages/Settings/Security"; @@ -31,6 +32,7 @@ const pathToKey: Record = { "/skills": "skills", "/tools": "tools", "/mcp": "mcp", + "/acp": "acp", "/workspace": "workspace", "/agents": "agents", "/models": "models", @@ -78,6 +80,7 @@ export default function MainLayout() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/console/src/layouts/Sidebar.tsx b/console/src/layouts/Sidebar.tsx index 36252f9cc..6737dd88f 100644 --- a/console/src/layouts/Sidebar.tsx +++ b/console/src/layouts/Sidebar.tsx @@ -36,6 +36,7 @@ import { Copy, Check, BarChart3, + Bot, } from "lucide-react"; import api from "../api"; import styles from "./index.module.less"; @@ -60,6 +61,7 @@ const KEY_TO_PATH: Record = { skills: "/skills", tools: "/tools", mcp: "/mcp", + acp: "/acp", workspace: "/workspace", models: "/models", environments: "/environments", @@ -255,8 +257,8 @@ export default function Sidebar({ selectedKey }: SidebarProps) { const lang = i18n.language?.startsWith("zh") ? "zh" : i18n.language?.startsWith("ru") - ? "ru" - : "en"; + ? "ru" + : "en"; const faqLang = lang === "zh" ? "zh" : "en"; const url = `https://copaw.agentscope.io/docs/faq.${faqLang}.md`; fetch(url, { cache: "no-cache" }) @@ -268,7 +270,7 @@ export default function Sidebar({ selectedKey }: SidebarProps) { setUpdateMarkdown( match && lang !== "ru" ? match[0].trim() - : UPDATE_MD[lang] ?? UPDATE_MD.en, + : (UPDATE_MD[lang] ?? UPDATE_MD.en), ); }) .catch(() => { @@ -325,6 +327,7 @@ export default function Sidebar({ selectedKey }: SidebarProps) { { key: "skills", label: t("nav.skills"), icon: }, { key: "tools", label: t("nav.tools"), icon: }, { key: "mcp", label: t("nav.mcp"), icon: }, + { key: "acp", label: t("nav.acp"), icon: }, { key: "agent-config", label: t("nav.agentConfig"), diff --git a/console/src/locales/en.json b/console/src/locales/en.json index c235a5164..f98c28304 100644 --- a/console/src/locales/en.json +++ b/console/src/locales/en.json @@ -36,6 +36,7 @@ "skills": "Skills", "tools": "Tools", "mcp": "MCP", + "acp": "ACP", "agentConfig": "Configuration", "settings": "Settings", "models": "Models", @@ -121,6 +122,50 @@ "deleteSuccess": "MCP client deleted successfully", "deleteError": "Failed to delete MCP client" }, + "acp": { + "title": "ACP Configuration", + "description": "Manage ACP (Agent Client Protocol) external agent protocol configuration.", + "globalSettings": "Global Settings", + "enabled": "Enable ACP", + "enabledDescription": "When enabled, CoPaw can invoke external agents via ACP protocol (e.g., OpenCode, Qwen-code)", + "requireApproval": "Require user approval", + "requireApprovalDescription": "When enabled, ACP tasks require user confirmation before execution", + "saveDir": "Session Save Directory", + "saveDirDescription": "Directory path for saving ACP session states", + "harnesses": "Harness List", + "createHarness": "Add Harness", + "editHarness": "Edit Harness", + "harnessKey": "Harness Key", + "harnessKeyHelp": "Unique harness identifier, e.g., opencode, qwen", + "command": "Command", + "commandHelp": "Command to launch the harness, e.g., npx", + "args": "Arguments", + "argsHelp": "Command arguments, separated by spaces", + "envVars": "Environment Variables", + "envVarsCount": "variables", + "addEnvVar": "Add Environment Variable", + "envKeyPlaceholder": "Variable name", + "envValuePlaceholder": "Variable value", + "emptyState": "No harnesses configured yet", + "loadError": "Failed to load ACP configuration", + "saveSuccess": "ACP configuration saved successfully", + "saveError": "Failed to save ACP configuration", + "createSuccess": "Harness created successfully", + "enableSuccess": "Harness enabled successfully", + "disableSuccess": "Harness disabled successfully", + "deleteSuccess": "Harness deleted successfully", + "deleteConfirm": "Are you sure you want to delete this harness?", + "keyExists": "This harness key already exists", + "approval": { + "title": "Waiting for external agent approval", + "defaultMessage": "External agent is waiting for approval.", + "harness": "Harness", + "tool": "Tool", + "kind": "Kind", + "target": "Target", + "instruction": "Type /approve to allow, or send any other message to deny." + } + }, "heartbeat": { "title": "Heartbeat", "description": "Run HEARTBEAT.md at a fixed interval for self-checks. By default runs silently without affecting current conversations, or optionally send replies to the last chat channel.", diff --git a/console/src/locales/ja.json b/console/src/locales/ja.json index 46cbfbbd7..b838d7dac 100644 --- a/console/src/locales/ja.json +++ b/console/src/locales/ja.json @@ -34,6 +34,7 @@ "workspace": "ワークスペース", "skills": "スキル", "mcp": "MCP", + "acp": "ACP", "agentConfig": "設定", "settings": "設定", "models": "モデル", @@ -115,6 +116,50 @@ "deleteSuccess": "MCPクライアントを削除しました", "deleteError": "MCPクライアントの削除に失敗しました" }, + "acp": { + "title": "ACP設定", + "description": "ACP(Agent Client Protocol)外部エージェントプロトコル設定を管理します。", + "globalSettings": "グローバル設定", + "enabled": "ACPを有効化", + "enabledDescription": "有効にすると、CoPawはACPプロトコルで外部エージェント(OpenCode、Qwen-codeなど)を呼び出せます", + "requireApproval": "実行前にユーザー承認が必要", + "requireApprovalDescription": "有効にすると、ACPタスク実行前にユーザー確認が必要です", + "saveDir": "セッション保存ディレクトリ", + "saveDirDescription": "ACPセッション状態を保存するディレクトリパス", + "harnesses": "Harnessリスト", + "createHarness": "Harnessを追加", + "editHarness": "Harnessを編集", + "harnessKey": "Harness識別子", + "harnessKeyHelp": "一意のHarness識別子(例:opencode、qwen)", + "command": "起動コマンド", + "commandHelp": "Harnessを起動するコマンド(例:npx)", + "args": "コマンド引数", + "argsHelp": "起動コマンドの引数(スペース区切り)", + "envVars": "環境変数", + "envVarsCount": "個の変数", + "addEnvVar": "環境変数を追加", + "envKeyPlaceholder": "変数名", + "envValuePlaceholder": "変数値", + "emptyState": "Harnessが設定されていません", + "loadError": "ACP設定の読み込みに失敗しました", + "saveSuccess": "ACP設定を保存しました", + "saveError": "ACP設定の保存に失敗しました", + "createSuccess": "Harnessを作成しました", + "enableSuccess": "Harnessを有効化しました", + "disableSuccess": "Harnessを無効化しました", + "deleteSuccess": "Harnessを削除しました", + "deleteConfirm": "このHarnessを削除してもよろしいですか?", + "keyExists": "このHarness識別子は既に存在します", + "approval": { + "title": "外部エージェントの承認待ち", + "defaultMessage": "外部エージェントが承認を待っています。", + "harness": "Harness", + "tool": "ツール", + "kind": "種類", + "target": "ターゲット", + "instruction": "/approve と入力して許可するか、他のメッセージを送信して拒否してください。" + } + }, "heartbeat": { "title": "ハートビート", "description": "一定間隔でHEARTBEAT.mdを実行してセルフチェックを行います。デフォルトでは現在の会話に影響を与えずサイレントに動作しますが、オプションで最後のチャットチャンネルに返信を送ることもできます。", diff --git a/console/src/locales/ru.json b/console/src/locales/ru.json index dbafd670f..f43fa20f1 100644 --- a/console/src/locales/ru.json +++ b/console/src/locales/ru.json @@ -35,6 +35,7 @@ "skills": "Навыки", "tools": "Инструменты", "mcp": "MCP", + "acp": "ACP", "agentConfig": "Конфигурация", "settings": "Настройки", "models": "Модели", @@ -120,6 +121,50 @@ "deleteSuccess": "MCP-клиент успешно удалён", "deleteError": "Не удалось удалить MCP-клиент" }, + "acp": { + "title": "Конфигурация ACP", + "description": "Управление конфигурацией протокола внешнего агента ACP (Agent Client Protocol).", + "globalSettings": "Глобальные настройки", + "enabled": "Включить ACP", + "enabledDescription": "При включении CoPaw может вызывать внешних агентов через протокол ACP (например, OpenCode, Qwen-code)", + "requireApproval": "Требовать подтверждение пользователя", + "requireApprovalDescription": "При включении задачи ACP требуют подтверждения пользователя перед выполнением", + "saveDir": "Каталог сохранения сессий", + "saveDirDescription": "Путь к каталогу для сохранения состояний сессий ACP", + "harnesses": "Список Harness", + "createHarness": "Добавить Harness", + "editHarness": "Редактировать Harness", + "harnessKey": "Идентификатор Harness", + "harnessKeyHelp": "Уникальный идентификатор Harness, например opencode, qwen", + "command": "Команда запуска", + "commandHelp": "Команда для запуска Harness, например npx", + "args": "Аргументы команды", + "argsHelp": "Аргументы команды запуска, разделённые пробелами", + "envVars": "Переменные окружения", + "envVarsCount": "переменных", + "addEnvVar": "Добавить переменную окружения", + "envKeyPlaceholder": "Имя переменной", + "envValuePlaceholder": "Значение переменной", + "emptyState": "Harness не настроены", + "loadError": "Не удалось загрузить конфигурацию ACP", + "saveSuccess": "Конфигурация ACP успешно сохранена", + "saveError": "Не удалось сохранить конфигурацию ACP", + "createSuccess": "Harness успешно создан", + "enableSuccess": "Harness успешно включён", + "disableSuccess": "Harness успешно отключён", + "deleteSuccess": "Harness успешно удалён", + "deleteConfirm": "Вы уверены, что хотите удалить этот Harness?", + "keyExists": "Этот идентификатор Harness уже существует", + "approval": { + "title": "Ожидание подтверждения внешнего агента", + "defaultMessage": "Внешний агент ожидает подтверждения.", + "harness": "Harness", + "tool": "Инструмент", + "kind": "Тип", + "target": "Цель", + "instruction": "Введите /approve для разрешения или отправьте любое другое сообщение для отклонения." + } + }, "heartbeat": { "title": "Пульс", "description": "Запускайте HEARTBEAT.md с фиксированным интервалом для самопроверок. По умолчанию выполняется тихо и не влияет на текущие диалоги, либо может отправлять ответы в последний канал чата.", diff --git a/console/src/locales/zh.json b/console/src/locales/zh.json index 208191bbf..2bc3f67c7 100644 --- a/console/src/locales/zh.json +++ b/console/src/locales/zh.json @@ -36,6 +36,7 @@ "skills": "技能", "tools": "工具", "mcp": "MCP", + "acp": "ACP", "agentConfig": "运行配置", "settings": "设置", "models": "模型", @@ -121,6 +122,50 @@ "deleteSuccess": "MCP 客户端删除成功", "deleteError": "MCP 客户端删除失败" }, + "acp": { + "title": "ACP 配置", + "description": "管理 ACP(Agent Client Protocol)外部智能体协议配置。", + "globalSettings": "全局设置", + "enabled": "启用 ACP", + "enabledDescription": "开启后,CoPaw 可以通过 ACP 协议调用外部智能体(如 OpenCode、Qwen-code)", + "requireApproval": "执行前需要用户批准", + "requireApprovalDescription": "开启后,ACP 执行任务前需要用户确认", + "saveDir": "会话保存目录", + "saveDirDescription": "ACP 会话状态保存的目录路径", + "harnesses": "Harness 列表", + "createHarness": "添加 Harness", + "editHarness": "编辑 Harness", + "harnessKey": "Harness 标识", + "harnessKeyHelp": "唯一的 Harness 标识名,如 opencode、qwen", + "command": "启动命令", + "commandHelp": "用于启动 Harness 的命令,如 npx", + "args": "命令参数", + "argsHelp": "启动命令的参数,用空格分隔", + "envVars": "环境变量", + "envVarsCount": "个变量", + "addEnvVar": "添加环境变量", + "envKeyPlaceholder": "变量名", + "envValuePlaceholder": "变量值", + "emptyState": "暂无配置的 Harness", + "loadError": "加载 ACP 配置失败", + "saveSuccess": "保存 ACP 配置成功", + "saveError": "保存 ACP 配置失败", + "createSuccess": "Harness 创建成功", + "enableSuccess": "Harness 启用成功", + "disableSuccess": "Harness 禁用成功", + "deleteSuccess": "Harness 删除成功", + "deleteConfirm": "确定要删除此 Harness 吗?", + "keyExists": "该 Harness 标识已存在", + "approval": { + "title": "等待外部 Agent 权限确认", + "defaultMessage": "外部 Agent 等待审批中。", + "harness": "Harness", + "tool": "工具", + "kind": "类型", + "target": "目标", + "instruction": "输入 /approve 批准,或发送任意消息拒绝。" + } + }, "heartbeat": { "title": "心跳", "description": "按固定间隔用 HEARTBEAT.md 内容执行自检。默认静默运行不影响当前对话,也可选择将回复发到上次对话频道。", diff --git a/console/src/pages/Agent/ACP/components/ACPGlobalSettings.tsx b/console/src/pages/Agent/ACP/components/ACPGlobalSettings.tsx new file mode 100644 index 000000000..de8830372 --- /dev/null +++ b/console/src/pages/Agent/ACP/components/ACPGlobalSettings.tsx @@ -0,0 +1,118 @@ +import { Card, Form, Switch, Input, Button } from "@agentscope-ai/design"; +import { useTranslation } from "react-i18next"; +import { useEffect, useState } from "react"; +import type { ACPConfig } from "../../../../api/types"; +import styles from "../index.module.less"; + +interface ACPGlobalSettingsProps { + config: ACPConfig | null; + onUpdate: (settings: { + enabled?: boolean; + require_approval?: boolean; + save_dir?: string; + }) => Promise; + saving: boolean; +} + +export function ACPGlobalSettings({ + config, + onUpdate, + saving, +}: ACPGlobalSettingsProps) { + const { t } = useTranslation(); + const [localEnabled, setLocalEnabled] = useState(false); + const [localRequireApproval, setLocalRequireApproval] = useState(false); + const [localSaveDir, setLocalSaveDir] = useState(""); + const [hasChanges, setHasChanges] = useState(false); + + useEffect(() => { + if (config) { + setLocalEnabled(config.enabled); + setLocalRequireApproval(config.require_approval); + setLocalSaveDir(config.save_dir); + setHasChanges(false); + } + }, [config]); + + const handleEnabledChange = (checked: boolean) => { + setLocalEnabled(checked); + setHasChanges(true); + }; + + const handleRequireApprovalChange = (checked: boolean) => { + setLocalRequireApproval(checked); + setHasChanges(true); + }; + + const handleSaveDirChange = (e: React.ChangeEvent) => { + setLocalSaveDir(e.target.value); + setHasChanges(true); + }; + + const handleSave = async () => { + const success = await onUpdate({ + enabled: localEnabled, + require_approval: localRequireApproval, + save_dir: localSaveDir, + }); + if (success) { + setHasChanges(false); + } + }; + + const handleReset = () => { + if (config) { + setLocalEnabled(config.enabled); + setLocalRequireApproval(config.require_approval); + setLocalSaveDir(config.save_dir); + setHasChanges(false); + } + }; + + return ( + +
+ + + + + + + + + + + + + {hasChanges && ( + +
+ + +
+
+ )} +
+
+ ); +} diff --git a/console/src/pages/Agent/ACP/components/ACPHarnessCard.tsx b/console/src/pages/Agent/ACP/components/ACPHarnessCard.tsx new file mode 100644 index 000000000..7ea8f6417 --- /dev/null +++ b/console/src/pages/Agent/ACP/components/ACPHarnessCard.tsx @@ -0,0 +1,142 @@ +import { Card, Button, Modal, Tooltip } from "@agentscope-ai/design"; +import { DeleteOutlined } from "@ant-design/icons"; +import { Bot } from "lucide-react"; +import type { ACPHarnessInfo } from "../../../../api/types"; +import { useTranslation } from "react-i18next"; +import { useState } from "react"; +import styles from "../index.module.less"; + +interface ACPHarnessCardProps { + harness: ACPHarnessInfo; + onToggle: (key: string) => void; + onDelete: (key: string) => void; + onEdit: (harness: ACPHarnessInfo) => void; + isHovered: boolean; + onMouseEnter: () => void; + onMouseLeave: () => void; +} + +export function ACPHarnessCard({ + harness, + onToggle, + onDelete, + onEdit, + isHovered, + onMouseEnter, + onMouseLeave, +}: ACPHarnessCardProps) { + const { t } = useTranslation(); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + + const handleToggleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onToggle(harness.key); + }; + + const handleDeleteClick = (e: React.MouseEvent) => { + e.stopPropagation(); + setDeleteModalOpen(true); + }; + + const confirmDelete = () => { + setDeleteModalOpen(false); + onDelete(harness.key); + }; + + const handleCardClick = () => { + onEdit(harness); + }; + + return ( + <> + +
+
+ + + + +

{harness.name}

+
+
+
+ + + {harness.enabled ? t("common.enabled") : t("common.disabled")} + +
+
+ +
+
+ {t("acp.command")}: + {harness.command || "-"} +
+
+ {t("acp.args")}: + + {harness.args?.join(" ") || "-"} + +
+
+ {t("acp.envVars")}: + + {Object.keys(harness.env || {}).length > 0 + ? `${Object.keys(harness.env).length} ${t("acp.envVarsCount")}` + : "-"} + +
+
+ +
+ + +
+
+ + setDeleteModalOpen(false)} + okText={t("common.confirm")} + cancelText={t("common.cancel")} + okButtonProps={{ danger: true }} + > +

{t("acp.deleteConfirm")}

+
+ + ); +} diff --git a/console/src/pages/Agent/ACP/components/HarnessEditDrawer.tsx b/console/src/pages/Agent/ACP/components/HarnessEditDrawer.tsx new file mode 100644 index 000000000..d2fd792e1 --- /dev/null +++ b/console/src/pages/Agent/ACP/components/HarnessEditDrawer.tsx @@ -0,0 +1,245 @@ +import { + Drawer, + Form, + Input, + Switch, + Button, + Tag, +} from "@agentscope-ai/design"; +import type { ACPHarnessInfo } from "../../../../api/types"; +import { useTranslation } from "react-i18next"; +import { useState, useEffect } from "react"; +import styles from "../index.module.less"; + +interface HarnessEditDrawerProps { + open: boolean; + harness: ACPHarnessInfo | null; + onClose: () => void; + onSubmit: ( + key: string, + values: { + command: string; + args: string[]; + env: Record; + enabled: boolean; + }, + ) => Promise; + isCreating?: boolean; +} + +export function HarnessEditDrawer({ + open, + harness, + onClose, + onSubmit, + isCreating = false, +}: HarnessEditDrawerProps) { + const { t } = useTranslation(); + const [submitting, setSubmitting] = useState(false); + const [key, setKey] = useState(""); + const [command, setCommand] = useState(""); + const [args, setArgs] = useState(""); + const [env, setEnv] = useState>({}); + const [enabled, setEnabled] = useState(false); + const [newEnvKey, setNewEnvKey] = useState(""); + const [newEnvValue, setNewEnvValue] = useState(""); + const [showNewEnv, setShowNewEnv] = useState(false); + + useEffect(() => { + if (open && harness) { + setKey(harness.key); + setCommand(harness.command || ""); + setArgs(harness.args?.join(" ") || ""); + setEnv(harness.env || {}); + setEnabled(harness.enabled || false); + } else if (open && isCreating) { + setKey(""); + setCommand("npx"); + setArgs(""); + setEnv({}); + setEnabled(false); + } + setShowNewEnv(false); + setNewEnvKey(""); + setNewEnvValue(""); + }, [open, harness, isCreating]); + + const handleSubmit = async () => { + setSubmitting(true); + try { + const submitKey = isCreating ? key : harness!.key; + const success = await onSubmit(submitKey, { + command, + args: args.split(" ").filter(Boolean), + env, + enabled, + }); + if (success) { + onClose(); + } + } finally { + setSubmitting(false); + } + }; + + const handleAddEnv = () => { + if (newEnvKey.trim()) { + setEnv({ ...env, [newEnvKey.trim()]: newEnvValue }); + setNewEnvKey(""); + setNewEnvValue(""); + setShowNewEnv(false); + } + }; + + const handleRemoveEnv = (keyToRemove: string) => { + const { [keyToRemove]: _, ...rest } = env; + setEnv(rest); + }; + + const title = isCreating ? t("acp.createHarness") : t("acp.editHarness"); + + return ( + + + + + } + > +
+ {isCreating && ( + + setKey(e.target.value)} + placeholder="opencode" + disabled={submitting} + /> + + )} + + + setCommand(e.target.value)} + placeholder="npx" + disabled={submitting} + /> + + + + setArgs(e.target.value)} + placeholder="-y opencode-ai@latest acp" + disabled={submitting} + /> + + + + + + + +
+ {Object.entries(env).map(([k]) => ( +
+ + {k} + = + ****** + + +
+ ))} + + {showNewEnv ? ( +
+ setNewEnvKey(e.target.value)} + disabled={submitting} + style={{ width: 150 }} + /> + = + setNewEnvValue(e.target.value)} + disabled={submitting} + style={{ width: 200 }} + /> + + +
+ ) : ( + + )} +
+
+
+
+ ); +} diff --git a/console/src/pages/Agent/ACP/components/index.ts b/console/src/pages/Agent/ACP/components/index.ts new file mode 100644 index 000000000..a51c10bd4 --- /dev/null +++ b/console/src/pages/Agent/ACP/components/index.ts @@ -0,0 +1,3 @@ +export { ACPHarnessCard } from "./ACPHarnessCard"; +export { ACPGlobalSettings } from "./ACPGlobalSettings"; +export { HarnessEditDrawer } from "./HarnessEditDrawer"; diff --git a/console/src/pages/Agent/ACP/index.module.less b/console/src/pages/Agent/ACP/index.module.less new file mode 100644 index 000000000..4f30ab01c --- /dev/null +++ b/console/src/pages/Agent/ACP/index.module.less @@ -0,0 +1,188 @@ +// Global Settings Card +.globalSettingsCard { + margin-bottom: 24px; + border-radius: 12px; + + :global(.ant-card-head) { + border-bottom: 1px solid #f0f0f0; + } + + :global(.ant-card-head-title) { + font-weight: 600; + font-size: 16px; + } +} + +// Harness Card +.harnessCard { + border-radius: 12px; + transition: all 0.2s ease-in-out; + cursor: pointer; + padding: 16px; + + &.enabledCard { + border: 2px solid #615ced !important; + box-shadow: rgba(97, 92, 237, 0.2) 0px 4px 12px !important; + } + + &.hover { + border: 1px solid #615ced; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08); + } + + &.normal { + border: 1px solid rgba(0, 0, 0, 0.04); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); + } +} + +.cardHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.harnessTitle { + margin: 0; + font-size: 17px; + font-weight: 600; + color: #1a1a1a; + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fileIcon { + font-size: 20px; + display: flex; + align-items: center; +} + +// Status +.statusContainer { + display: flex; + align-items: center; + gap: 6px; +} + +.statusDot { + width: 6px; + height: 6px; + border-radius: 50%; + + &.enabled { + background-color: #52c41a; + } + + &.disabled { + background-color: #d9d9d9; + } +} + +.statusText { + font-size: 12px; + + &.enabled { + color: #52c41a; + } + + &.disabled { + color: #999; + } +} + +// Harness Details +.harnessDetails { + margin-bottom: 16px; +} + +.detailRow { + display: flex; + margin-bottom: 8px; + font-size: 13px; +} + +.detailLabel { + color: #666; + min-width: 80px; + flex-shrink: 0; +} + +.detailValue { + color: #1a1a1a; + word-break: break-all; + font-family: "Monaco", "Courier New", monospace; +} + +// Card Footer +.cardFooter { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 8px; + padding-top: 16px; + border-top: 1px solid #f0f0f0; +} + +.actionButton { + padding: 0; + font-size: 12px; +} + +.deleteButton { + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + background-color: #ff4d4f; + color: #fff; + border-color: #ff4d4f; + } +} + +// Environment Variables in Drawer +.envContainer { + display: flex; + flex-direction: column; + gap: 8px; +} + +.envRow { + display: flex; + align-items: center; + gap: 8px; +} + +.envTag { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + font-size: 13px; +} + +.envKey { + font-weight: 500; + color: #1a1a1a; +} + +.envSeparator { + color: #999; + margin: 0 4px; +} + +.envValue { + color: #666; + font-family: "Monaco", "Courier New", monospace; +} + +.newEnvRow { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 0; +} diff --git a/console/src/pages/Agent/ACP/index.tsx b/console/src/pages/Agent/ACP/index.tsx new file mode 100644 index 000000000..b8ea62f79 --- /dev/null +++ b/console/src/pages/Agent/ACP/index.tsx @@ -0,0 +1,149 @@ +import { useState } from "react"; +import { Button, Empty } from "@agentscope-ai/design"; +import { useACP } from "./useACP"; +import { + ACPHarnessCard, + ACPGlobalSettings, + HarnessEditDrawer, +} from "./components"; +import { useTranslation } from "react-i18next"; +import type { ACPHarnessInfo } from "../../../api/types"; + +function ACPPage() { + const { t } = useTranslation(); + const { + harnesses, + config, + loading, + saving, + toggleHarnessEnabled, + deleteHarness, + createHarness, + updateHarness, + updateGlobalSettings, + } = useACP(); + const [hoverKey, setHoverKey] = useState(null); + const [drawerOpen, setDrawerOpen] = useState(false); + const [editingHarness, setEditingHarness] = useState( + null, + ); + const [isCreating, setIsCreating] = useState(false); + + const handleToggleEnabled = async (key: string) => { + await toggleHarnessEnabled(key); + }; + + const handleDelete = async (key: string) => { + await deleteHarness(key); + }; + + const handleEdit = (harness: ACPHarnessInfo) => { + setEditingHarness(harness); + setIsCreating(false); + setDrawerOpen(true); + }; + + const handleCreate = () => { + setEditingHarness(null); + setIsCreating(true); + setDrawerOpen(true); + }; + + const handleDrawerClose = () => { + setDrawerOpen(false); + setEditingHarness(null); + setIsCreating(false); + }; + + const handleSubmit = async ( + key: string, + values: { + command: string; + args: string[]; + env: Record; + enabled: boolean; + }, + ) => { + if (isCreating) { + return await createHarness(key, values); + } else { + return await updateHarness(key, values); + } + }; + + return ( +
+
+
+

+ {t("acp.title")} +

+

+ {t("acp.description")} +

+
+ +
+ + + +
+

+ {t("acp.harnesses")} +

+ + {loading ? ( +
+

{t("common.loading")}

+
+ ) : harnesses.length === 0 ? ( + + ) : ( +
+ {harnesses.map((harness) => ( + setHoverKey(harness.key)} + onMouseLeave={() => setHoverKey(null)} + /> + ))} +
+ )} +
+ + +
+ ); +} + +export default ACPPage; diff --git a/console/src/pages/Agent/ACP/useACP.ts b/console/src/pages/Agent/ACP/useACP.ts new file mode 100644 index 000000000..84fcc86c0 --- /dev/null +++ b/console/src/pages/Agent/ACP/useACP.ts @@ -0,0 +1,194 @@ +import { useCallback, useEffect, useState } from "react"; +import { message } from "@agentscope-ai/design"; +import api from "../../../api"; +import type { + ACPConfig, + ACPHarnessInfo, + ACPHarnessConfig, +} from "../../../api/types"; +import { useTranslation } from "react-i18next"; + +export function useACP() { + const { t } = useTranslation(); + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + const loadConfig = useCallback(async () => { + setLoading(true); + try { + const data = await api.getACPConfig(); + setConfig(data); + } catch (error) { + console.error("Failed to load ACP config:", error); + message.error(t("acp.loadError")); + } finally { + setLoading(false); + } + }, [t]); + + useEffect(() => { + loadConfig(); + }, [loadConfig]); + + const saveConfig = useCallback( + async (newConfig: ACPConfig) => { + setSaving(true); + try { + const updated = await api.updateACPConfig(newConfig); + setConfig(updated); + message.success(t("acp.saveSuccess")); + return true; + } catch (error: any) { + const errorMsg = error?.message || t("acp.saveError"); + message.error(errorMsg); + return false; + } finally { + setSaving(false); + } + }, + [t], + ); + + const toggleHarnessEnabled = useCallback( + async (key: string) => { + if (!config) return false; + const harness = config.harnesses[key]; + if (!harness) return false; + + const newConfig = { + ...config, + harnesses: { + ...config.harnesses, + [key]: { + ...harness, + enabled: !harness.enabled, + }, + }, + }; + + const success = await saveConfig(newConfig); + if (success) { + message.success( + harness.enabled ? t("acp.disableSuccess") : t("acp.enableSuccess"), + ); + } + return success; + }, + [config, saveConfig, t], + ); + + const updateHarness = useCallback( + async (key: string, updates: Partial) => { + if (!config) return false; + const harness = config.harnesses[key]; + + const newConfig = { + ...config, + harnesses: { + ...config.harnesses, + [key]: { + command: harness?.command ?? "", + args: harness?.args ?? [], + env: harness?.env ?? {}, + enabled: harness?.enabled ?? false, + ...updates, + }, + }, + }; + + return await saveConfig(newConfig); + }, + [config, saveConfig], + ); + + const deleteHarness = useCallback( + async (key: string) => { + if (!config) return false; + const { [key]: _, ...remainingHarnesses } = config.harnesses; + + const newConfig = { + ...config, + harnesses: remainingHarnesses, + }; + + const success = await saveConfig(newConfig); + if (success) { + message.success(t("acp.deleteSuccess")); + } + return success; + }, + [config, saveConfig, t], + ); + + const createHarness = useCallback( + async (key: string, harnessConfig: ACPHarnessConfig) => { + if (!config) return false; + + if (config.harnesses[key]) { + message.error(t("acp.keyExists")); + return false; + } + + const newConfig = { + ...config, + harnesses: { + ...config.harnesses, + [key]: { + command: harnessConfig.command, + args: harnessConfig.args, + env: harnessConfig.env, + enabled: harnessConfig.enabled, + }, + }, + }; + + const success = await saveConfig(newConfig); + if (success) { + message.success(t("acp.createSuccess")); + } + return success; + }, + [config, saveConfig, t], + ); + + const updateGlobalSettings = useCallback( + async (settings: { + enabled?: boolean; + require_approval?: boolean; + save_dir?: string; + }) => { + if (!config) return false; + + const newConfig = { + ...config, + ...settings, + }; + + return await saveConfig(newConfig); + }, + [config, saveConfig], + ); + + const harnesses: ACPHarnessInfo[] = config + ? Object.entries(config.harnesses).map(([key, h]) => ({ + key, + name: key, + ...h, + })) + : []; + + return { + config, + harnesses, + loading, + saving, + loadConfig, + saveConfig, + toggleHarnessEnabled, + updateHarness, + deleteHarness, + createHarness, + updateGlobalSettings, + }; +} diff --git a/console/src/pages/Chat/index.tsx b/console/src/pages/Chat/index.tsx index b40674c0e..742353fb5 100644 --- a/console/src/pages/Chat/index.tsx +++ b/console/src/pages/Chat/index.tsx @@ -3,12 +3,26 @@ import { IAgentScopeRuntimeWebUIOptions, } from "@agentscope-ai/chat"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Button, Modal, Result, message } from "antd"; +import { + Button, + Card, + Modal, + Result, + Select, + Space, + Switch, + Typography, + message, +} from "antd"; import { ExclamationCircleOutlined, SettingOutlined } from "@ant-design/icons"; import { SparkCopyLine } from "@agentscope-ai/icons"; import { useTranslation } from "react-i18next"; import { useLocation, useNavigate } from "react-router-dom"; +import api, { acpApi } from "../../api"; +import type { PendingApproval } from "../../api/modules/approval"; +import type { ParsedExternalAgent } from "../../api/types"; import sessionApi from "./sessionApi"; +import type { ExternalAgentMeta } from "./sessionApi"; import defaultConfig, { getDefaultConfig } from "./OptionsPanel/defaultConfig"; import Weather from "./Weather"; import { getApiToken, getApiUrl } from "../../api/config"; @@ -38,6 +52,168 @@ interface CustomWindow extends Window { declare const window: CustomWindow; +type ExternalAgentState = { + enabled: boolean; + harness: "opencode" | "qwen"; + keepSession: boolean; +}; + +const DEFAULT_EXTERNAL_AGENT: ExternalAgentState = { + enabled: false, + harness: "opencode", + keepSession: false, +}; + +function isTempSessionId(value?: string): boolean { + return !!value && /^\d+$/.test(value); +} + +function stripQuotedValue(value?: string): string | undefined { + if (!value) return undefined; + const trimmed = value.trim(); + if ( + trimmed.length >= 2 && + ((trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'"))) + ) { + return trimmed.slice(1, -1).trim() || undefined; + } + return trimmed || undefined; +} + +/** + * Structured ACP approval summary from backend. + */ +interface ACPApprovalSummary { + type: "acp_approval_summary"; + harness: string; + tool_name: string; + tool_kind: string; + target?: string; +} + +/** + * Render ACP approval summary from structured data. + * Supports both new structured format and legacy string format. + */ +function renderApprovalSummary( + summary: string | ACPApprovalSummary | undefined, + t: (key: string) => string, +): string { + if (!summary) { + return ( + t("acp.approval.defaultMessage") || + "External agent is waiting for approval." + ); + } + + // Handle legacy string format for backward compatibility + if (typeof summary === "string") { + return summary; + } + + // Handle new structured format + if (summary.type === "acp_approval_summary") { + const lines: string[] = [ + t("acp.approval.title") || "Waiting for external agent approval", + "", + `${t("acp.approval.harness") || "Harness"}: ${summary.harness}`, + `${t("acp.approval.tool") || "Tool"}: ${summary.tool_name}`, + `${t("acp.approval.kind") || "Kind"}: ${summary.tool_kind}`, + ]; + + if (summary.target) { + lines.push(`${t("acp.approval.target") || "Target"}: ${summary.target}`); + } + + lines.push( + "", + t("acp.approval.instruction") || + "Type /approve to allow, or send any other message to deny.", + ); + + return lines.join("\n"); + } + + return String(summary); +} + +/** + * Convert ParsedExternalAgent from API to UI state format. + */ +function parsedAgentToState(parsed: ParsedExternalAgent): { + harness: "opencode" | "qwen"; + keepSession: boolean; + keepSessionSpecified: boolean; + cwd?: string; + existingSessionId?: string; + cleanText: string; +} | null { + if (!parsed.enabled || !parsed.harness) return null; + + const harness = parsed.harness === "qwen" ? "qwen" : "opencode"; + return { + harness, + keepSession: parsed.keep_session, + keepSessionSpecified: true, + cwd: parsed.cwd || undefined, + existingSessionId: parsed.existing_session_id || undefined, + cleanText: parsed.prompt || "请帮我处理", + }; +} + +function externalAgentStateFromMeta( + meta?: ExternalAgentMeta | null, +): ExternalAgentState { + const harness = meta?.harness === "qwen" ? "qwen" : "opencode"; + return { + enabled: Boolean(meta?.enabled), + harness, + keepSession: Boolean(meta?.keep_session), + }; +} + +function ExternalAgentSelector(props: { + value: ExternalAgentState; + onChange: (value: ExternalAgentState) => void; +}) { + const { value, onChange } = props; + + return ( + + External Agent +