mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
✨ feat(platform-agent): openclaw/hermes agent creation UI, device guard, and remote dispatch backend (#15065)
* ♻️ refactor(agent-invocation): add AgentInvocationIntent + unified non-hetero dispatcher (LOBE-8927/8928) Introduce a shared invocation contract and unified dispatcher for the non-hetero, non-group agent call paths (callAgent speak mode and @agent direct mentions). Removes the implicit client-only fallback that existed in both entry points. Changes: - agentDispatcher.ts: add AgentInvocationIntent interface as the unified intent type for callSubAgent / callAgent / @agent invocations - nonHeteroSubAgentDispatcher.ts (new): dispatchNonHeteroSubAgent() resolves child runtime via selectRuntimeType and routes to executeClientAgent (client) or executeGatewayAgent (gateway); throws for hetero (out of scope per LOBE-8926) - conversationLifecycle.ts #executeDirectMentionRoute: replace hardcoded executeClientAgent + TODO fallback with dispatchNonHeteroSubAgent call - builtin-tool-agent-management executor.ts callAgent speak mode: replace hardcoded executeClientAgent + TODO fallback with dispatchNonHeteroSubAgent call Fixes LOBE-8927 Fixes LOBE-8928 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * ✨ feat(platform-agent): openclaw/hermes agent creation UI, device guard, and remote dispatch backend - Add CreatePlatformAgent 3-step creation modal (type select → config → bind device) - Add RemoteAgentConfigCard to agent profile editor for openclaw/hermes config - Add device guard banner in HeterogeneousChatInput for offline/unavailable devices - Add useRemoteAgentDeviceGuard hook for real-time device status polling - Fix backend dispatch: openclaw/hermes now use executeToolCall(runHeteroTask) instead of dispatchAgentRun (lh connect only handles tool_call_request) - Add agentNotify router for lh notify → DB write + gateway stream event - Add device.checkCapability endpoint for platform availability probe - Add notify_update event type to gateway stream and event handler - Add sendDoneSignal in heteroTask.ts for clean openclaw exit signaling - Unify non-hetero sub-agent dispatch via dispatchNonHeteroSubAgent (LOBE-8927) - Route openclaw/hermes to gateway runtime; keep claude-code/codex on hetero/client paths - Add i18n keys for platform agent UI and device guard banners Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 🐛 fix(agentNotify): reuse execAgent placeholder message on first lh notify call Instead of creating a second empty bubble, the first assistant notify without a messageId now updates the placeholder assistantMessageId that execAgent already seeded in runningOperation.assistantMessageId. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * ✨ feat(agentNotify): cancel openclaw/hermes process on interruptTask - Store deviceId + heteroType in topic.metadata.runningOperation at dispatch time - interruptTask now dispatches cancelHeteroTask tool call to the bound device when topicId reveals a remote hetero operation, sending SIGINT to the process - Pass topicId from gateway cancel callback to interruptTask - Add topicId to InterruptTaskSchema and InterruptTaskParams Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * ♻️ refactor(hetero-agent): consolidate remote/local type classification into heterogeneous-agents package - Add RemoteHeterogeneousAgentConfig, REMOTE_HETEROGENEOUS_AGENT_CONFIGS, isRemoteHeterogeneousType, and derived type aliases (HeterogeneousAgentType, LocalHeterogeneousAgentType, RemoteHeterogeneousAgentType) to packages/heterogeneous-agents/src/config.ts - Extend HETEROGENEOUS_TYPE_LABELS to cover remote platform types (openclaw, hermes) via REMOTE_HETEROGENEOUS_AGENT_CONFIGS - Replace all inline `=== 'openclaw' || === 'hermes'` checks and local Sets/type aliases across aiAgent service, ProfileEditor, HeterogeneousChatInput, useRemoteAgentDeviceGuard, CreatePlatformAgent, RemoteAgentConfigCard, and deviceProxy with the shared utility - Show OpenClaw/Hermes display name in assistant message model tag (Usage component) by setting provider=heteroType on placeholder message and using HETEROGENEOUS_TYPE_LABELS for rendering - Fix ReferenceError: move remoteDeviceId declaration before updateMetadata call Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add the platform agents get profiles * 🐛 fix(platform-agent): routing, security, and i18n issues from review - Route openclaw/hermes to gateway on desktop (P1): add isRemoteHeterogeneousType check in selectRuntimeType before desktop hetero branch — remote agents never use local desktop IPC, no special-casing needed - Fix race in heteroTask: sendAutoNotify → sendDoneSignal now sequential via .finally() so error message is written before agent_runtime_end is published - Security: validate messageId belongs to topicId in agentNotify before MessageModel.update to prevent cross-conversation data corruption - Clear capability/device/profile state on platform change in creation modal (P2) - Derive PLATFORM_DEFS from REMOTE_HETEROGENEOUS_AGENT_CONFIGS — new platforms automatically appear in the modal without code changes - Use HETEROGENEOUS_TYPE_LABELS for platform names in HeterogeneousChatInput and RemoteAgentConfigCard (remove hardcoded PLATFORM_NAMES map) - i18n: platform card descs, 'online'/'offline' tags, 'Select a device' placeholder, checkFailed error — all now use i18n keys Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * ♻️ refactor(platform-agent): derive remote platform enum from config + fix test - device.ts: replace hardcoded z.enum(['hermes','openclaw']) with a zod enum derived from REMOTE_HETEROGENEOUS_AGENT_CONFIGS so new platforms are automatically covered without touching this file - heteroTask.ts / getAgentProfile.ts: use RemoteHeterogeneousAgentType instead of literal 'hermes' | 'openclaw' union for the same reason - gateway.test.ts: update cancel-handler assertion to include topicId which was added to the interruptTask call in the previous commit Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * ✨ feat(platform-agent): gate creation entry behind labs flag + expand dispatcher tests - Add enablePlatformAgent lab preference (default false) — the "Add Platform Agent" menu item is hidden until the user opts in via Settings → Advanced → Labs - Wire toggle in settings/advanced with labs i18n key (en/zh) - createPlatformAgentMenuItem returns null when flag is off - agentDispatcher.test: add remote hetero cases (openclaw/hermes → gateway on both web and desktop) to cover the routing fix added earlier Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 🐛 fix(lint): merge duplicate import + sort interface props in nonHeteroSubAgentDispatcher Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 💄 feat(platform-agent): disable Hermes option in creation modal (coming soon) Hermes is not yet ready for production. Mark it as coming-soon in the platform selection step: grayed-out card, not clickable, "Coming Soon" tag next to the name. To enable Hermes when ready: remove 'hermes' from COMING_SOON_PLATFORMS in CreatePlatformAgent/index.tsx. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * ✅ fix(test): mock CreatePlatformAgentModal in ModalProvider.test The modal always mounts (open=false) and calls lambdaQuery.useQuery which requires a tRPC context not present in the test environment. Mock it out the same way as ChatGroupWizard and EditingPopover. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * ✅ fix(test): mock useUserStore + labPreferSelectors in useCreateMenuItems.test Adding useUserStore to useCreateMenuItems triggered user store initialization in tests, which pulled in @lobechat/const and failed because the existing mock only exports isDesktop. Mock the store and selectors directly instead. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 🐛 fix(platform-agent): hide divider when platform agent entry is disabled The divider before 'Add Platform Agent' was unconditional — it showed even when the labs flag was off. Conditionally include both the divider and the menu item together so no orphaned separator appears. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
.\" Code generated by `npm run man:generate`; DO NOT EDIT.
|
||||
.\" Manual command details come from the Commander command tree.
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.15" "User Commands"
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.17" "User Commands"
|
||||
.SH NAME
|
||||
lh \- LobeHub CLI \- manage and connect to LobeHub services
|
||||
.SH SYNOPSIS
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lobehub/cli",
|
||||
"version": "0.0.15",
|
||||
"version": "0.0.17",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"lh": "./dist/index.js",
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
import { getHermesPort } from './heteroTask';
|
||||
|
||||
export interface CheckPlatformCapabilityParams {
|
||||
platform: 'hermes' | 'openclaw';
|
||||
}
|
||||
|
||||
export interface CheckPlatformCapabilityResult {
|
||||
available: boolean;
|
||||
reason?: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe whether a specific agent platform is available on this device.
|
||||
* Dispatched by the server via `device.checkCapability` tRPC procedure.
|
||||
*
|
||||
* - openclaw: runs `openclaw --version` and parses the output
|
||||
* - hermes: hits the gateway health endpoint on the configured port
|
||||
*/
|
||||
export async function checkPlatformCapability(
|
||||
params: CheckPlatformCapabilityParams,
|
||||
): Promise<CheckPlatformCapabilityResult> {
|
||||
const { platform } = params;
|
||||
|
||||
if (platform === 'openclaw') {
|
||||
try {
|
||||
const output = execFileSync('openclaw', ['--version'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
}).trim();
|
||||
// output is typically "openclaw x.y.z"
|
||||
const version = output.split(/\s+/).at(-1);
|
||||
return { available: true, version };
|
||||
} catch (err) {
|
||||
return {
|
||||
available: false,
|
||||
reason: err instanceof Error ? err.message : 'openclaw not found or failed to run',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === 'hermes') {
|
||||
const port = getHermesPort();
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${port}/health`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (res.ok) {
|
||||
let version: string | undefined;
|
||||
try {
|
||||
const body = (await res.json()) as { version?: string };
|
||||
version = body.version;
|
||||
} catch {
|
||||
/* ignore parse errors */
|
||||
}
|
||||
return { available: true, version };
|
||||
}
|
||||
return { available: false, reason: `Hermes gateway returned HTTP ${res.status}` };
|
||||
} catch (err) {
|
||||
return {
|
||||
available: false,
|
||||
reason: err instanceof Error ? err.message : `Hermes gateway not reachable on port ${port}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { available: false, reason: `Unknown platform: ${platform as string}` };
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { RemoteHeterogeneousAgentType } from '@lobechat/heterogeneous-agents';
|
||||
|
||||
export interface GetAgentProfileParams {
|
||||
/** Agent ID to query (openclaw only). Defaults to the default agent. */
|
||||
agentId?: string;
|
||||
platform: RemoteHeterogeneousAgentType;
|
||||
}
|
||||
|
||||
export interface AgentProfileResult {
|
||||
avatar?: string;
|
||||
description?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// Files to look for a description (tried in order)
|
||||
const IDENTITY_FILES = ['IDENTITY.md', 'SOUL.md'];
|
||||
|
||||
/**
|
||||
* Try to extract a description from the workspace identity file.
|
||||
* Looks for Creature / Vibe / Description fields in IDENTITY.md or SOUL.md.
|
||||
*/
|
||||
function readDescriptionFromWorkspace(workspacePath: string): string | undefined {
|
||||
for (const filename of IDENTITY_FILES) {
|
||||
const filePath = path.join(workspacePath, filename);
|
||||
if (!fs.existsSync(filePath)) continue;
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const match = content.match(/\*{0,2}(?:Creature|Vibe|Description):?\*{0,2}\s*(.+)/i);
|
||||
if (!match) continue;
|
||||
|
||||
const value = match[1].trim();
|
||||
// Skip unfilled template placeholders like _(pick something)_ or (TBD)
|
||||
if (/^[_*((].*[))*_]$|^(?:tbd|todo|n\/?a|none|待定|未定)$/i.test(value)) continue;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
interface OpenClawAgentEntry {
|
||||
id: string;
|
||||
identityEmoji?: string;
|
||||
identityName?: string;
|
||||
isDefault?: boolean;
|
||||
workspace?: string;
|
||||
}
|
||||
|
||||
function getOpenClawProfile(agentId?: string): AgentProfileResult {
|
||||
let output: string;
|
||||
try {
|
||||
output = execFileSync('openclaw', ['agents', 'list', '--json'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
|
||||
let agents: OpenClawAgentEntry[];
|
||||
try {
|
||||
agents = JSON.parse(output) as OpenClawAgentEntry[];
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
|
||||
const agent = agentId
|
||||
? agents.find((a) => a.id === agentId)
|
||||
: (agents.find((a) => a.isDefault) ?? agents[0]);
|
||||
|
||||
if (!agent) return {};
|
||||
|
||||
const title = agent.identityName || undefined;
|
||||
const avatar = agent.identityEmoji || '🦞'; // OpenClaw brand mascot as default
|
||||
|
||||
// Description is not exposed by the CLI — read from the workspace IDENTITY.md
|
||||
const description = agent.workspace ? readDescriptionFromWorkspace(agent.workspace) : undefined;
|
||||
|
||||
return { avatar, description, title };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the agent profile (title, avatar, description) from the platform
|
||||
* installed on this device. Dispatched by the server via `device.getAgentProfile`.
|
||||
*
|
||||
* - openclaw: `openclaw agents list --json` for name + emoji, workspace
|
||||
* IDENTITY.md for description fallback
|
||||
* - hermes: not yet implemented — returns empty profile
|
||||
*/
|
||||
export async function getAgentProfile(params: GetAgentProfileParams): Promise<AgentProfileResult> {
|
||||
const { platform, agentId } = params;
|
||||
|
||||
if (platform === 'openclaw') {
|
||||
return getOpenClawProfile(agentId);
|
||||
}
|
||||
|
||||
if (platform === 'hermes') {
|
||||
// Profile fetch not yet implemented for Hermes — return empty
|
||||
return {};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
|
||||
import type { RemoteHeterogeneousAgentType } from '@lobechat/heterogeneous-agents';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { getTask, removeTask, saveTask } from '../daemon/taskRegistry';
|
||||
import { log } from '../utils/logger';
|
||||
@@ -35,7 +37,7 @@ function openclawSessionExists(agentId: string, topicId: string): boolean {
|
||||
|
||||
export interface RunHeteroTaskParams {
|
||||
agentId?: string;
|
||||
agentType: 'hermes' | 'openclaw';
|
||||
agentType: RemoteHeterogeneousAgentType;
|
||||
cwd?: string;
|
||||
operationId: string;
|
||||
prompt: string;
|
||||
@@ -48,7 +50,7 @@ export interface CancelHeteroTaskParams {
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
function getHermesPort(): number {
|
||||
export function getHermesPort(): number {
|
||||
const env = process.env.HERMES_GATEWAY_PORT;
|
||||
if (env) {
|
||||
const parsed = Number.parseInt(env, 10);
|
||||
@@ -101,6 +103,28 @@ async function sendAutoNotify(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal remote hetero task completion to the server so it can publish
|
||||
* `agent_runtime_end` to the gateway WS and close the frontend subscription.
|
||||
* Called on clean process exit (code=0, no signal) — error exits go through
|
||||
* `sendAutoNotify` which writes an error message AND triggers completion via
|
||||
* the `done` flag.
|
||||
*/
|
||||
async function sendDoneSignal(topicId: string, agentId?: string): Promise<void> {
|
||||
try {
|
||||
const client = await getTrpcClient();
|
||||
await client.agentNotify.notify.mutate({
|
||||
agentId,
|
||||
content: '',
|
||||
done: true,
|
||||
role: 'assistant',
|
||||
topicId,
|
||||
});
|
||||
} catch (err) {
|
||||
log.error('Failed to send done signal:', err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the notify protocol injected into the first message of a new hetero-agent session.
|
||||
* Tells the agent how to push updates back to the LobeHub user via `lh notify`.
|
||||
@@ -187,15 +211,24 @@ export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string
|
||||
});
|
||||
log.info(`OpenClaw task started: taskId=${taskId} pid=${pid} agent=${openclawAgent}`);
|
||||
|
||||
// Auto-notify on abnormal exit (signal or non-zero code); normal completion
|
||||
// is reported by openclaw itself via `lh notify` (injected into the prompt).
|
||||
// On exit: notify the server so it can close the frontend gateway WS subscription.
|
||||
// - Abnormal exit (signal or non-zero code): write an error message bubble.
|
||||
// - Clean exit (code=0, no signal): openclaw already sent its final message via
|
||||
// `lh notify`; just send a done signal to publish `agent_runtime_end`.
|
||||
child.on('close', (code, signal) => {
|
||||
removeTask(taskId);
|
||||
if (code !== 0 || signal !== null) {
|
||||
const text = signal
|
||||
? `Task cancelled (signal: ${signal})`
|
||||
: `Task failed (exit code: ${code})`;
|
||||
void sendAutoNotify(topicId, taskId, text, agentId);
|
||||
// Send error message first, THEN signal done (sequential).
|
||||
// Fire-and-forget both, but ensure done is always sent even if notify fails.
|
||||
void sendAutoNotify(topicId, taskId, text, agentId).finally(() =>
|
||||
sendDoneSignal(topicId, agentId),
|
||||
);
|
||||
} else {
|
||||
// Clean exit — openclaw already sent its final message; just signal done.
|
||||
void sendDoneSignal(topicId, agentId);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { log } from '../utils/logger';
|
||||
import { checkPlatformCapability } from './checkPlatformCapability';
|
||||
import {
|
||||
editLocalFile,
|
||||
globLocalFiles,
|
||||
@@ -8,11 +9,14 @@ import {
|
||||
searchLocalFiles,
|
||||
writeLocalFile,
|
||||
} from './file';
|
||||
import { getAgentProfile } from './getAgentProfile';
|
||||
import { cancelHeteroTask, runHeteroTask } from './heteroTask';
|
||||
import { getCommandOutput, killCommand, runCommand } from './shell';
|
||||
|
||||
const methodMap: Record<string, (args: any) => Promise<unknown>> = {
|
||||
cancelHeteroTask,
|
||||
checkPlatformCapability,
|
||||
getAgentProfile,
|
||||
editFile: editLocalFile,
|
||||
getCommandOutput,
|
||||
globFiles: globLocalFiles,
|
||||
|
||||
@@ -350,6 +350,37 @@
|
||||
"pageSelection.reference": "Selected Text",
|
||||
"pin": "Pin",
|
||||
"pinOff": "Unpin",
|
||||
"platformAgent.create.available": "Available",
|
||||
"platformAgent.create.back": "Back",
|
||||
"platformAgent.create.checkFailed": "Check failed",
|
||||
"platformAgent.create.checking": "Checking availability...",
|
||||
"platformAgent.create.comingSoon": "Coming Soon",
|
||||
"platformAgent.create.create": "Create Agent",
|
||||
"platformAgent.create.creating": "Creating...",
|
||||
"platformAgent.create.desc.hermes": "Run Hermes agents on your local machine",
|
||||
"platformAgent.create.desc.openclaw": "Run OpenClaw agents on your local machine",
|
||||
"platformAgent.create.descriptionPlaceholder": "Brief description (optional)",
|
||||
"platformAgent.create.fetchingProfile": "Fetching profile...",
|
||||
"platformAgent.create.namePlaceholder": "e.g. My OpenClaw Agent",
|
||||
"platformAgent.create.next": "Next",
|
||||
"platformAgent.create.noDevices": "No devices connected",
|
||||
"platformAgent.create.noDevicesHint": "Run `lh connect` on the target machine first",
|
||||
"platformAgent.create.notInstalled": "{{name}} not installed on this device",
|
||||
"platformAgent.create.refresh": "Refresh",
|
||||
"platformAgent.create.selectDevice": "Select a device",
|
||||
"platformAgent.create.step1": "Select Platform",
|
||||
"platformAgent.create.step2": "Select Device",
|
||||
"platformAgent.create.step3": "Configure Agent",
|
||||
"platformAgent.create.title": "Add Platform Agent",
|
||||
"platformAgent.device.online": "Online",
|
||||
"platformAgent.deviceGuard.configure": "Configure",
|
||||
"platformAgent.deviceGuard.deviceOffline.desc": "The bound device is not connected. Run `lh connect` on that machine then refresh.",
|
||||
"platformAgent.deviceGuard.deviceOffline.title": "Device not connected",
|
||||
"platformAgent.deviceGuard.noDevice.desc": "This agent has no bound device. Edit the agent profile to configure one.",
|
||||
"platformAgent.deviceGuard.noDevice.title": "No device bound",
|
||||
"platformAgent.deviceGuard.platformUnavailable.desc": "{{name}} is not installed on the connected device.",
|
||||
"platformAgent.deviceGuard.platformUnavailable.title": "{{name}} not available",
|
||||
"platformAgent.deviceGuard.refresh": "Refresh",
|
||||
"plus.addSkills": "Add Skills...",
|
||||
"plus.search.appSearch": "Smart Search",
|
||||
"plus.search.appSearchDesc": "LobeHub optimized search service, delivering best retrieval results.",
|
||||
|
||||
@@ -9,5 +9,7 @@
|
||||
"features.groupChat.title": "Group Chat (Multi-Agent)",
|
||||
"features.inputMarkdown.desc": "Render Markdown in the input area in real time (bold text, code blocks, tables, etc.).",
|
||||
"features.inputMarkdown.title": "Input Markdown Rendering",
|
||||
"features.platformAgent.desc": "Show the \"Add Platform Agent\" entry in the create menu. Platform agents (e.g. OpenClaw, Hermes) run on a connected device and communicate back via lh connect.",
|
||||
"features.platformAgent.title": "Platform Agent Creation",
|
||||
"title": "Labs"
|
||||
}
|
||||
|
||||
@@ -479,6 +479,20 @@
|
||||
"notification.item.image_generation_completed": "Image generation completed",
|
||||
"notification.item.video_generation_completed": "Video generation completed",
|
||||
"notification.title": "Notification Channels",
|
||||
"platformAgentConfig.availability.available": "Available",
|
||||
"platformAgentConfig.availability.checking": "Checking...",
|
||||
"platformAgentConfig.availability.label": "Availability",
|
||||
"platformAgentConfig.availability.noDevice": "No device bound",
|
||||
"platformAgentConfig.availability.notInstalled": "Not installed",
|
||||
"platformAgentConfig.changeDevice": "Change Device",
|
||||
"platformAgentConfig.device.label": "Bound Device",
|
||||
"platformAgentConfig.device.none": "None",
|
||||
"platformAgentConfig.device.offline": "Offline",
|
||||
"platformAgentConfig.device.online": "Online",
|
||||
"platformAgentConfig.platform.label": "Platform",
|
||||
"platformAgentConfig.redetect": "Re-detect",
|
||||
"platformAgentConfig.selectDevice": "Select a device",
|
||||
"platformAgentConfig.title": "Platform Configuration",
|
||||
"plugin.addMCPPlugin": "Add MCP",
|
||||
"plugin.addTooltip": "Custom Skills",
|
||||
"plugin.clearDeprecated": "Remove Deprecated Skills",
|
||||
|
||||
@@ -350,6 +350,37 @@
|
||||
"pageSelection.reference": "选中文本",
|
||||
"pin": "置顶",
|
||||
"pinOff": "取消置顶",
|
||||
"platformAgent.create.available": "可用",
|
||||
"platformAgent.create.back": "上一步",
|
||||
"platformAgent.create.checkFailed": "检测失败",
|
||||
"platformAgent.create.checking": "正在检测可用性...",
|
||||
"platformAgent.create.comingSoon": "即将推出",
|
||||
"platformAgent.create.create": "创建 Agent",
|
||||
"platformAgent.create.creating": "创建中...",
|
||||
"platformAgent.create.desc.hermes": "在本地机器上运行 Hermes Agent",
|
||||
"platformAgent.create.desc.openclaw": "在本地机器上运行 OpenClaw Agent",
|
||||
"platformAgent.create.descriptionPlaceholder": "简短描述(可选)",
|
||||
"platformAgent.create.fetchingProfile": "正在读取配置...",
|
||||
"platformAgent.create.namePlaceholder": "例:我的 OpenClaw Agent",
|
||||
"platformAgent.create.next": "下一步",
|
||||
"platformAgent.create.noDevices": "没有已连接的设备",
|
||||
"platformAgent.create.noDevicesHint": "请先在目标机器上运行 `lh connect`",
|
||||
"platformAgent.create.notInstalled": "此设备未安装 {{name}}",
|
||||
"platformAgent.create.refresh": "刷新",
|
||||
"platformAgent.create.selectDevice": "选择设备",
|
||||
"platformAgent.create.step1": "选择平台",
|
||||
"platformAgent.create.step2": "选择设备",
|
||||
"platformAgent.create.step3": "配置 Agent",
|
||||
"platformAgent.create.title": "添加平台 Agent",
|
||||
"platformAgent.device.online": "在线",
|
||||
"platformAgent.deviceGuard.configure": "去配置",
|
||||
"platformAgent.deviceGuard.deviceOffline.desc": "绑定的设备未连接,请在目标机器上运行 `lh connect` 后刷新。",
|
||||
"platformAgent.deviceGuard.deviceOffline.title": "设备未连接",
|
||||
"platformAgent.deviceGuard.noDevice.desc": "该 Agent 未绑定设备,请在 Agent 配置中进行设置。",
|
||||
"platformAgent.deviceGuard.noDevice.title": "未绑定设备",
|
||||
"platformAgent.deviceGuard.platformUnavailable.desc": "连接的设备上未安装 {{name}}。",
|
||||
"platformAgent.deviceGuard.platformUnavailable.title": "{{name}} 不可用",
|
||||
"platformAgent.deviceGuard.refresh": "刷新",
|
||||
"plus.addSkills": "添加技能...",
|
||||
"plus.search.appSearch": "智能检索",
|
||||
"plus.search.appSearchDesc": "LobeHub 专属调优的搜索服务,检索效果最佳。",
|
||||
|
||||
@@ -9,5 +9,7 @@
|
||||
"features.groupChat.title": "群聊(多代理)",
|
||||
"features.inputMarkdown.desc": "在输入区域实时渲染 Markdown(粗体、代码块、表格等)",
|
||||
"features.inputMarkdown.title": "输入框 Markdown 渲染",
|
||||
"features.platformAgent.desc": "在创建菜单中显示「添加平台 Agent」入口。平台 Agent(如 OpenClaw、Hermes)运行在已连接的设备上,通过 lh connect 与 LobeHub 通信。",
|
||||
"features.platformAgent.title": "平台 Agent 创建",
|
||||
"title": "实验室"
|
||||
}
|
||||
|
||||
@@ -479,6 +479,20 @@
|
||||
"notification.item.image_generation_completed": "图片生成完成",
|
||||
"notification.item.video_generation_completed": "视频生成完成",
|
||||
"notification.title": "通知渠道",
|
||||
"platformAgentConfig.availability.available": "可用",
|
||||
"platformAgentConfig.availability.checking": "检测中...",
|
||||
"platformAgentConfig.availability.label": "可用性",
|
||||
"platformAgentConfig.availability.noDevice": "未绑定设备",
|
||||
"platformAgentConfig.availability.notInstalled": "未安装",
|
||||
"platformAgentConfig.changeDevice": "更换设备",
|
||||
"platformAgentConfig.device.label": "绑定设备",
|
||||
"platformAgentConfig.device.none": "无",
|
||||
"platformAgentConfig.device.offline": "离线",
|
||||
"platformAgentConfig.device.online": "在线",
|
||||
"platformAgentConfig.platform.label": "平台",
|
||||
"platformAgentConfig.redetect": "重新检测",
|
||||
"platformAgentConfig.selectDevice": "选择设备",
|
||||
"platformAgentConfig.title": "平台配置",
|
||||
"plugin.addMCPPlugin": "添加 MCP",
|
||||
"plugin.addTooltip": "自定义技能",
|
||||
"plugin.clearDeprecated": "移除无效技能",
|
||||
|
||||
@@ -35,6 +35,13 @@ export type AgentStreamEventType =
|
||||
| 'agent_intervention_response'
|
||||
| 'step_start'
|
||||
| 'step_complete'
|
||||
/**
|
||||
* Lightweight invalidation signal emitted by `agentNotify.notify` when a
|
||||
* remote hetero agent (openclaw / hermes) writes a message to DB via
|
||||
* `lh notify`. The frontend reacts by calling `fetchAndReplaceMessages` —
|
||||
* no content is carried in the event itself (DB is the source of truth).
|
||||
*/
|
||||
| 'notify_update'
|
||||
| 'error';
|
||||
|
||||
export interface AgentStreamEvent {
|
||||
|
||||
@@ -18,7 +18,7 @@ import { discoverService } from '@/services/discover';
|
||||
import { getAgentStoreState, useAgentStore } from '@/store/agent';
|
||||
import { agentSelectors } from '@/store/agent/selectors';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { selectRuntimeType } from '@/store/chat/slices/aiChat/actions/agentDispatcher';
|
||||
import { dispatchNonHeteroSubAgent } from '@/store/chat/slices/aiChat/actions/nonHeteroSubAgentDispatcher';
|
||||
import { dbMessageSelectors } from '@/store/chat/slices/message/selectors';
|
||||
import { messageMapKey } from '@/store/chat/utils/messageMapKey';
|
||||
|
||||
@@ -212,18 +212,17 @@ class AgentManagementExecutor extends BaseExecutor<typeof AgentManagementApiName
|
||||
}
|
||||
}
|
||||
|
||||
// Register afterCompletion to execute the agent
|
||||
// Register afterCompletion to execute the agent.
|
||||
// Runtime routing is fully delegated to dispatchNonHeteroSubAgent (LOBE-8927).
|
||||
ctx.registerAfterCompletion(async () => {
|
||||
const get = useChatStore.getState;
|
||||
|
||||
// Build conversation context - use current agent's context
|
||||
const conversationContext: ConversationContext = {
|
||||
agentId: ctx.agentId || '',
|
||||
topicId: ctx.topicId || null,
|
||||
// subAgentId will be set when calling executeClientAgent
|
||||
};
|
||||
|
||||
// Get current messages
|
||||
// Get current messages for client-mode runner (gateway loads from DB).
|
||||
const chatKey = messageMapKey(conversationContext);
|
||||
const messages = dbMessageSelectors.getDbMessagesByKey(chatKey)(get());
|
||||
|
||||
@@ -232,9 +231,9 @@ class AgentManagementExecutor extends BaseExecutor<typeof AgentManagementApiName
|
||||
return;
|
||||
}
|
||||
|
||||
// If instruction is provided, inject it as a virtual User Message
|
||||
// Same pattern as group orchestration's call_agent executor:
|
||||
// virtual message with <speaker> tag gives the called agent clear direction
|
||||
// Inject a virtual instruction message so the sub-agent has clear direction.
|
||||
// Only used by the client runner; gateway mode sends `instruction` as a real
|
||||
// user message via dispatchNonHeteroSubAgent.
|
||||
const now = Date.now();
|
||||
const messagesWithInstruction = instruction
|
||||
? [
|
||||
@@ -249,35 +248,23 @@ class AgentManagementExecutor extends BaseExecutor<typeof AgentManagementApiName
|
||||
]
|
||||
: messages;
|
||||
|
||||
// callAgent inherits the parent's runtime selection — a hetero/gateway
|
||||
// parent must keep the called sub-agent on the same path. See LOBE-8519.
|
||||
const parentAgentConfig = conversationContext.agentId
|
||||
? agentSelectors.getAgentConfigById(conversationContext.agentId)(getAgentStoreState())
|
||||
: undefined;
|
||||
const runtimeType = selectRuntimeType({
|
||||
heterogeneousProvider: parentAgentConfig?.agencyConfig?.heterogeneousProvider,
|
||||
isGatewayMode: get().isGatewayModeEnabled(),
|
||||
});
|
||||
|
||||
// TODO(LOBE-8519 follow-up): only client sub-agent dispatch is wired.
|
||||
// Gateway / hetero callAgent invocations fall through to client and
|
||||
// will need their own runner once Step 2 lands.
|
||||
if (runtimeType !== 'client') {
|
||||
console.warn(
|
||||
`[callAgent] runtime=${runtimeType} not yet supported for sub-agent dispatch; ` +
|
||||
'falling through to client mode',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await get().executeClientAgent({
|
||||
context: { ...conversationContext, subAgentId: agentId, scope: 'sub_agent' },
|
||||
messages: messagesWithInstruction,
|
||||
parentMessageId: ctx.messageId,
|
||||
parentMessageType: 'tool',
|
||||
});
|
||||
await dispatchNonHeteroSubAgent(
|
||||
{ kind: 'callAgent', targetAgentId: agentId, instruction, parentMessageId: ctx.messageId },
|
||||
{
|
||||
conversationContext,
|
||||
heterogeneousProvider: parentAgentConfig?.agencyConfig?.heterogeneousProvider,
|
||||
isGatewayMode: get().isGatewayModeEnabled(),
|
||||
messages: messagesWithInstruction,
|
||||
},
|
||||
get(),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[callAgent] executeClientAgent failed:', error);
|
||||
console.error('[callAgent] dispatchNonHeteroSubAgent failed:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ export const DEFAULT_PREFERENCE: UserPreference = {
|
||||
lab: {
|
||||
enableAgentSelfIteration: false,
|
||||
enableInputMarkdown: true,
|
||||
enablePlatformAgent: false,
|
||||
},
|
||||
topicGroupMode: 'byTime',
|
||||
topicIncludeCompleted: false,
|
||||
|
||||
@@ -74,7 +74,7 @@ export class GatewayHttpClient {
|
||||
}
|
||||
|
||||
async dispatchAgentRun(params: {
|
||||
agentType: 'claude-code' | 'codex';
|
||||
agentType: 'claude-code' | 'codex' | 'hermes' | 'openclaw';
|
||||
cwd?: string;
|
||||
deviceId?: string;
|
||||
jwt: string;
|
||||
|
||||
@@ -90,7 +90,7 @@ export interface SystemInfoResponseMessage {
|
||||
|
||||
/** Server → Client: request the desktop to spawn `lh hetero exec`. */
|
||||
export interface AgentRunRequestMessage {
|
||||
agentType: 'claude-code' | 'codex';
|
||||
agentType: 'claude-code' | 'codex' | 'hermes' | 'openclaw';
|
||||
cwd?: string;
|
||||
jwt: string;
|
||||
operationId: string;
|
||||
|
||||
@@ -29,7 +29,7 @@ export const HETEROGENEOUS_AGENT_CLIENT_CONFIGS = HETEROGENEOUS_AGENT_CONFIGS.ma
|
||||
icon: heterogeneousAgentIcons[config.type],
|
||||
})) as readonly HeterogeneousAgentClientConfig[];
|
||||
|
||||
export const getHeterogeneousAgentClientConfig = (type: HeterogeneousAgentConfig['type']) => {
|
||||
export const getHeterogeneousAgentClientConfig = (type: string) => {
|
||||
const config = getHeterogeneousAgentConfig(type);
|
||||
|
||||
if (!config) return undefined;
|
||||
@@ -37,6 +37,6 @@ export const getHeterogeneousAgentClientConfig = (type: HeterogeneousAgentConfig
|
||||
return {
|
||||
...config,
|
||||
avatar: createAgentAvatar(config.iconId),
|
||||
icon: heterogeneousAgentIcons[config.type],
|
||||
icon: heterogeneousAgentIcons[config.type as keyof typeof heterogeneousAgentIcons],
|
||||
} satisfies HeterogeneousAgentClientConfig;
|
||||
};
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import type { HeterogeneousProviderConfig } from '@lobechat/types';
|
||||
|
||||
export type HeterogeneousAgentMenuLabelKey = 'newClaudeCodeAgent' | 'newCodexAgent';
|
||||
|
||||
/**
|
||||
* Config for local CLI hetero agents (Claude Code, Codex) that run as
|
||||
* desktop subprocesses via Electron IPC. Remote device agents (openclaw,
|
||||
* hermes) have their own setup flow and are not listed here.
|
||||
*/
|
||||
export interface HeterogeneousAgentConfig {
|
||||
command: string;
|
||||
iconId: string;
|
||||
menuKey: string;
|
||||
menuLabelKey: HeterogeneousAgentMenuLabelKey;
|
||||
title: string;
|
||||
type: HeterogeneousProviderConfig['type'];
|
||||
type: 'claude-code' | 'codex';
|
||||
}
|
||||
|
||||
export const HETEROGENEOUS_AGENT_CONFIGS = [
|
||||
@@ -30,5 +33,38 @@ export const HETEROGENEOUS_AGENT_CONFIGS = [
|
||||
},
|
||||
] as const satisfies readonly HeterogeneousAgentConfig[];
|
||||
|
||||
export const getHeterogeneousAgentConfig = (type: HeterogeneousProviderConfig['type']) =>
|
||||
export const getHeterogeneousAgentConfig = (type: string) =>
|
||||
HETEROGENEOUS_AGENT_CONFIGS.find((config) => config.type === type);
|
||||
|
||||
/**
|
||||
* Config for remote platform hetero agents that communicate back via
|
||||
* agentNotify.notify. Unlike local CLI agents these are always bound to
|
||||
* a device via `lh connect` and do not run as desktop subprocesses.
|
||||
* Add new remote platform types here to automatically propagate display
|
||||
* names across the UI (model tag, loading indicator, agent list, etc.).
|
||||
*/
|
||||
export interface RemoteHeterogeneousAgentConfig {
|
||||
title: string;
|
||||
type: 'hermes' | 'openclaw';
|
||||
}
|
||||
|
||||
export const REMOTE_HETEROGENEOUS_AGENT_CONFIGS = [
|
||||
{ title: 'OpenClaw', type: 'openclaw' },
|
||||
{ title: 'Hermes', type: 'hermes' },
|
||||
] as const satisfies readonly RemoteHeterogeneousAgentConfig[];
|
||||
|
||||
/** Union of all local CLI hetero types. */
|
||||
export type LocalHeterogeneousAgentType = (typeof HETEROGENEOUS_AGENT_CONFIGS)[number]['type'];
|
||||
|
||||
/** Union of all remote platform hetero types. */
|
||||
export type RemoteHeterogeneousAgentType =
|
||||
(typeof REMOTE_HETEROGENEOUS_AGENT_CONFIGS)[number]['type'];
|
||||
|
||||
/** Union of every supported hetero agent type. */
|
||||
export type HeterogeneousAgentType = LocalHeterogeneousAgentType | RemoteHeterogeneousAgentType;
|
||||
|
||||
const REMOTE_HETERO_TYPES = new Set<string>(REMOTE_HETEROGENEOUS_AGENT_CONFIGS.map((c) => c.type));
|
||||
|
||||
/** Returns true when `type` identifies a remote platform agent (openclaw, hermes, …). */
|
||||
export const isRemoteHeterogeneousType = (type: string): type is RemoteHeterogeneousAgentType =>
|
||||
REMOTE_HETERO_TYPES.has(type);
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
export { ClaudeCodeAdapter, claudeCodePreset } from './adapters';
|
||||
export { getHeterogeneousAgentConfig, HETEROGENEOUS_AGENT_CONFIGS } from './config';
|
||||
export type {
|
||||
HeterogeneousAgentType,
|
||||
LocalHeterogeneousAgentType,
|
||||
RemoteHeterogeneousAgentType,
|
||||
} from './config';
|
||||
export {
|
||||
getHeterogeneousAgentConfig,
|
||||
HETEROGENEOUS_AGENT_CONFIGS,
|
||||
isRemoteHeterogeneousType,
|
||||
REMOTE_HETEROGENEOUS_AGENT_CONFIGS,
|
||||
} from './config';
|
||||
export { HETEROGENEOUS_TYPE_LABELS } from './labels';
|
||||
export { createAdapter, getPreset, listAgentTypes } from './registry';
|
||||
export type {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { HETEROGENEOUS_AGENT_CONFIGS } from './config';
|
||||
import { HETEROGENEOUS_AGENT_CONFIGS, REMOTE_HETEROGENEOUS_AGENT_CONFIGS } from './config';
|
||||
|
||||
/**
|
||||
* Display-name mapping for heterogeneous agent types.
|
||||
* Display-name mapping for all heterogeneous agent types (local CLI + remote platform).
|
||||
*
|
||||
* Keys mirror the registry keys in `registry.ts` (adapter type). UI layers
|
||||
* use this to render user-facing names (e.g. "Claude Code is running")
|
||||
* without knowing adapter-specific branding.
|
||||
* Add new types to HETEROGENEOUS_AGENT_CONFIGS or REMOTE_HETEROGENEOUS_AGENT_CONFIGS
|
||||
* in config.ts to automatically include them here.
|
||||
*/
|
||||
export const HETEROGENEOUS_TYPE_LABELS: Record<string, string> = Object.fromEntries(
|
||||
HETEROGENEOUS_AGENT_CONFIGS.map((config) => [config.type, config.title]),
|
||||
);
|
||||
export const HETEROGENEOUS_TYPE_LABELS: Record<string, string> = Object.fromEntries([
|
||||
...HETEROGENEOUS_AGENT_CONFIGS.map((config) => [config.type, config.title]),
|
||||
...REMOTE_HETEROGENEOUS_AGENT_CONFIGS.map((config) => [config.type, config.title]),
|
||||
]);
|
||||
|
||||
@@ -485,8 +485,7 @@ export const createAnthropicCompatibleRuntime = <T extends Record<string, any> =
|
||||
baseURL: finalBaseURL,
|
||||
...constructorOptions,
|
||||
...rest,
|
||||
timeout:
|
||||
rest.timeout ?? constructorOptions?.timeout ?? resolveDefaultAnthropicTimeout(),
|
||||
timeout: rest.timeout ?? constructorOptions?.timeout ?? resolveDefaultAnthropicTimeout(),
|
||||
};
|
||||
|
||||
if (customClient?.createClient) {
|
||||
|
||||
@@ -31,7 +31,7 @@ export const params = {
|
||||
...(thinking.budget_tokens !== undefined &&
|
||||
thinking.budget_tokens !== 0 && {
|
||||
thinking_budget: Math.min(Math.max(thinking.budget_tokens, 100), 16_384),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
} as any;
|
||||
},
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
/**
|
||||
* Heterogeneous agent provider configuration.
|
||||
* When set, the assistant delegates execution to an external agent CLI
|
||||
* When set, the assistant delegates execution to an external agent runtime
|
||||
* instead of using the built-in model runtime.
|
||||
*
|
||||
* Two families of hetero agents are supported:
|
||||
*
|
||||
* - **Local CLI** (`claude-code` | `codex`): spawned as a child process on the
|
||||
* desktop; uses `command`, `args`, `env`, `systemContext`.
|
||||
*
|
||||
* - **Remote device** (`openclaw` | `hermes`): dispatched to a machine
|
||||
* connected via `lh connect`; device is identified by `LobeAgentAgencyConfig.boundDeviceId`.
|
||||
* `platformAgentId` selects the named agent on the remote platform (defaults to `'main'`).
|
||||
*/
|
||||
export interface HeterogeneousProviderConfig {
|
||||
/** Additional CLI arguments for the agent command */
|
||||
/** Additional CLI arguments for the agent command (local CLI only). */
|
||||
args?: string[];
|
||||
/** Command to spawn the agent (e.g. 'claude') */
|
||||
/** Command to spawn the agent (e.g. 'claude') (local CLI only). */
|
||||
command?: string;
|
||||
/** Custom environment variables */
|
||||
/** Custom environment variables (local CLI only). */
|
||||
env?: Record<string, string>;
|
||||
/**
|
||||
* Platform-side agent identifier used by remote device runtimes.
|
||||
* - openclaw: selects the named agent (defaults to `'main'`)
|
||||
* - hermes: reserved for future use
|
||||
*/
|
||||
platformAgentId?: string;
|
||||
/**
|
||||
* Static context prepended to every user prompt before it reaches the agent CLI.
|
||||
* Use this to prime the agent with workspace conventions, rules, or instructions
|
||||
@@ -17,15 +32,22 @@ export interface HeterogeneousProviderConfig {
|
||||
* Combined with any runtime-generated context (e.g. cloned repo list).
|
||||
*/
|
||||
systemContext?: string;
|
||||
/** Agent runtime type */
|
||||
type: 'claude-code' | 'codex';
|
||||
/** Agent runtime type. */
|
||||
type: 'claude-code' | 'codex' | 'hermes' | 'openclaw';
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent agency configuration.
|
||||
* Contains settings for agent execution modes and device binding.
|
||||
*
|
||||
* For remote hetero agents (`type: 'openclaw' | 'hermes'`), `boundDeviceId`
|
||||
* identifies the target `lh connect` device and is required.
|
||||
*/
|
||||
export interface LobeAgentAgencyConfig {
|
||||
/**
|
||||
* Device ID of the machine connected via `lh connect`.
|
||||
* Required when `heterogeneousProvider.type` is `'openclaw'` or `'hermes'`.
|
||||
*/
|
||||
boundDeviceId?: string;
|
||||
heterogeneousProvider?: HeterogeneousProviderConfig;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,10 @@ export const UserLabSchema = z.object({
|
||||
* enable markdown rendering in chat input editor
|
||||
*/
|
||||
enableInputMarkdown: z.boolean().optional(),
|
||||
/**
|
||||
* show the "Add Platform Agent" entry in the create menu
|
||||
*/
|
||||
enablePlatformAgent: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type UserLab = z.infer<typeof UserLabSchema>;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { LOADING_FLAT } from '@lobechat/const';
|
||||
import { HETEROGENEOUS_TYPE_LABELS } from '@lobechat/heterogeneous-agents';
|
||||
import { type ModelPerformance, type ModelUsage } from '@lobechat/types';
|
||||
import { Flexbox } from '@lobehub/ui';
|
||||
import { memo } from 'react';
|
||||
@@ -30,7 +31,10 @@ export const AssistantMessageExtra = memo<AssistantMessageExtraProps>(
|
||||
const isLogin = useUserStore(authSelectors.isLogin);
|
||||
const isDevMode = useUserStore((s) => userGeneralSettingsSelectors.config(s).isDevMode);
|
||||
|
||||
const showUsage = isDevMode && content !== LOADING_FLAT && !!model;
|
||||
const showUsage =
|
||||
isDevMode &&
|
||||
content !== LOADING_FLAT &&
|
||||
(!!model || !!(provider && HETEROGENEOUS_TYPE_LABELS[provider]));
|
||||
const showTts = isLogin && !!extra?.tts;
|
||||
const showTranslate = isLogin && !!extra?.translate;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { HETEROGENEOUS_TYPE_LABELS } from '@lobechat/heterogeneous-agents';
|
||||
import { type ModelPerformance, type ModelUsage } from '@lobechat/types';
|
||||
import { ModelIcon } from '@lobehub/icons';
|
||||
import { Center, Flexbox } from '@lobehub/ui';
|
||||
@@ -32,6 +33,8 @@ const Usage = memo<UsageProps>(({ model, usage, performance, provider }) => {
|
||||
|
||||
if (!isDev && onboardingAgentId && conversationAgentId === onboardingAgentId) return null;
|
||||
|
||||
const heteroName = provider ? HETEROGENEOUS_TYPE_LABELS[provider] : undefined;
|
||||
|
||||
return (
|
||||
<Flexbox
|
||||
horizontal
|
||||
@@ -41,8 +44,14 @@ const Usage = memo<UsageProps>(({ model, usage, performance, provider }) => {
|
||||
justify={'space-between'}
|
||||
>
|
||||
<Center horizontal gap={4} style={{ fontSize: 12 }}>
|
||||
<ModelIcon model={model as string} type={'mono'} />
|
||||
{model}
|
||||
{heteroName ? (
|
||||
heteroName
|
||||
) : (
|
||||
<>
|
||||
<ModelIcon model={model as string} type={'mono'} />
|
||||
{model}
|
||||
</>
|
||||
)}
|
||||
</Center>
|
||||
|
||||
{!!usage?.totalTokens && (
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
REMOTE_HETEROGENEOUS_AGENT_CONFIGS,
|
||||
type RemoteHeterogeneousAgentType,
|
||||
} from '@lobechat/heterogeneous-agents';
|
||||
import { Button, Flexbox, Icon } from '@lobehub/ui';
|
||||
import { Alert, Input, Modal, Select, Steps, Tag } from 'antd';
|
||||
import { createStyles } from 'antd-style';
|
||||
import { BotIcon, CheckCircle2, MonitorSmartphone, RefreshCw, XCircle } from 'lucide-react';
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { lambdaClient, lambdaQuery } from '@/libs/trpc/client';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { useHomeStore } from '@/store/home';
|
||||
|
||||
const useStyles = createStyles(({ css, token }) => ({
|
||||
avatarPreview: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: ${token.borderRadiusLG}px;
|
||||
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
|
||||
background: ${token.colorFillSecondary};
|
||||
`,
|
||||
deviceItem: css`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
`,
|
||||
platformCard: css`
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: flex-start;
|
||||
|
||||
padding-block: 12px;
|
||||
padding-inline: 16px;
|
||||
border: 1.5px solid ${token.colorBorderSecondary};
|
||||
border-radius: ${token.borderRadiusLG}px;
|
||||
|
||||
background: ${token.colorBgContainer};
|
||||
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: ${token.colorPrimary};
|
||||
}
|
||||
|
||||
&[data-selected='true'] {
|
||||
border-color: ${token.colorPrimary};
|
||||
background: ${token.colorPrimaryBg};
|
||||
}
|
||||
|
||||
&[data-disabled='true'] {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
border-color: ${token.colorBorderSecondary};
|
||||
}
|
||||
}
|
||||
`,
|
||||
platformDesc: css`
|
||||
font-size: 13px;
|
||||
color: ${token.colorTextSecondary};
|
||||
`,
|
||||
platformName: css`
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: ${token.colorText};
|
||||
`,
|
||||
}));
|
||||
|
||||
interface AgentProfile {
|
||||
avatar?: string;
|
||||
description?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface CreatePlatformAgentModalProps {
|
||||
groupId?: string;
|
||||
onClose: () => void;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
const CreatePlatformAgentModal = memo<CreatePlatformAgentModalProps>(
|
||||
({ open, onClose, groupId }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const { styles } = useStyles();
|
||||
const navigate = useNavigate();
|
||||
const storeCreateAgent = useAgentStore((s) => s.createAgent);
|
||||
const refreshAgentList = useHomeStore((s) => s.refreshAgentList);
|
||||
|
||||
const [step, setStep] = useState(0);
|
||||
const [platform, setPlatform] = useState<RemoteHeterogeneousAgentType>('openclaw');
|
||||
const [deviceId, setDeviceId] = useState<string | undefined>(undefined);
|
||||
const [agentName, setAgentName] = useState('');
|
||||
const [agentDescription, setAgentDescription] = useState('');
|
||||
const [agentProfile, setAgentProfile] = useState<AgentProfile | null>(null);
|
||||
const [fetchingProfile, setFetchingProfile] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [capabilityResult, setCapabilityResult] = useState<
|
||||
{ available: boolean; reason?: string; version?: string } | undefined
|
||||
>(undefined);
|
||||
const [checkingCapability, setCheckingCapability] = useState(false);
|
||||
|
||||
// Platforms that are not yet ready for production use.
|
||||
// Remove a type from this set when the platform is fully supported.
|
||||
const COMING_SOON_PLATFORMS = new Set<RemoteHeterogeneousAgentType>(['hermes']);
|
||||
|
||||
// Derive platform display list from the registry — adding a new platform to
|
||||
// REMOTE_HETEROGENEOUS_AGENT_CONFIGS automatically includes it here.
|
||||
const platformDefs = REMOTE_HETEROGENEOUS_AGENT_CONFIGS.map((c) => ({
|
||||
comingSoon: COMING_SOON_PLATFORMS.has(c.type),
|
||||
desc: t(`platformAgent.create.desc.${c.type}`),
|
||||
name: c.title,
|
||||
type: c.type,
|
||||
}));
|
||||
|
||||
// Fetch device list when the modal opens; expose refetch for the refresh button
|
||||
const {
|
||||
data: devices,
|
||||
isLoading: loadingDevices,
|
||||
isFetching: fetchingDevices,
|
||||
refetch: refetchDevices,
|
||||
} = lambdaQuery.device.listDevices.useQuery(undefined, {
|
||||
enabled: open,
|
||||
staleTime: 0, // always re-fetch when explicitly called
|
||||
});
|
||||
|
||||
const selectedPlatformDef = platformDefs.find((p) => p.type === platform)!;
|
||||
|
||||
// Reset state when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setStep(0);
|
||||
setPlatform('openclaw');
|
||||
setDeviceId(undefined);
|
||||
setAgentName('');
|
||||
setAgentDescription('');
|
||||
setAgentProfile(null);
|
||||
setCapabilityResult(undefined);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Pre-fill name and description from fetched profile when entering step 2
|
||||
useEffect(() => {
|
||||
if (step !== 2) return;
|
||||
if (agentProfile !== null) {
|
||||
if (!agentName) setAgentName(agentProfile.title ?? selectedPlatformDef.name);
|
||||
if (!agentDescription) setAgentDescription(agentProfile.description ?? '');
|
||||
} else if (!fetchingProfile && !agentName) {
|
||||
// Profile fetch failed or no profile — fall back to platform name
|
||||
setAgentName(selectedPlatformDef.name);
|
||||
}
|
||||
}, [step, agentProfile, fetchingProfile]);
|
||||
|
||||
const handlePlatformChange = useCallback((type: RemoteHeterogeneousAgentType) => {
|
||||
setPlatform(type);
|
||||
// Reset device + capability state — capability is platform-specific;
|
||||
// stale results from the previous platform must not carry over.
|
||||
setDeviceId(undefined);
|
||||
setCapabilityResult(undefined);
|
||||
setAgentProfile(null);
|
||||
}, []);
|
||||
|
||||
const checkCapability = useCallback(
|
||||
async (dId: string) => {
|
||||
setCheckingCapability(true);
|
||||
setCapabilityResult(undefined);
|
||||
try {
|
||||
const result = await lambdaClient.device.checkCapability.query({
|
||||
deviceId: dId,
|
||||
platform,
|
||||
});
|
||||
setCapabilityResult(result);
|
||||
} catch {
|
||||
setCapabilityResult({ available: false, reason: t('platformAgent.create.checkFailed') });
|
||||
} finally {
|
||||
setCheckingCapability(false);
|
||||
}
|
||||
},
|
||||
[platform, t],
|
||||
);
|
||||
|
||||
const fetchProfile = useCallback(
|
||||
async (dId: string) => {
|
||||
setFetchingProfile(true);
|
||||
setAgentProfile(null);
|
||||
try {
|
||||
const profile = await lambdaClient.device.getAgentProfile.query({
|
||||
deviceId: dId,
|
||||
platform,
|
||||
});
|
||||
setAgentProfile(profile);
|
||||
} catch {
|
||||
setAgentProfile({});
|
||||
} finally {
|
||||
setFetchingProfile(false);
|
||||
}
|
||||
},
|
||||
[platform],
|
||||
);
|
||||
|
||||
const handleDeviceChange = useCallback(
|
||||
(dId: string) => {
|
||||
setDeviceId(dId);
|
||||
void checkCapability(dId);
|
||||
void fetchProfile(dId);
|
||||
},
|
||||
[checkCapability, fetchProfile],
|
||||
);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
setStep((s) => s + 1);
|
||||
}, []);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
setStep((s) => s - 1);
|
||||
}, []);
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
if (!deviceId) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const title = agentName.trim() || selectedPlatformDef.name;
|
||||
const result = await storeCreateAgent({
|
||||
config: {
|
||||
agencyConfig: {
|
||||
boundDeviceId: deviceId,
|
||||
heterogeneousProvider: {
|
||||
type: platform,
|
||||
},
|
||||
},
|
||||
avatar: agentProfile?.avatar || undefined,
|
||||
description: agentDescription.trim() || undefined,
|
||||
title,
|
||||
},
|
||||
groupId,
|
||||
});
|
||||
await refreshAgentList();
|
||||
onClose();
|
||||
navigate(`/agent/${result.agentId}`);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}, [
|
||||
deviceId,
|
||||
agentName,
|
||||
agentDescription,
|
||||
agentProfile,
|
||||
platform,
|
||||
groupId,
|
||||
storeCreateAgent,
|
||||
refreshAgentList,
|
||||
onClose,
|
||||
navigate,
|
||||
selectedPlatformDef.name,
|
||||
]);
|
||||
|
||||
const renderCapabilityStatus = () => {
|
||||
if (!deviceId) return null;
|
||||
if (checkingCapability)
|
||||
return <Tag style={{ marginInlineEnd: 0 }}>{t('platformAgent.create.checking')}</Tag>;
|
||||
if (!capabilityResult) return null;
|
||||
if (capabilityResult.available) {
|
||||
return (
|
||||
<Flexbox horizontal align="center" gap={4}>
|
||||
<Icon color="var(--ant-color-success)" icon={CheckCircle2} size={14} />
|
||||
<Tag color="success" style={{ marginInlineEnd: 0 }}>
|
||||
{capabilityResult.version ?? t('platformAgent.create.available')}
|
||||
</Tag>
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Flexbox horizontal align="center" gap={4}>
|
||||
<Icon color="var(--ant-color-error)" icon={XCircle} size={14} />
|
||||
<Tag color="error" style={{ marginInlineEnd: 0 }}>
|
||||
{capabilityResult.reason ??
|
||||
t('platformAgent.create.notInstalled', { name: selectedPlatformDef.name })}
|
||||
</Tag>
|
||||
</Flexbox>
|
||||
);
|
||||
};
|
||||
|
||||
const step2NextDisabled =
|
||||
!deviceId || checkingCapability || capabilityResult?.available === false;
|
||||
|
||||
const renderStepContent = () => {
|
||||
if (step === 0) {
|
||||
return (
|
||||
<Flexbox gap={12}>
|
||||
{platformDefs.map((def) => (
|
||||
<div
|
||||
className={styles.platformCard}
|
||||
data-disabled={def.comingSoon}
|
||||
data-selected={!def.comingSoon && platform === def.type}
|
||||
key={def.type}
|
||||
role="button"
|
||||
tabIndex={def.comingSoon ? -1 : 0}
|
||||
onClick={() => !def.comingSoon && handlePlatformChange(def.type)}
|
||||
onKeyDown={(e) => {
|
||||
if (!def.comingSoon && (e.key === 'Enter' || e.key === ' '))
|
||||
handlePlatformChange(def.type);
|
||||
}}
|
||||
>
|
||||
<Flexbox horizontal align="center" gap={8}>
|
||||
<Icon icon={MonitorSmartphone} size={18} />
|
||||
<span className={styles.platformName}>{def.name}</span>
|
||||
{def.comingSoon && (
|
||||
<Tag style={{ marginInlineEnd: 0 }}>{t('platformAgent.create.comingSoon')}</Tag>
|
||||
)}
|
||||
</Flexbox>
|
||||
<span className={styles.platformDesc}>{def.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 1) {
|
||||
const onlineDevices = (devices ?? []).filter((d) => d.online);
|
||||
const isRefreshing = loadingDevices || fetchingDevices;
|
||||
|
||||
const refreshButton = (
|
||||
<Button
|
||||
icon={<Icon icon={RefreshCw} size={13} />}
|
||||
loading={isRefreshing}
|
||||
size="small"
|
||||
type="text"
|
||||
onClick={() => void refetchDevices()}
|
||||
>
|
||||
{t('platformAgent.create.refresh')}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (!isRefreshing && onlineDevices.length === 0) {
|
||||
return (
|
||||
<Flexbox gap={12}>
|
||||
<Alert
|
||||
showIcon
|
||||
description={t('platformAgent.create.noDevicesHint')}
|
||||
message={t('platformAgent.create.noDevices')}
|
||||
type="warning"
|
||||
/>
|
||||
{refreshButton}
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flexbox gap={12}>
|
||||
<Flexbox horizontal align="center" gap={8}>
|
||||
<Select
|
||||
loading={isRefreshing}
|
||||
placeholder={t('platformAgent.create.selectDevice')}
|
||||
style={{ flex: 1 }}
|
||||
value={deviceId}
|
||||
options={onlineDevices.map((d) => ({
|
||||
label: (
|
||||
<div className={styles.deviceItem}>
|
||||
<Icon icon={BotIcon} size={14} />
|
||||
<span>{d.hostname}</span>
|
||||
<Tag color="success" style={{ marginInlineEnd: 0 }}>
|
||||
{t('platformAgent.device.online')}
|
||||
</Tag>
|
||||
</div>
|
||||
),
|
||||
value: d.deviceId,
|
||||
}))}
|
||||
onChange={handleDeviceChange}
|
||||
/>
|
||||
{refreshButton}
|
||||
</Flexbox>
|
||||
{renderCapabilityStatus()}
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 2) {
|
||||
const avatar = agentProfile?.avatar;
|
||||
return (
|
||||
<Flexbox gap={12}>
|
||||
{avatar && (
|
||||
<Flexbox horizontal align="center" gap={12}>
|
||||
<div className={styles.avatarPreview}>{avatar}</div>
|
||||
</Flexbox>
|
||||
)}
|
||||
<Input
|
||||
maxLength={60}
|
||||
value={agentName}
|
||||
placeholder={
|
||||
fetchingProfile
|
||||
? t('platformAgent.create.fetchingProfile')
|
||||
: t('platformAgent.create.namePlaceholder')
|
||||
}
|
||||
onChange={(e) => setAgentName(e.target.value)}
|
||||
onPressEnter={() => void handleCreate()}
|
||||
/>
|
||||
<Input.TextArea
|
||||
autoSize={{ maxRows: 4, minRows: 2 }}
|
||||
maxLength={200}
|
||||
placeholder={t('platformAgent.create.descriptionPlaceholder')}
|
||||
value={agentDescription}
|
||||
onChange={(e) => setAgentDescription(e.target.value)}
|
||||
/>
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderFooter = () => {
|
||||
const buttons = [];
|
||||
|
||||
if (step > 0) {
|
||||
buttons.push(
|
||||
<Button key="back" onClick={handleBack}>
|
||||
{t('platformAgent.create.back')}
|
||||
</Button>,
|
||||
);
|
||||
}
|
||||
|
||||
if (step < 2) {
|
||||
const nextDisabled = step === 1 && step2NextDisabled;
|
||||
buttons.push(
|
||||
<Button disabled={nextDisabled} key="next" type="primary" onClick={handleNext}>
|
||||
{t('platformAgent.create.next')}
|
||||
</Button>,
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 2) {
|
||||
buttons.push(
|
||||
<Button
|
||||
disabled={!agentName.trim() && !selectedPlatformDef.name}
|
||||
key="create"
|
||||
loading={creating}
|
||||
type="primary"
|
||||
onClick={() => void handleCreate()}
|
||||
>
|
||||
{creating ? t('platformAgent.create.creating') : t('platformAgent.create.create')}
|
||||
</Button>,
|
||||
);
|
||||
}
|
||||
|
||||
return buttons;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
destroyOnClose
|
||||
footer={renderFooter()}
|
||||
open={open}
|
||||
title={t('platformAgent.create.title')}
|
||||
width={480}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<Flexbox gap={24} paddingBlock={'16px 8px'}>
|
||||
<Steps
|
||||
current={step}
|
||||
size="small"
|
||||
items={[
|
||||
{ title: t('platformAgent.create.step1') },
|
||||
{ title: t('platformAgent.create.step2') },
|
||||
{ title: t('platformAgent.create.step3') },
|
||||
]}
|
||||
/>
|
||||
{renderStepContent()}
|
||||
</Flexbox>
|
||||
</Modal>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CreatePlatformAgentModal.displayName = 'CreatePlatformAgentModal';
|
||||
|
||||
export default CreatePlatformAgentModal;
|
||||
@@ -0,0 +1,87 @@
|
||||
import { isRemoteHeterogeneousType } from '@lobechat/heterogeneous-agents';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { lambdaClient } from '@/libs/trpc/client';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
|
||||
export type RemoteAgentDeviceStatus =
|
||||
| 'checking'
|
||||
| 'device-offline'
|
||||
| 'no-device'
|
||||
| 'ok'
|
||||
| 'platform-unavailable';
|
||||
|
||||
interface UseRemoteAgentDeviceGuardOptions {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface UseRemoteAgentDeviceGuardResult {
|
||||
refresh: () => void;
|
||||
status: RemoteAgentDeviceStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the bound device is online and the agent platform is available.
|
||||
* Used in HeterogeneousChatInput to gate sending for openclaw / hermes agents.
|
||||
*/
|
||||
export const useRemoteAgentDeviceGuard = ({
|
||||
enabled = true,
|
||||
}: UseRemoteAgentDeviceGuardOptions = {}): UseRemoteAgentDeviceGuardResult => {
|
||||
const agentId = useAgentStore((s) => s.activeAgentId);
|
||||
const agencyConfig = useAgentStore((s) =>
|
||||
agentId ? s.agentMap[agentId]?.agencyConfig : undefined,
|
||||
);
|
||||
|
||||
const boundDeviceId = agencyConfig?.boundDeviceId;
|
||||
const providerType = agencyConfig?.heterogeneousProvider?.type;
|
||||
|
||||
const [status, setStatus] = useState<RemoteAgentDeviceStatus>('checking');
|
||||
|
||||
const check = useCallback(async () => {
|
||||
if (!enabled) return;
|
||||
|
||||
if (!boundDeviceId) {
|
||||
setStatus('no-device');
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('checking');
|
||||
|
||||
try {
|
||||
const devices = await lambdaClient.device.listDevices.query();
|
||||
const device = devices.find((d) => d.deviceId === boundDeviceId);
|
||||
|
||||
if (!device || !device.online) {
|
||||
setStatus('device-offline');
|
||||
return;
|
||||
}
|
||||
|
||||
if (providerType && isRemoteHeterogeneousType(providerType)) {
|
||||
const capability = await lambdaClient.device.checkCapability.query({
|
||||
deviceId: boundDeviceId,
|
||||
platform: providerType,
|
||||
});
|
||||
setStatus(capability.available ? 'ok' : 'platform-unavailable');
|
||||
} else {
|
||||
setStatus('ok');
|
||||
}
|
||||
} catch {
|
||||
// On error, allow sending — don't block user on network issues
|
||||
setStatus('ok');
|
||||
}
|
||||
}, [enabled, boundDeviceId, providerType]);
|
||||
|
||||
useEffect(() => {
|
||||
void check();
|
||||
}, [check]);
|
||||
|
||||
// Re-check when window regains focus
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const handler = () => void check();
|
||||
document.addEventListener('visibilitychange', handler);
|
||||
return () => document.removeEventListener('visibilitychange', handler);
|
||||
}, [enabled, check]);
|
||||
|
||||
return { refresh: () => void check(), status };
|
||||
};
|
||||
@@ -343,7 +343,50 @@ export default {
|
||||
'newAgent': 'Create Agent',
|
||||
'newClaudeCodeAgent': 'Add Claude Code',
|
||||
'newCodexAgent': 'Add Codex',
|
||||
'newPlatformAgent': 'Add Platform Agent',
|
||||
'newGroupChat': 'Create Group',
|
||||
|
||||
// Platform agent: per-platform descriptions shown in step 0 of the creation modal
|
||||
'platformAgent.create.desc.openclaw': 'Run OpenClaw agents on your local machine',
|
||||
'platformAgent.create.desc.hermes': 'Run Hermes agents on your local machine',
|
||||
|
||||
// Platform agent: shared device status label (used in Select option labels)
|
||||
'platformAgent.device.online': 'Online',
|
||||
|
||||
// Platform agent creation modal (openclaw / hermes)
|
||||
'platformAgent.create.title': 'Add Platform Agent',
|
||||
'platformAgent.create.step1': 'Select Platform',
|
||||
'platformAgent.create.step2': 'Select Device',
|
||||
'platformAgent.create.step3': 'Configure Agent',
|
||||
'platformAgent.create.next': 'Next',
|
||||
'platformAgent.create.back': 'Back',
|
||||
'platformAgent.create.create': 'Create Agent',
|
||||
'platformAgent.create.creating': 'Creating...',
|
||||
'platformAgent.create.namePlaceholder': 'e.g. My OpenClaw Agent',
|
||||
'platformAgent.create.descriptionPlaceholder': 'Brief description (optional)',
|
||||
'platformAgent.create.fetchingProfile': 'Fetching profile...',
|
||||
'platformAgent.create.noDevices': 'No devices connected',
|
||||
'platformAgent.create.noDevicesHint': 'Run `lh connect` on the target machine first',
|
||||
'platformAgent.create.refresh': 'Refresh',
|
||||
'platformAgent.create.selectDevice': 'Select a device',
|
||||
'platformAgent.create.checking': 'Checking availability...',
|
||||
'platformAgent.create.available': 'Available',
|
||||
'platformAgent.create.notInstalled': '{{name}} not installed on this device',
|
||||
'platformAgent.create.checkFailed': 'Check failed',
|
||||
'platformAgent.create.comingSoon': 'Coming Soon',
|
||||
|
||||
// Platform agent device guard banner
|
||||
'platformAgent.deviceGuard.deviceOffline.title': 'Device not connected',
|
||||
'platformAgent.deviceGuard.deviceOffline.desc':
|
||||
'The bound device is not connected. Run `lh connect` on that machine then refresh.',
|
||||
'platformAgent.deviceGuard.platformUnavailable.title': '{{name}} not available',
|
||||
'platformAgent.deviceGuard.platformUnavailable.desc':
|
||||
'{{name}} is not installed on the connected device.',
|
||||
'platformAgent.deviceGuard.noDevice.title': 'No device bound',
|
||||
'platformAgent.deviceGuard.noDevice.desc':
|
||||
'This agent has no bound device. Edit the agent profile to configure one.',
|
||||
'platformAgent.deviceGuard.refresh': 'Refresh',
|
||||
'platformAgent.deviceGuard.configure': 'Configure',
|
||||
'newPage': 'Create Page',
|
||||
'noAgentsYet': 'This group has no members yet. Click the + button to invite agents.',
|
||||
'noAvailableAgents': 'No members available to invite',
|
||||
|
||||
@@ -13,5 +13,8 @@ export default {
|
||||
'features.inputMarkdown.desc':
|
||||
'Render Markdown in the input area in real time (bold text, code blocks, tables, etc.).',
|
||||
'features.inputMarkdown.title': 'Input Markdown Rendering',
|
||||
'features.platformAgent.desc':
|
||||
'Show the "Add Platform Agent" entry in the create menu. Platform agents (e.g. OpenClaw, Hermes) run on a connected device and communicate back via lh connect.',
|
||||
'features.platformAgent.title': 'Platform Agent Creation',
|
||||
'title': 'Labs',
|
||||
};
|
||||
|
||||
@@ -241,6 +241,22 @@ export default {
|
||||
// Heterogeneous agent — Desktop tab
|
||||
'heterogeneousStatus.desktop.tabLabel': 'Desktop',
|
||||
|
||||
// Remote platform agent profile config panel (openclaw / hermes)
|
||||
'platformAgentConfig.title': 'Platform Configuration',
|
||||
'platformAgentConfig.platform.label': 'Platform',
|
||||
'platformAgentConfig.device.label': 'Bound Device',
|
||||
'platformAgentConfig.device.none': 'None',
|
||||
'platformAgentConfig.device.online': 'Online',
|
||||
'platformAgentConfig.device.offline': 'Offline',
|
||||
'platformAgentConfig.availability.label': 'Availability',
|
||||
'platformAgentConfig.availability.checking': 'Checking...',
|
||||
'platformAgentConfig.availability.available': 'Available',
|
||||
'platformAgentConfig.availability.notInstalled': 'Not installed',
|
||||
'platformAgentConfig.availability.noDevice': 'No device bound',
|
||||
'platformAgentConfig.changeDevice': 'Change Device',
|
||||
'platformAgentConfig.redetect': 'Re-detect',
|
||||
'platformAgentConfig.selectDevice': 'Select a device',
|
||||
|
||||
'checking': 'Checking...',
|
||||
|
||||
// Credentials Management
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
HETEROGENEOUS_TYPE_LABELS,
|
||||
isRemoteHeterogeneousType,
|
||||
} from '@lobechat/heterogeneous-agents';
|
||||
import { Alert, Button, Flexbox } from '@lobehub/ui';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import urlJoin from 'url-join';
|
||||
|
||||
import { useHeteroAgentCloudConfig } from '@/business/client/hooks/useHeteroAgentCloudConfig';
|
||||
import { type ActionKeys } from '@/features/ChatInput';
|
||||
import { ChatInput } from '@/features/Conversation';
|
||||
import WideScreenContainer from '@/features/WideScreenContainer';
|
||||
import { useRemoteAgentDeviceGuard } from '@/hooks/useRemoteAgentDeviceGuard';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { agentSelectors } from '@/store/agent/selectors';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
|
||||
import WorkingDirectoryBar from './WorkingDirectoryBar';
|
||||
@@ -31,17 +40,75 @@ const rightActions: ActionKeys[] = [];
|
||||
const HeterogeneousChatInput = memo(() => {
|
||||
const { t } = useTranslation('chat');
|
||||
const { isConfigured, goToConfig } = useHeteroAgentCloudConfig();
|
||||
const params = useParams<{ aid: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const providerType = useAgentStore(agentSelectors.currentAgentHeterogeneousProviderType);
|
||||
const isRemoteAgent = !!providerType && isRemoteHeterogeneousType(providerType);
|
||||
|
||||
const { status, refresh } = useRemoteAgentDeviceGuard({ enabled: isRemoteAgent });
|
||||
|
||||
const goToAgentProfile = () => {
|
||||
if (params.aid) navigate(urlJoin('/agent', params.aid, 'profile'));
|
||||
};
|
||||
|
||||
const deviceBlocked =
|
||||
isRemoteAgent &&
|
||||
(status === 'device-offline' || status === 'platform-unavailable' || status === 'no-device');
|
||||
|
||||
const renderDeviceGuard = () => {
|
||||
if (!isRemoteAgent || !deviceBlocked) return null;
|
||||
|
||||
let title: string;
|
||||
let desc: string;
|
||||
|
||||
if (status === 'no-device') {
|
||||
title = t('platformAgent.deviceGuard.noDevice.title');
|
||||
desc = t('platformAgent.deviceGuard.noDevice.desc');
|
||||
} else if (status === 'device-offline') {
|
||||
title = t('platformAgent.deviceGuard.deviceOffline.title');
|
||||
desc = t('platformAgent.deviceGuard.deviceOffline.desc');
|
||||
} else {
|
||||
const name = HETEROGENEOUS_TYPE_LABELS[providerType] ?? providerType;
|
||||
title = t('platformAgent.deviceGuard.platformUnavailable.title', { name });
|
||||
desc = t('platformAgent.deviceGuard.platformUnavailable.desc', { name });
|
||||
}
|
||||
|
||||
return (
|
||||
<Flexbox paddingBlock={'0 6px'} paddingInline={12}>
|
||||
<Alert
|
||||
title={title}
|
||||
type={'warning'}
|
||||
description={
|
||||
<Flexbox horizontal align={'center'} gap={8} justify={'space-between'}>
|
||||
<span>{desc}</span>
|
||||
<Flexbox horizontal gap={6}>
|
||||
<Button size={'small'} onClick={refresh}>
|
||||
{t('platformAgent.deviceGuard.refresh')}
|
||||
</Button>
|
||||
<Button size={'small'} type={'primary'} onClick={goToAgentProfile}>
|
||||
{t('platformAgent.deviceGuard.configure')}
|
||||
</Button>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
}
|
||||
/>
|
||||
</Flexbox>
|
||||
);
|
||||
};
|
||||
|
||||
const inputDisabled = (!isConfigured && !isRemoteAgent) || deviceBlocked;
|
||||
|
||||
return (
|
||||
<Flexbox>
|
||||
{!isConfigured && (
|
||||
{!isRemoteAgent && !isConfigured && (
|
||||
<WideScreenContainer>
|
||||
<Flexbox paddingBlock={'0 6px'} paddingInline={12}>
|
||||
<Alert
|
||||
type={'warning'}
|
||||
title={t('heteroAgent.cloudNotConfigured.title')}
|
||||
type={'warning'}
|
||||
description={
|
||||
<Flexbox horizontal align={'center'} justify={'space-between'} gap={8}>
|
||||
<Flexbox horizontal align={'center'} gap={8} justify={'space-between'}>
|
||||
<span>{t('heteroAgent.cloudNotConfigured.desc')}</span>
|
||||
<Button size={'small'} type={'primary'} onClick={goToConfig}>
|
||||
{t('heteroAgent.cloudNotConfigured.action')}
|
||||
@@ -52,12 +119,13 @@ const HeterogeneousChatInput = memo(() => {
|
||||
</Flexbox>
|
||||
</WideScreenContainer>
|
||||
)}
|
||||
{renderDeviceGuard()}
|
||||
<ChatInput
|
||||
skipScrollMarginWithList
|
||||
leftActions={leftActions}
|
||||
rightActions={rightActions}
|
||||
runtimeConfigSlot={<WorkingDirectoryBar />}
|
||||
sendButtonProps={{ disabled: !isConfigured, shape: 'round' }}
|
||||
sendButtonProps={{ disabled: inputDisabled, shape: 'round' }}
|
||||
onEditorReady={(instance) => {
|
||||
// Sync to global ChatStore for compatibility with other features
|
||||
useChatStore.setState({ mainInputEditor: instance });
|
||||
|
||||
+7
-1
@@ -250,7 +250,13 @@ const HeterogeneousAgentStatusCard = memo<HeterogeneousAgentStatusCardProps>(
|
||||
}, [provider.type, resolvedCommand]);
|
||||
|
||||
const detect = useCallback(async () => {
|
||||
if (!isDesktop || !resolvedCommand) {
|
||||
// openclaw / hermes are remote device agents — no local CLI to detect.
|
||||
if (
|
||||
provider.type === 'openclaw' ||
|
||||
provider.type === 'hermes' ||
|
||||
!isDesktop ||
|
||||
!resolvedCommand
|
||||
) {
|
||||
setDetecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
HETEROGENEOUS_TYPE_LABELS,
|
||||
type RemoteHeterogeneousAgentType,
|
||||
} from '@lobechat/heterogeneous-agents';
|
||||
import type { HeterogeneousProviderConfig } from '@lobechat/types';
|
||||
import { ActionIcon, Flexbox, Icon, Text, Tooltip } from '@lobehub/ui';
|
||||
import { Button, Modal, Select, Tag } from 'antd';
|
||||
import { createStyles } from 'antd-style';
|
||||
import { BotIcon, CheckCircle2, MonitorSmartphone, RefreshCw, XCircle } from 'lucide-react';
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { lambdaClient, lambdaQuery } from '@/libs/trpc/client';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
|
||||
const useStyles = createStyles(({ css, token }) => ({
|
||||
card: css`
|
||||
padding-block: 16px 4px;
|
||||
padding-inline: 16px;
|
||||
border: 1px solid ${token.colorBorderSecondary};
|
||||
border-radius: ${token.borderRadiusLG}px;
|
||||
|
||||
background: ${token.colorBgContainer};
|
||||
`,
|
||||
cardHeader: css`
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
padding-block-end: 12px;
|
||||
`,
|
||||
title: css`
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
`,
|
||||
detailList: css`
|
||||
border-block-start: 1px solid ${token.colorBorderSecondary};
|
||||
`,
|
||||
detailRow: css`
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
|
||||
min-height: 44px;
|
||||
padding-block: 6px;
|
||||
|
||||
& + & {
|
||||
border-block-start: 1px solid ${token.colorBorderSecondary};
|
||||
}
|
||||
`,
|
||||
detailLabel: css`
|
||||
flex-shrink: 0;
|
||||
|
||||
width: 96px;
|
||||
|
||||
font-size: 12px;
|
||||
color: ${token.colorTextTertiary};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
`,
|
||||
detailContent: css`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
|
||||
min-width: 0;
|
||||
`,
|
||||
deviceItem: css`
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
`,
|
||||
}));
|
||||
|
||||
interface RemoteAgentConfigCardProps {
|
||||
onBoundDeviceChange?: (deviceId: string) => Promise<void> | void;
|
||||
provider: HeterogeneousProviderConfig;
|
||||
}
|
||||
|
||||
const RemoteAgentConfigCard = memo<RemoteAgentConfigCardProps>(
|
||||
({ provider, onBoundDeviceChange }) => {
|
||||
const { t } = useTranslation('setting');
|
||||
const { styles } = useStyles();
|
||||
|
||||
const agentId = useAgentStore((s) => s.activeAgentId);
|
||||
const boundDeviceId = useAgentStore((s) =>
|
||||
agentId ? s.agentMap[agentId]?.agencyConfig?.boundDeviceId : undefined,
|
||||
);
|
||||
|
||||
const [changeDeviceOpen, setChangeDeviceOpen] = useState(false);
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState<string | undefined>(undefined);
|
||||
const [capabilityResult, setCapabilityResult] = useState<
|
||||
{ available: boolean; reason?: string; version?: string } | undefined
|
||||
>(undefined);
|
||||
const [checkingCapability, setCheckingCapability] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const platformName = HETEROGENEOUS_TYPE_LABELS[provider.type] ?? provider.type;
|
||||
|
||||
const { data: devices, isLoading: loadingDevices } = lambdaQuery.device.listDevices.useQuery(
|
||||
undefined,
|
||||
{ staleTime: 30_000 },
|
||||
);
|
||||
|
||||
const boundDevice = devices?.find((d) => d.deviceId === boundDeviceId);
|
||||
|
||||
const checkCapability = useCallback(
|
||||
async (deviceId: string) => {
|
||||
setCheckingCapability(true);
|
||||
setCapabilityResult(undefined);
|
||||
try {
|
||||
const result = await lambdaClient.device.checkCapability.query({
|
||||
deviceId,
|
||||
platform: provider.type as RemoteHeterogeneousAgentType,
|
||||
});
|
||||
setCapabilityResult(result);
|
||||
} catch {
|
||||
setCapabilityResult({ available: false, reason: 'Check failed' });
|
||||
} finally {
|
||||
setCheckingCapability(false);
|
||||
}
|
||||
},
|
||||
[provider.type],
|
||||
);
|
||||
|
||||
// Check capability on mount when bound device is online
|
||||
useEffect(() => {
|
||||
if (boundDeviceId && boundDevice?.online) {
|
||||
void checkCapability(boundDeviceId);
|
||||
}
|
||||
}, [boundDeviceId, boundDevice?.online, checkCapability]);
|
||||
|
||||
const handleOpenChangeDevice = useCallback(() => {
|
||||
setSelectedDeviceId(boundDeviceId);
|
||||
setCapabilityResult(undefined);
|
||||
setChangeDeviceOpen(true);
|
||||
}, [boundDeviceId]);
|
||||
|
||||
const handleDeviceSelect = useCallback(
|
||||
(dId: string) => {
|
||||
setSelectedDeviceId(dId);
|
||||
void checkCapability(dId);
|
||||
},
|
||||
[checkCapability],
|
||||
);
|
||||
|
||||
const handleSaveDevice = useCallback(async () => {
|
||||
if (!selectedDeviceId) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onBoundDeviceChange?.(selectedDeviceId);
|
||||
setChangeDeviceOpen(false);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [selectedDeviceId, onBoundDeviceChange]);
|
||||
|
||||
const renderAvailability = () => {
|
||||
if (!boundDeviceId) {
|
||||
return (
|
||||
<Tag style={{ marginInlineEnd: 0 }}>{t('platformAgentConfig.availability.noDevice')}</Tag>
|
||||
);
|
||||
}
|
||||
if (!boundDevice?.online) {
|
||||
return (
|
||||
<Tag color="warning" style={{ marginInlineEnd: 0 }}>
|
||||
{t('platformAgentConfig.device.offline')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
if (checkingCapability) {
|
||||
return (
|
||||
<Tag style={{ marginInlineEnd: 0 }}>{t('platformAgentConfig.availability.checking')}</Tag>
|
||||
);
|
||||
}
|
||||
if (!capabilityResult) return null;
|
||||
if (capabilityResult.available) {
|
||||
return (
|
||||
<Flexbox horizontal align="center" gap={4}>
|
||||
<Icon color="var(--ant-color-success)" icon={CheckCircle2} size={14} />
|
||||
<Tag color="success" style={{ marginInlineEnd: 0 }}>
|
||||
{capabilityResult.version ?? t('platformAgentConfig.availability.available')}
|
||||
</Tag>
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Flexbox horizontal align="center" gap={4}>
|
||||
<Icon color="var(--ant-color-error)" icon={XCircle} size={14} />
|
||||
<Tag color="error" style={{ marginInlineEnd: 0 }}>
|
||||
{t('platformAgentConfig.availability.notInstalled')}
|
||||
</Tag>
|
||||
</Flexbox>
|
||||
);
|
||||
};
|
||||
|
||||
const onlineDevices = (devices ?? []).filter((d) => d.online);
|
||||
const capabilityOk = capabilityResult?.available === true;
|
||||
const capabilityBad = capabilityResult?.available === false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flexbox className={styles.card} gap={0}>
|
||||
<div className={styles.cardHeader}>
|
||||
<Flexbox horizontal align="center" gap={8}>
|
||||
<Icon icon={MonitorSmartphone} size={16} />
|
||||
<Text strong className={styles.title}>
|
||||
{t('platformAgentConfig.title')}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
<Tooltip title={t('platformAgentConfig.redetect')}>
|
||||
<ActionIcon
|
||||
aria-label={t('platformAgentConfig.redetect')}
|
||||
disabled={!boundDeviceId || checkingCapability}
|
||||
icon={RefreshCw}
|
||||
loading={checkingCapability}
|
||||
size="small"
|
||||
onClick={() => boundDeviceId && void checkCapability(boundDeviceId)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={styles.detailList}>
|
||||
<div className={styles.detailRow}>
|
||||
<Text className={styles.detailLabel}>{t('platformAgentConfig.platform.label')}</Text>
|
||||
<div className={styles.detailContent}>
|
||||
<Tag style={{ marginInlineEnd: 0 }}>{platformName}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<Text className={styles.detailLabel}>{t('platformAgentConfig.device.label')}</Text>
|
||||
<div className={styles.detailContent}>
|
||||
{boundDevice ? (
|
||||
<Flexbox horizontal align="center" gap={6}>
|
||||
<Text ellipsis style={{ fontSize: 14 }}>
|
||||
{boundDevice.hostname}
|
||||
</Text>
|
||||
<Tag
|
||||
color={boundDevice.online ? 'success' : 'default'}
|
||||
style={{ marginInlineEnd: 0 }}
|
||||
>
|
||||
{boundDevice.online
|
||||
? t('platformAgentConfig.device.online')
|
||||
: t('platformAgentConfig.device.offline')}
|
||||
</Tag>
|
||||
</Flexbox>
|
||||
) : (
|
||||
<Tag style={{ marginInlineEnd: 0 }}>{t('platformAgentConfig.device.none')}</Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<Text className={styles.detailLabel}>
|
||||
{t('platformAgentConfig.availability.label')}
|
||||
</Text>
|
||||
<div className={styles.detailContent}>{renderAvailability()}</div>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<div className={styles.detailLabel} />
|
||||
<div className={styles.detailContent}>
|
||||
<Button size="small" onClick={handleOpenChangeDevice}>
|
||||
{t('platformAgentConfig.changeDevice')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Flexbox>
|
||||
|
||||
{/* Change Device Modal */}
|
||||
<Modal
|
||||
destroyOnClose
|
||||
okText={t('platformAgentConfig.changeDevice')}
|
||||
open={changeDeviceOpen}
|
||||
title={t('platformAgentConfig.changeDevice')}
|
||||
width={400}
|
||||
okButtonProps={{
|
||||
disabled: !selectedDeviceId || checkingCapability || capabilityBad,
|
||||
loading: saving,
|
||||
}}
|
||||
onCancel={() => setChangeDeviceOpen(false)}
|
||||
onOk={() => void handleSaveDevice()}
|
||||
>
|
||||
<Flexbox gap={12} paddingBlock={'12px 4px'}>
|
||||
<Select
|
||||
loading={loadingDevices}
|
||||
placeholder={t('platformAgentConfig.selectDevice')}
|
||||
style={{ width: '100%' }}
|
||||
value={selectedDeviceId}
|
||||
options={onlineDevices.map((d) => ({
|
||||
label: (
|
||||
<div className={styles.deviceItem}>
|
||||
<Icon icon={BotIcon} size={14} />
|
||||
<span>{d.hostname}</span>
|
||||
<Tag color="success" style={{ marginInlineEnd: 0 }}>
|
||||
{t('platformAgentConfig.device.online')}
|
||||
</Tag>
|
||||
</div>
|
||||
),
|
||||
value: d.deviceId,
|
||||
}))}
|
||||
onChange={handleDeviceSelect}
|
||||
/>
|
||||
{checkingCapability && (
|
||||
<Tag style={{ marginInlineEnd: 0 }}>
|
||||
{t('platformAgentConfig.availability.checking')}
|
||||
</Tag>
|
||||
)}
|
||||
{capabilityOk && (
|
||||
<Flexbox horizontal align="center" gap={4}>
|
||||
<Icon color="var(--ant-color-success)" icon={CheckCircle2} size={14} />
|
||||
<Tag color="success" style={{ marginInlineEnd: 0 }}>
|
||||
{capabilityResult?.version ?? t('platformAgentConfig.availability.available')}
|
||||
</Tag>
|
||||
</Flexbox>
|
||||
)}
|
||||
{capabilityBad && (
|
||||
<Flexbox horizontal align="center" gap={4}>
|
||||
<Icon color="var(--ant-color-error)" icon={XCircle} size={14} />
|
||||
<Tag color="error" style={{ marginInlineEnd: 0 }}>
|
||||
{t('platformAgentConfig.availability.notInstalled')}
|
||||
</Tag>
|
||||
</Flexbox>
|
||||
)}
|
||||
</Flexbox>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
RemoteAgentConfigCard.displayName = 'RemoteAgentConfigCard';
|
||||
|
||||
export default RemoteAgentConfigCard;
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { isDesktop } from '@lobechat/const';
|
||||
import { isRemoteHeterogeneousType } from '@lobechat/heterogeneous-agents';
|
||||
import { Flexbox } from '@lobehub/ui';
|
||||
import { Divider, Tabs } from 'antd';
|
||||
import isEqual from 'fast-deep-equal';
|
||||
@@ -17,6 +18,7 @@ import AgentHeader from './AgentHeader';
|
||||
import AgentTool from './AgentTool';
|
||||
import CloudHeterogeneousConfig from './CloudHeterogeneousConfig';
|
||||
import HeterogeneousAgentStatusCard from './HeterogeneousAgentStatusCard';
|
||||
import RemoteAgentConfigCard from './RemoteAgentConfigCard';
|
||||
|
||||
const ProfileEditor = memo(() => {
|
||||
const { t } = useTranslation('setting');
|
||||
@@ -43,6 +45,15 @@ const ProfileEditor = memo(() => {
|
||||
});
|
||||
};
|
||||
|
||||
const updateBoundDeviceId = async (boundDeviceId: string) => {
|
||||
await updateConfig({ agencyConfig: { ...config.agencyConfig, boundDeviceId } });
|
||||
};
|
||||
|
||||
const isRemoteHetero =
|
||||
isHeterogeneous &&
|
||||
!!heterogeneousProvider &&
|
||||
isRemoteHeterogeneousType(heterogeneousProvider.type);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flexbox
|
||||
@@ -53,8 +64,16 @@ const ProfileEditor = memo(() => {
|
||||
>
|
||||
{/* Header: Avatar + Name + Description */}
|
||||
<AgentHeader />
|
||||
{isHeterogeneous && heterogeneousProvider ? (
|
||||
// Heterogeneous integration mode: tabs for cloud (web) and desktop environments
|
||||
{isRemoteHetero && heterogeneousProvider ? (
|
||||
// Remote platform agents (openclaw / hermes): show device config panel
|
||||
<Flexbox paddingBlock={'8px 0'}>
|
||||
<RemoteAgentConfigCard
|
||||
provider={heterogeneousProvider}
|
||||
onBoundDeviceChange={updateBoundDeviceId}
|
||||
/>
|
||||
</Flexbox>
|
||||
) : isHeterogeneous && heterogeneousProvider ? (
|
||||
// Local CLI agents (claude-code, codex): tabs for cloud (web) and desktop environments
|
||||
<Tabs
|
||||
defaultActiveKey={isDesktop ? 'desktop' : 'cloud'}
|
||||
size="small"
|
||||
|
||||
@@ -48,6 +48,7 @@ const CreateAgentButton = memo<CreateAgentButtonProps>(({ groupId, className })
|
||||
createAgentMenuItem,
|
||||
createGroupChatMenuItem,
|
||||
createHeterogeneousAgentMenuItems,
|
||||
createPlatformAgentMenuItem,
|
||||
isMutatingAgent,
|
||||
openCreateModal,
|
||||
} = useCreateMenuItems();
|
||||
@@ -60,15 +61,18 @@ const CreateAgentButton = memo<CreateAgentButtonProps>(({ groupId, className })
|
||||
|
||||
const dropdownItems = useMemo(() => {
|
||||
const heteroItems = createHeterogeneousAgentMenuItems(menuOptions);
|
||||
const platformItem = createPlatformAgentMenuItem(menuOptions);
|
||||
return [
|
||||
createAgentMenuItem(menuOptions),
|
||||
createGroupChatMenuItem(menuOptions),
|
||||
...(heteroItems.length > 0 ? [{ type: 'divider' as const }, ...heteroItems] : []),
|
||||
...(platformItem ? [{ type: 'divider' as const }, platformItem] : []),
|
||||
];
|
||||
}, [
|
||||
createAgentMenuItem,
|
||||
createGroupChatMenuItem,
|
||||
createHeterogeneousAgentMenuItems,
|
||||
createPlatformAgentMenuItem,
|
||||
menuOptions,
|
||||
]);
|
||||
|
||||
|
||||
@@ -24,6 +24,10 @@ vi.mock('@/components/MemberSelectionModal', () => ({
|
||||
MemberSelectionModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@/features/CreatePlatformAgent', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@/features/EditingPopover', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { ChatGroupWizard } from '@/components/ChatGroupWizard';
|
||||
import { MemberSelectionModal } from '@/components/MemberSelectionModal';
|
||||
import CreatePlatformAgentModal from '@/features/CreatePlatformAgent';
|
||||
import EditingPopover from '@/features/EditingPopover';
|
||||
import { CreateAgentModal } from '@/routes/(main)/home/_layout/hooks/useCreateModal';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
@@ -24,11 +25,13 @@ interface AgentModalContextValue {
|
||||
closeAllModals: () => void;
|
||||
closeConfigGroupModal: () => void;
|
||||
closeCreateGroupModal: () => void;
|
||||
closeCreatePlatformAgentModal: () => void;
|
||||
closeGroupWizardModal: () => void;
|
||||
closeMemberSelectionModal: () => void;
|
||||
openConfigGroupModal: () => void;
|
||||
openCreateGroupModal: (sessionId: string) => void;
|
||||
openCreateModal: (type: 'agent' | 'group', options?: OpenCreateModalOptions) => void;
|
||||
openCreatePlatformAgentModal: (options?: OpenCreateModalOptions) => void;
|
||||
openGroupWizardModal: (callbacks: GroupWizardCallbacks) => void;
|
||||
openMemberSelectionModal: (callbacks: MemberSelectionCallbacks) => void;
|
||||
setGroupWizardLoading: (loading: boolean) => void;
|
||||
@@ -135,6 +138,12 @@ export const AgentModalProvider = memo<AgentModalProviderProps>(({ children }) =
|
||||
const [createModalType, setCreateModalType] = useState<'agent' | 'group'>('agent');
|
||||
const [createModalGroupId, setCreateModalGroupId] = useState<string | undefined>(undefined);
|
||||
|
||||
// CreatePlatformAgentModal state
|
||||
const [createPlatformAgentOpen, setCreatePlatformAgentOpen] = useState(false);
|
||||
const [createPlatformAgentGroupId, setCreatePlatformAgentGroupId] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const contextValue = useMemo<AgentModalContextValue>(
|
||||
() => ({
|
||||
closeAllModals: () => {
|
||||
@@ -143,9 +152,11 @@ export const AgentModalProvider = memo<AgentModalProviderProps>(({ children }) =
|
||||
setGroupWizardOpen(false);
|
||||
setMemberSelectionOpen(false);
|
||||
setCreateModalOpen(false);
|
||||
setCreatePlatformAgentOpen(false);
|
||||
},
|
||||
closeConfigGroupModal: () => setConfigGroupModalOpen(false),
|
||||
closeCreateGroupModal: () => setCreateGroupModalOpen(false),
|
||||
closeCreatePlatformAgentModal: () => setCreatePlatformAgentOpen(false),
|
||||
closeGroupWizardModal: () => setGroupWizardOpen(false),
|
||||
closeMemberSelectionModal: () => setMemberSelectionOpen(false),
|
||||
openConfigGroupModal: () => setConfigGroupModalOpen(true),
|
||||
@@ -158,6 +169,10 @@ export const AgentModalProvider = memo<AgentModalProviderProps>(({ children }) =
|
||||
setCreateModalGroupId(options?.groupId);
|
||||
setCreateModalOpen(true);
|
||||
},
|
||||
openCreatePlatformAgentModal: (options?: OpenCreateModalOptions) => {
|
||||
setCreatePlatformAgentGroupId(options?.groupId);
|
||||
setCreatePlatformAgentOpen(true);
|
||||
},
|
||||
openGroupWizardModal: (callbacks: GroupWizardCallbacks) => {
|
||||
setGroupWizardCallbacks(callbacks);
|
||||
setGroupWizardOpen(true);
|
||||
@@ -179,6 +194,11 @@ export const AgentModalProvider = memo<AgentModalProviderProps>(({ children }) =
|
||||
type={createModalType}
|
||||
onClose={() => setCreateModalOpen(false)}
|
||||
/>
|
||||
<CreatePlatformAgentModal
|
||||
groupId={createPlatformAgentGroupId}
|
||||
open={createPlatformAgentOpen}
|
||||
onClose={() => setCreatePlatformAgentOpen(false)}
|
||||
/>
|
||||
{children}
|
||||
|
||||
{/* All modals rendered at top level */}
|
||||
|
||||
@@ -29,11 +29,13 @@ const Agent = memo<AgentProps>(({ itemKey }) => {
|
||||
createAgentMenuItem,
|
||||
createGroupChatMenuItem,
|
||||
createHeterogeneousAgentMenuItems,
|
||||
createPlatformAgentMenuItem,
|
||||
isLoading,
|
||||
} = useCreateMenuItems();
|
||||
|
||||
const addMenuItems = useMemo(() => {
|
||||
const heterogeneousItems = createHeterogeneousAgentMenuItems();
|
||||
const platformItem = createPlatformAgentMenuItem();
|
||||
|
||||
return [
|
||||
createAgentMenuItem(),
|
||||
@@ -41,8 +43,14 @@ const Agent = memo<AgentProps>(({ itemKey }) => {
|
||||
...(heterogeneousItems.length > 0
|
||||
? [{ type: 'divider' as const }, ...heterogeneousItems]
|
||||
: []),
|
||||
...(platformItem ? [{ type: 'divider' as const }, platformItem] : []),
|
||||
];
|
||||
}, [createAgentMenuItem, createGroupChatMenuItem, createHeterogeneousAgentMenuItems]);
|
||||
}, [
|
||||
createAgentMenuItem,
|
||||
createGroupChatMenuItem,
|
||||
createHeterogeneousAgentMenuItems,
|
||||
createPlatformAgentMenuItem,
|
||||
]);
|
||||
|
||||
const handleOpenConfigGroupModal = useCallback(() => {
|
||||
openConfigGroupModal();
|
||||
|
||||
@@ -18,6 +18,7 @@ const AddButton = memo(() => {
|
||||
createGroupChatMenuItem,
|
||||
createHeterogeneousAgentMenuItems,
|
||||
createPageMenuItem,
|
||||
createPlatformAgentMenuItem,
|
||||
openCreateModal,
|
||||
isMutatingAgent,
|
||||
isCreatingGroup,
|
||||
@@ -34,6 +35,7 @@ const AddButton = memo(() => {
|
||||
|
||||
const dropdownItems = useMemo(() => {
|
||||
const heterogeneousItems = createHeterogeneousAgentMenuItems();
|
||||
const platformItem = createPlatformAgentMenuItem();
|
||||
|
||||
return [
|
||||
createAgentMenuItem(),
|
||||
@@ -42,12 +44,14 @@ const AddButton = memo(() => {
|
||||
...(heterogeneousItems.length > 0
|
||||
? [{ type: 'divider' as const }, ...heterogeneousItems]
|
||||
: []),
|
||||
...(platformItem ? [{ type: 'divider' as const }, platformItem] : []),
|
||||
];
|
||||
}, [
|
||||
createAgentMenuItem,
|
||||
createGroupChatMenuItem,
|
||||
createHeterogeneousAgentMenuItems,
|
||||
createPageMenuItem,
|
||||
createPlatformAgentMenuItem,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -124,6 +124,17 @@ vi.mock('@/store/page', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/user', () => ({
|
||||
useUserStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector({ preference: { lab: {} } }),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/user/selectors', () => ({
|
||||
labPreferSelectors: {
|
||||
enablePlatformAgent: () => false,
|
||||
},
|
||||
}));
|
||||
|
||||
const isActionItem = (
|
||||
item: unknown,
|
||||
): item is {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Icon } from '@lobehub/ui';
|
||||
import { GroupBotSquareIcon } from '@lobehub/ui/icons';
|
||||
import { App } from 'antd';
|
||||
import type { ItemType } from 'antd/es/menu/interface';
|
||||
import { BotIcon, FileTextIcon, FolderCogIcon, FolderPlus } from 'lucide-react';
|
||||
import { BotIcon, FileTextIcon, FolderCogIcon, FolderPlus, MonitorSmartphone } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -21,6 +21,8 @@ import { useAgentStore } from '@/store/agent';
|
||||
import { useAgentGroupStore } from '@/store/agentGroup';
|
||||
import { useHomeStore } from '@/store/home';
|
||||
import { usePageStore } from '@/store/page';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { labPreferSelectors } from '@/store/user/selectors';
|
||||
|
||||
interface CreateAgentOptions {
|
||||
groupId?: string;
|
||||
@@ -198,6 +200,7 @@ export const useCreateMenuItems = () => {
|
||||
|
||||
const agentModal = useOptionalAgentModal();
|
||||
const openCreateModal = agentModal?.openCreateModal;
|
||||
const enablePlatformAgent = useUserStore(labPreferSelectors.enablePlatformAgent);
|
||||
|
||||
/**
|
||||
* Create agent menu item
|
||||
@@ -243,6 +246,28 @@ export const useCreateMenuItems = () => {
|
||||
[t, createHeterogeneousAgent],
|
||||
);
|
||||
|
||||
/**
|
||||
* Create platform agent menu item (openclaw / hermes — remote device agents)
|
||||
* Opens the 3-step creation modal
|
||||
*/
|
||||
const createPlatformAgentMenuItem = useCallback(
|
||||
(options?: CreateAgentOptions): ItemType => {
|
||||
if (!enablePlatformAgent) return null;
|
||||
return {
|
||||
icon: <Icon icon={MonitorSmartphone} />,
|
||||
key: 'newPlatformAgent',
|
||||
label: t('newPlatformAgent'),
|
||||
onClick: (info) => {
|
||||
info.domEvent?.stopPropagation();
|
||||
agentModal?.openCreatePlatformAgentModal(
|
||||
options?.groupId ? { groupId: options.groupId } : undefined,
|
||||
);
|
||||
},
|
||||
};
|
||||
},
|
||||
[t, agentModal, enablePlatformAgent],
|
||||
);
|
||||
|
||||
/**
|
||||
* Create group chat menu item
|
||||
* Creates an empty group and navigates to its profile page
|
||||
@@ -340,6 +365,7 @@ export const useCreateMenuItems = () => {
|
||||
createGroupWithMembers,
|
||||
createPage,
|
||||
createPageMenuItem,
|
||||
createPlatformAgentMenuItem,
|
||||
createSessionGroupMenuItem,
|
||||
openCreateModal,
|
||||
|
||||
|
||||
@@ -35,14 +35,14 @@ const Page = memo(() => {
|
||||
const [setSettings, isUserStateInit] = useUserStore((s) => [s.setSettings, s.isUserStateInit]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [isPreferenceInit, enableInputMarkdown, enableGatewayMode, updateLab] = useUserStore(
|
||||
(s) => [
|
||||
const [isPreferenceInit, enableInputMarkdown, enableGatewayMode, enablePlatformAgent, updateLab] =
|
||||
useUserStore((s) => [
|
||||
preferenceSelectors.isPreferenceInit(s),
|
||||
labPreferSelectors.enableInputMarkdown(s),
|
||||
labPreferSelectors.enableGatewayMode(s),
|
||||
labPreferSelectors.enablePlatformAgent(s),
|
||||
s.updateLab,
|
||||
],
|
||||
);
|
||||
]);
|
||||
|
||||
const hasGatewayUrl = useServerConfigStore((s) => !!s.serverConfig.agentGatewayUrl);
|
||||
|
||||
@@ -125,6 +125,19 @@ const Page = memo(() => {
|
||||
label: tLabs('features.gatewayMode.title'),
|
||||
minWidth: undefined,
|
||||
} satisfies FormItemProps,
|
||||
{
|
||||
children: (
|
||||
<Switch
|
||||
checked={enablePlatformAgent}
|
||||
loading={!isPreferenceInit}
|
||||
onChange={(checked: boolean) => updateLab({ enablePlatformAgent: checked })}
|
||||
/>
|
||||
),
|
||||
className: styles.labItem,
|
||||
desc: tLabs('features.platformAgent.desc'),
|
||||
label: tLabs('features.platformAgent.title'),
|
||||
minWidth: undefined,
|
||||
} satisfies FormItemProps,
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
@@ -7,8 +7,16 @@ import { MessageModel } from '@/database/models/message';
|
||||
import { TopicModel } from '@/database/models/topic';
|
||||
import { authedProcedure, router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import { createStreamEventManager } from '@/server/modules/AgentRuntime/factory';
|
||||
import { AiAgentService } from '@/server/services/aiAgent';
|
||||
|
||||
// Module-level singleton so we don't create a new Redis connection per request.
|
||||
let _streamManager: ReturnType<typeof createStreamEventManager> | undefined;
|
||||
const getStreamManager = () => {
|
||||
if (!_streamManager) _streamManager = createStreamEventManager();
|
||||
return _streamManager;
|
||||
};
|
||||
|
||||
const log = debug('lobe-server:agent-notify-router');
|
||||
|
||||
const agentNotifyProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
|
||||
@@ -33,6 +41,13 @@ const NotifySchema = z.object({
|
||||
* the assistant message. Defaults to false.
|
||||
*/
|
||||
continue: z.boolean().optional(),
|
||||
/**
|
||||
* Signal that the remote hetero agent (openclaw / hermes) has finished its
|
||||
* task. When true, the server publishes `agent_runtime_end` to the stream event
|
||||
* manager so the frontend's gateway WS subscription closes cleanly.
|
||||
* Can be combined with content (final message + done) or sent alone (just done).
|
||||
*/
|
||||
done: z.boolean().optional(),
|
||||
/**
|
||||
* When role is 'assistant': update an existing message instead of creating a
|
||||
* new one. The caller is responsible for passing the messageId returned by the
|
||||
@@ -69,20 +84,22 @@ export const agentNotifyRouter = router({
|
||||
threadId,
|
||||
role = 'user',
|
||||
continue: shouldContinue = false,
|
||||
done = false,
|
||||
messageId,
|
||||
} = input;
|
||||
|
||||
log(
|
||||
'notify: topicId=%s, agentId=%s, role=%s, continue=%s, messageId=%s, content=%s',
|
||||
'notify: topicId=%s, agentId=%s, role=%s, continue=%s, done=%s, messageId=%s, content=%s',
|
||||
topicId,
|
||||
inputAgentId,
|
||||
role,
|
||||
shouldContinue,
|
||||
done,
|
||||
messageId,
|
||||
content.slice(0, 80),
|
||||
);
|
||||
|
||||
// 1. Verify the topic exists and get its agentId
|
||||
// 1. Verify the topic exists and get its agentId + running operationId
|
||||
const topic = await ctx.topicModel.findById(topicId);
|
||||
if (!topic) {
|
||||
throw new TRPCError({
|
||||
@@ -91,6 +108,12 @@ export const agentNotifyRouter = router({
|
||||
});
|
||||
}
|
||||
|
||||
// Extract the operationId seeded by execAgent for remote hetero agents.
|
||||
// Used to publish notify_update / agent_runtime_end events to the gateway WS.
|
||||
const remoteOperationId = (topic.metadata as any)?.runningOperation?.operationId as
|
||||
| string
|
||||
| undefined;
|
||||
|
||||
const agentId = inputAgentId ?? topic.agentId;
|
||||
if (!agentId) {
|
||||
throw new TRPCError({
|
||||
@@ -99,24 +122,89 @@ export const agentNotifyRouter = router({
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a stream event for remote hetero agents (openclaw / hermes).
|
||||
* Fire-and-forget — stream publish failures must not break the notify response.
|
||||
*/
|
||||
const publishRemoteHeteroEvent = async (writtenMessageId?: string) => {
|
||||
if (!remoteOperationId) return;
|
||||
try {
|
||||
const stream = getStreamManager();
|
||||
if (done) {
|
||||
// Signal task completion — frontend gateway WS subscription closes.
|
||||
await stream.publishAgentRuntimeEnd(
|
||||
remoteOperationId,
|
||||
0,
|
||||
{ reason: 'success' },
|
||||
'success',
|
||||
'Remote hetero agent task completed',
|
||||
);
|
||||
} else {
|
||||
// Lightweight invalidation — frontend calls fetchAndReplaceMessages.
|
||||
await stream.publishStreamEvent(remoteOperationId, {
|
||||
data: { messageId: writtenMessageId },
|
||||
stepIndex: 0,
|
||||
type: 'notify_update',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
log(
|
||||
'notify: failed to publish stream event for operationId=%s: %O',
|
||||
remoteOperationId,
|
||||
err,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 2a. Assistant mode: write message directly without triggering LLM
|
||||
if (role === 'assistant') {
|
||||
try {
|
||||
// Update existing message if messageId provided (single-bubble progress updates)
|
||||
if (messageId) {
|
||||
await ctx.messageModel.update(messageId, { content });
|
||||
// Resolve the target message ID:
|
||||
// 1. Caller-supplied messageId (subsequent notify calls with --message-id)
|
||||
// 2. Placeholder assistantMessageId seeded by execAgent (first notify call for remote hetero)
|
||||
// Using the placeholder avoids creating a second empty bubble in the UI.
|
||||
const placeholderMessageId = (topic.metadata as any)?.runningOperation
|
||||
?.assistantMessageId as string | undefined;
|
||||
const resolvedMessageId = messageId ?? placeholderMessageId;
|
||||
|
||||
// Update existing message if we have a resolved target
|
||||
if (resolvedMessageId) {
|
||||
// Security: verify the message belongs to this topic before writing.
|
||||
// MessageModel.update scopes only by userId; without this check, a remote
|
||||
// runtime could overwrite messages from other conversations.
|
||||
const existingMsg = await ctx.messageModel.findById(resolvedMessageId);
|
||||
if (!existingMsg || existingMsg.topicId !== topicId) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Message does not belong to this topic',
|
||||
});
|
||||
}
|
||||
|
||||
// done=true with empty content + existing placeholder → just signal completion, no update.
|
||||
if (done && !content) {
|
||||
void publishRemoteHeteroEvent();
|
||||
return { messageId: resolvedMessageId, operationId: undefined, topicId };
|
||||
}
|
||||
await ctx.messageModel.update(resolvedMessageId, { content });
|
||||
void publishRemoteHeteroEvent(resolvedMessageId);
|
||||
if (shouldContinue) {
|
||||
const result = await ctx.aiAgentService.execAgent({
|
||||
agentId,
|
||||
appContext: { threadId, topicId },
|
||||
parentMessageId: messageId,
|
||||
parentMessageId: resolvedMessageId,
|
||||
prompt: '',
|
||||
resume: true,
|
||||
trigger: RequestTrigger.Notify,
|
||||
});
|
||||
return { messageId, operationId: result.operationId, topicId };
|
||||
return { messageId: resolvedMessageId, operationId: result.operationId, topicId };
|
||||
}
|
||||
return { messageId, operationId: undefined, topicId };
|
||||
return { messageId: resolvedMessageId, operationId: undefined, topicId };
|
||||
}
|
||||
|
||||
// done=true with no messageId and empty content → just signal completion, no DB write.
|
||||
if (done && !content) {
|
||||
void publishRemoteHeteroEvent();
|
||||
return { messageId: undefined, operationId: undefined, topicId };
|
||||
}
|
||||
|
||||
const msg = await ctx.messageModel.create({
|
||||
@@ -127,6 +215,8 @@ export const agentNotifyRouter = router({
|
||||
topicId,
|
||||
});
|
||||
|
||||
void publishRemoteHeteroEvent(msg.id);
|
||||
|
||||
// Optionally trigger a follow-up agent turn.
|
||||
// Use resume=true + parentMessageId so execAgent skips creating an
|
||||
// empty user message (effectiveResume=true bypasses that branch).
|
||||
|
||||
@@ -325,6 +325,12 @@ const InterruptTaskSchema = z
|
||||
operationId: z.string().optional(),
|
||||
/** Thread ID */
|
||||
threadId: z.string().optional(),
|
||||
/**
|
||||
* Topic ID — required to cancel remote hetero tasks (openclaw / hermes).
|
||||
* When provided and the topic's runningOperation has a deviceId, the server
|
||||
* will dispatch a cancelHeteroTask tool call to kill the remote process.
|
||||
*/
|
||||
topicId: z.string().optional(),
|
||||
})
|
||||
.refine((data) => data.threadId || data.operationId, {
|
||||
message: 'Either threadId or operationId must be provided',
|
||||
@@ -357,6 +363,7 @@ const AgentStreamEventSchema = z.object({
|
||||
'agent_intervention_response',
|
||||
'step_start',
|
||||
'step_complete',
|
||||
'notify_update',
|
||||
'error',
|
||||
]),
|
||||
});
|
||||
@@ -1131,12 +1138,12 @@ export const aiAgentRouter = router({
|
||||
* It updates both operation status and Thread status to cancelled state.
|
||||
*/
|
||||
interruptTask: aiAgentProcedure.input(InterruptTaskSchema).mutation(async ({ input, ctx }) => {
|
||||
const { threadId, operationId } = input;
|
||||
const { threadId, operationId, topicId } = input;
|
||||
|
||||
log('interruptTask: threadId=%s, operationId=%s', threadId, operationId);
|
||||
log('interruptTask: threadId=%s, operationId=%s, topicId=%s', threadId, operationId, topicId);
|
||||
|
||||
try {
|
||||
return await ctx.aiAgentService.interruptTask({ operationId, threadId });
|
||||
return await ctx.aiAgentService.interruptTask({ operationId, threadId, topicId });
|
||||
} catch (error: any) {
|
||||
if (error.message === 'Thread not found') {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Thread not found' });
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import { REMOTE_HETEROGENEOUS_AGENT_CONFIGS } from '@lobechat/heterogeneous-agents';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { authedProcedure, router } from '@/libs/trpc/lambda';
|
||||
import { deviceProxy } from '@/server/services/toolExecution/deviceProxy';
|
||||
|
||||
// Derive the zod enum from the canonical config so new platforms are
|
||||
// automatically covered without touching this file.
|
||||
const remotePlatformEnum = z.enum(
|
||||
REMOTE_HETEROGENEOUS_AGENT_CONFIGS.map((c) => c.type) as [
|
||||
(typeof REMOTE_HETEROGENEOUS_AGENT_CONFIGS)[number]['type'],
|
||||
...(typeof REMOTE_HETEROGENEOUS_AGENT_CONFIGS)[number]['type'][],
|
||||
],
|
||||
);
|
||||
|
||||
const CAPABILITY_TIMEOUT_MS = 5_000;
|
||||
const PROFILE_TIMEOUT_MS = 5_000;
|
||||
|
||||
const deviceProcedure = authedProcedure.use(async (opts) => {
|
||||
const { ctx } = opts;
|
||||
|
||||
@@ -12,6 +25,80 @@ const deviceProcedure = authedProcedure.use(async (opts) => {
|
||||
});
|
||||
|
||||
export const deviceRouter = router({
|
||||
/**
|
||||
* Probe whether a specific agent platform (openclaw / hermes) is available
|
||||
* on the given device. Dispatches a `checkPlatformCapability` tool call to
|
||||
* the device via the gateway and waits up to 5 s for a response.
|
||||
*/
|
||||
checkCapability: deviceProcedure
|
||||
.input(
|
||||
z.object({
|
||||
deviceId: z.string(),
|
||||
platform: remotePlatformEnum,
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const result = await deviceProxy.executeToolCall(
|
||||
{ deviceId: input.deviceId, userId: ctx.userId },
|
||||
{
|
||||
apiName: 'checkPlatformCapability',
|
||||
arguments: JSON.stringify({ platform: input.platform }),
|
||||
identifier: 'local',
|
||||
},
|
||||
CAPABILITY_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return { available: false, reason: result.error ?? 'Device tool call failed' };
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(result.content) as {
|
||||
available: boolean;
|
||||
reason?: string;
|
||||
version?: string;
|
||||
};
|
||||
} catch {
|
||||
return { available: false, reason: 'Invalid response from device' };
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Fetch the agent profile (title, description, avatar) from the platform
|
||||
* installed on the given device. Used to pre-fill the creation modal.
|
||||
* Returns an empty object on failure or when the platform has no profile.
|
||||
*/
|
||||
getAgentProfile: deviceProcedure
|
||||
.input(
|
||||
z.object({
|
||||
deviceId: z.string(),
|
||||
platform: remotePlatformEnum,
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const result = await deviceProxy.executeToolCall(
|
||||
{ deviceId: input.deviceId, userId: ctx.userId },
|
||||
{
|
||||
apiName: 'getAgentProfile',
|
||||
arguments: JSON.stringify({ platform: input.platform }),
|
||||
identifier: 'local',
|
||||
},
|
||||
PROFILE_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
if (!result.success) return {};
|
||||
|
||||
try {
|
||||
return JSON.parse(result.content) as {
|
||||
avatar?: string;
|
||||
description?: string;
|
||||
title?: string;
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}),
|
||||
|
||||
getDeviceSystemInfo: deviceProcedure
|
||||
.input(z.object({ deviceId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
} from '@lobechat/context-engine';
|
||||
import { SkillEngine } from '@lobechat/context-engine';
|
||||
import type { LobeChatDatabase } from '@lobechat/database';
|
||||
import { isRemoteHeterogeneousType } from '@lobechat/heterogeneous-agents';
|
||||
import { buildTaskManagerDefaultsPrompt } from '@lobechat/prompts';
|
||||
import type {
|
||||
ChatFileItem,
|
||||
@@ -650,10 +651,11 @@ export class AiAgentService {
|
||||
const model = agentConfig.model!;
|
||||
const provider = agentConfig.provider!;
|
||||
|
||||
// 3.5. Hetero-agent early exit — Claude Code / Codex agents bypass the
|
||||
// 3.5. Hetero-agent early exit — Claude Code / Codex / OpenClaw / Hermes agents bypass the
|
||||
// server-side LLM pipeline. After topic + message creation we hand off to
|
||||
// the device gateway (desktop) or cloud sandbox, which will push events
|
||||
// back via `heteroIngest` / `heteroFinish`.
|
||||
// back via `heteroIngest` / `heteroFinish` (claude-code / codex) or
|
||||
// `agentNotify.notify` (openclaw / hermes).
|
||||
//
|
||||
// Detection: prefer agencyConfig.heterogeneousProvider.type (set by the UI),
|
||||
// fall back to model field for backwards compatibility.
|
||||
@@ -661,7 +663,12 @@ export class AiAgentService {
|
||||
const heteroProviderType = agentConfig.agencyConfig?.heterogeneousProvider?.type;
|
||||
const isHeteroAgent = !!heteroProviderType || HETERO_AGENT_MODELS.has(model);
|
||||
if (isHeteroAgent) {
|
||||
const heteroType = (heteroProviderType ?? model) as 'claude-code' | 'codex';
|
||||
const heteroType = (heteroProviderType ?? model) as
|
||||
| 'claude-code'
|
||||
| 'codex'
|
||||
| 'hermes'
|
||||
| 'openclaw';
|
||||
const isRemoteHetero = isRemoteHeterogeneousType(heteroType);
|
||||
const operationId = nanoid();
|
||||
|
||||
// Create user message so the conversation is visible in the UI immediately.
|
||||
@@ -676,12 +683,14 @@ export class AiAgentService {
|
||||
});
|
||||
|
||||
// Create an assistant message placeholder (shows spinner in the UI).
|
||||
// For remote hetero agents (openclaw/hermes), override provider with the hetero type
|
||||
// so the frontend can identify the platform and display the correct name in the model tag.
|
||||
const assistantMsg = await this.messageModel.create({
|
||||
agentId: resolvedAgentId,
|
||||
content: LOADING_FLAT,
|
||||
model,
|
||||
parentId: parentMessageId ?? userMsg?.id,
|
||||
provider,
|
||||
provider: isRemoteHetero ? heteroType : provider,
|
||||
role: 'assistant',
|
||||
threadId: appContext?.threadId ?? undefined,
|
||||
topicId,
|
||||
@@ -746,6 +755,9 @@ export class AiAgentService {
|
||||
userId: this.userId,
|
||||
};
|
||||
|
||||
const remoteDeviceId =
|
||||
requestedDeviceId || agentConfig.agencyConfig?.boundDeviceId || undefined;
|
||||
|
||||
// Seed topic.metadata.runningOperation so heteroIngest can validate the operation.
|
||||
// completionWebhook is stored so heteroFinish can call back to the IM bot-callback
|
||||
// endpoint even though the hetero path bypasses the normal hook registration flow.
|
||||
@@ -753,14 +765,111 @@ export class AiAgentService {
|
||||
runningOperation: {
|
||||
assistantMessageId: assistantMsg.id,
|
||||
completionWebhook: hooks?.find((h) => h.type === 'onComplete')?.webhook,
|
||||
// Store deviceId + heteroType so interruptTask can cancel remote processes
|
||||
...(isRemoteHetero && remoteDeviceId
|
||||
? { deviceId: remoteDeviceId, heteroType }
|
||||
: undefined),
|
||||
operationId,
|
||||
scope: appContext?.scope ?? undefined,
|
||||
threadId: appContext?.threadId ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (requestedDeviceId) {
|
||||
// Dispatch to the user's connected desktop via device-gateway.
|
||||
// Remote hetero agents (openclaw / hermes) dispatch to the device identified
|
||||
// by agencyConfig.boundDeviceId and communicate back via agentNotify.notify.
|
||||
// They always go through the gateway WS channel — open the stream now so the
|
||||
// frontend can subscribe before the first lh notify arrives.
|
||||
|
||||
if (isRemoteHetero) {
|
||||
if (!remoteDeviceId) {
|
||||
log('execAgent: openclaw/hermes requires a bound device (boundDeviceId not set)');
|
||||
await this.messageModel.update(assistantMsg.id, {
|
||||
content: '',
|
||||
error: {
|
||||
body: { detail: 'No device bound to this agent. Configure boundDeviceId.' },
|
||||
message: 'No bound device for remote hetero agent',
|
||||
type: 'ServerAgentRuntimeError',
|
||||
},
|
||||
});
|
||||
return {
|
||||
agentId: resolvedAgentId,
|
||||
assistantMessageId: assistantMsg.id,
|
||||
autoStarted: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
error: 'No bound device',
|
||||
message: 'Remote hetero agent requires boundDeviceId',
|
||||
operationId,
|
||||
status: 'error',
|
||||
success: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
topicId,
|
||||
userMessageId: userMsg?.id ?? parentMessageId ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
// Open the stream channel so the gateway WS subscription can receive
|
||||
// notify_update events published by agentNotify.notify.
|
||||
const { createStreamEventManager } = await import('@/server/modules/AgentRuntime/factory');
|
||||
const streamManager = createStreamEventManager();
|
||||
await streamManager
|
||||
.publishAgentRuntimeInit(operationId, {
|
||||
agentId: resolvedAgentId,
|
||||
assistantMessageId: assistantMsg.id,
|
||||
heteroType,
|
||||
topicId,
|
||||
userId: this.userId,
|
||||
})
|
||||
.catch((err) => log('execAgent: failed to init stream for remote hetero: %O', err));
|
||||
|
||||
// lh connect only handles tool_call_request (not agent_run_request),
|
||||
// so we use executeToolCall with the runHeteroTask tool instead of dispatchAgentRun.
|
||||
const result = await deviceProxy.executeToolCall(
|
||||
{ deviceId: remoteDeviceId, userId: this.userId },
|
||||
{
|
||||
apiName: 'runHeteroTask',
|
||||
arguments: JSON.stringify({
|
||||
agentId: resolvedAgentId,
|
||||
agentType: heteroType,
|
||||
cwd: undefined,
|
||||
operationId,
|
||||
prompt,
|
||||
taskId: operationId,
|
||||
topicId,
|
||||
}),
|
||||
identifier: 'runHeteroTask',
|
||||
},
|
||||
120_000, // hetero tasks can take longer than the default 30 s
|
||||
);
|
||||
if (!result.success) {
|
||||
log('execAgent: remote hetero dispatch failed: %s', result.error);
|
||||
await streamManager
|
||||
.publishAgentRuntimeEnd(operationId, 0, { error: result.error }, 'error', result.error)
|
||||
.catch(() => {});
|
||||
await this.messageModel.update(assistantMsg.id, {
|
||||
content: '',
|
||||
error: {
|
||||
body: { detail: result.error },
|
||||
message: result.error ?? 'Device dispatch failed',
|
||||
type: 'ServerAgentRuntimeError',
|
||||
},
|
||||
});
|
||||
return {
|
||||
agentId: resolvedAgentId,
|
||||
assistantMessageId: assistantMsg.id,
|
||||
autoStarted: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
error: result.error,
|
||||
message: 'Remote hetero agent dispatch failed',
|
||||
operationId,
|
||||
status: 'error',
|
||||
success: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
topicId,
|
||||
userMessageId: userMsg?.id ?? parentMessageId ?? '',
|
||||
};
|
||||
}
|
||||
} else if (requestedDeviceId) {
|
||||
// Local CLI (claude-code / codex) — dispatch to user's connected desktop.
|
||||
const result = await deviceProxy.dispatchAgentRun({
|
||||
...heteroParams,
|
||||
deviceId: requestedDeviceId,
|
||||
@@ -791,10 +900,15 @@ export class AiAgentService {
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Cloud sandbox path — fire-and-forget; errors surfaced via heteroFinish.
|
||||
// Cloud sandbox path — only for local CLI agents (claude-code / codex).
|
||||
// Remote agents (openclaw / hermes) always require a bound device.
|
||||
const { spawnHeteroSandbox } =
|
||||
await import('@/server/services/heterogeneousAgent/sandboxRunner');
|
||||
spawnHeteroSandbox({ ...heteroParams, marketService: this.marketService }).catch((err) => {
|
||||
spawnHeteroSandbox({
|
||||
...heteroParams,
|
||||
agentType: heteroType as 'claude-code' | 'codex',
|
||||
marketService: this.marketService,
|
||||
}).catch((err) => {
|
||||
log('execAgent: hetero sandbox spawn failed: %O', err);
|
||||
});
|
||||
}
|
||||
@@ -2549,8 +2663,9 @@ export class AiAgentService {
|
||||
async interruptTask(params: {
|
||||
operationId?: string;
|
||||
threadId?: string;
|
||||
topicId?: string;
|
||||
}): Promise<{ operationId?: string; success: boolean; threadId?: string }> {
|
||||
const { threadId, operationId } = params;
|
||||
const { threadId, operationId, topicId } = params;
|
||||
|
||||
log('interruptTask: threadId=%s, operationId=%s', threadId, operationId);
|
||||
|
||||
@@ -2570,7 +2685,43 @@ export class AiAgentService {
|
||||
throw new Error('Operation ID not found');
|
||||
}
|
||||
|
||||
// 2. Interrupt the runtime operation first. Only mark the thread cancelled
|
||||
// 2. Cancel remote hetero process (openclaw / hermes) if applicable.
|
||||
// Check topic.metadata.runningOperation for device + heteroType info seeded by execAgent.
|
||||
// This runs regardless of whether interruptOperation succeeds — the remote process
|
||||
// is independent of the local operation registry.
|
||||
if (topicId) {
|
||||
const topic = await this.topicModel.findById(topicId);
|
||||
const runningOp = (topic?.metadata as any)?.runningOperation as
|
||||
| { deviceId?: string; heteroType?: string; operationId?: string }
|
||||
| undefined;
|
||||
|
||||
if (
|
||||
runningOp?.deviceId &&
|
||||
runningOp.heteroType &&
|
||||
isRemoteHeterogeneousType(runningOp.heteroType)
|
||||
) {
|
||||
const taskId = runningOp.operationId ?? resolvedOperationId;
|
||||
log(
|
||||
'interruptTask: cancelling remote hetero process heteroType=%s deviceId=%s taskId=%s',
|
||||
runningOp.heteroType,
|
||||
runningOp.deviceId,
|
||||
taskId,
|
||||
);
|
||||
await deviceProxy
|
||||
.executeToolCall(
|
||||
{ deviceId: runningOp.deviceId, userId: this.userId },
|
||||
{
|
||||
apiName: 'cancelHeteroTask',
|
||||
arguments: JSON.stringify({ signal: 'SIGINT', taskId }),
|
||||
identifier: 'cancelHeteroTask',
|
||||
},
|
||||
5_000,
|
||||
)
|
||||
.catch((err) => log('interruptTask: cancelHeteroTask dispatch failed: %O', err));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Interrupt the runtime operation first. Only mark the thread cancelled
|
||||
// after the runtime acknowledges the interrupt to avoid unlocking a live task.
|
||||
const interrupted = await this.agentRuntimeService.interruptOperation(resolvedOperationId);
|
||||
log(
|
||||
@@ -2589,7 +2740,7 @@ export class AiAgentService {
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Update Thread status to cancel
|
||||
// 4. Update Thread status to cancel
|
||||
if (thread) {
|
||||
await this.threadModel.update(thread.id, {
|
||||
metadata: {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type DeviceSystemInfo,
|
||||
GatewayHttpClient,
|
||||
} from '@lobechat/device-gateway-client';
|
||||
import type { HeterogeneousAgentType } from '@lobechat/heterogeneous-agents';
|
||||
import debug from 'debug';
|
||||
|
||||
import { gatewayEnv } from '@/envs/gateway';
|
||||
@@ -67,7 +68,7 @@ export class DeviceProxy {
|
||||
}
|
||||
|
||||
async dispatchAgentRun(params: {
|
||||
agentType: 'claude-code' | 'codex';
|
||||
agentType: HeterogeneousAgentType;
|
||||
cwd?: string;
|
||||
deviceId?: string;
|
||||
jwt: string;
|
||||
|
||||
@@ -77,6 +77,7 @@ export interface GetSubAgentTaskStatusParams {
|
||||
export interface InterruptTaskParams {
|
||||
operationId?: string;
|
||||
threadId?: string;
|
||||
topicId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,8 @@ import { describe, expect, it } from 'vitest';
|
||||
import { selectRuntimeType } from '../agentDispatcher';
|
||||
|
||||
const heteroProvider = { command: 'claude', type: 'claude-code' as const };
|
||||
const remoteHeteroProvider = { type: 'openclaw' as const };
|
||||
const remoteHeteroProviderHermes = { type: 'hermes' as const };
|
||||
|
||||
describe('selectRuntimeType', () => {
|
||||
describe('on web (isDesktop = false)', () => {
|
||||
@@ -16,7 +18,7 @@ describe('selectRuntimeType', () => {
|
||||
expect(selectRuntimeType({ isGatewayMode: true }, opts)).toBe('gateway');
|
||||
});
|
||||
|
||||
it('routes heterogeneousProvider to gateway on web — cloud sandbox is the only execution env', () => {
|
||||
it('routes local heterogeneousProvider to gateway on web — cloud sandbox is the only execution env', () => {
|
||||
expect(
|
||||
selectRuntimeType({ heterogeneousProvider: heteroProvider, isGatewayMode: true }, opts),
|
||||
).toBe('gateway');
|
||||
@@ -24,12 +26,27 @@ describe('selectRuntimeType', () => {
|
||||
selectRuntimeType({ heterogeneousProvider: heteroProvider, isGatewayMode: false }, opts),
|
||||
).toBe('gateway');
|
||||
});
|
||||
|
||||
it('routes remote platform agents (openclaw/hermes) to gateway on web', () => {
|
||||
expect(
|
||||
selectRuntimeType(
|
||||
{ heterogeneousProvider: remoteHeteroProvider, isGatewayMode: false },
|
||||
opts,
|
||||
),
|
||||
).toBe('gateway');
|
||||
expect(
|
||||
selectRuntimeType(
|
||||
{ heterogeneousProvider: remoteHeteroProviderHermes, isGatewayMode: false },
|
||||
opts,
|
||||
),
|
||||
).toBe('gateway');
|
||||
});
|
||||
});
|
||||
|
||||
describe('on desktop (isDesktop = true)', () => {
|
||||
const opts = { isDesktop: true };
|
||||
|
||||
it('returns hetero when a heterogeneousProvider is configured', () => {
|
||||
it('returns hetero for local CLI agents (claude-code, codex)', () => {
|
||||
expect(
|
||||
selectRuntimeType({ heterogeneousProvider: heteroProvider, isGatewayMode: true }, opts),
|
||||
).toBe('hetero');
|
||||
@@ -38,6 +55,22 @@ describe('selectRuntimeType', () => {
|
||||
).toBe('hetero');
|
||||
});
|
||||
|
||||
it('routes remote platform agents (openclaw/hermes) to gateway even on desktop', () => {
|
||||
// openclaw and hermes use device gateway, not desktop subprocess — must not go to hetero
|
||||
expect(
|
||||
selectRuntimeType(
|
||||
{ heterogeneousProvider: remoteHeteroProvider, isGatewayMode: false },
|
||||
opts,
|
||||
),
|
||||
).toBe('gateway');
|
||||
expect(
|
||||
selectRuntimeType(
|
||||
{ heterogeneousProvider: remoteHeteroProviderHermes, isGatewayMode: false },
|
||||
opts,
|
||||
),
|
||||
).toBe('gateway');
|
||||
});
|
||||
|
||||
it('falls back to gateway/client when no hetero provider', () => {
|
||||
expect(selectRuntimeType({ isGatewayMode: true }, opts)).toBe('gateway');
|
||||
expect(selectRuntimeType({ isGatewayMode: false }, opts)).toBe('client');
|
||||
|
||||
@@ -742,7 +742,10 @@ describe('GatewayActionImpl', () => {
|
||||
// ...and, when invoked, fires tRPC interruptTask with the *server-side* operation id
|
||||
const [, handler] = onOperationCancel.mock.calls[0];
|
||||
await handler();
|
||||
expect(interruptTaskSpy).toHaveBeenCalledWith({ operationId: 'server-op-xyz' });
|
||||
expect(interruptTaskSpy).toHaveBeenCalledWith({
|
||||
operationId: 'server-op-xyz',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isDesktop as defaultIsDesktop } from '@lobechat/const';
|
||||
import { isRemoteHeterogeneousType } from '@lobechat/heterogeneous-agents';
|
||||
import { type HeterogeneousProviderConfig } from '@lobechat/types';
|
||||
|
||||
/**
|
||||
@@ -10,6 +11,41 @@ import { type HeterogeneousProviderConfig } from '@lobechat/types';
|
||||
*/
|
||||
export type AgentRuntimeType = 'client' | 'gateway' | 'hetero';
|
||||
|
||||
/**
|
||||
* Unified intent for a non-hetero, non-group sub-agent invocation.
|
||||
*
|
||||
* All three caller patterns (`callSubAgent` / `callAgent` / `@agent`) map
|
||||
* their parameters into this shape before handing off to
|
||||
* `dispatchNonHeteroSubAgent`. Runtime routing is entirely the dispatcher's
|
||||
* responsibility — callers only declare *what* they want, not *how* to run it.
|
||||
*
|
||||
* Excluded from this contract:
|
||||
* - Hetero agents (handled by the heterogeneous pipeline)
|
||||
* - Group orchestration (handled by `groupOrchestration.triggerSpeak`)
|
||||
* - Async task mode (handled by the `execSubAgent` executor via state.type)
|
||||
*/
|
||||
export interface AgentInvocationIntent {
|
||||
/**
|
||||
* Instruction delivered to the sub-agent.
|
||||
* In client mode it is injected as a virtual user message prepended to the
|
||||
* existing message history. In gateway mode it becomes the `message` param
|
||||
* of `executeGatewayAgent` (i.e. a real user message on the server).
|
||||
*/
|
||||
instruction: string;
|
||||
/**
|
||||
* Which invocation pattern produced this intent.
|
||||
* Preserved for logging / debugging; has no effect on runtime selection.
|
||||
*/
|
||||
kind: 'callAgent' | 'callSubAgent' | 'mention';
|
||||
/**
|
||||
* ID of the tool result message that triggered this invocation.
|
||||
* Used as `parentMessageId` by the client executor.
|
||||
*/
|
||||
parentMessageId: string;
|
||||
/** Target agent to execute. */
|
||||
targetAgentId: string;
|
||||
}
|
||||
|
||||
export interface RuntimeSelectionContext {
|
||||
/** Per-agent heterogeneous provider config (desktop only — takes priority over gateway). */
|
||||
heterogeneousProvider?: HeterogeneousProviderConfig;
|
||||
@@ -45,9 +81,15 @@ export const selectRuntimeType = (
|
||||
{ isDesktop = defaultIsDesktop }: SelectRuntimeTypeOptions = {},
|
||||
): AgentRuntimeType => {
|
||||
if (ctx.parentRuntime) return ctx.parentRuntime;
|
||||
// Remote device agents (openclaw / hermes) always use the gateway path regardless of
|
||||
// desktop/web — they communicate via a device connected with `lh connect`, not via
|
||||
// local desktop IPC. No special desktop handling needed.
|
||||
if (ctx.heterogeneousProvider && isRemoteHeterogeneousType(ctx.heterogeneousProvider.type)) {
|
||||
return 'gateway';
|
||||
}
|
||||
// Local CLI agents (claude-code, codex) run as desktop subprocesses.
|
||||
if (isDesktop && ctx.heterogeneousProvider) return 'hetero';
|
||||
// On web, heterogeneous agents always run via Gateway sandbox regardless of the
|
||||
// isGatewayMode user preference — the sandbox is the only execution environment.
|
||||
// On web, all remaining hetero agents run via the Gateway sandbox.
|
||||
if (!isDesktop && ctx.heterogeneousProvider) return 'gateway';
|
||||
if (ctx.isGatewayMode) return 'gateway';
|
||||
return 'client';
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
import { agentGroupByIdSelectors, getChatGroupStoreState } from '@/store/agentGroup';
|
||||
import { selectRuntimeType } from '@/store/chat/slices/aiChat/actions/agentDispatcher';
|
||||
import { resolveHeteroResume } from '@/store/chat/slices/aiChat/actions/heteroResume';
|
||||
import { dispatchNonHeteroSubAgent } from '@/store/chat/slices/aiChat/actions/nonHeteroSubAgentDispatcher';
|
||||
import { type ChatStore } from '@/store/chat/store';
|
||||
import {
|
||||
mergeAgentRuntimeInitialContexts,
|
||||
@@ -1184,34 +1185,24 @@ export class ConversationLifecycleActionImpl {
|
||||
: currentMessages;
|
||||
|
||||
// Sub-agent dispatch inherits the parent's runtime selection — a
|
||||
// hetero/gateway parent must keep its sub-agents on the same path so
|
||||
// events route through the same wire. See LOBE-8519.
|
||||
// gateway/hetero parent must keep its sub-agents on the same path.
|
||||
// Runtime routing is fully delegated to dispatchNonHeteroSubAgent (LOBE-8927).
|
||||
const parentAgentConfig = context.agentId
|
||||
? agentSelectors.getAgentConfigById(context.agentId)(getAgentStoreState())
|
||||
: undefined;
|
||||
const runtimeType = selectRuntimeType({
|
||||
heterogeneousProvider: parentAgentConfig?.agencyConfig?.heterogeneousProvider,
|
||||
isGatewayMode: this.#get().isGatewayModeEnabled(),
|
||||
});
|
||||
|
||||
// TODO(LOBE-8519 follow-up): only client sub-agent dispatch is
|
||||
// implemented today. Gateway / hetero direct mentions fall through to
|
||||
// client and will need their own runner once Step 2 lands.
|
||||
if (runtimeType !== 'client') {
|
||||
console.warn(
|
||||
`[directMentionRoute] runtime=${runtimeType} not yet supported for sub-agent dispatch; ` +
|
||||
'falling through to client mode',
|
||||
);
|
||||
}
|
||||
|
||||
await this.#get().executeClientAgent({
|
||||
context: { ...context, scope: 'sub_agent', subAgentId: targetAgentId },
|
||||
inPortalThread,
|
||||
messages: messagesWithInstruction,
|
||||
parentMessageId: toolMessage.id,
|
||||
parentMessageType: 'tool',
|
||||
parentOperationId: operationId,
|
||||
});
|
||||
await dispatchNonHeteroSubAgent(
|
||||
{ kind: 'mention', targetAgentId, instruction, parentMessageId: toolMessage.id },
|
||||
{
|
||||
conversationContext: context,
|
||||
heterogeneousProvider: parentAgentConfig?.agencyConfig?.heterogeneousProvider,
|
||||
inPortalThread,
|
||||
isGatewayMode: this.#get().isGatewayModeEnabled(),
|
||||
messages: messagesWithInstruction,
|
||||
parentOperationId: operationId,
|
||||
},
|
||||
this.#get(),
|
||||
);
|
||||
|
||||
this.#get().completeOperation(operationId);
|
||||
} catch (error) {
|
||||
|
||||
@@ -423,7 +423,7 @@ export class GatewayActionImpl {
|
||||
// never block the local cancel flow.
|
||||
this.#get().onOperationCancel(gatewayOpId, async () => {
|
||||
await aiAgentService
|
||||
.interruptTask({ operationId: result.operationId })
|
||||
.interruptTask({ operationId: result.operationId, topicId: result.topicId })
|
||||
.catch((err) => console.error('[Gateway] interruptTask failed:', err));
|
||||
});
|
||||
|
||||
|
||||
@@ -504,6 +504,15 @@ export const createGatewayEventHandler = (
|
||||
break;
|
||||
}
|
||||
|
||||
case 'notify_update': {
|
||||
// Remote hetero agent (openclaw / hermes) wrote a message to DB via
|
||||
// `lh notify`. DB is the source of truth — just refresh the message list.
|
||||
enqueue(async () => {
|
||||
await fetchAndReplaceMessages(get, context).catch(console.error);
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'error': {
|
||||
enqueue(async () => {
|
||||
const messageError = toChatMessageError(event.data);
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import type {
|
||||
ConversationContext,
|
||||
HeterogeneousProviderConfig,
|
||||
UIChatMessage,
|
||||
} from '@lobechat/types';
|
||||
|
||||
import type { ChatStore } from '@/store/chat/store';
|
||||
|
||||
import {
|
||||
type AgentInvocationIntent,
|
||||
type AgentRuntimeType,
|
||||
selectRuntimeType,
|
||||
} from './agentDispatcher';
|
||||
|
||||
/**
|
||||
* Execution context supplied by the caller at dispatch time.
|
||||
* Carries the runtime-selection inputs and client-mode extras that the
|
||||
* dispatcher needs but that do not belong in the intent itself.
|
||||
*/
|
||||
export interface NonHeteroSubAgentDispatchContext {
|
||||
/** Conversation context of the *parent* agent (agentId = parent agent). */
|
||||
conversationContext: ConversationContext;
|
||||
/** Per-agent heterogeneous provider config used for runtime resolution. */
|
||||
heterogeneousProvider?: HeterogeneousProviderConfig;
|
||||
/**
|
||||
* Whether the sub-agent runs inside a portal thread.
|
||||
* Client mode only — has no effect in gateway mode.
|
||||
*/
|
||||
inPortalThread?: boolean;
|
||||
/** Current gateway mode status (`chatStore.isGatewayModeEnabled()`). */
|
||||
isGatewayMode: boolean;
|
||||
/**
|
||||
* Messages passed to the client-side runner.
|
||||
* Typically the current conversation messages plus a virtual instruction
|
||||
* message prepended by the caller. Only consumed when runtime = 'client'.
|
||||
*/
|
||||
messages?: UIChatMessage[];
|
||||
/**
|
||||
* Parent operation ID to link the sub-agent operation as a child.
|
||||
* Optional — only provided when the caller has an active operation to chain.
|
||||
*/
|
||||
parentOperationId?: string;
|
||||
/**
|
||||
* Explicit runtime inherited from the parent operation.
|
||||
* When set, `selectRuntimeType` returns this value immediately, preserving
|
||||
* the parent's execution environment for the child invocation.
|
||||
*/
|
||||
parentRuntime?: AgentRuntimeType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified dispatcher for non-hetero, non-group sub-agent invocations (LOBE-8927).
|
||||
*
|
||||
* Resolves the child runtime by inheriting from the parent (via
|
||||
* `selectRuntimeType` with `parentRuntime`), then routes to the correct
|
||||
* executor. Replaces the per-entry-point runtime selection and client-only
|
||||
* fallback that previously lived in `callAgent` and `#executeDirectMentionRoute`.
|
||||
*
|
||||
* Runtime routing rules (same as top-level `selectRuntimeType`):
|
||||
* `parentRuntime` wins → otherwise hetero → gateway → client
|
||||
*
|
||||
* Context semantics by runtime:
|
||||
* - client: `agentId` = parent agent (for message key), `subAgentId` = target
|
||||
* - gateway: `agentId` = target agent (gateway runs this agent), `subAgentId` = target
|
||||
*
|
||||
* Explicitly excluded:
|
||||
* - `hetero` runtime → throws (handled by the heterogeneous pipeline)
|
||||
* - group orchestration → not routed here (callers guard this upstream)
|
||||
*/
|
||||
export async function dispatchNonHeteroSubAgent(
|
||||
intent: AgentInvocationIntent,
|
||||
ctx: NonHeteroSubAgentDispatchContext,
|
||||
store: Pick<ChatStore, 'executeClientAgent' | 'executeGatewayAgent'>,
|
||||
): Promise<void> {
|
||||
const runtimeType = selectRuntimeType({
|
||||
heterogeneousProvider: ctx.heterogeneousProvider,
|
||||
isGatewayMode: ctx.isGatewayMode,
|
||||
parentRuntime: ctx.parentRuntime,
|
||||
});
|
||||
|
||||
switch (runtimeType) {
|
||||
case 'client': {
|
||||
// Keep agentId as the parent agent so the message map key is correct.
|
||||
// subAgentId selects the target agent's config (effectiveAgentId = subAgentId).
|
||||
await store.executeClientAgent({
|
||||
context: {
|
||||
...ctx.conversationContext,
|
||||
scope: 'sub_agent',
|
||||
subAgentId: intent.targetAgentId,
|
||||
},
|
||||
inPortalThread: ctx.inPortalThread,
|
||||
messages: ctx.messages ?? [],
|
||||
parentMessageId: intent.parentMessageId,
|
||||
parentMessageType: 'tool',
|
||||
parentOperationId: ctx.parentOperationId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'gateway': {
|
||||
// Switch agentId to the target agent so the gateway runs the correct one.
|
||||
// The gateway loads conversation history from the topic DB, so we do NOT
|
||||
// pass the client-side message array. The instruction becomes a real user
|
||||
// message created on the server.
|
||||
await store.executeGatewayAgent({
|
||||
context: {
|
||||
...ctx.conversationContext,
|
||||
agentId: intent.targetAgentId,
|
||||
scope: 'sub_agent',
|
||||
subAgentId: intent.targetAgentId,
|
||||
},
|
||||
message: intent.instruction,
|
||||
parentOperationId: ctx.parentOperationId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'hetero': {
|
||||
// Hetero sub-agent invocation is out of scope for LOBE-8926.
|
||||
// Hetero agents are dispatched through a dedicated heterogeneous pipeline
|
||||
// (`executeHeterogeneousAgent`) and must not fall through to client mode.
|
||||
throw new Error(
|
||||
`[dispatchNonHeteroSubAgent] Hetero runtime is not supported for ` +
|
||||
`non-hetero sub-agent dispatch. ` +
|
||||
`kind=${intent.kind}, targetAgentId=${intent.targetAgentId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,4 +8,5 @@ export const labPreferSelectors = {
|
||||
enableGatewayMode: (s: UserState): boolean => s.preference.lab?.enableGatewayMode ?? false,
|
||||
enableInputMarkdown: (s: UserState): boolean =>
|
||||
s.preference.lab?.enableInputMarkdown ?? DEFAULT_PREFERENCE.lab!.enableInputMarkdown!,
|
||||
enablePlatformAgent: (s: UserState): boolean => s.preference.lab?.enablePlatformAgent ?? false,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user