mirror of
https://github.com/lotsoftick/openclaw_client.git
synced 2026-08-14 00:48:07 +00:00
tool execution output
This commit is contained in:
@@ -4,12 +4,29 @@ export interface SseEmitter {
|
||||
error: (msg: string) => void;
|
||||
}
|
||||
|
||||
export interface ToolStepOutput {
|
||||
text: string;
|
||||
isError: boolean;
|
||||
status?: string | null;
|
||||
exitCode?: number | null;
|
||||
durationMs?: number | null;
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
export interface ToolStep {
|
||||
id: string;
|
||||
name: string;
|
||||
input: Record<string, unknown> | null;
|
||||
output: ToolStepOutput | null;
|
||||
}
|
||||
|
||||
export interface OpenClawMessage {
|
||||
externalId: string;
|
||||
role: string;
|
||||
text: string;
|
||||
thinking: string | null;
|
||||
timestamp: string | null;
|
||||
toolSteps: ToolStep[] | null;
|
||||
}
|
||||
|
||||
export interface OpenClawSession {
|
||||
@@ -31,6 +48,18 @@ export interface SessionEntry {
|
||||
fastMode?: boolean | null;
|
||||
verboseLevel?: string | null;
|
||||
reasoningLevel?: string | null;
|
||||
status?: string;
|
||||
abortedLastRun?: boolean;
|
||||
abortReason?: string;
|
||||
lastInteractionAt?: number;
|
||||
endedAt?: number;
|
||||
}
|
||||
|
||||
export interface SessionRunStatus {
|
||||
aborted: boolean;
|
||||
status: string | null;
|
||||
reason: string | null;
|
||||
endedAt: number | null;
|
||||
}
|
||||
|
||||
export type SessionsFile = Record<string, SessionEntry>;
|
||||
@@ -47,12 +76,23 @@ export interface JsonlThinkingPart {
|
||||
thinking: string;
|
||||
}
|
||||
|
||||
export interface JsonlToolCallPart {
|
||||
type: 'toolCall';
|
||||
id?: string;
|
||||
name?: string;
|
||||
arguments?: unknown;
|
||||
}
|
||||
|
||||
export interface JsonlOtherPart {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type JsonlContentPart = JsonlTextPart | JsonlThinkingPart | JsonlOtherPart;
|
||||
export type JsonlContentPart =
|
||||
| JsonlTextPart
|
||||
| JsonlThinkingPart
|
||||
| JsonlToolCallPart
|
||||
| JsonlOtherPart;
|
||||
|
||||
export interface JsonlMessageEntry {
|
||||
type: 'message';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, DeleteDateColumn, Index } from 'typeorm';
|
||||
import { ToolStep } from '../@types/openclaw';
|
||||
|
||||
@Entity('messages')
|
||||
@Index(['conversationId', 'externalId'], { unique: true, where: 'externalId IS NOT NULL' })
|
||||
@@ -21,6 +22,9 @@ export default class Message {
|
||||
@Column({ type: 'simple-json', default: '[]' })
|
||||
files: { filename: string; originalName: string; mimetype: string; size: number; url: string }[];
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true, default: null })
|
||||
toolSteps: ToolStep[] | null;
|
||||
|
||||
@Column({ type: 'text', default: 'user' })
|
||||
role: 'user' | 'assistant';
|
||||
|
||||
|
||||
@@ -267,6 +267,7 @@ const sync: RequestHandler = async (req, res, next) => {
|
||||
externalId: m.externalId,
|
||||
text: m.text,
|
||||
thinking: m.thinking || null,
|
||||
toolSteps: m.toolSteps && m.toolSteps.length > 0 ? m.toolSteps : null,
|
||||
files: [],
|
||||
role: m.role as MessageRole,
|
||||
createdBy: req.user!._id,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/* The other route modules in this folder export multiple handlers, so
|
||||
* `import * as controller` reads naturally there. This one just exposes
|
||||
* a single GET — keeping the same pattern for consistency rather than
|
||||
* switching to a default export. */
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { RequestHandler } from 'express';
|
||||
import { gateway } from '../../services/openclawGateway';
|
||||
|
||||
/**
|
||||
* GET /api/gateway/status — cheap, unauthenticated snapshot of the
|
||||
* WebSocket connection to the OpenClaw daemon. Polled by the chat UI
|
||||
* (5 s cadence) to render the green/yellow/red dot in the message input.
|
||||
*
|
||||
* Returning unauthenticated because the response carries no PII (just a
|
||||
* connection state enum + counter) and the chat shell needs it before
|
||||
* the user is necessarily logged in to render a useful "gateway down"
|
||||
* cue. If we ever add detailed timing/credentials in here, gate it.
|
||||
*/
|
||||
export const status: RequestHandler = (_req, res, next) => {
|
||||
try {
|
||||
return res.json(gateway.getStatus());
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import Router from 'express';
|
||||
import * as controller from './controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.route('/gateway/status').get(controller.status);
|
||||
|
||||
export default router;
|
||||
@@ -9,6 +9,7 @@ import plugin from './plugin';
|
||||
import skill from './skill';
|
||||
import cron from './cron';
|
||||
import update from './update';
|
||||
import gateway from './gateway';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -22,5 +23,6 @@ router.use(skill);
|
||||
router.use(cron);
|
||||
router.use(auth);
|
||||
router.use(update);
|
||||
router.use(gateway);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { RequestHandler } from 'express';
|
||||
import { LessThan, IsNull, MoreThan, FindOptionsWhere } from 'typeorm';
|
||||
import { LessThan, IsNull, In, MoreThan, FindOptionsWhere } from 'typeorm';
|
||||
import AppDataSource from '../../data-source';
|
||||
import { Message, Conversation, Agent } from '../../entities';
|
||||
import {
|
||||
@@ -217,13 +217,18 @@ const chat: Chat = async (req, res, next) => {
|
||||
const assistantThinking = lastAssistantJsonl.thinking
|
||||
? stripWrapperTags(lastAssistantJsonl.thinking).trim()
|
||||
: null;
|
||||
const assistantToolSteps = lastAssistantJsonl.toolSteps ?? null;
|
||||
|
||||
if (assistantText || assistantThinking) {
|
||||
/* Persist if we got any signal: real text, thinking, or tool calls
|
||||
* — the last produces a compact tool-stub bubble in the UI. */
|
||||
if (assistantText || assistantThinking || (assistantToolSteps && assistantToolSteps.length > 0)) {
|
||||
const assistantMessage = msgRepo.create({
|
||||
conversationId: Number(conversationId),
|
||||
externalId: lastAssistantJsonl.externalId || null,
|
||||
text: assistantText || '...',
|
||||
text: assistantText,
|
||||
thinking: assistantThinking || null,
|
||||
toolSteps:
|
||||
assistantToolSteps && assistantToolSteps.length > 0 ? assistantToolSteps : null,
|
||||
role: 'assistant' as const,
|
||||
createdBy: req.user!._id,
|
||||
createdAt: new Date(),
|
||||
@@ -283,14 +288,18 @@ const poll: RequestHandler<{ conversationId: string }, unknown, never, { after?:
|
||||
|
||||
const conv = await convRepo.findOneBy({ _id: convId });
|
||||
if (!conv?.sessionKey) {
|
||||
return res.json({ items: [], synced: 0 });
|
||||
return res.json({ items: [], synced: 0, runStatus: null });
|
||||
}
|
||||
|
||||
const agent = await agentRepo.findOneBy({ _id: conv.agentId });
|
||||
if (!agent?.openclawAgentId) {
|
||||
return res.json({ items: [], synced: 0 });
|
||||
return res.json({ items: [], synced: 0, runStatus: null });
|
||||
}
|
||||
|
||||
/* Run state surfaced to the UI so it can show a banner when the daemon
|
||||
* aborted the last run (model idle timeout, error, user cancel). */
|
||||
const runStatus = ocService.getSessionRunStatus(agent.openclawAgentId, conv.sessionKey);
|
||||
|
||||
let synced = 0;
|
||||
const jsonlMessages = ocService
|
||||
.getSessionMessages(agent.openclawAgentId, conv.sessionKey)
|
||||
@@ -356,20 +365,47 @@ const poll: RequestHandler<{ conversationId: string }, unknown, never, { after?:
|
||||
});
|
||||
|
||||
const toInsert: typeof candidates = [];
|
||||
const updates: Array<{ id: number; externalId: string }> = [];
|
||||
const updates: Array<{
|
||||
id: number;
|
||||
externalId: string;
|
||||
thinking: string | null;
|
||||
toolSteps: NonNullable<(typeof candidates)[number]['toolSteps']> | null;
|
||||
}> = [];
|
||||
|
||||
candidates.forEach((m) => {
|
||||
const pool = unlinkedByRole.get(m.role);
|
||||
if (pool && pool.length > 0) {
|
||||
const match = pool.shift()!;
|
||||
updates.push({ id: match._id, externalId: m.externalId! });
|
||||
/* The chat handler may have saved this row pre-stream-completion,
|
||||
* before any toolResult had landed in JSONL. We carry the live
|
||||
* toolSteps + thinking through the link so the row picks up
|
||||
* whatever the JSONL has now (including populated tool outputs). */
|
||||
updates.push({
|
||||
id: match._id,
|
||||
externalId: m.externalId!,
|
||||
thinking: m.thinking || null,
|
||||
toolSteps: m.toolSteps && m.toolSteps.length > 0 ? m.toolSteps : null,
|
||||
});
|
||||
} else {
|
||||
toInsert.push(m);
|
||||
}
|
||||
});
|
||||
|
||||
if (updates.length) {
|
||||
await Promise.all(updates.map((u) => msgRepo.update(u.id, { externalId: u.externalId })));
|
||||
await Promise.all(
|
||||
updates.map((u) => {
|
||||
/* TypeORM's update() type widens to _QueryDeepPartialEntity,
|
||||
* which doesn't model arbitrary `Record<string, unknown>` shapes
|
||||
* inside ToolStep.input. The runtime contract is just
|
||||
* "JSON-serialisable patch", so the cast is safe. */
|
||||
const patch = {
|
||||
externalId: u.externalId,
|
||||
thinking: u.thinking,
|
||||
toolSteps: u.toolSteps,
|
||||
} as unknown as Parameters<typeof msgRepo.update>[1];
|
||||
return msgRepo.update(u.id, patch);
|
||||
})
|
||||
);
|
||||
synced += updates.length;
|
||||
}
|
||||
|
||||
@@ -381,6 +417,7 @@ const poll: RequestHandler<{ conversationId: string }, unknown, never, { after?:
|
||||
externalId: m.externalId,
|
||||
text: m.text,
|
||||
thinking: m.thinking || null,
|
||||
toolSteps: m.toolSteps && m.toolSteps.length > 0 ? m.toolSteps : null,
|
||||
files: [],
|
||||
role: m.role as 'user' | 'assistant',
|
||||
createdBy: req.user!._id,
|
||||
@@ -390,6 +427,50 @@ const poll: RequestHandler<{ conversationId: string }, unknown, never, { after?:
|
||||
);
|
||||
synced += toInsert.length;
|
||||
}
|
||||
|
||||
/* Refresh tool steps on already-linked assistant rows. A long-running
|
||||
* tool finishes AFTER its parent assistant turn was first synced, so
|
||||
* the toolResult lands in JSONL on a later poll. Without this pass,
|
||||
* the row keeps `output: null` forever and the UI shows "(no result
|
||||
* captured)". We compare canonical JSON to skip no-op writes. */
|
||||
const liveAssistantSteps = jsonlMessages.filter(
|
||||
(m) => m.role === 'assistant' && m.toolSteps && m.toolSteps.length > 0
|
||||
);
|
||||
if (liveAssistantSteps.length) {
|
||||
const liveIds = liveAssistantSteps.map((m) => m.externalId!).filter(Boolean);
|
||||
if (liveIds.length) {
|
||||
const existing = await msgRepo.find({
|
||||
where: {
|
||||
conversationId: convId,
|
||||
role: 'assistant',
|
||||
externalId: In(liveIds),
|
||||
},
|
||||
select: ['_id', 'externalId', 'toolSteps'],
|
||||
});
|
||||
const dbByExt = new Map(existing.map((m) => [m.externalId!, m]));
|
||||
const refreshes: Array<{ id: number; toolSteps: typeof liveAssistantSteps[number]['toolSteps'] }> = [];
|
||||
liveAssistantSteps.forEach((m) => {
|
||||
const row = dbByExt.get(m.externalId!);
|
||||
if (!row) return;
|
||||
const liveJson = JSON.stringify(m.toolSteps ?? null);
|
||||
const dbJson = JSON.stringify(row.toolSteps ?? null);
|
||||
if (liveJson !== dbJson) {
|
||||
refreshes.push({ id: row._id, toolSteps: m.toolSteps });
|
||||
}
|
||||
});
|
||||
if (refreshes.length) {
|
||||
await Promise.all(
|
||||
refreshes.map((r) => {
|
||||
const patch = { toolSteps: r.toolSteps } as unknown as Parameters<
|
||||
typeof msgRepo.update
|
||||
>[1];
|
||||
return msgRepo.update(r.id, patch);
|
||||
})
|
||||
);
|
||||
synced += refreshes.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const where: FindOptionsWhere<Message> = { conversationId: convId };
|
||||
@@ -403,7 +484,7 @@ const poll: RequestHandler<{ conversationId: string }, unknown, never, { after?:
|
||||
take: 200,
|
||||
});
|
||||
|
||||
return res.json({ items, synced });
|
||||
return res.json({ items, synced, runStatus });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export {
|
||||
deleteSession,
|
||||
deleteSessionMessage,
|
||||
extractThinkingFromJsonl,
|
||||
getSessionRunStatus,
|
||||
getSessionSettingsInternal,
|
||||
} from './sessions';
|
||||
export { runChat } from './chat';
|
||||
|
||||
@@ -4,9 +4,132 @@ import {
|
||||
JsonlEntry,
|
||||
JsonlTextPart,
|
||||
JsonlThinkingPart,
|
||||
JsonlToolCallPart,
|
||||
OpenClawMessage,
|
||||
ToolStep,
|
||||
ToolStepOutput,
|
||||
} from '../../@types/openclaw';
|
||||
|
||||
/** Hard limits to keep tool step JSON small enough to live alongside chat
|
||||
* messages in SQLite without blowing up message payloads. Real tool I/O
|
||||
* (e.g. file dumps, large model outputs) routinely runs past these caps;
|
||||
* the UI shows a "(truncated)" hint when that happens. */
|
||||
const MAX_TOOL_INPUT_VALUE_CHARS = 8_000;
|
||||
const MAX_TOOL_OUTPUT_TEXT_CHARS = 16_000;
|
||||
/** Provider-specific noise we never want to surface to the UI. */
|
||||
const HIDDEN_TOOL_INPUT_KEYS = new Set(['thoughtSignature']);
|
||||
|
||||
function isTextPart(p: JsonlContentPart): p is JsonlTextPart {
|
||||
return p.type === 'text' && typeof (p as JsonlTextPart).text === 'string';
|
||||
}
|
||||
|
||||
function isThinkingPart(p: JsonlContentPart): p is JsonlThinkingPart {
|
||||
return p.type === 'thinking' && typeof (p as JsonlThinkingPart).thinking === 'string';
|
||||
}
|
||||
|
||||
function truncateString(value: string, max: number): { value: string; truncated: boolean } {
|
||||
if (value.length <= max) return { value, truncated: false };
|
||||
return { value: `${value.slice(0, max)}\n…[truncated ${value.length - max} chars]`, truncated: true };
|
||||
}
|
||||
|
||||
function sanitizeToolInput(args: unknown): Record<string, unknown> | null {
|
||||
if (!args || typeof args !== 'object' || Array.isArray(args)) return null;
|
||||
const out: Record<string, unknown> = {};
|
||||
Object.entries(args as Record<string, unknown>).forEach(([k, v]) => {
|
||||
if (HIDDEN_TOOL_INPUT_KEYS.has(k)) return;
|
||||
if (typeof v === 'string') {
|
||||
out[k] = truncateString(v, MAX_TOOL_INPUT_VALUE_CHARS).value;
|
||||
} else {
|
||||
/* For nested objects we serialize once + truncate, avoiding deep
|
||||
* recursion through arbitrary tool schemas. */
|
||||
try {
|
||||
const json = JSON.stringify(v);
|
||||
if (json && json.length > MAX_TOOL_INPUT_VALUE_CHARS) {
|
||||
out[k] = `${json.slice(0, MAX_TOOL_INPUT_VALUE_CHARS)}…[truncated]`;
|
||||
} else {
|
||||
out[k] = v;
|
||||
}
|
||||
} catch {
|
||||
out[k] = '[unserialisable]';
|
||||
}
|
||||
}
|
||||
});
|
||||
return Object.keys(out).length === 0 ? null : out;
|
||||
}
|
||||
|
||||
function isToolCallPart(p: JsonlContentPart): p is JsonlToolCallPart {
|
||||
return (
|
||||
Boolean(p) && typeof p === 'object' && (p as { type?: unknown }).type === 'toolCall'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk all JSONL entries once and index `toolResult` rows by their
|
||||
* `toolCallId`. The map gives us O(1) lookup when assembling assistant
|
||||
* messages so the parser stays O(N) overall.
|
||||
*/
|
||||
function indexToolResults(entries: JsonlEntry[]): Map<string, ToolStepOutput> {
|
||||
const out = new Map<string, ToolStepOutput>();
|
||||
entries.forEach((entry) => {
|
||||
if (entry.type !== 'message') return;
|
||||
const msg = entry.message as
|
||||
| {
|
||||
role?: string;
|
||||
toolCallId?: string;
|
||||
content?: JsonlContentPart[] | string;
|
||||
isError?: boolean;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
| undefined;
|
||||
if (!msg || msg.role !== 'toolResult' || !msg.toolCallId) return;
|
||||
|
||||
/* The result's primary text lives either as a single string in
|
||||
* `content` or as a list of text parts. Joining is the safe default. */
|
||||
let text = '';
|
||||
if (typeof msg.content === 'string') {
|
||||
text = msg.content;
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
text = msg.content
|
||||
.filter(isTextPart)
|
||||
.map((c) => c.text)
|
||||
.join('\n');
|
||||
}
|
||||
const { value: clipped, truncated } = truncateString(text, MAX_TOOL_OUTPUT_TEXT_CHARS);
|
||||
|
||||
const details = (msg.details ?? {}) as {
|
||||
status?: unknown;
|
||||
exitCode?: unknown;
|
||||
durationMs?: unknown;
|
||||
};
|
||||
out.set(msg.toolCallId, {
|
||||
text: clipped,
|
||||
isError: msg.isError === true,
|
||||
status: typeof details.status === 'string' ? details.status : null,
|
||||
exitCode: typeof details.exitCode === 'number' ? details.exitCode : null,
|
||||
durationMs: typeof details.durationMs === 'number' ? details.durationMs : null,
|
||||
truncated,
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function extractToolSteps(
|
||||
content: JsonlContentPart[] | string,
|
||||
results: Map<string, ToolStepOutput>
|
||||
): ToolStep[] {
|
||||
if (!Array.isArray(content)) return [];
|
||||
return content.filter(isToolCallPart).map((part) => {
|
||||
const id = typeof part.id === 'string' ? part.id : '';
|
||||
const name = typeof part.name === 'string' ? part.name : 'tool';
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
input: sanitizeToolInput(part.arguments),
|
||||
output: id ? results.get(id) ?? null : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function extractUserText(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
// Preserve scheduled-task headers so the frontend can render them specially
|
||||
@@ -29,14 +152,6 @@ export function extractAssistantText(raw: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function isTextPart(p: JsonlContentPart): p is JsonlTextPart {
|
||||
return p.type === 'text' && typeof (p as JsonlTextPart).text === 'string';
|
||||
}
|
||||
|
||||
function isThinkingPart(p: JsonlContentPart): p is JsonlThinkingPart {
|
||||
return p.type === 'thinking' && typeof (p as JsonlThinkingPart).thinking === 'string';
|
||||
}
|
||||
|
||||
function readJsonlLines(jsonlPath: string): JsonlEntry[] {
|
||||
if (!jsonlPath || !fs.existsSync(jsonlPath)) return [];
|
||||
return fs
|
||||
@@ -71,7 +186,13 @@ export function readFirstUserMessage(jsonlPath: string): string | null {
|
||||
|
||||
export function parseMessagesFromJsonl(jsonlPath: string): OpenClawMessage[] {
|
||||
try {
|
||||
const raw = readJsonlLines(jsonlPath)
|
||||
const entries = readJsonlLines(jsonlPath);
|
||||
/* One pre-pass to index toolResult rows by toolCallId — assistant entries
|
||||
* reference these by id rather than positionally, so a Map is the only
|
||||
* reliable way to pair them. */
|
||||
const resultsByCallId = indexToolResults(entries);
|
||||
|
||||
const raw = entries
|
||||
.filter((entry) => {
|
||||
if (entry.type !== 'message') return false;
|
||||
const role = entry.message?.role;
|
||||
@@ -97,13 +218,18 @@ export function parseMessagesFromJsonl(jsonlPath: string): OpenClawMessage[] {
|
||||
.join('\n')
|
||||
.trim();
|
||||
const thinking = [structuredThink, inlineThink].filter(Boolean).join('\n').trim() || null;
|
||||
if (!text) return null;
|
||||
const toolSteps = role === 'assistant' ? extractToolSteps(message.content, resultsByCallId) : [];
|
||||
/* Keep the entry if it has ANY meaningful signal: text, thinking, or
|
||||
* tool calls. Tool-only assistant turns used to be dropped here,
|
||||
* which made tool-using runs look like silent gaps in the chat. */
|
||||
if (!text && !thinking && toolSteps.length === 0) return null;
|
||||
return {
|
||||
externalId: entry.id || '',
|
||||
role,
|
||||
text,
|
||||
thinking,
|
||||
timestamp: entry.timestamp || null,
|
||||
toolSteps: toolSteps.length > 0 ? toolSteps : null,
|
||||
};
|
||||
})
|
||||
.filter((m): m is OpenClawMessage => m !== null);
|
||||
@@ -113,6 +239,9 @@ export function parseMessagesFromJsonl(jsonlPath: string): OpenClawMessage[] {
|
||||
if (msg.role === 'assistant' && prev?.role === 'assistant') {
|
||||
prev.text += msg.text;
|
||||
if (msg.thinking) prev.thinking = (prev.thinking || '') + msg.thinking;
|
||||
if (msg.toolSteps && msg.toolSteps.length > 0) {
|
||||
prev.toolSteps = [...(prev.toolSteps ?? []), ...msg.toolSteps];
|
||||
}
|
||||
prev.externalId = msg.externalId;
|
||||
prev.timestamp = msg.timestamp || prev.timestamp;
|
||||
} else {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
OpenClawMessage,
|
||||
OpenClawSession,
|
||||
SessionEntry,
|
||||
SessionRunStatus,
|
||||
SessionSettings,
|
||||
SessionSettingsPatchBody,
|
||||
SessionsFile,
|
||||
@@ -77,6 +78,39 @@ export function getSessionSettingsInternal(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect `sessions.json` for the run state of one session.
|
||||
*
|
||||
* The OpenClaw daemon writes `status`, `abortedLastRun`, `abortReason`, and
|
||||
* `endedAt` after each run. When `status === 'timeout'` (model idle timeout)
|
||||
* or `abortedLastRun === true` (any other abnormal end) the assistant's
|
||||
* partial reply was streamed to whatever client was connected but never
|
||||
* committed to the JSONL — so we surface this so the chat UI can explain
|
||||
* the gap to the user instead of silently showing a missing message.
|
||||
*/
|
||||
export function getSessionRunStatus(
|
||||
agentId: string,
|
||||
sessionKey: string
|
||||
): SessionRunStatus {
|
||||
const empty: SessionRunStatus = { aborted: false, status: null, reason: null, endedAt: null };
|
||||
try {
|
||||
const sessions = readSessions(agentId);
|
||||
if (!sessions) return empty;
|
||||
const entry = findSessionEntry(sessions, agentId, sessionKey);
|
||||
if (!entry) return empty;
|
||||
const status = typeof entry.status === 'string' ? entry.status : null;
|
||||
const aborted = entry.abortedLastRun === true || status === 'timeout' || status === 'error';
|
||||
const reason =
|
||||
(typeof entry.abortReason === 'string' && entry.abortReason) ||
|
||||
(status && status !== 'ok' ? status : null) ||
|
||||
null;
|
||||
const endedAt = typeof entry.endedAt === 'number' ? entry.endedAt : null;
|
||||
return { aborted, status, reason, endedAt };
|
||||
} catch {
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractThinkingFromJsonl(agentId: string, sessionKey: string): string | null {
|
||||
try {
|
||||
const sessions = readSessions(agentId);
|
||||
|
||||
@@ -400,6 +400,39 @@ export class GatewayClient {
|
||||
offEvent(key: string): void {
|
||||
this.eventListeners.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap snapshot of the connection's current health, surfaced through
|
||||
* `/api/gateway/status` for the chat UI's status dot. `connected` means
|
||||
* the socket is OPEN and post-auth; `connecting` covers both initial
|
||||
* handshake and reconnect attempts; `disconnected` is everything else.
|
||||
*/
|
||||
getStatus(): {
|
||||
state: 'connected' | 'connecting' | 'disconnected';
|
||||
lastSeenAt: number;
|
||||
reconnectAttempts: number;
|
||||
hasCredentials: boolean;
|
||||
} {
|
||||
const hasCredentials = Boolean(this.credentials || loadGatewayCredentials());
|
||||
if (this.ws?.readyState === WsWebSocket.OPEN && this.authenticated) {
|
||||
return {
|
||||
state: 'connected',
|
||||
lastSeenAt: this.lastSeenAt,
|
||||
reconnectAttempts: 0,
|
||||
hasCredentials,
|
||||
};
|
||||
}
|
||||
const connecting =
|
||||
this.ws?.readyState === WsWebSocket.CONNECTING ||
|
||||
Boolean(this.connectPromise) ||
|
||||
(this.ws?.readyState === WsWebSocket.OPEN && !this.authenticated);
|
||||
return {
|
||||
state: connecting ? 'connecting' : 'disconnected',
|
||||
lastSeenAt: this.lastSeenAt,
|
||||
reconnectAttempts: this.reconnectAttempts,
|
||||
hasCredentials,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const gateway = new GatewayClient();
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { baseApi } from '../../shared/api/baseApi';
|
||||
|
||||
export type GatewayState = 'connected' | 'connecting' | 'disconnected';
|
||||
|
||||
export interface GatewayStatus {
|
||||
state: GatewayState;
|
||||
/** Wall-clock ms of the last byte received on the current socket. 0
|
||||
* when never connected. */
|
||||
lastSeenAt: number;
|
||||
/** Successive reconnect attempts since the last successful auth.
|
||||
* Surfaced for diagnostics, not currently rendered. */
|
||||
reconnectAttempts: number;
|
||||
/** False when device-pairing files are missing — the dot turns into a
|
||||
* red "needs pairing" cue regardless of socket state. */
|
||||
hasCredentials: boolean;
|
||||
}
|
||||
|
||||
const gatewayApi = baseApi.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
getGatewayStatus: build.query<GatewayStatus, void>({
|
||||
query: () => '/gateway/status',
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const { useGetGatewayStatusQuery } = gatewayApi;
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './api';
|
||||
export { default as GatewayStatusDot } from './ui/GatewayStatusDot';
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Box, Tooltip } from '@mui/material';
|
||||
import { useGetGatewayStatusQuery } from '../api';
|
||||
|
||||
const POLL_MS = 5_000;
|
||||
|
||||
const COPY: Record<string, { color: string; tooltip: string }> = {
|
||||
connected: {
|
||||
color: 'success.main',
|
||||
tooltip: 'Gateway connected',
|
||||
},
|
||||
connecting: {
|
||||
color: 'warning.main',
|
||||
tooltip: 'Connecting to gateway…',
|
||||
},
|
||||
disconnected: {
|
||||
color: 'error.main',
|
||||
tooltip:
|
||||
'Gateway disconnected — agent traffic is paused. Reconnect attempts are running in the background.',
|
||||
},
|
||||
unpaired: {
|
||||
color: 'error.main',
|
||||
tooltip: 'Device not paired with the gateway. Run the pairing flow before sending messages.',
|
||||
},
|
||||
};
|
||||
|
||||
export default function GatewayStatusDot({ size = 5 }: { size?: number }) {
|
||||
const { data } = useGetGatewayStatusQuery(undefined, {
|
||||
pollingInterval: POLL_MS,
|
||||
refetchOnMountOrArgChange: true,
|
||||
});
|
||||
|
||||
const key = data?.hasCredentials === false ? 'unpaired' : (data?.state ?? 'disconnected');
|
||||
const meta = COPY[key];
|
||||
|
||||
return (
|
||||
<Tooltip title={meta.tooltip} arrow placement="top">
|
||||
<Box
|
||||
aria-label={meta.tooltip}
|
||||
sx={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
bgcolor: meta.color,
|
||||
flexShrink: 0,
|
||||
/* Pulse while connecting so the user sees something is in
|
||||
* progress and the UI isn't just frozen on a stale state. */
|
||||
animation:
|
||||
data?.state === 'connecting' ? 'gatewayPulse 1.4s ease-in-out infinite' : 'none',
|
||||
'@keyframes gatewayPulse': {
|
||||
'0%, 100%': { opacity: 1 },
|
||||
'50%': { opacity: 0.35 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -8,11 +8,35 @@ export interface MessageFile {
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** Provider-reported result of a single tool invocation. */
|
||||
export interface ToolStepOutput {
|
||||
text: string;
|
||||
isError: boolean;
|
||||
status?: string | null;
|
||||
exitCode?: number | null;
|
||||
durationMs?: number | null;
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One tool invocation captured from the assistant's JSONL turn — the
|
||||
* `toolCall` content part paired with its matching `toolResult` row. The
|
||||
* UI renders these as collapsible "Tool call / Tool output" blocks beneath
|
||||
* the thinking section so users can audit what the agent did.
|
||||
*/
|
||||
export interface ToolStep {
|
||||
id: string;
|
||||
name: string;
|
||||
input: Record<string, unknown> | null;
|
||||
output: ToolStepOutput | null;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
_id: string;
|
||||
conversationId: string;
|
||||
text: string;
|
||||
thinking: string | null;
|
||||
toolSteps?: ToolStep[] | null;
|
||||
files: MessageFile[];
|
||||
role: 'user' | 'assistant';
|
||||
createdAt: string;
|
||||
@@ -29,9 +53,24 @@ export interface MessagesQueryArg {
|
||||
before?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run-state surfaced from `sessions.json` on each poll. When `aborted` is
|
||||
* true the OpenClaw daemon ended the last run abnormally (idle timeout,
|
||||
* error, manual cancel) and the agent's reply was streamed only — never
|
||||
* committed to the JSONL. The UI shows a banner explaining this so the
|
||||
* apparent gap doesn't look like a sync failure.
|
||||
*/
|
||||
export interface SessionRunStatus {
|
||||
aborted: boolean;
|
||||
status: string | null;
|
||||
reason: string | null;
|
||||
endedAt: number | null;
|
||||
}
|
||||
|
||||
export interface PollResponse {
|
||||
items: Message[];
|
||||
synced: number;
|
||||
runStatus: SessionRunStatus | null;
|
||||
}
|
||||
|
||||
export interface PollQueryArg {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ToolStep } from '../api';
|
||||
|
||||
/**
|
||||
* Pull the most informative one-liner out of a tool call's arguments so
|
||||
* the collapsed header has context without forcing the user to expand.
|
||||
* Special-cases the common shapes (exec/read/write/edit) and falls back
|
||||
* to the first string field for anything else.
|
||||
*/
|
||||
export function summarizeInput(name: string, input: Record<string, unknown> | null): string {
|
||||
if (!input) return '';
|
||||
const candidate =
|
||||
(typeof input.command === 'string' && input.command) ||
|
||||
(typeof input.path === 'string' && input.path) ||
|
||||
(typeof input.file_path === 'string' && input.file_path) ||
|
||||
(typeof input.url === 'string' && input.url) ||
|
||||
(typeof input.query === 'string' && input.query) ||
|
||||
(typeof input.pattern === 'string' && input.pattern) ||
|
||||
'';
|
||||
if (candidate) return candidate;
|
||||
/* Generic fallback: first string-valued arg, prefixed with its key so
|
||||
* the header stays self-describing for unknown tools. */
|
||||
const first = Object.entries(input).find(([, v]) => typeof v === 'string' && v);
|
||||
if (first) return `${first[0]}: ${first[1]}`;
|
||||
return name;
|
||||
}
|
||||
|
||||
export function formatDuration(ms: number | null | undefined): string | null {
|
||||
if (ms == null || !Number.isFinite(ms)) return null;
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||
return `${(ms / 1000).toFixed(ms < 10_000 ? 2 : 1)}s`;
|
||||
}
|
||||
|
||||
/** Roll up per-step statuses into one summary state for the outer header. */
|
||||
export function aggregateStatus(steps: ToolStep[]): 'ok' | 'error' | 'pending' {
|
||||
const hasPending = steps.some((s) => !s.output);
|
||||
const hasError = steps.some(
|
||||
(s) => s.output?.isError || (typeof s.output?.exitCode === 'number' && s.output.exitCode !== 0)
|
||||
);
|
||||
if (hasError) return 'error';
|
||||
if (hasPending) return 'pending';
|
||||
return 'ok';
|
||||
}
|
||||
@@ -1,6 +1,22 @@
|
||||
import { useState, memo, useCallback } from 'react';
|
||||
import { Box, Paper, Typography, IconButton, Chip, alpha, useTheme } from '@mui/material';
|
||||
import { DeleteOutline, ContentCopy, Done, ScheduleOutlined } from '@mui/icons-material';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Stack,
|
||||
Tooltip,
|
||||
Typography,
|
||||
IconButton,
|
||||
Chip,
|
||||
alpha,
|
||||
useTheme,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
DeleteOutline,
|
||||
ContentCopy,
|
||||
Done,
|
||||
ScheduleOutlined,
|
||||
ErrorOutline,
|
||||
} from '@mui/icons-material';
|
||||
import { DeleteButton, MarkdownContent } from '../../../shared/ui';
|
||||
import { useDeleteMessageMutation, type Message } from '../api';
|
||||
import type { ParsedCronMessage } from '../lib/parseCronMessage';
|
||||
@@ -9,12 +25,14 @@ interface CronMessageBubbleProps {
|
||||
message: Message | { text: string; role: string };
|
||||
messageId?: string;
|
||||
parsed: ParsedCronMessage;
|
||||
deliveryError?: string | null;
|
||||
}
|
||||
|
||||
const CronMessageBubble = memo(function CronMessageBubble({
|
||||
message,
|
||||
messageId,
|
||||
parsed,
|
||||
deliveryError,
|
||||
}: CronMessageBubbleProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -121,13 +139,19 @@ const CronMessageBubble = memo(function CronMessageBubble({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{createdAt && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ opacity: 0.6, display: 'block', mt: 0.5, fontSize: 10.5 }}
|
||||
>
|
||||
{new Date(createdAt).toLocaleTimeString()}
|
||||
</Typography>
|
||||
{(createdAt || deliveryError) && (
|
||||
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ mt: 0.5 }}>
|
||||
{deliveryError && (
|
||||
<Tooltip title={deliveryError} arrow placement="right">
|
||||
<ErrorOutline sx={{ fontSize: 13, color: 'warning.main', cursor: 'help' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{createdAt && (
|
||||
<Typography variant="caption" sx={{ opacity: 0.6, fontSize: 10.5 }}>
|
||||
{new Date(createdAt).toLocaleTimeString()}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
import { useState, memo, useCallback } from 'react';
|
||||
import { Box, Paper, Typography, IconButton, useTheme } from '@mui/material';
|
||||
import { DeleteOutline, ContentCopy, Done } from '@mui/icons-material';
|
||||
import { Box, Paper, Stack, Tooltip, Typography, IconButton, useTheme } from '@mui/material';
|
||||
import { DeleteOutline, ContentCopy, Done, ErrorOutline } from '@mui/icons-material';
|
||||
import { DeleteButton, MarkdownContent } from '../../../shared/ui';
|
||||
import ThinkingBlock from './ThinkingBlock';
|
||||
import ToolStepsBlock from './ToolStepsBlock';
|
||||
import FileAttachments from './FileAttachments';
|
||||
import CronMessageBubble from './CronMessageBubble';
|
||||
import ChannelMetadataHeader from './ChannelMetadataHeader';
|
||||
import { parseCronMessage } from '../lib/parseCronMessage';
|
||||
import { parseChannelMetadata } from '../lib/parseChannelMetadata';
|
||||
import { useDeleteMessageMutation, type Message, type MessageFile } from '../api';
|
||||
import { useDeleteMessageMutation, type Message, type MessageFile, type ToolStep } from '../api';
|
||||
|
||||
export type MessageLike =
|
||||
| Message
|
||||
| { text: string; role: string; thinking?: string | null; files?: MessageFile[] };
|
||||
| {
|
||||
text: string;
|
||||
role: string;
|
||||
thinking?: string | null;
|
||||
files?: MessageFile[];
|
||||
toolSteps?: ToolStep[] | null;
|
||||
};
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: MessageLike;
|
||||
isStreaming?: boolean;
|
||||
thinkingText?: string;
|
||||
messageId?: string;
|
||||
deliveryError?: string | null;
|
||||
}
|
||||
|
||||
const MessageBubble = memo(function MessageBubble({
|
||||
@@ -26,6 +34,7 @@ const MessageBubble = memo(function MessageBubble({
|
||||
isStreaming,
|
||||
thinkingText,
|
||||
messageId,
|
||||
deliveryError,
|
||||
}: MessageBubbleProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -35,6 +44,10 @@ const MessageBubble = memo(function MessageBubble({
|
||||
const isUser = message.role === 'user';
|
||||
const thinking = thinkingText || ('thinking' in message ? message.thinking : null);
|
||||
const files = ('files' in message ? message.files : undefined) ?? [];
|
||||
const toolSteps =
|
||||
!isUser && 'toolSteps' in message && Array.isArray(message.toolSteps)
|
||||
? (message.toolSteps as ToolStep[])
|
||||
: [];
|
||||
const parsedCron = isUser && !isStreaming ? parseCronMessage(message.text) : null;
|
||||
const parsedChannel =
|
||||
isUser && !isStreaming && !parsedCron ? parseChannelMetadata(message.text) : null;
|
||||
@@ -55,7 +68,38 @@ const MessageBubble = memo(function MessageBubble({
|
||||
|
||||
if (parsedCron) {
|
||||
return (
|
||||
<CronMessageBubble message={message as Message} messageId={messageId} parsed={parsedCron} />
|
||||
<CronMessageBubble
|
||||
message={message as Message}
|
||||
messageId={messageId}
|
||||
parsed={parsedCron}
|
||||
deliveryError={deliveryError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isUser && !hasTextContent && !thinking && toolSteps.length > 0 && !isStreaming) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
mb: 1.5,
|
||||
width: '100%',
|
||||
maxWidth: { xs: '90%', sm: '80%', md: 'min(70%, 100%)' },
|
||||
}}
|
||||
>
|
||||
<ToolStepsBlock steps={toolSteps} asStandalone />
|
||||
{'createdAt' in message && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ opacity: 0.6, mt: 0.5, fontSize: '0.65rem' }}
|
||||
>
|
||||
{new Date((message as Message).createdAt).toLocaleTimeString()}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -133,6 +177,7 @@ const MessageBubble = memo(function MessageBubble({
|
||||
) : (
|
||||
<MarkdownContent isStreaming={isStreaming}>{displayText}</MarkdownContent>
|
||||
))}
|
||||
{!isUser && toolSteps.length > 0 && <ToolStepsBlock steps={toolSteps} />}
|
||||
{isStreaming && !hasTextContent && (
|
||||
<Box
|
||||
component="span"
|
||||
@@ -146,10 +191,31 @@ const MessageBubble = memo(function MessageBubble({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{'createdAt' in message && (
|
||||
<Typography variant="caption" sx={{ opacity: 0.7 }}>
|
||||
{new Date(message.createdAt).toLocaleTimeString()}
|
||||
</Typography>
|
||||
{('createdAt' in message || deliveryError) && (
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={0.5}
|
||||
alignItems="center"
|
||||
justifyContent={isUser ? 'flex-end' : 'flex-start'}
|
||||
sx={{ mt: 0.25 }}
|
||||
>
|
||||
{deliveryError && (
|
||||
<Tooltip title={deliveryError} arrow placement={isUser ? 'left' : 'right'}>
|
||||
<ErrorOutline
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
color: 'warning.main',
|
||||
cursor: 'help',
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{'createdAt' in message && (
|
||||
<Typography variant="caption" sx={{ opacity: 0.7 }}>
|
||||
{new Date(message.createdAt).toLocaleTimeString()}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Box } from '@mui/material';
|
||||
|
||||
export default function ToolStepCodeFrame({ children }: { children: string }) {
|
||||
return (
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{
|
||||
m: 0,
|
||||
mt: 0.5,
|
||||
p: 1,
|
||||
bgcolor: 'action.hover',
|
||||
borderRadius: 1,
|
||||
fontSize: '0.72rem',
|
||||
fontFamily: 'monospace',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
maxHeight: 320,
|
||||
overflow: 'auto',
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Box, Chip, Typography } from '@mui/material';
|
||||
import { ExpandMore } from '@mui/icons-material';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface ToolStepHeaderProps {
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
label: string;
|
||||
toolName: string;
|
||||
summary?: string;
|
||||
trailing?: ReactNode;
|
||||
}
|
||||
|
||||
export default function ToolStepHeader({
|
||||
expanded,
|
||||
onToggle,
|
||||
label,
|
||||
toolName,
|
||||
summary,
|
||||
trailing,
|
||||
}: ToolStepHeaderProps) {
|
||||
return (
|
||||
<Box
|
||||
onClick={onToggle}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
cursor: 'pointer',
|
||||
opacity: 0.7,
|
||||
'&:hover': { opacity: 1 },
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<ExpandMore
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
transition: 'transform 0.2s',
|
||||
transform: expanded ? 'rotate(0deg)' : 'rotate(-90deg)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, fontSize: '0.7rem', flexShrink: 0 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={toolName}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
height: 16,
|
||||
fontSize: '0.62rem',
|
||||
fontFamily: 'monospace',
|
||||
'& .MuiChip-label': { px: 0.5 },
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{summary && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontSize: '0.7rem',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
opacity: 0.8,
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{summary}
|
||||
</Typography>
|
||||
)}
|
||||
{trailing && <Box sx={{ flexShrink: 0, ml: 'auto' }}>{trailing}</Box>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Box, Collapse, Typography } from '@mui/material';
|
||||
import type { ToolStep } from '../api';
|
||||
import { summarizeInput } from '../lib/toolStepFormatting';
|
||||
import ToolStepCodeFrame from './ToolStepCodeFrame';
|
||||
import ToolStepHeader from './ToolStepHeader';
|
||||
import ToolStepTrailing from './ToolStepTrailing';
|
||||
|
||||
interface ToolStepPairProps {
|
||||
step: ToolStep;
|
||||
idx: number;
|
||||
}
|
||||
|
||||
export default function ToolStepPair({ step, idx }: ToolStepPairProps) {
|
||||
const [callOpen, setCallOpen] = useState(false);
|
||||
const [outputOpen, setOutputOpen] = useState(false);
|
||||
|
||||
const inputJson = useMemo(() => {
|
||||
if (!step.input) return '';
|
||||
try {
|
||||
return JSON.stringify(step.input, null, 2);
|
||||
} catch {
|
||||
return String(step.input);
|
||||
}
|
||||
}, [step.input]);
|
||||
|
||||
const summary = summarizeInput(step.name, step.input);
|
||||
const out = step.output;
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: idx === 0 ? 0 : 0.75 }}>
|
||||
<ToolStepHeader
|
||||
expanded={callOpen}
|
||||
onToggle={() => setCallOpen((v) => !v)}
|
||||
label="Tool call"
|
||||
toolName={step.name}
|
||||
summary={summary}
|
||||
trailing={<ToolStepTrailing step={step} />}
|
||||
/>
|
||||
<Collapse in={callOpen}>
|
||||
{step.input ? (
|
||||
<ToolStepCodeFrame>{inputJson}</ToolStepCodeFrame>
|
||||
) : (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ display: 'block', pl: 2.5, fontStyle: 'italic', opacity: 0.7 }}
|
||||
>
|
||||
(no arguments)
|
||||
</Typography>
|
||||
)}
|
||||
</Collapse>
|
||||
|
||||
<Box sx={{ mt: 0.25 }}>
|
||||
<ToolStepHeader
|
||||
expanded={outputOpen}
|
||||
onToggle={() => setOutputOpen((v) => !v)}
|
||||
label="Tool output"
|
||||
toolName={step.name}
|
||||
/>
|
||||
<Collapse in={outputOpen}>
|
||||
{out ? (
|
||||
<>
|
||||
{out.text ? (
|
||||
<ToolStepCodeFrame>{out.text}</ToolStepCodeFrame>
|
||||
) : (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ display: 'block', pl: 2.5, fontStyle: 'italic', opacity: 0.7 }}
|
||||
>
|
||||
(no output)
|
||||
</Typography>
|
||||
)}
|
||||
{out.truncated && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
display: 'block',
|
||||
pl: 2.5,
|
||||
mt: 0.25,
|
||||
fontStyle: 'italic',
|
||||
opacity: 0.7,
|
||||
}}
|
||||
>
|
||||
output truncated for storage
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ display: 'block', pl: 2.5, fontStyle: 'italic', opacity: 0.7 }}
|
||||
>
|
||||
{/* In-flight call (e.g. live stream) or aborted run — no result row exists yet. */}
|
||||
(no result captured)
|
||||
</Typography>
|
||||
)}
|
||||
</Collapse>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Stack, Typography } from '@mui/material';
|
||||
import { ErrorOutline, CheckCircleOutline } from '@mui/icons-material';
|
||||
import type { ToolStep } from '../api';
|
||||
import { formatDuration } from '../lib/toolStepFormatting';
|
||||
|
||||
export default function ToolStepTrailing({ step }: { step: ToolStep }) {
|
||||
const out = step.output;
|
||||
if (!out) {
|
||||
return (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ fontSize: '0.65rem', opacity: 0.6 }}
|
||||
>
|
||||
pending
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
const duration = formatDuration(out.durationMs);
|
||||
const errored = out.isError || (typeof out.exitCode === 'number' && out.exitCode !== 0);
|
||||
|
||||
return (
|
||||
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||
{errored ? (
|
||||
<ErrorOutline sx={{ fontSize: 13, color: 'error.main' }} />
|
||||
) : (
|
||||
<CheckCircleOutline sx={{ fontSize: 13, color: 'success.main', opacity: 0.7 }} />
|
||||
)}
|
||||
{duration && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.65rem' }}>
|
||||
{duration}
|
||||
</Typography>
|
||||
)}
|
||||
{typeof out.exitCode === 'number' && out.exitCode !== 0 && (
|
||||
<Typography variant="caption" color="error.main" sx={{ fontSize: '0.65rem' }}>
|
||||
exit {out.exitCode}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState } from 'react';
|
||||
import { Box, Collapse, Stack, Typography } from '@mui/material';
|
||||
import { ExpandMore, Build, ErrorOutline, CheckCircleOutline } from '@mui/icons-material';
|
||||
import type { ToolStep } from '../api';
|
||||
import { aggregateStatus } from '../lib/toolStepFormatting';
|
||||
import ToolStepPair from './ToolStepPair';
|
||||
|
||||
interface ToolStepsBlockProps {
|
||||
steps: ToolStep[];
|
||||
asStandalone?: boolean;
|
||||
}
|
||||
|
||||
export default function ToolStepsBlock({ steps, asStandalone }: ToolStepsBlockProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!steps.length) return null;
|
||||
|
||||
const status = aggregateStatus(steps);
|
||||
const trailing = (
|
||||
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||
{status === 'ok' && (
|
||||
<CheckCircleOutline sx={{ fontSize: 13, color: 'success.main', opacity: 0.7 }} />
|
||||
)}
|
||||
{status === 'error' && <ErrorOutline sx={{ fontSize: 13, color: 'error.main' }} />}
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.65rem' }}>
|
||||
{steps.length === 1 ? '1 call' : `${steps.length} calls`}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: asStandalone ? 0 : 0.5 }}>
|
||||
<Box
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
cursor: 'pointer',
|
||||
opacity: 0.75,
|
||||
'&:hover': { opacity: 1 },
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<ExpandMore
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
transition: 'transform 0.2s',
|
||||
transform: open ? 'rotate(0deg)' : 'rotate(-90deg)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{asStandalone && <Build sx={{ fontSize: 13, color: 'text.secondary', flexShrink: 0 }} />}
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, fontSize: '0.72rem', flexShrink: 0 }}>
|
||||
Tool execution
|
||||
</Typography>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }} />
|
||||
<Box sx={{ flexShrink: 0 }}>{trailing}</Box>
|
||||
</Box>
|
||||
<Collapse in={open}>
|
||||
<Box sx={{ pl: 2, mt: 0.25 }}>
|
||||
{steps.map((step, idx) => (
|
||||
<ToolStepPair key={`${step.id || step.name}-${idx}`} step={step} idx={idx} />
|
||||
))}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { RefObject } from 'react';
|
||||
import type { Message, MessageFile } from '../../../entities/message';
|
||||
import type { Message, MessageFile, SessionRunStatus } from '../../../entities/message';
|
||||
|
||||
export interface ChatState {
|
||||
messages: Message[];
|
||||
@@ -15,6 +15,10 @@ export interface ChatState {
|
||||
pendingUserText: string;
|
||||
pendingFilesPreviews: MessageFile[];
|
||||
|
||||
/** Last-run state from the gateway daemon. `null` while unknown. */
|
||||
runStatus: SessionRunStatus | null;
|
||||
/** Hide the timeout/abort banner for the current chat session. */
|
||||
|
||||
send: (text: string, files: File[]) => Promise<void>;
|
||||
loadMore: () => void;
|
||||
handleScroll: () => void;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
usePollMessagesQuery,
|
||||
type Message,
|
||||
type MessagesResponse,
|
||||
type SessionRunStatus,
|
||||
} from '../../../entities/message';
|
||||
import { useSendMessage } from '../../../features/message/send';
|
||||
import type { ChatState } from './types';
|
||||
@@ -146,6 +147,15 @@ export function useChat(conversationId: string | undefined): ChatState {
|
||||
if (loadMoreCursor !== undefined) setLoadMoreCursor(undefined);
|
||||
}
|
||||
|
||||
/* Run state is derived directly from the latest poll. Polling is paused
|
||||
* while streaming, so by definition this only updates between runs.
|
||||
* The MessageList consumes it to flag the last "stuck" message inline
|
||||
* (exclamation icon + tooltip) — no banner state, no dismissal needed,
|
||||
* because the indicator clears naturally when a new assistant reply
|
||||
* arrives after the affected message. */
|
||||
const runStatus: SessionRunStatus | null =
|
||||
pollData?.runStatus?.aborted ? pollData.runStatus : null;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abort();
|
||||
@@ -182,6 +192,7 @@ export function useChat(conversationId: string | undefined): ChatState {
|
||||
streamError,
|
||||
pendingUserText,
|
||||
pendingFilesPreviews,
|
||||
runStatus,
|
||||
send,
|
||||
loadMore,
|
||||
handleScroll,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
InsertDriveFileOutlined,
|
||||
ImageOutlined,
|
||||
} from '@mui/icons-material';
|
||||
import { GatewayStatusDot } from '../../../entities/gateway';
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (text: string, files: File[]) => Promise<void>;
|
||||
@@ -96,6 +97,13 @@ export default function ChatInput({ onSend, isStreaming }: ChatInputProps) {
|
||||
}}
|
||||
>
|
||||
<input ref={fileInputRef} type="file" multiple hidden onChange={handleFileChange} />
|
||||
{/* Gateway status dot — mirrors WebSocket health so the user sees
|
||||
* immediately whether agent traffic can flow. Sits to the left
|
||||
* of the paperclip so it stays visible without crowding the
|
||||
* send-side controls. */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mr: 0.75, ml: 0.25 }}>
|
||||
<GatewayStatusDot />
|
||||
</Box>
|
||||
<IconButton
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isStreaming || pendingFiles.length >= 5}
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
import { Alert, Box, Typography, CircularProgress } from '@mui/material';
|
||||
import { Box, Typography, CircularProgress } from '@mui/material';
|
||||
import { MessageBubble } from '../../../entities/message';
|
||||
import type { ChatState } from '../model/types';
|
||||
|
||||
/** Short, single-line copy shown inside the per-bubble error tooltip. */
|
||||
function describeDeliveryError(status: string | null, reason: string | null): string {
|
||||
if (status === 'timeout') {
|
||||
return 'The model went idle past its timeout — no reply was committed. Try resending.';
|
||||
}
|
||||
if (status === 'error') {
|
||||
return reason ? `Run failed: ${reason}.` : 'The daemon ended the run with an error.';
|
||||
}
|
||||
if (status === 'cancelled') {
|
||||
return 'The run was cancelled before a reply was committed.';
|
||||
}
|
||||
return reason
|
||||
? `The daemon aborted the run: ${reason}.`
|
||||
: 'The daemon aborted the run before a reply was committed.';
|
||||
}
|
||||
|
||||
interface MessageListProps {
|
||||
chat: ChatState;
|
||||
}
|
||||
@@ -18,14 +34,39 @@ export default function MessageList({ chat }: MessageListProps) {
|
||||
streamError,
|
||||
pendingUserText,
|
||||
pendingFilesPreviews,
|
||||
runStatus,
|
||||
loadMore,
|
||||
loadMoreCursor,
|
||||
scrollContainerRef,
|
||||
messagesEndRef,
|
||||
handleScroll,
|
||||
clearError,
|
||||
} = chat;
|
||||
|
||||
/* Two error sources can flag a stuck send:
|
||||
* 1. `streamError` — the SSE pipeline itself failed (network drop,
|
||||
* gateway 5xx, model timeout surfaced as an error event). Most
|
||||
* immediate; clears automatically when the next send starts.
|
||||
* 2. `runStatus.aborted` — the daemon's run ended abnormally on the
|
||||
* previous turn (idle timeout, error, cancelled). Surfaces on the
|
||||
* next poll between runs.
|
||||
* Both render as the same inline marker (warning icon + tooltip) on
|
||||
* whichever bubble is currently waiting for a reply. The marker clears
|
||||
* naturally once an assistant reply arrives after that bubble. */
|
||||
const errorTooltip = streamError
|
||||
? streamError
|
||||
: runStatus
|
||||
? describeDeliveryError(runStatus.status, runStatus.reason)
|
||||
: null;
|
||||
const lastInputIdx =
|
||||
errorTooltip && !isStreaming
|
||||
? (() => {
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
if (messages[i].role !== 'assistant') return i;
|
||||
}
|
||||
return -1;
|
||||
})()
|
||||
: -1;
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={scrollContainerRef}
|
||||
@@ -39,29 +80,6 @@ export default function MessageList({ chat }: MessageListProps) {
|
||||
py: 2,
|
||||
}}
|
||||
>
|
||||
{streamError && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 2,
|
||||
mb: 1.5,
|
||||
}}
|
||||
>
|
||||
<Alert
|
||||
severity="error"
|
||||
variant="filled"
|
||||
onClose={clearError}
|
||||
sx={{
|
||||
alignItems: 'flex-start',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{streamError}
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
{isLoading && !loadMoreCursor ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress size={28} />
|
||||
@@ -90,8 +108,13 @@ export default function MessageList({ chat }: MessageListProps) {
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble key={msg._id} message={msg} messageId={msg._id} />
|
||||
{messages.map((msg, idx) => (
|
||||
<MessageBubble
|
||||
key={msg._id}
|
||||
message={msg}
|
||||
messageId={msg._id}
|
||||
deliveryError={idx === lastInputIdx ? errorTooltip : null}
|
||||
/>
|
||||
))}
|
||||
{isStreaming && (pendingUserText || pendingFilesPreviews.length > 0) && (
|
||||
<MessageBubble
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openclaw-client",
|
||||
"version": "2.5.1",
|
||||
"version": "2.5.2",
|
||||
"description": "Web-based chat interface for OpenClaw AI agents",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
Reference in New Issue
Block a user