feat(agents): support chat to agent (#403)
This commit is contained in:
@@ -98,7 +98,8 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています
|
|||||||
インストールから最初のAIインタラクションまで、すべてのセットアップを直感的なグラフィカルインターフェースで完了できます。ターミナルコマンド不要、YAMLファイル不要、環境変数の探索も不要です。
|
インストールから最初のAIインタラクションまで、すべてのセットアップを直感的なグラフィカルインターフェースで完了できます。ターミナルコマンド不要、YAMLファイル不要、環境変数の探索も不要です。
|
||||||
|
|
||||||
### 💬 インテリジェントチャットインターフェース
|
### 💬 インテリジェントチャットインターフェース
|
||||||
モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴、Markdownによるリッチコンテンツレンダリングをサポートしています。
|
モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴、Markdownによるリッチコンテンツレンダリングに加え、マルチエージェント構成ではメイン入力欄の `@agent` から対象エージェントへ直接ルーティングできます。
|
||||||
|
`@agent` で別のエージェントを選ぶと、ClawX はデフォルトエージェントを経由せず、そのエージェント自身の会話コンテキストへ直接切り替えます。各エージェントのワークスペースは既定で分離されていますが、より強い実行時分離は OpenClaw の sandbox 設定に依存します。
|
||||||
|
|
||||||
### 📡 マルチチャネル管理
|
### 📡 マルチチャネル管理
|
||||||
複数のAIチャネルを同時に設定・監視できます。各チャネルは独立して動作するため、異なるタスクに特化したエージェントを実行できます。
|
複数のAIチャネルを同時に設定・監視できます。各チャネルは独立して動作するため、異なるタスクに特化したエージェントを実行できます。
|
||||||
|
|||||||
@@ -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.
|
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
|
### 💬 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
|
### 📡 Multi-Channel Management
|
||||||
Configure and monitor multiple AI channels simultaneously. Each channel operates independently, allowing you to run specialized agents for different tasks.
|
Configure and monitor multiple AI channels simultaneously. Each channel operates independently, allowing you to run specialized agents for different tasks.
|
||||||
|
|||||||
@@ -99,7 +99,8 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们
|
|||||||
从安装到第一次 AI 对话,全程通过直观的图形界面完成。无需终端命令,无需 YAML 文件,无需到处寻找环境变量。
|
从安装到第一次 AI 对话,全程通过直观的图形界面完成。无需终端命令,无需 YAML 文件,无需到处寻找环境变量。
|
||||||
|
|
||||||
### 💬 智能聊天界面
|
### 💬 智能聊天界面
|
||||||
通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录以及 Markdown 富文本渲染。
|
通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录、Markdown 富文本渲染,以及在多 Agent 场景下通过主输入框中的 `@agent` 直接路由到目标智能体。
|
||||||
|
当你使用 `@agent` 选择其他智能体时,ClawX 会直接切换到该智能体自己的对话上下文,而不是经过默认智能体转发。各 Agent 工作区默认彼此分离,但更强的运行时隔离仍取决于 OpenClaw 的 sandbox 配置。
|
||||||
|
|
||||||
### 📡 多频道管理
|
### 📡 多频道管理
|
||||||
同时配置和监控多个 AI 频道。每个频道独立运行,允许你为不同任务运行专门的智能体。
|
同时配置和监控多个 AI 频道。每个频道独立运行,允许你为不同任务运行专门的智能体。
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ interface BindingConfig extends Record<string, unknown> {
|
|||||||
interface AgentConfigDocument extends Record<string, unknown> {
|
interface AgentConfigDocument extends Record<string, unknown> {
|
||||||
agents?: AgentsConfig;
|
agents?: AgentsConfig;
|
||||||
bindings?: BindingConfig[];
|
bindings?: BindingConfig[];
|
||||||
|
session?: {
|
||||||
|
mainKey?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentSummary {
|
export interface AgentSummary {
|
||||||
@@ -69,6 +73,7 @@ export interface AgentSummary {
|
|||||||
inheritedModel: boolean;
|
inheritedModel: boolean;
|
||||||
workspace: string;
|
workspace: string;
|
||||||
agentDir: string;
|
agentDir: string;
|
||||||
|
mainSessionKey: string;
|
||||||
channelTypes: string[];
|
channelTypes: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,6 +206,16 @@ function normalizeAgentIdForBinding(id: string): string {
|
|||||||
return (id ?? '').trim().toLowerCase() || '';
|
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<string, string> {
|
function getSimpleChannelBindingMap(bindings: unknown): Map<string, string> {
|
||||||
const owners = new Map<string, string>();
|
const owners = new Map<string, string>();
|
||||||
if (!Array.isArray(bindings)) return owners;
|
if (!Array.isArray(bindings)) return owners;
|
||||||
@@ -369,6 +384,7 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument): Promise<Age
|
|||||||
inheritedModel,
|
inheritedModel,
|
||||||
workspace: entry.workspace || (entry.id === MAIN_AGENT_ID ? getDefaultWorkspacePath(config) : `~/.openclaw/workspace-${entry.id}`),
|
workspace: entry.workspace || (entry.id === MAIN_AGENT_ID ? getDefaultWorkspacePath(config) : `~/.openclaw/workspace-${entry.id}`),
|
||||||
agentDir: entry.agentDir || getDefaultAgentDirPath(entry.id),
|
agentDir: entry.agentDir || getDefaultAgentDirPath(entry.id),
|
||||||
|
mainSessionKey: buildAgentMainSessionKey(config, entry.id),
|
||||||
channelTypes: configuredChannels.filter((channelType) => channelOwners[channelType] === entryIdNorm),
|
channelTypes: configuredChannels.filter((channelType) => channelOwners[channelType] === entryIdNorm),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Navigation sidebar with menu items.
|
* Navigation sidebar with menu items.
|
||||||
* No longer fixed - sits inside the flex layout below the title bar.
|
* 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 { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Network,
|
Network,
|
||||||
@@ -23,6 +23,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import { useSettingsStore } from '@/stores/settings';
|
import { useSettingsStore } from '@/stores/settings';
|
||||||
import { useChatStore } from '@/stores/chat';
|
import { useChatStore } from '@/stores/chat';
|
||||||
import { useGatewayStore } from '@/stores/gateway';
|
import { useGatewayStore } from '@/stores/gateway';
|
||||||
|
import { useAgentsStore } from '@/stores/agents';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||||
@@ -103,6 +104,12 @@ function getSessionBucket(activityMs: number, nowMs: number): SessionBucketKey {
|
|||||||
|
|
||||||
const INITIAL_NOW_MS = Date.now();
|
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() {
|
export function Sidebar() {
|
||||||
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
|
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
|
||||||
const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed);
|
const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed);
|
||||||
@@ -133,7 +140,8 @@ export function Sidebar() {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [isGatewayRunning, loadHistory, loadSessions]);
|
}, [isGatewayRunning, loadHistory, loadSessions]);
|
||||||
|
const agents = useAgentsStore((s) => s.agents);
|
||||||
|
const fetchAgents = useAgentsStore((s) => s.fetchAgents);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isOnChat = useLocation().pathname === '/';
|
const isOnChat = useLocation().pathname === '/';
|
||||||
@@ -168,6 +176,15 @@ export function Sidebar() {
|
|||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
return () => window.clearInterval(timer);
|
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 }> = [
|
const sessionBuckets: Array<{ key: SessionBucketKey; label: string; sessions: typeof sessions }> = [
|
||||||
{ key: 'today', label: t('chat:historyBuckets.today'), sessions: [] },
|
{ key: 'today', label: t('chat:historyBuckets.today'), sessions: [] },
|
||||||
{ key: 'yesterday', label: t('chat:historyBuckets.yesterday'), sessions: [] },
|
{ key: 'yesterday', label: t('chat:historyBuckets.yesterday'), sessions: [] },
|
||||||
@@ -265,39 +282,48 @@ export function Sidebar() {
|
|||||||
<div className="px-2.5 pb-1 text-[11px] font-medium text-muted-foreground/60 tracking-tight">
|
<div className="px-2.5 pb-1 text-[11px] font-medium text-muted-foreground/60 tracking-tight">
|
||||||
{bucket.label}
|
{bucket.label}
|
||||||
</div>
|
</div>
|
||||||
{bucket.sessions.map((s) => (
|
{bucket.sessions.map((s) => {
|
||||||
<div key={s.key} className="group relative flex items-center">
|
const agentId = getAgentIdFromSessionKey(s.key);
|
||||||
<button
|
const agentName = agentNameById[agentId] || agentId;
|
||||||
onClick={() => { switchSession(s.key); navigate('/'); }}
|
return (
|
||||||
className={cn(
|
<div key={s.key} className="group relative flex items-center">
|
||||||
'w-full text-left rounded-lg px-2.5 py-1.5 text-[13px] truncate transition-colors pr-7',
|
<button
|
||||||
'hover:bg-black/5 dark:hover:bg-white/5',
|
onClick={() => { switchSession(s.key); navigate('/'); }}
|
||||||
isOnChat && currentSessionKey === s.key
|
className={cn(
|
||||||
? 'bg-black/5 dark:bg-white/10 text-foreground font-medium'
|
'w-full text-left rounded-lg px-2.5 py-1.5 text-[13px] transition-colors pr-7',
|
||||||
: 'text-foreground/75',
|
'hover:bg-black/5 dark:hover:bg-white/5',
|
||||||
)}
|
isOnChat && currentSessionKey === s.key
|
||||||
>
|
? 'bg-black/5 dark:bg-white/10 text-foreground font-medium'
|
||||||
{getSessionLabel(s.key, s.displayName, s.label)}
|
: 'text-foreground/75',
|
||||||
</button>
|
)}
|
||||||
<button
|
>
|
||||||
aria-label="Delete session"
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
onClick={(e) => {
|
<span className="shrink-0 rounded-full bg-black/[0.04] px-2 py-0.5 text-[10px] font-medium text-foreground/70 dark:bg-white/[0.08]">
|
||||||
e.stopPropagation();
|
{agentName}
|
||||||
setSessionToDelete({
|
</span>
|
||||||
key: s.key,
|
<span className="truncate">{getSessionLabel(s.key, s.displayName, s.label)}</span>
|
||||||
label: getSessionLabel(s.key, s.displayName, s.label),
|
</div>
|
||||||
});
|
</button>
|
||||||
}}
|
<button
|
||||||
className={cn(
|
aria-label="Delete session"
|
||||||
'absolute right-1 flex items-center justify-center rounded p-0.5 transition-opacity',
|
onClick={(e) => {
|
||||||
'opacity-0 group-hover:opacity-100',
|
e.stopPropagation();
|
||||||
'text-muted-foreground hover:text-destructive hover:bg-destructive/10',
|
setSessionToDelete({
|
||||||
)}
|
key: s.key,
|
||||||
>
|
label: getSessionLabel(s.key, s.displayName, s.label),
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
});
|
||||||
</button>
|
}}
|
||||||
</div>
|
className={cn(
|
||||||
))}
|
'absolute right-1 flex items-center justify-center rounded p-0.5 transition-opacity',
|
||||||
|
'opacity-0 group-hover:opacity-100',
|
||||||
|
'text-muted-foreground hover:text-destructive hover:bg-destructive/10',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : null
|
) : null
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -416,9 +416,9 @@ function ProviderCard({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"group flex flex-col p-4 rounded-2xl transition-all relative overflow-hidden",
|
"group flex flex-col p-4 rounded-2xl transition-all relative overflow-hidden hover:bg-black/5 dark:hover:bg-white/5",
|
||||||
isDefault
|
isDefault
|
||||||
? "bg-white dark:bg-accent border border-black/10 dark:border-white/10 shadow-sm"
|
? "bg-black/[0.04] dark:bg-white/[0.06] border border-transparent"
|
||||||
: "bg-transparent border border-black/10 dark:border-white/10"
|
: "bg-transparent border border-black/10 dark:border-white/10"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -435,9 +435,9 @@ function ProviderCard({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-semibold text-[15px]">{account.label}</span>
|
<span className="font-semibold text-[15px]">{account.label}</span>
|
||||||
{isDefault && (
|
{isDefault && (
|
||||||
<span className="flex items-center gap-1 text-[11px] font-medium text-blue-600 dark:text-blue-400 bg-blue-500/10 px-2 py-0.5 rounded-full border border-blue-500/20">
|
<span className="flex items-center gap-1 font-mono text-[10px] font-medium px-2 py-0.5 rounded-full bg-black/[0.04] dark:bg-white/[0.08] border-0 shadow-none text-foreground/70">
|
||||||
<Check className="h-3 w-3" />
|
<Check className="h-3 w-3" />
|
||||||
Default
|
{t('aiProviders.card.default')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"title": "Agents",
|
"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",
|
"refresh": "Refresh",
|
||||||
"addAgent": "Add Agent",
|
"addAgent": "Add Agent",
|
||||||
"gatewayWarning": "Gateway service is not running. Agent/channel changes may take a moment to apply.",
|
"gatewayWarning": "Gateway service is not running. Agent/channel changes may take a moment to apply.",
|
||||||
@@ -48,4 +48,4 @@
|
|||||||
"channelRemoved": "{{channel}} removed",
|
"channelRemoved": "{{channel}} removed",
|
||||||
"channelRemoveFailed": "Failed to remove channel: {{error}}"
|
"channelRemoveFailed": "Failed to remove channel: {{error}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"title": "Messaging Channels",
|
"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",
|
"refresh": "Refresh",
|
||||||
"addChannel": "Add Channel",
|
"addChannel": "Add Channel",
|
||||||
"stats": {
|
"stats": {
|
||||||
|
|||||||
@@ -13,7 +13,21 @@
|
|||||||
"toolbar": {
|
"toolbar": {
|
||||||
"refresh": "Refresh chat",
|
"refresh": "Refresh chat",
|
||||||
"showThinking": "Show thinking",
|
"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": {
|
"historyBuckets": {
|
||||||
"today": "Today",
|
"today": "Today",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"title": "Agents",
|
"title": "Agents",
|
||||||
"subtitle": "OpenClaw エージェントとチャンネルの所属を管理します",
|
"subtitle": "新しい Agent を追加すると、ClawX はメイン Agent のワークスペース初期ファイルと実行時認証設定をコピーします。これらの設定は対話形式で編集できます。",
|
||||||
"refresh": "更新",
|
"refresh": "更新",
|
||||||
"addAgent": "Agent を追加",
|
"addAgent": "Agent を追加",
|
||||||
"gatewayWarning": "Gateway サービスが停止しています。Agent または Channel の変更が反映されるまで少し時間がかかる場合があります。",
|
"gatewayWarning": "Gateway サービスが停止しています。Agent または Channel の変更が反映されるまで少し時間がかかる場合があります。",
|
||||||
@@ -48,4 +48,4 @@
|
|||||||
"channelRemoved": "{{channel}} を削除しました",
|
"channelRemoved": "{{channel}} を削除しました",
|
||||||
"channelRemoveFailed": "Channel の削除に失敗しました: {{error}}"
|
"channelRemoveFailed": "Channel の削除に失敗しました: {{error}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"title": "メッセージングチャンネル",
|
"title": "メッセージングチャンネル",
|
||||||
"subtitle": "メッセージングチャンネルと接続を管理",
|
"subtitle": "メッセージングチャンネルと接続を管理。設定はメイン Agent のみ有効です。",
|
||||||
"refresh": "更新",
|
"refresh": "更新",
|
||||||
"addChannel": "チャンネルを追加",
|
"addChannel": "チャンネルを追加",
|
||||||
"stats": {
|
"stats": {
|
||||||
|
|||||||
@@ -13,7 +13,21 @@
|
|||||||
"toolbar": {
|
"toolbar": {
|
||||||
"refresh": "チャットを更新",
|
"refresh": "チャットを更新",
|
||||||
"showThinking": "思考を表示",
|
"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": {
|
"historyBuckets": {
|
||||||
"today": "今日",
|
"today": "今日",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"title": "Agents",
|
"title": "Agents",
|
||||||
"subtitle": "管理 OpenClaw Agent 以及它们的 Channel 归属",
|
"subtitle": "添加新的 Agent 时,ClawX 会复制主 Agent 的工作区引导文件和运行时认证配置, 配置可以通过以对话的形式进行修改。",
|
||||||
"refresh": "刷新",
|
"refresh": "刷新",
|
||||||
"addAgent": "添加 Agent",
|
"addAgent": "添加 Agent",
|
||||||
"gatewayWarning": "Gateway 服务未运行。Agent 或 Channel 变更可能需要一点时间生效。",
|
"gatewayWarning": "Gateway 服务未运行。Agent 或 Channel 变更可能需要一点时间生效。",
|
||||||
@@ -48,4 +48,4 @@
|
|||||||
"channelRemoved": "{{channel}} 已移除",
|
"channelRemoved": "{{channel}} 已移除",
|
||||||
"channelRemoveFailed": "移除 Channel 失败:{{error}}"
|
"channelRemoveFailed": "移除 Channel 失败:{{error}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"title": "消息频道",
|
"title": "消息频道",
|
||||||
"subtitle": "连接到消息平台。",
|
"subtitle": "连接到消息平台,配置仅对主 Agent 生效。",
|
||||||
"refresh": "刷新",
|
"refresh": "刷新",
|
||||||
"addChannel": "添加频道",
|
"addChannel": "添加频道",
|
||||||
"stats": {
|
"stats": {
|
||||||
|
|||||||
@@ -13,7 +13,21 @@
|
|||||||
"toolbar": {
|
"toolbar": {
|
||||||
"refresh": "刷新聊天",
|
"refresh": "刷新聊天",
|
||||||
"showThinking": "显示思考过程",
|
"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": {
|
"historyBuckets": {
|
||||||
"today": "今天",
|
"today": "今天",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -197,8 +197,9 @@ function AgentCard({
|
|||||||
{agent.isDefault && (
|
{agent.isDefault && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="font-mono text-[10px] font-medium px-2 py-0.5 rounded-full bg-black/[0.04] dark:bg-white/[0.08] border-0 shadow-none text-foreground/70"
|
className="flex items-center gap-1 font-mono text-[10px] font-medium px-2 py-0.5 rounded-full bg-black/[0.04] dark:bg-white/[0.08] border-0 shadow-none text-foreground/70"
|
||||||
>
|
>
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
{t('defaultBadge')}
|
{t('defaultBadge')}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,14 +6,18 @@
|
|||||||
* Files are staged to disk via IPC — only lightweight path references
|
* Files are staged to disk via IPC — only lightweight path references
|
||||||
* are sent with the message (no base64 over WebSocket).
|
* are sent with the message (no base64 over WebSocket).
|
||||||
*/
|
*/
|
||||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { SendHorizontal, Square, X, Paperclip, FileText, Film, Music, FileArchive, File, Loader2 } from 'lucide-react';
|
import { SendHorizontal, Square, X, Paperclip, FileText, Film, Music, FileArchive, File, Loader2, AtSign } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { hostApiFetch } from '@/lib/host-api';
|
import { hostApiFetch } from '@/lib/host-api';
|
||||||
import { invokeIpc } from '@/lib/api-client';
|
import { invokeIpc } from '@/lib/api-client';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useGatewayStore } from '@/stores/gateway';
|
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 ────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -29,7 +33,7 @@ export interface FileAttachment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
onSend: (text: string, attachments?: FileAttachment[]) => void;
|
onSend: (text: string, attachments?: FileAttachment[], targetAgentId?: string | null) => void;
|
||||||
onStop?: () => void;
|
onStop?: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
sending?: boolean;
|
sending?: boolean;
|
||||||
@@ -81,11 +85,30 @@ function readFileAsBase64(file: globalThis.File): Promise<string> {
|
|||||||
// ── Component ────────────────────────────────────────────────────
|
// ── Component ────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function ChatInput({ onSend, onStop, disabled = false, sending = false, isEmpty = false }: ChatInputProps) {
|
export function ChatInput({ onSend, onStop, disabled = false, sending = false, isEmpty = false }: ChatInputProps) {
|
||||||
|
const { t } = useTranslation('chat');
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [attachments, setAttachments] = useState<FileAttachment[]>([]);
|
const [attachments, setAttachments] = useState<FileAttachment[]>([]);
|
||||||
|
const [targetAgentId, setTargetAgentId] = useState<string | null>(null);
|
||||||
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const pickerRef = useRef<HTMLDivElement>(null);
|
||||||
const isComposingRef = useRef(false);
|
const isComposingRef = useRef(false);
|
||||||
const gatewayStatus = useGatewayStore((s) => s.status);
|
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
|
// Auto-resize textarea
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -102,6 +125,32 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
|
|||||||
}
|
}
|
||||||
}, [disabled]);
|
}, [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 ─────────────────────────────
|
// ── File staging via native dialog ─────────────────────────────
|
||||||
|
|
||||||
const pickFiles = useCallback(async () => {
|
const pickFiles = useCallback(async () => {
|
||||||
@@ -258,8 +307,10 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
|
|||||||
if (textareaRef.current) {
|
if (textareaRef.current) {
|
||||||
textareaRef.current.style.height = 'auto';
|
textareaRef.current.style.height = 'auto';
|
||||||
}
|
}
|
||||||
onSend(textToSend, attachmentsToSend);
|
onSend(textToSend, attachmentsToSend, targetAgentId);
|
||||||
}, [input, attachments, canSend, onSend]);
|
setTargetAgentId(null);
|
||||||
|
setPickerOpen(false);
|
||||||
|
}, [input, attachments, canSend, onSend, targetAgentId]);
|
||||||
|
|
||||||
const handleStop = useCallback(() => {
|
const handleStop = useCallback(() => {
|
||||||
if (!canStop) return;
|
if (!canStop) return;
|
||||||
@@ -268,6 +319,10 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
|
|||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent) => {
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Backspace' && !input && targetAgentId) {
|
||||||
|
setTargetAgentId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
const nativeEvent = e.nativeEvent as KeyboardEvent;
|
const nativeEvent = e.nativeEvent as KeyboardEvent;
|
||||||
if (isComposingRef.current || nativeEvent.isComposing || nativeEvent.keyCode === 229) {
|
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],
|
[handleSend, input, targetAgentId],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle paste (Ctrl/Cmd+V with files)
|
// Handle paste (Ctrl/Cmd+V with files)
|
||||||
@@ -353,66 +408,126 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Input Row */}
|
{/* Input Row */}
|
||||||
<div className={`flex items-end gap-1.5 bg-white dark:bg-[#1a1a19] rounded-[28px] shadow-sm border p-1.5 transition-all ${dragOver ? 'border-primary ring-1 ring-primary' : 'border-black/10 dark:border-white/10'}`}>
|
<div className={`relative bg-white dark:bg-[#1a1a19] rounded-[28px] shadow-sm border p-1.5 transition-all ${dragOver ? 'border-primary ring-1 ring-primary' : 'border-black/10 dark:border-white/10'}`}>
|
||||||
|
{selectedTarget && (
|
||||||
|
<div className="px-2.5 pt-2 pb-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTargetAgentId(null)}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full border border-primary/20 bg-primary/5 px-3 py-1 text-[13px] font-medium text-foreground transition-colors hover:bg-primary/10"
|
||||||
|
title={t('composer.clearTarget')}
|
||||||
|
>
|
||||||
|
<span>{t('composer.targetChip', { agent: selectedTarget.name })}</span>
|
||||||
|
<X className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Attach Button */}
|
<div className="flex items-end gap-1.5">
|
||||||
<Button
|
{/* Attach Button */}
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
variant="ghost"
|
||||||
className="shrink-0 h-10 w-10 rounded-full text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground transition-colors"
|
size="icon"
|
||||||
onClick={pickFiles}
|
className="shrink-0 h-10 w-10 rounded-full text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground transition-colors"
|
||||||
disabled={disabled || sending}
|
onClick={pickFiles}
|
||||||
title="Attach files"
|
disabled={disabled || sending}
|
||||||
>
|
title={t('composer.attachFiles')}
|
||||||
<Paperclip className="h-4 w-4" />
|
>
|
||||||
</Button>
|
<Paperclip className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
{/* Textarea */}
|
{showAgentPicker && (
|
||||||
<div className="flex-1 relative">
|
<div ref={pickerRef} className="relative shrink-0">
|
||||||
<Textarea
|
<Button
|
||||||
ref={textareaRef}
|
variant="ghost"
|
||||||
value={input}
|
size="icon"
|
||||||
onChange={(e) => setInput(e.target.value)}
|
className={cn(
|
||||||
onKeyDown={handleKeyDown}
|
'h-10 w-10 rounded-full text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground transition-colors',
|
||||||
onCompositionStart={() => {
|
(pickerOpen || selectedTarget) && 'bg-primary/10 text-primary hover:bg-primary/20'
|
||||||
isComposingRef.current = true;
|
)}
|
||||||
}}
|
onClick={() => setPickerOpen((open) => !open)}
|
||||||
onCompositionEnd={() => {
|
disabled={disabled || sending}
|
||||||
isComposingRef.current = false;
|
title={t('composer.pickAgent')}
|
||||||
}}
|
>
|
||||||
onPaste={handlePaste}
|
<AtSign className="h-4 w-4" />
|
||||||
placeholder={disabled ? 'Gateway not connected...' : ''}
|
</Button>
|
||||||
disabled={disabled}
|
{pickerOpen && (
|
||||||
className="min-h-[40px] max-h-[200px] resize-none border-0 focus-visible:ring-0 focus-visible:ring-offset-0 shadow-none bg-transparent py-2.5 px-2 text-[15px] placeholder:text-muted-foreground/60 leading-relaxed"
|
<div className="absolute left-0 bottom-full z-20 mb-2 w-72 overflow-hidden rounded-2xl border border-black/10 bg-white p-1.5 shadow-xl dark:border-white/10 dark:bg-[#1a1a19]">
|
||||||
rows={1}
|
<div className="px-3 py-2 text-[11px] font-medium text-muted-foreground/80">
|
||||||
/>
|
{t('composer.agentPickerTitle', { currentAgent: currentAgentName })}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="max-h-64 overflow-y-auto">
|
||||||
{/* Send Button */}
|
{mentionableAgents.map((agent) => (
|
||||||
<Button
|
<AgentPickerItem
|
||||||
onClick={sending ? handleStop : handleSend}
|
key={agent.id}
|
||||||
disabled={sending ? !canStop : !canSend}
|
agent={agent}
|
||||||
size="icon"
|
selected={agent.id === targetAgentId}
|
||||||
className={`shrink-0 h-10 w-10 rounded-full transition-colors ${
|
onSelect={() => {
|
||||||
(sending || canSend)
|
setTargetAgentId(agent.id);
|
||||||
? 'bg-black/5 dark:bg-white/10 text-foreground hover:bg-black/10 dark:hover:bg-white/20'
|
setPickerOpen(false);
|
||||||
: 'text-muted-foreground/50 hover:bg-transparent bg-transparent'
|
textareaRef.current?.focus();
|
||||||
}`}
|
}}
|
||||||
variant="ghost"
|
/>
|
||||||
title={sending ? 'Stop' : 'Send'}
|
))}
|
||||||
>
|
</div>
|
||||||
{sending ? (
|
</div>
|
||||||
<Square className="h-4 w-4" fill="currentColor" />
|
)}
|
||||||
) : (
|
</div>
|
||||||
<SendHorizontal className="h-[18px] w-[18px]" strokeWidth={2} />
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
|
||||||
|
{/* Textarea */}
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onCompositionStart={() => {
|
||||||
|
isComposingRef.current = true;
|
||||||
|
}}
|
||||||
|
onCompositionEnd={() => {
|
||||||
|
isComposingRef.current = false;
|
||||||
|
}}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
placeholder={disabled ? t('composer.gatewayDisconnectedPlaceholder') : ''}
|
||||||
|
disabled={disabled}
|
||||||
|
className="min-h-[40px] max-h-[200px] resize-none border-0 focus-visible:ring-0 focus-visible:ring-offset-0 shadow-none bg-transparent py-2.5 px-2 text-[15px] placeholder:text-muted-foreground/60 leading-relaxed"
|
||||||
|
rows={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Send Button */}
|
||||||
|
<Button
|
||||||
|
onClick={sending ? handleStop : handleSend}
|
||||||
|
disabled={sending ? !canStop : !canSend}
|
||||||
|
size="icon"
|
||||||
|
className={`shrink-0 h-10 w-10 rounded-full transition-colors ${
|
||||||
|
(sending || canSend)
|
||||||
|
? 'bg-black/5 dark:bg-white/10 text-foreground hover:bg-black/10 dark:hover:bg-white/20'
|
||||||
|
: 'text-muted-foreground/50 hover:bg-transparent bg-transparent'
|
||||||
|
}`}
|
||||||
|
variant="ghost"
|
||||||
|
title={sending ? t('composer.stop') : t('composer.send')}
|
||||||
|
>
|
||||||
|
{sending ? (
|
||||||
|
<Square className="h-4 w-4" fill="currentColor" />
|
||||||
|
) : (
|
||||||
|
<SendHorizontal className="h-[18px] w-[18px]" strokeWidth={2} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2.5 flex items-center justify-between gap-2 text-[11px] text-muted-foreground/60 px-4">
|
<div className="mt-2.5 flex items-center justify-between gap-2 text-[11px] text-muted-foreground/60 px-4">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<div className={cn("w-1.5 h-1.5 rounded-full", gatewayStatus.state === 'running' ? "bg-green-500/80" : "bg-red-500/80")} />
|
<div className={cn("w-1.5 h-1.5 rounded-full", gatewayStatus.state === 'running' ? "bg-green-500/80" : "bg-red-500/80")} />
|
||||||
<span>
|
<span>
|
||||||
gateway {gatewayStatus.state === 'running' ? 'connected' : gatewayStatus.state} | port: {gatewayStatus.port} {gatewayStatus.pid ? `| pid: ${gatewayStatus.pid}` : ''}
|
{t('composer.gatewayStatus', {
|
||||||
|
state: gatewayStatus.state === 'running'
|
||||||
|
? t('composer.gatewayConnected')
|
||||||
|
: gatewayStatus.state,
|
||||||
|
port: gatewayStatus.port,
|
||||||
|
pid: gatewayStatus.pid ? `| pid: ${gatewayStatus.pid}` : '',
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{hasFailedAttachments && (
|
{hasFailedAttachments && (
|
||||||
@@ -425,7 +540,7 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
|
|||||||
void pickFiles();
|
void pickFiles();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Retry failed attachments
|
{t('composer.retryFailedAttachments')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -493,3 +608,29 @@ function AttachmentPreview({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AgentPickerItem({
|
||||||
|
agent,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
agent: AgentSummary;
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSelect}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full flex-col items-start rounded-xl px-3 py-2 text-left transition-colors',
|
||||||
|
selected ? 'bg-primary/10 text-foreground' : 'hover:bg-black/5 dark:hover:bg-white/5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-[14px] font-medium text-foreground">{agent.name}</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
{agent.modelDisplay}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
* Session selector, new session, refresh, and thinking toggle.
|
* Session selector, new session, refresh, and thinking toggle.
|
||||||
* Rendered in the Header when on the Chat page.
|
* Rendered in the Header when on the Chat page.
|
||||||
*/
|
*/
|
||||||
import { RefreshCw, Brain } from 'lucide-react';
|
import { useMemo } from 'react';
|
||||||
|
import { RefreshCw, Brain, Bot } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { useChatStore } from '@/stores/chat';
|
import { useChatStore } from '@/stores/chat';
|
||||||
|
import { useAgentsStore } from '@/stores/agents';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -15,10 +17,20 @@ export function ChatToolbar() {
|
|||||||
const loading = useChatStore((s) => s.loading);
|
const loading = useChatStore((s) => s.loading);
|
||||||
const showThinking = useChatStore((s) => s.showThinking);
|
const showThinking = useChatStore((s) => s.showThinking);
|
||||||
const toggleThinking = useChatStore((s) => s.toggleThinking);
|
const toggleThinking = useChatStore((s) => s.toggleThinking);
|
||||||
|
const currentAgentId = useChatStore((s) => s.currentAgentId);
|
||||||
|
const agents = useAgentsStore((s) => s.agents);
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
|
const currentAgentName = useMemo(
|
||||||
|
() => agents.find((agent) => agent.id === currentAgentId)?.name ?? currentAgentId,
|
||||||
|
[agents, currentAgentId],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="hidden sm:flex items-center gap-1.5 rounded-full border border-black/10 bg-white/70 px-3 py-1.5 text-[12px] font-medium text-foreground/80 dark:border-white/10 dark:bg-white/5">
|
||||||
|
<Bot className="h-3.5 w-3.5 text-primary" />
|
||||||
|
<span>{t('toolbar.currentAgent', { agent: currentAgentName })}</span>
|
||||||
|
</div>
|
||||||
{/* Refresh */}
|
{/* Refresh */}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
import { AlertCircle, Loader2, Sparkles } from 'lucide-react';
|
import { AlertCircle, Loader2, Sparkles } from 'lucide-react';
|
||||||
import { useChatStore, type RawMessage } from '@/stores/chat';
|
import { useChatStore, type RawMessage } from '@/stores/chat';
|
||||||
import { useGatewayStore } from '@/stores/gateway';
|
import { useGatewayStore } from '@/stores/gateway';
|
||||||
|
import { useAgentsStore } from '@/stores/agents';
|
||||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||||
import { ChatMessage } from './ChatMessage';
|
import { ChatMessage } from './ChatMessage';
|
||||||
import { ChatInput } from './ChatInput';
|
import { ChatInput } from './ChatInput';
|
||||||
@@ -32,6 +33,7 @@ export function Chat() {
|
|||||||
const sendMessage = useChatStore((s) => s.sendMessage);
|
const sendMessage = useChatStore((s) => s.sendMessage);
|
||||||
const abortRun = useChatStore((s) => s.abortRun);
|
const abortRun = useChatStore((s) => s.abortRun);
|
||||||
const clearError = useChatStore((s) => s.clearError);
|
const clearError = useChatStore((s) => s.clearError);
|
||||||
|
const fetchAgents = useAgentsStore((s) => s.fetchAgents);
|
||||||
|
|
||||||
const cleanupEmptySession = useChatStore((s) => s.cleanupEmptySession);
|
const cleanupEmptySession = useChatStore((s) => s.cleanupEmptySession);
|
||||||
|
|
||||||
@@ -51,6 +53,10 @@ export function Chat() {
|
|||||||
};
|
};
|
||||||
}, [cleanupEmptySession]);
|
}, [cleanupEmptySession]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchAgents();
|
||||||
|
}, [fetchAgents]);
|
||||||
|
|
||||||
// Auto-scroll on new messages, streaming, or activity changes
|
// Auto-scroll on new messages, streaming, or activity changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { hostApiFetch } from '@/lib/host-api';
|
import { hostApiFetch } from '@/lib/host-api';
|
||||||
import { useGatewayStore } from './gateway';
|
import { useGatewayStore } from './gateway';
|
||||||
|
import { useAgentsStore } from './agents';
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -104,7 +105,11 @@ interface ChatState {
|
|||||||
deleteSession: (key: string) => Promise<void>;
|
deleteSession: (key: string) => Promise<void>;
|
||||||
cleanupEmptySession: () => void;
|
cleanupEmptySession: () => void;
|
||||||
loadHistory: (quiet?: boolean) => Promise<void>;
|
loadHistory: (quiet?: boolean) => Promise<void>;
|
||||||
sendMessage: (text: string, attachments?: Array<{ fileName: string; mimeType: string; fileSize: number; stagedPath: string; preview: string | null }>) => Promise<void>;
|
sendMessage: (
|
||||||
|
text: string,
|
||||||
|
attachments?: Array<{ fileName: string; mimeType: string; fileSize: number; stagedPath: string; preview: string | null }>,
|
||||||
|
targetAgentId?: string | null,
|
||||||
|
) => Promise<void>;
|
||||||
abortRun: () => Promise<void>;
|
abortRun: () => Promise<void>;
|
||||||
handleChatEvent: (event: Record<string, unknown>) => void;
|
handleChatEvent: (event: Record<string, unknown>) => void;
|
||||||
toggleThinking: () => void;
|
toggleThinking: () => void;
|
||||||
@@ -664,6 +669,66 @@ function getAgentIdFromSessionKey(sessionKey: string): string {
|
|||||||
return parts[1] || 'main';
|
return parts[1] || 'main';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAgentId(value: string | undefined | null): string {
|
||||||
|
return (value ?? '').trim().toLowerCase() || 'main';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFallbackMainSessionKey(agentId: string): string {
|
||||||
|
return `agent:${normalizeAgentId(agentId)}:main`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMainSessionKeyForAgent(agentId: string | undefined | null): string | null {
|
||||||
|
if (!agentId) return null;
|
||||||
|
const normalizedAgentId = normalizeAgentId(agentId);
|
||||||
|
const summary = useAgentsStore.getState().agents.find((agent) => agent.id === normalizedAgentId);
|
||||||
|
return summary?.mainSessionKey || buildFallbackMainSessionKey(normalizedAgentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureSessionEntry(sessions: ChatSession[], sessionKey: string): ChatSession[] {
|
||||||
|
if (sessions.some((session) => session.key === sessionKey)) {
|
||||||
|
return sessions;
|
||||||
|
}
|
||||||
|
return [...sessions, { key: sessionKey, displayName: sessionKey }];
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSessionEntryFromMap<T extends Record<string, unknown>>(entries: T, sessionKey: string): T {
|
||||||
|
return Object.fromEntries(Object.entries(entries).filter(([key]) => key !== sessionKey)) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSessionSwitchPatch(
|
||||||
|
state: Pick<
|
||||||
|
ChatState,
|
||||||
|
'currentSessionKey' | 'messages' | 'sessions' | 'sessionLabels' | 'sessionLastActivity'
|
||||||
|
>,
|
||||||
|
nextSessionKey: string,
|
||||||
|
): Partial<ChatState> {
|
||||||
|
const leavingEmpty = !state.currentSessionKey.endsWith(':main') && state.messages.length === 0;
|
||||||
|
const nextSessions = leavingEmpty
|
||||||
|
? state.sessions.filter((session) => session.key !== state.currentSessionKey)
|
||||||
|
: state.sessions;
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentSessionKey: nextSessionKey,
|
||||||
|
currentAgentId: getAgentIdFromSessionKey(nextSessionKey),
|
||||||
|
sessions: ensureSessionEntry(nextSessions, nextSessionKey),
|
||||||
|
sessionLabels: leavingEmpty
|
||||||
|
? clearSessionEntryFromMap(state.sessionLabels, state.currentSessionKey)
|
||||||
|
: state.sessionLabels,
|
||||||
|
sessionLastActivity: leavingEmpty
|
||||||
|
? clearSessionEntryFromMap(state.sessionLastActivity, state.currentSessionKey)
|
||||||
|
: state.sessionLastActivity,
|
||||||
|
messages: [],
|
||||||
|
streamingText: '',
|
||||||
|
streamingMessage: null,
|
||||||
|
streamingTools: [],
|
||||||
|
activeRunId: null,
|
||||||
|
error: null,
|
||||||
|
pendingFinal: false,
|
||||||
|
lastUserMessageAt: null,
|
||||||
|
pendingToolImages: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getCanonicalPrefixFromSessionKey(sessionKey: string): string | null {
|
function getCanonicalPrefixFromSessionKey(sessionKey: string): string | null {
|
||||||
if (!sessionKey.startsWith('agent:')) return null;
|
if (!sessionKey.startsWith('agent:')) return null;
|
||||||
const parts = sessionKey.split(':');
|
const parts = sessionKey.split(':');
|
||||||
@@ -1054,30 +1119,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
// ── Switch session ──
|
// ── Switch session ──
|
||||||
|
|
||||||
switchSession: (key: string) => {
|
switchSession: (key: string) => {
|
||||||
const { currentSessionKey, messages } = get();
|
set((s) => buildSessionSwitchPatch(s, key));
|
||||||
const leavingEmpty = !currentSessionKey.endsWith(':main') && messages.length === 0;
|
|
||||||
set((s) => ({
|
|
||||||
currentSessionKey: key,
|
|
||||||
currentAgentId: getAgentIdFromSessionKey(key),
|
|
||||||
messages: [],
|
|
||||||
streamingText: '',
|
|
||||||
streamingMessage: null,
|
|
||||||
streamingTools: [],
|
|
||||||
activeRunId: null,
|
|
||||||
error: null,
|
|
||||||
pendingFinal: false,
|
|
||||||
lastUserMessageAt: null,
|
|
||||||
pendingToolImages: [],
|
|
||||||
...(leavingEmpty ? {
|
|
||||||
sessions: s.sessions.filter((s) => s.key !== currentSessionKey),
|
|
||||||
sessionLabels: Object.fromEntries(
|
|
||||||
Object.entries(s.sessionLabels).filter(([k]) => k !== currentSessionKey),
|
|
||||||
),
|
|
||||||
sessionLastActivity: Object.fromEntries(
|
|
||||||
Object.entries(s.sessionLastActivity).filter(([k]) => k !== currentSessionKey),
|
|
||||||
),
|
|
||||||
} : {}),
|
|
||||||
}));
|
|
||||||
get().loadHistory();
|
get().loadHistory();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1332,11 +1374,22 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
|
|
||||||
// ── Send message ──
|
// ── Send message ──
|
||||||
|
|
||||||
sendMessage: async (text: string, attachments?: Array<{ fileName: string; mimeType: string; fileSize: number; stagedPath: string; preview: string | null }>) => {
|
sendMessage: async (
|
||||||
|
text: string,
|
||||||
|
attachments?: Array<{ fileName: string; mimeType: string; fileSize: number; stagedPath: string; preview: string | null }>,
|
||||||
|
targetAgentId?: string | null,
|
||||||
|
) => {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if (!trimmed && (!attachments || attachments.length === 0)) return;
|
if (!trimmed && (!attachments || attachments.length === 0)) return;
|
||||||
|
|
||||||
const { currentSessionKey } = get();
|
const targetSessionKey = resolveMainSessionKeyForAgent(targetAgentId) ?? get().currentSessionKey;
|
||||||
|
|
||||||
|
if (targetSessionKey !== get().currentSessionKey) {
|
||||||
|
set((s) => buildSessionSwitchPatch(s, targetSessionKey));
|
||||||
|
await get().loadHistory(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSessionKey = targetSessionKey;
|
||||||
|
|
||||||
// Add user message optimistically (with local file metadata for UI display)
|
// Add user message optimistically (with local file metadata for UI display)
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const initialChatState: Pick<
|
|||||||
| 'pendingToolImages'
|
| 'pendingToolImages'
|
||||||
| 'sessions'
|
| 'sessions'
|
||||||
| 'currentSessionKey'
|
| 'currentSessionKey'
|
||||||
|
| 'currentAgentId'
|
||||||
| 'sessionLabels'
|
| 'sessionLabels'
|
||||||
| 'sessionLastActivity'
|
| 'sessionLastActivity'
|
||||||
| 'showThinking'
|
| 'showThinking'
|
||||||
@@ -38,6 +39,7 @@ export const initialChatState: Pick<
|
|||||||
|
|
||||||
sessions: [],
|
sessions: [],
|
||||||
currentSessionKey: DEFAULT_SESSION_KEY,
|
currentSessionKey: DEFAULT_SESSION_KEY,
|
||||||
|
currentAgentId: 'main',
|
||||||
sessionLabels: {},
|
sessionLabels: {},
|
||||||
sessionLastActivity: {},
|
sessionLastActivity: {},
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { invokeIpc } from '@/lib/api-client';
|
import { invokeIpc } from '@/lib/api-client';
|
||||||
|
import { useAgentsStore } from '@/stores/agents';
|
||||||
import {
|
import {
|
||||||
clearErrorRecoveryTimer,
|
clearErrorRecoveryTimer,
|
||||||
clearHistoryPoll,
|
clearHistoryPoll,
|
||||||
@@ -7,16 +8,78 @@ import {
|
|||||||
setLastChatEventAt,
|
setLastChatEventAt,
|
||||||
upsertImageCacheEntry,
|
upsertImageCacheEntry,
|
||||||
} from './helpers';
|
} from './helpers';
|
||||||
import type { RawMessage } from './types';
|
import type { ChatSession, RawMessage } from './types';
|
||||||
import type { ChatGet, ChatSet, RuntimeActions } from './store-api';
|
import type { ChatGet, ChatSet, RuntimeActions } from './store-api';
|
||||||
|
|
||||||
|
function normalizeAgentId(value: string | undefined | null): string {
|
||||||
|
return (value ?? '').trim().toLowerCase() || 'main';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAgentIdFromSessionKey(sessionKey: string): string {
|
||||||
|
if (!sessionKey.startsWith('agent:')) return 'main';
|
||||||
|
const [, agentId] = sessionKey.split(':');
|
||||||
|
return agentId || 'main';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFallbackMainSessionKey(agentId: string): string {
|
||||||
|
return `agent:${normalizeAgentId(agentId)}:main`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMainSessionKeyForAgent(agentId: string | undefined | null): string | null {
|
||||||
|
if (!agentId) return null;
|
||||||
|
const normalizedAgentId = normalizeAgentId(agentId);
|
||||||
|
const summary = useAgentsStore.getState().agents.find((agent) => agent.id === normalizedAgentId);
|
||||||
|
return summary?.mainSessionKey || buildFallbackMainSessionKey(normalizedAgentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureSessionEntry(sessions: ChatSession[], sessionKey: string): ChatSession[] {
|
||||||
|
if (sessions.some((session) => session.key === sessionKey)) {
|
||||||
|
return sessions;
|
||||||
|
}
|
||||||
|
return [...sessions, { key: sessionKey, displayName: sessionKey }];
|
||||||
|
}
|
||||||
|
|
||||||
export function createRuntimeSendActions(set: ChatSet, get: ChatGet): Pick<RuntimeActions, 'sendMessage' | 'abortRun'> {
|
export function createRuntimeSendActions(set: ChatSet, get: ChatGet): Pick<RuntimeActions, 'sendMessage' | 'abortRun'> {
|
||||||
return {
|
return {
|
||||||
sendMessage: async (text: string, attachments?: Array<{ fileName: string; mimeType: string; fileSize: number; stagedPath: string; preview: string | null }>) => {
|
sendMessage: async (
|
||||||
|
text: string,
|
||||||
|
attachments?: Array<{ fileName: string; mimeType: string; fileSize: number; stagedPath: string; preview: string | null }>,
|
||||||
|
targetAgentId?: string | null,
|
||||||
|
) => {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if (!trimmed && (!attachments || attachments.length === 0)) return;
|
if (!trimmed && (!attachments || attachments.length === 0)) return;
|
||||||
|
|
||||||
const { currentSessionKey } = get();
|
const targetSessionKey = resolveMainSessionKeyForAgent(targetAgentId) ?? get().currentSessionKey;
|
||||||
|
if (targetSessionKey !== get().currentSessionKey) {
|
||||||
|
const current = get();
|
||||||
|
const leavingEmpty = !current.currentSessionKey.endsWith(':main') && current.messages.length === 0;
|
||||||
|
set((s) => ({
|
||||||
|
currentSessionKey: targetSessionKey,
|
||||||
|
currentAgentId: getAgentIdFromSessionKey(targetSessionKey),
|
||||||
|
sessions: ensureSessionEntry(
|
||||||
|
leavingEmpty ? s.sessions.filter((session) => session.key !== current.currentSessionKey) : s.sessions,
|
||||||
|
targetSessionKey,
|
||||||
|
),
|
||||||
|
sessionLabels: leavingEmpty
|
||||||
|
? Object.fromEntries(Object.entries(s.sessionLabels).filter(([key]) => key !== current.currentSessionKey))
|
||||||
|
: s.sessionLabels,
|
||||||
|
sessionLastActivity: leavingEmpty
|
||||||
|
? Object.fromEntries(Object.entries(s.sessionLastActivity).filter(([key]) => key !== current.currentSessionKey))
|
||||||
|
: s.sessionLastActivity,
|
||||||
|
messages: [],
|
||||||
|
streamingText: '',
|
||||||
|
streamingMessage: null,
|
||||||
|
streamingTools: [],
|
||||||
|
activeRunId: null,
|
||||||
|
error: null,
|
||||||
|
pendingFinal: false,
|
||||||
|
lastUserMessageAt: null,
|
||||||
|
pendingToolImages: [],
|
||||||
|
}));
|
||||||
|
await get().loadHistory(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSessionKey = targetSessionKey;
|
||||||
|
|
||||||
// Add user message optimistically (with local file metadata for UI display)
|
// Add user message optimistically (with local file metadata for UI display)
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ import { getCanonicalPrefixFromSessions, getMessageText, toMs } from './helpers'
|
|||||||
import { DEFAULT_CANONICAL_PREFIX, DEFAULT_SESSION_KEY, type ChatSession, type RawMessage } from './types';
|
import { DEFAULT_CANONICAL_PREFIX, DEFAULT_SESSION_KEY, type ChatSession, type RawMessage } from './types';
|
||||||
import type { ChatGet, ChatSet, SessionHistoryActions } from './store-api';
|
import type { ChatGet, ChatSet, SessionHistoryActions } from './store-api';
|
||||||
|
|
||||||
|
function getAgentIdFromSessionKey(sessionKey: string): string {
|
||||||
|
if (!sessionKey.startsWith('agent:')) return 'main';
|
||||||
|
const [, agentId] = sessionKey.split(':');
|
||||||
|
return agentId || 'main';
|
||||||
|
}
|
||||||
|
|
||||||
export function createSessionActions(
|
export function createSessionActions(
|
||||||
set: ChatSet,
|
set: ChatSet,
|
||||||
get: ChatGet,
|
get: ChatGet,
|
||||||
@@ -70,7 +76,11 @@ export function createSessionActions(
|
|||||||
]
|
]
|
||||||
: dedupedSessions;
|
: dedupedSessions;
|
||||||
|
|
||||||
set({ sessions: sessionsWithCurrent, currentSessionKey: nextSessionKey });
|
set({
|
||||||
|
sessions: sessionsWithCurrent,
|
||||||
|
currentSessionKey: nextSessionKey,
|
||||||
|
currentAgentId: getAgentIdFromSessionKey(nextSessionKey),
|
||||||
|
});
|
||||||
|
|
||||||
if (currentSessionKey !== nextSessionKey) {
|
if (currentSessionKey !== nextSessionKey) {
|
||||||
get().loadHistory();
|
get().loadHistory();
|
||||||
@@ -123,6 +133,7 @@ export function createSessionActions(
|
|||||||
const leavingEmpty = !currentSessionKey.endsWith(':main') && messages.length === 0;
|
const leavingEmpty = !currentSessionKey.endsWith(':main') && messages.length === 0;
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
currentSessionKey: key,
|
currentSessionKey: key,
|
||||||
|
currentAgentId: getAgentIdFromSessionKey(key),
|
||||||
messages: [],
|
messages: [],
|
||||||
streamingText: '',
|
streamingText: '',
|
||||||
streamingMessage: null,
|
streamingMessage: null,
|
||||||
@@ -190,6 +201,7 @@ export function createSessionActions(
|
|||||||
lastUserMessageAt: null,
|
lastUserMessageAt: null,
|
||||||
pendingToolImages: [],
|
pendingToolImages: [],
|
||||||
currentSessionKey: next?.key ?? DEFAULT_SESSION_KEY,
|
currentSessionKey: next?.key ?? DEFAULT_SESSION_KEY,
|
||||||
|
currentAgentId: getAgentIdFromSessionKey(next?.key ?? DEFAULT_SESSION_KEY),
|
||||||
}));
|
}));
|
||||||
if (next) {
|
if (next) {
|
||||||
get().loadHistory();
|
get().loadHistory();
|
||||||
@@ -217,6 +229,7 @@ export function createSessionActions(
|
|||||||
const newSessionEntry: ChatSession = { key: newKey, displayName: newKey };
|
const newSessionEntry: ChatSession = { key: newKey, displayName: newKey };
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
currentSessionKey: newKey,
|
currentSessionKey: newKey,
|
||||||
|
currentAgentId: getAgentIdFromSessionKey(newKey),
|
||||||
sessions: [
|
sessions: [
|
||||||
...(leavingEmpty ? s.sessions.filter((sess) => sess.key !== currentSessionKey) : s.sessions),
|
...(leavingEmpty ? s.sessions.filter((sess) => sess.key !== currentSessionKey) : s.sessions),
|
||||||
newSessionEntry,
|
newSessionEntry,
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export interface ChatState {
|
|||||||
// Sessions
|
// Sessions
|
||||||
sessions: ChatSession[];
|
sessions: ChatSession[];
|
||||||
currentSessionKey: string;
|
currentSessionKey: string;
|
||||||
|
currentAgentId: string;
|
||||||
/** First user message text per session key, used as display label */
|
/** First user message text per session key, used as display label */
|
||||||
sessionLabels: Record<string, string>;
|
sessionLabels: Record<string, string>;
|
||||||
/** Last message timestamp (ms) per session key, used for sorting */
|
/** Last message timestamp (ms) per session key, used for sorting */
|
||||||
@@ -100,7 +101,8 @@ export interface ChatState {
|
|||||||
fileSize: number;
|
fileSize: number;
|
||||||
stagedPath: string;
|
stagedPath: string;
|
||||||
preview: string | null;
|
preview: string | null;
|
||||||
}>
|
}>,
|
||||||
|
targetAgentId?: string | null,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
abortRun: () => Promise<void>;
|
abortRun: () => Promise<void>;
|
||||||
handleChatEvent: (event: Record<string, unknown>) => void;
|
handleChatEvent: (event: Record<string, unknown>) => void;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface AgentSummary {
|
|||||||
inheritedModel: boolean;
|
inheritedModel: boolean;
|
||||||
workspace: string;
|
workspace: string;
|
||||||
agentDir: string;
|
agentDir: string;
|
||||||
|
mainSessionKey: string;
|
||||||
channelTypes: string[];
|
channelTypes: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,36 @@ describe('agent config lifecycle', () => {
|
|||||||
await expect(listConfiguredAgentIds()).resolves.toEqual(['main']);
|
await expect(listConfiguredAgentIds()).resolves.toEqual(['main']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes canonical per-agent main session keys in the snapshot', async () => {
|
||||||
|
await writeOpenClawJson({
|
||||||
|
session: {
|
||||||
|
mainKey: 'desk',
|
||||||
|
},
|
||||||
|
agents: {
|
||||||
|
list: [
|
||||||
|
{ id: 'main', name: 'Main', default: true },
|
||||||
|
{ id: 'research', name: 'Research' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { listAgentsSnapshot } = await import('@electron/utils/agent-config');
|
||||||
|
|
||||||
|
const snapshot = await listAgentsSnapshot();
|
||||||
|
expect(snapshot.agents).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'main',
|
||||||
|
mainSessionKey: 'agent:main:desk',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'research',
|
||||||
|
mainSessionKey: 'agent:research:desk',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('deletes the config entry, bindings, runtime directory, and managed workspace for a removed agent', async () => {
|
it('deletes the config entry, bindings, runtime directory, and managed workspace for a removed agent', async () => {
|
||||||
await writeOpenClawJson({
|
await writeOpenClawJson({
|
||||||
agents: {
|
agents: {
|
||||||
|
|||||||
138
tests/unit/chat-input.test.tsx
Normal file
138
tests/unit/chat-input.test.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { ChatInput } from '@/pages/Chat/ChatInput';
|
||||||
|
|
||||||
|
const { agentsState, chatState, gatewayState } = vi.hoisted(() => ({
|
||||||
|
agentsState: {
|
||||||
|
agents: [] as Array<Record<string, unknown>>,
|
||||||
|
},
|
||||||
|
chatState: {
|
||||||
|
currentAgentId: 'main',
|
||||||
|
},
|
||||||
|
gatewayState: {
|
||||||
|
status: { state: 'running', port: 18789 },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/stores/agents', () => ({
|
||||||
|
useAgentsStore: (selector: (state: typeof agentsState) => unknown) => selector(agentsState),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/stores/chat', () => ({
|
||||||
|
useChatStore: (selector: (state: typeof chatState) => unknown) => selector(chatState),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/stores/gateway', () => ({
|
||||||
|
useGatewayStore: (selector: (state: typeof gatewayState) => unknown) => selector(gatewayState),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/host-api', () => ({
|
||||||
|
hostApiFetch: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/api-client', () => ({
|
||||||
|
invokeIpc: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function translate(key: string, vars?: Record<string, unknown>): string {
|
||||||
|
switch (key) {
|
||||||
|
case 'composer.attachFiles':
|
||||||
|
return 'Attach files';
|
||||||
|
case 'composer.pickAgent':
|
||||||
|
return 'Choose agent';
|
||||||
|
case 'composer.clearTarget':
|
||||||
|
return 'Clear target agent';
|
||||||
|
case 'composer.targetChip':
|
||||||
|
return `@${String(vars?.agent ?? '')}`;
|
||||||
|
case 'composer.agentPickerTitle':
|
||||||
|
return 'Route the next message to another agent';
|
||||||
|
case 'composer.gatewayDisconnectedPlaceholder':
|
||||||
|
return 'Gateway not connected...';
|
||||||
|
case 'composer.send':
|
||||||
|
return 'Send';
|
||||||
|
case 'composer.stop':
|
||||||
|
return 'Stop';
|
||||||
|
case 'composer.gatewayConnected':
|
||||||
|
return 'connected';
|
||||||
|
case 'composer.gatewayStatus':
|
||||||
|
return `gateway ${String(vars?.state ?? '')} | port: ${String(vars?.port ?? '')} ${String(vars?.pid ?? '')}`.trim();
|
||||||
|
case 'composer.retryFailedAttachments':
|
||||||
|
return 'Retry failed attachments';
|
||||||
|
default:
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: translate,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('ChatInput agent targeting', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
agentsState.agents = [];
|
||||||
|
chatState.currentAgentId = 'main';
|
||||||
|
gatewayState.status = { state: 'running', port: 18789 };
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the @agent picker when only one agent is configured', () => {
|
||||||
|
agentsState.agents = [
|
||||||
|
{
|
||||||
|
id: 'main',
|
||||||
|
name: 'Main',
|
||||||
|
isDefault: true,
|
||||||
|
modelDisplay: 'MiniMax',
|
||||||
|
inheritedModel: true,
|
||||||
|
workspace: '~/.openclaw/workspace',
|
||||||
|
agentDir: '~/.openclaw/agents/main/agent',
|
||||||
|
mainSessionKey: 'agent:main:main',
|
||||||
|
channelTypes: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<ChatInput onSend={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.queryByTitle('Choose agent')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the user select an agent target and sends it with the message', () => {
|
||||||
|
const onSend = vi.fn();
|
||||||
|
agentsState.agents = [
|
||||||
|
{
|
||||||
|
id: 'main',
|
||||||
|
name: 'Main',
|
||||||
|
isDefault: true,
|
||||||
|
modelDisplay: 'MiniMax',
|
||||||
|
inheritedModel: true,
|
||||||
|
workspace: '~/.openclaw/workspace',
|
||||||
|
agentDir: '~/.openclaw/agents/main/agent',
|
||||||
|
mainSessionKey: 'agent:main:main',
|
||||||
|
channelTypes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'research',
|
||||||
|
name: 'Research',
|
||||||
|
isDefault: false,
|
||||||
|
modelDisplay: 'Claude',
|
||||||
|
inheritedModel: false,
|
||||||
|
workspace: '~/.openclaw/workspace-research',
|
||||||
|
agentDir: '~/.openclaw/agents/research/agent',
|
||||||
|
mainSessionKey: 'agent:research:desk',
|
||||||
|
channelTypes: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<ChatInput onSend={onSend} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTitle('Choose agent'));
|
||||||
|
fireEvent.click(screen.getByText('Research'));
|
||||||
|
|
||||||
|
expect(screen.getByText('@Research')).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Hello direct agent' } });
|
||||||
|
fireEvent.click(screen.getByTitle('Send'));
|
||||||
|
|
||||||
|
expect(onSend).toHaveBeenCalledWith('Hello direct agent', undefined, 'research');
|
||||||
|
});
|
||||||
|
});
|
||||||
190
tests/unit/chat-target-routing.test.ts
Normal file
190
tests/unit/chat-target-routing.test.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const { gatewayRpcMock, hostApiFetchMock, agentsState } = vi.hoisted(() => ({
|
||||||
|
gatewayRpcMock: vi.fn(),
|
||||||
|
hostApiFetchMock: vi.fn(),
|
||||||
|
agentsState: {
|
||||||
|
agents: [] as Array<Record<string, unknown>>,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/stores/gateway', () => ({
|
||||||
|
useGatewayStore: {
|
||||||
|
getState: () => ({
|
||||||
|
rpc: gatewayRpcMock,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/stores/agents', () => ({
|
||||||
|
useAgentsStore: {
|
||||||
|
getState: () => agentsState,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/host-api', () => ({
|
||||||
|
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('chat target routing', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-03-11T12:00:00Z'));
|
||||||
|
window.localStorage.clear();
|
||||||
|
|
||||||
|
agentsState.agents = [
|
||||||
|
{
|
||||||
|
id: 'main',
|
||||||
|
name: 'Main',
|
||||||
|
isDefault: true,
|
||||||
|
modelDisplay: 'MiniMax',
|
||||||
|
inheritedModel: true,
|
||||||
|
workspace: '~/.openclaw/workspace',
|
||||||
|
agentDir: '~/.openclaw/agents/main/agent',
|
||||||
|
mainSessionKey: 'agent:main:main',
|
||||||
|
channelTypes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'research',
|
||||||
|
name: 'Research',
|
||||||
|
isDefault: false,
|
||||||
|
modelDisplay: 'Claude',
|
||||||
|
inheritedModel: false,
|
||||||
|
workspace: '~/.openclaw/workspace-research',
|
||||||
|
agentDir: '~/.openclaw/agents/research/agent',
|
||||||
|
mainSessionKey: 'agent:research:desk',
|
||||||
|
channelTypes: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
gatewayRpcMock.mockReset();
|
||||||
|
gatewayRpcMock.mockImplementation(async (method: string) => {
|
||||||
|
if (method === 'chat.history') {
|
||||||
|
return { messages: [] };
|
||||||
|
}
|
||||||
|
if (method === 'chat.send') {
|
||||||
|
return { runId: 'run-text' };
|
||||||
|
}
|
||||||
|
if (method === 'chat.abort') {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
if (method === 'sessions.list') {
|
||||||
|
return { sessions: [] };
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected gateway RPC: ${method}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
hostApiFetchMock.mockReset();
|
||||||
|
hostApiFetchMock.mockResolvedValue({ success: true, result: { runId: 'run-media' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches to the selected agent main session before sending text', async () => {
|
||||||
|
const { useChatStore } = await import('@/stores/chat');
|
||||||
|
|
||||||
|
useChatStore.setState({
|
||||||
|
currentSessionKey: 'agent:main:main',
|
||||||
|
currentAgentId: 'main',
|
||||||
|
sessions: [{ key: 'agent:main:main' }],
|
||||||
|
messages: [{ role: 'assistant', content: 'Existing main history' }],
|
||||||
|
sessionLabels: {},
|
||||||
|
sessionLastActivity: {},
|
||||||
|
sending: false,
|
||||||
|
activeRunId: null,
|
||||||
|
streamingText: '',
|
||||||
|
streamingMessage: null,
|
||||||
|
streamingTools: [],
|
||||||
|
pendingFinal: false,
|
||||||
|
lastUserMessageAt: null,
|
||||||
|
pendingToolImages: [],
|
||||||
|
error: null,
|
||||||
|
loading: false,
|
||||||
|
thinkingLevel: null,
|
||||||
|
showThinking: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await useChatStore.getState().sendMessage('Hello direct agent', undefined, 'research');
|
||||||
|
|
||||||
|
const state = useChatStore.getState();
|
||||||
|
expect(state.currentSessionKey).toBe('agent:research:desk');
|
||||||
|
expect(state.currentAgentId).toBe('research');
|
||||||
|
expect(state.sessions.some((session) => session.key === 'agent:research:desk')).toBe(true);
|
||||||
|
expect(state.messages.at(-1)?.content).toBe('Hello direct agent');
|
||||||
|
|
||||||
|
const historyCall = gatewayRpcMock.mock.calls.find(([method]) => method === 'chat.history');
|
||||||
|
expect(historyCall?.[1]).toEqual({ sessionKey: 'agent:research:desk', limit: 200 });
|
||||||
|
|
||||||
|
const sendCall = gatewayRpcMock.mock.calls.find(([method]) => method === 'chat.send');
|
||||||
|
expect(sendCall?.[1]).toMatchObject({
|
||||||
|
sessionKey: 'agent:research:desk',
|
||||||
|
message: 'Hello direct agent',
|
||||||
|
deliver: false,
|
||||||
|
});
|
||||||
|
expect(typeof (sendCall?.[1] as { idempotencyKey?: unknown })?.idempotencyKey).toBe('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the selected agent main session for attachment sends', async () => {
|
||||||
|
const { useChatStore } = await import('@/stores/chat');
|
||||||
|
|
||||||
|
useChatStore.setState({
|
||||||
|
currentSessionKey: 'agent:main:main',
|
||||||
|
currentAgentId: 'main',
|
||||||
|
sessions: [{ key: 'agent:main:main' }],
|
||||||
|
messages: [],
|
||||||
|
sessionLabels: {},
|
||||||
|
sessionLastActivity: {},
|
||||||
|
sending: false,
|
||||||
|
activeRunId: null,
|
||||||
|
streamingText: '',
|
||||||
|
streamingMessage: null,
|
||||||
|
streamingTools: [],
|
||||||
|
pendingFinal: false,
|
||||||
|
lastUserMessageAt: null,
|
||||||
|
pendingToolImages: [],
|
||||||
|
error: null,
|
||||||
|
loading: false,
|
||||||
|
thinkingLevel: null,
|
||||||
|
showThinking: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await useChatStore.getState().sendMessage(
|
||||||
|
'',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
fileName: 'design.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
fileSize: 128,
|
||||||
|
stagedPath: '/tmp/design.png',
|
||||||
|
preview: 'data:image/png;base64,abc',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'research',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(useChatStore.getState().currentSessionKey).toBe('agent:research:desk');
|
||||||
|
|
||||||
|
expect(hostApiFetchMock).toHaveBeenCalledWith(
|
||||||
|
'/api/chat/send-with-media',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
body: expect.any(String),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const payload = JSON.parse(
|
||||||
|
(hostApiFetchMock.mock.calls[0]?.[1] as { body: string }).body,
|
||||||
|
) as {
|
||||||
|
sessionKey: string;
|
||||||
|
message: string;
|
||||||
|
media: Array<{ filePath: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(payload.sessionKey).toBe('agent:research:desk');
|
||||||
|
expect(payload.message).toBe('Process the attached file(s).');
|
||||||
|
expect(payload.media[0]?.filePath).toBe('/tmp/design.png');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user