feat(agents): support chat to agent (#403)
This commit is contained in:
@@ -98,7 +98,8 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています
|
||||
インストールから最初のAIインタラクションまで、すべてのセットアップを直感的なグラフィカルインターフェースで完了できます。ターミナルコマンド不要、YAMLファイル不要、環境変数の探索も不要です。
|
||||
|
||||
### 💬 インテリジェントチャットインターフェース
|
||||
モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴、Markdownによるリッチコンテンツレンダリングをサポートしています。
|
||||
モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴、Markdownによるリッチコンテンツレンダリングに加え、マルチエージェント構成ではメイン入力欄の `@agent` から対象エージェントへ直接ルーティングできます。
|
||||
`@agent` で別のエージェントを選ぶと、ClawX はデフォルトエージェントを経由せず、そのエージェント自身の会話コンテキストへ直接切り替えます。各エージェントのワークスペースは既定で分離されていますが、より強い実行時分離は OpenClaw の sandbox 設定に依存します。
|
||||
|
||||
### 📡 マルチチャネル管理
|
||||
複数の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.
|
||||
|
||||
### 💬 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.
|
||||
|
||||
@@ -99,7 +99,8 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们
|
||||
从安装到第一次 AI 对话,全程通过直观的图形界面完成。无需终端命令,无需 YAML 文件,无需到处寻找环境变量。
|
||||
|
||||
### 💬 智能聊天界面
|
||||
通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录以及 Markdown 富文本渲染。
|
||||
通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录、Markdown 富文本渲染,以及在多 Agent 场景下通过主输入框中的 `@agent` 直接路由到目标智能体。
|
||||
当你使用 `@agent` 选择其他智能体时,ClawX 会直接切换到该智能体自己的对话上下文,而不是经过默认智能体转发。各 Agent 工作区默认彼此分离,但更强的运行时隔离仍取决于 OpenClaw 的 sandbox 配置。
|
||||
|
||||
### 📡 多频道管理
|
||||
同时配置和监控多个 AI 频道。每个频道独立运行,允许你为不同任务运行专门的智能体。
|
||||
|
||||
@@ -59,6 +59,10 @@ interface BindingConfig extends Record<string, unknown> {
|
||||
interface AgentConfigDocument extends Record<string, unknown> {
|
||||
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<string, string> {
|
||||
const owners = new Map<string, string>();
|
||||
if (!Array.isArray(bindings)) return owners;
|
||||
@@ -369,6 +384,7 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument): Promise<Age
|
||||
inheritedModel,
|
||||
workspace: entry.workspace || (entry.id === MAIN_AGENT_ID ? getDefaultWorkspacePath(config) : `~/.openclaw/workspace-${entry.id}`),
|
||||
agentDir: entry.agentDir || getDefaultAgentDirPath(entry.id),
|
||||
mainSessionKey: buildAgentMainSessionKey(config, entry.id),
|
||||
channelTypes: configuredChannels.filter((channelType) => channelOwners[channelType] === entryIdNorm),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
<div className="px-2.5 pb-1 text-[11px] font-medium text-muted-foreground/60 tracking-tight">
|
||||
{bucket.label}
|
||||
</div>
|
||||
{bucket.sessions.map((s) => (
|
||||
<div key={s.key} className="group relative flex items-center">
|
||||
<button
|
||||
onClick={() => { switchSession(s.key); navigate('/'); }}
|
||||
className={cn(
|
||||
'w-full text-left rounded-lg px-2.5 py-1.5 text-[13px] truncate transition-colors pr-7',
|
||||
'hover:bg-black/5 dark:hover:bg-white/5',
|
||||
isOnChat && currentSessionKey === s.key
|
||||
? 'bg-black/5 dark:bg-white/10 text-foreground font-medium'
|
||||
: 'text-foreground/75',
|
||||
)}
|
||||
>
|
||||
{getSessionLabel(s.key, s.displayName, s.label)}
|
||||
</button>
|
||||
<button
|
||||
aria-label="Delete session"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSessionToDelete({
|
||||
key: s.key,
|
||||
label: getSessionLabel(s.key, s.displayName, s.label),
|
||||
});
|
||||
}}
|
||||
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>
|
||||
))}
|
||||
{bucket.sessions.map((s) => {
|
||||
const agentId = getAgentIdFromSessionKey(s.key);
|
||||
const agentName = agentNameById[agentId] || agentId;
|
||||
return (
|
||||
<div key={s.key} className="group relative flex items-center">
|
||||
<button
|
||||
onClick={() => { switchSession(s.key); navigate('/'); }}
|
||||
className={cn(
|
||||
'w-full text-left rounded-lg px-2.5 py-1.5 text-[13px] transition-colors pr-7',
|
||||
'hover:bg-black/5 dark:hover:bg-white/5',
|
||||
isOnChat && currentSessionKey === s.key
|
||||
? 'bg-black/5 dark:bg-white/10 text-foreground font-medium'
|
||||
: 'text-foreground/75',
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<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]">
|
||||
{agentName}
|
||||
</span>
|
||||
<span className="truncate">{getSessionLabel(s.key, s.displayName, s.label)}</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Delete session"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSessionToDelete({
|
||||
key: s.key,
|
||||
label: getSessionLabel(s.key, s.displayName, s.label),
|
||||
});
|
||||
}}
|
||||
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>
|
||||
) : null
|
||||
))}
|
||||
|
||||
@@ -416,9 +416,9 @@ function ProviderCard({
|
||||
return (
|
||||
<div
|
||||
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
|
||||
? "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"
|
||||
)}
|
||||
>
|
||||
@@ -435,9 +435,9 @@ function ProviderCard({
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-[15px]">{account.label}</span>
|
||||
{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" />
|
||||
Default
|
||||
{t('aiProviders.card.default')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"title": "メッセージングチャンネル",
|
||||
"subtitle": "メッセージングチャンネルと接続を管理",
|
||||
"subtitle": "メッセージングチャンネルと接続を管理。設定はメイン Agent のみ有効です。",
|
||||
"refresh": "更新",
|
||||
"addChannel": "チャンネルを追加",
|
||||
"stats": {
|
||||
|
||||
@@ -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": "今日",
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"title": "消息频道",
|
||||
"subtitle": "连接到消息平台。",
|
||||
"subtitle": "连接到消息平台,配置仅对主 Agent 生效。",
|
||||
"refresh": "刷新",
|
||||
"addChannel": "添加频道",
|
||||
"stats": {
|
||||
|
||||
@@ -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": "今天",
|
||||
|
||||
@@ -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 && (
|
||||
<Badge
|
||||
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')}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
@@ -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<string> {
|
||||
// ── 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<FileAttachment[]>([]);
|
||||
const [targetAgentId, setTargetAgentId] = useState<string | null>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const pickerRef = useRef<HTMLDivElement>(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 */}
|
||||
<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 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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"
|
||||
onClick={pickFiles}
|
||||
disabled={disabled || sending}
|
||||
title="Attach files"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex items-end gap-1.5">
|
||||
{/* Attach Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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"
|
||||
onClick={pickFiles}
|
||||
disabled={disabled || sending}
|
||||
title={t('composer.attachFiles')}
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</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 ? 'Gateway not connected...' : ''}
|
||||
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 ? 'Stop' : 'Send'}
|
||||
>
|
||||
{sending ? (
|
||||
<Square className="h-4 w-4" fill="currentColor" />
|
||||
) : (
|
||||
<SendHorizontal className="h-[18px] w-[18px]" strokeWidth={2} />
|
||||
{showAgentPicker && (
|
||||
<div ref={pickerRef} className="relative shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'h-10 w-10 rounded-full text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground transition-colors',
|
||||
(pickerOpen || selectedTarget) && 'bg-primary/10 text-primary hover:bg-primary/20'
|
||||
)}
|
||||
onClick={() => setPickerOpen((open) => !open)}
|
||||
disabled={disabled || sending}
|
||||
title={t('composer.pickAgent')}
|
||||
>
|
||||
<AtSign className="h-4 w-4" />
|
||||
</Button>
|
||||
{pickerOpen && (
|
||||
<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]">
|
||||
<div className="px-3 py-2 text-[11px] font-medium text-muted-foreground/80">
|
||||
{t('composer.agentPickerTitle', { currentAgent: currentAgentName })}
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{mentionableAgents.map((agent) => (
|
||||
<AgentPickerItem
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
selected={agent.id === targetAgentId}
|
||||
onSelect={() => {
|
||||
setTargetAgentId(agent.id);
|
||||
setPickerOpen(false);
|
||||
textareaRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</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 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={cn("w-1.5 h-1.5 rounded-full", gatewayStatus.state === 'running' ? "bg-green-500/80" : "bg-red-500/80")} />
|
||||
<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>
|
||||
</div>
|
||||
{hasFailedAttachments && (
|
||||
@@ -425,7 +540,7 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
|
||||
void pickFiles();
|
||||
}}
|
||||
>
|
||||
Retry failed attachments
|
||||
{t('composer.retryFailedAttachments')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -493,3 +608,29 @@ function AttachmentPreview({
|
||||
</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.
|
||||
* 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 { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useChatStore } from '@/stores/chat';
|
||||
import { useAgentsStore } from '@/stores/agents';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -15,10 +17,20 @@ export function ChatToolbar() {
|
||||
const loading = useChatStore((s) => s.loading);
|
||||
const showThinking = useChatStore((s) => s.showThinking);
|
||||
const toggleThinking = useChatStore((s) => s.toggleThinking);
|
||||
const currentAgentId = useChatStore((s) => s.currentAgentId);
|
||||
const agents = useAgentsStore((s) => s.agents);
|
||||
const { t } = useTranslation('chat');
|
||||
const currentAgentName = useMemo(
|
||||
() => agents.find((agent) => agent.id === currentAgentId)?.name ?? currentAgentId,
|
||||
[agents, currentAgentId],
|
||||
);
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { AlertCircle, Loader2, Sparkles } from 'lucide-react';
|
||||
import { useChatStore, type RawMessage } from '@/stores/chat';
|
||||
import { useGatewayStore } from '@/stores/gateway';
|
||||
import { useAgentsStore } from '@/stores/agents';
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||
import { ChatMessage } from './ChatMessage';
|
||||
import { ChatInput } from './ChatInput';
|
||||
@@ -32,6 +33,7 @@ export function Chat() {
|
||||
const sendMessage = useChatStore((s) => s.sendMessage);
|
||||
const abortRun = useChatStore((s) => s.abortRun);
|
||||
const clearError = useChatStore((s) => s.clearError);
|
||||
const fetchAgents = useAgentsStore((s) => s.fetchAgents);
|
||||
|
||||
const cleanupEmptySession = useChatStore((s) => s.cleanupEmptySession);
|
||||
|
||||
@@ -51,6 +53,10 @@ export function Chat() {
|
||||
};
|
||||
}, [cleanupEmptySession]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchAgents();
|
||||
}, [fetchAgents]);
|
||||
|
||||
// Auto-scroll on new messages, streaming, or activity changes
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import { useGatewayStore } from './gateway';
|
||||
import { useAgentsStore } from './agents';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -104,7 +105,11 @@ interface ChatState {
|
||||
deleteSession: (key: string) => Promise<void>;
|
||||
cleanupEmptySession: () => 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>;
|
||||
handleChatEvent: (event: Record<string, unknown>) => void;
|
||||
toggleThinking: () => void;
|
||||
@@ -664,6 +669,66 @@ function getAgentIdFromSessionKey(sessionKey: string): string {
|
||||
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 {
|
||||
if (!sessionKey.startsWith('agent:')) return null;
|
||||
const parts = sessionKey.split(':');
|
||||
@@ -1054,30 +1119,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
// ── Switch session ──
|
||||
|
||||
switchSession: (key: string) => {
|
||||
const { currentSessionKey, messages } = get();
|
||||
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),
|
||||
),
|
||||
} : {}),
|
||||
}));
|
||||
set((s) => buildSessionSwitchPatch(s, key));
|
||||
get().loadHistory();
|
||||
},
|
||||
|
||||
@@ -1332,11 +1374,22 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
|
||||
// ── 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();
|
||||
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)
|
||||
const nowMs = Date.now();
|
||||
|
||||
@@ -18,6 +18,7 @@ export const initialChatState: Pick<
|
||||
| 'pendingToolImages'
|
||||
| 'sessions'
|
||||
| 'currentSessionKey'
|
||||
| 'currentAgentId'
|
||||
| 'sessionLabels'
|
||||
| 'sessionLastActivity'
|
||||
| 'showThinking'
|
||||
@@ -38,6 +39,7 @@ export const initialChatState: Pick<
|
||||
|
||||
sessions: [],
|
||||
currentSessionKey: DEFAULT_SESSION_KEY,
|
||||
currentAgentId: 'main',
|
||||
sessionLabels: {},
|
||||
sessionLastActivity: {},
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import { useAgentsStore } from '@/stores/agents';
|
||||
import {
|
||||
clearErrorRecoveryTimer,
|
||||
clearHistoryPoll,
|
||||
@@ -7,16 +8,78 @@ import {
|
||||
setLastChatEventAt,
|
||||
upsertImageCacheEntry,
|
||||
} from './helpers';
|
||||
import type { RawMessage } from './types';
|
||||
import type { ChatSession, RawMessage } from './types';
|
||||
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'> {
|
||||
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();
|
||||
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)
|
||||
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 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(
|
||||
set: ChatSet,
|
||||
get: ChatGet,
|
||||
@@ -70,7 +76,11 @@ export function createSessionActions(
|
||||
]
|
||||
: dedupedSessions;
|
||||
|
||||
set({ sessions: sessionsWithCurrent, currentSessionKey: nextSessionKey });
|
||||
set({
|
||||
sessions: sessionsWithCurrent,
|
||||
currentSessionKey: nextSessionKey,
|
||||
currentAgentId: getAgentIdFromSessionKey(nextSessionKey),
|
||||
});
|
||||
|
||||
if (currentSessionKey !== nextSessionKey) {
|
||||
get().loadHistory();
|
||||
@@ -123,6 +133,7 @@ export function createSessionActions(
|
||||
const leavingEmpty = !currentSessionKey.endsWith(':main') && messages.length === 0;
|
||||
set((s) => ({
|
||||
currentSessionKey: key,
|
||||
currentAgentId: getAgentIdFromSessionKey(key),
|
||||
messages: [],
|
||||
streamingText: '',
|
||||
streamingMessage: null,
|
||||
@@ -190,6 +201,7 @@ export function createSessionActions(
|
||||
lastUserMessageAt: null,
|
||||
pendingToolImages: [],
|
||||
currentSessionKey: next?.key ?? DEFAULT_SESSION_KEY,
|
||||
currentAgentId: getAgentIdFromSessionKey(next?.key ?? DEFAULT_SESSION_KEY),
|
||||
}));
|
||||
if (next) {
|
||||
get().loadHistory();
|
||||
@@ -217,6 +229,7 @@ export function createSessionActions(
|
||||
const newSessionEntry: ChatSession = { key: newKey, displayName: newKey };
|
||||
set((s) => ({
|
||||
currentSessionKey: newKey,
|
||||
currentAgentId: getAgentIdFromSessionKey(newKey),
|
||||
sessions: [
|
||||
...(leavingEmpty ? s.sessions.filter((sess) => sess.key !== currentSessionKey) : s.sessions),
|
||||
newSessionEntry,
|
||||
|
||||
@@ -76,6 +76,7 @@ export interface ChatState {
|
||||
// Sessions
|
||||
sessions: ChatSession[];
|
||||
currentSessionKey: string;
|
||||
currentAgentId: string;
|
||||
/** First user message text per session key, used as display label */
|
||||
sessionLabels: Record<string, string>;
|
||||
/** Last message timestamp (ms) per session key, used for sorting */
|
||||
@@ -100,7 +101,8 @@ export interface ChatState {
|
||||
fileSize: number;
|
||||
stagedPath: string;
|
||||
preview: string | null;
|
||||
}>
|
||||
}>,
|
||||
targetAgentId?: string | null,
|
||||
) => Promise<void>;
|
||||
abortRun: () => Promise<void>;
|
||||
handleChatEvent: (event: Record<string, unknown>) => void;
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface AgentSummary {
|
||||
inheritedModel: boolean;
|
||||
workspace: string;
|
||||
agentDir: string;
|
||||
mainSessionKey: string;
|
||||
channelTypes: string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,36 @@ describe('agent config lifecycle', () => {
|
||||
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 () => {
|
||||
await writeOpenClawJson({
|
||||
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