diff --git a/README.ja-JP.md b/README.ja-JP.md index c745690bb..6d4ceeb3e 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -98,7 +98,8 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています インストールから最初のAIインタラクションまで、すべてのセットアップを直感的なグラフィカルインターフェースで完了できます。ターミナルコマンド不要、YAMLファイル不要、環境変数の探索も不要です。 ### 💬 インテリジェントチャットインターフェース -モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴、Markdownによるリッチコンテンツレンダリングをサポートしています。 +モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴、Markdownによるリッチコンテンツレンダリングに加え、マルチエージェント構成ではメイン入力欄の `@agent` から対象エージェントへ直接ルーティングできます。 +`@agent` で別のエージェントを選ぶと、ClawX はデフォルトエージェントを経由せず、そのエージェント自身の会話コンテキストへ直接切り替えます。各エージェントのワークスペースは既定で分離されていますが、より強い実行時分離は OpenClaw の sandbox 設定に依存します。 ### 📡 マルチチャネル管理 複数のAIチャネルを同時に設定・監視できます。各チャネルは独立して動作するため、異なるタスクに特化したエージェントを実行できます。 diff --git a/README.md b/README.md index 9783f8a38..a1ee163f8 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,8 @@ We are committed to maintaining strict alignment with the upstream OpenClaw proj Complete the entire setup—from installation to your first AI interaction—through an intuitive graphical interface. No terminal commands, no YAML files, no environment variable hunting. ### 💬 Intelligent Chat Interface -Communicate with AI agents through a modern chat experience. Support for multiple conversation contexts, message history, and rich content rendering with Markdown. +Communicate with AI agents through a modern chat experience. Support for multiple conversation contexts, message history, rich content rendering with Markdown, and direct `@agent` routing in the main composer for multi-agent setups. +When you target another agent with `@agent`, ClawX switches into that agent's own conversation context directly instead of relaying through the default agent. Agent workspaces stay separate by default, and stronger isolation depends on OpenClaw sandbox settings. ### 📡 Multi-Channel Management Configure and monitor multiple AI channels simultaneously. Each channel operates independently, allowing you to run specialized agents for different tasks. diff --git a/README.zh-CN.md b/README.zh-CN.md index cae60837b..5ba03d596 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -99,7 +99,8 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们 从安装到第一次 AI 对话,全程通过直观的图形界面完成。无需终端命令,无需 YAML 文件,无需到处寻找环境变量。 ### 💬 智能聊天界面 -通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录以及 Markdown 富文本渲染。 +通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录、Markdown 富文本渲染,以及在多 Agent 场景下通过主输入框中的 `@agent` 直接路由到目标智能体。 +当你使用 `@agent` 选择其他智能体时,ClawX 会直接切换到该智能体自己的对话上下文,而不是经过默认智能体转发。各 Agent 工作区默认彼此分离,但更强的运行时隔离仍取决于 OpenClaw 的 sandbox 配置。 ### 📡 多频道管理 同时配置和监控多个 AI 频道。每个频道独立运行,允许你为不同任务运行专门的智能体。 diff --git a/electron/utils/agent-config.ts b/electron/utils/agent-config.ts index fa2bd5d63..c83444b83 100644 --- a/electron/utils/agent-config.ts +++ b/electron/utils/agent-config.ts @@ -59,6 +59,10 @@ interface BindingConfig extends Record { interface AgentConfigDocument extends Record { agents?: AgentsConfig; bindings?: BindingConfig[]; + session?: { + mainKey?: string; + [key: string]: unknown; + }; } export interface AgentSummary { @@ -69,6 +73,7 @@ export interface AgentSummary { inheritedModel: boolean; workspace: string; agentDir: string; + mainSessionKey: string; channelTypes: string[]; } @@ -201,6 +206,16 @@ function normalizeAgentIdForBinding(id: string): string { return (id ?? '').trim().toLowerCase() || ''; } +function normalizeMainKey(value: unknown): string { + if (typeof value !== 'string') return 'main'; + const trimmed = value.trim().toLowerCase(); + return trimmed || 'main'; +} + +function buildAgentMainSessionKey(config: AgentConfigDocument, agentId: string): string { + return `agent:${normalizeAgentIdForBinding(agentId) || MAIN_AGENT_ID}:${normalizeMainKey(config.session?.mainKey)}`; +} + function getSimpleChannelBindingMap(bindings: unknown): Map { const owners = new Map(); if (!Array.isArray(bindings)) return owners; @@ -369,6 +384,7 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument): Promise channelOwners[channelType] === entryIdNorm), }; }); diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 67fec7b5e..607de237c 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -3,7 +3,7 @@ * Navigation sidebar with menu items. * No longer fixed - sits inside the flex layout below the title bar. */ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { NavLink, useLocation, useNavigate } from 'react-router-dom'; import { Network, @@ -23,6 +23,7 @@ import { cn } from '@/lib/utils'; import { useSettingsStore } from '@/stores/settings'; import { useChatStore } from '@/stores/chat'; import { useGatewayStore } from '@/stores/gateway'; +import { useAgentsStore } from '@/stores/agents'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; @@ -103,6 +104,12 @@ function getSessionBucket(activityMs: number, nowMs: number): SessionBucketKey { const INITIAL_NOW_MS = Date.now(); +function getAgentIdFromSessionKey(sessionKey: string): string { + if (!sessionKey.startsWith('agent:')) return 'main'; + const [, agentId] = sessionKey.split(':'); + return agentId || 'main'; +} + export function Sidebar() { const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed); const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed); @@ -133,7 +140,8 @@ export function Sidebar() { cancelled = true; }; }, [isGatewayRunning, loadHistory, loadSessions]); - + const agents = useAgentsStore((s) => s.agents); + const fetchAgents = useAgentsStore((s) => s.fetchAgents); const navigate = useNavigate(); const isOnChat = useLocation().pathname === '/'; @@ -168,6 +176,15 @@ export function Sidebar() { }, 60 * 1000); return () => window.clearInterval(timer); }, []); + + useEffect(() => { + void fetchAgents(); + }, [fetchAgents]); + + const agentNameById = useMemo( + () => Object.fromEntries(agents.map((agent) => [agent.id, agent.name])), + [agents], + ); const sessionBuckets: Array<{ key: SessionBucketKey; label: string; sessions: typeof sessions }> = [ { key: 'today', label: t('chat:historyBuckets.today'), sessions: [] }, { key: 'yesterday', label: t('chat:historyBuckets.yesterday'), sessions: [] }, @@ -265,39 +282,48 @@ export function Sidebar() {
{bucket.label}
- {bucket.sessions.map((s) => ( -
- - -
- ))} + {bucket.sessions.map((s) => { + const agentId = getAgentIdFromSessionKey(s.key); + const agentName = agentNameById[agentId] || agentId; + return ( +
+ + +
+ ); + })} ) : null ))} diff --git a/src/components/settings/ProvidersSettings.tsx b/src/components/settings/ProvidersSettings.tsx index 35ae7b611..7509600e1 100644 --- a/src/components/settings/ProvidersSettings.tsx +++ b/src/components/settings/ProvidersSettings.tsx @@ -416,9 +416,9 @@ function ProviderCard({ return (
@@ -435,9 +435,9 @@ function ProviderCard({
{account.label} {isDefault && ( - + - Default + {t('aiProviders.card.default')} )}
diff --git a/src/i18n/locales/en/agents.json b/src/i18n/locales/en/agents.json index 4f631ea20..46a261a4b 100644 --- a/src/i18n/locales/en/agents.json +++ b/src/i18n/locales/en/agents.json @@ -1,6 +1,6 @@ { "title": "Agents", - "subtitle": "Manage your OpenClaw agents and their channel ownership", + "subtitle": "When adding a new Agent, ClawX will copy the main Agent's workspace files and runtime auth setup. The configuration can be modified through a dialog.", "refresh": "Refresh", "addAgent": "Add Agent", "gatewayWarning": "Gateway service is not running. Agent/channel changes may take a moment to apply.", @@ -48,4 +48,4 @@ "channelRemoved": "{{channel}} removed", "channelRemoveFailed": "Failed to remove channel: {{error}}" } -} +} \ No newline at end of file diff --git a/src/i18n/locales/en/channels.json b/src/i18n/locales/en/channels.json index 87cbd884e..95df11b8a 100644 --- a/src/i18n/locales/en/channels.json +++ b/src/i18n/locales/en/channels.json @@ -1,6 +1,6 @@ { "title": "Messaging Channels", - "subtitle": "Manage your messaging channels and connections", + "subtitle": "Manage your messaging channels and connections. The configuration is only effective for the main Agent.", "refresh": "Refresh", "addChannel": "Add Channel", "stats": { diff --git a/src/i18n/locales/en/chat.json b/src/i18n/locales/en/chat.json index 8d82e0df8..f0dd04f82 100644 --- a/src/i18n/locales/en/chat.json +++ b/src/i18n/locales/en/chat.json @@ -13,7 +13,21 @@ "toolbar": { "refresh": "Refresh chat", "showThinking": "Show thinking", - "hideThinking": "Hide thinking" + "hideThinking": "Hide thinking", + "currentAgent": "Talking to {{agent}}" + }, + "composer": { + "attachFiles": "Attach files", + "pickAgent": "Choose agent", + "clearTarget": "Clear target agent", + "targetChip": "@{{agent}}", + "agentPickerTitle": "Route the next message to another agent", + "gatewayDisconnectedPlaceholder": "Gateway not connected...", + "send": "Send", + "stop": "Stop", + "gatewayConnected": "connected", + "gatewayStatus": "gateway {{state}} | port: {{port}} {{pid}}", + "retryFailedAttachments": "Retry failed attachments" }, "historyBuckets": { "today": "Today", diff --git a/src/i18n/locales/ja/agents.json b/src/i18n/locales/ja/agents.json index 7c57b7a82..4d3a9467e 100644 --- a/src/i18n/locales/ja/agents.json +++ b/src/i18n/locales/ja/agents.json @@ -1,6 +1,6 @@ { "title": "Agents", - "subtitle": "OpenClaw エージェントとチャンネルの所属を管理します", + "subtitle": "新しい Agent を追加すると、ClawX はメイン Agent のワークスペース初期ファイルと実行時認証設定をコピーします。これらの設定は対話形式で編集できます。", "refresh": "更新", "addAgent": "Agent を追加", "gatewayWarning": "Gateway サービスが停止しています。Agent または Channel の変更が反映されるまで少し時間がかかる場合があります。", @@ -48,4 +48,4 @@ "channelRemoved": "{{channel}} を削除しました", "channelRemoveFailed": "Channel の削除に失敗しました: {{error}}" } -} +} \ No newline at end of file diff --git a/src/i18n/locales/ja/channels.json b/src/i18n/locales/ja/channels.json index ab240f753..2908ee5e1 100644 --- a/src/i18n/locales/ja/channels.json +++ b/src/i18n/locales/ja/channels.json @@ -1,6 +1,6 @@ { "title": "メッセージングチャンネル", - "subtitle": "メッセージングチャンネルと接続を管理", + "subtitle": "メッセージングチャンネルと接続を管理。設定はメイン Agent のみ有効です。", "refresh": "更新", "addChannel": "チャンネルを追加", "stats": { diff --git a/src/i18n/locales/ja/chat.json b/src/i18n/locales/ja/chat.json index bdfd5430d..6c42e0f80 100644 --- a/src/i18n/locales/ja/chat.json +++ b/src/i18n/locales/ja/chat.json @@ -13,7 +13,21 @@ "toolbar": { "refresh": "チャットを更新", "showThinking": "思考を表示", - "hideThinking": "思考を非表示" + "hideThinking": "思考を非表示", + "currentAgent": "現在の会話先: {{agent}}" + }, + "composer": { + "attachFiles": "ファイルを添付", + "pickAgent": "Agent を選択", + "clearTarget": "対象 Agent をクリア", + "targetChip": "@{{agent}}", + "agentPickerTitle": "次のメッセージを別の Agent に直接送信", + "gatewayDisconnectedPlaceholder": "ゲートウェイ未接続...", + "send": "送信", + "stop": "停止", + "gatewayConnected": "接続済み", + "gatewayStatus": "gateway {{state}} | port: {{port}} {{pid}}", + "retryFailedAttachments": "失敗した添付を再試行" }, "historyBuckets": { "today": "今日", diff --git a/src/i18n/locales/zh/agents.json b/src/i18n/locales/zh/agents.json index 99a7ff501..57084c5ce 100644 --- a/src/i18n/locales/zh/agents.json +++ b/src/i18n/locales/zh/agents.json @@ -1,6 +1,6 @@ { "title": "Agents", - "subtitle": "管理 OpenClaw Agent 以及它们的 Channel 归属", + "subtitle": "添加新的 Agent 时,ClawX 会复制主 Agent 的工作区引导文件和运行时认证配置, 配置可以通过以对话的形式进行修改。", "refresh": "刷新", "addAgent": "添加 Agent", "gatewayWarning": "Gateway 服务未运行。Agent 或 Channel 变更可能需要一点时间生效。", @@ -48,4 +48,4 @@ "channelRemoved": "{{channel}} 已移除", "channelRemoveFailed": "移除 Channel 失败:{{error}}" } -} +} \ No newline at end of file diff --git a/src/i18n/locales/zh/channels.json b/src/i18n/locales/zh/channels.json index 083b202af..ea67e78dc 100644 --- a/src/i18n/locales/zh/channels.json +++ b/src/i18n/locales/zh/channels.json @@ -1,6 +1,6 @@ { "title": "消息频道", - "subtitle": "连接到消息平台。", + "subtitle": "连接到消息平台,配置仅对主 Agent 生效。", "refresh": "刷新", "addChannel": "添加频道", "stats": { diff --git a/src/i18n/locales/zh/chat.json b/src/i18n/locales/zh/chat.json index 84031e143..2a68e63cd 100644 --- a/src/i18n/locales/zh/chat.json +++ b/src/i18n/locales/zh/chat.json @@ -13,7 +13,21 @@ "toolbar": { "refresh": "刷新聊天", "showThinking": "显示思考过程", - "hideThinking": "隐藏思考过程" + "hideThinking": "隐藏思考过程", + "currentAgent": "当前对话对象:{{agent}}" + }, + "composer": { + "attachFiles": "添加文件", + "pickAgent": "选择 Agent", + "clearTarget": "清除目标 Agent", + "targetChip": "@{{agent}}", + "agentPickerTitle": "将下一条消息直接发送给其他 Agent", + "gatewayDisconnectedPlaceholder": "网关未连接...", + "send": "发送", + "stop": "停止", + "gatewayConnected": "已连接", + "gatewayStatus": "gateway {{state}} | port: {{port}} {{pid}}", + "retryFailedAttachments": "重试失败的附件" }, "historyBuckets": { "today": "今天", diff --git a/src/pages/Agents/index.tsx b/src/pages/Agents/index.tsx index 2d2c33ded..743b747ec 100644 --- a/src/pages/Agents/index.tsx +++ b/src/pages/Agents/index.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; -import { AlertCircle, Bot, Plus, RefreshCw, Settings2, Trash2, X } from 'lucide-react'; +import { AlertCircle, Bot, Check, Plus, RefreshCw, Settings2, Trash2, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -197,8 +197,9 @@ function AgentCard({ {agent.isDefault && ( + {t('defaultBadge')} )} diff --git a/src/pages/Chat/ChatInput.tsx b/src/pages/Chat/ChatInput.tsx index f3b6d0155..eb73e5625 100644 --- a/src/pages/Chat/ChatInput.tsx +++ b/src/pages/Chat/ChatInput.tsx @@ -6,14 +6,18 @@ * Files are staged to disk via IPC — only lightweight path references * are sent with the message (no base64 over WebSocket). */ -import { useState, useRef, useEffect, useCallback } from 'react'; -import { SendHorizontal, Square, X, Paperclip, FileText, Film, Music, FileArchive, File, Loader2 } from 'lucide-react'; +import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; +import { SendHorizontal, Square, X, Paperclip, FileText, Film, Music, FileArchive, File, Loader2, AtSign } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; import { hostApiFetch } from '@/lib/host-api'; import { invokeIpc } from '@/lib/api-client'; import { cn } from '@/lib/utils'; import { useGatewayStore } from '@/stores/gateway'; +import { useAgentsStore } from '@/stores/agents'; +import { useChatStore } from '@/stores/chat'; +import type { AgentSummary } from '@/types/agent'; +import { useTranslation } from 'react-i18next'; // ── Types ──────────────────────────────────────────────────────── @@ -29,7 +33,7 @@ export interface FileAttachment { } interface ChatInputProps { - onSend: (text: string, attachments?: FileAttachment[]) => void; + onSend: (text: string, attachments?: FileAttachment[], targetAgentId?: string | null) => void; onStop?: () => void; disabled?: boolean; sending?: boolean; @@ -81,11 +85,30 @@ function readFileAsBase64(file: globalThis.File): Promise { // ── Component ──────────────────────────────────────────────────── export function ChatInput({ onSend, onStop, disabled = false, sending = false, isEmpty = false }: ChatInputProps) { + const { t } = useTranslation('chat'); const [input, setInput] = useState(''); const [attachments, setAttachments] = useState([]); + const [targetAgentId, setTargetAgentId] = useState(null); + const [pickerOpen, setPickerOpen] = useState(false); const textareaRef = useRef(null); + const pickerRef = useRef(null); const isComposingRef = useRef(false); const gatewayStatus = useGatewayStore((s) => s.status); + const agents = useAgentsStore((s) => s.agents); + const currentAgentId = useChatStore((s) => s.currentAgentId); + const currentAgentName = useMemo( + () => agents.find((agent) => agent.id === currentAgentId)?.name ?? currentAgentId, + [agents, currentAgentId], + ); + const mentionableAgents = useMemo( + () => agents.filter((agent) => agent.id !== currentAgentId), + [agents, currentAgentId], + ); + const selectedTarget = useMemo( + () => agents.find((agent) => agent.id === targetAgentId) ?? null, + [agents, targetAgentId], + ); + const showAgentPicker = mentionableAgents.length > 0; // Auto-resize textarea useEffect(() => { @@ -102,6 +125,32 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i } }, [disabled]); + useEffect(() => { + if (!targetAgentId) return; + if (targetAgentId === currentAgentId) { + setTargetAgentId(null); + setPickerOpen(false); + return; + } + if (!agents.some((agent) => agent.id === targetAgentId)) { + setTargetAgentId(null); + setPickerOpen(false); + } + }, [agents, currentAgentId, targetAgentId]); + + useEffect(() => { + if (!pickerOpen) return; + const handlePointerDown = (event: MouseEvent) => { + if (!pickerRef.current?.contains(event.target as Node)) { + setPickerOpen(false); + } + }; + document.addEventListener('mousedown', handlePointerDown); + return () => { + document.removeEventListener('mousedown', handlePointerDown); + }; + }, [pickerOpen]); + // ── File staging via native dialog ───────────────────────────── const pickFiles = useCallback(async () => { @@ -258,8 +307,10 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i if (textareaRef.current) { textareaRef.current.style.height = 'auto'; } - onSend(textToSend, attachmentsToSend); - }, [input, attachments, canSend, onSend]); + onSend(textToSend, attachmentsToSend, targetAgentId); + setTargetAgentId(null); + setPickerOpen(false); + }, [input, attachments, canSend, onSend, targetAgentId]); const handleStop = useCallback(() => { if (!canStop) return; @@ -268,6 +319,10 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { + if (e.key === 'Backspace' && !input && targetAgentId) { + setTargetAgentId(null); + return; + } if (e.key === 'Enter' && !e.shiftKey) { const nativeEvent = e.nativeEvent as KeyboardEvent; if (isComposingRef.current || nativeEvent.isComposing || nativeEvent.keyCode === 229) { @@ -277,7 +332,7 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i handleSend(); } }, - [handleSend], + [handleSend, input, targetAgentId], ); // Handle paste (Ctrl/Cmd+V with files) @@ -353,66 +408,126 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i )} {/* Input Row */} -
+
+ {selectedTarget && ( +
+ +
+ )} - {/* Attach Button */} - +
+ {/* Attach Button */} + - {/* Textarea */} -
-