mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 08:53:09 +00:00
Compare commits
1
Commits
main
...
dev-zx-ui-port
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
645f8075da |
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,604 @@
|
||||
# OpenClaw Chat Core Port Design
|
||||
|
||||
Date: 2026-06-19
|
||||
|
||||
## Summary
|
||||
|
||||
ClawX Chat will move from a ClawX-specific Gateway event adapter to an
|
||||
OpenClaw Chat Core driven React surface. The first implementation phase will
|
||||
vendor the relevant OpenClaw Web UI chat core code into ClawX, adapt it to the
|
||||
Electron host API boundary, and replace the existing Chat page by default.
|
||||
|
||||
The goal is correctness first: eliminate duplicated optimistic user messages,
|
||||
incorrect assistant terminal rendering, stale streaming state, and session/event
|
||||
cross-contamination caused by maintaining a second ClawX-specific chat runtime
|
||||
protocol.
|
||||
|
||||
This is not a visual port of OpenClaw Web UI. The Chat surface remains a ClawX
|
||||
desktop UI and must continue to use ClawX design tokens, i18n, toolbar patterns,
|
||||
artifact panel integration, and Electron Main/Renderer communication rules.
|
||||
|
||||
## Decisions
|
||||
|
||||
- Use option B: port OpenClaw Chat Core semantics and rebuild the core React
|
||||
Chat surface around that message model.
|
||||
- Vendor OpenClaw chat core into ClawX for the first phase. Do not fork or modify
|
||||
OpenClaw upstream in the short term.
|
||||
- Replace the current Chat implementation by default. Do not keep a long-lived
|
||||
feature flag or parallel route.
|
||||
- Keep Renderer communication behind `hostApi` and host event subscriptions.
|
||||
Renderer must not directly connect to Gateway, call Gateway HTTP endpoints, or
|
||||
add direct `window.electron.ipcRenderer.invoke(...)` calls.
|
||||
- Stop using ClawX `ChatRuntimeEvent` as the new Chat surface's primary runtime
|
||||
protocol. Main should forward upstream-shaped OpenClaw `agent` payloads through
|
||||
host-events so the vendored core can consume OpenClaw-style semantics.
|
||||
- Keep the current ClawX attachment policy: images are sent as base64 media;
|
||||
non-image files are sent as path/text references.
|
||||
- Keep the composer as a textarea with floating menus. Do not introduce Lexical.
|
||||
- Do not implement realtime voice/talk in this project.
|
||||
- Do not include pinned messages, deleted messages, full history search, or
|
||||
checkpoints in the first implementation phase.
|
||||
- Defer canvas preview support. Tool card extraction should preserve extension
|
||||
points, but canvas rendering is not part of phase one.
|
||||
|
||||
## Goals
|
||||
|
||||
- Use OpenClaw Web UI's proven chat semantics for history loading, send
|
||||
idempotency, stream reconciliation, run lifecycle, tool streams, slash command
|
||||
execution, compaction, and approvals.
|
||||
- Make the visible Chat UI a deterministic projection of history, live stream,
|
||||
optimistic sends, queued sends, tool stream state, and runtime indicators.
|
||||
- Support a redesigned core Chat surface:
|
||||
- message groups
|
||||
- streaming assistant group
|
||||
- thinking blocks
|
||||
- tool cards
|
||||
- raw output panel
|
||||
- run status
|
||||
- send queue state
|
||||
- compaction/fallback status
|
||||
- exec/plugin approval prompt
|
||||
- Preserve ClawX application shell behavior:
|
||||
- Electron Main owns transport
|
||||
- Renderer uses `hostApi`
|
||||
- existing toolbar and artifact panel concepts remain
|
||||
- i18n covers `en`, `zh`, `ja`, and `ru`
|
||||
- styling follows `src/styles/globals.css` design token rules
|
||||
- Add Electron E2E coverage for user-visible Chat changes.
|
||||
- Run communication validation for Gateway/chat path changes.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not port OpenClaw Web UI's Lit templates or CSS wholesale.
|
||||
- Do not turn ClawX into a browser Control UI.
|
||||
- Do not add direct browser Gateway authentication in Renderer.
|
||||
- Do not replace ClawX settings, sidebar, artifact panel, model picker, agent
|
||||
picker, or skill picker unless a narrow adapter is needed.
|
||||
- Do not change OpenClaw upstream source as part of this design.
|
||||
|
||||
## Architecture
|
||||
|
||||
The implementation should be organized as four layers:
|
||||
|
||||
```text
|
||||
Vendored OpenClaw chat core
|
||||
reducer / history / send / stream reconciliation / lifecycle / slash / tools
|
||||
|
|
||||
ClawX host API adapter
|
||||
hostApi.gateway.rpc / hostApi.chat.sendWithMedia / sessions / approvals
|
||||
|
|
||||
Thin Zustand binding
|
||||
snapshot / dispatch / host-event subscriptions / selectors
|
||||
|
|
||||
React Chat surface
|
||||
message list / streaming group / tool cards / raw output / run status / composer
|
||||
```
|
||||
|
||||
The core rule is that protocol and lifecycle semantics live in a framework-neutral
|
||||
chat engine. Zustand is only the React binding layer. React components subscribe
|
||||
through selectors and should not contain protocol reconciliation logic.
|
||||
|
||||
## Proposed File Layout
|
||||
|
||||
The exact names can be refined during implementation, but the design expects
|
||||
these boundaries:
|
||||
|
||||
```text
|
||||
src/chat-core/openclaw-port/
|
||||
state.ts
|
||||
reducer.ts
|
||||
actions.ts
|
||||
history.ts
|
||||
send.ts
|
||||
events.ts
|
||||
stream-reconciliation.ts
|
||||
run-lifecycle.ts
|
||||
slash-command-executor.ts
|
||||
tool-cards.ts
|
||||
selectors.ts
|
||||
|
||||
src/chat-core/clawx-adapter/
|
||||
client.ts
|
||||
attachments.ts
|
||||
host-events.ts
|
||||
session-routing.ts
|
||||
|
||||
src/stores/openclaw-chat-surface.ts
|
||||
|
||||
src/pages/Chat/
|
||||
ChatSurface.tsx
|
||||
MessageList.tsx
|
||||
MessageGroup.tsx
|
||||
StreamingGroup.tsx
|
||||
ToolCard.tsx
|
||||
RawOutputPanel.tsx
|
||||
RunStatusBar.tsx
|
||||
ApprovalPrompt.tsx
|
||||
ChatComposer.tsx
|
||||
```
|
||||
|
||||
Vendored files should retain comments indicating their OpenClaw origin and the
|
||||
local changes made for ClawX. Local React components should be ClawX-native and
|
||||
should not import Lit.
|
||||
|
||||
## Main and Renderer Event Contract
|
||||
|
||||
Renderer still consumes events through host-events IPC. The change is the shape
|
||||
of the Chat runtime event payload.
|
||||
|
||||
New Chat should consume upstream-shaped events:
|
||||
|
||||
```text
|
||||
gateway:chat-event
|
||||
gateway:agent-event
|
||||
```
|
||||
|
||||
`gateway:chat-event` carries OpenClaw chat event payloads such as delta, final,
|
||||
error, and aborted states.
|
||||
|
||||
`gateway:agent-event` carries OpenClaw agent event payloads without converting
|
||||
them into ClawX-specific runtime events:
|
||||
|
||||
```ts
|
||||
type GatewayAgentEventPayload = {
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
runId?: string;
|
||||
seq?: number;
|
||||
stream:
|
||||
| "lifecycle"
|
||||
| "assistant"
|
||||
| "thinking"
|
||||
| "tool"
|
||||
| "command_output"
|
||||
| "patch"
|
||||
| "approval"
|
||||
| "compaction"
|
||||
| "fallback"
|
||||
| string;
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
```
|
||||
|
||||
Main process responsibilities:
|
||||
|
||||
- Receive Gateway events.
|
||||
- Preserve upstream event semantics and fields as much as possible.
|
||||
- Forward serializable payloads through host-events.
|
||||
- Keep Gateway connection, RPC proxying, and process lifecycle ownership.
|
||||
- Keep old `chat:runtime-event` only as a transition channel for existing code
|
||||
that still depends on it. The new Chat surface must not depend on it.
|
||||
|
||||
Renderer responsibilities:
|
||||
|
||||
- Subscribe to the upstream-shaped host events.
|
||||
- Route events into the vendored chat core.
|
||||
- Avoid creating another ClawX-specific runtime protocol.
|
||||
- Keep UI components ignorant of raw Gateway payload details.
|
||||
|
||||
## Host API Adapter
|
||||
|
||||
The vendored core should talk to a small OpenClaw-style client interface:
|
||||
|
||||
```ts
|
||||
type ChatCoreClient = {
|
||||
request<T>(
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
timeoutMs?: number,
|
||||
): Promise<T>;
|
||||
};
|
||||
```
|
||||
|
||||
ClawX implementation:
|
||||
|
||||
```text
|
||||
chat.history
|
||||
-> hostApi.gateway.rpc("chat.history", params, timeoutMs)
|
||||
|
||||
chat.send, text only
|
||||
-> hostApi.gateway.rpc("chat.send", params, 120000)
|
||||
|
||||
chat.send, staged media present
|
||||
-> hostApi.chat.sendWithMedia(...)
|
||||
|
||||
chat.abort
|
||||
-> hostApi.gateway.rpc("chat.abort", params)
|
||||
|
||||
sessions.compact
|
||||
-> hostApi.gateway.rpc("sessions.compact", params)
|
||||
|
||||
exec.approval.resolve
|
||||
-> hostApi.gateway.rpc("exec.approval.resolve", params)
|
||||
|
||||
plugin.approval.resolve
|
||||
-> hostApi.gateway.rpc("plugin.approval.resolve", params)
|
||||
```
|
||||
|
||||
The adapter owns ClawX-specific attachment conversion and session routing. The
|
||||
vendored core should receive normalized send inputs and should not know about
|
||||
native file picker implementation details.
|
||||
|
||||
## State Model
|
||||
|
||||
The visible UI should be a selector output, not a direct render of history
|
||||
messages. Conceptually:
|
||||
|
||||
```text
|
||||
history messages
|
||||
+ visible current assistant stream
|
||||
+ live tool stream
|
||||
+ pending optimistic user message
|
||||
+ queued messages
|
||||
+ runtime indicators
|
||||
= visible chat items
|
||||
```
|
||||
|
||||
The core state should track these categories:
|
||||
|
||||
```ts
|
||||
type ChatSurfaceState = {
|
||||
sessionKey: string;
|
||||
selectedAgentId?: string;
|
||||
currentSessionId?: string;
|
||||
history: {
|
||||
messages: unknown[];
|
||||
loading: boolean;
|
||||
hasMore: boolean;
|
||||
requestVersion: number;
|
||||
};
|
||||
live: {
|
||||
runId: string | null;
|
||||
stream: string | null;
|
||||
streamSegments: Array<{ text: string; ts: number }>;
|
||||
toolMessages: unknown[];
|
||||
};
|
||||
send: {
|
||||
sending: boolean;
|
||||
queue: ChatQueueItem[];
|
||||
activeRunId: string | null;
|
||||
canAbort: boolean;
|
||||
lastError: string | null;
|
||||
};
|
||||
runtime: {
|
||||
runStatus: ChatRunUiStatus | null;
|
||||
compactionStatus: CompactionStatus | null;
|
||||
fallbackStatus: FallbackStatus | null;
|
||||
approvals: ApprovalRequest[];
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
The implementation should prefer OpenClaw-origin types where practical. This
|
||||
type sketch describes boundaries, not a new protocol to invent independently.
|
||||
|
||||
## Reconciliation Rules
|
||||
|
||||
The reducer and selectors must explicitly handle these cases:
|
||||
|
||||
- Optimistic user messages are replaced by matching transcript/history user
|
||||
messages when available.
|
||||
- A single `runId` may produce at most one visible streaming assistant group.
|
||||
- If history contains the terminal assistant for the active stream, the stream is
|
||||
no longer rendered separately.
|
||||
- `tool_use` and `tool_result` content is rendered through tool cards or process
|
||||
blocks, not as stray assistant prose.
|
||||
- `chat.send` ACK can arrive before or after the first delta.
|
||||
- `chat.history` responses use request versions so stale loads cannot overwrite
|
||||
a newer session.
|
||||
- Gateway events whose `sessionKey` or selected agent scope does not match the
|
||||
visible session do not pollute the current Chat surface.
|
||||
- Recoverable send failures keep the queued item and enter waiting-reconnect
|
||||
state.
|
||||
- Abort, error, and final events clear the correct run state without clearing
|
||||
unrelated session state.
|
||||
|
||||
## Send Reliability
|
||||
|
||||
The port should include OpenClaw-style handling for:
|
||||
|
||||
- idempotency keys
|
||||
- duplicate submit prevention
|
||||
- optimistic user message reconciliation
|
||||
- delta-before-ACK preservation
|
||||
- send queue state
|
||||
- waiting-reconnect state
|
||||
- recoverable timeout handling
|
||||
- retry after reconnect
|
||||
- terminal lifecycle reconciliation
|
||||
|
||||
This directly targets the current duplicated user query and stale terminal
|
||||
assistant rendering failures.
|
||||
|
||||
## React Surface
|
||||
|
||||
The first React surface rebuild includes:
|
||||
|
||||
```text
|
||||
ChatPage
|
||||
ChatToolbar
|
||||
ChatSurface
|
||||
RunStatusBar
|
||||
MessageList
|
||||
MessageGroup
|
||||
StreamingGroup
|
||||
ToolCard
|
||||
ThinkingBlock
|
||||
AttachmentBlock
|
||||
RawOutputPanel
|
||||
ApprovalPrompt
|
||||
ChatComposer
|
||||
ArtifactPanel
|
||||
```
|
||||
|
||||
Preserve from current ClawX:
|
||||
|
||||
- page shell and navigation
|
||||
- toolbar concepts
|
||||
- artifact/generated files panel
|
||||
- agent/model/skill picker concepts
|
||||
- textarea composer base behavior
|
||||
- staged file UX and attachment policy
|
||||
|
||||
Rebuild:
|
||||
|
||||
- message list projection
|
||||
- message grouping
|
||||
- streaming group rendering
|
||||
- tool card rendering
|
||||
- thinking block rendering
|
||||
- raw output panel
|
||||
- run status and queue display
|
||||
- compaction/fallback display
|
||||
- approval prompt
|
||||
- slash menu behavior
|
||||
|
||||
## Slash Commands and Skills
|
||||
|
||||
The composer remains textarea-based. A floating slash menu should be implemented
|
||||
with ClawX styling and i18n.
|
||||
|
||||
Phase one commands:
|
||||
|
||||
- `/help`
|
||||
- `/new`
|
||||
- `/reset`
|
||||
- `/clear`
|
||||
- `/compact`
|
||||
- `/model`
|
||||
- `/think`
|
||||
- `/verbose`
|
||||
- `/agents`
|
||||
- `/skill`
|
||||
- `/skills`
|
||||
|
||||
OpenClaw-origin command execution should be adapted through `ChatCoreClient`.
|
||||
ClawX-specific `/skill` and `/skills` behavior should integrate with existing
|
||||
skill discovery/display. At minimum, the UI must be able to list available
|
||||
skills and insert the selected skill invocation into the composer.
|
||||
|
||||
## Tool Cards
|
||||
|
||||
Tool card behavior should come from OpenClaw tool extraction semantics where
|
||||
possible, with React rendering:
|
||||
|
||||
- tool name
|
||||
- status and error state
|
||||
- arguments
|
||||
- result preview
|
||||
- raw output
|
||||
- copy actions
|
||||
- collapse/expand state
|
||||
|
||||
Canvas preview is excluded from phase one. The extraction layer may preserve
|
||||
metadata that keeps future canvas support possible.
|
||||
|
||||
## Compaction, Fallback, and Approval
|
||||
|
||||
Compaction:
|
||||
|
||||
- `/compact` calls `sessions.compact`.
|
||||
- `agent` compaction/fallback events update runtime indicators.
|
||||
- History reload and stream reconciliation run after completion.
|
||||
|
||||
Approval:
|
||||
|
||||
- `exec.approval.requested` and `plugin.approval.requested` are routed into an
|
||||
approval queue.
|
||||
- The UI presents the current approval with command/plugin context.
|
||||
- Resolve actions call `exec.approval.resolve` or `plugin.approval.resolve`.
|
||||
- Resolved or expired approvals disappear from the prompt.
|
||||
|
||||
Fallback:
|
||||
|
||||
- Upstream fallback events are surfaced as runtime indicators.
|
||||
- Fallback indicators do not create normal assistant messages.
|
||||
|
||||
## Styling and i18n
|
||||
|
||||
- All new user-facing strings must use `react-i18next`.
|
||||
- Locale coverage must include `en`, `zh`, `ja`, and `ru`.
|
||||
- Use ClawX design tokens and substitution rules from `src/styles/globals.css`.
|
||||
- Do not copy OpenClaw Web UI CSS class names as the visual contract.
|
||||
- Cards should remain restrained and desktop-app appropriate.
|
||||
- Avoid oversized marketing-style layouts in the Chat surface.
|
||||
- Text must fit at mobile and desktop viewport sizes.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### Phase 1: Event Channels and Core Skeleton
|
||||
|
||||
- Add upstream-shaped Gateway `agent` event forwarding through Main/host-events.
|
||||
- Add Renderer host event subscription for `gateway:agent-event`.
|
||||
- Vendor the minimum chat core skeleton.
|
||||
- Add the host API adapter.
|
||||
- Keep existing Chat UI running during this internal setup.
|
||||
|
||||
Validation:
|
||||
|
||||
- Unit test Main-to-Renderer event forwarding.
|
||||
- Unit test adapter request routing.
|
||||
- Run communication validation:
|
||||
- `pnpm run comms:replay`
|
||||
- `pnpm run comms:compare`
|
||||
|
||||
### Phase 2: History, Send, and Stream Reconciliation
|
||||
|
||||
- Connect `chat.history`, `chat.send`, and `chat.abort`.
|
||||
- Add idempotent send queue and recoverable failure handling.
|
||||
- Generate visible chat items from history plus live stream.
|
||||
- Replace the Chat page's primary message source with the new surface store.
|
||||
|
||||
Validation:
|
||||
|
||||
- Optimistic user message is not duplicated after history reload.
|
||||
- Delta-before-ACK does not drop content.
|
||||
- Final assistant history replaces live stream.
|
||||
- Stale history response cannot overwrite a newly selected session.
|
||||
- Basic Electron E2E for send, stream, final, history reload, and abort.
|
||||
|
||||
### Phase 3: Core React Surface
|
||||
|
||||
- Rebuild MessageList, MessageGroup, StreamingGroup, ThinkingBlock, ToolCard,
|
||||
RawOutputPanel, RunStatusBar, and ApprovalPrompt.
|
||||
- Preserve toolbar/composer/artifact shell integrations.
|
||||
- Connect generated file discovery to the new message/tool selectors.
|
||||
|
||||
Validation:
|
||||
|
||||
- Tool calls render as tool cards, not stray assistant process messages.
|
||||
- Raw output can open, copy, and close.
|
||||
- Markdown, math, images, and attachments still render.
|
||||
- Electron E2E covers tool rendering and raw output panel.
|
||||
|
||||
### Phase 4: Slash, Compaction, Fallback, and Approval
|
||||
|
||||
- Add slash menu and command execution.
|
||||
- Add `/skill` and `/skills`.
|
||||
- Add compaction status and `/compact`.
|
||||
- Add exec/plugin approval prompt and resolve actions.
|
||||
- Add fallback status display.
|
||||
|
||||
Validation:
|
||||
|
||||
- `/compact` triggers compaction and reconciles history after completion.
|
||||
- Approval requested events show a prompt and resolve correctly.
|
||||
- Slash skill list displays available skills and inserts a selected skill.
|
||||
- Reconnect state can flush queued sends without duplicates.
|
||||
|
||||
### Phase 5: Remove Old Chat Main Path
|
||||
|
||||
- Stop using ClawX `ChatRuntimeEvent` in the Chat surface.
|
||||
- Remove or isolate obsolete Chat store paths.
|
||||
- Keep reusable helpers that other pages still need.
|
||||
- Update relevant documentation and harness specs.
|
||||
|
||||
Validation:
|
||||
|
||||
- `pnpm run lint`
|
||||
- `pnpm run typecheck`
|
||||
- `pnpm test`
|
||||
- `pnpm run comms:replay`
|
||||
- `pnpm run comms:compare`
|
||||
- Relevant Electron E2E specs
|
||||
- i18n coverage for new strings
|
||||
|
||||
## Test Strategy
|
||||
|
||||
Unit tests:
|
||||
|
||||
- reducer actions
|
||||
- stream reconciliation
|
||||
- optimistic/history merge
|
||||
- stale history request versioning
|
||||
- send queue and retry state
|
||||
- event routing for upstream-shaped `agent` events
|
||||
- slash command adapter calls
|
||||
- tool card extraction
|
||||
- approval queue handling
|
||||
|
||||
Integration tests:
|
||||
|
||||
- host API adapter request mapping
|
||||
- Main event forwarding contract
|
||||
- Renderer host event binding
|
||||
- generated visible items from mixed history/live/tool events
|
||||
|
||||
Electron E2E:
|
||||
|
||||
- send message and receive streaming response
|
||||
- reload history without duplicate user message
|
||||
- final assistant clears streaming group
|
||||
- abort active run
|
||||
- render tool call and raw output
|
||||
- compaction command/status
|
||||
- approval request/resolve
|
||||
- attachment send with image and path reference
|
||||
|
||||
Communication validation:
|
||||
|
||||
- Required because the design touches renderer/Main/host-api/api-client/Gateway
|
||||
runtime paths.
|
||||
- Run `pnpm run comms:replay` and `pnpm run comms:compare` before merging.
|
||||
- Add or update harness task/rule specs that cover this communication path.
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
Risk: vendored OpenClaw code drifts from upstream.
|
||||
|
||||
Mitigation: keep a clear vendor directory, retain source comments, keep local
|
||||
changes isolated in adapters, and add tests around expected upstream semantics.
|
||||
|
||||
Risk: replacing Chat by default creates regression risk.
|
||||
|
||||
Mitigation: implement in phases, keep existing UI alive until the new source is
|
||||
ready, and require E2E coverage before deleting the old main path.
|
||||
|
||||
Risk: raw upstream event payloads are wider and less typed than ClawX events.
|
||||
|
||||
Mitigation: confine payload parsing to the chat core adapter and keep React
|
||||
components typed against selector outputs.
|
||||
|
||||
Risk: Zustand becomes another large business-logic store.
|
||||
|
||||
Mitigation: keep protocol logic in pure core modules, use Zustand only for
|
||||
snapshot storage, dispatch, subscriptions, and selectors.
|
||||
|
||||
Risk: performance regressions from token streaming.
|
||||
|
||||
Mitigation: use selector-driven subscriptions, keep message windows bounded, and
|
||||
avoid having the full Chat page subscribe to every state field.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- The same user prompt is not rendered twice after history reconciliation.
|
||||
- The final assistant answer is not left as a process/streaming message after the
|
||||
run is complete.
|
||||
- Tool use and tool results render as structured tool cards.
|
||||
- `chat.send` ACK/delta ordering races do not lose content.
|
||||
- Slow history responses do not overwrite a different selected session.
|
||||
- Gateway disconnect/reconnect does not duplicate submitted prompts.
|
||||
- Abort, error, and final events produce correct terminal run status.
|
||||
- Compaction, fallback, and approval states match OpenClaw Web UI semantics.
|
||||
- Renderer stays behind `hostApi` and host-events.
|
||||
- The Chat surface visually matches ClawX rather than OpenClaw Web UI.
|
||||
@@ -0,0 +1,296 @@
|
||||
# OpenClaw Chat P0/P1 Parity Design
|
||||
|
||||
Date: 2026-06-20
|
||||
|
||||
## Context
|
||||
|
||||
ClawX is an Electron wrapper GUI for OpenClaw. The Chat UI must keep using the
|
||||
existing Main/Renderer IPC path through `hostApi` and `api-client`; Renderer code
|
||||
must not connect to the Gateway directly.
|
||||
|
||||
The current OpenClaw chat core port receives raw Gateway agent payloads, but its
|
||||
Renderer core still models live output too narrowly: a single assistant stream
|
||||
string, simple history/queue/stream concatenation, and approval request/resolved
|
||||
states. This leaves important OpenClaw Web UI semantics under-modeled, including
|
||||
thinking output, assistant phases, live tool events, command output, patch
|
||||
summaries, terminal lifecycle metadata, and stable stream/tool interleaving.
|
||||
|
||||
This design fills P0 and P1 protocol/rendering gaps while preserving ClawX's
|
||||
current visual system and Electron integration.
|
||||
|
||||
## Goals
|
||||
|
||||
- Render OpenClaw assistant output with phase-aware semantics.
|
||||
- Distinguish thinking/reasoning from normal assistant replies.
|
||||
- Render live tool calls as tool cards before history polling catches up.
|
||||
- Preserve correct ordering among user messages, streaming assistant text, tool
|
||||
cards, command output, patch summaries, approvals, and terminal lifecycle
|
||||
events.
|
||||
- Stop relying on ClawX's former secondary event protocol for Chat UI behavior.
|
||||
- Keep ClawX's current UI style, component surfaces, IPC boundaries, and
|
||||
attachment sending semantics.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not fork OpenClaw.
|
||||
- Do not restore the old execution graph UI.
|
||||
- Do not implement canvas preview or checkpoint browsing.
|
||||
- Do not implement audio or voice rendering.
|
||||
- Do not support OpenClaw `item` or `plan` streams in this iteration.
|
||||
- Do not add direct Renderer-to-Gateway calls.
|
||||
- Do not reintroduce a ClawX-specific secondary chat event protocol.
|
||||
|
||||
## Recommended Approach
|
||||
|
||||
Use an OpenClaw-semantics core with a ClawX renderer:
|
||||
|
||||
1. Main continues to dispatch raw agent payloads to Renderer.
|
||||
2. `actionsFromAgentEvent` maps raw OpenClaw streams into semantic core actions.
|
||||
3. `chatCoreReducer` stores structured live run state.
|
||||
4. `selectVisibleChatItems` builds stable visible items from history, queue, live
|
||||
assistant segments, live thinking, live tools, runtime status, and approvals.
|
||||
5. Existing ClawX components render those items using the current design system.
|
||||
|
||||
This keeps protocol correctness in the chat core, while React components remain
|
||||
thin rendering surfaces.
|
||||
|
||||
## P0 Data Model
|
||||
|
||||
Extend `ChatCoreState.live` from a single assistant stream into structured run
|
||||
state:
|
||||
|
||||
- `assistantSegments`: timestamped assistant text segments with `phase`,
|
||||
`replace/append` behavior, and optional `mediaUrls`.
|
||||
- `currentAssistant`: the current in-progress assistant text for the active run.
|
||||
- `thinkingSegments`: timestamped thinking/reasoning text segments.
|
||||
- `currentThinking`: the current in-progress thinking text for the active run.
|
||||
- `toolStreamById`: live tool entries keyed by `toolCallId`.
|
||||
- `toolStreamOrder`: stable order of live tool calls.
|
||||
- `commandOutputs`: command output entries keyed by `toolCallId`, `itemId`, or a
|
||||
generated event key.
|
||||
- `patchSummaries`: patch summary entries keyed by `toolCallId`, `itemId`, or a
|
||||
generated event key.
|
||||
|
||||
Extend `ChatRunUiStatus` with terminal metadata:
|
||||
|
||||
- `endedAt`
|
||||
- `stopReason`
|
||||
- `livenessState`
|
||||
- `replayInvalid`
|
||||
|
||||
Keep approval state structured by stable ids:
|
||||
|
||||
- `approvalId`
|
||||
- `approvalSlug`
|
||||
- `itemId`
|
||||
- `toolCallId`
|
||||
- local fallback id
|
||||
|
||||
## P0 Event Mapping
|
||||
|
||||
`stream=assistant`
|
||||
|
||||
- Read `text`, `delta`, `replace`, `phase`, and `mediaUrls`.
|
||||
- Normalize phase as `final_answer`, `commentary`, or legacy.
|
||||
- Write to assistant live state only.
|
||||
- Do not merge commentary into final answer text.
|
||||
|
||||
`stream=thinking`
|
||||
|
||||
- Read `text` or `delta`.
|
||||
- Write to thinking live state.
|
||||
- Render separately from assistant final text.
|
||||
|
||||
`stream=tool`
|
||||
|
||||
- On `phase=start`, create a running tool card with name and args summary.
|
||||
- On `phase=update`, update partial output.
|
||||
- On `phase=result` or `phase=end`, complete the card with output, preview, and
|
||||
error state.
|
||||
- When a new tool starts, commit any current assistant text into an assistant
|
||||
segment so text that preceded the tool stays above the tool card.
|
||||
|
||||
`stream=lifecycle`
|
||||
|
||||
- `start` sets the run to running.
|
||||
- `completed`, `done`, `finished`, and `end` set the run to done and clear live
|
||||
state after history can replace transient items.
|
||||
- `error` and `failed` set the run to error.
|
||||
- `aborted` and `cancelled` set the run to interrupted, restore Send, and do not
|
||||
leave the session running.
|
||||
- Preserve `endedAt`, `stopReason`, `livenessState`, and `replayInvalid`.
|
||||
|
||||
## P0 Visible Items
|
||||
|
||||
`selectVisibleChatItems` becomes a build pipeline similar in responsibility to
|
||||
OpenClaw Web UI's `buildChatItems`, but it emits ClawX `VisibleChatItem` values.
|
||||
|
||||
Pipeline:
|
||||
|
||||
1. Normalize and filter history.
|
||||
2. Hide heartbeat acknowledgements, `NO_REPLY`, and pure internal runtime
|
||||
prompts.
|
||||
3. Extract assistant visible text by preferring `final_answer` over legacy
|
||||
unphased text.
|
||||
4. Extract thinking blocks from `content[].type === "thinking"` and legacy
|
||||
`<think>` tags.
|
||||
5. Preserve current user message and attachment echo deduplication.
|
||||
6. Convert queued sends into normal user message items while hiding them once a
|
||||
matching history message exists.
|
||||
7. Insert live assistant segments, live thinking, live tool cards, command
|
||||
output, and patch summaries.
|
||||
8. Sort by visible timestamp with stable tie-breaking.
|
||||
9. Collapse sequential duplicate display signatures.
|
||||
|
||||
Visible item kinds:
|
||||
|
||||
- `message`
|
||||
- `stream`
|
||||
- `thinking`
|
||||
- `tool`
|
||||
- `command`
|
||||
- `patch`
|
||||
- `approval`
|
||||
- `runtime`
|
||||
- `status`
|
||||
|
||||
`status` is reserved for visible errors or exceptional terminal states. Running
|
||||
state is not rendered as a full-width chat item.
|
||||
|
||||
## P0 Rendering
|
||||
|
||||
Assistant messages and assistant streams use the existing `ChatMessage` surface
|
||||
so Markdown, Sparkle avatar, copy button, and reply timestamp stay consistent.
|
||||
|
||||
Thinking is rendered as a muted, collapsible reasoning block near the associated
|
||||
assistant/tool sequence. It is not merged into the final assistant reply.
|
||||
|
||||
Live tool events render through the existing ClawX tool card style. The card
|
||||
uses a fixed default width of 50 percent of the chat viewport, with responsive
|
||||
constraints for narrow windows.
|
||||
|
||||
The running indicator is not part of the message stream. It appears at the top
|
||||
left of the composer area, directly above the input field, with a breathing
|
||||
indicator and the label `AI 回复中`.
|
||||
|
||||
## P1 Event Coverage
|
||||
|
||||
`stream=command_output`
|
||||
|
||||
- Associate with an existing tool card by `toolCallId` or `itemId` when possible.
|
||||
- If no tool association exists, render as an independent command card.
|
||||
- Show title/name, cwd, status, exit code, duration, and output summary.
|
||||
- Do not restore a raw output panel.
|
||||
|
||||
`stream=patch`
|
||||
|
||||
- Associate with an existing tool card by `toolCallId` or `itemId` when possible.
|
||||
- If no tool association exists, render as an independent patch summary card.
|
||||
- Show title/name, summary, added, modified, and deleted counts.
|
||||
- Do not restore the execution graph.
|
||||
|
||||
`stream=approval`
|
||||
|
||||
- Treat approval events as upserts keyed by approval id candidates.
|
||||
- Support `pending`, `approved`, `denied`, `failed`, and `unavailable`.
|
||||
- Remove resolved cards from the pending list.
|
||||
- Retain recent resolved ids in reducer state to prevent duplicate cards.
|
||||
|
||||
Assistant `mediaUrls`
|
||||
|
||||
- Attach live assistant `mediaUrls` to stream items.
|
||||
- During live streaming, preview image-like URLs when safe.
|
||||
- Let history reload provide complete file/path semantics for non-image media.
|
||||
|
||||
Heartbeat filtering
|
||||
|
||||
- Filter `HEARTBEAT_OK`, `NO_REPLY`, and pure heartbeat ack messages from both
|
||||
history and live output.
|
||||
- Do not treat thinking/reasoning blocks as visible heartbeat content.
|
||||
|
||||
Compaction and `session.operation`
|
||||
|
||||
- Handle `stream=compaction` and `session.operation` with `operation=compact`
|
||||
through the same `CompactionStatus`.
|
||||
- Support active, retrying, complete, and error phases.
|
||||
- Render as lightweight runtime state near the composer, not as a full chat row.
|
||||
- Do not add checkpoint browsing in this iteration.
|
||||
|
||||
## Component Boundaries
|
||||
|
||||
- `events.ts`: raw OpenClaw event to semantic core action mapping.
|
||||
- `reducer.ts`: state transitions and dedup/upsert behavior.
|
||||
- `selectors.ts`: visible item construction, filtering, ordering, and duplicate
|
||||
collapse.
|
||||
- `history.ts` or a new extractor module: assistant phase extraction, thinking
|
||||
extraction, heartbeat filtering, and display text sanitization.
|
||||
- `ToolCard.tsx`: shared rendering for history and live tool cards.
|
||||
- New lightweight cards may be added for thinking, command output, and patch
|
||||
summaries when existing components cannot represent them cleanly.
|
||||
- `ChatMessage` remains the normal assistant/user message renderer.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Unknown stream types are retained in `agent.event` for diagnostics but do not
|
||||
render visible UI.
|
||||
- Malformed tool or approval events are ignored unless enough ids exist to
|
||||
produce a stable card.
|
||||
- Terminal lifecycle events always clear abortable/sending state for the active
|
||||
run.
|
||||
- If history reload arrives after live streaming, persisted messages replace
|
||||
transient live items through duplicate and timestamp reconciliation.
|
||||
- Approval resolution failures surface through existing run error or approval
|
||||
card error paths.
|
||||
|
||||
## Tests
|
||||
|
||||
Unit coverage:
|
||||
|
||||
- `actionsFromAgentEvent` for assistant phases, thinking, tool start/update/end,
|
||||
command output, patch, lifecycle terminal phases, and approval upsert inputs.
|
||||
- Reducer coverage for live assistant/tool interleaving, terminal cleanup,
|
||||
aborted/cancelled state, approval deduplication, and queued send replacement.
|
||||
- Selector coverage for final answer priority, commentary suppression, thinking
|
||||
extraction, heartbeat filtering, timestamp sorting, and duplicate collapse.
|
||||
|
||||
Electron E2E coverage:
|
||||
|
||||
- Streaming assistant output remains after the user prompt and renders Markdown.
|
||||
- Thinking appears as a separate reasoning block.
|
||||
- Tool start/update/result renders live tool cards.
|
||||
- Command output and patch summary render without raw output controls.
|
||||
- Approval cards upsert and resolve without duplication.
|
||||
- Abort/stop restores Send and clears running state.
|
||||
- Heartbeat ack messages do not appear in the chat log.
|
||||
- The running indicator appears at the composer top-left with `AI 回复中`.
|
||||
|
||||
Manual validation:
|
||||
|
||||
- Re-run the P0/P1 parts of the existing 33-item manual plan.
|
||||
- Include normal chat, thinking model output, file read/write tools, shell command
|
||||
output, patch application, approval allow/deny, stop/abort, history reload, and
|
||||
Gateway restart.
|
||||
|
||||
Required commands after implementation:
|
||||
|
||||
```bash
|
||||
pnpm vitest run tests/unit/openclaw-chat-core-reducer.test.ts tests/unit/chat-input.test.tsx
|
||||
pnpm run test:e2e -- tests/e2e/chat-openclaw-core.spec.ts
|
||||
pnpm run typecheck
|
||||
pnpm run lint
|
||||
pnpm run comms:replay
|
||||
pnpm run comms:compare
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- User prompts do not duplicate.
|
||||
- Streaming assistant text stays after the corresponding user prompt.
|
||||
- Final assistant answers are not rendered as process/running messages.
|
||||
- Thinking is distinguishable from normal assistant replies.
|
||||
- Live tool calls, command output, patch summaries, approvals, compaction, and
|
||||
fallback states have visible, testable paths.
|
||||
- Stop/abort and terminal lifecycle events never leave the session stuck running.
|
||||
- Renderer continues to use `hostApi`/`api-client` and does not call Gateway
|
||||
endpoints directly.
|
||||
- ClawX visual style remains consistent with the current Chat UI.
|
||||
@@ -12,6 +12,28 @@ function readNumber(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function readFirstString(...values: unknown[]): string | undefined {
|
||||
for (const value of values) {
|
||||
const next = readString(value);
|
||||
if (next) return next;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readStringArray(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const entries = value.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0);
|
||||
return entries.length > 0 ? entries : undefined;
|
||||
}
|
||||
|
||||
function readApprovalDecisions(value: unknown): Array<'allow-once' | 'allow-always' | 'deny'> | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const decisions = value.filter((entry): entry is 'allow-once' | 'allow-always' | 'deny' => (
|
||||
entry === 'allow-once' || entry === 'allow-always' || entry === 'deny'
|
||||
));
|
||||
return decisions.length > 0 ? Array.from(new Set(decisions)) : undefined;
|
||||
}
|
||||
|
||||
type ChatRuntimeEventType = ChatRuntimeEvent['type'];
|
||||
type ChatRuntimeEventFor<T extends ChatRuntimeEventType> = Extract<ChatRuntimeEvent, { type: T }>;
|
||||
type ChatRuntimeEventBaseFor<T extends ChatRuntimeEventType> = Pick<
|
||||
@@ -34,6 +56,107 @@ function withBase<T extends ChatRuntimeEventType>(
|
||||
} as ChatRuntimeEventBaseFor<T>;
|
||||
}
|
||||
|
||||
function approvalNotificationKind(method: string): 'exec' | 'plugin' | null {
|
||||
if (method.startsWith('exec.approval.')) return 'exec';
|
||||
if (method.startsWith('plugin.approval.')) return 'plugin';
|
||||
return null;
|
||||
}
|
||||
|
||||
function approvalNotificationPhase(method: string): 'requested' | 'resolved' | null {
|
||||
if (method.endsWith('.requested')) return 'requested';
|
||||
if (method.endsWith('.resolved')) return 'resolved';
|
||||
return null;
|
||||
}
|
||||
|
||||
function approvalResolvedStatus(decision: string | undefined): string {
|
||||
if (decision === 'deny' || decision === 'denied') return 'denied';
|
||||
if (decision === 'allow' || decision === 'allow-once' || decision === 'allow-always' || decision === 'approved') {
|
||||
return 'approved';
|
||||
}
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
function readCommandFromApprovalRequest(request: Record<string, unknown>): string | undefined {
|
||||
const command = readString(request.command);
|
||||
if (command) return command;
|
||||
|
||||
const argv = readStringArray(request.commandArgv);
|
||||
if (argv) return argv.join(' ');
|
||||
|
||||
const systemRunPlan = asRecord(request.systemRunPlan);
|
||||
return readFirstString(
|
||||
systemRunPlan?.commandText,
|
||||
systemRunPlan?.command,
|
||||
);
|
||||
}
|
||||
|
||||
function readApprovalDetail(
|
||||
kind: 'exec' | 'plugin',
|
||||
raw: Record<string, unknown>,
|
||||
request: Record<string, unknown>,
|
||||
): string | undefined {
|
||||
if (kind === 'exec') {
|
||||
return readFirstString(
|
||||
readCommandFromApprovalRequest(request),
|
||||
raw.detail,
|
||||
request.warningText,
|
||||
raw.message,
|
||||
);
|
||||
}
|
||||
|
||||
return readFirstString(
|
||||
raw.detail,
|
||||
request.description,
|
||||
request.title,
|
||||
request.toolName,
|
||||
raw.message,
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeGatewayChatRuntimeNotification(
|
||||
method: string,
|
||||
payload: unknown,
|
||||
): ChatRuntimeEvent | null {
|
||||
const kind = approvalNotificationKind(method);
|
||||
const phase = approvalNotificationPhase(method);
|
||||
if (!kind || !phase) return null;
|
||||
|
||||
const raw = asRecord(payload);
|
||||
if (!raw) return null;
|
||||
|
||||
const request = asRecord(raw.request) ?? {};
|
||||
const approvalId = readFirstString(raw.id, raw.approvalId, raw.approval_id, request.id);
|
||||
if (!approvalId) return null;
|
||||
|
||||
const decision = readString(raw.decision);
|
||||
const command = readCommandFromApprovalRequest(request);
|
||||
const detail = readApprovalDetail(kind, raw, request);
|
||||
|
||||
return {
|
||||
type: 'approval.updated',
|
||||
runId: readFirstString(raw.runId, request.runId) ?? `approval:${approvalId}`,
|
||||
sessionKey: readFirstString(raw.sessionKey, request.sessionKey),
|
||||
seq: readNumber(raw.seq),
|
||||
ts: readNumber(raw.ts) ?? readNumber(raw.createdAtMs),
|
||||
approvalId,
|
||||
approvalSlug: readFirstString(raw.approvalSlug, raw.approval_slug, request.approvalSlug, request.approval_slug),
|
||||
itemId: readFirstString(raw.itemId, raw.item_id, request.itemId, request.item_id),
|
||||
toolCallId: readFirstString(raw.toolCallId, raw.tool_call_id, request.toolCallId, request.tool_call_id),
|
||||
title: readFirstString(raw.title, request.title),
|
||||
kind,
|
||||
phase,
|
||||
status: phase === 'requested'
|
||||
? readFirstString(raw.status, request.status) ?? 'pending'
|
||||
: readFirstString(raw.status, request.status) ?? approvalResolvedStatus(decision),
|
||||
message: readFirstString(raw.message, request.message, request.warningText),
|
||||
detail,
|
||||
command,
|
||||
agentId: readFirstString(raw.agentId, request.agentId),
|
||||
expiresAtMs: readNumber(raw.expiresAtMs),
|
||||
allowedDecisions: readApprovalDecisions(raw.allowedDecisions) ?? readApprovalDecisions(request.allowedDecisions),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeGatewayChatRuntimeEvent(payload: unknown): ChatRuntimeEvent | null {
|
||||
const raw = asRecord(payload);
|
||||
if (!raw) return null;
|
||||
@@ -196,13 +319,20 @@ export function normalizeGatewayChatRuntimeEvent(payload: unknown): ChatRuntimeE
|
||||
return base
|
||||
? {
|
||||
...base,
|
||||
itemId: readString(data.itemId),
|
||||
toolCallId: readString(data.toolCallId),
|
||||
approvalId: readString(data.approvalId) ?? readString(data.approval_id),
|
||||
approvalSlug: readString(data.approvalSlug) ?? readString(data.approval_slug),
|
||||
itemId: readString(data.itemId) ?? readString(data.item_id),
|
||||
toolCallId: readString(data.toolCallId) ?? readString(data.tool_call_id),
|
||||
title: readString(data.title),
|
||||
kind: readString(data.kind),
|
||||
phase: readString(data.phase),
|
||||
status: readString(data.status),
|
||||
message: readString(data.message),
|
||||
detail: readString(data.detail),
|
||||
command: readString(data.command),
|
||||
agentId: readString(data.agentId) ?? readString(raw.agentId),
|
||||
expiresAtMs: readNumber(data.expiresAtMs),
|
||||
allowedDecisions: readApprovalDecisions(data.allowedDecisions),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { GatewayEventType, type JsonRpcNotification } from './protocol';
|
||||
import { logger } from '../utils/logger';
|
||||
import { normalizeGatewayChatRuntimeEvent } from './chat-runtime-events';
|
||||
import {
|
||||
normalizeGatewayChatRuntimeEvent,
|
||||
normalizeGatewayChatRuntimeNotification,
|
||||
} from './chat-runtime-events';
|
||||
import type {
|
||||
GatewayChannelStatusEvent,
|
||||
GatewayChatMessageEvent,
|
||||
@@ -11,6 +14,17 @@ type GatewayEventEmitter = {
|
||||
emit: (event: string, payload: unknown) => boolean;
|
||||
};
|
||||
|
||||
function emitNormalizedRuntimeNotification(
|
||||
emitter: GatewayEventEmitter,
|
||||
event: string,
|
||||
payload: unknown,
|
||||
): void {
|
||||
const normalized = normalizeGatewayChatRuntimeNotification(event, payload);
|
||||
if (normalized) {
|
||||
emitter.emit('chat:runtime-event', normalized);
|
||||
}
|
||||
}
|
||||
|
||||
export function dispatchProtocolEvent(
|
||||
emitter: GatewayEventEmitter,
|
||||
event: string,
|
||||
@@ -23,6 +37,7 @@ export function dispatchProtocolEvent(
|
||||
emitter.emit('chat:message', { message: payload });
|
||||
break;
|
||||
case 'agent': {
|
||||
emitter.emit('agent:event', payload);
|
||||
const normalized = normalizeGatewayChatRuntimeEvent(payload);
|
||||
if (normalized) {
|
||||
emitter.emit('chat:runtime-event', normalized);
|
||||
@@ -45,6 +60,7 @@ export function dispatchProtocolEvent(
|
||||
emitter.emit('gateway:presence', payload as GatewayRuntimePayload);
|
||||
break;
|
||||
default:
|
||||
emitNormalizedRuntimeNotification(emitter, event, payload);
|
||||
emitter.emit('notification', { method: event, params: payload });
|
||||
}
|
||||
}
|
||||
@@ -54,7 +70,9 @@ export function dispatchJsonRpcNotification(
|
||||
notification: JsonRpcNotification,
|
||||
): void {
|
||||
emitter.emit('notification', notification);
|
||||
emitNormalizedRuntimeNotification(emitter, notification.method, notification.params);
|
||||
if (notification.method === 'agent') {
|
||||
emitter.emit('agent:event', notification.params);
|
||||
const normalized = normalizeGatewayChatRuntimeEvent(notification.params);
|
||||
if (normalized) {
|
||||
emitter.emit('chat:runtime-event', normalized);
|
||||
|
||||
@@ -474,6 +474,10 @@ async function initialize(): Promise<void> {
|
||||
sendMainWindowEvent('chat:runtime-event', data);
|
||||
});
|
||||
|
||||
gatewayManager.on('agent:event', (data) => {
|
||||
sendMainWindowEvent('gateway:agent-event', data);
|
||||
});
|
||||
|
||||
gatewayManager.on('channel:status', (data) => {
|
||||
sendMainWindowEvent('gateway:channel-status', data);
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ import { deviceOAuthManager } from '../utils/device-oauth';
|
||||
import { browserOAuthManager } from '../utils/browser-oauth';
|
||||
import { applyProxySettings } from './proxy';
|
||||
import { syncLaunchAtStartupSettingFromStore } from './launch-at-startup';
|
||||
import { refreshTrayMenu } from './tray';
|
||||
import { getRecentTokenUsageHistory } from '../utils/token-usage';
|
||||
import { getProviderService } from '../services/providers/provider-service';
|
||||
import {
|
||||
@@ -554,6 +555,7 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
|
||||
}
|
||||
if (key === 'language') {
|
||||
await createMenu(typeof value === 'string' ? value : undefined);
|
||||
await refreshTrayMenu(typeof value === 'string' ? value : undefined);
|
||||
}
|
||||
data = { success: true };
|
||||
break;
|
||||
@@ -572,6 +574,7 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
|
||||
}
|
||||
if (entries.some(([key]) => key === 'language')) {
|
||||
await createMenu(typeof patch.language === 'string' ? patch.language : undefined);
|
||||
await refreshTrayMenu(typeof patch.language === 'string' ? patch.language : undefined);
|
||||
}
|
||||
data = { success: true };
|
||||
break;
|
||||
@@ -582,6 +585,7 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
|
||||
await handleProxySettingsChange();
|
||||
await syncLaunchAtStartupSettingFromStore();
|
||||
await createMenu(settings.language);
|
||||
await refreshTrayMenu(settings.language);
|
||||
data = { success: true, settings };
|
||||
break;
|
||||
}
|
||||
@@ -1157,6 +1161,7 @@ function registerSettingsHandlers(gatewayManager: GatewayManager): void {
|
||||
}
|
||||
if (key === 'language') {
|
||||
await createMenu(typeof value === 'string' ? value : undefined);
|
||||
await refreshTrayMenu(typeof value === 'string' ? value : undefined);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
@@ -1183,6 +1188,7 @@ function registerSettingsHandlers(gatewayManager: GatewayManager): void {
|
||||
}
|
||||
if (entries.some(([key]) => key === 'language')) {
|
||||
await createMenu(typeof patch.language === 'string' ? patch.language : undefined);
|
||||
await refreshTrayMenu(typeof patch.language === 'string' ? patch.language : undefined);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
@@ -1194,6 +1200,7 @@ function registerSettingsHandlers(gatewayManager: GatewayManager): void {
|
||||
await handleProxySettingsChange();
|
||||
await syncLaunchAtStartupSettingFromStore();
|
||||
await createMenu(settings.language);
|
||||
await refreshTrayMenu(settings.language);
|
||||
return { success: true, settings };
|
||||
});
|
||||
}
|
||||
|
||||
+131
-80
@@ -3,9 +3,130 @@
|
||||
* Creates and manages the system tray icon and menu
|
||||
*/
|
||||
import { Tray, Menu, BrowserWindow, app, nativeImage } from 'electron';
|
||||
import type { MenuItemConstructorOptions } from 'electron';
|
||||
import { join } from 'path';
|
||||
import { MENU_LABELS, type MenuLabels } from '@shared/i18n/resources';
|
||||
import { resolveSupportedLanguage, type LanguageCode } from '@shared/language';
|
||||
import { getSetting } from '../utils/store';
|
||||
|
||||
let tray: Tray | null = null;
|
||||
let trayMainWindow: BrowserWindow | null = null;
|
||||
let trayStatus: string | null = null;
|
||||
|
||||
export type TrayMenuLabels = MenuLabels['tray'];
|
||||
|
||||
function applyTemplate(label: string, values: Record<string, string>): string {
|
||||
return Object.entries(values).reduce(
|
||||
(result, [key, value]) => result.replaceAll(`{{${key}}}`, value),
|
||||
label,
|
||||
);
|
||||
}
|
||||
|
||||
function applyAppName(label: string): string {
|
||||
return applyTemplate(label, { appName: app.name });
|
||||
}
|
||||
|
||||
async function resolveTrayLanguage(language?: string): Promise<LanguageCode> {
|
||||
if (language) return resolveSupportedLanguage(language);
|
||||
try {
|
||||
return resolveSupportedLanguage(await getSetting('language'));
|
||||
} catch {
|
||||
return resolveSupportedLanguage(app.getLocale());
|
||||
}
|
||||
}
|
||||
|
||||
export function getTrayMenuLabels(language?: string): TrayMenuLabels {
|
||||
return MENU_LABELS[resolveSupportedLanguage(language)].tray;
|
||||
}
|
||||
|
||||
function showWindow(mainWindow: BrowserWindow): void {
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
|
||||
export function buildTrayMenuTemplate(
|
||||
mainWindow: BrowserWindow,
|
||||
labels: TrayMenuLabels,
|
||||
): MenuItemConstructorOptions[] {
|
||||
return [
|
||||
{
|
||||
label: applyAppName(labels.show),
|
||||
click: () => showWindow(mainWindow),
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
{
|
||||
label: labels.gatewayStatus,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
label: ` ${labels.running}`,
|
||||
type: 'checkbox',
|
||||
checked: true,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
{
|
||||
label: labels.quickActions,
|
||||
submenu: [
|
||||
{
|
||||
label: labels.openChat,
|
||||
click: () => {
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
mainWindow.show();
|
||||
mainWindow.webContents.send('navigate', '/');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: labels.openSettings,
|
||||
click: () => {
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
mainWindow.show();
|
||||
mainWindow.webContents.send('navigate', '/settings');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
{
|
||||
label: labels.checkForUpdates,
|
||||
click: () => {
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
mainWindow.webContents.send('update:check');
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
{
|
||||
label: applyAppName(labels.quit),
|
||||
click: () => {
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function applyTrayMenu(mainWindow: BrowserWindow, labels: TrayMenuLabels): void {
|
||||
if (!tray) return;
|
||||
const tooltip = trayStatus
|
||||
? applyTemplate(labels.statusTooltip, { appName: app.name, status: trayStatus })
|
||||
: applyAppName(labels.tooltip);
|
||||
tray.setToolTip(tooltip);
|
||||
tray.setContextMenu(Menu.buildFromTemplate(buildTrayMenuTemplate(mainWindow, labels)));
|
||||
}
|
||||
|
||||
export async function refreshTrayMenu(language?: string): Promise<void> {
|
||||
if (!tray || !trayMainWindow || trayMainWindow.isDestroyed()) return;
|
||||
const resolvedLanguage = await resolveTrayLanguage(language);
|
||||
applyTrayMenu(trayMainWindow, getTrayMenuLabels(resolvedLanguage));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the icons directory path (works in both dev and packaged mode)
|
||||
@@ -53,84 +174,13 @@ export function createTray(mainWindow: BrowserWindow): Tray {
|
||||
if (process.platform === 'darwin') {
|
||||
icon.setTemplateImage(true);
|
||||
}
|
||||
|
||||
tray = new Tray(icon);
|
||||
|
||||
// Set tooltip
|
||||
tray.setToolTip('ClawX - AI Assistant');
|
||||
|
||||
const showWindow = () => {
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
};
|
||||
|
||||
// Create context menu
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: 'Show ClawX',
|
||||
click: showWindow,
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
{
|
||||
label: 'Gateway Status',
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
label: ' Running',
|
||||
type: 'checkbox',
|
||||
checked: true,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
{
|
||||
label: 'Quick Actions',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Open Chat',
|
||||
click: () => {
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
mainWindow.show();
|
||||
mainWindow.webContents.send('navigate', '/');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Open Settings',
|
||||
click: () => {
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
mainWindow.show();
|
||||
mainWindow.webContents.send('navigate', '/settings');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
{
|
||||
label: 'Check for Updates...',
|
||||
click: () => {
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
mainWindow.webContents.send('update:check');
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
{
|
||||
label: 'Quit ClawX',
|
||||
click: () => {
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
tray.setContextMenu(contextMenu);
|
||||
|
||||
tray = new Tray(icon);
|
||||
trayMainWindow = mainWindow;
|
||||
|
||||
applyTrayMenu(mainWindow, getTrayMenuLabels(app.getLocale()));
|
||||
void refreshTrayMenu();
|
||||
|
||||
// Click to show window (Windows/Linux)
|
||||
tray.on('click', () => {
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
@@ -156,9 +206,8 @@ export function createTray(mainWindow: BrowserWindow): Tray {
|
||||
* Update tray tooltip with Gateway status
|
||||
*/
|
||||
export function updateTrayStatus(status: string): void {
|
||||
if (tray) {
|
||||
tray.setToolTip(`ClawX - ${status}`);
|
||||
}
|
||||
trayStatus = status;
|
||||
void refreshTrayMenu();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -168,5 +217,7 @@ export function destroyTray(): void {
|
||||
if (tray) {
|
||||
tray.destroy();
|
||||
tray = null;
|
||||
trayMainWindow = null;
|
||||
trayStatus = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ type ChatSendWithMediaPayload = {
|
||||
message?: unknown;
|
||||
deliver?: unknown;
|
||||
idempotencyKey?: unknown;
|
||||
thinking?: unknown;
|
||||
media?: unknown;
|
||||
};
|
||||
|
||||
@@ -58,15 +59,12 @@ export function createChatApi({ gatewayManager }: { gatewayManager: GatewayManag
|
||||
const fsP = await import('node:fs/promises');
|
||||
for (const item of media) {
|
||||
const exists = await fsP.access(item.filePath).then(() => true, () => false);
|
||||
const isVision = VISION_MIME_TYPES.has(item.mimeType);
|
||||
logger.info(
|
||||
`[chat:sendWithMedia] Processing file: ${item.fileName} (${item.mimeType}), path: ${item.filePath}, exists: ${exists}, isVision: ${VISION_MIME_TYPES.has(item.mimeType)}`,
|
||||
`[chat:sendWithMedia] Processing file: ${item.fileName} (${item.mimeType}), path: ${item.filePath}, exists: ${exists}, isVision: ${isVision}`,
|
||||
);
|
||||
|
||||
fileReferences.push(
|
||||
`[media attached: ${item.filePath} (${item.mimeType}) | ${item.filePath}]`,
|
||||
);
|
||||
|
||||
if (VISION_MIME_TYPES.has(item.mimeType)) {
|
||||
if (isVision) {
|
||||
const fileBuffer = await fsP.readFile(item.filePath);
|
||||
const base64Data = fileBuffer.toString('base64');
|
||||
logger.info(`[chat:sendWithMedia] Read ${fileBuffer.length} bytes, base64 length: ${base64Data.length}`);
|
||||
@@ -75,6 +73,10 @@ export function createChatApi({ gatewayManager }: { gatewayManager: GatewayManag
|
||||
mimeType: item.mimeType,
|
||||
fileName: item.fileName,
|
||||
});
|
||||
} else {
|
||||
fileReferences.push(
|
||||
`[media attached: ${item.filePath} (${item.mimeType}) | ${item.filePath}]`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,6 +92,9 @@ export function createChatApi({ gatewayManager }: { gatewayManager: GatewayManag
|
||||
deliver: body.deliver ?? false,
|
||||
idempotencyKey,
|
||||
};
|
||||
if (typeof body.thinking === 'string' && body.thinking.trim()) {
|
||||
rpcParams.thinking = body.thinking.trim();
|
||||
}
|
||||
if (imageAttachments.length > 0) {
|
||||
rpcParams.attachments = imageAttachments;
|
||||
}
|
||||
|
||||
@@ -107,7 +107,9 @@ function mimeToExt(mimeType: string): string {
|
||||
async function generateImagePreview(filePath: string, mimeType: string): Promise<string | null> {
|
||||
try {
|
||||
const img = nativeImage.createFromPath(filePath);
|
||||
if (img.isEmpty()) return null;
|
||||
if (img.isEmpty()) {
|
||||
return mimeType.startsWith('image/') ? readImageDataUrl(filePath, mimeType) : null;
|
||||
}
|
||||
const size = img.getSize();
|
||||
const maxDim = 512;
|
||||
if (size.width > maxDim || size.height > maxDim) {
|
||||
@@ -116,14 +118,32 @@ async function generateImagePreview(filePath: string, mimeType: string): Promise
|
||||
: img.resize({ height: maxDim });
|
||||
return `data:image/png;base64,${resized.toPNG().toString('base64')}`;
|
||||
}
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
const buf = await readFile(filePath);
|
||||
return `data:${mimeType};base64,${buf.toString('base64')}`;
|
||||
return readImageDataUrl(filePath, mimeType);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readImageDataUrl(filePath: string, mimeType: string): Promise<string> {
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
const buf = await readFile(filePath);
|
||||
return `data:${mimeType};base64,${buf.toString('base64')}`;
|
||||
}
|
||||
|
||||
function sanitizeStagedFileName(fileName: string, ext: string): string {
|
||||
const fallback = ext ? `file${ext}` : 'file';
|
||||
const safeBase = basename(fileName || fallback)
|
||||
.replace(/[^\p{L}\p{N}._-]+/gu, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
const safeName = safeBase || fallback;
|
||||
if (!ext) return safeName;
|
||||
return safeName.toLowerCase().endsWith(ext.toLowerCase()) ? safeName : `${safeName}${ext}`;
|
||||
}
|
||||
|
||||
function stagedFileName(id: string, fileName: string, ext: string): string {
|
||||
return `${id}-${sanitizeStagedFileName(fileName, ext)}`;
|
||||
}
|
||||
|
||||
function requirePath(payload: unknown): string {
|
||||
const path = isRecord(payload) ? payload.path : payload;
|
||||
if (typeof path !== 'string' || !path.trim()) {
|
||||
@@ -235,7 +255,7 @@ export function createFilesApi(): CompleteHostServiceRegistry['files'] {
|
||||
}
|
||||
|
||||
const ext = extname(filePath);
|
||||
const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`);
|
||||
const stagedPath = join(OUTBOUND_DIR, stagedFileName(id, fileName, ext));
|
||||
await fsP.copyFile(filePath, stagedPath);
|
||||
const s = await fsP.stat(stagedPath);
|
||||
const mimeType = getMimeType(ext);
|
||||
@@ -257,7 +277,7 @@ export function createFilesApi(): CompleteHostServiceRegistry['files'] {
|
||||
const id = crypto.randomUUID();
|
||||
const payloadMimeType = typeof body.mimeType === 'string' ? body.mimeType : '';
|
||||
const ext = extname(body.fileName) || mimeToExt(payloadMimeType);
|
||||
const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`);
|
||||
const stagedPath = join(OUTBOUND_DIR, stagedFileName(id, body.fileName, ext));
|
||||
const buffer = Buffer.from(body.base64, 'base64');
|
||||
await fsP.writeFile(stagedPath, buffer);
|
||||
|
||||
|
||||
@@ -39,14 +39,14 @@ type ImageGenerationSettingsPayload = {
|
||||
|
||||
async function generateImagePreview(filePath: string, mimeType: string): Promise<string | null> {
|
||||
try {
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
if (mimeType === 'image/svg+xml') {
|
||||
const buf = await readFile(filePath);
|
||||
return `data:${mimeType};base64,${buf.toString('base64')}`;
|
||||
return readImageDataUrl(filePath, mimeType);
|
||||
}
|
||||
|
||||
const img = nativeImage.createFromPath(filePath);
|
||||
if (img.isEmpty()) return null;
|
||||
if (img.isEmpty()) {
|
||||
return mimeType.startsWith('image/') ? readImageDataUrl(filePath, mimeType) : null;
|
||||
}
|
||||
const size = img.getSize();
|
||||
const maxDim = 512;
|
||||
if (size.width > maxDim || size.height > maxDim) {
|
||||
@@ -55,13 +55,18 @@ async function generateImagePreview(filePath: string, mimeType: string): Promise
|
||||
: img.resize({ height: maxDim });
|
||||
return `data:image/png;base64,${resized.toPNG().toString('base64')}`;
|
||||
}
|
||||
const buf = await readFile(filePath);
|
||||
return `data:${mimeType};base64,${buf.toString('base64')}`;
|
||||
return readImageDataUrl(filePath, mimeType);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readImageDataUrl(filePath: string, mimeType: string): Promise<string> {
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
const buf = await readFile(filePath);
|
||||
return `data:${mimeType};base64,${buf.toString('base64')}`;
|
||||
}
|
||||
|
||||
async function resolveOutgoingMediaUrl(
|
||||
gatewayUrl: string,
|
||||
): Promise<{ path: string; mimeType: string } | null> {
|
||||
|
||||
@@ -235,6 +235,25 @@ export async function syncAllProviderAuthToRuntime(): Promise<void> {
|
||||
continue;
|
||||
}
|
||||
|
||||
const configForRuntime: ProviderConfig = {
|
||||
id: account.id,
|
||||
name: account.label,
|
||||
type: account.vendorId,
|
||||
baseUrl: account.baseUrl,
|
||||
apiProtocol: account.apiProtocol,
|
||||
headers: account.headers,
|
||||
model: account.model,
|
||||
fallbackModels: account.fallbackModels,
|
||||
fallbackProviderIds: account.fallbackAccountIds,
|
||||
enabled: account.enabled,
|
||||
createdAt: account.createdAt,
|
||||
updatedAt: account.updatedAt,
|
||||
};
|
||||
const context = await resolveRuntimeSyncContext(configForRuntime);
|
||||
if (context) {
|
||||
await syncRuntimeProviderConfig(configForRuntime, context);
|
||||
}
|
||||
|
||||
if (secret.type === 'api_key') {
|
||||
await saveProviderKeyToOpenClaw(runtimeProviderKey, secret.apiKey);
|
||||
continue;
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { GatewayManager } from '../gateway/manager';
|
||||
import { syncLaunchAtStartupSettingFromStore } from '../main/launch-at-startup';
|
||||
import { createMenu } from '../main/menu';
|
||||
import { applyProxySettings } from '../main/proxy';
|
||||
import { refreshTrayMenu } from '../main/tray';
|
||||
import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy';
|
||||
import {
|
||||
type AppSettings,
|
||||
@@ -95,6 +96,7 @@ async function runSettingsSideEffects(
|
||||
}
|
||||
if (patchTouchesLanguage(patch)) {
|
||||
await createMenu(typeof patch.language === 'string' ? patch.language : undefined);
|
||||
await refreshTrayMenu(typeof patch.language === 'string' ? patch.language : undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +129,7 @@ export function createSettingsApi(gatewayManager: GatewayManager): CompleteHostS
|
||||
await syncLaunchAtStartupSettingFromStore();
|
||||
const settings = await getAllSettings();
|
||||
await createMenu(settings.language);
|
||||
await refreshTrayMenu(settings.language);
|
||||
return { success: true, settings };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
export type ModelInputModality = 'text' | 'image';
|
||||
|
||||
export interface InferredCustomModelMetadata {
|
||||
input: ModelInputModality[];
|
||||
reasoning?: boolean;
|
||||
compat?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors OpenClaw 2026.5.20 custom-provider onboarding inference.
|
||||
* Unknown models use the same conservative text-only fallback as non-interactive onboarding.
|
||||
@@ -17,3 +23,38 @@ export function inferCustomModelInputModalities(modelId: string): ModelInputModa
|
||||
|
||||
return supportsImageInput ? ['text', 'image'] : ['text'];
|
||||
}
|
||||
|
||||
function isZaiCompatibleEndpoint(baseUrl?: string): boolean {
|
||||
if (!baseUrl) return false;
|
||||
try {
|
||||
const hostname = new URL(baseUrl).hostname.toLowerCase();
|
||||
return hostname === 'api.z.ai' || hostname.endsWith('.api.z.ai') || hostname === 'open.bigmodel.cn';
|
||||
} catch {
|
||||
const normalized = baseUrl.toLowerCase();
|
||||
return normalized.includes('api.z.ai') || normalized.includes('open.bigmodel.cn');
|
||||
}
|
||||
}
|
||||
|
||||
function isReasoningGlmModel(modelId: string): boolean {
|
||||
const normalized = modelId.trim().toLowerCase();
|
||||
return /(?:^|[/_-])glm-(?:5(?:[._-]\d+)?|5v(?:[._-]\w+)?|5-turbo|5v-turbo|4\.7|4\.7-flashx?|4\.6v?|4\.5(?:v|-air|-flash)?)(?:$|[/_-])/.test(normalized);
|
||||
}
|
||||
|
||||
export function inferCustomModelMetadata(
|
||||
modelId: string,
|
||||
options: { baseUrl?: string } = {},
|
||||
): InferredCustomModelMetadata {
|
||||
const metadata: InferredCustomModelMetadata = {
|
||||
input: inferCustomModelInputModalities(modelId),
|
||||
};
|
||||
|
||||
if (isZaiCompatibleEndpoint(options.baseUrl) && isReasoningGlmModel(modelId)) {
|
||||
metadata.reasoning = true;
|
||||
metadata.compat = {
|
||||
thinkingFormat: 'zai',
|
||||
supportsReasoningEffort: false,
|
||||
};
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
assertValidApiProtocol,
|
||||
normalizeOpenClawApiProtocol,
|
||||
} from '../shared/providers/types';
|
||||
import { inferCustomModelInputModalities } from '../shared/providers/model-capabilities';
|
||||
import { inferCustomModelMetadata } from '../shared/providers/model-capabilities';
|
||||
import {
|
||||
CLAWX_OPENAI_IMAGE_DEFAULT_MODEL,
|
||||
CLAWX_OPENAI_IMAGE_PROVIDER_KEY,
|
||||
@@ -1552,19 +1552,52 @@ function mergeProviderModels(
|
||||
...groups: Array<Array<Record<string, unknown>>>
|
||||
): Array<Record<string, unknown>> {
|
||||
const merged: Array<Record<string, unknown>> = [];
|
||||
const seen = new Set<string>();
|
||||
const seen = new Map<string, Record<string, unknown>>();
|
||||
|
||||
for (const group of groups) {
|
||||
for (const item of group) {
|
||||
const id = typeof item?.id === 'string' ? item.id : '';
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
if (!id) continue;
|
||||
const existing = seen.get(id);
|
||||
if (existing) {
|
||||
mergeMissingProviderModelMetadata(existing, item);
|
||||
continue;
|
||||
}
|
||||
seen.set(id, item);
|
||||
merged.push(item);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function mergeMissingProviderModelMetadata(
|
||||
target: Record<string, unknown>,
|
||||
source: Record<string, unknown>,
|
||||
): void {
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (value === undefined) continue;
|
||||
const existing = target[key];
|
||||
if (existing === undefined) {
|
||||
target[key] = value;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
key === 'compat'
|
||||
&& existing
|
||||
&& typeof existing === 'object'
|
||||
&& !Array.isArray(existing)
|
||||
&& value
|
||||
&& typeof value === 'object'
|
||||
&& !Array.isArray(value)
|
||||
) {
|
||||
target[key] = {
|
||||
...(value as Record<string, unknown>),
|
||||
...(existing as Record<string, unknown>),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenClaw 2026.5+ requires a positive `maxTokens` on each model (and can
|
||||
* fall back to provider-level `maxTokens`) when `api` is `anthropic-messages`.
|
||||
@@ -1828,7 +1861,7 @@ function upsertOpenClawProviderEntry(
|
||||
id,
|
||||
name: id,
|
||||
...(options.inferRuntimeModelInputs
|
||||
? { input: inferCustomModelInputModalities(id) }
|
||||
? inferCustomModelMetadata(id, { baseUrl: options.baseUrl })
|
||||
: {}),
|
||||
}));
|
||||
let mergedModels = mergeProviderModels(registryModels, existingModels, runtimeModels);
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
id: openclaw-chat-core-port
|
||||
title: Port OpenClaw Chat Core semantics into the ClawX Chat surface
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Replace the visible ClawX Chat UI's ClawX-specific event/rendering protocol with an OpenClaw-compatible chat core and React surface while preserving the Electron Main-owned host API boundary and ClawX visual conventions.
|
||||
touchedAreas:
|
||||
- harness/specs/tasks/openclaw-chat-core-port.md
|
||||
- docs/superpowers/plans/2026-06-19-openclaw-chat-core-port.md
|
||||
- docs/superpowers/plans/2026-06-20-openclaw-chat-p0-p1-parity.md
|
||||
- docs/superpowers/specs/2026-06-19-openclaw-chat-core-port-design.md
|
||||
- docs/superpowers/specs/2026-06-20-openclaw-chat-p0-p1-design.md
|
||||
- electron/gateway/**
|
||||
- electron/gateway/event-dispatch.ts
|
||||
- electron/gateway/ws-client.ts
|
||||
- electron/main/ipc-handlers.ts
|
||||
- electron/main/index.ts
|
||||
- electron/main/tray.ts
|
||||
- electron/preload/index.ts
|
||||
- electron/services/**
|
||||
- electron/shared/providers/model-capabilities.ts
|
||||
- electron/utils/**
|
||||
- shared/chat-runtime-events.ts
|
||||
- shared/host-api/contract.ts
|
||||
- shared/host-events/contract.ts
|
||||
- shared/i18n/locales/*/common.json
|
||||
- src/chat-core/openclaw-port/**
|
||||
- src/chat-core/clawx-adapter/**
|
||||
- src/components/layout/Sidebar.tsx
|
||||
- src/lib/host-events.ts
|
||||
- src/pages/Chat/**
|
||||
- src/stores/chat.ts
|
||||
- src/stores/chat/**
|
||||
- src/stores/openclaw-chat-surface.ts
|
||||
- shared/i18n/locales/*/chat.json
|
||||
- shared/i18n/locales/*/menu.json
|
||||
- tests/e2e/chat-*.spec.ts
|
||||
- tests/e2e/cron-run-live-status.spec.ts
|
||||
- tests/e2e/gateway-lifecycle.spec.ts
|
||||
- tests/e2e/skills-gateway-readiness.spec.ts
|
||||
- tests/e2e/chat-openclaw-core.spec.ts
|
||||
- tests/e2e/chat-question-directory.spec.ts
|
||||
- tests/e2e/chat-run-state-events.spec.ts
|
||||
- tests/e2e/chat-scroll-pin-bottom.spec.ts
|
||||
- tests/e2e/chat-scroll-to-latest.spec.ts
|
||||
- tests/e2e/chat-skill-trigger-i18n.spec.ts
|
||||
- tests/unit/chat-history-reply-while-sending.test.tsx
|
||||
- tests/unit/chat-input.test.tsx
|
||||
- tests/unit/chat-leading-orphan-tools.test.tsx
|
||||
- tests/unit/chat-page-execution-graph.test.tsx
|
||||
- tests/unit/chat-tool-card-suppression.test.tsx
|
||||
- tests/unit/*.test.ts
|
||||
- tests/unit/*.test.tsx
|
||||
- tests/unit/gateway-agent-events.test.ts
|
||||
- tests/unit/host-events.test.ts
|
||||
- tests/unit/openclaw-chat-core-adapter.test.ts
|
||||
- tests/unit/openclaw-chat-core-reducer.test.ts
|
||||
- tests/unit/openclaw-chat-message-extraction.test.ts
|
||||
- tests/unit/openclaw-chat-surface-render.test.tsx
|
||||
- tests/unit/openclaw-chat-surface-store.test.ts
|
||||
- tests/unit/slash-command-executor.test.ts
|
||||
expectedUserBehavior:
|
||||
- Chat history renders from the OpenClaw-compatible chat surface without duplicating user messages.
|
||||
- Gateway agent event payloads reach Renderer in an upstream-shaped form; visible Chat rendering does not depend on ClawX ChatRuntimeEvent as its source of truth.
|
||||
- Tool calls render as reusable expandable tool cards with input and output details.
|
||||
- Slash command support exposes Skills via the Chat composer path.
|
||||
- Runtime compaction, fallback, and approval requests render in the Chat surface, and approval decisions flow back through hostApi-backed Gateway RPC.
|
||||
- Existing ClawX attachment semantics remain unchanged: images use base64 payloads and other attachments pass paths through the current composer/send path.
|
||||
- Existing question directory and scroll-to-latest behavior continue to work on the OpenClaw surface.
|
||||
- Thinking/reasoning output renders separately from normal assistant replies.
|
||||
- Assistant final_answer content is preferred over commentary content when displaying final replies.
|
||||
- Live tool, command_output, and patch agent streams render before history polling catches up.
|
||||
- Lifecycle aborted/cancelled events clear sending and abortable UI state.
|
||||
- The running state appears as a composer-adjacent pulse labeled "AI 回复中" instead of a full-width message row.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
requiredRules:
|
||||
- renderer-main-boundary
|
||||
- backend-communication-boundary
|
||||
- api-client-transport-policy
|
||||
- host-api-fallback-policy
|
||||
- host-events-fallback-policy
|
||||
- gateway-readiness-policy
|
||||
- comms-regression
|
||||
- docs-sync
|
||||
requiredTests:
|
||||
- pnpm vitest run tests/unit/openclaw-chat-message-extraction.test.ts tests/unit/openclaw-chat-core-reducer.test.ts
|
||||
- pnpm run test:e2e -- tests/e2e/chat-openclaw-core.spec.ts
|
||||
- pnpm run typecheck:web
|
||||
- pnpm exec vitest run tests/unit/openclaw-chat-core-reducer.test.ts tests/unit/openclaw-chat-surface-render.test.tsx tests/unit/openclaw-chat-surface-store.test.ts tests/unit/chat-input.test.tsx tests/unit/slash-command-executor.test.ts tests/unit/i18n-locale-parity.test.ts
|
||||
- pnpm run build:vite
|
||||
- pnpm exec playwright test tests/e2e/chat-openclaw-core.spec.ts tests/e2e/chat-skill-trigger-i18n.spec.ts tests/e2e/chat-question-directory.spec.ts tests/e2e/chat-scroll-to-latest.spec.ts tests/e2e/chat-scroll-pin-bottom.spec.ts tests/e2e/chat-run-state-events.spec.ts
|
||||
- pnpm run comms:replay
|
||||
- pnpm run comms:compare
|
||||
acceptance:
|
||||
- Renderer page/component code uses host-api, api-client, or host-events and does not introduce direct IPC, Gateway HTTP, or Gateway WebSocket calls.
|
||||
- Main forwards upstream-shaped Gateway agent events to Renderer for Chat consumption.
|
||||
- The visible Chat message list is rendered by the OpenClaw-compatible surface/store path, not by the legacy hidden ClawX execution graph renderer.
|
||||
- Tool cards, slash Skills, compaction, fallback, and approval states have focused unit or E2E coverage.
|
||||
- Communication replay and comparison pass without changing the accepted baseline.
|
||||
docs:
|
||||
required: true
|
||||
---
|
||||
@@ -78,6 +78,8 @@ export type ChatRuntimeEvent =
|
||||
})
|
||||
| (ChatRuntimeEventBase & {
|
||||
type: 'approval.updated';
|
||||
approvalId?: string;
|
||||
approvalSlug?: string;
|
||||
itemId?: string;
|
||||
toolCallId?: string;
|
||||
title?: string;
|
||||
@@ -85,4 +87,9 @@ export type ChatRuntimeEvent =
|
||||
phase?: string;
|
||||
status?: string;
|
||||
message?: string;
|
||||
detail?: string;
|
||||
command?: string;
|
||||
agentId?: string;
|
||||
expiresAtMs?: number;
|
||||
allowedDecisions?: Array<'allow-once' | 'allow-always' | 'deny'>;
|
||||
});
|
||||
|
||||
@@ -573,6 +573,7 @@ export type ChatSendWithMediaPayload = {
|
||||
message?: string;
|
||||
deliver?: boolean;
|
||||
idempotencyKey: string;
|
||||
thinking?: string;
|
||||
media?: ChatMediaItem[];
|
||||
};
|
||||
export type ChatSendWithMediaResult = HostSuccess & {
|
||||
|
||||
@@ -15,6 +15,14 @@ export type GatewayChatMessageEvent = GatewayRuntimeRecord & {
|
||||
message?: GatewayRuntimePayload;
|
||||
runId?: GatewayRuntimePayload;
|
||||
};
|
||||
export type GatewayAgentEventPayload = JsonRecord & {
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
runId?: string;
|
||||
seq?: number;
|
||||
stream?: string;
|
||||
data?: JsonRecord;
|
||||
};
|
||||
export type GatewayChannelStatusEvent = {
|
||||
channelId: string;
|
||||
status: string;
|
||||
@@ -69,6 +77,7 @@ export type HostEventContract = {
|
||||
healthChanged: (payload: GatewayRuntimePayload) => void;
|
||||
presenceChanged: (payload: GatewayRuntimePayload) => void;
|
||||
chatMessage: (payload: GatewayChatMessageEvent) => void;
|
||||
agentEvent: (payload: GatewayAgentEventPayload) => void;
|
||||
channelStatus: (payload: GatewayChannelStatusEvent) => void;
|
||||
exit: (payload: GatewayExitEvent) => void;
|
||||
error: (payload: GatewayErrorEvent) => void;
|
||||
@@ -116,6 +125,7 @@ export const HOST_EVENT_CHANNELS = {
|
||||
healthChanged: 'gateway:health-changed',
|
||||
presenceChanged: 'gateway:presence-changed',
|
||||
chatMessage: 'gateway:chat-message',
|
||||
agentEvent: 'gateway:agent-event',
|
||||
channelStatus: 'gateway:channel-status',
|
||||
exit: 'gateway:exit',
|
||||
error: 'gateway:error',
|
||||
|
||||
@@ -29,6 +29,39 @@
|
||||
"runError": {
|
||||
"title": "Model call failed"
|
||||
},
|
||||
"runStatus": {
|
||||
"idle": "Idle",
|
||||
"running": "Running",
|
||||
"done": "Done",
|
||||
"interrupted": "Interrupted",
|
||||
"error": "Error"
|
||||
},
|
||||
"toolCard": {
|
||||
"show": "Show",
|
||||
"hide": "Hide",
|
||||
"error": "Error",
|
||||
"calling": "Calling {{tool}}",
|
||||
"preview": "Preview"
|
||||
},
|
||||
"thinkingBlock": {
|
||||
"title": "Thinking",
|
||||
"completedTitle": "Thinking process"
|
||||
},
|
||||
"commandCard": {
|
||||
"title": "Command",
|
||||
"exitCode": "exit {{code}}",
|
||||
"durationMs": "{{count}} ms",
|
||||
"durationSeconds": "{{value}} s"
|
||||
},
|
||||
"patchCard": {
|
||||
"title": "Patch applied",
|
||||
"files_one": "{{count}} file",
|
||||
"files_few": "{{count}} files",
|
||||
"files_many": "{{count}} files",
|
||||
"files_other": "{{count}} files",
|
||||
"modified": "{{count}} modified",
|
||||
"moreFiles": "+{{count}} more"
|
||||
},
|
||||
"artifactPanel": {
|
||||
"tabs": {
|
||||
"changes": "Changes",
|
||||
@@ -168,8 +201,37 @@
|
||||
"previewLoading": "Loading image preview…",
|
||||
"previewUnavailable": "Image preview is temporarily unavailable"
|
||||
},
|
||||
"runtime": {
|
||||
"compaction": {
|
||||
"active": "Compacting context",
|
||||
"retrying": "Retrying after compaction",
|
||||
"complete": "Context compacted",
|
||||
"error": "Compaction failed"
|
||||
},
|
||||
"fallback": {
|
||||
"active": "Using fallback model",
|
||||
"cleared": "Fallback cleared",
|
||||
"error": "Fallback failed"
|
||||
}
|
||||
},
|
||||
"approval": {
|
||||
"title": "Approval required",
|
||||
"status": {
|
||||
"pending": "Pending approval",
|
||||
"unavailable": "Approval unavailable",
|
||||
"approved": "Approved",
|
||||
"denied": "Denied",
|
||||
"failed": "Approval failed"
|
||||
},
|
||||
"allowOnce": "Allow once",
|
||||
"allowAlways": "Allow for session",
|
||||
"deny": "Deny"
|
||||
},
|
||||
"composer": {
|
||||
"attachFiles": "Attach files",
|
||||
"runningPulse": "AI is replying",
|
||||
"slashCommands": "Slash commands",
|
||||
"slashSkillsHeading": "Skills",
|
||||
"pickSkill": "Choose skill",
|
||||
"skillButton": "Skills",
|
||||
"skillPickerTitle": "Quick skill access for {{agent}}",
|
||||
@@ -187,7 +249,10 @@
|
||||
"send": "Send",
|
||||
"stop": "Stop",
|
||||
"gatewayConnected": "connected",
|
||||
"gatewayStatus": "gateway {{state}} | port: {{port}} {{pid}}",
|
||||
"gatewayConnectedState": "Gateway connected",
|
||||
"gatewayStartingState": "Gateway starting",
|
||||
"gatewayPid": " | PID: {{pid}}",
|
||||
"gatewayStatus": "{{state}} | port: {{port}}{{pid}}",
|
||||
"retryFailedAttachments": "Retry failed attachments",
|
||||
"folderDropUnsupported": "Couldn't attach this folder. Drag it again, or paste the folder path into your message.",
|
||||
"folderAttachment": "Folder",
|
||||
@@ -205,4 +270,4 @@
|
||||
"fallback": "Question {{number}}",
|
||||
"moreHint": "{{count}} more questions not shown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,5 +55,17 @@
|
||||
"documentation": "Documentation",
|
||||
"reportIssue": "Report Issue",
|
||||
"openClawDocumentation": "OpenClaw Documentation"
|
||||
},
|
||||
"tray": {
|
||||
"tooltip": "{{appName}} - AI Assistant",
|
||||
"statusTooltip": "{{appName}} - {{status}}",
|
||||
"show": "Show {{appName}}",
|
||||
"gatewayStatus": "Gateway Status",
|
||||
"running": "Running",
|
||||
"quickActions": "Quick Actions",
|
||||
"openChat": "Open Chat",
|
||||
"openSettings": "Open Settings",
|
||||
"checkForUpdates": "Check for Updates...",
|
||||
"quit": "Quit {{appName}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,39 @@
|
||||
"runError": {
|
||||
"title": "モデル呼び出しに失敗しました"
|
||||
},
|
||||
"runStatus": {
|
||||
"idle": "待機中",
|
||||
"running": "実行中",
|
||||
"done": "完了",
|
||||
"interrupted": "中断",
|
||||
"error": "エラー"
|
||||
},
|
||||
"toolCard": {
|
||||
"show": "表示",
|
||||
"hide": "非表示",
|
||||
"error": "エラー",
|
||||
"calling": "{{tool}} を呼び出し",
|
||||
"preview": "プレビュー"
|
||||
},
|
||||
"thinkingBlock": {
|
||||
"title": "考え中",
|
||||
"completedTitle": "思考プロセス"
|
||||
},
|
||||
"commandCard": {
|
||||
"title": "コマンド",
|
||||
"exitCode": "終了コード {{code}}",
|
||||
"durationMs": "{{count}} ミリ秒",
|
||||
"durationSeconds": "{{value}} 秒"
|
||||
},
|
||||
"patchCard": {
|
||||
"title": "パッチを適用しました",
|
||||
"files_one": "{{count}} ファイル",
|
||||
"files_few": "{{count}} ファイル",
|
||||
"files_many": "{{count}} ファイル",
|
||||
"files_other": "{{count}} ファイル",
|
||||
"modified": "{{count}} 件変更",
|
||||
"moreFiles": "ほか {{count}} 件"
|
||||
},
|
||||
"artifactPanel": {
|
||||
"tabs": {
|
||||
"changes": "変更",
|
||||
@@ -168,8 +201,37 @@
|
||||
"previewLoading": "画像プレビューを読み込んでいます…",
|
||||
"previewUnavailable": "画像プレビューを一時的に表示できません"
|
||||
},
|
||||
"runtime": {
|
||||
"compaction": {
|
||||
"active": "コンテキストを圧縮中",
|
||||
"retrying": "圧縮後に再試行中",
|
||||
"complete": "コンテキストを圧縮しました",
|
||||
"error": "コンテキスト圧縮に失敗しました"
|
||||
},
|
||||
"fallback": {
|
||||
"active": "フォールバックモデルを使用中",
|
||||
"cleared": "フォールバック状態をクリアしました",
|
||||
"error": "フォールバックに失敗しました"
|
||||
}
|
||||
},
|
||||
"approval": {
|
||||
"title": "承認が必要",
|
||||
"status": {
|
||||
"pending": "承認待ち",
|
||||
"unavailable": "承認を利用できません",
|
||||
"approved": "承認済み",
|
||||
"denied": "拒否済み",
|
||||
"failed": "承認に失敗しました"
|
||||
},
|
||||
"allowOnce": "一度だけ許可",
|
||||
"allowAlways": "このセッションで許可",
|
||||
"deny": "拒否"
|
||||
},
|
||||
"composer": {
|
||||
"attachFiles": "ファイルを添付",
|
||||
"runningPulse": "AI が返信中",
|
||||
"slashCommands": "スラッシュコマンド",
|
||||
"slashSkillsHeading": "スキル",
|
||||
"pickSkill": "Skill を選択",
|
||||
"skillButton": "Skills",
|
||||
"skillPickerTitle": "{{agent}} のクイック Skill",
|
||||
@@ -187,7 +249,10 @@
|
||||
"send": "送信",
|
||||
"stop": "停止",
|
||||
"gatewayConnected": "接続済み",
|
||||
"gatewayStatus": "ゲートウェイ {{state}} | ポート: {{port}} {{pid}}",
|
||||
"gatewayConnectedState": "ゲートウェイ接続済み",
|
||||
"gatewayStartingState": "ゲートウェイ起動中",
|
||||
"gatewayPid": " | PID: {{pid}}",
|
||||
"gatewayStatus": "{{state}} | ポート: {{port}}{{pid}}",
|
||||
"retryFailedAttachments": "失敗した添付を再試行",
|
||||
"folderDropUnsupported": "このフォルダを添付できませんでした。もう一度ドラッグするか、メッセージにフォルダパスを貼り付けてください。",
|
||||
"folderAttachment": "フォルダ",
|
||||
@@ -205,4 +270,4 @@
|
||||
"fallback": "質問 {{number}}",
|
||||
"moreHint": "さらに {{count}} 件の質問は表示されていません"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,5 +55,17 @@
|
||||
"documentation": "ドキュメント",
|
||||
"reportIssue": "問題を報告",
|
||||
"openClawDocumentation": "OpenClaw ドキュメント"
|
||||
},
|
||||
"tray": {
|
||||
"tooltip": "{{appName}} - AI アシスタント",
|
||||
"statusTooltip": "{{appName}} - {{status}}",
|
||||
"show": "{{appName}} を表示",
|
||||
"gatewayStatus": "ゲートウェイ状態",
|
||||
"running": "実行中",
|
||||
"quickActions": "クイックアクション",
|
||||
"openChat": "チャットを開く",
|
||||
"openSettings": "設定を開く",
|
||||
"checkForUpdates": "更新を確認...",
|
||||
"quit": "{{appName}} を終了"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,39 @@
|
||||
"runError": {
|
||||
"title": "Ошибка вызова модели"
|
||||
},
|
||||
"runStatus": {
|
||||
"idle": "Ожидание",
|
||||
"running": "Выполняется",
|
||||
"done": "Готово",
|
||||
"interrupted": "Прервано",
|
||||
"error": "Ошибка"
|
||||
},
|
||||
"toolCard": {
|
||||
"show": "Показать",
|
||||
"hide": "Скрыть",
|
||||
"error": "Ошибка",
|
||||
"calling": "Вызов {{tool}}",
|
||||
"preview": "Предпросмотр"
|
||||
},
|
||||
"thinkingBlock": {
|
||||
"title": "Думаю",
|
||||
"completedTitle": "Ход рассуждений"
|
||||
},
|
||||
"commandCard": {
|
||||
"title": "Команда",
|
||||
"exitCode": "код {{code}}",
|
||||
"durationMs": "{{count}} мс",
|
||||
"durationSeconds": "{{value}} с"
|
||||
},
|
||||
"patchCard": {
|
||||
"title": "Патч применён",
|
||||
"files_one": "{{count}} файл",
|
||||
"files_few": "{{count}} файла",
|
||||
"files_many": "{{count}} файлов",
|
||||
"files_other": "{{count}} файла",
|
||||
"modified": "изменено: {{count}}",
|
||||
"moreFiles": "ещё {{count}}"
|
||||
},
|
||||
"artifactPanel": {
|
||||
"tabs": {
|
||||
"changes": "Изменения",
|
||||
@@ -168,8 +201,37 @@
|
||||
"previewLoading": "Загрузка предпросмотра изображения…",
|
||||
"previewUnavailable": "Предпросмотр изображения временно недоступен"
|
||||
},
|
||||
"runtime": {
|
||||
"compaction": {
|
||||
"active": "Сжатие контекста",
|
||||
"retrying": "Повтор после сжатия",
|
||||
"complete": "Контекст сжат",
|
||||
"error": "Не удалось сжать контекст"
|
||||
},
|
||||
"fallback": {
|
||||
"active": "Используется резервная модель",
|
||||
"cleared": "Резервный режим очищен",
|
||||
"error": "Резервная модель не сработала"
|
||||
}
|
||||
},
|
||||
"approval": {
|
||||
"title": "Требуется подтверждение",
|
||||
"status": {
|
||||
"pending": "Ожидает подтверждения",
|
||||
"unavailable": "Подтверждение недоступно",
|
||||
"approved": "Подтверждено",
|
||||
"denied": "Отклонено",
|
||||
"failed": "Подтверждение не удалось"
|
||||
},
|
||||
"allowOnce": "Разрешить один раз",
|
||||
"allowAlways": "Разрешить для сессии",
|
||||
"deny": "Отклонить"
|
||||
},
|
||||
"composer": {
|
||||
"attachFiles": "Прикрепить файлы",
|
||||
"runningPulse": "AI отвечает",
|
||||
"slashCommands": "Slash-команды",
|
||||
"slashSkillsHeading": "Навыки",
|
||||
"pickSkill": "Выбрать Skill",
|
||||
"skillButton": "Skills",
|
||||
"skillPickerTitle": "Быстрый доступ к Skill для {{agent}}",
|
||||
@@ -187,7 +249,10 @@
|
||||
"send": "Отправить",
|
||||
"stop": "Остановить",
|
||||
"gatewayConnected": "подключён",
|
||||
"gatewayStatus": "шлюз {{state}} | порт: {{port}} {{pid}}",
|
||||
"gatewayConnectedState": "Шлюз подключён",
|
||||
"gatewayStartingState": "Шлюз запускается",
|
||||
"gatewayPid": " | PID: {{pid}}",
|
||||
"gatewayStatus": "{{state}} | порт: {{port}}{{pid}}",
|
||||
"retryFailedAttachments": "Повторить неудавшиеся вложения",
|
||||
"folderDropUnsupported": "Не удалось прикрепить эту папку. Перетащите её снова или вставьте путь к папке в сообщение.",
|
||||
"folderAttachment": "Папка",
|
||||
@@ -205,4 +270,4 @@
|
||||
"fallback": "Вопрос {{number}}",
|
||||
"moreHint": "Ещё {{count}} вопросов не показано"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,5 +55,17 @@
|
||||
"documentation": "Документация",
|
||||
"reportIssue": "Сообщить о проблеме",
|
||||
"openClawDocumentation": "Документация OpenClaw"
|
||||
},
|
||||
"tray": {
|
||||
"tooltip": "{{appName}} - AI-ассистент",
|
||||
"statusTooltip": "{{appName}} - {{status}}",
|
||||
"show": "Показать {{appName}}",
|
||||
"gatewayStatus": "Статус шлюза",
|
||||
"running": "Запущен",
|
||||
"quickActions": "Быстрые действия",
|
||||
"openChat": "Открыть чат",
|
||||
"openSettings": "Открыть настройки",
|
||||
"checkForUpdates": "Проверить обновления...",
|
||||
"quit": "Выйти из {{appName}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,39 @@
|
||||
"runError": {
|
||||
"title": "模型调用失败"
|
||||
},
|
||||
"runStatus": {
|
||||
"idle": "空闲",
|
||||
"running": "运行中",
|
||||
"done": "已完成",
|
||||
"interrupted": "已中断",
|
||||
"error": "错误"
|
||||
},
|
||||
"toolCard": {
|
||||
"show": "显示",
|
||||
"hide": "隐藏",
|
||||
"error": "错误",
|
||||
"calling": "调用 {{tool}}",
|
||||
"preview": "预览"
|
||||
},
|
||||
"thinkingBlock": {
|
||||
"title": "思考中",
|
||||
"completedTitle": "思考过程"
|
||||
},
|
||||
"commandCard": {
|
||||
"title": "命令",
|
||||
"exitCode": "退出码 {{code}}",
|
||||
"durationMs": "{{count}} 毫秒",
|
||||
"durationSeconds": "{{value}} 秒"
|
||||
},
|
||||
"patchCard": {
|
||||
"title": "已应用补丁",
|
||||
"files_one": "{{count}} 个文件",
|
||||
"files_few": "{{count}} 个文件",
|
||||
"files_many": "{{count}} 个文件",
|
||||
"files_other": "{{count}} 个文件",
|
||||
"modified": "修改 {{count}}",
|
||||
"moreFiles": "另有 {{count}} 个"
|
||||
},
|
||||
"artifactPanel": {
|
||||
"tabs": {
|
||||
"changes": "变更",
|
||||
@@ -168,8 +201,37 @@
|
||||
"previewLoading": "正在载入图片预览…",
|
||||
"previewUnavailable": "图片预览暂时不可用"
|
||||
},
|
||||
"runtime": {
|
||||
"compaction": {
|
||||
"active": "正在压缩上下文",
|
||||
"retrying": "压缩后重试中",
|
||||
"complete": "上下文已压缩",
|
||||
"error": "上下文压缩失败"
|
||||
},
|
||||
"fallback": {
|
||||
"active": "正在使用备用模型",
|
||||
"cleared": "备用模型状态已清除",
|
||||
"error": "备用模型失败"
|
||||
}
|
||||
},
|
||||
"approval": {
|
||||
"title": "需要审批",
|
||||
"status": {
|
||||
"pending": "等待审批",
|
||||
"unavailable": "审批不可用",
|
||||
"approved": "已批准",
|
||||
"denied": "已拒绝",
|
||||
"failed": "审批失败"
|
||||
},
|
||||
"allowOnce": "允许一次",
|
||||
"allowAlways": "本会话允许",
|
||||
"deny": "拒绝"
|
||||
},
|
||||
"composer": {
|
||||
"attachFiles": "添加文件",
|
||||
"runningPulse": "AI 回复中",
|
||||
"slashCommands": "斜杠命令",
|
||||
"slashSkillsHeading": "技能",
|
||||
"pickSkill": "选择技能",
|
||||
"skillButton": "技能",
|
||||
"skillPickerTitle": "{{agent}} 的快捷技能",
|
||||
@@ -187,7 +249,10 @@
|
||||
"send": "发送",
|
||||
"stop": "停止",
|
||||
"gatewayConnected": "已连接",
|
||||
"gatewayStatus": "gateway {{state}} | port: {{port}} {{pid}}",
|
||||
"gatewayConnectedState": "网关已连接",
|
||||
"gatewayStartingState": "网关启动中",
|
||||
"gatewayPid": " | PID: {{pid}}",
|
||||
"gatewayStatus": "{{state}} | 端口: {{port}}{{pid}}",
|
||||
"retryFailedAttachments": "重试失败的附件",
|
||||
"folderDropUnsupported": "无法添加此文件夹。请重新拖入,或在消息中粘贴文件夹路径。",
|
||||
"folderAttachment": "文件夹",
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
"gateway": {
|
||||
"notRunning": "网关未运行",
|
||||
"notRunningDesc": "OpenClaw 网关需要运行才能使用此功能。它将自动启动,或者您可以从设置中启动。",
|
||||
"restarting": "Gateway 重启中",
|
||||
"restarting": "网关重启中",
|
||||
"warning": "网关未运行。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,5 +55,17 @@
|
||||
"documentation": "文档",
|
||||
"reportIssue": "报告问题",
|
||||
"openClawDocumentation": "OpenClaw 文档"
|
||||
},
|
||||
"tray": {
|
||||
"tooltip": "{{appName}} - AI 助手",
|
||||
"statusTooltip": "{{appName}} - {{status}}",
|
||||
"show": "显示 {{appName}}",
|
||||
"gatewayStatus": "网关状态",
|
||||
"running": "运行中",
|
||||
"quickActions": "快捷操作",
|
||||
"openChat": "打开聊天",
|
||||
"openSettings": "打开设置",
|
||||
"checkForUpdates": "检查更新...",
|
||||
"quit": "退出 {{appName}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
export type ClawXStagedFile = {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
export function extractClawXStagedFiles(params: Record<string, unknown>): ClawXStagedFile[] {
|
||||
const files = params.clawxStagedFiles;
|
||||
if (!Array.isArray(files)) return [];
|
||||
|
||||
return files.filter((file): file is ClawXStagedFile => {
|
||||
if (!file || typeof file !== 'object') return false;
|
||||
const entry = file as Record<string, unknown>;
|
||||
return (
|
||||
typeof entry.fileName === 'string'
|
||||
&& typeof entry.filePath === 'string'
|
||||
&& typeof entry.mimeType === 'string'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function stripClawXAdapterFields<T extends Record<string, unknown>>(
|
||||
params: T,
|
||||
): Omit<T, 'clawxStagedFiles'> {
|
||||
const rest: Record<string, unknown> = { ...params };
|
||||
delete rest.clawxStagedFiles;
|
||||
return rest as Omit<T, 'clawxStagedFiles'>;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { hostApi } from '@/lib/host-api';
|
||||
import type { ChatCoreClient } from '@/chat-core/openclaw-port/types';
|
||||
import { extractClawXStagedFiles, stripClawXAdapterFields } from './attachments';
|
||||
|
||||
type ChatSendWithMediaResponse = {
|
||||
success?: boolean;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function unwrapMediaSendResult(response: ChatSendWithMediaResponse): unknown {
|
||||
if (response?.success === false) {
|
||||
throw new Error(response.error || 'chat.send media request failed');
|
||||
}
|
||||
return response?.result ?? response;
|
||||
}
|
||||
|
||||
export function createClawXChatCoreClient(): ChatCoreClient {
|
||||
return {
|
||||
async request<T>(
|
||||
method: string,
|
||||
params: Record<string, unknown> = {},
|
||||
timeoutMs?: number,
|
||||
): Promise<T> {
|
||||
if (method === 'chat.send') {
|
||||
const stagedFiles = extractClawXStagedFiles(params);
|
||||
if (stagedFiles.length > 0) {
|
||||
const thinking = typeof params.thinking === 'string' && params.thinking.trim()
|
||||
? params.thinking.trim()
|
||||
: undefined;
|
||||
const response = await hostApi.chat.sendWithMedia({
|
||||
sessionKey: String(params.sessionKey ?? ''),
|
||||
message: String(params.message ?? ''),
|
||||
media: stagedFiles,
|
||||
idempotencyKey: typeof params.idempotencyKey === 'string'
|
||||
? params.idempotencyKey
|
||||
: '',
|
||||
...(thinking ? { thinking } : {}),
|
||||
});
|
||||
return unwrapMediaSendResult(response as ChatSendWithMediaResponse) as T;
|
||||
}
|
||||
}
|
||||
|
||||
return hostApi.gateway.rpc<T>(method, stripClawXAdapterFields(params), timeoutMs);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { hostEvents } from '@/lib/host-events';
|
||||
import { actionsFromAgentEvent } from '@/chat-core/openclaw-port/events';
|
||||
import type { ChatCoreAction } from '@/chat-core/openclaw-port/actions';
|
||||
import { extractMessageText } from '@/chat-core/openclaw-port/history';
|
||||
import type { ChatRuntimeEvent } from '@shared/chat-runtime-events';
|
||||
import type {
|
||||
ChatRunUiStatus,
|
||||
OpenClawAgentEvent,
|
||||
RawOpenClawMessage,
|
||||
} from '@/chat-core/openclaw-port/types';
|
||||
|
||||
type Dispatch = (action: ChatCoreAction) => void;
|
||||
|
||||
function normalizeChatMessagePayload(payload: Record<string, unknown>): ChatCoreAction | null {
|
||||
const state = typeof payload.state === 'string' ? payload.state : undefined;
|
||||
const runId = typeof payload.runId === 'string' ? payload.runId : undefined;
|
||||
const sessionKey = typeof payload.sessionKey === 'string' ? payload.sessionKey : undefined;
|
||||
const message = payload.message as RawOpenClawMessage | undefined;
|
||||
const messageText = extractMessageText(message ?? {});
|
||||
const deltaText = typeof payload.deltaText === 'string' ? payload.deltaText : undefined;
|
||||
const replace = payload.replace === true;
|
||||
if (!state || !runId) return null;
|
||||
|
||||
if (state === 'delta') {
|
||||
const hasMessageText = messageText.trim().length > 0;
|
||||
const hasDeltaText = deltaText !== undefined;
|
||||
const text = hasMessageText ? messageText : deltaText ?? '';
|
||||
return {
|
||||
type: 'chat.delta',
|
||||
sessionKey,
|
||||
runId,
|
||||
text,
|
||||
mode: hasMessageText || replace ? 'replace' : hasDeltaText ? 'append' : 'replace',
|
||||
ts: Date.now(),
|
||||
};
|
||||
}
|
||||
if (state === 'final') return { type: 'chat.final', sessionKey, runId };
|
||||
if (state === 'error') {
|
||||
return {
|
||||
type: 'chat.error',
|
||||
sessionKey,
|
||||
runId,
|
||||
error: typeof payload.errorMessage === 'string' ? payload.errorMessage : 'Chat run failed',
|
||||
};
|
||||
}
|
||||
if (state === 'aborted') {
|
||||
return { type: 'chat.error', sessionKey, runId, error: 'aborted' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function actionsFromChatRuntimeEvent(event: ChatRuntimeEvent): ChatCoreAction[] {
|
||||
if (event.type === 'run.started') {
|
||||
return [{
|
||||
type: 'run.status',
|
||||
...(event.sessionKey ? { sessionKey: event.sessionKey } : {}),
|
||||
status: {
|
||||
phase: 'running',
|
||||
runId: event.runId,
|
||||
...(event.sessionKey ? { sessionKey: event.sessionKey } : {}),
|
||||
},
|
||||
}];
|
||||
}
|
||||
|
||||
if (event.type === 'run.ended') {
|
||||
const phase: ChatRunUiStatus['phase'] = event.status === 'aborted'
|
||||
? 'interrupted'
|
||||
: event.status === 'error'
|
||||
? 'error'
|
||||
: 'done';
|
||||
|
||||
return [{
|
||||
type: 'run.status',
|
||||
...(event.sessionKey ? { sessionKey: event.sessionKey } : {}),
|
||||
status: {
|
||||
phase,
|
||||
runId: event.runId,
|
||||
...(event.sessionKey ? { sessionKey: event.sessionKey } : {}),
|
||||
...(event.error ? { message: event.error } : {}),
|
||||
...(event.endedAt !== undefined ? { endedAt: event.endedAt } : {}),
|
||||
...(event.stopReason ? { stopReason: event.stopReason } : {}),
|
||||
...(event.livenessState ? { livenessState: event.livenessState } : {}),
|
||||
...(event.replayInvalid !== undefined ? { replayInvalid: event.replayInvalid } : {}),
|
||||
},
|
||||
}];
|
||||
}
|
||||
|
||||
if (event.type !== 'approval.updated') return [];
|
||||
|
||||
return actionsFromAgentEvent({
|
||||
sessionKey: event.sessionKey,
|
||||
agentId: event.agentId,
|
||||
runId: event.runId,
|
||||
seq: event.seq,
|
||||
stream: 'approval',
|
||||
ts: event.ts,
|
||||
data: {
|
||||
approvalId: event.approvalId,
|
||||
approvalSlug: event.approvalSlug,
|
||||
itemId: event.itemId,
|
||||
toolCallId: event.toolCallId,
|
||||
title: event.title,
|
||||
kind: event.kind,
|
||||
phase: event.phase,
|
||||
status: event.status,
|
||||
message: event.message,
|
||||
detail: event.detail,
|
||||
command: event.command,
|
||||
agentId: event.agentId,
|
||||
expiresAtMs: event.expiresAtMs,
|
||||
allowedDecisions: event.allowedDecisions,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function subscribeOpenClawChatHostEvents(dispatch: Dispatch): () => void {
|
||||
const cleanups = [
|
||||
hostEvents.onGatewayChatMessage((payload) => {
|
||||
const action = normalizeChatMessagePayload(payload as Record<string, unknown>);
|
||||
if (action) dispatch(action);
|
||||
}),
|
||||
hostEvents.onGatewayAgentEvent((payload) => {
|
||||
for (const action of actionsFromAgentEvent(payload as OpenClawAgentEvent)) {
|
||||
dispatch(action);
|
||||
}
|
||||
}),
|
||||
hostEvents.onChatRuntimeEvent((payload) => {
|
||||
for (const action of actionsFromChatRuntimeEvent(payload as ChatRuntimeEvent)) {
|
||||
dispatch(action);
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
return () => {
|
||||
for (const cleanup of cleanups) cleanup();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type {
|
||||
AssistantStreamPhase,
|
||||
ApprovalRequest,
|
||||
CommandOutputEntry,
|
||||
CompactionStatus,
|
||||
FallbackStatus,
|
||||
ChatQueueItem,
|
||||
ChatRunUiStatus,
|
||||
LiveToolEntry,
|
||||
OpenClawAgentEvent,
|
||||
PatchSummaryEntry,
|
||||
RawOpenClawMessage,
|
||||
} from './types';
|
||||
|
||||
export type ToolStreamActionPayload = Omit<
|
||||
LiveToolEntry,
|
||||
'commandOutputIds' | 'patchSummaryIds' | 'order'
|
||||
>;
|
||||
|
||||
export type CommandOutputActionPayload = Omit<CommandOutputEntry, 'order'>;
|
||||
export type PatchSummaryActionPayload = Omit<PatchSummaryEntry, 'order'>;
|
||||
|
||||
export type ChatCoreAction =
|
||||
| { type: 'session.changed'; sessionKey: string; selectedAgentId?: string }
|
||||
| { type: 'history.requested'; sessionKey: string; requestVersion: number }
|
||||
| {
|
||||
type: 'history.loaded';
|
||||
sessionKey: string;
|
||||
requestVersion: number;
|
||||
messages: RawOpenClawMessage[];
|
||||
hasMore: boolean;
|
||||
}
|
||||
| { type: 'send.enqueued'; item: ChatQueueItem }
|
||||
| { type: 'send.acked'; id: string; runId: string }
|
||||
| { type: 'send.aborted'; sessionKey?: string; runId?: string | null }
|
||||
| { type: 'send.failed'; id: string; error: string; recoverable: boolean }
|
||||
| {
|
||||
type: 'assistant.delta';
|
||||
sessionKey?: string;
|
||||
runId: string;
|
||||
text: string;
|
||||
phase: AssistantStreamPhase;
|
||||
ts: number;
|
||||
mediaUrls?: string[];
|
||||
mode?: 'replace' | 'append';
|
||||
}
|
||||
| { type: 'thinking.delta'; sessionKey?: string; runId: string; text: string; ts: number; mode?: 'replace' | 'append' }
|
||||
| { type: 'chat.delta'; sessionKey?: string; runId: string; text: string; ts: number; mode?: 'replace' | 'append' }
|
||||
| { type: 'chat.final'; sessionKey?: string; runId: string }
|
||||
| { type: 'chat.error'; sessionKey?: string; runId?: string; error: string }
|
||||
| { type: 'agent.event'; event: OpenClawAgentEvent }
|
||||
| { type: 'tool.started'; sessionKey?: string; tool: ToolStreamActionPayload }
|
||||
| { type: 'tool.updated'; sessionKey?: string; tool: ToolStreamActionPayload }
|
||||
| { type: 'tool.completed'; sessionKey?: string; tool: ToolStreamActionPayload }
|
||||
| { type: 'command.output'; sessionKey?: string; output: CommandOutputActionPayload }
|
||||
| { type: 'patch.completed'; sessionKey?: string; patch: PatchSummaryActionPayload }
|
||||
| { type: 'run.status'; sessionKey?: string; status: ChatRunUiStatus | null }
|
||||
| { type: 'runtime.compaction'; sessionKey?: string; status: CompactionStatus | null }
|
||||
| { type: 'runtime.fallback'; sessionKey?: string; status: FallbackStatus | null }
|
||||
| { type: 'approval.upserted'; approval: ApprovalRequest }
|
||||
| { type: 'approval.requested'; approval: ApprovalRequest }
|
||||
| { type: 'approval.resolved'; sessionKey?: string; ids: string[] };
|
||||
@@ -0,0 +1,813 @@
|
||||
/*
|
||||
* Vendored from OpenClaw Web UI on 2026-06-19.
|
||||
* Local ClawX changes must stay adapter-oriented and must not add Renderer
|
||||
* direct Gateway access.
|
||||
*/
|
||||
|
||||
import type { ChatCoreAction } from './actions';
|
||||
import type { AssistantStreamPhase, ChatRunUiStatus, OpenClawAgentEvent } from './types';
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function eventData(event: OpenClawAgentEvent): Record<string, unknown> {
|
||||
return asRecord(event.data) ?? {};
|
||||
}
|
||||
|
||||
function stringField(data: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = data[key];
|
||||
return typeof value === 'string' && value.trim() ? value : undefined;
|
||||
}
|
||||
|
||||
function booleanField(data: Record<string, unknown>, key: string): boolean | undefined {
|
||||
const value = data[key];
|
||||
return typeof value === 'boolean' ? value : undefined;
|
||||
}
|
||||
|
||||
function stringArrayField(data: Record<string, unknown>, key: string): string[] | undefined {
|
||||
const value = data[key];
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const strings = value.filter((item): item is string => (
|
||||
typeof item === 'string' && item.trim().length > 0
|
||||
));
|
||||
return strings.length > 0 ? strings : undefined;
|
||||
}
|
||||
|
||||
function mediaUrlsField(data: Record<string, unknown>): string[] | undefined {
|
||||
const urls = [
|
||||
stringField(data, 'mediaUrl'),
|
||||
...(stringArrayField(data, 'mediaUrls') ?? []),
|
||||
].filter((url): url is string => typeof url === 'string');
|
||||
const uniqueUrls = Array.from(new Set(urls));
|
||||
return uniqueUrls.length > 0 ? uniqueUrls : undefined;
|
||||
}
|
||||
|
||||
function firstStringArrayField(data: Record<string, unknown>, keys: string[]): string[] | undefined {
|
||||
for (const key of keys) {
|
||||
const value = stringArrayField(data, key);
|
||||
if (value) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function numberField(record: Record<string, unknown>, key: string): number | undefined {
|
||||
const value = record[key];
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function firstString(values: Array<unknown>): string | undefined {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'string' && value.trim()) return value;
|
||||
if (Array.isArray(value)) {
|
||||
const nested = firstString(value);
|
||||
if (nested) return nested;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function firstNumber(data: Record<string, unknown>, keys: string[]): number | undefined {
|
||||
for (const key of keys) {
|
||||
const value = numberField(data, key);
|
||||
if (value !== undefined) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function firstValue(data: Record<string, unknown>, keys: string[]): unknown {
|
||||
for (const key of keys) {
|
||||
if (Object.prototype.hasOwnProperty.call(data, key)) return data[key];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function firstStringField(data: Record<string, unknown>, keys: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = stringField(data, key);
|
||||
if (value) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function serializeEventValue(value: unknown): string | undefined {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return Object.prototype.toString.call(value);
|
||||
}
|
||||
}
|
||||
|
||||
function serializedField(data: Record<string, unknown>, keys: string[]): string | undefined {
|
||||
return serializeEventValue(firstValue(data, keys));
|
||||
}
|
||||
|
||||
function firstThinkingContent(value: unknown): string | undefined {
|
||||
if (typeof value === 'string' && value.trim()) return value;
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const nested = firstThinkingContent(item);
|
||||
if (nested) return nested;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const record = asRecord(value);
|
||||
if (!record) return undefined;
|
||||
return firstStringField(record, [
|
||||
'thinking',
|
||||
'reasoning',
|
||||
'reasoningText',
|
||||
'reasoning_text',
|
||||
'reasoningContent',
|
||||
'reasoning_content',
|
||||
'summary',
|
||||
'summaryText',
|
||||
'summary_text',
|
||||
'text',
|
||||
'content',
|
||||
]);
|
||||
}
|
||||
|
||||
function normalizeKind(value: unknown): string {
|
||||
return typeof value === 'string' ? value.replace(/[_-]/g, '').toLowerCase() : '';
|
||||
}
|
||||
|
||||
function firstReasoningContent(value: unknown, acceptBareString = false): string | undefined {
|
||||
if (typeof value === 'string' && value.trim()) return acceptBareString ? value : undefined;
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const nested = firstReasoningContent(item);
|
||||
if (nested) return nested;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const record = asRecord(value);
|
||||
if (!record) return undefined;
|
||||
|
||||
const reasoningKeys = [
|
||||
'thinking',
|
||||
'reasoning',
|
||||
'reasoningText',
|
||||
'reasoning_text',
|
||||
'reasoningContent',
|
||||
'reasoning_content',
|
||||
'summary',
|
||||
'summaryText',
|
||||
'summary_text',
|
||||
];
|
||||
const direct = firstStringField(record, reasoningKeys);
|
||||
if (direct) return direct;
|
||||
for (const key of reasoningKeys) {
|
||||
const nested = firstReasoningContent(record[key], true);
|
||||
if (nested) return nested;
|
||||
}
|
||||
|
||||
const kind = normalizeKind(record.type);
|
||||
if (
|
||||
(kind === 'thinking' || kind === 'reasoning' || kind === 'reasoningcontent')
|
||||
&& typeof record.text === 'string'
|
||||
&& record.text.trim()
|
||||
) {
|
||||
return record.text;
|
||||
}
|
||||
|
||||
for (const key of [
|
||||
'delta',
|
||||
'deltaContent',
|
||||
'delta_content',
|
||||
'thinkingDelta',
|
||||
'thinking_delta',
|
||||
'reasoningDelta',
|
||||
'reasoning_delta',
|
||||
'content',
|
||||
'message',
|
||||
'payload',
|
||||
]) {
|
||||
const nested = firstReasoningContent(record[key]);
|
||||
if (nested) return nested;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function debugMissingThinkingText(
|
||||
event: OpenClawAgentEvent,
|
||||
data: Record<string, unknown>,
|
||||
): void {
|
||||
const env = typeof import.meta !== 'undefined' ? import.meta.env : undefined;
|
||||
if (!env?.DEV) return;
|
||||
console.debug('[ClawX Chat] thinking event had no displayable text', {
|
||||
stream: event.stream,
|
||||
runId: event.runId,
|
||||
keys: Object.keys(data),
|
||||
});
|
||||
}
|
||||
|
||||
function reasoningTokensForEventData(data: Record<string, unknown>): number | undefined {
|
||||
const direct = firstNumber(data, ['reasoningTokens', 'reasoning_tokens']);
|
||||
if (direct !== undefined) return direct;
|
||||
const usage = asRecord(data.usage);
|
||||
return usage ? firstNumber(usage, ['reasoningTokens', 'reasoning_tokens']) : undefined;
|
||||
}
|
||||
|
||||
function debugMissingAssistantReasoningText(
|
||||
event: OpenClawAgentEvent,
|
||||
data: Record<string, unknown>,
|
||||
): void {
|
||||
const reasoningTokens = reasoningTokensForEventData(data);
|
||||
if (!reasoningTokens) return;
|
||||
const env = typeof import.meta !== 'undefined' ? import.meta.env : undefined;
|
||||
if (!env?.DEV) return;
|
||||
console.debug('[ClawX Chat] assistant event has reasoning tokens but no displayable thinking', {
|
||||
stream: event.stream,
|
||||
runId: event.runId,
|
||||
reasoningTokens,
|
||||
keys: Object.keys(data),
|
||||
});
|
||||
}
|
||||
|
||||
function compactCandidateIds(values: Array<unknown>): string[] {
|
||||
return values.filter((value): value is string => (
|
||||
typeof value === 'string' && value.trim().length > 0
|
||||
));
|
||||
}
|
||||
|
||||
function toolCallIdField(data: Record<string, unknown>): string | undefined {
|
||||
return firstStringField(data, ['toolCallId', 'tool_call_id', 'toolUseId', 'tool_use_id']);
|
||||
}
|
||||
|
||||
function callIdField(data: Record<string, unknown>): string | undefined {
|
||||
return firstStringField(data, ['callId', 'call_id']);
|
||||
}
|
||||
|
||||
function itemIdField(data: Record<string, unknown>): string | undefined {
|
||||
return firstStringField(data, ['itemId', 'item_id']);
|
||||
}
|
||||
|
||||
function toolIdField(data: Record<string, unknown>): string | undefined {
|
||||
return firstStringField(data, ['toolId', 'tool_id']);
|
||||
}
|
||||
|
||||
function toolItemIdField(data: Record<string, unknown>): string | undefined {
|
||||
return firstStringField(data, ['toolItemId', 'tool_item_id']);
|
||||
}
|
||||
|
||||
function parentIdField(data: Record<string, unknown>): string | undefined {
|
||||
return firstStringField(data, ['parentId', 'parent_id', 'parentToolId', 'parent_tool_id']);
|
||||
}
|
||||
|
||||
function parentItemIdField(data: Record<string, unknown>): string | undefined {
|
||||
return firstStringField(data, ['parentItemId', 'parent_item_id', 'parentToolItemId', 'parent_tool_item_id']);
|
||||
}
|
||||
|
||||
function timestampField(data: Record<string, unknown>, keys: string[]): number | undefined {
|
||||
return firstNumber(data, keys);
|
||||
}
|
||||
|
||||
let fallbackToolIdCounter = 0;
|
||||
let fallbackStreamEntryIdCounter = 0;
|
||||
|
||||
function stableStreamEntryId(
|
||||
prefix: string,
|
||||
event: OpenClawAgentEvent,
|
||||
data: Record<string, unknown>,
|
||||
ts: number,
|
||||
): string {
|
||||
const explicitId = stringField(data, 'id');
|
||||
if (explicitId) return `${prefix}:${explicitId}`;
|
||||
const itemId = itemIdField(data);
|
||||
if (itemId) return `${prefix}:${itemId}`;
|
||||
const eventPart = event.seq
|
||||
?? `${numberField(event, 'ts') ?? ts}:${++fallbackStreamEntryIdCounter}`;
|
||||
return `${prefix}:${event.runId ?? 'event'}:${eventPart}`;
|
||||
}
|
||||
|
||||
function stableToolIdentity(
|
||||
event: OpenClawAgentEvent,
|
||||
data: Record<string, unknown>,
|
||||
ts: number,
|
||||
): { id: string; identitySource: 'explicit' | 'fallback' } {
|
||||
const explicitId = firstStringField(data, ['id'])
|
||||
?? itemIdField(data)
|
||||
?? toolIdField(data)
|
||||
?? toolCallIdField(data)
|
||||
?? callIdField(data);
|
||||
if (explicitId) return { id: explicitId, identitySource: 'explicit' };
|
||||
|
||||
const eventPart = event.seq !== undefined
|
||||
? event.seq
|
||||
: `${numberField(event, 'ts') ?? ts}:${++fallbackToolIdCounter}`;
|
||||
return {
|
||||
id: `tool:${event.runId ?? 'event'}:${eventPart}`,
|
||||
identitySource: 'fallback',
|
||||
};
|
||||
}
|
||||
|
||||
function toolFingerprint(data: Record<string, unknown>): string {
|
||||
const fingerprint = {
|
||||
name: firstStringField(data, ['name', 'toolName', 'tool_name']) ?? 'tool',
|
||||
title: stringField(data, 'title') ?? null,
|
||||
args: firstValue(data, ['args', 'arguments', 'input']) ?? null,
|
||||
};
|
||||
try {
|
||||
return JSON.stringify(fingerprint);
|
||||
} catch {
|
||||
return `${fingerprint.name}:${fingerprint.title ?? ''}:${String(fingerprint.args)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function approvalIds(event: OpenClawAgentEvent, data: Record<string, unknown>): string[] {
|
||||
return compactCandidateIds([
|
||||
data.approvalId,
|
||||
data.approval_id,
|
||||
data.approvalSlug,
|
||||
data.approval_slug,
|
||||
data.id,
|
||||
data.itemId,
|
||||
data.item_id,
|
||||
data.toolCallId,
|
||||
data.tool_call_id,
|
||||
event.runId,
|
||||
]);
|
||||
}
|
||||
|
||||
function approvalDecisions(value: unknown): Array<'allow-once' | 'allow-always' | 'deny'> | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const decisions = value.filter((entry): entry is 'allow-once' | 'allow-always' | 'deny' => (
|
||||
entry === 'allow-once' || entry === 'allow-always' || entry === 'deny'
|
||||
));
|
||||
return decisions.length > 0 ? Array.from(new Set(decisions)) : undefined;
|
||||
}
|
||||
|
||||
function normalizeAssistantPhase(value: string | undefined): AssistantStreamPhase {
|
||||
if (value === 'commentary' || value === 'final_answer') return value;
|
||||
return 'legacy';
|
||||
}
|
||||
|
||||
function lifecycleMetadata(data: Record<string, unknown>): Partial<ChatRunUiStatus> {
|
||||
const endedAt = numberField(data, 'endedAt');
|
||||
const stopReason = stringField(data, 'stopReason');
|
||||
const livenessState = stringField(data, 'livenessState');
|
||||
const replayInvalid = booleanField(data, 'replayInvalid');
|
||||
|
||||
return {
|
||||
...(endedAt !== undefined ? { endedAt } : {}),
|
||||
...(stopReason ? { stopReason } : {}),
|
||||
...(livenessState ? { livenessState } : {}),
|
||||
...(replayInvalid !== undefined ? { replayInvalid } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function fallbackStepStatus(data: Record<string, unknown>): { phase: 'active' | 'cleared' | 'error'; message?: string } {
|
||||
const outcome = stringField(data, 'fallbackStepFinalOutcome');
|
||||
const fromModel = stringField(data, 'fallbackStepFromModel');
|
||||
const toModel = stringField(data, 'fallbackStepToModel');
|
||||
const prefix = fromModel && toModel
|
||||
? `${fromModel} -> ${toModel}`
|
||||
: toModel ?? fromModel;
|
||||
const detail = firstString([
|
||||
data.fallbackStepFromFailureDetail,
|
||||
data.fallbackStepFromFailureReason,
|
||||
data.message,
|
||||
data.reason,
|
||||
]);
|
||||
const message = prefix && detail
|
||||
? `${prefix}: ${detail}`
|
||||
: prefix ?? detail;
|
||||
|
||||
return {
|
||||
phase: outcome === 'chain_exhausted'
|
||||
? 'error'
|
||||
: outcome === 'succeeded'
|
||||
? 'cleared'
|
||||
: 'active',
|
||||
...(message ? { message } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function sessionOperationPayload(
|
||||
event: OpenClawAgentEvent,
|
||||
data: Record<string, unknown>,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (event.stream !== 'session.operation' && event.event !== 'session.operation') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return asRecord(event.payload)
|
||||
?? (Object.keys(data).length > 0 ? data : event);
|
||||
}
|
||||
|
||||
function runStatusAction(
|
||||
event: OpenClawAgentEvent,
|
||||
status: ChatRunUiStatus,
|
||||
): ChatCoreAction {
|
||||
return {
|
||||
type: 'run.status',
|
||||
...(event.sessionKey ? { sessionKey: event.sessionKey } : {}),
|
||||
status: {
|
||||
...status,
|
||||
...(event.sessionKey ? { sessionKey: event.sessionKey } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function actionsFromAgentEvent(event: OpenClawAgentEvent): ChatCoreAction[] {
|
||||
const actions: ChatCoreAction[] = [{ type: 'agent.event', event }];
|
||||
const data = eventData(event);
|
||||
const phase = stringField(data, 'phase');
|
||||
|
||||
if (event.stream === 'assistant' && event.runId) {
|
||||
const text = stringField(data, 'text');
|
||||
const delta = stringField(data, 'delta');
|
||||
const replace = booleanField(data, 'replace') === true;
|
||||
const visibleText = text ?? delta;
|
||||
const mediaUrls = mediaUrlsField(data);
|
||||
const reasoningText = firstReasoningContent(data);
|
||||
if (visibleText || mediaUrls?.length) {
|
||||
actions.push({
|
||||
type: 'assistant.delta',
|
||||
sessionKey: event.sessionKey,
|
||||
runId: event.runId,
|
||||
text: visibleText ?? '',
|
||||
phase: normalizeAssistantPhase(phase),
|
||||
...(mediaUrls ? { mediaUrls } : {}),
|
||||
mode: text || replace ? 'replace' : 'append',
|
||||
ts: numberField(event, 'ts') ?? Date.now(),
|
||||
});
|
||||
}
|
||||
if (reasoningText) {
|
||||
actions.push({
|
||||
type: 'thinking.delta',
|
||||
sessionKey: event.sessionKey,
|
||||
runId: event.runId,
|
||||
text: reasoningText,
|
||||
mode: 'replace',
|
||||
ts: numberField(event, 'ts') ?? Date.now(),
|
||||
});
|
||||
} else {
|
||||
debugMissingAssistantReasoningText(event, data);
|
||||
}
|
||||
}
|
||||
|
||||
if ((event.stream === 'thinking' || event.stream === 'plan' || event.stream === 'reasoning') && event.runId) {
|
||||
const text = firstStringField(data, [
|
||||
'text',
|
||||
'thinking',
|
||||
'reasoning',
|
||||
'reasoningText',
|
||||
'reasoning_text',
|
||||
'reasoningContent',
|
||||
'reasoning_content',
|
||||
'content',
|
||||
])
|
||||
?? firstThinkingContent(firstValue(data, ['content', 'message', 'payload']));
|
||||
const delta = firstStringField(data, [
|
||||
'delta',
|
||||
'deltaContent',
|
||||
'delta_content',
|
||||
'thinkingDelta',
|
||||
'thinking_delta',
|
||||
'reasoningDelta',
|
||||
'reasoning_delta',
|
||||
])
|
||||
?? firstThinkingContent(firstValue(data, [
|
||||
'delta',
|
||||
'deltaContent',
|
||||
'delta_content',
|
||||
'thinkingDelta',
|
||||
'thinking_delta',
|
||||
'reasoningDelta',
|
||||
'reasoning_delta',
|
||||
]));
|
||||
const replace = booleanField(data, 'replace') === true;
|
||||
const visibleText = text ?? delta;
|
||||
if (visibleText) {
|
||||
actions.push({
|
||||
type: 'thinking.delta',
|
||||
sessionKey: event.sessionKey,
|
||||
runId: event.runId,
|
||||
text: visibleText,
|
||||
mode: text || replace ? 'replace' : 'append',
|
||||
ts: numberField(event, 'ts') ?? Date.now(),
|
||||
});
|
||||
} else {
|
||||
debugMissingThinkingText(event, data);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.stream === 'tool' && event.runId) {
|
||||
const toolCallId = toolCallIdField(data);
|
||||
const ts = numberField(event, 'ts') ?? Date.now();
|
||||
const identity = stableToolIdentity(event, data, ts);
|
||||
if (identity.id) {
|
||||
const normalizedPhase = phase?.toLowerCase();
|
||||
const callId = callIdField(data);
|
||||
const itemId = itemIdField(data);
|
||||
const explicitToolId = toolIdField(data);
|
||||
const output = serializedField(data, ['output', 'result', 'partialResult', 'partial_result']);
|
||||
const errorText = firstStringField(data, ['error', 'errorText', 'error_text', 'errorExcerpt', 'error_excerpt', 'message']);
|
||||
const tool = {
|
||||
id: identity.id,
|
||||
...(itemId ? { itemId } : {}),
|
||||
...(explicitToolId ? { toolId: explicitToolId } : {}),
|
||||
...(toolCallId ? { toolCallId } : {}),
|
||||
...(callId && callId !== toolCallId ? { callId } : {}),
|
||||
runId: event.runId,
|
||||
sessionKey: event.sessionKey,
|
||||
name: firstStringField(data, ['name', 'toolName', 'tool_name']) ?? 'tool',
|
||||
...(stringField(data, 'title') ? { title: stringField(data, 'title') } : {}),
|
||||
status: stringField(data, 'status') ?? normalizedPhase,
|
||||
args: firstValue(data, ['args', 'arguments', 'input']),
|
||||
...(output !== undefined ? { output } : {}),
|
||||
...(booleanField(data, 'isError') !== undefined ? { isError: booleanField(data, 'isError') } : {}),
|
||||
...(booleanField(data, 'is_error') !== undefined ? { isError: booleanField(data, 'is_error') } : {}),
|
||||
...(errorText ? { errorText } : {}),
|
||||
rawPayload: data,
|
||||
identitySource: identity.identitySource,
|
||||
fingerprint: toolFingerprint(data),
|
||||
startedAt: timestampField(data, ['startedAt', 'started_at', 'startTime', 'start_time']) ?? ts,
|
||||
updatedAt: timestampField(data, ['updatedAt', 'updated_at', 'updatedTime', 'updated_time']) ?? ts,
|
||||
};
|
||||
|
||||
if (normalizedPhase === 'start' || normalizedPhase === 'started' || normalizedPhase === 'begin') {
|
||||
actions.push({ type: 'tool.started', sessionKey: event.sessionKey, tool });
|
||||
} else if (
|
||||
normalizedPhase === 'result'
|
||||
|| normalizedPhase === 'end'
|
||||
|| normalizedPhase === 'completed'
|
||||
|| normalizedPhase === 'done'
|
||||
|| normalizedPhase === 'finished'
|
||||
) {
|
||||
actions.push({ type: 'tool.completed', sessionKey: event.sessionKey, tool });
|
||||
} else if (
|
||||
normalizedPhase === 'update'
|
||||
|| normalizedPhase === 'updated'
|
||||
|| normalizedPhase === 'delta'
|
||||
|| normalizedPhase === 'partial'
|
||||
|| output !== undefined
|
||||
) {
|
||||
actions.push({ type: 'tool.updated', sessionKey: event.sessionKey, tool });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.stream === 'command_output' && event.runId) {
|
||||
const ts = numberField(event, 'ts') ?? Date.now();
|
||||
const toolCallId = toolCallIdField(data);
|
||||
const itemId = itemIdField(data);
|
||||
const toolId = toolIdField(data);
|
||||
const toolItemId = toolItemIdField(data);
|
||||
const callId = callIdField(data);
|
||||
const parentId = parentIdField(data);
|
||||
const parentItemId = parentItemIdField(data);
|
||||
const command = firstStringField(data, ['command', 'cmd', 'commandText', 'command_text']);
|
||||
const stdout = serializedField(data, ['stdout']);
|
||||
const stderr = serializedField(data, ['stderr']);
|
||||
const stdoutExcerpt = serializedField(data, ['stdoutExcerpt', 'stdout_excerpt']);
|
||||
const stderrExcerpt = serializedField(data, ['stderrExcerpt', 'stderr_excerpt']);
|
||||
const output = serializedField(data, ['output', 'text', 'content'])
|
||||
?? stdout
|
||||
?? stdoutExcerpt
|
||||
?? stderr
|
||||
?? stderrExcerpt;
|
||||
actions.push({
|
||||
type: 'command.output',
|
||||
sessionKey: event.sessionKey,
|
||||
output: {
|
||||
id: stableStreamEntryId('command', event, data, ts),
|
||||
runId: event.runId,
|
||||
...(itemId ? { itemId } : {}),
|
||||
...(toolCallId ? { toolCallId } : {}),
|
||||
...(toolId ? { toolId } : {}),
|
||||
...(toolItemId ? { toolItemId } : {}),
|
||||
...(callId ? { callId } : {}),
|
||||
...(parentId ? { parentId } : {}),
|
||||
...(parentItemId ? { parentItemId } : {}),
|
||||
...(firstStringField(data, ['name', 'commandName']) ? { name: firstStringField(data, ['name', 'commandName']) } : {}),
|
||||
...(stringField(data, 'title') ? { title: stringField(data, 'title') } : {}),
|
||||
...(command ? { command } : {}),
|
||||
...(output !== undefined ? { output } : {}),
|
||||
...(stdout !== undefined ? { stdout } : {}),
|
||||
...(stderr !== undefined ? { stderr } : {}),
|
||||
...(stdoutExcerpt !== undefined ? { stdoutExcerpt } : {}),
|
||||
...(stderrExcerpt !== undefined ? { stderrExcerpt } : {}),
|
||||
...(stringField(data, 'status') ? { status: stringField(data, 'status') } : {}),
|
||||
...(phase ? { phase } : {}),
|
||||
...(firstNumber(data, ['exitCode', 'exit_code']) !== undefined
|
||||
? { exitCode: firstNumber(data, ['exitCode', 'exit_code']) }
|
||||
: {}),
|
||||
...(firstNumber(data, ['durationMs', 'duration_ms']) !== undefined
|
||||
? { durationMs: firstNumber(data, ['durationMs', 'duration_ms']) }
|
||||
: {}),
|
||||
...(stringField(data, 'cwd') ? { cwd: stringField(data, 'cwd') } : {}),
|
||||
rawPayload: data,
|
||||
...(timestampField(data, ['startedAt', 'started_at', 'startTime', 'start_time']) !== undefined
|
||||
? { startedAt: timestampField(data, ['startedAt', 'started_at', 'startTime', 'start_time']) }
|
||||
: {}),
|
||||
...(timestampField(data, ['updatedAt', 'updated_at', 'updatedTime', 'updated_time']) !== undefined
|
||||
? { updatedAt: timestampField(data, ['updatedAt', 'updated_at', 'updatedTime', 'updated_time']) }
|
||||
: {}),
|
||||
...(timestampField(data, ['endedAt', 'ended_at', 'endTime', 'end_time', 'completedAt', 'completed_at']) !== undefined
|
||||
? { endedAt: timestampField(data, ['endedAt', 'ended_at', 'endTime', 'end_time', 'completedAt', 'completed_at']) }
|
||||
: {}),
|
||||
ts,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (event.stream === 'patch' && event.runId) {
|
||||
const ts = numberField(event, 'ts') ?? Date.now();
|
||||
const toolCallId = toolCallIdField(data);
|
||||
const itemId = itemIdField(data);
|
||||
const toolId = toolIdField(data);
|
||||
const toolItemId = toolItemIdField(data);
|
||||
const callId = callIdField(data);
|
||||
const parentId = parentIdField(data);
|
||||
const parentItemId = parentItemIdField(data);
|
||||
const filePaths = firstStringArrayField(data, ['filePaths', 'file_paths', 'files', 'paths']);
|
||||
const fileCount = firstNumber(data, ['fileCount', 'file_count', 'filesCount', 'files_count'])
|
||||
?? filePaths?.length;
|
||||
actions.push({
|
||||
type: 'patch.completed',
|
||||
sessionKey: event.sessionKey,
|
||||
patch: {
|
||||
id: stableStreamEntryId('patch', event, data, ts),
|
||||
runId: event.runId,
|
||||
...(itemId ? { itemId } : {}),
|
||||
...(toolCallId ? { toolCallId } : {}),
|
||||
...(toolId ? { toolId } : {}),
|
||||
...(toolItemId ? { toolItemId } : {}),
|
||||
...(callId ? { callId } : {}),
|
||||
...(parentId ? { parentId } : {}),
|
||||
...(parentItemId ? { parentItemId } : {}),
|
||||
...(firstStringField(data, ['name', 'patchName']) ? { name: firstStringField(data, ['name', 'patchName']) } : {}),
|
||||
...(stringField(data, 'title') ? { title: stringField(data, 'title') } : {}),
|
||||
...(firstStringField(data, ['summary', 'message']) ? { summary: firstStringField(data, ['summary', 'message']) } : {}),
|
||||
...(stringField(data, 'status') ? { status: stringField(data, 'status') } : {}),
|
||||
...(filePaths ? { filePaths, files: filePaths } : {}),
|
||||
...(fileCount !== undefined ? { fileCount } : {}),
|
||||
...(numberField(data, 'added') !== undefined ? { added: numberField(data, 'added') } : {}),
|
||||
...(numberField(data, 'modified') !== undefined ? { modified: numberField(data, 'modified') } : {}),
|
||||
...(numberField(data, 'deleted') !== undefined ? { deleted: numberField(data, 'deleted') } : {}),
|
||||
rawPayload: data,
|
||||
ts,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (event.stream === 'lifecycle') {
|
||||
if (phase === 'start') {
|
||||
actions.push(runStatusAction(event, { phase: 'running', runId: event.runId }));
|
||||
}
|
||||
if (phase === 'fallback_step') {
|
||||
actions.push({
|
||||
type: 'runtime.fallback',
|
||||
sessionKey: event.sessionKey,
|
||||
status: fallbackStepStatus(data),
|
||||
});
|
||||
}
|
||||
if (phase === 'end' || phase === 'completed' || phase === 'done' || phase === 'finished') {
|
||||
actions.push(runStatusAction(event, { phase: 'done', runId: event.runId }));
|
||||
}
|
||||
if (phase === 'error' || phase === 'failed') {
|
||||
actions.push(
|
||||
runStatusAction(event, {
|
||||
phase: 'error',
|
||||
runId: event.runId,
|
||||
message: stringField(data, 'error') ?? stringField(data, 'message'),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (phase === 'aborted' || phase === 'cancelled' || phase === 'canceled') {
|
||||
actions.push(
|
||||
runStatusAction(event, {
|
||||
phase: 'interrupted',
|
||||
runId: event.runId,
|
||||
...lifecycleMetadata(data),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.stream === 'compaction') {
|
||||
if (phase === 'start' || phase === 'before') {
|
||||
actions.push({
|
||||
type: 'runtime.compaction',
|
||||
sessionKey: event.sessionKey,
|
||||
status: {
|
||||
phase: 'active',
|
||||
message: firstString([data.message, data.reason, data.messages]),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (phase === 'end' || phase === 'after' || phase === 'completed') {
|
||||
const willRetry = booleanField(data, 'willRetry') === true;
|
||||
const completed = booleanField(data, 'completed');
|
||||
actions.push({
|
||||
type: 'runtime.compaction',
|
||||
sessionKey: event.sessionKey,
|
||||
status: {
|
||||
phase: willRetry ? 'retrying' : completed === false ? 'error' : 'complete',
|
||||
message: firstString([data.message, data.reason, data.messages]),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sessionOperation = sessionOperationPayload(event, data);
|
||||
if (sessionOperation?.operation === 'compact') {
|
||||
const operationPhase = stringField(sessionOperation, 'phase');
|
||||
const sessionKey = stringField(sessionOperation, 'sessionKey') ?? event.sessionKey;
|
||||
if (operationPhase === 'start') {
|
||||
actions.push({
|
||||
type: 'runtime.compaction',
|
||||
sessionKey,
|
||||
status: { phase: 'active' },
|
||||
});
|
||||
}
|
||||
if (operationPhase === 'end') {
|
||||
const completed = booleanField(sessionOperation, 'completed');
|
||||
actions.push({
|
||||
type: 'runtime.compaction',
|
||||
sessionKey,
|
||||
status: {
|
||||
phase: completed === false ? 'error' : 'complete',
|
||||
message: firstString([sessionOperation.message, sessionOperation.reason]),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (event.stream === 'fallback' || event.stream === 'failover') {
|
||||
const resolvedPhase = phase === 'end' || phase === 'done' || phase === 'cleared'
|
||||
? 'cleared'
|
||||
: phase === 'error' || phase === 'failed'
|
||||
? 'error'
|
||||
: 'active';
|
||||
actions.push({
|
||||
type: 'runtime.fallback',
|
||||
sessionKey: event.sessionKey,
|
||||
status: {
|
||||
phase: resolvedPhase,
|
||||
message: firstString([
|
||||
data.message,
|
||||
data.reason,
|
||||
data.decision,
|
||||
data.action,
|
||||
]),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (event.stream === 'approval') {
|
||||
const ids = approvalIds(event, data);
|
||||
if (
|
||||
phase === 'resolved'
|
||||
|| data.status === 'approved'
|
||||
|| data.status === 'denied'
|
||||
|| data.status === 'failed'
|
||||
) {
|
||||
actions.push({ type: 'approval.resolved', sessionKey: event.sessionKey, ids });
|
||||
return actions;
|
||||
}
|
||||
|
||||
if (phase === 'requested' || data.status === 'pending' || data.status === 'unavailable') {
|
||||
const id = ids[0] ?? `${event.runId ?? 'approval'}:${event.seq ?? 'pending'}`;
|
||||
const kind = data.kind === 'plugin' || data.kind === 'exec' ? data.kind : 'exec';
|
||||
const status = data.status === 'unavailable' ? 'unavailable' : 'pending';
|
||||
const detail = stringField(data, 'command')
|
||||
?? stringField(data, 'detail')
|
||||
?? stringField(data, 'reason')
|
||||
?? stringField(data, 'message')
|
||||
?? JSON.stringify(data);
|
||||
actions.push({
|
||||
type: 'approval.upserted',
|
||||
approval: {
|
||||
id,
|
||||
kind,
|
||||
status,
|
||||
title: stringField(data, 'title') ?? '',
|
||||
detail,
|
||||
approvalId: firstStringField(data, ['approvalId', 'approval_id']),
|
||||
approvalSlug: firstStringField(data, ['approvalSlug', 'approval_slug']),
|
||||
itemId: itemIdField(data),
|
||||
toolCallId: toolCallIdField(data),
|
||||
message: stringField(data, 'message'),
|
||||
sessionKey: event.sessionKey,
|
||||
agentId: event.agentId,
|
||||
expiresAtMs: typeof data.expiresAtMs === 'number' ? data.expiresAtMs : undefined,
|
||||
allowedDecisions: approvalDecisions(data.allowedDecisions),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { ChatQueueItem, RawOpenClawMessage } from './types';
|
||||
import {
|
||||
extractAssistantVisibleText,
|
||||
isHiddenAssistantMessage,
|
||||
} from './message-extraction';
|
||||
import { extractToolCards } from './tool-cards';
|
||||
|
||||
export function extractMessageText(message: RawOpenClawMessage): string {
|
||||
if (message.role === 'assistant') return extractAssistantVisibleText(message) ?? '';
|
||||
if (typeof message.text === 'string') return message.text;
|
||||
if (typeof message.content === 'string') return message.content;
|
||||
if (Array.isArray(message.content)) {
|
||||
return message.content
|
||||
.flatMap((part) => {
|
||||
if (!part || typeof part !== 'object') return [];
|
||||
const text = (part as { text?: unknown }).text;
|
||||
return typeof text === 'string' ? [text] : [];
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const INLINE_DISPLAY_DIRECTIVE_PATTERN =
|
||||
/\[\[\s*(?:audio_as_voice|reply_to_current|reply_to\s*:\s*[^\]\n]+)\s*\]\]/gi;
|
||||
|
||||
export function stripInlineDirectiveTagsForDisplay(text: string): string {
|
||||
if (!text) return text;
|
||||
return text
|
||||
.replace(INLINE_DISPLAY_DIRECTIVE_PATTERN, (match, offset: number, source: string) => {
|
||||
const before = source[offset - 1];
|
||||
const after = source[offset + match.length];
|
||||
if (before && after && !/\s/u.test(before) && !/\s/u.test(after)) return ' ';
|
||||
return '';
|
||||
})
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function extractDisplayMessageText(message: RawOpenClawMessage): string {
|
||||
return stripInlineDirectiveTagsForDisplay(extractMessageText(message));
|
||||
}
|
||||
|
||||
export function shouldHideHistoryMessage(message: RawOpenClawMessage): boolean {
|
||||
if (extractToolCards(message).length > 0) return false;
|
||||
return isHiddenAssistantMessage(message);
|
||||
}
|
||||
|
||||
const MEDIA_ATTACHMENT_PATTERN = /\s*\[media attached:[^\]]*\]/gi;
|
||||
const QUEUE_HISTORY_EARLY_ECHO_SKEW_MS = 250;
|
||||
|
||||
export function stripMediaAttachmentReferences(text: string): string {
|
||||
return text.replace(MEDIA_ATTACHMENT_PATTERN, '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function timestampMs(value: unknown): number | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null;
|
||||
return value < 1e12 ? value * 1000 : value;
|
||||
}
|
||||
|
||||
function queueItemCanMatchHistoryTimestamp(
|
||||
item: ChatQueueItem,
|
||||
message: RawOpenClawMessage,
|
||||
): boolean {
|
||||
const createdAt = timestampMs(item.createdAt);
|
||||
if (createdAt === null) return true;
|
||||
|
||||
const historyTimestamp = timestampMs(message.timestamp);
|
||||
if (historyTimestamp === null) return false;
|
||||
return historyTimestamp >= createdAt - QUEUE_HISTORY_EARLY_ECHO_SKEW_MS;
|
||||
}
|
||||
|
||||
export function queueItemHasMatchingHistoryMessage(
|
||||
item: ChatQueueItem,
|
||||
messages: RawOpenClawMessage[],
|
||||
): boolean {
|
||||
const expected = stripMediaAttachmentReferences(item.message);
|
||||
if (!expected) return false;
|
||||
const candidates = typeof item.historyMessageCountAtEnqueue === 'number'
|
||||
? messages.slice(Math.max(0, item.historyMessageCountAtEnqueue))
|
||||
: messages;
|
||||
return candidates.some((message) => {
|
||||
if (message.role !== 'user') return false;
|
||||
if (!queueItemCanMatchHistoryTimestamp(item, message)) return false;
|
||||
return stripMediaAttachmentReferences(extractMessageText(message)) === expected;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
export type AssistantPhase = 'commentary' | 'final_answer';
|
||||
|
||||
type AssistantTextBuckets = {
|
||||
finalAnswerTexts: string[];
|
||||
commentaryTexts: string[];
|
||||
legacyTexts: string[];
|
||||
thinkingTexts: string[];
|
||||
hasExplicitPhase: boolean;
|
||||
};
|
||||
|
||||
const HIDDEN_ASSISTANT_TEXT_PATTERN = /^(?:HEARTBEAT_OK|NO_REPLY)\s*$/i;
|
||||
const HIDDEN_ASSISTANT_LINE_PATTERN = /(^|\n)[ \t]*(?:HEARTBEAT_OK|NO_REPLY)[ \t]*(?=\n|$)/gi;
|
||||
const LEGACY_THINK_TAG_PATTERN =
|
||||
/<thinking\b[^>]*>([\s\S]*?)<\/thinking>|<think\b[^>]*>([\s\S]*?)<\/think>/gi;
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizeKind(value: unknown): string {
|
||||
return typeof value === 'string' ? value.replace(/[_-]/g, '').toLowerCase() : '';
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function isAssistantLike(message: Record<string, unknown>): boolean {
|
||||
return typeof message.role !== 'string' || message.role === 'assistant';
|
||||
}
|
||||
|
||||
function isAssistantPhase(value: unknown): value is AssistantPhase {
|
||||
return value === 'commentary' || value === 'final_answer';
|
||||
}
|
||||
|
||||
function parseTextSignaturePhase(textSignature: unknown): AssistantPhase | undefined {
|
||||
if (typeof textSignature !== 'string') return undefined;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(textSignature) as unknown;
|
||||
const record = asRecord(parsed);
|
||||
if (!record || record.v !== 1) return undefined;
|
||||
return isAssistantPhase(record.phase) ? record.phase : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAssistantPhase(
|
||||
textSignature: unknown,
|
||||
blockPhase: unknown,
|
||||
messagePhase: unknown,
|
||||
): AssistantPhase | undefined {
|
||||
return parseTextSignaturePhase(textSignature)
|
||||
?? (isAssistantPhase(blockPhase) ? blockPhase : undefined)
|
||||
?? (isAssistantPhase(messagePhase) ? messagePhase : undefined);
|
||||
}
|
||||
|
||||
function cleanupDisplayText(text: string): string {
|
||||
return text
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n[ \t]+/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function stripLegacyThinking(raw: string): { text: string; thinkingTexts: string[] } {
|
||||
const thinkingTexts: string[] = [];
|
||||
const text = raw.replace(
|
||||
LEGACY_THINK_TAG_PATTERN,
|
||||
(_match: string, thinkingTagText: string | undefined, thinkTagText: string | undefined): string => {
|
||||
const thinkingText = (thinkingTagText ?? thinkTagText ?? '').trim();
|
||||
if (thinkingText) thinkingTexts.push(thinkingText);
|
||||
return '';
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
text: cleanupDisplayText(text),
|
||||
thinkingTexts,
|
||||
};
|
||||
}
|
||||
|
||||
function pushText(
|
||||
buckets: AssistantTextBuckets,
|
||||
rawText: string,
|
||||
phase: AssistantPhase | undefined,
|
||||
): void {
|
||||
if (phase) buckets.hasExplicitPhase = true;
|
||||
|
||||
const withoutThinking = stripLegacyThinking(rawText);
|
||||
buckets.thinkingTexts.push(...withoutThinking.thinkingTexts);
|
||||
|
||||
const display = stripHeartbeatTokenForDisplay(withoutThinking.text);
|
||||
if (display.shouldSkip) return;
|
||||
|
||||
if (phase === 'final_answer') {
|
||||
buckets.finalAnswerTexts.push(display.text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (phase === 'commentary') {
|
||||
buckets.commentaryTexts.push(display.text);
|
||||
return;
|
||||
}
|
||||
|
||||
buckets.legacyTexts.push(display.text);
|
||||
}
|
||||
|
||||
function firstStringField(record: Record<string, unknown>, keys: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === 'string' && value.trim()) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function firstThinkingContent(value: unknown): string | undefined {
|
||||
if (typeof value === 'string' && value.trim()) return value;
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const nested = firstThinkingContent(item);
|
||||
if (nested) return nested;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const record = asRecord(value);
|
||||
if (!record) return undefined;
|
||||
return firstStringField(record, [
|
||||
'thinking',
|
||||
'reasoning',
|
||||
'reasoningText',
|
||||
'reasoning_text',
|
||||
'reasoningContent',
|
||||
'reasoning_content',
|
||||
'summary',
|
||||
'summaryText',
|
||||
'summary_text',
|
||||
'text',
|
||||
'content',
|
||||
]);
|
||||
}
|
||||
|
||||
function pushThinking(buckets: AssistantTextBuckets, thinking: unknown): void {
|
||||
const cleaned = firstThinkingContent(thinking)?.trim();
|
||||
if (cleaned) buckets.thinkingTexts.push(cleaned);
|
||||
}
|
||||
|
||||
function isThinkingBlock(block: Record<string, unknown>): boolean {
|
||||
const kind = normalizeKind(block.type);
|
||||
return kind === 'thinking' || kind === 'reasoning' || kind === 'reasoningcontent';
|
||||
}
|
||||
|
||||
function collectAssistantText(message: unknown): AssistantTextBuckets {
|
||||
const buckets: AssistantTextBuckets = {
|
||||
finalAnswerTexts: [],
|
||||
commentaryTexts: [],
|
||||
legacyTexts: [],
|
||||
thinkingTexts: [],
|
||||
hasExplicitPhase: false,
|
||||
};
|
||||
const record = asRecord(message);
|
||||
if (!record || !isAssistantLike(record)) return buckets;
|
||||
|
||||
const content = record.content;
|
||||
const messagePhase = record.phase;
|
||||
let foundContentText = false;
|
||||
|
||||
if (typeof content === 'string') {
|
||||
foundContentText = true;
|
||||
pushText(buckets, content, resolveAssistantPhase(undefined, undefined, messagePhase));
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const part of content) {
|
||||
if (typeof part === 'string') {
|
||||
foundContentText = true;
|
||||
pushText(buckets, part, resolveAssistantPhase(undefined, undefined, messagePhase));
|
||||
continue;
|
||||
}
|
||||
|
||||
const block = asRecord(part);
|
||||
if (!block) continue;
|
||||
|
||||
const thinkingBlock = isThinkingBlock(block);
|
||||
if (thinkingBlock) {
|
||||
pushThinking(buckets, block);
|
||||
}
|
||||
|
||||
if (!thinkingBlock && typeof block.text === 'string') {
|
||||
foundContentText = true;
|
||||
pushText(buckets, block.text, resolveAssistantPhase(block.textSignature, block.phase, messagePhase));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundContentText && typeof record.text === 'string') {
|
||||
pushText(buckets, record.text, resolveAssistantPhase(undefined, undefined, messagePhase));
|
||||
}
|
||||
|
||||
return buckets;
|
||||
}
|
||||
|
||||
function joinDisplayText(parts: string[]): string | undefined {
|
||||
const text = cleanupDisplayText(parts.join('\n'));
|
||||
return text ? text : undefined;
|
||||
}
|
||||
|
||||
function joinThinkingText(parts: string[]): string | undefined {
|
||||
const text = parts
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
.trim();
|
||||
return text ? text : undefined;
|
||||
}
|
||||
|
||||
const loggedReasoningTokenMessages = new WeakSet<Record<string, unknown>>();
|
||||
|
||||
function firstNumberField(record: Record<string, unknown>, keys: string[]): number | undefined {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function reasoningTokensForMessage(record: Record<string, unknown>): number | undefined {
|
||||
const usage = asRecord(record.usage);
|
||||
return firstNumberField(record, ['reasoningTokens', 'reasoning_tokens'])
|
||||
?? (usage ? firstNumberField(usage, ['reasoningTokens', 'reasoning_tokens']) : undefined);
|
||||
}
|
||||
|
||||
function debugMissingThinkingForReasoningTokens(message: unknown): void {
|
||||
const record = asRecord(message);
|
||||
if (!record || !isAssistantLike(record)) return;
|
||||
const reasoningTokens = reasoningTokensForMessage(record);
|
||||
if (!reasoningTokens) return;
|
||||
const env = typeof import.meta !== 'undefined' ? import.meta.env : undefined;
|
||||
if (!env?.DEV) return;
|
||||
if (loggedReasoningTokenMessages.has(record)) return;
|
||||
loggedReasoningTokenMessages.add(record);
|
||||
console.debug('[ClawX Chat] assistant message has reasoning tokens but no displayable thinking', {
|
||||
id: typeof record.id === 'string' ? record.id : undefined,
|
||||
responseId: typeof record.responseId === 'string' ? record.responseId : undefined,
|
||||
reasoningTokens,
|
||||
contentTypes: Array.isArray(record.content)
|
||||
? record.content.map((part) => asRecord(part)?.type).filter(Boolean)
|
||||
: typeof record.content,
|
||||
});
|
||||
}
|
||||
|
||||
export function stripHeartbeatTokenForDisplay(raw: string): { shouldSkip: boolean; text: string } {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed || HIDDEN_ASSISTANT_TEXT_PATTERN.test(trimmed)) {
|
||||
return { shouldSkip: true, text: '' };
|
||||
}
|
||||
|
||||
const text = raw
|
||||
.replace(HIDDEN_ASSISTANT_LINE_PATTERN, '$1')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
|
||||
return { shouldSkip: !text, text };
|
||||
}
|
||||
|
||||
export function isHiddenStreamText(text: string): boolean {
|
||||
return stripHeartbeatTokenForDisplay(text).shouldSkip;
|
||||
}
|
||||
|
||||
function hasStringInArray(value: unknown): boolean {
|
||||
return Array.isArray(value) && value.some(isNonEmptyString);
|
||||
}
|
||||
|
||||
function hasRenderableMediaValue(record: Record<string, unknown>): boolean {
|
||||
if (
|
||||
isNonEmptyString(record.mediaUrl)
|
||||
|| isNonEmptyString(record.gatewayUrl)
|
||||
|| isNonEmptyString(record.filePath)
|
||||
|| isNonEmptyString(record.path)
|
||||
|| isNonEmptyString(record.url)
|
||||
|| isNonEmptyString(record.data)
|
||||
|| hasStringInArray(record.mediaUrls)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const source = asRecord(record.source);
|
||||
if (source && (isNonEmptyString(source.url) || isNonEmptyString(source.data))) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasRenderableMediaArray(value: unknown): boolean {
|
||||
return Array.isArray(value) && value.some((entry) => {
|
||||
if (isNonEmptyString(entry)) return true;
|
||||
const record = asRecord(entry);
|
||||
return record ? hasRenderableMediaValue(record) : false;
|
||||
});
|
||||
}
|
||||
|
||||
export function hasRenderableAssistantMedia(message: unknown): boolean {
|
||||
const record = asRecord(message);
|
||||
if (!record || !isAssistantLike(record)) return false;
|
||||
|
||||
if (
|
||||
hasRenderableMediaValue(record)
|
||||
|| hasRenderableMediaArray(record._attachedFiles)
|
||||
|| hasRenderableMediaArray(record.attachments)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!Array.isArray(record.content)) return false;
|
||||
return record.content.some((part) => {
|
||||
const block = asRecord(part);
|
||||
if (!block) return false;
|
||||
if (block.type === 'image' && hasRenderableMediaValue(block)) return true;
|
||||
return hasRenderableMediaArray(block.attachments) || hasRenderableMediaValue(block);
|
||||
});
|
||||
}
|
||||
|
||||
export function extractAssistantVisibleText(message: unknown): string | undefined {
|
||||
const buckets = collectAssistantText(message);
|
||||
if (buckets.finalAnswerTexts.length > 0) return joinDisplayText(buckets.finalAnswerTexts);
|
||||
if (buckets.hasExplicitPhase) return undefined;
|
||||
return joinDisplayText(buckets.legacyTexts);
|
||||
}
|
||||
|
||||
export function extractAssistantCommentaryText(message: unknown): string | undefined {
|
||||
return joinDisplayText(collectAssistantText(message).commentaryTexts);
|
||||
}
|
||||
|
||||
export function extractThinkingText(message: unknown): string | undefined {
|
||||
const text = joinThinkingText(collectAssistantText(message).thinkingTexts);
|
||||
if (!text) debugMissingThinkingForReasoningTokens(message);
|
||||
return text;
|
||||
}
|
||||
|
||||
export function extractAssistantDisplayParts(message: unknown): {
|
||||
visibleText?: string;
|
||||
commentaryText?: string;
|
||||
thinkingText?: string;
|
||||
} {
|
||||
const buckets = collectAssistantText(message);
|
||||
const visibleText = buckets.finalAnswerTexts.length > 0
|
||||
? joinDisplayText(buckets.finalAnswerTexts)
|
||||
: buckets.hasExplicitPhase
|
||||
? undefined
|
||||
: joinDisplayText(buckets.legacyTexts);
|
||||
const commentaryText = joinDisplayText(buckets.commentaryTexts);
|
||||
const thinkingText = joinThinkingText(buckets.thinkingTexts);
|
||||
|
||||
return {
|
||||
...(visibleText ? { visibleText } : {}),
|
||||
...(commentaryText ? { commentaryText } : {}),
|
||||
...(thinkingText ? { thinkingText } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function isHiddenAssistantMessage(message: unknown): boolean {
|
||||
const record = asRecord(message);
|
||||
if (!record || record.role !== 'assistant') return false;
|
||||
if (hasRenderableAssistantMedia(record)) return false;
|
||||
|
||||
const parts = extractAssistantDisplayParts(record);
|
||||
return !parts.visibleText;
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import type { RawOpenClawMessage } from './types';
|
||||
import { extractMessageText, stripMediaAttachmentReferences } from './history';
|
||||
|
||||
type RawRecord = Record<string, unknown>;
|
||||
|
||||
function isRecord(value: unknown): value is RawRecord {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readString(...values: unknown[]): string | undefined {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeKind(value: unknown): string {
|
||||
return typeof value === 'string'
|
||||
? value.replace(/[_-]/g, '').toLowerCase()
|
||||
: '';
|
||||
}
|
||||
|
||||
function normalizeContent(content: unknown): RawRecord[] {
|
||||
if (!Array.isArray(content)) return [];
|
||||
return content.filter(isRecord);
|
||||
}
|
||||
|
||||
function isToolCallBlock(block: RawRecord): boolean {
|
||||
const kind = normalizeKind(block.type);
|
||||
return (
|
||||
kind === 'toolcall'
|
||||
|| kind === 'tooluse'
|
||||
|| (typeof block.name === 'string'
|
||||
&& (block.arguments != null || block.args != null || block.input != null))
|
||||
);
|
||||
}
|
||||
|
||||
function isToolResultBlock(block: RawRecord): boolean {
|
||||
const kind = normalizeKind(block.type);
|
||||
return kind === 'toolresult';
|
||||
}
|
||||
|
||||
function readToolId(block: RawRecord, message: RawOpenClawMessage): string | undefined {
|
||||
return readString(
|
||||
block.id,
|
||||
block.toolCallId,
|
||||
block.tool_call_id,
|
||||
block.toolUseId,
|
||||
block.tool_use_id,
|
||||
block.callId,
|
||||
message.toolCallId,
|
||||
message.tool_call_id,
|
||||
message.toolUseId,
|
||||
message.tool_use_id,
|
||||
message.callId,
|
||||
);
|
||||
}
|
||||
|
||||
function readToolName(block: RawRecord, message: RawOpenClawMessage): string | undefined {
|
||||
return readString(block.name, message.toolName, message.tool_name);
|
||||
}
|
||||
|
||||
type ToolReference = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
function collectToolCalls(message: RawOpenClawMessage): ToolReference[] {
|
||||
return normalizeContent(message.content)
|
||||
.filter(isToolCallBlock)
|
||||
.map((block) => ({
|
||||
id: readToolId(block, message),
|
||||
name: readToolName(block, message),
|
||||
}));
|
||||
}
|
||||
|
||||
function isStandaloneToolResultMessage(message: RawOpenClawMessage): boolean {
|
||||
const role = normalizeKind(message.role);
|
||||
if (role === 'tool' || role === 'function' || role === 'toolresult') return true;
|
||||
if (
|
||||
readToolId({}, message)
|
||||
|| typeof message.toolName === 'string'
|
||||
|| typeof message.tool_name === 'string'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const content = normalizeContent(message.content);
|
||||
return content.length > 0 && content.every(isToolResultBlock);
|
||||
}
|
||||
|
||||
function toolResultMatchesCall(
|
||||
calls: ToolReference[],
|
||||
resultMessage: RawOpenClawMessage,
|
||||
): boolean {
|
||||
if (calls.length === 0) return false;
|
||||
|
||||
const resultId = readToolId({}, resultMessage);
|
||||
if (resultId && calls.some((call) => call.id === resultId)) return true;
|
||||
|
||||
const resultName = readToolName({}, resultMessage);
|
||||
if (resultName && calls.some((call) => call.name === resultName)) return true;
|
||||
|
||||
return calls.length === 1 && !resultId && !resultName;
|
||||
}
|
||||
|
||||
function toToolResultBlocks(message: RawOpenClawMessage): RawRecord[] {
|
||||
const id = readToolId({}, message);
|
||||
const name = readToolName({}, message);
|
||||
const existingBlocks = normalizeContent(message.content);
|
||||
const idFields = id ? { tool_use_id: id, toolCallId: id } : {};
|
||||
const nameFields = name ? { name } : {};
|
||||
|
||||
if (existingBlocks.length > 0 && existingBlocks.every(isToolResultBlock)) {
|
||||
return existingBlocks.map((block) => ({
|
||||
...block,
|
||||
...idFields,
|
||||
...nameFields,
|
||||
type: 'tool_result',
|
||||
}));
|
||||
}
|
||||
|
||||
return [{
|
||||
type: 'tool_result',
|
||||
...idFields,
|
||||
...nameFields,
|
||||
content: message.content ?? message.text ?? '',
|
||||
isError: message.isError ?? message.is_error,
|
||||
details: message.details,
|
||||
}];
|
||||
}
|
||||
|
||||
function appendToolResult(
|
||||
message: RawOpenClawMessage,
|
||||
resultMessage: RawOpenClawMessage,
|
||||
): RawOpenClawMessage {
|
||||
const content = Array.isArray(message.content) ? [...message.content] : [];
|
||||
return {
|
||||
...message,
|
||||
content: [
|
||||
...content,
|
||||
...toToolResultBlocks(resultMessage),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeAdjacentToolResultMessages(
|
||||
messages: RawOpenClawMessage[],
|
||||
): RawOpenClawMessage[] {
|
||||
const merged: RawOpenClawMessage[] = [];
|
||||
|
||||
for (let index = 0; index < messages.length; index++) {
|
||||
let message = messages[index];
|
||||
const calls = collectToolCalls(message);
|
||||
|
||||
if (calls.length === 0) {
|
||||
merged.push(message);
|
||||
continue;
|
||||
}
|
||||
|
||||
while (
|
||||
index + 1 < messages.length
|
||||
&& isStandaloneToolResultMessage(messages[index + 1])
|
||||
&& toolResultMatchesCall(calls, messages[index + 1])
|
||||
) {
|
||||
index += 1;
|
||||
message = appendToolResult(message, messages[index]);
|
||||
}
|
||||
|
||||
merged.push(message);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function hasMediaAttachmentReference(message: RawOpenClawMessage): boolean {
|
||||
return /\[media attached:/i.test(extractMessageText(message));
|
||||
}
|
||||
|
||||
function isUserMessage(message: RawOpenClawMessage): boolean {
|
||||
return typeof message.role === 'string' && message.role.toLowerCase() === 'user';
|
||||
}
|
||||
|
||||
function readIdempotencyKey(message: RawOpenClawMessage): string | undefined {
|
||||
return readString(message.idempotencyKey, message.idempotency_key);
|
||||
}
|
||||
|
||||
function normalizedUserPrompt(message: RawOpenClawMessage): string {
|
||||
return stripMediaAttachmentReferences(extractMessageText(message));
|
||||
}
|
||||
|
||||
function preferUserEcho(
|
||||
current: RawOpenClawMessage,
|
||||
candidate: RawOpenClawMessage,
|
||||
): RawOpenClawMessage {
|
||||
if (!hasMediaAttachmentReference(current) && hasMediaAttachmentReference(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
export function collapseDuplicateIdempotentUserEchoes(
|
||||
messages: RawOpenClawMessage[],
|
||||
): RawOpenClawMessage[] {
|
||||
const collapsed: RawOpenClawMessage[] = [];
|
||||
const userIndexByIdempotentPrompt = new Map<string, number>();
|
||||
|
||||
for (const message of messages) {
|
||||
if (!isUserMessage(message)) {
|
||||
collapsed.push(message);
|
||||
continue;
|
||||
}
|
||||
|
||||
const idempotencyKey = readIdempotencyKey(message);
|
||||
const prompt = normalizedUserPrompt(message);
|
||||
if (!idempotencyKey || !prompt) {
|
||||
collapsed.push(message);
|
||||
continue;
|
||||
}
|
||||
|
||||
const dedupeKey = `${idempotencyKey}\0${prompt}`;
|
||||
const existingIndex = userIndexByIdempotentPrompt.get(dedupeKey);
|
||||
if (existingIndex !== undefined) {
|
||||
collapsed[existingIndex] = preferUserEcho(collapsed[existingIndex], message);
|
||||
continue;
|
||||
}
|
||||
|
||||
collapsed.push(message);
|
||||
userIndexByIdempotentPrompt.set(dedupeKey, collapsed.length - 1);
|
||||
}
|
||||
|
||||
return collapsed;
|
||||
}
|
||||
|
||||
export function collapseDuplicateAttachmentUserEchoes(
|
||||
messages: RawOpenClawMessage[],
|
||||
): RawOpenClawMessage[] {
|
||||
const collapsed: RawOpenClawMessage[] = [];
|
||||
const mediaUserIndexByPrompt = new Map<string, number>();
|
||||
const plainUserIndexByPrompt = new Map<string, number>();
|
||||
|
||||
for (const message of messages) {
|
||||
if (!isUserMessage(message)) {
|
||||
collapsed.push(message);
|
||||
continue;
|
||||
}
|
||||
|
||||
const prompt = normalizedUserPrompt(message);
|
||||
const hasMedia = hasMediaAttachmentReference(message);
|
||||
if (!prompt) {
|
||||
collapsed.push(message);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingMediaIndex = mediaUserIndexByPrompt.get(prompt);
|
||||
if (existingMediaIndex !== undefined) {
|
||||
if (hasMedia) collapsed[existingMediaIndex] = message;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hasMedia) {
|
||||
const existingPlainIndex = plainUserIndexByPrompt.get(prompt);
|
||||
if (existingPlainIndex !== undefined) {
|
||||
collapsed[existingPlainIndex] = message;
|
||||
mediaUserIndexByPrompt.set(prompt, existingPlainIndex);
|
||||
plainUserIndexByPrompt.delete(prompt);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
collapsed.push(message);
|
||||
if (hasMedia) mediaUserIndexByPrompt.set(prompt, collapsed.length - 1);
|
||||
else plainUserIndexByPrompt.set(prompt, collapsed.length - 1);
|
||||
}
|
||||
|
||||
return collapsed;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,234 @@
|
||||
/*
|
||||
* Vendored from OpenClaw Web UI on 2026-06-19.
|
||||
* Local ClawX changes must stay adapter-oriented and must not add Renderer
|
||||
* direct Gateway access.
|
||||
*/
|
||||
|
||||
export const CHAT_RUN_STATUS_TOAST_DURATION_MS = 5_000;
|
||||
export const STALE_ACTIVE_ROW_RECONCILE_WINDOW_MS = 10_000;
|
||||
|
||||
export type SessionRunStatus = 'running' | 'done' | 'killed' | 'error' | 'aborted';
|
||||
|
||||
export type GatewaySessionRow = {
|
||||
key: string;
|
||||
hasActiveRun?: boolean;
|
||||
status?: SessionRunStatus;
|
||||
startedAt?: number;
|
||||
endedAt?: number;
|
||||
runtimeMs?: number;
|
||||
abortedLastRun?: boolean;
|
||||
};
|
||||
|
||||
export type SessionsListResult = {
|
||||
sessions: GatewaySessionRow[];
|
||||
};
|
||||
|
||||
export type ChatRunUiStatus = {
|
||||
phase: 'done' | 'interrupted';
|
||||
runId: string | null;
|
||||
sessionKey: string;
|
||||
occurredAt: number;
|
||||
};
|
||||
|
||||
export type LocalTerminalReconcile = {
|
||||
sessionKey: string;
|
||||
runId: string | null;
|
||||
phase: ChatRunUiStatus['phase'];
|
||||
sessionStatus: SessionRunStatus;
|
||||
occurredAt: number;
|
||||
};
|
||||
|
||||
type RunLifecycleHost = {
|
||||
sessionKey: string;
|
||||
chatRunId?: string | null;
|
||||
chatStream?: string | null;
|
||||
chatStreamStartedAt?: number | null;
|
||||
chatSideResultTerminalRuns?: Set<string>;
|
||||
chatRunStatus?: ChatRunUiStatus | null;
|
||||
sessionsResult?: SessionsListResult | null;
|
||||
lastLocalTerminalReconcile?: LocalTerminalReconcile | null;
|
||||
compactionStatus?: unknown | null;
|
||||
fallbackStatus?: unknown | null;
|
||||
toolStreamById?: Map<string, unknown>;
|
||||
toolStreamOrder?: unknown[];
|
||||
chatToolMessages?: unknown[];
|
||||
chatStreamSegments?: unknown[];
|
||||
requestUpdate?: () => void;
|
||||
};
|
||||
|
||||
type ReconcileOptions = {
|
||||
outcome?: ChatRunUiStatus['phase'];
|
||||
sessionStatus?: SessionRunStatus;
|
||||
runId?: string | null;
|
||||
sessionKey?: string | null;
|
||||
sessionKeys?: readonly (string | null | undefined)[];
|
||||
clearLocalRun?: boolean;
|
||||
clearChatStream?: boolean;
|
||||
clearIndicators?: boolean;
|
||||
clearToolStream?: boolean;
|
||||
clearSideResultTerminalRuns?: boolean;
|
||||
clearRunStatus?: boolean;
|
||||
publishRunStatus?: boolean;
|
||||
armLocalTerminalReconcile?: boolean;
|
||||
};
|
||||
|
||||
function toSessionKey(value: string | null | undefined): string | null {
|
||||
const trimmed = typeof value === 'string' ? value.trim() : '';
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function isSessionRunActive(row: GatewaySessionRow): boolean {
|
||||
return row.hasActiveRun === true || row.status === 'running';
|
||||
}
|
||||
|
||||
function clearRunIndicators(host: RunLifecycleHost): void {
|
||||
host.compactionStatus = null;
|
||||
host.fallbackStatus = null;
|
||||
}
|
||||
|
||||
function sessionKeysFor(host: RunLifecycleHost, options: ReconcileOptions): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
const primary = toSessionKey(options.sessionKey) ?? host.sessionKey;
|
||||
if (primary) keys.add(primary);
|
||||
for (const key of options.sessionKeys ?? []) {
|
||||
const normalized = toSessionKey(key);
|
||||
if (normalized) keys.add(normalized);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function resetToolStream(host: RunLifecycleHost): void {
|
||||
host.toolStreamById?.clear();
|
||||
if (Array.isArray(host.toolStreamOrder)) host.toolStreamOrder = [];
|
||||
if (Array.isArray(host.chatToolMessages)) host.chatToolMessages = [];
|
||||
if (Array.isArray(host.chatStreamSegments)) host.chatStreamSegments = [];
|
||||
}
|
||||
|
||||
function reconcileSessionRows(
|
||||
host: RunLifecycleHost,
|
||||
options: ReconcileOptions,
|
||||
occurredAt: number,
|
||||
): void {
|
||||
if (!options.outcome || !host.sessionsResult) return;
|
||||
const keys = sessionKeysFor(host, options);
|
||||
if (keys.size === 0) return;
|
||||
const status = options.sessionStatus ?? (options.outcome === 'done' ? 'done' : 'killed');
|
||||
let changed = false;
|
||||
const sessions = host.sessionsResult.sessions.map((row) => {
|
||||
if (!keys.has(row.key)) return row;
|
||||
const next: GatewaySessionRow = {
|
||||
...row,
|
||||
hasActiveRun: false,
|
||||
status,
|
||||
endedAt: row.endedAt ?? occurredAt,
|
||||
};
|
||||
if (status === 'killed') next.abortedLastRun = true;
|
||||
if (typeof next.startedAt === 'number' && typeof next.endedAt === 'number') {
|
||||
next.runtimeMs = Math.max(0, next.endedAt - next.startedAt);
|
||||
}
|
||||
changed = true;
|
||||
return next;
|
||||
});
|
||||
if (changed) host.sessionsResult = { ...host.sessionsResult, sessions };
|
||||
}
|
||||
|
||||
export function reconcileChatRunLifecycle(
|
||||
host: RunLifecycleHost,
|
||||
options: ReconcileOptions = {},
|
||||
): void {
|
||||
const occurredAt = Date.now();
|
||||
const runId = options.runId ?? host.chatRunId ?? null;
|
||||
const sessionKey = toSessionKey(options.sessionKey) ?? host.sessionKey;
|
||||
|
||||
if (options.clearIndicators ?? true) clearRunIndicators(host);
|
||||
if (options.clearChatStream) {
|
||||
host.chatStream = null;
|
||||
host.chatStreamStartedAt = null;
|
||||
}
|
||||
if (options.clearLocalRun) host.chatRunId = null;
|
||||
if (options.clearSideResultTerminalRuns) host.chatSideResultTerminalRuns?.clear();
|
||||
if (options.clearToolStream) resetToolStream(host);
|
||||
|
||||
if (options.outcome) {
|
||||
const status: ChatRunUiStatus = { phase: options.outcome, runId, sessionKey, occurredAt };
|
||||
reconcileSessionRows(host, options, occurredAt);
|
||||
if (options.armLocalTerminalReconcile) {
|
||||
host.lastLocalTerminalReconcile = {
|
||||
sessionKey,
|
||||
runId,
|
||||
phase: options.outcome,
|
||||
sessionStatus: options.sessionStatus ?? (options.outcome === 'done' ? 'done' : 'killed'),
|
||||
occurredAt,
|
||||
};
|
||||
}
|
||||
if (options.publishRunStatus !== false) host.chatRunStatus = status;
|
||||
} else if (options.clearRunStatus) {
|
||||
host.chatRunStatus = null;
|
||||
}
|
||||
host.requestUpdate?.();
|
||||
}
|
||||
|
||||
function currentSessionRow(host: RunLifecycleHost): GatewaySessionRow | undefined {
|
||||
return host.sessionsResult?.sessions.find((row) => row.key === host.sessionKey);
|
||||
}
|
||||
|
||||
function reconcileStaleSelectedSessionRunAfterLocalCompletion(host: RunLifecycleHost): boolean {
|
||||
const recent = host.lastLocalTerminalReconcile;
|
||||
if (!recent || recent.sessionKey !== host.sessionKey) return false;
|
||||
if (Date.now() - recent.occurredAt > STALE_ACTIVE_ROW_RECONCILE_WINDOW_MS) {
|
||||
host.lastLocalTerminalReconcile = null;
|
||||
return false;
|
||||
}
|
||||
const row = currentSessionRow(host);
|
||||
if (!row || !isSessionRunActive(row)) {
|
||||
host.lastLocalTerminalReconcile = null;
|
||||
return false;
|
||||
}
|
||||
if (typeof row.startedAt === 'number' && row.startedAt > recent.occurredAt) {
|
||||
host.lastLocalTerminalReconcile = null;
|
||||
return false;
|
||||
}
|
||||
reconcileSessionRows(
|
||||
host,
|
||||
{ outcome: recent.phase, sessionStatus: recent.sessionStatus, sessionKey: recent.sessionKey },
|
||||
Date.now(),
|
||||
);
|
||||
host.requestUpdate?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function reconcileChatRunFromCurrentSessionRow(
|
||||
host: RunLifecycleHost,
|
||||
options: { publishRunStatus?: boolean } = {},
|
||||
): boolean {
|
||||
if (!host.chatRunId && host.chatStream == null) {
|
||||
return reconcileStaleSelectedSessionRunAfterLocalCompletion(host);
|
||||
}
|
||||
const row = currentSessionRow(host);
|
||||
if (!row) return false;
|
||||
return reconcileChatRunFromSessionRow(host, row, options);
|
||||
}
|
||||
|
||||
export function reconcileChatRunFromSessionRow(
|
||||
host: RunLifecycleHost,
|
||||
row: GatewaySessionRow,
|
||||
options: { publishRunStatus?: boolean } = {},
|
||||
): boolean {
|
||||
if (row.key !== host.sessionKey) return false;
|
||||
if (!host.chatRunId && host.chatStream == null) return false;
|
||||
if (isSessionRunActive(row)) return false;
|
||||
const terminalStatus = row.status !== undefined;
|
||||
if (row.hasActiveRun !== false && !terminalStatus) return false;
|
||||
|
||||
reconcileChatRunLifecycle(host, {
|
||||
outcome: row.status === 'done' ? 'done' : 'interrupted',
|
||||
sessionStatus: row.status === 'done' ? 'done' : (row.status ?? 'killed'),
|
||||
runId: host.chatRunId,
|
||||
sessionKey: host.sessionKey,
|
||||
sessionKeys: [row.key],
|
||||
clearLocalRun: true,
|
||||
clearChatStream: true,
|
||||
publishRunStatus: options.publishRunStatus,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import type {
|
||||
ChatCoreState,
|
||||
ChatRunUiStatus,
|
||||
CommandOutputEntry,
|
||||
LiveAssistantSegment,
|
||||
LiveThinkingSegment,
|
||||
LiveToolEntry,
|
||||
PatchSummaryEntry,
|
||||
VisibleChatItem,
|
||||
} from './types';
|
||||
import {
|
||||
queueItemHasMatchingHistoryMessage,
|
||||
shouldHideHistoryMessage,
|
||||
} from './history';
|
||||
import {
|
||||
extractThinkingText,
|
||||
stripHeartbeatTokenForDisplay,
|
||||
} from './message-extraction';
|
||||
import {
|
||||
collapseDuplicateAttachmentUserEchoes,
|
||||
collapseDuplicateIdempotentUserEchoes,
|
||||
mergeAdjacentToolResultMessages,
|
||||
} from './message-normalization';
|
||||
import { toolCardFromLiveEntry } from './tool-cards';
|
||||
|
||||
function messageId(message: Record<string, unknown>, index: number): string {
|
||||
return typeof message.id === 'string' && message.id.trim()
|
||||
? message.id
|
||||
: `history-${index}`;
|
||||
}
|
||||
|
||||
function runIdForHistoryMessage(message: Record<string, unknown>, id: string): string {
|
||||
return typeof message.runId === 'string' && message.runId.trim()
|
||||
? message.runId
|
||||
: id;
|
||||
}
|
||||
|
||||
function shouldShowRunStatus(status: ChatRunUiStatus): boolean {
|
||||
if (status.phase === 'idle' || status.phase === 'done' || status.phase === 'interrupted') {
|
||||
return false;
|
||||
}
|
||||
if (status.phase === 'error' && status.message?.trim().toLowerCase() === 'aborted') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function assistantStreamItem(segment: LiveAssistantSegment): Extract<VisibleChatItem, { kind: 'stream' }> | null {
|
||||
const display = stripHeartbeatTokenForDisplay(segment.text);
|
||||
const mediaUrls = segment.mediaUrls?.filter((url) => url.trim().length > 0);
|
||||
if (display.shouldSkip && !mediaUrls?.length) return null;
|
||||
return {
|
||||
kind: 'stream',
|
||||
id: `stream-${segment.id}`,
|
||||
runId: segment.runId,
|
||||
text: display.text,
|
||||
phase: segment.phase,
|
||||
...(mediaUrls?.length ? { mediaUrls } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function thinkingItem(segment: LiveThinkingSegment): Extract<VisibleChatItem, { kind: 'thinking' }> {
|
||||
return {
|
||||
kind: 'thinking',
|
||||
id: segment.id,
|
||||
runId: segment.runId,
|
||||
text: segment.text,
|
||||
};
|
||||
}
|
||||
|
||||
function hiddenLiveStatus(runId: string): ChatRunUiStatus {
|
||||
return { phase: 'running', runId };
|
||||
}
|
||||
|
||||
function toolItem(entry: LiveToolEntry): Extract<VisibleChatItem, { kind: 'tool' }> {
|
||||
return {
|
||||
kind: 'tool',
|
||||
id: `tool-${entry.id}`,
|
||||
runId: entry.runId,
|
||||
toolCallId: entry.toolCallId,
|
||||
tool: toolCardFromLiveEntry(entry),
|
||||
status: hiddenLiveStatus(entry.runId),
|
||||
};
|
||||
}
|
||||
|
||||
function commandItem(entry: CommandOutputEntry): Extract<VisibleChatItem, { kind: 'command' }> {
|
||||
return {
|
||||
kind: 'command',
|
||||
id: `command-${entry.id}`,
|
||||
command: entry,
|
||||
status: hiddenLiveStatus(entry.runId),
|
||||
};
|
||||
}
|
||||
|
||||
function patchItem(entry: PatchSummaryEntry): Extract<VisibleChatItem, { kind: 'patch' }> {
|
||||
return {
|
||||
kind: 'patch',
|
||||
id: `patch-${entry.id}`,
|
||||
patch: entry,
|
||||
status: hiddenLiveStatus(entry.runId),
|
||||
};
|
||||
}
|
||||
|
||||
type OrderedLiveItem = {
|
||||
ts: number;
|
||||
order: number;
|
||||
index: number;
|
||||
item: VisibleChatItem;
|
||||
};
|
||||
|
||||
function liveOrder(entry: { order?: number }, fallback: number): number {
|
||||
return typeof entry.order === 'number' && Number.isFinite(entry.order)
|
||||
? entry.order
|
||||
: fallback;
|
||||
}
|
||||
|
||||
export function selectVisibleChatItems(state: ChatCoreState): VisibleChatItem[] {
|
||||
const historyMessages = collapseDuplicateAttachmentUserEchoes(
|
||||
collapseDuplicateIdempotentUserEchoes(
|
||||
mergeAdjacentToolResultMessages(state.history.messages),
|
||||
),
|
||||
);
|
||||
const items: VisibleChatItem[] = [];
|
||||
historyMessages.forEach((message, index) => {
|
||||
if (shouldHideHistoryMessage(message)) return;
|
||||
const id = messageId(message, index);
|
||||
if (message.role === 'assistant') {
|
||||
const thinkingText = extractThinkingText(message);
|
||||
if (thinkingText) {
|
||||
items.push({
|
||||
kind: 'thinking',
|
||||
id: `thinking-${id}`,
|
||||
runId: runIdForHistoryMessage(message, id),
|
||||
text: thinkingText,
|
||||
});
|
||||
}
|
||||
}
|
||||
items.push({
|
||||
kind: 'message',
|
||||
id,
|
||||
message,
|
||||
});
|
||||
});
|
||||
|
||||
for (const item of state.send.queue) {
|
||||
if (item.sessionKey !== state.sessionKey) continue;
|
||||
if (queueItemHasMatchingHistoryMessage(item, state.history.messages)) continue;
|
||||
if (
|
||||
item.state === 'queued'
|
||||
|| item.state === 'sending'
|
||||
|| item.state === 'waiting-reconnect'
|
||||
|| item.state === 'failed'
|
||||
) {
|
||||
items.push({ kind: 'queue', id: `queue-${item.id}`, item });
|
||||
}
|
||||
}
|
||||
|
||||
const liveItems: OrderedLiveItem[] = [];
|
||||
const pushLiveItem = (ts: number, order: number, item: VisibleChatItem | null) => {
|
||||
if (!item) return;
|
||||
liveItems.push({ ts, order, index: liveItems.length, item });
|
||||
};
|
||||
|
||||
for (const segment of state.live.thinkingSegments) {
|
||||
pushLiveItem(segment.ts, liveOrder(segment, liveItems.length), thinkingItem(segment));
|
||||
}
|
||||
for (const segment of state.live.assistantSegments) {
|
||||
pushLiveItem(segment.ts, liveOrder(segment, liveItems.length), assistantStreamItem(segment));
|
||||
}
|
||||
if (state.live.currentThinking) {
|
||||
pushLiveItem(
|
||||
state.live.currentThinking.ts,
|
||||
liveOrder(state.live.currentThinking, liveItems.length),
|
||||
thinkingItem(state.live.currentThinking),
|
||||
);
|
||||
}
|
||||
if (state.live.currentAssistant) {
|
||||
pushLiveItem(
|
||||
state.live.currentAssistant.ts,
|
||||
liveOrder(state.live.currentAssistant, liveItems.length),
|
||||
assistantStreamItem(state.live.currentAssistant),
|
||||
);
|
||||
}
|
||||
|
||||
for (const toolCallId of state.live.toolStreamOrder) {
|
||||
const entry = state.live.toolStreamById[toolCallId];
|
||||
if (entry) pushLiveItem(entry.startedAt, liveOrder(entry, liveItems.length), toolItem(entry));
|
||||
}
|
||||
|
||||
for (const command of state.live.commandOutputs) {
|
||||
pushLiveItem(command.ts, liveOrder(command, liveItems.length), commandItem(command));
|
||||
}
|
||||
|
||||
for (const patch of state.live.patchSummaries) {
|
||||
pushLiveItem(patch.ts, liveOrder(patch, liveItems.length), patchItem(patch));
|
||||
}
|
||||
|
||||
liveItems.sort((left, right) => left.ts - right.ts || left.order - right.order || left.index - right.index);
|
||||
for (const liveItem of liveItems) items.push(liveItem.item);
|
||||
|
||||
if (state.runtime.compactionStatus) {
|
||||
items.push({
|
||||
kind: 'runtime',
|
||||
id: `runtime-compaction-${state.runtime.compactionStatus.phase}`,
|
||||
status: { kind: 'compaction', ...state.runtime.compactionStatus },
|
||||
});
|
||||
}
|
||||
|
||||
if (state.runtime.fallbackStatus && state.runtime.fallbackStatus.phase !== 'cleared') {
|
||||
items.push({
|
||||
kind: 'runtime',
|
||||
id: `runtime-fallback-${state.runtime.fallbackStatus.phase}`,
|
||||
status: { kind: 'fallback', ...state.runtime.fallbackStatus },
|
||||
});
|
||||
}
|
||||
|
||||
for (const approval of state.runtime.approvals) {
|
||||
items.push({ kind: 'approval', id: `approval-${approval.id}`, approval });
|
||||
}
|
||||
|
||||
if (state.runtime.runStatus && shouldShowRunStatus(state.runtime.runStatus)) {
|
||||
items.push({
|
||||
kind: 'status',
|
||||
id: `status-${state.runtime.runStatus.runId ?? state.runtime.runStatus.phase}`,
|
||||
status: state.runtime.runStatus,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { ChatCoreClient, ChatQueueItem } from './types';
|
||||
|
||||
export function createIdempotencyKey(prefix = 'clawx-chat'): string {
|
||||
const random = Math.random().toString(36).slice(2, 10);
|
||||
return `${prefix}-${Date.now()}-${random}`;
|
||||
}
|
||||
|
||||
export function isRecoverableSendError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const normalized = message.toLowerCase();
|
||||
return (
|
||||
normalized.includes('rpc timeout: chat.send')
|
||||
|| normalized.includes('disconnected')
|
||||
|| normalized.includes('not connected')
|
||||
|| normalized.includes('gateway unavailable')
|
||||
);
|
||||
}
|
||||
|
||||
export function createQueueItem(input: {
|
||||
sessionKey: string;
|
||||
message: string;
|
||||
id?: string;
|
||||
idempotencyKey?: string;
|
||||
createdAt?: number;
|
||||
historyMessageCountAtEnqueue?: number;
|
||||
attachments?: ChatQueueItem['attachments'];
|
||||
}): ChatQueueItem {
|
||||
return {
|
||||
id: input.id ?? createIdempotencyKey('queue'),
|
||||
sessionKey: input.sessionKey,
|
||||
message: input.message,
|
||||
idempotencyKey: input.idempotencyKey ?? createIdempotencyKey(),
|
||||
createdAt: input.createdAt ?? Date.now(),
|
||||
...(typeof input.historyMessageCountAtEnqueue === 'number'
|
||||
? { historyMessageCountAtEnqueue: input.historyMessageCountAtEnqueue }
|
||||
: {}),
|
||||
...(input.attachments?.length ? { attachments: input.attachments } : {}),
|
||||
state: 'queued',
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendQueuedItem(
|
||||
client: ChatCoreClient,
|
||||
item: ChatQueueItem,
|
||||
extraParams: Record<string, unknown> = {},
|
||||
): Promise<{ runId: string | null }> {
|
||||
const response = await client.request<{ runId?: string; idempotencyKey?: string }>(
|
||||
'chat.send',
|
||||
{
|
||||
...extraParams,
|
||||
sessionKey: item.sessionKey,
|
||||
message: item.message,
|
||||
deliver: false,
|
||||
idempotencyKey: item.idempotencyKey,
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
return { runId: response.runId ?? response.idempotencyKey ?? null };
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/*
|
||||
* Vendored from OpenClaw Web UI on 2026-06-19.
|
||||
* Local ClawX changes must stay adapter-oriented and must not add Renderer
|
||||
* direct Gateway access.
|
||||
*/
|
||||
|
||||
import type { ChatCoreClient } from './types';
|
||||
|
||||
export type ChatModelOverride = {
|
||||
kind: 'model' | 'qualified';
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type SlashCommandResult = {
|
||||
content: string;
|
||||
action?: 'refresh' | 'export' | 'new-session' | 'reset' | 'stop' | 'clear' | 'navigate-usage';
|
||||
sessionPatch?: {
|
||||
modelOverride?: ChatModelOverride | null;
|
||||
};
|
||||
trackRunId?: string;
|
||||
pendingCurrentRun?: boolean;
|
||||
};
|
||||
|
||||
export type SlashCommandContext = {
|
||||
chatModelCatalog?: Array<{ id: string }>;
|
||||
modelCatalog?: Array<{ id: string }>;
|
||||
skills?: Array<{ name?: string; description?: string }>;
|
||||
sessionsResult?: {
|
||||
sessions?: Array<{ key?: string; model?: string; modelProvider?: string }>;
|
||||
defaults?: { model?: string };
|
||||
} | null;
|
||||
agentId?: string;
|
||||
};
|
||||
|
||||
type SessionPatchResult = {
|
||||
resolved?: {
|
||||
model?: string;
|
||||
modelProvider?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const LOCAL_COMMANDS = [
|
||||
{ name: 'help', description: 'Show available commands', args: '', category: 'general' },
|
||||
{ name: 'new', description: 'Start a new session', args: '', category: 'session' },
|
||||
{ name: 'reset', description: 'Reset this session', args: '', category: 'session' },
|
||||
{ name: 'stop', description: 'Stop the current run', args: '', category: 'session' },
|
||||
{ name: 'clear', description: 'Clear local chat view', args: '', category: 'session' },
|
||||
{ name: 'compact', description: 'Compact session context', args: '', category: 'agent' },
|
||||
{ name: 'model', description: 'Show or set model', args: '[model]', category: 'agent' },
|
||||
{ name: 'usage', description: 'Open usage view', args: '', category: 'general' },
|
||||
{ name: 'agents', description: 'List agents', args: '', category: 'agent' },
|
||||
{ name: 'export-session', description: 'Export session', args: '', category: 'session' },
|
||||
];
|
||||
|
||||
function createChatModelOverride(value: string): ChatModelOverride | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
return { kind: trimmed.includes('/') ? 'qualified' : 'model', value: trimmed };
|
||||
}
|
||||
|
||||
function selectedGlobalScope(
|
||||
sessionKey: string,
|
||||
context: SlashCommandContext,
|
||||
): Record<string, unknown> {
|
||||
return context.agentId ? { agentId: context.agentId, sessionKey } : { sessionKey };
|
||||
}
|
||||
|
||||
function executeHelp(): SlashCommandResult {
|
||||
const lines = ['**Available Commands**\n'];
|
||||
let currentCategory = '';
|
||||
for (const command of LOCAL_COMMANDS) {
|
||||
if (command.category !== currentCategory) {
|
||||
currentCategory = command.category;
|
||||
lines.push(`**${currentCategory.charAt(0).toUpperCase()}${currentCategory.slice(1)}**`);
|
||||
}
|
||||
const args = command.args ? ` ${command.args}` : '';
|
||||
lines.push(`\`/${command.name}${args}\` - ${command.description}`);
|
||||
}
|
||||
return { content: lines.join('\n') };
|
||||
}
|
||||
|
||||
async function executeCompact(
|
||||
client: ChatCoreClient,
|
||||
sessionKey: string,
|
||||
context: SlashCommandContext,
|
||||
): Promise<SlashCommandResult> {
|
||||
try {
|
||||
const result = await client.request<{
|
||||
compacted?: boolean;
|
||||
reason?: string;
|
||||
result?: { tokensBefore?: number; tokensAfter?: number };
|
||||
}>('sessions.compact', { key: sessionKey, ...selectedGlobalScope(sessionKey, context) });
|
||||
if (result?.compacted) {
|
||||
const before = result.result?.tokensBefore;
|
||||
const after = result.result?.tokensAfter;
|
||||
const tokenSummary = typeof before === 'number' && typeof after === 'number'
|
||||
? ` (${before.toLocaleString()} -> ${after.toLocaleString()} tokens)`
|
||||
: '';
|
||||
return { content: `Context compacted successfully${tokenSummary}.`, action: 'refresh' };
|
||||
}
|
||||
if (typeof result?.reason === 'string' && result.reason.trim()) {
|
||||
return { content: `Compaction skipped: ${result.reason}`, action: 'refresh' };
|
||||
}
|
||||
return { content: 'Compaction skipped.', action: 'refresh' };
|
||||
} catch (error) {
|
||||
return { content: `Compaction failed: ${String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
async function executeModel(
|
||||
client: ChatCoreClient,
|
||||
sessionKey: string,
|
||||
args: string,
|
||||
context: SlashCommandContext,
|
||||
): Promise<SlashCommandResult> {
|
||||
const requestedModel = args.trim();
|
||||
if (!requestedModel) {
|
||||
const sessions = context.sessionsResult
|
||||
?? await client.request<NonNullable<SlashCommandContext['sessionsResult']>>(
|
||||
'sessions.list',
|
||||
{},
|
||||
);
|
||||
const session = sessions?.sessions?.find((row) => row.key === sessionKey);
|
||||
const model = session?.model || sessions?.defaults?.model || 'default';
|
||||
const catalog = context.chatModelCatalog ?? context.modelCatalog ?? [];
|
||||
const lines = [`**Current model:** \`${model}\``];
|
||||
if (catalog.length > 0) {
|
||||
lines.push(`**Available:** ${catalog.slice(0, 10).map((entry) => `\`${entry.id}\``).join(', ')}`);
|
||||
}
|
||||
return { content: lines.join('\n') };
|
||||
}
|
||||
|
||||
try {
|
||||
const patched = await client.request<SessionPatchResult>('sessions.patch', {
|
||||
key: sessionKey,
|
||||
...selectedGlobalScope(sessionKey, context),
|
||||
model: requestedModel,
|
||||
});
|
||||
const resolvedModel = patched.resolved?.model ?? requestedModel;
|
||||
const resolvedProvider = patched.resolved?.modelProvider?.trim();
|
||||
const resolvedValue = resolvedProvider && !resolvedModel.includes('/')
|
||||
? `${resolvedProvider}/${resolvedModel}`
|
||||
: resolvedModel;
|
||||
return {
|
||||
content: `Model set to \`${requestedModel}\`.`,
|
||||
action: 'refresh',
|
||||
sessionPatch: { modelOverride: createChatModelOverride(resolvedValue) },
|
||||
};
|
||||
} catch (error) {
|
||||
return { content: `Failed to set model: ${String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
async function executeAgents(client: ChatCoreClient): Promise<SlashCommandResult> {
|
||||
try {
|
||||
const result = await client.request<{ agents?: Array<{ id?: string; name?: string }> }>(
|
||||
'agents.list',
|
||||
{},
|
||||
);
|
||||
const agents = result.agents ?? [];
|
||||
if (agents.length === 0) return { content: 'No agents found.' };
|
||||
return {
|
||||
content: agents
|
||||
.map((agent) => `- ${agent.name ?? agent.id ?? 'agent'}${agent.id ? ` (\`${agent.id}\`)` : ''}`)
|
||||
.join('\n'),
|
||||
};
|
||||
} catch (error) {
|
||||
return { content: `Failed to list agents: ${String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
function executeSkills(context: SlashCommandContext): SlashCommandResult {
|
||||
const skills = context.skills ?? [];
|
||||
if (skills.length === 0) return { content: 'No skills found.' };
|
||||
return {
|
||||
content: skills
|
||||
.map((skill) => {
|
||||
const name = skill.name ?? 'skill';
|
||||
return `- \`/skill ${name}\`${skill.description ? ` - ${skill.description}` : ''}`;
|
||||
})
|
||||
.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
function executeSkill(args: string, context: SlashCommandContext): SlashCommandResult {
|
||||
const skillName = args.trim();
|
||||
if (!skillName) return executeSkills(context);
|
||||
const skill = (context.skills ?? []).find((item) => item.name === skillName);
|
||||
if (!skill) return { content: `Skill not found: \`${skillName}\`` };
|
||||
return {
|
||||
content: `/skill ${skillName}${skill.description ? ` - ${skill.description}` : ''}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function executeUsage(client: ChatCoreClient): Promise<SlashCommandResult> {
|
||||
try {
|
||||
const result = await client.request<Record<string, unknown>>('usage.summary', {});
|
||||
return { content: `\`\`\`json\n${JSON.stringify(result, null, 2)}\n\`\`\``, action: 'navigate-usage' };
|
||||
} catch {
|
||||
return { content: 'Opening usage view...', action: 'navigate-usage' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeSlashCommand(
|
||||
client: ChatCoreClient,
|
||||
sessionKey: string,
|
||||
commandName: string,
|
||||
args: string,
|
||||
context: SlashCommandContext = {},
|
||||
): Promise<SlashCommandResult> {
|
||||
switch (commandName) {
|
||||
case 'help':
|
||||
return executeHelp();
|
||||
case 'new':
|
||||
return { content: 'Starting new session...', action: 'new-session' };
|
||||
case 'reset':
|
||||
return { content: 'Resetting session...', action: 'reset' };
|
||||
case 'stop':
|
||||
return { content: 'Stopping current run...', action: 'stop' };
|
||||
case 'clear':
|
||||
return { content: 'Chat history cleared.', action: 'clear' };
|
||||
case 'compact':
|
||||
return executeCompact(client, sessionKey, context);
|
||||
case 'model':
|
||||
return executeModel(client, sessionKey, args, context);
|
||||
case 'export-session':
|
||||
return { content: 'Exporting session...', action: 'export' };
|
||||
case 'usage':
|
||||
return executeUsage(client);
|
||||
case 'agents':
|
||||
return executeAgents(client);
|
||||
case 'skills':
|
||||
return executeSkills(context);
|
||||
case 'skill':
|
||||
return executeSkill(args, context);
|
||||
default:
|
||||
return { content: `Unknown command: \`/${commandName}\`` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ChatCoreState } from './types';
|
||||
|
||||
export function createInitialChatCoreState(input: {
|
||||
sessionKey: string;
|
||||
selectedAgentId?: string;
|
||||
}): ChatCoreState {
|
||||
return {
|
||||
sessionKey: input.sessionKey,
|
||||
...(input.selectedAgentId ? { selectedAgentId: input.selectedAgentId } : {}),
|
||||
history: {
|
||||
messages: [],
|
||||
loading: false,
|
||||
hasMore: false,
|
||||
requestVersion: 0,
|
||||
},
|
||||
live: {
|
||||
runId: null,
|
||||
currentAssistant: null,
|
||||
assistantSegments: [],
|
||||
currentThinking: null,
|
||||
thinkingSegments: [],
|
||||
toolMessages: [],
|
||||
toolStreamById: {},
|
||||
toolStreamOrder: [],
|
||||
commandOutputs: [],
|
||||
patchSummaries: [],
|
||||
},
|
||||
send: {
|
||||
sending: false,
|
||||
queue: [],
|
||||
activeRunId: null,
|
||||
canAbort: false,
|
||||
lastError: null,
|
||||
abortedRunIds: [],
|
||||
},
|
||||
runtime: {
|
||||
runStatus: null,
|
||||
compactionStatus: null,
|
||||
fallbackStatus: null,
|
||||
approvals: [],
|
||||
resolvedApprovalIds: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
/*
|
||||
* Vendored from OpenClaw Web UI on 2026-06-19.
|
||||
* Local ClawX changes must stay adapter-oriented and must not add Renderer
|
||||
* direct Gateway access.
|
||||
*/
|
||||
|
||||
export type StreamReconciliationState = {
|
||||
chatStream: string | null;
|
||||
chatStreamStartedAt: number | null;
|
||||
};
|
||||
|
||||
type ToolStreamHost = StreamReconciliationState & {
|
||||
chatStreamSegments?: Array<{ text?: unknown; ts?: unknown; toolCallId?: unknown }>;
|
||||
chatToolMessages?: unknown[];
|
||||
toolStreamById?: Map<string, unknown>;
|
||||
toolStreamOrder?: unknown[];
|
||||
};
|
||||
|
||||
export type AssistantMessageVisibility = (message: unknown) => boolean;
|
||||
export type StreamVisibility = (stream: string) => boolean;
|
||||
|
||||
export type MaterializeVisibleStreamOptions = {
|
||||
includeCurrent?: boolean;
|
||||
requirePersistedTool?: boolean;
|
||||
replacementMessages?: unknown[];
|
||||
isHiddenAssistantMessage: AssistantMessageVisibility;
|
||||
isHiddenStreamText: StreamVisibility;
|
||||
};
|
||||
|
||||
type VisibleAssistantStreamPart = {
|
||||
text: string;
|
||||
replacementText: string;
|
||||
source: 'segment' | 'current';
|
||||
timestamp: number;
|
||||
toolCallId?: string;
|
||||
};
|
||||
|
||||
function roleOf(message: unknown): string {
|
||||
if (!message || typeof message !== 'object') return '';
|
||||
const role = (message as Record<string, unknown>).role;
|
||||
return typeof role === 'string' ? role.trim().toLowerCase() : '';
|
||||
}
|
||||
|
||||
function extractText(message: unknown): string | null {
|
||||
if (typeof message === 'string') return message;
|
||||
if (!message || typeof message !== 'object') return null;
|
||||
const record = message as Record<string, unknown>;
|
||||
if (typeof record.text === 'string') return record.text;
|
||||
if (typeof record.content === 'string') return record.content;
|
||||
if (Array.isArray(record.content)) {
|
||||
const parts = record.content.flatMap((entry) => {
|
||||
if (!entry || typeof entry !== 'object') return [];
|
||||
const text = (entry as Record<string, unknown>).text;
|
||||
return typeof text === 'string' ? [text] : [];
|
||||
});
|
||||
return parts.length > 0 ? parts.join('\n') : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function trimAccumulatedStreamPrefix(text: string, previousText: string | null): string {
|
||||
if (!previousText || !text.startsWith(previousText)) return text;
|
||||
return text.slice(previousText.length);
|
||||
}
|
||||
|
||||
function extractToolMessageRefs(message: unknown): Array<{ id: string }> {
|
||||
if (!message || typeof message !== 'object') return [];
|
||||
const record = message as Record<string, unknown>;
|
||||
const refs: Array<{ id: string }> = [];
|
||||
const values = [
|
||||
record.toolCallId,
|
||||
record.tool_call_id,
|
||||
record.id,
|
||||
];
|
||||
for (const value of values) {
|
||||
if (typeof value === 'string' && value.trim()) refs.push({ id: value.trim() });
|
||||
}
|
||||
for (const item of Array.isArray(record.content) ? record.content : []) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const itemRecord = item as Record<string, unknown>;
|
||||
const id = itemRecord.toolCallId ?? itemRecord.tool_call_id ?? itemRecord.id;
|
||||
if (typeof id === 'string' && id.trim()) refs.push({ id: id.trim() });
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
export function currentLiveToolCallIds(state: StreamReconciliationState): string[] {
|
||||
const toolHost = state as ToolStreamHost;
|
||||
return Array.isArray(toolHost.toolStreamOrder)
|
||||
? toolHost.toolStreamOrder.filter(
|
||||
(value): value is string => typeof value === 'string' && value.trim().length > 0,
|
||||
)
|
||||
: [];
|
||||
}
|
||||
|
||||
export function lastUserMessageIndex(messages: unknown[]): number {
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
if (roleOf(messages[index]) === 'user') return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function maybeResetToolStream(
|
||||
state: StreamReconciliationState,
|
||||
opts?: { preserveStreamSegments?: boolean },
|
||||
): void {
|
||||
const toolHost = state as ToolStreamHost;
|
||||
const preserved = opts?.preserveStreamSegments && Array.isArray(toolHost.chatStreamSegments)
|
||||
? [...toolHost.chatStreamSegments]
|
||||
: null;
|
||||
toolHost.toolStreamById?.clear();
|
||||
if (Array.isArray(toolHost.toolStreamOrder)) toolHost.toolStreamOrder = [];
|
||||
if (Array.isArray(toolHost.chatToolMessages)) toolHost.chatToolMessages = [];
|
||||
if (Array.isArray(toolHost.chatStreamSegments)) toolHost.chatStreamSegments = preserved ?? [];
|
||||
}
|
||||
|
||||
export function clearToolStreamSegments(state: StreamReconciliationState): void {
|
||||
const toolHost = state as ToolStreamHost;
|
||||
if (Array.isArray(toolHost.chatStreamSegments)) toolHost.chatStreamSegments = [];
|
||||
}
|
||||
|
||||
export function persistedCurrentToolStreamIds(
|
||||
messages: unknown[],
|
||||
state: StreamReconciliationState,
|
||||
): Set<string> {
|
||||
const liveToolIdSet = new Set(currentLiveToolCallIds(state));
|
||||
const matchedToolIds = new Set<string>();
|
||||
if (liveToolIdSet.size === 0) return matchedToolIds;
|
||||
for (const message of messages.slice(lastUserMessageIndex(messages) + 1)) {
|
||||
for (const ref of extractToolMessageRefs(message)) {
|
||||
if (liveToolIdSet.has(ref.id)) matchedToolIds.add(ref.id);
|
||||
}
|
||||
}
|
||||
return matchedToolIds;
|
||||
}
|
||||
|
||||
function buildAssistantStreamMessage(
|
||||
stream: string,
|
||||
replacementText = stream,
|
||||
timestamp = Date.now(),
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: stream }],
|
||||
timestamp,
|
||||
openclawStreamFallback: { replacementText },
|
||||
};
|
||||
}
|
||||
|
||||
function streamFallbackReplacementText(message: unknown): string | null {
|
||||
if (!message || typeof message !== 'object') return null;
|
||||
const fallback = (message as Record<string, unknown>).openclawStreamFallback;
|
||||
if (!fallback || typeof fallback !== 'object') return null;
|
||||
const replacementText = (fallback as Record<string, unknown>).replacementText;
|
||||
if (typeof replacementText === 'string' && replacementText.trim()) return replacementText.trim();
|
||||
return extractText(message)?.trim() ?? null;
|
||||
}
|
||||
|
||||
function terminalMessageReplacesStreamFallback(message: unknown, fallback: unknown): boolean {
|
||||
const fallbackText = streamFallbackReplacementText(fallback);
|
||||
if (!fallbackText) return false;
|
||||
const terminalText = extractText(message)?.trim();
|
||||
return Boolean(
|
||||
terminalText && (terminalText === fallbackText || terminalText.startsWith(fallbackText)),
|
||||
);
|
||||
}
|
||||
|
||||
export function appendTerminalAssistantMessage(messages: unknown[], message: unknown): unknown[] {
|
||||
const retainedMessages = messages.filter((existing, index) => {
|
||||
if (index <= lastUserMessageIndex(messages)) return true;
|
||||
return !terminalMessageReplacesStreamFallback(message, existing);
|
||||
});
|
||||
return [...retainedMessages, message];
|
||||
}
|
||||
|
||||
function visibleAssistantStreamText(
|
||||
stream: string | null,
|
||||
isHiddenStreamText: StreamVisibility,
|
||||
): string | null {
|
||||
if (!stream?.trim() || isHiddenStreamText(stream)) return null;
|
||||
return stream;
|
||||
}
|
||||
|
||||
function hasAssistantStreamReplacement(
|
||||
messages: unknown[],
|
||||
stream: string,
|
||||
isHiddenAssistantMessage: AssistantMessageVisibility,
|
||||
): boolean {
|
||||
const expected = stream.trim();
|
||||
if (!expected) return false;
|
||||
return messages.slice(lastUserMessageIndex(messages) + 1).some((message) => {
|
||||
const role = roleOf(message);
|
||||
if (role && role !== 'assistant') return false;
|
||||
if (role === 'assistant' && isHiddenAssistantMessage(message)) return false;
|
||||
const text = extractText(message)?.trim();
|
||||
return Boolean(text && (text === expected || text.startsWith(expected)));
|
||||
});
|
||||
}
|
||||
|
||||
function visibleAssistantStreamParts(
|
||||
state: StreamReconciliationState,
|
||||
opts: Pick<MaterializeVisibleStreamOptions, 'includeCurrent' | 'isHiddenStreamText'>,
|
||||
): VisibleAssistantStreamPart[] {
|
||||
const streamHost = state as ToolStreamHost;
|
||||
const liveToolIds = currentLiveToolCallIds(state);
|
||||
const parts: VisibleAssistantStreamPart[] = [];
|
||||
let previousText: string | null = null;
|
||||
const segments = Array.isArray(streamHost.chatStreamSegments)
|
||||
? streamHost.chatStreamSegments
|
||||
: [];
|
||||
|
||||
for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex++) {
|
||||
const segment = segments[segmentIndex];
|
||||
if (!segment || typeof segment.text !== 'string') continue;
|
||||
const visible = visibleAssistantStreamText(
|
||||
trimAccumulatedStreamPrefix(segment.text, previousText),
|
||||
opts.isHiddenStreamText,
|
||||
);
|
||||
if (visible) {
|
||||
const explicitToolCallId = typeof segment.toolCallId === 'string' && segment.toolCallId.trim()
|
||||
? segment.toolCallId.trim()
|
||||
: undefined;
|
||||
parts.push({
|
||||
text: visible,
|
||||
replacementText: segment.text,
|
||||
source: 'segment',
|
||||
timestamp: typeof segment.ts === 'number' && Number.isFinite(segment.ts)
|
||||
? segment.ts
|
||||
: Date.now(),
|
||||
toolCallId: explicitToolCallId ?? liveToolIds[segmentIndex],
|
||||
});
|
||||
}
|
||||
if (segment.text.trim()) previousText = segment.text;
|
||||
}
|
||||
|
||||
if (opts.includeCurrent !== false && typeof state.chatStream === 'string') {
|
||||
const visible = visibleAssistantStreamText(
|
||||
trimAccumulatedStreamPrefix(state.chatStream, previousText),
|
||||
opts.isHiddenStreamText,
|
||||
);
|
||||
if (visible) {
|
||||
parts.push({
|
||||
text: visible,
|
||||
replacementText: state.chatStream,
|
||||
source: 'current',
|
||||
timestamp: state.chatStreamStartedAt ?? Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function visibleCurrentAssistantStreamTail(
|
||||
state: StreamReconciliationState,
|
||||
isHiddenStreamText: StreamVisibility,
|
||||
): string | null {
|
||||
if (typeof state.chatStream !== 'string') return null;
|
||||
const streamHost = state as ToolStreamHost;
|
||||
const segments = Array.isArray(streamHost.chatStreamSegments)
|
||||
? streamHost.chatStreamSegments
|
||||
: [];
|
||||
let previousText: string | null = null;
|
||||
for (const segment of segments) {
|
||||
if (typeof segment.text === 'string' && segment.text.trim()) previousText = segment.text;
|
||||
}
|
||||
return visibleAssistantStreamText(
|
||||
trimAccumulatedStreamPrefix(state.chatStream, previousText),
|
||||
isHiddenStreamText,
|
||||
);
|
||||
}
|
||||
|
||||
function hasAssistantStreamPartReplacement(
|
||||
messages: unknown[],
|
||||
part: VisibleAssistantStreamPart,
|
||||
isHiddenAssistantMessage: AssistantMessageVisibility,
|
||||
): boolean {
|
||||
return (
|
||||
hasAssistantStreamReplacement(messages, part.replacementText, isHiddenAssistantMessage)
|
||||
|| hasAssistantStreamReplacement(messages, part.text, isHiddenAssistantMessage)
|
||||
);
|
||||
}
|
||||
|
||||
export function historyReplacedVisibleStream(
|
||||
messages: unknown[],
|
||||
state: StreamReconciliationState,
|
||||
opts: Pick<
|
||||
MaterializeVisibleStreamOptions,
|
||||
'includeCurrent' | 'isHiddenAssistantMessage' | 'isHiddenStreamText'
|
||||
>,
|
||||
): boolean {
|
||||
const parts = visibleAssistantStreamParts(state, opts);
|
||||
return (
|
||||
parts.length > 0
|
||||
&& parts.every((part) => (
|
||||
hasAssistantStreamPartReplacement(messages, part, opts.isHiddenAssistantMessage)
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
export function hasVisibleStreamParts(
|
||||
state: StreamReconciliationState,
|
||||
opts: Pick<MaterializeVisibleStreamOptions, 'includeCurrent' | 'isHiddenStreamText'>,
|
||||
): boolean {
|
||||
return visibleAssistantStreamParts(state, opts).length > 0;
|
||||
}
|
||||
|
||||
function currentToolStreamMessageIndex(
|
||||
messages: unknown[],
|
||||
state: StreamReconciliationState,
|
||||
toolCallId?: string,
|
||||
): number {
|
||||
const liveToolIds = toolCallId ? new Set([toolCallId]) : new Set(currentLiveToolCallIds(state));
|
||||
if (liveToolIds.size === 0) return -1;
|
||||
const startIndex = lastUserMessageIndex(messages) + 1;
|
||||
for (let index = startIndex; index < messages.length; index++) {
|
||||
if (extractToolMessageRefs(messages[index]).some((ref) => liveToolIds.has(ref.id))) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function messageTimestampMs(message: unknown): number | null {
|
||||
if (!message || typeof message !== 'object') return null;
|
||||
const record = message as Record<string, unknown>;
|
||||
const timestamp = record.timestamp;
|
||||
if (typeof timestamp === 'number' && Number.isFinite(timestamp)) return timestamp;
|
||||
const ts = record.ts;
|
||||
return typeof ts === 'number' && Number.isFinite(ts) ? ts : null;
|
||||
}
|
||||
|
||||
function previousTimestamp(messages: unknown[], endIndex: number): number | null {
|
||||
for (let index = endIndex - 1; index >= 0; index--) {
|
||||
const timestamp = messageTimestampMs(messages[index]);
|
||||
if (timestamp != null) return timestamp;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function nextTimestamp(messages: unknown[], startIndex: number): number | null {
|
||||
for (let index = startIndex; index < messages.length; index++) {
|
||||
const timestamp = messageTimestampMs(messages[index]);
|
||||
if (timestamp != null) return timestamp;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function timestampForInsertedVisibleStream(
|
||||
messages: unknown[],
|
||||
index: number,
|
||||
desiredTimestamp: number,
|
||||
): number {
|
||||
const prev = previousTimestamp(messages, index);
|
||||
const next = nextTimestamp(messages, index);
|
||||
if (prev != null && desiredTimestamp <= prev) {
|
||||
const afterPrevious = prev + 1;
|
||||
return next != null && afterPrevious >= next ? prev + (next - prev) / 2 : afterPrevious;
|
||||
}
|
||||
if (next != null && desiredTimestamp >= next) {
|
||||
const beforeNext = next - 1;
|
||||
return prev != null && beforeNext <= prev ? prev + (next - prev) / 2 : beforeNext;
|
||||
}
|
||||
return desiredTimestamp;
|
||||
}
|
||||
|
||||
export function materializeVisibleStreamState(
|
||||
messages: unknown[],
|
||||
state: StreamReconciliationState,
|
||||
opts: MaterializeVisibleStreamOptions,
|
||||
): unknown[] {
|
||||
let nextMessages = messages;
|
||||
for (const part of visibleAssistantStreamParts(state, opts)) {
|
||||
const replacementMessages = opts.replacementMessages ?? [];
|
||||
if (
|
||||
hasAssistantStreamPartReplacement(
|
||||
[...nextMessages, ...replacementMessages],
|
||||
part,
|
||||
opts.isHiddenAssistantMessage,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const toolIndex = part.source === 'segment'
|
||||
? currentToolStreamMessageIndex(nextMessages, state, part.toolCallId)
|
||||
: -1;
|
||||
if (opts.requirePersistedTool && toolIndex < 0) continue;
|
||||
const insertIndex = toolIndex >= 0 ? toolIndex : nextMessages.length;
|
||||
const streamMessage = buildAssistantStreamMessage(
|
||||
part.text,
|
||||
part.replacementText,
|
||||
timestampForInsertedVisibleStream(nextMessages, insertIndex, part.timestamp),
|
||||
);
|
||||
nextMessages = [
|
||||
...nextMessages.slice(0, insertIndex),
|
||||
streamMessage,
|
||||
...nextMessages.slice(insertIndex),
|
||||
];
|
||||
}
|
||||
return nextMessages;
|
||||
}
|
||||
|
||||
export function prunePersistedToolStreamMessages(
|
||||
state: StreamReconciliationState,
|
||||
persistedToolIds: Set<string>,
|
||||
): void {
|
||||
if (persistedToolIds.size === 0) return;
|
||||
const toolHost = state as ToolStreamHost;
|
||||
toolHost.toolStreamById?.forEach((_value, id) => {
|
||||
if (persistedToolIds.has(id)) toolHost.toolStreamById?.delete(id);
|
||||
});
|
||||
if (Array.isArray(toolHost.toolStreamOrder)) {
|
||||
toolHost.toolStreamOrder = toolHost.toolStreamOrder.filter(
|
||||
(id): id is string => typeof id === 'string' && !persistedToolIds.has(id),
|
||||
);
|
||||
}
|
||||
if (Array.isArray(toolHost.chatToolMessages)) {
|
||||
toolHost.chatToolMessages = toolHost.chatToolMessages.filter((message) => (
|
||||
extractToolMessageRefs(message).every((ref) => !persistedToolIds.has(ref.id))
|
||||
));
|
||||
}
|
||||
if (Array.isArray(toolHost.chatStreamSegments)) {
|
||||
toolHost.chatStreamSegments = toolHost.chatStreamSegments.filter((segment) => {
|
||||
const toolCallId = typeof segment.toolCallId === 'string' ? segment.toolCallId.trim() : '';
|
||||
return !toolCallId || !persistedToolIds.has(toolCallId);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
/*
|
||||
* Vendored from OpenClaw Web UI on 2026-06-19.
|
||||
* Local ClawX changes must stay adapter-oriented and must not add Renderer
|
||||
* direct Gateway access.
|
||||
*/
|
||||
|
||||
import type { LiveToolEntry } from './types';
|
||||
|
||||
export type ToolCard = {
|
||||
id: string;
|
||||
toolName?: string;
|
||||
inputText?: string;
|
||||
outputText?: string;
|
||||
isError?: boolean;
|
||||
preview?: {
|
||||
kind: 'text' | 'json' | 'image' | 'unknown';
|
||||
label?: string;
|
||||
text?: string;
|
||||
url?: string;
|
||||
};
|
||||
transcriptMessageId?: string;
|
||||
};
|
||||
|
||||
function normalizeContent(content: unknown): Array<Record<string, unknown>> {
|
||||
if (!Array.isArray(content)) return [];
|
||||
return content.filter(
|
||||
(entry): entry is Record<string, unknown> => Boolean(entry) && typeof entry === 'object',
|
||||
);
|
||||
}
|
||||
|
||||
function resolveTranscriptMessageId(message: Record<string, unknown>): string | undefined {
|
||||
if (typeof message.messageId === 'string' && message.messageId.trim()) {
|
||||
return message.messageId;
|
||||
}
|
||||
const openClawMeta = message.__openclaw;
|
||||
if (!openClawMeta || typeof openClawMeta !== 'object' || Array.isArray(openClawMeta)) {
|
||||
return undefined;
|
||||
}
|
||||
const id = (openClawMeta as Record<string, unknown>).id;
|
||||
return typeof id === 'string' && id.trim() ? id : undefined;
|
||||
}
|
||||
|
||||
function coerceArgs(value: unknown): unknown {
|
||||
if (typeof value !== 'string') return value;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('['))) return value;
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function extractText(value: unknown): string | undefined {
|
||||
if (typeof value === 'string') return value;
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (typeof record.text === 'string') return record.text;
|
||||
if (typeof record.content === 'string') return record.content;
|
||||
if (Array.isArray(record.content)) {
|
||||
const parts = record.content.flatMap((entry) => {
|
||||
if (!entry || typeof entry !== 'object') return [];
|
||||
const text = (entry as Record<string, unknown>).text;
|
||||
return typeof text === 'string' ? [text] : [];
|
||||
});
|
||||
return parts.length > 0 ? parts.join('\n') : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractToolText(item: Record<string, unknown>): string | undefined {
|
||||
return extractText(item);
|
||||
}
|
||||
|
||||
function readToolErrorFlag(value: Record<string, unknown>): boolean | undefined {
|
||||
const raw = value.isError ?? value.is_error;
|
||||
return typeof raw === 'boolean' ? raw : undefined;
|
||||
}
|
||||
|
||||
const TOOL_NOT_FOUND_PATTERN = /^tool not found\.?$/i;
|
||||
const COMMAND_EXIT_CODE_PATTERN = /\(Command exited with code (-?\d+)\)\s*$/i;
|
||||
const MAX_ERROR_DETECT_CHARS = 20_000;
|
||||
const TOOL_ERROR_STATUSES = new Set(['error', 'failed', 'timeout']);
|
||||
|
||||
function hasToolErrorStatus(value: unknown): boolean {
|
||||
return typeof value === 'string' && TOOL_ERROR_STATUSES.has(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function isToolErrorOutput(outputText: string | undefined): boolean {
|
||||
if (!outputText) return false;
|
||||
const trimmed = outputText.trim();
|
||||
if (!trimmed) return false;
|
||||
if (TOOL_NOT_FOUND_PATTERN.test(trimmed)) return true;
|
||||
const commandExitCode = COMMAND_EXIT_CODE_PATTERN.exec(trimmed);
|
||||
if (commandExitCode) return Number(commandExitCode[1]) !== 0;
|
||||
if (trimmed.length > MAX_ERROR_DETECT_CHARS) return false;
|
||||
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return false;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
|
||||
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
const explicitErrorFlag = readToolErrorFlag(obj);
|
||||
if (explicitErrorFlag !== undefined) return explicitErrorFlag;
|
||||
if ('error' in obj) {
|
||||
const value = obj.error;
|
||||
if (typeof value === 'string') return value.trim().length > 0;
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (value && typeof value === 'object') return true;
|
||||
}
|
||||
return hasToolErrorStatus(obj.status);
|
||||
}
|
||||
|
||||
export function isToolCardError(card: ToolCard): boolean {
|
||||
if (card.isError === true) return true;
|
||||
return isToolErrorOutput(card.outputText);
|
||||
}
|
||||
|
||||
function serializeToolInput(args: unknown): string | undefined {
|
||||
if (args === undefined || args === null) return undefined;
|
||||
if (typeof args === 'string') return args;
|
||||
try {
|
||||
return JSON.stringify(args, null, 2);
|
||||
} catch {
|
||||
if (typeof args === 'number' || typeof args === 'boolean' || typeof args === 'bigint') {
|
||||
return String(args);
|
||||
}
|
||||
if (typeof args === 'symbol') {
|
||||
return args.description ? `Symbol(${args.description})` : 'Symbol()';
|
||||
}
|
||||
return Object.prototype.toString.call(args);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveToolCardId(
|
||||
item: Record<string, unknown>,
|
||||
message: Record<string, unknown>,
|
||||
index: number,
|
||||
prefix: string,
|
||||
): string {
|
||||
const explicitId =
|
||||
(typeof item.id === 'string' && item.id.trim())
|
||||
|| (typeof item.toolCallId === 'string' && item.toolCallId.trim())
|
||||
|| (typeof item.tool_call_id === 'string' && item.tool_call_id.trim())
|
||||
|| (typeof item.toolUseId === 'string' && item.toolUseId.trim())
|
||||
|| (typeof item.tool_use_id === 'string' && item.tool_use_id.trim())
|
||||
|| (typeof item.callId === 'string' && item.callId.trim())
|
||||
|| (typeof message.toolCallId === 'string' && message.toolCallId.trim())
|
||||
|| (typeof message.tool_call_id === 'string' && message.tool_call_id.trim())
|
||||
|| '';
|
||||
if (explicitId) return `${prefix}:${explicitId}`;
|
||||
|
||||
const name =
|
||||
(typeof item.name === 'string' && item.name.trim())
|
||||
|| (typeof message.toolName === 'string' && message.toolName.trim())
|
||||
|| (typeof message.tool_name === 'string' && message.tool_name.trim())
|
||||
|| 'tool';
|
||||
return `${prefix}:${name}:${index}`;
|
||||
}
|
||||
|
||||
function resolveToolName(item: Record<string, unknown>, message: Record<string, unknown>): string {
|
||||
return (
|
||||
(typeof item.name === 'string' && item.name.trim())
|
||||
|| (typeof message.toolName === 'string' && message.toolName.trim())
|
||||
|| (typeof message.tool_name === 'string' && message.tool_name.trim())
|
||||
|| 'tool'
|
||||
);
|
||||
}
|
||||
|
||||
function buildPreview(outputText: string | undefined): ToolCard['preview'] | undefined {
|
||||
const text = outputText?.trim();
|
||||
if (!text) return undefined;
|
||||
if (/^https?:\/\/\S+\.(png|jpe?g|gif|webp|svg)(\?\S*)?$/i.test(text)) {
|
||||
return { kind: 'image', url: text, label: 'Image' };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
return { kind: 'json', text: JSON.stringify(parsed, null, 2), label: 'JSON' };
|
||||
} catch {
|
||||
return { kind: 'text', text: text.slice(0, 4_000), label: 'Text' };
|
||||
}
|
||||
}
|
||||
|
||||
function nonBlankText(value: string | undefined): string | undefined {
|
||||
return value && value.trim() ? value : undefined;
|
||||
}
|
||||
|
||||
export function toolCardFromLiveEntry(entry: LiveToolEntry): ToolCard {
|
||||
const outputText = nonBlankText(entry.output) ?? entry.errorText;
|
||||
return {
|
||||
id: `live:${entry.id}`,
|
||||
toolName: entry.name,
|
||||
inputText: serializeToolInput(entry.args),
|
||||
outputText,
|
||||
...(entry.isError !== undefined ? { isError: entry.isError } : {}),
|
||||
preview: buildPreview(outputText),
|
||||
};
|
||||
}
|
||||
|
||||
function findFirstUnmatchedCard(
|
||||
cards: ToolCard[],
|
||||
id: string,
|
||||
toolName: string,
|
||||
fallbackMatchedCards: WeakSet<ToolCard>,
|
||||
): ToolCard | undefined {
|
||||
let nameOnlyCandidate: ToolCard | undefined;
|
||||
for (const card of cards) {
|
||||
if (card.id === id) return card;
|
||||
if (
|
||||
!nameOnlyCandidate
|
||||
&& card.toolName === toolName
|
||||
&& card.outputText === undefined
|
||||
&& !fallbackMatchedCards.has(card)
|
||||
) {
|
||||
nameOnlyCandidate = card;
|
||||
}
|
||||
}
|
||||
return nameOnlyCandidate;
|
||||
}
|
||||
|
||||
function normalizeRole(role: unknown): string {
|
||||
return typeof role === 'string' ? role.replace(/[_-]/g, '').toLowerCase() : '';
|
||||
}
|
||||
|
||||
function isStandaloneToolMessage(message: Record<string, unknown>): boolean {
|
||||
const role = normalizeRole(message.role);
|
||||
return (
|
||||
role === 'tool'
|
||||
|| role === 'toolresult'
|
||||
|| role === 'function'
|
||||
|| typeof message.toolName === 'string'
|
||||
|| typeof message.tool_name === 'string'
|
||||
|| typeof message.toolCallId === 'string'
|
||||
|| typeof message.tool_call_id === 'string'
|
||||
|| typeof message.toolUseId === 'string'
|
||||
|| typeof message.tool_use_id === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export function extractToolCards(message: unknown, prefix = 'tool'): ToolCard[] {
|
||||
if (!message || typeof message !== 'object') return [];
|
||||
const m = message as Record<string, unknown>;
|
||||
const content = normalizeContent(m.content);
|
||||
const messageIsError = readToolErrorFlag(m);
|
||||
const cards: ToolCard[] = [];
|
||||
const fallbackMatchedCards = new WeakSet<ToolCard>();
|
||||
const transcriptMessageId = resolveTranscriptMessageId(m);
|
||||
|
||||
for (let index = 0; index < content.length; index++) {
|
||||
const item = content[index] ?? {};
|
||||
const kind = typeof item.type === 'string' ? item.type.toLowerCase() : '';
|
||||
const isToolCall =
|
||||
['toolcall', 'tool_call', 'tooluse', 'tool_use'].includes(kind)
|
||||
|| (typeof item.name === 'string'
|
||||
&& (item.arguments != null || item.args != null || item.input != null));
|
||||
|
||||
if (isToolCall) {
|
||||
const args = coerceArgs(item.arguments ?? item.args ?? item.input);
|
||||
cards.push({
|
||||
id: resolveToolCardId(item, m, index, prefix),
|
||||
toolName: resolveToolName(item, m),
|
||||
inputText: serializeToolInput(args),
|
||||
transcriptMessageId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (kind === 'toolresult' || kind === 'tool_result') {
|
||||
const toolName = resolveToolName(item, m);
|
||||
const cardId = resolveToolCardId(item, m, index, prefix);
|
||||
const existing = findFirstUnmatchedCard(cards, cardId, toolName, fallbackMatchedCards);
|
||||
const outputText = extractToolText(item);
|
||||
const preview = buildPreview(outputText);
|
||||
const isError = readToolErrorFlag(item) ?? messageIsError;
|
||||
if (existing) {
|
||||
fallbackMatchedCards.add(existing);
|
||||
existing.outputText = outputText;
|
||||
existing.preview = preview;
|
||||
if (isError !== undefined) existing.isError = isError;
|
||||
continue;
|
||||
}
|
||||
cards.push({
|
||||
id: cardId,
|
||||
toolName,
|
||||
outputText,
|
||||
transcriptMessageId,
|
||||
...(isError !== undefined ? { isError } : {}),
|
||||
preview,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isStandaloneToolMessage(m) && cards.length === 0) {
|
||||
const toolName = resolveToolName({}, m);
|
||||
const outputText = extractText(message);
|
||||
cards.push({
|
||||
id: resolveToolCardId({}, m, 0, prefix),
|
||||
toolName,
|
||||
outputText,
|
||||
transcriptMessageId,
|
||||
...(messageIsError !== undefined ? { isError: messageIsError } : {}),
|
||||
preview: buildPreview(outputText),
|
||||
});
|
||||
}
|
||||
|
||||
return cards;
|
||||
}
|
||||
|
||||
const toolCardsByMessage = new WeakMap<object, Map<string, ToolCard[]>>();
|
||||
|
||||
export function extractToolCardsCached(message: unknown, prefix = 'tool'): ToolCard[] {
|
||||
if (!message || typeof message !== 'object') return extractToolCards(message, prefix);
|
||||
let byPrefix = toolCardsByMessage.get(message);
|
||||
if (!byPrefix) {
|
||||
byPrefix = new Map();
|
||||
toolCardsByMessage.set(message, byPrefix);
|
||||
}
|
||||
const cached = byPrefix.get(prefix);
|
||||
if (cached) return cached;
|
||||
const cards = extractToolCards(message, prefix);
|
||||
byPrefix.set(prefix, cards);
|
||||
return cards;
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import type { ToolCard } from './tool-cards';
|
||||
|
||||
export type ChatCoreClient = {
|
||||
request<T>(
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
timeoutMs?: number,
|
||||
): Promise<T>;
|
||||
};
|
||||
|
||||
export type RawOpenClawMessage = Record<string, unknown> & {
|
||||
id?: string;
|
||||
role?: string;
|
||||
content?: unknown;
|
||||
text?: string;
|
||||
timestamp?: number;
|
||||
};
|
||||
|
||||
export type OpenClawAgentEvent = Record<string, unknown> & {
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
runId?: string;
|
||||
seq?: number;
|
||||
stream?: string;
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ChatRunUiStatus = {
|
||||
phase: 'idle' | 'running' | 'done' | 'interrupted' | 'error';
|
||||
runId?: string;
|
||||
sessionKey?: string;
|
||||
message?: string;
|
||||
endedAt?: number;
|
||||
stopReason?: string;
|
||||
livenessState?: string;
|
||||
replayInvalid?: boolean;
|
||||
};
|
||||
|
||||
export type AssistantStreamPhase = 'commentary' | 'final_answer' | 'legacy';
|
||||
|
||||
export type LiveAssistantSegment = {
|
||||
id: string;
|
||||
runId: string;
|
||||
text: string;
|
||||
phase: AssistantStreamPhase;
|
||||
ts: number;
|
||||
order?: number;
|
||||
mediaUrls?: string[];
|
||||
};
|
||||
|
||||
export type LiveThinkingSegment = {
|
||||
id: string;
|
||||
runId: string;
|
||||
text: string;
|
||||
ts: number;
|
||||
order?: number;
|
||||
};
|
||||
|
||||
export type LiveToolEntry = {
|
||||
id: string;
|
||||
itemId?: string;
|
||||
toolId?: string;
|
||||
toolCallId?: string;
|
||||
callId?: string;
|
||||
runId: string;
|
||||
sessionKey?: string;
|
||||
name: string;
|
||||
title?: string;
|
||||
status?: string;
|
||||
args?: unknown;
|
||||
output?: string;
|
||||
isError?: boolean;
|
||||
errorText?: string;
|
||||
rawPayload?: Record<string, unknown>;
|
||||
identitySource?: 'explicit' | 'fallback';
|
||||
fingerprint?: string;
|
||||
commandOutputIds?: string[];
|
||||
patchSummaryIds?: string[];
|
||||
startedAt: number;
|
||||
updatedAt: number;
|
||||
order?: number;
|
||||
};
|
||||
|
||||
export type CommandOutputEntry = {
|
||||
id: string;
|
||||
runId: string;
|
||||
itemId?: string;
|
||||
toolCallId?: string;
|
||||
toolId?: string;
|
||||
toolItemId?: string;
|
||||
callId?: string;
|
||||
parentId?: string;
|
||||
parentItemId?: string;
|
||||
name?: string;
|
||||
title?: string;
|
||||
command?: string;
|
||||
output?: string;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
stdoutExcerpt?: string;
|
||||
stderrExcerpt?: string;
|
||||
status?: string;
|
||||
phase?: string;
|
||||
exitCode?: number;
|
||||
durationMs?: number;
|
||||
cwd?: string;
|
||||
rawPayload?: Record<string, unknown>;
|
||||
startedAt?: number;
|
||||
updatedAt?: number;
|
||||
endedAt?: number;
|
||||
ts: number;
|
||||
order?: number;
|
||||
};
|
||||
|
||||
export type PatchSummaryEntry = {
|
||||
id: string;
|
||||
runId: string;
|
||||
itemId?: string;
|
||||
toolCallId?: string;
|
||||
toolId?: string;
|
||||
toolItemId?: string;
|
||||
callId?: string;
|
||||
parentId?: string;
|
||||
parentItemId?: string;
|
||||
name?: string;
|
||||
title?: string;
|
||||
summary?: string;
|
||||
status?: string;
|
||||
filePaths?: string[];
|
||||
files?: string[];
|
||||
fileCount?: number;
|
||||
added?: number;
|
||||
modified?: number;
|
||||
deleted?: number;
|
||||
rawPayload?: Record<string, unknown>;
|
||||
ts: number;
|
||||
order?: number;
|
||||
};
|
||||
|
||||
export type ChatQueueItem = {
|
||||
id: string;
|
||||
sessionKey: string;
|
||||
message: string;
|
||||
idempotencyKey: string;
|
||||
createdAt?: number;
|
||||
historyMessageCountAtEnqueue?: number;
|
||||
attachments?: ChatQueueAttachment[];
|
||||
state: 'queued' | 'sending' | 'waiting-reconnect' | 'failed';
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type ChatQueueAttachment = {
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
fileSize: number;
|
||||
preview: string | null;
|
||||
filePath?: string;
|
||||
source?: 'user-upload' | 'tool-result' | 'message-ref' | 'gateway-media';
|
||||
gatewayUrl?: string;
|
||||
};
|
||||
|
||||
export type ApprovalDecision = 'allow-once' | 'allow-always' | 'deny';
|
||||
export type ApprovalStatus = 'pending' | 'unavailable' | 'approved' | 'denied' | 'failed';
|
||||
|
||||
export type ApprovalRequest = {
|
||||
id: string;
|
||||
kind: 'exec' | 'plugin' | 'unknown';
|
||||
status: ApprovalStatus;
|
||||
title: string;
|
||||
detail: string;
|
||||
approvalId?: string;
|
||||
approvalSlug?: string;
|
||||
itemId?: string;
|
||||
toolCallId?: string;
|
||||
message?: string;
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
expiresAtMs?: number;
|
||||
allowedDecisions?: ApprovalDecision[];
|
||||
};
|
||||
|
||||
export type CompactionStatus = {
|
||||
phase: 'active' | 'retrying' | 'complete' | 'error';
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type FallbackStatus = {
|
||||
phase: 'active' | 'cleared' | 'error';
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type RuntimeIndicatorStatus =
|
||||
| ({ kind: 'compaction' } & CompactionStatus)
|
||||
| ({ kind: 'fallback' } & FallbackStatus);
|
||||
|
||||
export type VisibleChatItem =
|
||||
| { kind: 'message'; id: string; message: RawOpenClawMessage }
|
||||
| {
|
||||
kind: 'stream';
|
||||
id: string;
|
||||
runId: string;
|
||||
text: string;
|
||||
phase: AssistantStreamPhase;
|
||||
mediaUrls?: string[];
|
||||
}
|
||||
| { kind: 'thinking'; id: string; runId: string; text: string }
|
||||
| { kind: 'tool'; id: string; runId: string; toolCallId?: string; tool: ToolCard; status: ChatRunUiStatus }
|
||||
| { kind: 'command'; id: string; command: CommandOutputEntry; status: ChatRunUiStatus }
|
||||
| { kind: 'patch'; id: string; patch: PatchSummaryEntry; status: ChatRunUiStatus }
|
||||
| { kind: 'queue'; id: string; item: ChatQueueItem }
|
||||
| { kind: 'runtime'; id: string; status: RuntimeIndicatorStatus }
|
||||
| { kind: 'approval'; id: string; approval: ApprovalRequest }
|
||||
| { kind: 'status'; id: string; status: ChatRunUiStatus };
|
||||
|
||||
export type ChatCoreState = {
|
||||
sessionKey: string;
|
||||
selectedAgentId?: string;
|
||||
currentSessionId?: string;
|
||||
history: {
|
||||
messages: RawOpenClawMessage[];
|
||||
loading: boolean;
|
||||
hasMore: boolean;
|
||||
requestVersion: number;
|
||||
};
|
||||
live: {
|
||||
runId: string | null;
|
||||
currentAssistant: LiveAssistantSegment | null;
|
||||
assistantSegments: LiveAssistantSegment[];
|
||||
currentThinking: LiveThinkingSegment | null;
|
||||
thinkingSegments: LiveThinkingSegment[];
|
||||
toolMessages: RawOpenClawMessage[];
|
||||
toolStreamById: Record<string, LiveToolEntry>;
|
||||
toolStreamOrder: string[];
|
||||
commandOutputs: CommandOutputEntry[];
|
||||
patchSummaries: PatchSummaryEntry[];
|
||||
};
|
||||
send: {
|
||||
sending: boolean;
|
||||
queue: ChatQueueItem[];
|
||||
activeRunId: string | null;
|
||||
canAbort: boolean;
|
||||
lastError: string | null;
|
||||
abortedRunIds: string[];
|
||||
};
|
||||
runtime: {
|
||||
runStatus: ChatRunUiStatus | null;
|
||||
compactionStatus: CompactionStatus | null;
|
||||
fallbackStatus: FallbackStatus | null;
|
||||
approvals: ApprovalRequest[];
|
||||
resolvedApprovalIds: string[];
|
||||
};
|
||||
};
|
||||
@@ -31,6 +31,7 @@ import { isGatewayRestarting } from '@/lib/gateway-status';
|
||||
import { rendererExtensionRegistry } from '@/extensions/registry';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { useChatStore } from '@/stores/chat';
|
||||
import { toSessionLabel } from '@/stores/chat/session-label-cleanup';
|
||||
import { useGatewayStore } from '@/stores/gateway';
|
||||
import { useAgentsStore } from '@/stores/agents';
|
||||
import { getSessionActivityMs, getSessionBucket, type SessionBucketKey } from './session-buckets';
|
||||
@@ -163,7 +164,7 @@ export function Sidebar() {
|
||||
const isOnChat = useLocation().pathname === '/';
|
||||
|
||||
const getSessionLabel = (key: string, displayName?: string, label?: string) =>
|
||||
sessionLabels[key] ?? label ?? displayName ?? key;
|
||||
toSessionLabel(sessionLabels[key] ?? label ?? displayName ?? key) || key;
|
||||
|
||||
const openControlUi = async (view?: 'dreams', label = 'OpenClaw Page') => {
|
||||
try {
|
||||
@@ -489,7 +490,8 @@ export function Sidebar() {
|
||||
}}
|
||||
onDoubleClick={() => handleStartRename(s.key, sessionLabel)}
|
||||
className={cn(
|
||||
'w-full text-left rounded-lg px-2.5 py-1.5 text-meta transition-colors pr-16',
|
||||
'w-full text-left rounded-lg px-2.5 py-1.5 pr-2.5 text-meta transition-colors',
|
||||
'group-hover:pr-16 group-focus-within:pr-16',
|
||||
'hover:bg-black/5 dark:hover:bg-white/5',
|
||||
isOnChat && currentSessionKey === s.key
|
||||
? 'bg-black/5 dark:bg-white/10 text-foreground font-medium'
|
||||
@@ -505,7 +507,7 @@ export function Sidebar() {
|
||||
</button>
|
||||
<div className={cn(
|
||||
'absolute right-1 flex items-center gap-0.5 transition-opacity',
|
||||
'opacity-0 group-hover:opacity-100',
|
||||
'pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100',
|
||||
)}>
|
||||
<button
|
||||
aria-label={t('common:sidebar.renameSession')}
|
||||
|
||||
@@ -83,6 +83,9 @@ export const hostEvents = {
|
||||
onGatewayChatMessage: (handler: HostEventHandler<'gateway', 'chatMessage'>) => (
|
||||
onGatewayEvent('chatMessage', handler)
|
||||
),
|
||||
onGatewayAgentEvent: (handler: HostEventHandler<'gateway', 'agentEvent'>) => (
|
||||
onGatewayEvent('agentEvent', handler)
|
||||
),
|
||||
onGatewayChannelStatus: (handler: HostEventHandler<'gateway', 'channelStatus'>) => (
|
||||
onGatewayEvent('channelStatus', handler)
|
||||
),
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Check, ShieldCheck, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ApprovalDecision, ApprovalRequest } from '@/chat-core/openclaw-port/types';
|
||||
|
||||
export function ApprovalCard({
|
||||
approval,
|
||||
onResolve,
|
||||
}: {
|
||||
approval: ApprovalRequest;
|
||||
onResolve?: (id: string, decision: ApprovalDecision) => void;
|
||||
}) {
|
||||
const { t } = useTranslation('chat');
|
||||
const canResolve = approval.status === 'pending' && onResolve;
|
||||
const allowedDecisions = approval.allowedDecisions ?? ['allow-once', 'allow-always', 'deny'];
|
||||
const title = approval.title || t('approval.title');
|
||||
const resolve = (decision: ApprovalDecision) => {
|
||||
onResolve?.(approval.id, decision);
|
||||
};
|
||||
|
||||
return (
|
||||
<article
|
||||
className="rounded-md border border-border bg-surface-input px-3 py-3"
|
||||
data-testid="chat-approval-card"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<ShieldCheck className="mt-0.5 h-4 w-4 text-amber-700 dark:text-amber-400" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="text-sm font-medium text-foreground">{title}</div>
|
||||
<span className="rounded-md bg-black/5 px-2 py-0.5 text-[11px] text-muted-foreground dark:bg-white/10">
|
||||
{t(`approval.status.${approval.status}`)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 whitespace-pre-wrap break-words rounded-md bg-background px-2 py-1.5 font-mono text-xs text-foreground">
|
||||
{approval.detail}
|
||||
</div>
|
||||
{approval.message ? (
|
||||
<div className="mt-2 text-xs text-muted-foreground">{approval.message}</div>
|
||||
) : null}
|
||||
{canResolve ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{allowedDecisions.includes('allow-once') ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border bg-surface-input px-2.5 text-xs text-foreground hover:bg-black/5 dark:hover:bg-white/10"
|
||||
onClick={() => resolve('allow-once')}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{t('approval.allowOnce')}
|
||||
</button>
|
||||
) : null}
|
||||
{allowedDecisions.includes('allow-always') ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border bg-surface-input px-2.5 text-xs text-foreground hover:bg-black/5 dark:hover:bg-white/10"
|
||||
onClick={() => resolve('allow-always')}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{t('approval.allowAlways')}
|
||||
</button>
|
||||
) : null}
|
||||
{allowedDecisions.includes('deny') ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border bg-surface-input px-2.5 text-xs text-foreground hover:bg-black/5 dark:hover:bg-white/10"
|
||||
onClick={() => resolve('deny')}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
{t('approval.deny')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { SendHorizontal, Square } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type ComposerSkill = {
|
||||
name: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
type ChatComposerProps = {
|
||||
disabled: boolean;
|
||||
sending: boolean;
|
||||
skills?: ComposerSkill[];
|
||||
onSend: (text: string) => void;
|
||||
onStop: () => void;
|
||||
};
|
||||
|
||||
const BASE_COMMANDS = [
|
||||
'/help',
|
||||
'/new',
|
||||
'/reset',
|
||||
'/clear',
|
||||
'/compact',
|
||||
'/model',
|
||||
'/think',
|
||||
'/verbose',
|
||||
'/agents',
|
||||
];
|
||||
|
||||
export function ChatComposer({
|
||||
disabled,
|
||||
sending,
|
||||
skills = [],
|
||||
onSend,
|
||||
onStop,
|
||||
}: ChatComposerProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const [text, setText] = useState('');
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const slashOpen = text.trimStart().startsWith('/');
|
||||
const slashItems = useMemo(() => {
|
||||
const skillItems = skills.map((skill) => `/skill ${skill.name}`);
|
||||
return [...BASE_COMMANDS, ...skillItems];
|
||||
}, [skills]);
|
||||
|
||||
const submit = () => {
|
||||
const value = text.trim();
|
||||
if (!value || disabled) return;
|
||||
onSend(value);
|
||||
setText('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-border bg-surface-input p-3">
|
||||
<div className="relative mx-auto flex max-w-4xl items-end gap-2">
|
||||
{slashOpen ? (
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label={t('composer.slashCommands')}
|
||||
className="absolute bottom-full left-0 mb-2 max-h-64 w-full overflow-auto rounded-md border border-border bg-surface-modal shadow-lg"
|
||||
>
|
||||
<div className="px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
{t('composer.slashSkillsHeading')}
|
||||
</div>
|
||||
{slashItems.map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
role="option"
|
||||
className="block w-full px-3 py-2 text-left text-sm hover:bg-black/5 dark:hover:bg-white/10"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
setText(`${item} `);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
data-testid="chat-composer-input"
|
||||
value={text}
|
||||
disabled={disabled}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
className="min-h-11 flex-1 resize-none rounded-md border border-border bg-surface-input px-3 py-2 text-sm text-foreground"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="chat-composer-send"
|
||||
aria-label={sending ? t('composer.stop') : t('composer.send')}
|
||||
className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-md border border-border bg-surface-input text-foreground hover:bg-black/5 dark:hover:bg-white/10"
|
||||
onClick={sending ? onStop : submit}
|
||||
>
|
||||
{sending ? <Square className="h-4 w-4" /> : <SendHorizontal className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+269
-21
@@ -46,11 +46,13 @@ interface ChatInputProps {
|
||||
onStop?: () => void;
|
||||
disabled?: boolean;
|
||||
sending?: boolean;
|
||||
draftScopeKey?: string;
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
const DIRECTORY_MIME_TYPE = 'application/x-directory';
|
||||
const STOP_ARM_DELAY_MS = 450;
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
@@ -95,6 +97,13 @@ function removeSkillToken(value: string, skillName: string): string {
|
||||
const SKILL_TOKEN_BUTTON_CLASS =
|
||||
'rounded-md bg-skill-bg/14 text-skill-fg [-webkit-box-decoration-break:clone] [box-decoration-break:clone] [text-shadow:0_0_10px_rgba(47,107,255,0.38)] dark:bg-skill-bg/18 dark:text-skill-fg-dark dark:[text-shadow:0_0_12px_rgba(37,99,235,0.42)]';
|
||||
|
||||
type SlashMenuItem =
|
||||
{ kind: 'skill'; id: string; skill: QuickAccessSkill };
|
||||
|
||||
function slashMenuItemElementId(item: SlashMenuItem): string {
|
||||
return `chat-slash-option-${item.id.replace(/[^A-Za-z0-9_-]/g, '-')}`;
|
||||
}
|
||||
|
||||
function renderHighlightedComposerText(
|
||||
value: string,
|
||||
tokenRanges: SkillTokenRange[],
|
||||
@@ -133,11 +142,13 @@ function renderHighlightedComposerText(
|
||||
onMouseDown={(event) => {
|
||||
// Keep focus in the textarea while still receiving the click.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void options.onPreviewSkill(skillName);
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
options.onPreviewSkill(skillName);
|
||||
if (event.detail === 0) options.onPreviewSkill(skillName);
|
||||
}}
|
||||
>
|
||||
{tokenLabel}
|
||||
@@ -191,7 +202,7 @@ function readFileAsBase64(file: globalThis.File): Promise<string> {
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────
|
||||
|
||||
export function ChatInput({ onSend, onStop, disabled = false, sending = false }: ChatInputProps) {
|
||||
export function ChatInput({ onSend, onStop, disabled = false, sending = false, draftScopeKey }: ChatInputProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const [input, setInput] = useState('');
|
||||
const [attachments, setAttachments] = useState<FileAttachment[]>([]);
|
||||
@@ -206,11 +217,16 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false }:
|
||||
const [selectedSkill, setSelectedSkill] = useState<QuickAccessSkill | null>(null);
|
||||
const [switchingModelRef, setSwitchingModelRef] = useState<string | null>(null);
|
||||
const [optimisticModelRef, setOptimisticModelRef] = useState<string | null>(null);
|
||||
const [stopArmed, setStopArmed] = useState(false);
|
||||
const [slashActiveIndex, setSlashActiveIndex] = useState(0);
|
||||
const [dismissedSlashInput, setDismissedSlashInput] = useState<string | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const pickerRef = useRef<HTMLDivElement>(null);
|
||||
const skillPickerRef = useRef<HTMLDivElement>(null);
|
||||
const modelPickerRef = useRef<HTMLDivElement>(null);
|
||||
const slashItemRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
const isComposingRef = useRef(false);
|
||||
const draftScopeKeyRef = useRef(draftScopeKey);
|
||||
const gatewayStatus = useGatewayStore((s) => s.status);
|
||||
const agents = useAgentsStore((s) => s.agents);
|
||||
const updateAgentModel = useAgentsStore((s) => s.updateAgentModel);
|
||||
@@ -270,6 +286,15 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false }:
|
||||
const isGatewayUsable = gatewayStatus.state === 'running' && gatewayStatus.gatewayReady !== false;
|
||||
const inputDisabled = disabled || !isGatewayUsable;
|
||||
const skillTokenRanges = useMemo(() => findSkillTokenRanges(input), [input]);
|
||||
const slashCommandQuery = useMemo(() => {
|
||||
const trimmedStart = input.trimStart();
|
||||
if (!trimmedStart.startsWith('/')) return null;
|
||||
if (trimmedStart.includes('\n')) return null;
|
||||
if (skillTokenRanges.length > 0) return null;
|
||||
const query = trimmedStart.slice(1);
|
||||
if (/\s/.test(query)) return null;
|
||||
return query.toLowerCase();
|
||||
}, [input, skillTokenRanges.length]);
|
||||
const openArtifactPreview = useArtifactPanel((s) => s.openPreview);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -296,8 +321,36 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false }:
|
||||
setOptimisticModelRef(null);
|
||||
}, [currentAgent?.modelRef, currentAgentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sending) {
|
||||
setStopArmed(false);
|
||||
return;
|
||||
}
|
||||
setStopArmed(false);
|
||||
const timer = window.setTimeout(() => setStopArmed(true), STOP_ARM_DELAY_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [sending]);
|
||||
|
||||
useEffect(() => {
|
||||
if (draftScopeKeyRef.current === draftScopeKey) return;
|
||||
draftScopeKeyRef.current = draftScopeKey;
|
||||
setInput('');
|
||||
setAttachments([]);
|
||||
setTargetAgentId(null);
|
||||
setPickerOpen(false);
|
||||
setSkillPickerOpen(false);
|
||||
setModelPickerOpen(false);
|
||||
setSkillQuery('');
|
||||
setQuickSkills([]);
|
||||
setSkillsError(null);
|
||||
setSelectedSkill(null);
|
||||
setDismissedSlashInput(null);
|
||||
setSlashActiveIndex(0);
|
||||
}, [draftScopeKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentAgent || switchingModelRef || optimisticModelRef) return;
|
||||
if (modelOptions.length === 0) return;
|
||||
const override = (currentAgent.overrideModelRef || '').trim();
|
||||
if (!override || isConfiguredModelRefAvailable(override, modelOptions)) return;
|
||||
void updateAgentModel(currentAgent.id, null).catch(() => {});
|
||||
@@ -361,6 +414,8 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false }:
|
||||
setSkillQuery('');
|
||||
setQuickSkills([]);
|
||||
setSkillsError(null);
|
||||
setDismissedSlashInput(null);
|
||||
setSlashActiveIndex(0);
|
||||
}, [currentAgentId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -373,6 +428,7 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false }:
|
||||
|
||||
const handleInputChange = useCallback((value: string) => {
|
||||
setInput(value);
|
||||
setDismissedSlashInput(null);
|
||||
}, []);
|
||||
|
||||
const moveCaretTo = useCallback((position: number) => {
|
||||
@@ -425,6 +481,56 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false }:
|
||||
}
|
||||
}, [currentAgent]);
|
||||
|
||||
const slashSkillItems = useMemo(() => {
|
||||
if (slashCommandQuery == null) return [];
|
||||
const query = slashCommandQuery.trim();
|
||||
return quickSkills
|
||||
.filter((skill) => {
|
||||
if (!query) return true;
|
||||
return skill.name.toLowerCase().includes(query)
|
||||
|| `skill ${skill.name}`.toLowerCase().includes(query)
|
||||
|| skill.description.toLowerCase().includes(query);
|
||||
})
|
||||
.slice(0, 8);
|
||||
}, [quickSkills, slashCommandQuery]);
|
||||
const showSlashSkillsHeading =
|
||||
slashCommandQuery != null
|
||||
&& (!slashCommandQuery || slashSkillItems.length > 0 || 'skills'.includes(slashCommandQuery));
|
||||
const showSlashMenu = slashCommandQuery != null
|
||||
&& dismissedSlashInput !== input
|
||||
&& !inputDisabled
|
||||
&& !sending;
|
||||
const slashMenuItems = useMemo<SlashMenuItem[]>(() => {
|
||||
if (!showSlashMenu) return [];
|
||||
const items: SlashMenuItem[] = [];
|
||||
for (const skill of slashSkillItems) {
|
||||
items.push({
|
||||
kind: 'skill',
|
||||
id: `skill:${skill.source}:${skill.name}`,
|
||||
skill,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [showSlashMenu, slashSkillItems]);
|
||||
const activeSlashItem = slashMenuItems[slashActiveIndex];
|
||||
|
||||
const insertSkillToken = useCallback((skill: QuickAccessSkill) => {
|
||||
const nextValue = getSkillPrefix(skill.name);
|
||||
setSelectedSkill(null);
|
||||
setInput(nextValue);
|
||||
setDismissedSlashInput(null);
|
||||
setSkillPickerOpen(false);
|
||||
setSkillQuery('');
|
||||
requestAnimationFrame(() => {
|
||||
textareaRef.current?.focus();
|
||||
textareaRef.current?.setSelectionRange(nextValue.length, nextValue.length);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectSlashMenuItem = useCallback((item: SlashMenuItem) => {
|
||||
insertSkillToken(item.skill);
|
||||
}, [insertSkillToken]);
|
||||
|
||||
const handleSkillTokenPreview = useCallback(async (skillName: string) => {
|
||||
let list = quickSkills;
|
||||
if (list.length === 0 && currentAgent) {
|
||||
@@ -445,6 +551,30 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false }:
|
||||
void loadQuickSkills();
|
||||
}, [skillPickerOpen, loadQuickSkills]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSlashMenu) return;
|
||||
if (quickSkills.length > 0 || skillsLoading) return;
|
||||
void loadQuickSkills();
|
||||
}, [loadQuickSkills, quickSkills.length, showSlashMenu, skillsLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
setSlashActiveIndex(0);
|
||||
}, [slashCommandQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSlashMenu || slashMenuItems.length === 0) {
|
||||
setSlashActiveIndex(0);
|
||||
return;
|
||||
}
|
||||
setSlashActiveIndex((current) => Math.min(current, slashMenuItems.length - 1));
|
||||
}, [showSlashMenu, slashMenuItems.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSlashMenu || !activeSlashItem) return;
|
||||
const activeElement = slashItemRefs.current[activeSlashItem.id];
|
||||
activeElement?.scrollIntoView?.({ block: 'nearest' });
|
||||
}, [activeSlashItem, showSlashMenu]);
|
||||
|
||||
const handleSelectModel = useCallback(async (modelRef: string) => {
|
||||
if (!currentAgent || switchingModelRef) return;
|
||||
if (modelRef === effectiveModelRef) {
|
||||
@@ -491,9 +621,7 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false }:
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[stagePathFiles] Staging files:', filePaths);
|
||||
const staged = await hostApi.files.stagePaths({ filePaths });
|
||||
console.log('[stagePathFiles] Stage result:', staged?.map(s => ({ id: s?.id, fileName: s?.fileName, mimeType: s?.mimeType, fileSize: s?.fileSize, stagedPath: s?.stagedPath, hasPreview: !!s?.preview })));
|
||||
|
||||
setAttachments(prev => {
|
||||
let updated = [...prev];
|
||||
@@ -555,15 +683,12 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false }:
|
||||
}]);
|
||||
|
||||
try {
|
||||
console.log(`[stageBuffer] Reading file: ${file.name} (${file.type}, ${file.size} bytes)`);
|
||||
const base64 = await readFileAsBase64(file);
|
||||
console.log(`[stageBuffer] Base64 length: ${base64?.length ?? 'null'}`);
|
||||
const staged = await hostApi.files.stageBuffer({
|
||||
base64,
|
||||
fileName: file.name,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
});
|
||||
console.log(`[stageBuffer] Staged: id=${staged?.id}, path=${staged?.stagedPath}, size=${staged?.fileSize}`);
|
||||
setAttachments(prev => prev.map(a =>
|
||||
a.id === tempId ? { ...staged, status: 'ready' as const } : a,
|
||||
));
|
||||
@@ -587,7 +712,7 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false }:
|
||||
const allReady = attachments.length === 0 || attachments.every(a => a.status === 'ready');
|
||||
const hasFailedAttachments = attachments.some((a) => a.status === 'error');
|
||||
const canSend = (input.trim() || attachments.length > 0) && allReady && !inputDisabled && !sending;
|
||||
const canStop = sending && !inputDisabled && !!onStop;
|
||||
const canStop = sending && stopArmed && !inputDisabled && !!onStop;
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!canSend) return;
|
||||
@@ -611,17 +736,11 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false }:
|
||||
|
||||
// Capture values before clearing — clear input immediately for snappy UX,
|
||||
// but keep attachments available for the async send
|
||||
console.log(`[handleSend] text="${textToSend.substring(0, 50)}", attachments=${attachments.length}, ready=${readyAttachments.length}, sending=${!!attachmentsToSend}`);
|
||||
if (attachmentsToSend) {
|
||||
console.log('[handleSend] Attachment details:', attachmentsToSend.map(a => ({
|
||||
id: a.id, fileName: a.fileName, mimeType: a.mimeType, fileSize: a.fileSize,
|
||||
stagedPath: a.stagedPath, status: a.status, hasPreview: !!a.preview,
|
||||
})));
|
||||
}
|
||||
setInput('');
|
||||
setAttachments([]);
|
||||
setSelectedSkill(null);
|
||||
setSkillQuery('');
|
||||
setDismissedSlashInput(null);
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
}
|
||||
@@ -638,6 +757,43 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false }:
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (showSlashMenu) {
|
||||
const nativeEvent = e.nativeEvent as KeyboardEvent;
|
||||
const isComposing = isComposingRef.current || nativeEvent.isComposing || nativeEvent.keyCode === 229;
|
||||
if (e.key === 'ArrowDown' && slashMenuItems.length > 0) {
|
||||
e.preventDefault();
|
||||
setSlashActiveIndex((current) => (current + 1) % slashMenuItems.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp' && slashMenuItems.length > 0) {
|
||||
e.preventDefault();
|
||||
setSlashActiveIndex((current) => (current - 1 + slashMenuItems.length) % slashMenuItems.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Home' && slashMenuItems.length > 0) {
|
||||
e.preventDefault();
|
||||
setSlashActiveIndex(0);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'End' && slashMenuItems.length > 0) {
|
||||
e.preventDefault();
|
||||
setSlashActiveIndex(slashMenuItems.length - 1);
|
||||
return;
|
||||
}
|
||||
if ((e.key === 'Enter' && !e.shiftKey) || e.key === 'Tab') {
|
||||
if (isComposing) return;
|
||||
e.preventDefault();
|
||||
if (activeSlashItem) {
|
||||
selectSlashMenuItem(activeSlashItem);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setDismissedSlashInput(input);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === 'Backspace') {
|
||||
const textarea = textareaRef.current;
|
||||
const selectionStart = textarea?.selectionStart ?? 0;
|
||||
@@ -704,7 +860,17 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false }:
|
||||
handleSend();
|
||||
}
|
||||
},
|
||||
[handleSend, input, moveCaretTo, selectedSkill, skillTokenRanges],
|
||||
[
|
||||
activeSlashItem,
|
||||
handleSend,
|
||||
input,
|
||||
moveCaretTo,
|
||||
selectSlashMenuItem,
|
||||
selectedSkill,
|
||||
showSlashMenu,
|
||||
slashMenuItems.length,
|
||||
skillTokenRanges,
|
||||
],
|
||||
);
|
||||
|
||||
// Handle paste (Ctrl/Cmd+V with files)
|
||||
@@ -802,6 +968,79 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false }:
|
||||
|
||||
{/* Text Row — flush-left */}
|
||||
<div className="relative min-h-[48px]">
|
||||
{showSlashMenu && (
|
||||
<div
|
||||
id="chat-slash-menu"
|
||||
role="listbox"
|
||||
aria-label={t('composer.slashCommands')}
|
||||
data-testid="chat-slash-menu"
|
||||
className="absolute bottom-full left-0 z-30 mb-2 max-h-72 w-full overflow-hidden rounded-2xl border border-black/10 bg-surface-modal p-1.5 shadow-xl dark:border-white/10"
|
||||
>
|
||||
{showSlashSkillsHeading && (
|
||||
<div
|
||||
data-testid="chat-slash-skills-heading"
|
||||
className="px-3 py-1.5 text-tiny font-medium text-muted-foreground"
|
||||
>
|
||||
{t('composer.slashSkillsHeading')}
|
||||
</div>
|
||||
)}
|
||||
{skillsLoading && slashSkillItems.length === 0 ? (
|
||||
<div className="px-3 py-3 text-xs text-muted-foreground">
|
||||
{t('composer.skillLoading')}
|
||||
</div>
|
||||
) : null}
|
||||
{!skillsLoading && slashSkillItems.length === 0 ? (
|
||||
<div className="px-3 py-3 text-xs text-muted-foreground">
|
||||
{t('composer.skillEmpty')}
|
||||
</div>
|
||||
) : null}
|
||||
{slashSkillItems.length > 0 && (
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
{slashSkillItems.map((skill) => {
|
||||
const itemId = `skill:${skill.source}:${skill.name}`;
|
||||
const item = slashMenuItems.find((entry) => entry.id === itemId);
|
||||
const selected = activeSlashItem?.id === itemId;
|
||||
return (
|
||||
<button
|
||||
key={`${skill.source}:${skill.name}`}
|
||||
id={item ? slashMenuItemElementId(item) : undefined}
|
||||
ref={(node) => {
|
||||
slashItemRefs.current[itemId] = node;
|
||||
}}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
data-testid={`chat-slash-skill-${skill.name}`}
|
||||
className={cn(
|
||||
'flex w-full min-w-0 items-center rounded-xl px-3 py-2 text-left text-sm text-foreground',
|
||||
selected
|
||||
? 'bg-black/5 dark:bg-white/10'
|
||||
: 'hover:bg-black/5 dark:hover:bg-white/10',
|
||||
)}
|
||||
onMouseEnter={() => {
|
||||
const index = slashMenuItems.findIndex((entry) => entry.id === itemId);
|
||||
if (index >= 0) setSlashActiveIndex(index);
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
insertSkillToken(skill);
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
if (event.detail === 0) insertSkillToken(skill);
|
||||
}}
|
||||
>
|
||||
<span className="shrink-0 font-medium">/{skill.name}</span>
|
||||
{skill.description ? (
|
||||
<span className="ml-2 min-w-0 truncate whitespace-nowrap text-xs text-muted-foreground">{skill.description}</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{skillTokenRanges.length > 0 && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
@@ -832,6 +1071,12 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false }:
|
||||
placeholder={inputDisabled ? t('composer.gatewayDisconnectedPlaceholder') : ''}
|
||||
disabled={inputDisabled}
|
||||
data-testid="chat-composer-input"
|
||||
aria-controls={showSlashMenu ? 'chat-slash-menu' : undefined}
|
||||
aria-activedescendant={
|
||||
showSlashMenu && activeSlashItem ? slashMenuItemElementId(activeSlashItem) : undefined
|
||||
}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={showSlashMenu}
|
||||
className={cn(
|
||||
'relative min-h-[48px] max-h-[240px] resize-none border-0 focus-visible:ring-0 focus-visible:ring-offset-0 shadow-none bg-transparent p-0 text-sm leading-relaxed placeholder:text-muted-foreground/60',
|
||||
skillTokenRanges.length > 0 ? 'z-0 text-transparent caret-foreground selection:bg-primary/20' : 'z-10',
|
||||
@@ -952,8 +1197,11 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false }:
|
||||
onSelect={() => {
|
||||
const textarea = textareaRef.current;
|
||||
const nextToken = getSkillPrefix(skill.name);
|
||||
const selectionStart = textarea?.selectionStart ?? input.length;
|
||||
const selectionEnd = textarea?.selectionEnd ?? input.length;
|
||||
const isSlashOnlyDraft = slashCommandQuery != null
|
||||
&& input.trimStart().startsWith('/')
|
||||
&& input.trim().length === slashCommandQuery.length + 1;
|
||||
const selectionStart = isSlashOnlyDraft ? 0 : textarea?.selectionStart ?? input.length;
|
||||
const selectionEnd = isSlashOnlyDraft ? input.length : textarea?.selectionEnd ?? input.length;
|
||||
let nextValue = input;
|
||||
let adjustedStart = selectionStart;
|
||||
let adjustedEnd = selectionEnd;
|
||||
@@ -1064,12 +1312,12 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false }:
|
||||
<span>
|
||||
{t('composer.gatewayStatus', {
|
||||
state: isGatewayUsable
|
||||
? t('composer.gatewayConnected')
|
||||
? t('composer.gatewayConnectedState')
|
||||
: gatewayStatus.state === 'running'
|
||||
? 'starting'
|
||||
? t('composer.gatewayStartingState')
|
||||
: gatewayStatus.state,
|
||||
port: gatewayStatus.port,
|
||||
pid: gatewayStatus.pid ? `| pid: ${gatewayStatus.pid}` : '',
|
||||
pid: gatewayStatus.pid ? t('composer.gatewayPid', { pid: gatewayStatus.pid }) : '',
|
||||
})}
|
||||
</span>
|
||||
{chatComposerStatusComponents.map((Component, index) => (
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* surfaced via ExecutionGraphCard, not inside message bubbles.
|
||||
*/
|
||||
import { useState, useCallback, useEffect, memo } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Sparkles, Copy, Check, Wrench, FileText, Film, Music, FileArchive, File, X, FolderOpen, ZoomIn, Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
@@ -43,6 +44,11 @@ interface ChatMessageProps {
|
||||
durationMs?: number;
|
||||
summary?: string;
|
||||
}>;
|
||||
assistantBeforeContent?: ReactNode;
|
||||
assistantAfterContent?: ReactNode;
|
||||
hideAssistantAvatar?: boolean;
|
||||
assistantCopyText?: string;
|
||||
suppressAssistantActions?: boolean;
|
||||
/**
|
||||
* Optional callback invoked when a non-image file card is clicked.
|
||||
* When provided, the file opens in the in-app preview panel instead of
|
||||
@@ -238,6 +244,11 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
suppressAssistantText = false,
|
||||
isStreaming = false,
|
||||
streamingTools = [],
|
||||
assistantBeforeContent,
|
||||
assistantAfterContent,
|
||||
hideAssistantAvatar = false,
|
||||
assistantCopyText,
|
||||
suppressAssistantActions = false,
|
||||
onOpenFile,
|
||||
}: ChatMessageProps) {
|
||||
const isUser = message.role === 'user';
|
||||
@@ -329,14 +340,25 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
? processVisibleAttachments
|
||||
: existingDerivedAttachedFiles;
|
||||
const imageCopyTarget = resolvePrimaryImageCopyTarget(resolvableContentImages, attachedFiles);
|
||||
const showAssistantHoverBar = !isUser && (hasText || imageCopyTarget != null);
|
||||
const copyText = assistantCopyText ?? text;
|
||||
const showAssistantHoverBar = !isUser
|
||||
&& !suppressAssistantActions
|
||||
&& (copyText.trim().length > 0 || imageCopyTarget != null);
|
||||
const hasAssistantInjectedContent = !isUser && (assistantBeforeContent != null || assistantAfterContent != null);
|
||||
const [lightboxImg, setLightboxImg] = useState<{ src: string; fileName: string; filePath?: string; base64?: string; mimeType?: string } | null>(null);
|
||||
|
||||
// Never render tool result messages in chat UI
|
||||
if (isToolResult) return null;
|
||||
|
||||
const hasStreamingToolStatus = isStreaming && streamingTools.length > 0;
|
||||
if (!hasText && resolvableContentImages.length === 0 && visibleTools.length === 0 && attachedFiles.length === 0 && !hasStreamingToolStatus) return null;
|
||||
if (
|
||||
!hasText
|
||||
&& resolvableContentImages.length === 0
|
||||
&& visibleTools.length === 0
|
||||
&& attachedFiles.length === 0
|
||||
&& !hasStreamingToolStatus
|
||||
&& !hasAssistantInjectedContent
|
||||
) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -351,8 +373,8 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
avatar inside is centered within that slot and intentionally
|
||||
overflows ±4px above/below the line, which mirrors how chat avatars
|
||||
sit alongside a single line of text. */}
|
||||
{!isUser && (
|
||||
<div className="flex h-6 shrink-0 items-center">
|
||||
{!isUser && !hideAssistantAvatar && (
|
||||
<div className="flex h-6 shrink-0 items-center" data-testid="chat-assistant-avatar">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-black/5 dark:bg-white/5 text-foreground">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</div>
|
||||
@@ -362,7 +384,8 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
{/* Content */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col w-full min-w-0 max-w-[80%] space-y-2',
|
||||
'flex flex-col w-full min-w-0 space-y-2',
|
||||
hideAssistantAvatar ? 'max-w-full' : 'max-w-[80%]',
|
||||
isUser ? 'items-end' : 'items-start',
|
||||
)}
|
||||
>
|
||||
@@ -370,6 +393,8 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
<ToolStatusBar tools={streamingTools} />
|
||||
)}
|
||||
|
||||
{!isUser && assistantBeforeContent}
|
||||
|
||||
{/* Images — rendered ABOVE text bubble for user messages */}
|
||||
{/* Images from content blocks (Gateway session data / channel push photos) */}
|
||||
{isUser && resolvableContentImages.length > 0 && (
|
||||
@@ -473,6 +498,8 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isUser && assistantAfterContent}
|
||||
|
||||
{/* Hover row for user messages — timestamp only */}
|
||||
{isUser && message.timestamp && (
|
||||
<span className="text-xs text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity duration-200 select-none">
|
||||
@@ -482,7 +509,7 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
|
||||
{/* Hover row for assistant messages */}
|
||||
{showAssistantHoverBar && (
|
||||
<AssistantHoverBar text={text} timestamp={message.timestamp} imageCopyTarget={imageCopyTarget} />
|
||||
<AssistantHoverBar text={copyText} timestamp={message.timestamp} imageCopyTarget={imageCopyTarget} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -616,6 +643,7 @@ function AssistantHoverBar({
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={copyContent}
|
||||
data-testid="chat-assistant-copy"
|
||||
>
|
||||
{copied ? <Check className="h-3 w-3 text-green-500" /> : <Copy className="h-3 w-3" />}
|
||||
</Button>
|
||||
@@ -631,7 +659,10 @@ function UserMessageBubble({
|
||||
text: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative rounded-2xl px-4 py-3 bg-brand text-white shadow-sm">
|
||||
<div
|
||||
data-testid="chat-user-message-bubble"
|
||||
className="relative rounded-2xl bg-primary px-4 py-3 text-primary-foreground shadow-sm"
|
||||
>
|
||||
<p className="whitespace-pre-wrap break-words text-sm">{text}</p>
|
||||
</div>
|
||||
);
|
||||
@@ -639,7 +670,7 @@ function UserMessageBubble({
|
||||
|
||||
// ── Assistant Markdown ──────────────────────────────────────────
|
||||
|
||||
function AssistantMarkdown({
|
||||
export function AssistantMarkdown({
|
||||
text,
|
||||
isStreaming,
|
||||
}: {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ApprovalDecision, VisibleChatItem } from '@/chat-core/openclaw-port/types';
|
||||
import { mergeAdjacentToolResultMessages } from '@/chat-core/openclaw-port/message-normalization';
|
||||
import type { GeneratedFile } from '@/lib/generated-files';
|
||||
import type { AttachedFileMeta } from '@/stores/chat';
|
||||
import { MessageList } from './MessageList';
|
||||
|
||||
function messageItemId(message: Record<string, unknown>, fallback: string): string {
|
||||
return typeof message.id === 'string' && message.id.trim() ? message.id : fallback;
|
||||
}
|
||||
|
||||
function normalizeVisibleItems(items: VisibleChatItem[]): VisibleChatItem[] {
|
||||
const normalized: VisibleChatItem[] = [];
|
||||
let pendingMessages: Array<Extract<VisibleChatItem, { kind: 'message' }>> = [];
|
||||
|
||||
const flushMessages = () => {
|
||||
if (pendingMessages.length === 0) return;
|
||||
const mergedMessages = mergeAdjacentToolResultMessages(
|
||||
pendingMessages.map((item) => item.message),
|
||||
);
|
||||
for (let index = 0; index < mergedMessages.length; index++) {
|
||||
const message = mergedMessages[index];
|
||||
normalized.push({
|
||||
kind: 'message',
|
||||
id: messageItemId(message, pendingMessages[index]?.id ?? `message-${normalized.length}`),
|
||||
message,
|
||||
});
|
||||
}
|
||||
pendingMessages = [];
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
if (item.kind === 'message') {
|
||||
pendingMessages.push(item);
|
||||
continue;
|
||||
}
|
||||
flushMessages();
|
||||
normalized.push(item);
|
||||
}
|
||||
|
||||
flushMessages();
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function ChatSurface({
|
||||
items,
|
||||
generatedFilesByMessageId,
|
||||
questionDirectory,
|
||||
onOpenFile,
|
||||
onOpenGeneratedFile,
|
||||
onResolveApproval,
|
||||
}: {
|
||||
items: VisibleChatItem[];
|
||||
generatedFilesByMessageId?: Record<string, GeneratedFile[]>;
|
||||
questionDirectory?: ReactNode;
|
||||
onOpenFile?: (file: AttachedFileMeta) => void;
|
||||
onOpenGeneratedFile?: (file: GeneratedFile) => void;
|
||||
onResolveApproval?: (id: string, decision: ApprovalDecision) => void;
|
||||
}) {
|
||||
const normalizedItems = useMemo(() => normalizeVisibleItems(items), [items]);
|
||||
const isEmptySession = normalizedItems.length === 0;
|
||||
return (
|
||||
<section
|
||||
className="flex min-h-0 flex-1 flex-col bg-background lg:flex-row"
|
||||
data-testid="openclaw-chat-surface"
|
||||
>
|
||||
<div className="order-2 flex min-h-0 min-w-0 flex-1 flex-col lg:order-1">
|
||||
{isEmptySession ? (
|
||||
<ChatWelcome />
|
||||
) : (
|
||||
<MessageList
|
||||
items={normalizedItems}
|
||||
generatedFilesByMessageId={generatedFilesByMessageId}
|
||||
onOpenFile={onOpenFile}
|
||||
onOpenGeneratedFile={onOpenGeneratedFile}
|
||||
onResolveApproval={onResolveApproval}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{questionDirectory}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatWelcome() {
|
||||
const { t } = useTranslation('chat');
|
||||
const features = [
|
||||
{
|
||||
title: t('welcome.askQuestions'),
|
||||
description: t('welcome.askQuestionsDesc'),
|
||||
},
|
||||
{
|
||||
title: t('welcome.creativeTasks'),
|
||||
description: t('welcome.creativeTasksDesc'),
|
||||
},
|
||||
{
|
||||
title: t('welcome.brainstorming'),
|
||||
description: '',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="chat-welcome"
|
||||
className="flex min-h-0 flex-1 items-center justify-center px-6 py-10"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-2xl text-center">
|
||||
<h2 className="mt-3 font-serif text-3xl font-normal tracking-tight text-foreground">
|
||||
{t('welcome.subtitle')}
|
||||
</h2>
|
||||
<div className="mt-7 grid gap-2 sm:grid-cols-3">
|
||||
{features.map((feature) => (
|
||||
<div
|
||||
key={feature.title}
|
||||
className="rounded-lg border border-border bg-surface-modal px-3 py-3 text-left"
|
||||
>
|
||||
<div className="text-meta font-medium text-foreground">
|
||||
{feature.title}
|
||||
</div>
|
||||
{feature.description ? (
|
||||
<div className="mt-1 text-tiny leading-relaxed text-muted-foreground">
|
||||
{feature.description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,12 +18,14 @@ type ChatToolbarProps = {
|
||||
questionDirectoryOpen?: boolean;
|
||||
questionDirectoryCount?: number;
|
||||
onToggleQuestionDirectory?: () => void;
|
||||
onRefresh?: () => void;
|
||||
};
|
||||
|
||||
export function ChatToolbar({
|
||||
questionDirectoryOpen = false,
|
||||
questionDirectoryCount = 0,
|
||||
onToggleQuestionDirectory,
|
||||
onRefresh,
|
||||
}: ChatToolbarProps = {}) {
|
||||
const refresh = useChatStore((s) => s.refresh);
|
||||
const loading = useChatStore((s) => s.loading);
|
||||
@@ -99,7 +101,7 @@ export function ChatToolbar({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10"
|
||||
onClick={() => refresh()}
|
||||
onClick={onRefresh ?? refresh}
|
||||
disabled={loading}
|
||||
aria-label={t('toolbar.refresh')}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Terminal } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { CommandOutputEntry } from '@/chat-core/openclaw-port/types';
|
||||
|
||||
function firstText(...values: Array<string | undefined>): string | null {
|
||||
for (const value of values) {
|
||||
const text = value?.trim();
|
||||
if (text) return text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatDuration(
|
||||
durationMs: number | undefined,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
): string | null {
|
||||
if (durationMs == null || !Number.isFinite(durationMs)) return null;
|
||||
if (durationMs < 1000) return t('commandCard.durationMs', { count: Math.round(durationMs) });
|
||||
return t('commandCard.durationSeconds', { value: (durationMs / 1000).toFixed(1) });
|
||||
}
|
||||
|
||||
export function CommandCard({ command }: { command: CommandOutputEntry }) {
|
||||
return (
|
||||
<section
|
||||
className="w-[50vw] max-w-full rounded-md border border-border bg-surface-input text-sm"
|
||||
data-testid="chat-command-card"
|
||||
>
|
||||
<CommandDetails command={command} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommandDetails({ command }: { command: CommandOutputEntry }) {
|
||||
const { t } = useTranslation('chat');
|
||||
const title = firstText(command.title, command.name, command.command) ?? t('commandCard.title');
|
||||
const output = firstText(command.output, command.stdoutExcerpt, command.stderrExcerpt, command.stdout, command.stderr);
|
||||
const duration = formatDuration(command.durationMs, t);
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-2 px-3 py-2" data-testid="chat-command-card-body">
|
||||
<Terminal className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<h3 className="truncate text-xs font-medium text-foreground">{title}</h3>
|
||||
{command.exitCode != null ? (
|
||||
<span className="rounded bg-black/5 px-1.5 py-0.5 text-2xs font-medium text-muted-foreground dark:bg-white/10">
|
||||
{t('commandCard.exitCode', { code: command.exitCode })}
|
||||
</span>
|
||||
) : null}
|
||||
{duration ? (
|
||||
<span className="text-2xs text-muted-foreground">{duration}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{command.command ? (
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap break-words rounded bg-black/5 px-2 py-1.5 font-mono text-xs text-foreground dark:bg-white/10">
|
||||
{command.command}
|
||||
</pre>
|
||||
) : null}
|
||||
{output ? (
|
||||
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-words text-xs leading-5 text-muted-foreground">
|
||||
{output}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { RawOpenClawMessage } from '@/chat-core/openclaw-port/types';
|
||||
import { extractDisplayMessageText } from '@/chat-core/openclaw-port/history';
|
||||
import { extractAssistantCommentaryText } from '@/chat-core/openclaw-port/message-extraction';
|
||||
import { extractToolCardsCached } from '@/chat-core/openclaw-port/tool-cards';
|
||||
import type { AttachedFileMeta, RawMessage } from '@/stores/chat';
|
||||
import { hostApi } from '@/lib/host-api';
|
||||
import { ChatMessage } from './ChatMessage';
|
||||
import { extractMediaRefs, extractText as extractRenderedMessageText, sanitizeAssistantReplyText } from './message-utils';
|
||||
import { ToolCard } from './ToolCard';
|
||||
|
||||
const IMAGE_PREVIEW_RETRY_DELAYS_MS = [300, 900, 1800];
|
||||
|
||||
function normalizeRole(role: unknown): string {
|
||||
return typeof role === 'string' ? role.replace(/[_-]/g, '').toLowerCase() : '';
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeStoreRole(role: string): RawMessage['role'] {
|
||||
if (role === 'user') return 'user';
|
||||
if (role === 'tool' || role === 'toolresult' || role === 'function') return 'toolresult';
|
||||
if (role === 'system') return 'system';
|
||||
return 'assistant';
|
||||
}
|
||||
|
||||
function normalizeOpenClawMediaFileName(fileName: string): string {
|
||||
const uuidPattern = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
|
||||
return fileName
|
||||
.replace(new RegExp(`^${uuidPattern}-`, 'i'), '')
|
||||
.replace(new RegExp(`---${uuidPattern}(?=\\.[^.]+$|$)`, 'i'), '');
|
||||
}
|
||||
|
||||
function fileNameFromPath(filePath: string, fallback = 'image'): string {
|
||||
const raw = filePath.split(/[\\/]/).pop() || fallback;
|
||||
return normalizeOpenClawMediaFileName(raw) || fallback;
|
||||
}
|
||||
|
||||
function mimeFromPath(filePath: string, fallback = 'application/octet-stream'): string {
|
||||
const lower = filePath.toLowerCase();
|
||||
if (lower.endsWith('.png')) return 'image/png';
|
||||
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
|
||||
if (lower.endsWith('.gif')) return 'image/gif';
|
||||
if (lower.endsWith('.webp')) return 'image/webp';
|
||||
if (lower.endsWith('.bmp')) return 'image/bmp';
|
||||
if (lower.endsWith('.avif')) return 'image/avif';
|
||||
if (lower.endsWith('.svg')) return 'image/svg+xml';
|
||||
if (lower.endsWith('.pdf')) return 'application/pdf';
|
||||
if (lower.endsWith('.md') || lower.endsWith('.markdown')) return 'text/markdown';
|
||||
if (lower.endsWith('.txt')) return 'text/plain';
|
||||
if (lower.endsWith('.csv')) return 'text/csv';
|
||||
if (lower.endsWith('.xlsx')) return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
if (lower.endsWith('.xls')) return 'application/vnd.ms-excel';
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function mediaKey(file: AttachedFileMeta): string | null {
|
||||
return file.filePath ?? file.gatewayUrl ?? null;
|
||||
}
|
||||
|
||||
function openClawMediaStorageKind(file: AttachedFileMeta): 'inbound' | 'outbound' | null {
|
||||
const value = mediaKey(file);
|
||||
if (!value) return null;
|
||||
const normalized = value.replace(/\\/g, '/');
|
||||
if (normalized.includes('/.openclaw/media/inbound/')) return 'inbound';
|
||||
if (normalized.includes('/.openclaw/media/outbound/')) return 'outbound';
|
||||
return null;
|
||||
}
|
||||
|
||||
function dedupeFiles(files: AttachedFileMeta[]): AttachedFileMeta[] {
|
||||
const seen = new Set<string>();
|
||||
const seenOpenClawImages = new Map<string, Set<'inbound' | 'outbound'>>();
|
||||
return files.map((file) => ({
|
||||
...file,
|
||||
fileName: normalizeOpenClawMediaFileName(file.fileName),
|
||||
})).filter((file) => {
|
||||
const key = mediaKey(file) ?? `${file.fileName}:${file.mimeType}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
|
||||
const storageKind = openClawMediaStorageKind(file);
|
||||
if (storageKind && file.mimeType.startsWith('image/')) {
|
||||
const displayKey = `${file.mimeType}:${file.fileName}`;
|
||||
const storageKinds = seenOpenClawImages.get(displayKey) ?? new Set<'inbound' | 'outbound'>();
|
||||
const counterpart = storageKind === 'inbound' ? 'outbound' : 'inbound';
|
||||
if (storageKinds.has(counterpart)) return false;
|
||||
storageKinds.add(storageKind);
|
||||
seenOpenClawImages.set(displayKey, storageKinds);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function isToolResultMessage(message: RawOpenClawMessage): boolean {
|
||||
const role = normalizeRole(message.role);
|
||||
return (
|
||||
role === 'tool'
|
||||
|| role === 'function'
|
||||
|| role === 'toolresult'
|
||||
|| typeof message.toolName === 'string'
|
||||
|| typeof message.tool_name === 'string'
|
||||
|| typeof message.toolCallId === 'string'
|
||||
|| typeof message.tool_call_id === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function assistantDisplayTextForMessage(
|
||||
message: RawOpenClawMessage,
|
||||
hasToolCards: boolean,
|
||||
): string {
|
||||
const visibleText = extractDisplayMessageText(message);
|
||||
if (visibleText.trim() || !hasToolCards) return visibleText;
|
||||
return extractAssistantCommentaryText(message) ?? '';
|
||||
}
|
||||
|
||||
function collectMediaValues(record: Record<string, unknown> | undefined): string[] {
|
||||
if (!record) return [];
|
||||
const values: string[] = [];
|
||||
const push = (value: unknown) => {
|
||||
if (typeof value === 'string' && value.trim()) values.push(value.trim());
|
||||
};
|
||||
push(record.mediaUrl);
|
||||
push(record.url);
|
||||
const mediaUrls = record.mediaUrls;
|
||||
if (Array.isArray(mediaUrls)) {
|
||||
for (const value of mediaUrls) push(value);
|
||||
}
|
||||
const attachments = record.attachments;
|
||||
if (Array.isArray(attachments)) {
|
||||
for (const attachment of attachments) {
|
||||
if (!isRecord(attachment)) continue;
|
||||
push(attachment.path);
|
||||
push(attachment.filePath);
|
||||
push(attachment.url);
|
||||
push(attachment.mediaUrl);
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function toAttachedFile(value: string, source: AttachedFileMeta['source']): AttachedFileMeta {
|
||||
if (value.startsWith('/api/chat/media/')) {
|
||||
return {
|
||||
fileName: fileNameFromPath(value, 'image'),
|
||||
mimeType: 'image/png',
|
||||
fileSize: 0,
|
||||
preview: null,
|
||||
gatewayUrl: value,
|
||||
source: 'gateway-media',
|
||||
};
|
||||
}
|
||||
return {
|
||||
fileName: fileNameFromPath(value),
|
||||
mimeType: mimeFromPath(value),
|
||||
fileSize: 0,
|
||||
preview: null,
|
||||
filePath: value,
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
function sourceRecord(block: Record<string, unknown>): Record<string, unknown> | undefined {
|
||||
return isRecord(block.source) ? block.source : undefined;
|
||||
}
|
||||
|
||||
function contentImageUrl(block: Record<string, unknown>): string {
|
||||
const directUrl = typeof block.url === 'string' ? block.url.trim() : '';
|
||||
if (directUrl) return directUrl;
|
||||
const source = sourceRecord(block);
|
||||
return typeof source?.url === 'string' ? source.url.trim() : '';
|
||||
}
|
||||
|
||||
function shouldSurfaceImageUrlAsAttachment(url: string): boolean {
|
||||
return !!url && !/^https?:\/\//i.test(url) && !url.startsWith('data:');
|
||||
}
|
||||
|
||||
function hasInlineContentImage(message: RawOpenClawMessage): boolean {
|
||||
if (!Array.isArray(message.content)) return false;
|
||||
return message.content.some((block) => {
|
||||
if (!isRecord(block) || block.type !== 'image') return false;
|
||||
if (typeof block.data === 'string' && block.data.trim()) return true;
|
||||
const source = sourceRecord(block);
|
||||
if (!source) return false;
|
||||
if (source.type === 'base64' && typeof source.data === 'string' && source.data.trim()) return true;
|
||||
if (source.type === 'url' && typeof source.url === 'string' && source.url.trim()) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function extractMediaTagFiles(text: string): AttachedFileMeta[] {
|
||||
const files: AttachedFileMeta[] = [];
|
||||
const exts = 'png|jpe?g|gif|webp|bmp|avif|svg|pdf|docx?|xlsx?|pptx?|txt|csv|md|rtf|epub|zip|tar|gz|rar|7z|mp3|wav|ogg|aac|flac|m4a|mp4|mov|avi|mkv|webm|m4v';
|
||||
const taggedRegex = new RegExp(`(?<![A-Za-z0-9/\\\\])(?:MEDIA|media):(?!\\/\\/)((?:\\/|~\\/|[A-Za-z]:\\\\)[^\\n"'()\\[\\],<>\`]*?\\.(?:${exts}))(?=$|[\\s\\n"'()\\[\\],<>\`]|[,。;;,.!?])`, 'g');
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = taggedRegex.exec(text)) !== null) {
|
||||
const filePath = match[1];
|
||||
if (filePath) files.push(toAttachedFile(filePath, 'message-ref'));
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function stringList(value: unknown): string[] {
|
||||
if (typeof value === 'string' && value.trim()) return [value.trim()];
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0);
|
||||
}
|
||||
|
||||
function firstStringAt(values: string[], index: number): string | undefined {
|
||||
return values[index] ?? values[0];
|
||||
}
|
||||
|
||||
function extractOpenClawMediaPathFiles(message: RawOpenClawMessage): AttachedFileMeta[] {
|
||||
const record = message as Record<string, unknown>;
|
||||
const paths = [
|
||||
...stringList(record.MediaPath),
|
||||
...stringList(record.mediaPath),
|
||||
...stringList(record.filePath),
|
||||
...stringList(record.MediaPaths),
|
||||
...stringList(record.mediaPaths),
|
||||
...stringList(record.filePaths),
|
||||
];
|
||||
const types = [
|
||||
...stringList(record.MediaType),
|
||||
...stringList(record.mediaType),
|
||||
...stringList(record.mimeType),
|
||||
...stringList(record.MediaTypes),
|
||||
...stringList(record.mediaTypes),
|
||||
...stringList(record.mimeTypes),
|
||||
];
|
||||
|
||||
return paths.map((filePath, index) => ({
|
||||
fileName: fileNameFromPath(filePath),
|
||||
mimeType: firstStringAt(types, index) ?? mimeFromPath(filePath),
|
||||
fileSize: 0,
|
||||
preview: null,
|
||||
filePath,
|
||||
source: 'user-upload' as const,
|
||||
}));
|
||||
}
|
||||
|
||||
function extractMediaAttachedFiles(message: RawOpenClawMessage): AttachedFileMeta[] {
|
||||
if (normalizeRole(message.role) !== 'user') return [];
|
||||
return dedupeFiles([
|
||||
...extractMediaRefs({
|
||||
...(message as Record<string, unknown>),
|
||||
role: 'user',
|
||||
content: message.content ?? message.text ?? '',
|
||||
}).map(({ filePath, mimeType }) => ({
|
||||
fileName: fileNameFromPath(filePath),
|
||||
mimeType,
|
||||
fileSize: 0,
|
||||
preview: null,
|
||||
filePath,
|
||||
source: 'user-upload' as const,
|
||||
})),
|
||||
...extractOpenClawMediaPathFiles(message),
|
||||
]);
|
||||
}
|
||||
|
||||
function extractContentImageFiles(message: RawOpenClawMessage): AttachedFileMeta[] {
|
||||
if (!Array.isArray(message.content)) return [];
|
||||
const files: AttachedFileMeta[] = [];
|
||||
for (const block of message.content) {
|
||||
if (!isRecord(block) || block.type !== 'image') continue;
|
||||
const url = contentImageUrl(block);
|
||||
if (!shouldSurfaceImageUrlAsAttachment(url)) continue;
|
||||
const alt = typeof block.alt === 'string' && block.alt.trim()
|
||||
? block.alt.trim()
|
||||
: fileNameFromPath(url, 'image');
|
||||
files.push({
|
||||
fileName: alt,
|
||||
mimeType: typeof block.mimeType === 'string' && block.mimeType.trim()
|
||||
? block.mimeType.trim()
|
||||
: mimeFromPath(url, 'image/png'),
|
||||
fileSize: 0,
|
||||
preview: null,
|
||||
gatewayUrl: url.startsWith('/api/chat/media/') ? url : undefined,
|
||||
filePath: url.startsWith('/api/chat/media/') ? undefined : url,
|
||||
source: url.startsWith('/api/chat/media/') ? 'gateway-media' : 'message-ref',
|
||||
});
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function extractSourceReply(message: RawOpenClawMessage): { text?: string; files: AttachedFileMeta[] } {
|
||||
const detailRecords: Record<string, unknown>[] = [];
|
||||
if (isRecord(message.details)) detailRecords.push(message.details);
|
||||
if (Array.isArray(message.content)) {
|
||||
for (const block of message.content) {
|
||||
if (isRecord(block.details)) detailRecords.push(block.details);
|
||||
}
|
||||
}
|
||||
|
||||
let text: string | undefined;
|
||||
const files: AttachedFileMeta[] = [];
|
||||
for (const details of detailRecords) {
|
||||
const sourceReply = isRecord(details.sourceReply) ? details.sourceReply : undefined;
|
||||
const internalUi = details.sourceReplySink === 'internal-ui'
|
||||
|| details.sourceReplyDeliveryMode === 'message_tool_only';
|
||||
if (internalUi && typeof sourceReply?.text === 'string' && sourceReply.text.trim()) {
|
||||
text = sourceReply.text.trim();
|
||||
}
|
||||
for (const media of [...collectMediaValues(details), ...collectMediaValues(sourceReply)]) {
|
||||
files.push(toAttachedFile(media, 'tool-result'));
|
||||
}
|
||||
}
|
||||
return { text, files };
|
||||
}
|
||||
|
||||
type MediaAdapter = {
|
||||
message: RawMessage;
|
||||
textOverride: string;
|
||||
};
|
||||
|
||||
function useMediaAdapter(
|
||||
message: RawOpenClawMessage,
|
||||
sourceText: string,
|
||||
displayText: string,
|
||||
): MediaAdapter | null {
|
||||
const role = normalizeRole(message.role);
|
||||
const base = useMemo(() => {
|
||||
const sourceReply = extractSourceReply(message);
|
||||
const files = dedupeFiles([
|
||||
...extractMediaAttachedFiles(message),
|
||||
...extractContentImageFiles(message),
|
||||
...extractMediaTagFiles(sourceText),
|
||||
...sourceReply.files,
|
||||
...((Array.isArray(message._attachedFiles) ? message._attachedFiles : []) as AttachedFileMeta[]),
|
||||
]);
|
||||
const hasInlineImageData = hasInlineContentImage(message);
|
||||
|
||||
if (files.length === 0 && !hasInlineImageData) return null;
|
||||
|
||||
const textOverride = sourceReply.text ?? displayText;
|
||||
return {
|
||||
files,
|
||||
textOverride,
|
||||
message: {
|
||||
...(message as Record<string, unknown>),
|
||||
role: normalizeStoreRole(role),
|
||||
content: message.content ?? '',
|
||||
_attachedFiles: files,
|
||||
} as RawMessage,
|
||||
};
|
||||
}, [displayText, message, role, sourceText]);
|
||||
|
||||
const [previewResults, setPreviewResults] = useState<Record<string, { preview: string | null; fileSize: number; unavailable?: boolean }>>({});
|
||||
const previewRequestKey = useMemo(() => {
|
||||
if (!base) return '';
|
||||
return base.files
|
||||
.flatMap((file) => {
|
||||
const key = mediaKey(file);
|
||||
if (!key || previewResults[key]) return [];
|
||||
return [`${file.mimeType}:${key}`];
|
||||
})
|
||||
.sort()
|
||||
.join('\n');
|
||||
}, [base, previewResults]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!base || !previewRequestKey) return;
|
||||
let cancelled = false;
|
||||
|
||||
const run = async () => {
|
||||
const pending = base.files
|
||||
.map((file) => ({ file, key: mediaKey(file) }))
|
||||
.filter((entry): entry is { file: AttachedFileMeta; key: string } => {
|
||||
const key = entry.key;
|
||||
return key !== null && !previewResults[key];
|
||||
});
|
||||
|
||||
for (let attempt = 0; attempt <= IMAGE_PREVIEW_RETRY_DELAYS_MS.length; attempt++) {
|
||||
if (cancelled) return;
|
||||
if (attempt > 0) {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, IMAGE_PREVIEW_RETRY_DELAYS_MS[attempt - 1]));
|
||||
}
|
||||
if (cancelled) return;
|
||||
const thumbnails = await hostApi.media.thumbnails({
|
||||
paths: pending.map(({ file }) => file.filePath
|
||||
? { filePath: file.filePath, mimeType: file.mimeType }
|
||||
: { gatewayUrl: file.gatewayUrl, mimeType: file.mimeType }),
|
||||
});
|
||||
if (cancelled) return;
|
||||
const resolved: Record<string, { preview: string | null; fileSize: number; unavailable?: boolean }> = {};
|
||||
let hasMissingImage = false;
|
||||
for (const { file, key } of pending) {
|
||||
const thumbnail = thumbnails[key];
|
||||
if (thumbnail && (thumbnail.preview || thumbnail.fileSize)) {
|
||||
resolved[key] = file.mimeType.startsWith('image/') && !thumbnail.preview
|
||||
? { ...thumbnail, unavailable: true }
|
||||
: thumbnail;
|
||||
continue;
|
||||
}
|
||||
if (file.mimeType.startsWith('image/')) {
|
||||
hasMissingImage = true;
|
||||
}
|
||||
resolved[key] = { preview: null, fileSize: 0 };
|
||||
}
|
||||
if (!hasMissingImage || attempt >= IMAGE_PREVIEW_RETRY_DELAYS_MS.length) {
|
||||
setPreviewResults((current) => {
|
||||
const next = { ...current };
|
||||
for (const { file, key } of pending) {
|
||||
const result = resolved[key];
|
||||
next[key] = file.mimeType.startsWith('image/') && !result.preview && !result.fileSize
|
||||
? { ...result, unavailable: true }
|
||||
: result;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
const resolvedWithPreview = Object.entries(resolved).filter(([, result]) => result.preview || result.unavailable || result.fileSize);
|
||||
if (resolvedWithPreview.length > 0) {
|
||||
setPreviewResults((current) => {
|
||||
const next = { ...current };
|
||||
for (const [key, result] of resolvedWithPreview) next[key] = result;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void run().catch(() => {
|
||||
if (cancelled || !base) return;
|
||||
setPreviewResults((current) => {
|
||||
const next = { ...current };
|
||||
for (const file of base.files) {
|
||||
const key = mediaKey(file);
|
||||
if (!key || next[key]) continue;
|
||||
next[key] = { preview: null, fileSize: 0, unavailable: file.mimeType.startsWith('image/') };
|
||||
}
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [base, previewRequestKey, previewResults]);
|
||||
|
||||
if (!base) return null;
|
||||
|
||||
const files = base.files.map((file) => {
|
||||
const key = mediaKey(file);
|
||||
const result = key ? previewResults[key] : undefined;
|
||||
if (!result) return file;
|
||||
return {
|
||||
...file,
|
||||
preview: result.preview ?? file.preview,
|
||||
fileSize: result.fileSize || file.fileSize,
|
||||
previewStatus: result.unavailable || (file.mimeType.startsWith('image/') && !result.preview)
|
||||
? 'unavailable' as const
|
||||
: file.previewStatus,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
textOverride: base.textOverride,
|
||||
message: {
|
||||
...base.message,
|
||||
_attachedFiles: files,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function MessageGroup({
|
||||
message,
|
||||
index,
|
||||
onOpenFile,
|
||||
}: {
|
||||
message: RawOpenClawMessage;
|
||||
index?: number;
|
||||
onOpenFile?: (file: AttachedFileMeta) => void;
|
||||
}) {
|
||||
const role = normalizeRole(message.role);
|
||||
const isUser = role === 'user';
|
||||
const toolCards = extractToolCardsCached(message, String(message.id ?? 'message'));
|
||||
const rawText = role === 'assistant'
|
||||
? assistantDisplayTextForMessage(message, toolCards.length > 0)
|
||||
: extractDisplayMessageText(message);
|
||||
const userMessageForText = {
|
||||
...(message as Record<string, unknown>),
|
||||
role: 'user',
|
||||
content: message.content ?? rawText,
|
||||
} as RawMessage;
|
||||
const text = role === 'assistant'
|
||||
? sanitizeAssistantReplyText(rawText)
|
||||
: extractRenderedMessageText(userMessageForText);
|
||||
const mediaAdapter = useMediaAdapter(message, rawText, text);
|
||||
if (isToolResultMessage(message) && toolCards.length === 0 && !mediaAdapter) return null;
|
||||
const showText = !mediaAdapter
|
||||
&& text
|
||||
&& !(isToolResultMessage(message) && toolCards.length > 0);
|
||||
const toolCardContent = toolCards.length > 0 ? (
|
||||
<div className="space-y-2" data-testid="chat-tool-card-group">
|
||||
{toolCards.map((card) => (
|
||||
<ToolCard key={card.id} card={card} onOpenFile={onOpenFile} />
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
const assistantMessage = {
|
||||
...(message as Record<string, unknown>),
|
||||
role: 'assistant',
|
||||
content: message.content ?? text,
|
||||
} as RawMessage;
|
||||
if (!isUser && (mediaAdapter || showText || toolCardContent)) {
|
||||
return (
|
||||
<article
|
||||
id={typeof index === 'number' ? `chat-message-${index}` : undefined}
|
||||
className="flex justify-start"
|
||||
data-testid={typeof index === 'number' ? `chat-message-${index}` : 'chat-assistant-message'}
|
||||
data-message-role="assistant"
|
||||
>
|
||||
<div className="w-full min-w-0 text-sm text-foreground">
|
||||
{mediaAdapter ? (
|
||||
<ChatMessage
|
||||
message={mediaAdapter.message}
|
||||
textOverride={mediaAdapter.textOverride}
|
||||
suppressToolCards
|
||||
assistantAfterContent={toolCardContent}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
) : (
|
||||
<ChatMessage
|
||||
message={assistantMessage}
|
||||
textOverride={showText ? text : ''}
|
||||
suppressToolCards
|
||||
assistantAfterContent={toolCardContent}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
if (isUser) {
|
||||
const userMessage = mediaAdapter?.message ?? {
|
||||
...(message as Record<string, unknown>),
|
||||
role: 'user',
|
||||
content: message.content ?? text,
|
||||
} as RawMessage;
|
||||
return (
|
||||
<article
|
||||
id={typeof index === 'number' ? `chat-message-${index}` : undefined}
|
||||
className="flex justify-end"
|
||||
data-testid={typeof index === 'number' ? `chat-message-${index}` : 'chat-user-message'}
|
||||
data-message-role="user"
|
||||
>
|
||||
<div className="w-full min-w-0 text-sm">
|
||||
<ChatMessage
|
||||
message={userMessage}
|
||||
textOverride={mediaAdapter?.textOverride ?? text}
|
||||
suppressToolCards
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
id={typeof index === 'number' ? `chat-message-${index}` : undefined}
|
||||
className="flex justify-start"
|
||||
data-testid={typeof index === 'number' ? `chat-message-${index}` : 'chat-assistant-message'}
|
||||
data-message-role="assistant"
|
||||
>
|
||||
<div className="max-w-[85%] px-1 py-1 text-sm text-foreground">
|
||||
{showText ? <div className="whitespace-pre-wrap break-words">{text}</div> : null}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,822 @@
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { ArrowDownToLine } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractDisplayMessageText } from '@/chat-core/openclaw-port/history';
|
||||
import { extractAssistantCommentaryText } from '@/chat-core/openclaw-port/message-extraction';
|
||||
import { extractToolCardsCached } from '@/chat-core/openclaw-port/tool-cards';
|
||||
import { GeneratedFilesPanel } from '@/components/file-preview/GeneratedFilesPanel';
|
||||
import type { GeneratedFile } from '@/lib/generated-files';
|
||||
import type {
|
||||
ApprovalDecision,
|
||||
CommandOutputEntry,
|
||||
RawOpenClawMessage,
|
||||
VisibleChatItem,
|
||||
} from '@/chat-core/openclaw-port/types';
|
||||
import type { AttachedFileMeta, RawMessage } from '@/stores/chat';
|
||||
import { ApprovalCard } from './ApprovalCard';
|
||||
import { ChatMessage } from './ChatMessage';
|
||||
import { CommandCard } from './CommandCard';
|
||||
import { MessageGroup } from './MessageGroup';
|
||||
import { PatchCard } from './PatchCard';
|
||||
import { RunStatusBar } from './RunStatusBar';
|
||||
import { RuntimeIndicator } from './RuntimeIndicator';
|
||||
import { StreamingGroup } from './StreamingGroup';
|
||||
import { ThinkingBlock } from './ThinkingBlock';
|
||||
import { ToolCard } from './ToolCard';
|
||||
import { chatMessageAnchorId, sanitizeAssistantReplyText } from './message-utils';
|
||||
|
||||
const BOTTOM_EPSILON_PX = 8;
|
||||
|
||||
type AssistantRunProcessItem = Extract<
|
||||
VisibleChatItem,
|
||||
{ kind: 'thinking' | 'stream' | 'tool' | 'command' | 'patch' }
|
||||
>;
|
||||
|
||||
type AssistantRunGroupItem = {
|
||||
kind: 'assistant-run';
|
||||
id: string;
|
||||
runId: string;
|
||||
processItems: AssistantRunProcessItem[];
|
||||
followupMessages?: MessageChatItem[];
|
||||
};
|
||||
|
||||
type MessageChatItem = Extract<VisibleChatItem, { kind: 'message' }>;
|
||||
|
||||
type AssistantHistoryTurnItem = {
|
||||
kind: 'assistant-history-turn';
|
||||
id: string;
|
||||
messages: MessageChatItem[];
|
||||
thinkingText?: string;
|
||||
};
|
||||
|
||||
type AssistantHistoryTurnPartModel = {
|
||||
item: MessageChatItem;
|
||||
suppressText: boolean;
|
||||
};
|
||||
|
||||
type RenderableChatItem = VisibleChatItem | AssistantRunGroupItem | AssistantHistoryTurnItem;
|
||||
|
||||
function isAssistantRunProcessItem(item: VisibleChatItem): item is AssistantRunProcessItem {
|
||||
return (
|
||||
item.kind === 'thinking'
|
||||
|| item.kind === 'stream'
|
||||
|| item.kind === 'tool'
|
||||
|| item.kind === 'command'
|
||||
|| item.kind === 'patch'
|
||||
);
|
||||
}
|
||||
|
||||
function runIdForAssistantRunProcessItem(item: AssistantRunProcessItem): string {
|
||||
if (item.kind === 'command') return item.command.runId;
|
||||
if (item.kind === 'patch') return item.patch.runId;
|
||||
return item.runId;
|
||||
}
|
||||
|
||||
function groupAssistantRunItems(items: VisibleChatItem[]): RenderableChatItem[] {
|
||||
const grouped: RenderableChatItem[] = [];
|
||||
let pending: AssistantRunGroupItem | null = null;
|
||||
|
||||
const flush = () => {
|
||||
if (!pending) return;
|
||||
grouped.push(pending);
|
||||
pending = null;
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
if (!isAssistantRunProcessItem(item)) {
|
||||
flush();
|
||||
grouped.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
const runId = runIdForAssistantRunProcessItem(item);
|
||||
if (!pending || pending.runId !== runId) {
|
||||
flush();
|
||||
pending = {
|
||||
kind: 'assistant-run',
|
||||
id: `assistant-run-${runId}-${item.id}`,
|
||||
runId,
|
||||
processItems: [item],
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
pending.processItems.push(item);
|
||||
}
|
||||
|
||||
flush();
|
||||
return mergeAdjacentAssistantHistoryTurns(groupAssistantHistoryTurns(groupAssistantRunFollowupMessages(grouped)));
|
||||
}
|
||||
|
||||
function joinProcessText(values: string[]): string {
|
||||
return values
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
function assistantMessageText(message: RawOpenClawMessage): string {
|
||||
const visibleText = sanitizeAssistantReplyText(extractDisplayMessageText(message));
|
||||
if (visibleText.trim()) return visibleText;
|
||||
return sanitizeAssistantReplyText(extractAssistantCommentaryText(message) ?? '');
|
||||
}
|
||||
|
||||
function assistantRunCopyText(
|
||||
group: AssistantRunGroupItem,
|
||||
): string {
|
||||
return joinProcessText([
|
||||
...group.processItems.flatMap((item) => (item.kind === 'stream' ? [item.text] : [])),
|
||||
...(group.followupMessages ?? []).map((item) => assistantMessageText(item.message)),
|
||||
]);
|
||||
}
|
||||
|
||||
function normalizeRole(role: unknown): string {
|
||||
return typeof role === 'string' ? role.replace(/[_-]/g, '').toLowerCase() : '';
|
||||
}
|
||||
|
||||
function isAssistantMessageItem(item: RenderableChatItem): item is MessageChatItem {
|
||||
return item.kind === 'message' && normalizeRole(item.message.role) === 'assistant';
|
||||
}
|
||||
|
||||
function messageHasToolCards(message: RawOpenClawMessage): boolean {
|
||||
return extractToolCardsCached(message, String(message.id ?? 'message')).length > 0;
|
||||
}
|
||||
|
||||
function isToolResultLikeMessage(message: RawOpenClawMessage): boolean {
|
||||
const role = normalizeRole(message.role);
|
||||
return (
|
||||
role === 'tool'
|
||||
|| role === 'toolresult'
|
||||
|| role === 'function'
|
||||
|| typeof message.toolName === 'string'
|
||||
|| typeof message.tool_name === 'string'
|
||||
|| typeof message.toolCallId === 'string'
|
||||
|| typeof message.tool_call_id === 'string'
|
||||
|| typeof message.toolUseId === 'string'
|
||||
|| typeof message.tool_use_id === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function isAssistantTurnMessageItem(
|
||||
item: RenderableChatItem,
|
||||
): item is MessageChatItem {
|
||||
return item.kind === 'message'
|
||||
&& (
|
||||
normalizeRole(item.message.role) === 'assistant'
|
||||
|| messageHasToolCards(item.message)
|
||||
|| isToolResultLikeMessage(item.message)
|
||||
);
|
||||
}
|
||||
|
||||
function messageIsStreamFallback(message: RawOpenClawMessage): boolean {
|
||||
const fallback = (message as Record<string, unknown>).openclawStreamFallback;
|
||||
return Boolean(fallback && typeof fallback === 'object' && !Array.isArray(fallback));
|
||||
}
|
||||
|
||||
function assistantRunHasProcessBlocks(item: AssistantRunGroupItem): boolean {
|
||||
return item.processItems.some((processItem) => (
|
||||
processItem.kind === 'tool' || processItem.kind === 'command' || processItem.kind === 'patch'
|
||||
));
|
||||
}
|
||||
|
||||
function compactIds(values: Array<string | undefined>): string[] {
|
||||
return values
|
||||
.map((value) => value?.trim())
|
||||
.filter((value): value is string => Boolean(value));
|
||||
}
|
||||
|
||||
function withoutPrefix(value: string | undefined, prefix: string): string | undefined {
|
||||
if (!value?.startsWith(prefix)) return value;
|
||||
return value.slice(prefix.length);
|
||||
}
|
||||
|
||||
function toolAssociationIds(item: Extract<AssistantRunProcessItem, { kind: 'tool' }>): string[] {
|
||||
return compactIds([
|
||||
item.toolCallId,
|
||||
withoutPrefix(item.id, 'tool-'),
|
||||
withoutPrefix(item.tool.id, 'live:'),
|
||||
item.tool.transcriptMessageId,
|
||||
]);
|
||||
}
|
||||
|
||||
function commandAssociationIds(command: CommandOutputEntry): string[] {
|
||||
return compactIds([
|
||||
command.toolCallId,
|
||||
command.itemId,
|
||||
command.toolId,
|
||||
command.toolItemId,
|
||||
command.callId,
|
||||
command.parentId,
|
||||
command.parentItemId,
|
||||
]);
|
||||
}
|
||||
|
||||
function hasSharedId(left: string[], right: string[]): boolean {
|
||||
if (left.length === 0 || right.length === 0) return false;
|
||||
const rightSet = new Set(right);
|
||||
return left.some((id) => rightSet.has(id));
|
||||
}
|
||||
|
||||
const COMMAND_TOOL_NAMES = new Set(['exec', 'shell', 'bash', 'sh', 'terminal', 'command']);
|
||||
|
||||
function isLikelyCommandTool(toolName: string | undefined): boolean {
|
||||
const normalized = toolName?.trim().toLowerCase();
|
||||
return normalized ? COMMAND_TOOL_NAMES.has(normalized) : false;
|
||||
}
|
||||
|
||||
function commandMatchesTool(
|
||||
command: Extract<AssistantRunProcessItem, { kind: 'command' }>,
|
||||
tool: Extract<AssistantRunProcessItem, { kind: 'tool' }>,
|
||||
toolItems: Array<Extract<AssistantRunProcessItem, { kind: 'tool' }>>,
|
||||
commandItems: Array<Extract<AssistantRunProcessItem, { kind: 'command' }>>,
|
||||
): boolean {
|
||||
if (hasSharedId(commandAssociationIds(command.command), toolAssociationIds(tool))) return true;
|
||||
return toolItems.length === 1
|
||||
&& commandItems.length === 1
|
||||
&& isLikelyCommandTool(tool.tool.toolName);
|
||||
}
|
||||
|
||||
function commandForTool(
|
||||
tool: Extract<AssistantRunProcessItem, { kind: 'tool' }>,
|
||||
toolItems: Array<Extract<AssistantRunProcessItem, { kind: 'tool' }>>,
|
||||
commandItems: Array<Extract<AssistantRunProcessItem, { kind: 'command' }>>,
|
||||
): Extract<AssistantRunProcessItem, { kind: 'command' }> | undefined {
|
||||
return commandItems.find((command) => commandMatchesTool(command, tool, toolItems, commandItems));
|
||||
}
|
||||
|
||||
function commandIsMergedIntoTool(
|
||||
command: Extract<AssistantRunProcessItem, { kind: 'command' }>,
|
||||
toolItems: Array<Extract<AssistantRunProcessItem, { kind: 'tool' }>>,
|
||||
commandItems: Array<Extract<AssistantRunProcessItem, { kind: 'command' }>>,
|
||||
): boolean {
|
||||
return toolItems.some((tool) => commandMatchesTool(command, tool, toolItems, commandItems));
|
||||
}
|
||||
|
||||
const TERMINAL_COMMAND_STATUSES = new Set(['end', 'done', 'finished', 'completed', 'complete', 'success', 'error', 'failed']);
|
||||
|
||||
function isTerminalCommand(command: CommandOutputEntry | undefined): boolean {
|
||||
if (!command) return false;
|
||||
if (command.exitCode != null || command.endedAt != null) return true;
|
||||
const status = command.status?.trim().toLowerCase();
|
||||
const phase = command.phase?.trim().toLowerCase();
|
||||
return Boolean(
|
||||
(status && TERMINAL_COMMAND_STATUSES.has(status))
|
||||
|| (phase && TERMINAL_COMMAND_STATUSES.has(phase)),
|
||||
);
|
||||
}
|
||||
|
||||
function assistantRunCanOwnFollowupMessages(item: AssistantRunGroupItem): boolean {
|
||||
return assistantRunHasProcessBlocks(item)
|
||||
|| item.processItems.every((processItem) => processItem.kind === 'thinking');
|
||||
}
|
||||
|
||||
function groupAssistantRunFollowupMessages(
|
||||
items: RenderableChatItem[],
|
||||
): RenderableChatItem[] {
|
||||
const grouped: RenderableChatItem[] = [];
|
||||
|
||||
for (let index = 0; index < items.length; index++) {
|
||||
const item = items[index];
|
||||
if (item.kind !== 'assistant-run' || !assistantRunCanOwnFollowupMessages(item)) {
|
||||
grouped.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
const followupMessages: MessageChatItem[] = [];
|
||||
let nextIndex = index + 1;
|
||||
while (nextIndex < items.length && isAssistantMessageItem(items[nextIndex])) {
|
||||
followupMessages.push(items[nextIndex] as MessageChatItem);
|
||||
nextIndex += 1;
|
||||
}
|
||||
|
||||
if (followupMessages.length === 0) {
|
||||
grouped.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.processItems.every((processItem) => processItem.kind === 'thinking')) {
|
||||
grouped.push({
|
||||
kind: 'assistant-history-turn',
|
||||
id: `assistant-history-turn-${item.id}-${followupMessages.map((message) => message.id).join('-')}`,
|
||||
messages: followupMessages,
|
||||
thinkingText: joinProcessText(item.processItems.map((processItem) => processItem.text)),
|
||||
});
|
||||
index = nextIndex - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
grouped.push({
|
||||
...item,
|
||||
id: `${item.id}-${followupMessages.map((message) => message.id).join('-')}`,
|
||||
followupMessages,
|
||||
});
|
||||
index = nextIndex - 1;
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function groupAssistantHistoryTurns(
|
||||
items: RenderableChatItem[],
|
||||
): RenderableChatItem[] {
|
||||
const grouped: RenderableChatItem[] = [];
|
||||
|
||||
for (let index = 0; index < items.length; index++) {
|
||||
const item = items[index];
|
||||
if (!isAssistantTurnMessageItem(item)) {
|
||||
grouped.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
const messages: MessageChatItem[] = [item];
|
||||
let nextIndex = index + 1;
|
||||
while (nextIndex < items.length && isAssistantTurnMessageItem(items[nextIndex])) {
|
||||
messages.push(items[nextIndex] as MessageChatItem);
|
||||
nextIndex += 1;
|
||||
}
|
||||
|
||||
if (messages.length > 1 && messages.some((message) => (
|
||||
messageHasToolCards(message.message)
|
||||
|| messageIsStreamFallback(message.message)
|
||||
|| isToolResultLikeMessage(message.message)
|
||||
))) {
|
||||
grouped.push({
|
||||
kind: 'assistant-history-turn',
|
||||
id: `assistant-history-turn-${messages.map((message) => message.id).join('-')}`,
|
||||
messages,
|
||||
});
|
||||
} else {
|
||||
grouped.push(...messages);
|
||||
}
|
||||
index = nextIndex - 1;
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function mergeAdjacentAssistantHistoryTurns(items: RenderableChatItem[]): RenderableChatItem[] {
|
||||
const merged: RenderableChatItem[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const previous = merged.at(-1);
|
||||
if (previous?.kind === 'assistant-history-turn' && item.kind === 'assistant-history-turn') {
|
||||
merged[merged.length - 1] = {
|
||||
kind: 'assistant-history-turn',
|
||||
id: `${previous.id}-${item.id}`,
|
||||
messages: [...previous.messages, ...item.messages],
|
||||
thinkingText: joinProcessText([
|
||||
previous.thinkingText ?? '',
|
||||
item.thinkingText ?? '',
|
||||
]),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
merged.push(item);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function distanceFromBottom(element: HTMLElement): number {
|
||||
return element.scrollHeight - element.clientHeight - element.scrollTop;
|
||||
}
|
||||
|
||||
function scrollElementToBottom(element: HTMLElement): void {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
}
|
||||
|
||||
export function MessageList({
|
||||
items,
|
||||
generatedFilesByMessageId,
|
||||
onOpenFile,
|
||||
onOpenGeneratedFile,
|
||||
onResolveApproval,
|
||||
}: {
|
||||
items: VisibleChatItem[];
|
||||
generatedFilesByMessageId?: Record<string, GeneratedFile[]>;
|
||||
onOpenFile?: (file: AttachedFileMeta) => void;
|
||||
onOpenGeneratedFile?: (file: GeneratedFile) => void;
|
||||
onResolveApproval?: (id: string, decision: ApprovalDecision) => void;
|
||||
}) {
|
||||
const { t } = useTranslation('chat');
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const pinnedToBottomRef = useRef(true);
|
||||
const userDetachedFromBottomRef = useRef(false);
|
||||
const lastScrollTopRef = useRef(0);
|
||||
const [showJumpToLatest, setShowJumpToLatest] = useState(false);
|
||||
const renderItems = groupAssistantRunItems(items);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const element = scrollRef.current;
|
||||
if (!element) return;
|
||||
scrollElementToBottom(element);
|
||||
pinnedToBottomRef.current = true;
|
||||
userDetachedFromBottomRef.current = false;
|
||||
lastScrollTopRef.current = element.scrollTop;
|
||||
setShowJumpToLatest(false);
|
||||
}, []);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const element = scrollRef.current;
|
||||
if (!element) return;
|
||||
const pinned = distanceFromBottom(element) <= BOTTOM_EPSILON_PX;
|
||||
const previousScrollTop = lastScrollTopRef.current;
|
||||
lastScrollTopRef.current = element.scrollTop;
|
||||
|
||||
if (!pinned) {
|
||||
userDetachedFromBottomRef.current = true;
|
||||
pinnedToBottomRef.current = false;
|
||||
setShowJumpToLatest(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (userDetachedFromBottomRef.current && element.scrollTop <= previousScrollTop) {
|
||||
pinnedToBottomRef.current = false;
|
||||
setShowJumpToLatest(true);
|
||||
return;
|
||||
}
|
||||
|
||||
userDetachedFromBottomRef.current = false;
|
||||
pinnedToBottomRef.current = true;
|
||||
setShowJumpToLatest(false);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (items.length > 0) return;
|
||||
pinnedToBottomRef.current = true;
|
||||
userDetachedFromBottomRef.current = false;
|
||||
lastScrollTopRef.current = 0;
|
||||
const frame = requestAnimationFrame(() => {
|
||||
setShowJumpToLatest(false);
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [items.length]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!pinnedToBottomRef.current) return;
|
||||
const element = scrollRef.current;
|
||||
if (!element) return;
|
||||
scrollElementToBottom(element);
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const latestElement = scrollRef.current;
|
||||
if (latestElement) scrollElementToBottom(latestElement);
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [items]);
|
||||
|
||||
const shouldShowJumpToLatest = items.length > 0 && showJumpToLatest;
|
||||
|
||||
return (
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
data-testid="chat-scroll-container"
|
||||
className="h-full min-h-0 overflow-y-auto px-4 py-3"
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-3">
|
||||
{renderItems.map((item, index) => {
|
||||
if (item.kind === 'assistant-run') {
|
||||
return <AssistantRunGroup key={item.id} group={item} onOpenFile={onOpenFile} />;
|
||||
}
|
||||
if (item.kind === 'assistant-history-turn') {
|
||||
return <AssistantHistoryTurn key={item.id} group={item} index={index} onOpenFile={onOpenFile} />;
|
||||
}
|
||||
if (item.kind === 'message') {
|
||||
const messageId = String(item.message.id ?? item.id);
|
||||
const generatedFiles = generatedFilesByMessageId?.[messageId] ?? [];
|
||||
return (
|
||||
<div key={item.id} id={chatMessageAnchorId(messageId)} className="space-y-2">
|
||||
<MessageGroup
|
||||
message={item.message}
|
||||
index={index}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
{generatedFiles.length > 0 && onOpenGeneratedFile ? (
|
||||
<div className="mx-auto w-full max-w-4xl px-16">
|
||||
<GeneratedFilesPanel
|
||||
files={generatedFiles}
|
||||
onOpen={onOpenGeneratedFile}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (item.kind === 'runtime') {
|
||||
return <RuntimeIndicator key={item.id} status={item.status} />;
|
||||
}
|
||||
if (item.kind === 'approval') {
|
||||
return (
|
||||
<ApprovalCard
|
||||
key={item.id}
|
||||
approval={item.approval}
|
||||
onResolve={onResolveApproval}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isAssistantRunProcessItem(item)) return null;
|
||||
if (item.kind === 'queue') {
|
||||
const message = {
|
||||
role: 'user',
|
||||
content: item.item.message,
|
||||
timestamp: item.item.createdAt ? item.item.createdAt / 1000 : undefined,
|
||||
_attachedFiles: item.item.attachments,
|
||||
} as RawMessage;
|
||||
return (
|
||||
<article
|
||||
key={item.id}
|
||||
className="flex justify-end"
|
||||
data-testid="chat-optimistic-user-message"
|
||||
data-message-role="user"
|
||||
>
|
||||
<div className="w-full min-w-0 text-sm">
|
||||
<ChatMessage
|
||||
message={message}
|
||||
textOverride={item.item.message}
|
||||
suppressToolCards
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
if (item.status.phase === 'running') return null;
|
||||
return <RunStatusBar key={item.id} status={item.status} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{shouldShowJumpToLatest && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="chat-scroll-to-latest"
|
||||
onClick={scrollToBottom}
|
||||
className="absolute bottom-3 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1.5 whitespace-nowrap rounded-full border border-black/10 bg-background/95 px-3 py-1.5 text-xs font-medium text-foreground shadow-sm backdrop-blur hover:bg-black/5 dark:border-white/10 dark:hover:bg-white/10"
|
||||
aria-label={t('scrollToLatest')}
|
||||
title={t('scrollToLatest')}
|
||||
>
|
||||
<ArrowDownToLine className="h-3.5 w-3.5" />
|
||||
<span>{t('scrollToLatest')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssistantRunGroup({
|
||||
group,
|
||||
onOpenFile,
|
||||
}: {
|
||||
group: AssistantRunGroupItem;
|
||||
onOpenFile?: (file: AttachedFileMeta) => void;
|
||||
}) {
|
||||
const thinkingItems = group.processItems.filter(
|
||||
(item): item is Extract<AssistantRunProcessItem, { kind: 'thinking' }> => item.kind === 'thinking',
|
||||
);
|
||||
const streamItems = group.processItems.filter(
|
||||
(item): item is Extract<AssistantRunProcessItem, { kind: 'stream' }> => item.kind === 'stream',
|
||||
);
|
||||
const toolItems = group.processItems.filter(
|
||||
(item): item is Extract<AssistantRunProcessItem, { kind: 'tool' }> => item.kind === 'tool',
|
||||
);
|
||||
const commandItems = group.processItems.filter(
|
||||
(item): item is Extract<AssistantRunProcessItem, { kind: 'command' }> => item.kind === 'command',
|
||||
);
|
||||
const hasProcessBlocks = group.processItems.some((item) => (
|
||||
item.kind === 'tool' || item.kind === 'command' || item.kind === 'patch'
|
||||
));
|
||||
const followupMessages = group.followupMessages ?? [];
|
||||
const hasFollowupMessages = followupMessages.length > 0;
|
||||
const shouldRenderOrderedContent = hasProcessBlocks || hasFollowupMessages;
|
||||
const streamText = shouldRenderOrderedContent ? '' : joinProcessText(streamItems.map((item) => item.text));
|
||||
const thinkingText = joinProcessText(thinkingItems.map((item) => item.text));
|
||||
const lastStream = streamItems.at(-1);
|
||||
const mediaUrls = streamItems.flatMap((item) => item.mediaUrls ?? []);
|
||||
const thinkingCompleted = hasFollowupMessages || group.processItems.some((item) => item.kind !== 'thinking');
|
||||
const copyText = assistantRunCopyText(group);
|
||||
const afterContent = (
|
||||
shouldRenderOrderedContent
|
||||
? (
|
||||
<div className="space-y-2" data-testid="chat-assistant-run-process">
|
||||
{group.processItems.map((item) => {
|
||||
if (item.kind === 'thinking') {
|
||||
return (
|
||||
<ThinkingBlock
|
||||
key={item.id}
|
||||
text={item.text}
|
||||
completed={thinkingCompleted}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (item.kind === 'stream') {
|
||||
return (
|
||||
<EmbeddedStreamMessage
|
||||
key={item.id}
|
||||
item={item}
|
||||
suppressAssistantActions
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (item.kind === 'tool') {
|
||||
const command = commandForTool(item, toolItems, commandItems)?.command;
|
||||
const commandFinished = isTerminalCommand(command);
|
||||
return (
|
||||
<ToolCard
|
||||
key={item.id}
|
||||
card={item.tool}
|
||||
command={command}
|
||||
defaultOpen={Boolean(command && !commandFinished)}
|
||||
autoExpandWhen={Boolean(command && !commandFinished)}
|
||||
autoCollapseWhen={Boolean(command && commandFinished)}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (item.kind === 'command') {
|
||||
if (commandIsMergedIntoTool(item, toolItems, commandItems)) return null;
|
||||
return <CommandCard key={item.id} command={item.command} />;
|
||||
}
|
||||
return <PatchCard key={item.id} patch={item.patch} />;
|
||||
})}
|
||||
{assistantHistoryTurnParts(followupMessages).map((part) => (
|
||||
<AssistantHistoryTurnPart
|
||||
key={part.item.id}
|
||||
part={part}
|
||||
suppressAssistantActions
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
);
|
||||
|
||||
return (
|
||||
<StreamingGroup
|
||||
text={streamText}
|
||||
phase={lastStream?.phase ?? 'legacy'}
|
||||
mediaUrls={mediaUrls}
|
||||
thinkingText={!shouldRenderOrderedContent ? thinkingText || undefined : undefined}
|
||||
thinkingCompleted={thinkingCompleted}
|
||||
assistantCopyText={copyText || undefined}
|
||||
afterContent={afterContent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EmbeddedStreamMessage({
|
||||
item,
|
||||
suppressAssistantActions = false,
|
||||
onOpenFile,
|
||||
}: {
|
||||
item: Extract<AssistantRunProcessItem, { kind: 'stream' }>;
|
||||
suppressAssistantActions?: boolean;
|
||||
onOpenFile?: (file: AttachedFileMeta) => void;
|
||||
}) {
|
||||
const content = [{ type: 'text', text: item.text }];
|
||||
return (
|
||||
<ChatMessage
|
||||
message={{
|
||||
role: 'assistant',
|
||||
content,
|
||||
}}
|
||||
textOverride={item.text}
|
||||
suppressToolCards
|
||||
isStreaming
|
||||
hideAssistantAvatar
|
||||
suppressAssistantActions={suppressAssistantActions}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AssistantHistoryTurn({
|
||||
group,
|
||||
index,
|
||||
onOpenFile,
|
||||
}: {
|
||||
group: AssistantHistoryTurnItem;
|
||||
index: number;
|
||||
onOpenFile?: (file: AttachedFileMeta) => void;
|
||||
}) {
|
||||
const parts = assistantHistoryTurnParts(group.messages);
|
||||
const copyText = assistantHistoryTurnCopyTextFromParts(parts);
|
||||
const thinkingContent = group.thinkingText?.trim() ? (
|
||||
<ThinkingBlock text={group.thinkingText} completed />
|
||||
) : undefined;
|
||||
const content = (
|
||||
<div className="space-y-2" data-testid="chat-assistant-history-turn-content">
|
||||
{parts.map((part) => (
|
||||
<AssistantHistoryTurnPart
|
||||
key={part.item.id}
|
||||
part={part}
|
||||
suppressAssistantActions
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<article
|
||||
id={`chat-message-${index}`}
|
||||
className="flex justify-start"
|
||||
data-testid={`chat-message-${index}`}
|
||||
data-message-role="assistant"
|
||||
>
|
||||
<div className="w-full min-w-0 text-sm text-foreground">
|
||||
<ChatMessage
|
||||
message={{ role: 'assistant', content: '' }}
|
||||
textOverride=""
|
||||
suppressToolCards
|
||||
assistantCopyText={copyText || undefined}
|
||||
assistantBeforeContent={thinkingContent}
|
||||
assistantAfterContent={content}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeAssistantTextKey(text: string): string {
|
||||
return text.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function assistantHistoryTurnParts(messages: MessageChatItem[]): AssistantHistoryTurnPartModel[] {
|
||||
const seenText = new Set<string>();
|
||||
return messages.map((item) => {
|
||||
const text = assistantMessageText(item.message);
|
||||
const key = normalizeAssistantTextKey(text);
|
||||
const suppressText = Boolean(key && seenText.has(key));
|
||||
if (key && !suppressText) seenText.add(key);
|
||||
return { item, suppressText };
|
||||
});
|
||||
}
|
||||
|
||||
function assistantHistoryTurnCopyTextFromParts(parts: AssistantHistoryTurnPartModel[]): string {
|
||||
return joinProcessText(parts.flatMap((part) => (
|
||||
part.suppressText ? [] : [assistantMessageText(part.item.message)]
|
||||
)));
|
||||
}
|
||||
|
||||
function AssistantHistoryTurnPart({
|
||||
part,
|
||||
suppressAssistantActions = false,
|
||||
onOpenFile,
|
||||
}: {
|
||||
part: AssistantHistoryTurnPartModel;
|
||||
suppressAssistantActions?: boolean;
|
||||
onOpenFile?: (file: AttachedFileMeta) => void;
|
||||
}) {
|
||||
const item = part.item;
|
||||
const message = item.message;
|
||||
const toolCards = extractToolCardsCached(message, String(message.id ?? item.id));
|
||||
const text = assistantMessageText(message);
|
||||
|
||||
if (isToolResultLikeMessage(message) && toolCards.length === 0) return null;
|
||||
|
||||
if (toolCards.length > 0) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{text.trim() && !part.suppressText && !isToolResultLikeMessage(message) ? (
|
||||
<ChatMessage
|
||||
message={{
|
||||
...(message as Record<string, unknown>),
|
||||
role: 'assistant',
|
||||
content: message.content ?? text,
|
||||
}}
|
||||
textOverride={text}
|
||||
suppressToolCards
|
||||
hideAssistantAvatar
|
||||
suppressAssistantActions={suppressAssistantActions}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
) : null}
|
||||
<div className="space-y-2" data-testid="chat-tool-card-group">
|
||||
{toolCards.map((card) => (
|
||||
<ToolCard key={card.id} card={card} onOpenFile={onOpenFile} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!text.trim() || part.suppressText) return null;
|
||||
return (
|
||||
<ChatMessage
|
||||
message={{
|
||||
...(message as Record<string, unknown>),
|
||||
role: 'assistant',
|
||||
content: message.content ?? text,
|
||||
}}
|
||||
textOverride={text}
|
||||
suppressToolCards
|
||||
hideAssistantAvatar
|
||||
suppressAssistantActions={suppressAssistantActions}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { FileDiff } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { PatchSummaryEntry } from '@/chat-core/openclaw-port/types';
|
||||
|
||||
function uniquePaths(patch: PatchSummaryEntry): string[] {
|
||||
return Array.from(new Set([...(patch.filePaths ?? []), ...(patch.files ?? [])].filter(Boolean)));
|
||||
}
|
||||
|
||||
function countValue(value: number | undefined): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
export function PatchCard({ patch }: { patch: PatchSummaryEntry }) {
|
||||
const { t } = useTranslation('chat');
|
||||
const paths = uniquePaths(patch);
|
||||
const fileCount = countValue(patch.fileCount) ?? paths.length;
|
||||
const added = countValue(patch.added);
|
||||
const modified = countValue(patch.modified);
|
||||
const deleted = countValue(patch.deleted);
|
||||
const summary = patch.summary?.trim() || patch.title?.trim() || t('patchCard.title');
|
||||
|
||||
return (
|
||||
<section
|
||||
className="w-[50vw] max-w-full rounded-md border border-border bg-surface-input text-sm"
|
||||
data-testid="chat-patch-card"
|
||||
>
|
||||
<div className="flex items-start gap-2 px-3 py-2">
|
||||
<FileDiff className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="space-y-1">
|
||||
<h3 className="break-words text-xs font-medium text-foreground">{summary}</h3>
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-2xs text-muted-foreground">
|
||||
<span className="rounded bg-black/5 px-1.5 py-0.5 dark:bg-white/10">
|
||||
{t('patchCard.files', { count: fileCount })}
|
||||
</span>
|
||||
{added != null ? <span className="text-green-700 dark:text-green-400">+{added}</span> : null}
|
||||
{modified != null ? <span>{t('patchCard.modified', { count: modified })}</span> : null}
|
||||
{deleted != null ? <span className="text-red-700 dark:text-red-400">-{deleted}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
{paths.length > 0 ? (
|
||||
<ul className="space-y-1 text-xs text-muted-foreground">
|
||||
{paths.slice(0, 4).map((path) => (
|
||||
<li key={path} className="truncate font-mono" title={path}>{path}</li>
|
||||
))}
|
||||
{paths.length > 4 ? (
|
||||
<li className="text-2xs">{t('patchCard.moreFiles', { count: paths.length - 4 })}</li>
|
||||
) : null}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ChatRunUiStatus } from '@/chat-core/openclaw-port/types';
|
||||
|
||||
export function RunStatusBar({ status }: { status: ChatRunUiStatus }) {
|
||||
const { t } = useTranslation('chat');
|
||||
const label = t(`runStatus.${status.phase}`);
|
||||
return (
|
||||
<div
|
||||
className="rounded-md bg-surface-input px-3 py-2 text-xs text-muted-foreground"
|
||||
data-testid="chat-run-status"
|
||||
>
|
||||
{label}
|
||||
{status.message ? `: ${status.message}` : ''}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { CircleAlert, RotateCw } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { RuntimeIndicatorStatus } from '@/chat-core/openclaw-port/types';
|
||||
|
||||
export function RuntimeIndicator({ status }: { status: RuntimeIndicatorStatus }) {
|
||||
const { t } = useTranslation('chat');
|
||||
const label = t(`runtime.${status.kind}.${status.phase}`);
|
||||
const isError = status.phase === 'error';
|
||||
const Icon = isError ? CircleAlert : RotateCw;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-md border border-border bg-surface-input px-3 py-2 text-xs text-muted-foreground"
|
||||
data-testid="chat-runtime-indicator"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<Icon className={isError ? 'mt-0.5 h-3.5 w-3.5 text-red-700 dark:text-red-400' : 'mt-0.5 h-3.5 w-3.5'} />
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-foreground">{label}</div>
|
||||
{status.message ? <div className="mt-1 break-words">{status.message}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { stripInlineDirectiveTagsForDisplay } from '@/chat-core/openclaw-port/history';
|
||||
import type { AssistantStreamPhase } from '@/chat-core/openclaw-port/types';
|
||||
import type { AttachedFileMeta, RawMessage } from '@/stores/chat';
|
||||
import { ChatMessage } from './ChatMessage';
|
||||
import { ThinkingBlock } from './ThinkingBlock';
|
||||
|
||||
function mediaBlocks(mediaUrls: string[] | undefined): Array<Record<string, unknown>> {
|
||||
if (!mediaUrls?.length) return [];
|
||||
return mediaUrls
|
||||
.filter((url) => /^https?:\/\//i.test(url) || url.startsWith('data:'))
|
||||
.map((url) => ({
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'url',
|
||||
url,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function safeDecodeURIComponent(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function pathWithoutUrlSuffix(value: string): string {
|
||||
return value.split(/[?#]/, 1)[0] ?? value;
|
||||
}
|
||||
|
||||
function filePathFromFileUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== 'file:') return url;
|
||||
const hostPrefix = parsed.hostname && parsed.hostname !== 'localhost'
|
||||
? `//${parsed.hostname}`
|
||||
: '';
|
||||
const decodedPath = safeDecodeURIComponent(parsed.pathname);
|
||||
if (/^\/[A-Za-z]:/.test(decodedPath)) return decodedPath.slice(1);
|
||||
return `${hostPrefix}${decodedPath}`;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function fileNameFromMediaPath(pathOrUrl: string): string {
|
||||
const path = pathWithoutUrlSuffix(pathOrUrl);
|
||||
const lastSegment = safeDecodeURIComponent(path.split(/[\\/]/).filter(Boolean).at(-1) ?? '');
|
||||
return lastSegment.includes('.') ? lastSegment : 'image';
|
||||
}
|
||||
|
||||
function mimeFromMediaPath(pathOrUrl: string): string {
|
||||
const lower = pathWithoutUrlSuffix(pathOrUrl).toLowerCase();
|
||||
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
|
||||
if (lower.endsWith('.gif')) return 'image/gif';
|
||||
if (lower.endsWith('.webp')) return 'image/webp';
|
||||
if (lower.endsWith('.bmp')) return 'image/bmp';
|
||||
if (lower.endsWith('.avif')) return 'image/avif';
|
||||
if (lower.endsWith('.svg')) return 'image/svg+xml';
|
||||
if (lower.endsWith('.pdf')) return 'application/pdf';
|
||||
return 'image/png';
|
||||
}
|
||||
|
||||
function attachedFilesFromMediaUrls(mediaUrls: string[] | undefined): AttachedFileMeta[] {
|
||||
if (!mediaUrls?.length) return [];
|
||||
return mediaUrls.flatMap((url): AttachedFileMeta[] => {
|
||||
if (!url.trim()) return [];
|
||||
if (url.startsWith('/api/chat/media/')) {
|
||||
return [{
|
||||
fileName: fileNameFromMediaPath(url),
|
||||
mimeType: mimeFromMediaPath(url),
|
||||
fileSize: 0,
|
||||
preview: null,
|
||||
gatewayUrl: url,
|
||||
source: 'gateway-media' as const,
|
||||
}];
|
||||
}
|
||||
if (/^https?:\/\//i.test(url) || url.startsWith('data:')) return [];
|
||||
const filePath = /^file:\/\//i.test(url) ? filePathFromFileUrl(url) : url;
|
||||
return [{
|
||||
fileName: fileNameFromMediaPath(filePath),
|
||||
mimeType: mimeFromMediaPath(filePath),
|
||||
fileSize: 0,
|
||||
preview: null,
|
||||
filePath,
|
||||
source: 'message-ref' as const,
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
export function StreamingGroup({
|
||||
text,
|
||||
phase,
|
||||
mediaUrls,
|
||||
thinkingText,
|
||||
thinkingCompleted = false,
|
||||
assistantCopyText,
|
||||
afterContent,
|
||||
}: {
|
||||
text: string;
|
||||
phase: AssistantStreamPhase;
|
||||
mediaUrls?: string[];
|
||||
thinkingText?: string;
|
||||
thinkingCompleted?: boolean;
|
||||
assistantCopyText?: string;
|
||||
afterContent?: ReactNode;
|
||||
}) {
|
||||
const displayText = stripInlineDirectiveTagsForDisplay(text);
|
||||
const content = [
|
||||
{ type: 'text', text: displayText },
|
||||
...mediaBlocks(mediaUrls),
|
||||
];
|
||||
const message = {
|
||||
role: 'assistant',
|
||||
content,
|
||||
streamPhase: phase,
|
||||
mediaUrls,
|
||||
_attachedFiles: attachedFilesFromMediaUrls(mediaUrls),
|
||||
} as RawMessage;
|
||||
return (
|
||||
<article className="flex justify-start" data-testid="chat-streaming-group">
|
||||
<div className="w-full min-w-0 text-sm text-foreground">
|
||||
<ChatMessage
|
||||
message={message}
|
||||
textOverride={displayText}
|
||||
suppressToolCards
|
||||
isStreaming
|
||||
assistantBeforeContent={
|
||||
thinkingText
|
||||
? (
|
||||
<ThinkingBlock
|
||||
key={thinkingCompleted ? 'completed-thinking' : 'active-thinking'}
|
||||
text={thinkingText}
|
||||
completed={thinkingCompleted}
|
||||
/>
|
||||
)
|
||||
: null
|
||||
}
|
||||
assistantAfterContent={afterContent}
|
||||
assistantCopyText={assistantCopyText}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useState } from 'react';
|
||||
import { Brain, ChevronDown } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function ThinkingBlock({ text, completed = false }: { text: string; completed?: boolean }) {
|
||||
const { t } = useTranslation('chat');
|
||||
const [open, setOpen] = useState(!completed);
|
||||
const trimmedText = text.trim();
|
||||
|
||||
if (!trimmedText) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
className="w-full max-w-full rounded-md border border-border/70 bg-black/[0.02] text-sm text-muted-foreground dark:bg-white/[0.03]"
|
||||
data-testid="chat-thinking-block"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Brain className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate text-xs font-medium">
|
||||
{completed ? t('thinkingBlock.completedTitle') : t('thinkingBlock.title')}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn('h-4 w-4 shrink-0 transition-transform', open && 'rotate-180')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="border-t border-border px-3 py-2">
|
||||
<p className="whitespace-pre-wrap break-words text-xs leading-5">{trimmedText}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useState } from 'react';
|
||||
import { Wrench } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { isToolCardError, type ToolCard as ToolCardModel } from '@/chat-core/openclaw-port/tool-cards';
|
||||
import type { CommandOutputEntry } from '@/chat-core/openclaw-port/types';
|
||||
import type { AttachedFileMeta } from '@/stores/chat';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CommandDetails } from './CommandCard';
|
||||
|
||||
const COMMAND_TOOL_NAMES = new Set(['exec', 'shell', 'bash', 'sh', 'terminal', 'command']);
|
||||
|
||||
function parseToolInput(inputText: string | undefined): unknown {
|
||||
const trimmed = inputText?.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (!trimmed.startsWith('{') && !trimmed.startsWith('[') && !trimmed.startsWith('"')) return trimmed;
|
||||
try {
|
||||
return JSON.parse(trimmed) as unknown;
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
function readStringField(record: Record<string, unknown>, keys: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||
if (Array.isArray(value)) {
|
||||
const first = value.find((entry) => typeof entry === 'string' && entry.trim());
|
||||
if (typeof first === 'string') return first.trim();
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function commandFromToolInput(inputText: string | undefined): string | undefined {
|
||||
const input = parseToolInput(inputText);
|
||||
if (typeof input === 'string') return input.trim() || undefined;
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) return undefined;
|
||||
return readStringField(input as Record<string, unknown>, ['command', 'cmd', 'script']);
|
||||
}
|
||||
|
||||
function isCommandTool(toolName: string | undefined): boolean {
|
||||
const normalized = toolName?.trim().toLowerCase();
|
||||
return normalized ? COMMAND_TOOL_NAMES.has(normalized) : false;
|
||||
}
|
||||
|
||||
function fileNameFromPath(filePath: string): string {
|
||||
return filePath.split(/[\\/]/).filter(Boolean).at(-1) || filePath;
|
||||
}
|
||||
|
||||
function mimeFromPath(filePath: string): string {
|
||||
const lower = filePath.toLowerCase();
|
||||
if (lower.endsWith('.md') || lower.endsWith('.markdown')) return 'text/markdown';
|
||||
if (lower.endsWith('.txt')) return 'text/plain';
|
||||
if (lower.endsWith('.json')) return 'application/json';
|
||||
if (lower.endsWith('.html') || lower.endsWith('.htm')) return 'text/html';
|
||||
if (lower.endsWith('.css')) return 'text/css';
|
||||
if (lower.endsWith('.png')) return 'image/png';
|
||||
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
|
||||
if (lower.endsWith('.gif')) return 'image/gif';
|
||||
if (lower.endsWith('.webp')) return 'image/webp';
|
||||
if (lower.endsWith('.pdf')) return 'application/pdf';
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
function previewFileFromToolCard(card: ToolCardModel): AttachedFileMeta | null {
|
||||
const input = parseToolInput(card.inputText);
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) return null;
|
||||
const filePath = readStringField(input as Record<string, unknown>, [
|
||||
'path',
|
||||
'filePath',
|
||||
'file_path',
|
||||
'targetPath',
|
||||
'target_path',
|
||||
]);
|
||||
if (!filePath) return null;
|
||||
return {
|
||||
fileName: fileNameFromPath(filePath),
|
||||
filePath,
|
||||
mimeType: mimeFromPath(filePath),
|
||||
fileSize: 0,
|
||||
preview: null,
|
||||
source: 'tool-result',
|
||||
};
|
||||
}
|
||||
|
||||
function commandDetailsFromToolCard(card: ToolCardModel, command: CommandOutputEntry | undefined): CommandOutputEntry | null {
|
||||
if (command) {
|
||||
return {
|
||||
...command,
|
||||
output: command.output ?? card.outputText,
|
||||
};
|
||||
}
|
||||
|
||||
const commandText = commandFromToolInput(card.inputText);
|
||||
if (!commandText && !isCommandTool(card.toolName)) return null;
|
||||
if (!commandText && !card.outputText) return null;
|
||||
return {
|
||||
id: `${card.id}:command-details`,
|
||||
runId: card.transcriptMessageId ?? card.id,
|
||||
title: commandText ? `command ${commandText}` : card.toolName ?? 'command',
|
||||
output: card.outputText,
|
||||
ts: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function ToolCard({
|
||||
card,
|
||||
command,
|
||||
defaultOpen = false,
|
||||
autoExpandWhen = false,
|
||||
autoCollapseWhen = false,
|
||||
onOpenFile,
|
||||
}: {
|
||||
card: ToolCardModel;
|
||||
command?: CommandOutputEntry;
|
||||
defaultOpen?: boolean;
|
||||
autoExpandWhen?: boolean;
|
||||
autoCollapseWhen?: boolean;
|
||||
onOpenFile?: (file: AttachedFileMeta) => void;
|
||||
}) {
|
||||
const { t } = useTranslation('chat');
|
||||
const [manualOpen, setManualOpen] = useState<{ value: boolean; collapseState: boolean } | null>(null);
|
||||
const isError = isToolCardError(card);
|
||||
const toolName = card.toolName ?? 'tool';
|
||||
const title = t('toolCard.calling', { tool: toolName });
|
||||
const commandDetails = commandDetailsFromToolCard(card, command);
|
||||
const previewFile = previewFileFromToolCard(card);
|
||||
const autoOpen = autoCollapseWhen ? false : autoExpandWhen || defaultOpen;
|
||||
const open = manualOpen?.collapseState === autoCollapseWhen ? manualOpen.value : autoOpen;
|
||||
|
||||
const toggleOpen = () => {
|
||||
setManualOpen({ value: !open, collapseState: autoCollapseWhen });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-[50vw] max-w-[calc(100vw-8rem)] rounded-md border bg-surface-input text-sm',
|
||||
isError ? 'border-destructive/40' : 'border-border',
|
||||
)}
|
||||
data-testid="chat-tool-card"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between px-3 py-2 text-left"
|
||||
onClick={toggleOpen}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Wrench className="h-3.5 w-3.5 shrink-0 text-muted-foreground" data-testid="chat-tool-card-icon" />
|
||||
<span className="truncate text-xs font-medium">{title}</span>
|
||||
{isError ? (
|
||||
<span className="rounded bg-destructive/10 px-1.5 py-0.5 text-2xs font-medium text-destructive">
|
||||
{t('toolCard.error')}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{open ? t('toolCard.hide') : t('toolCard.show')}
|
||||
</span>
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="space-y-2 border-t border-border px-3 py-2">
|
||||
{previewFile && onOpenFile ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-border px-2 py-1 text-xs font-medium text-foreground/80 transition-colors hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10"
|
||||
data-testid="chat-tool-card-preview"
|
||||
onClick={() => onOpenFile(previewFile)}
|
||||
>
|
||||
{t('toolCard.preview')}
|
||||
</button>
|
||||
) : null}
|
||||
{commandDetails ? (
|
||||
<CommandDetails command={commandDetails} />
|
||||
) : (
|
||||
<>
|
||||
{card.inputText ? <pre className="whitespace-pre-wrap text-xs">{card.inputText}</pre> : null}
|
||||
{card.outputText ? (
|
||||
<pre className="max-h-48 overflow-auto whitespace-pre-wrap text-xs">
|
||||
{card.outputText}
|
||||
</pre>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+396
-1127
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,13 @@
|
||||
*/
|
||||
import type { RawMessage, ContentBlock } from '@/stores/chat';
|
||||
|
||||
export function chatMessageAnchorId(messageId: string | number | null | undefined): string | undefined {
|
||||
if (messageId == null) return undefined;
|
||||
const rawId = String(messageId).trim();
|
||||
if (!rawId) return undefined;
|
||||
return `chat-message-anchor-${encodeURIComponent(rawId)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip OpenClaw's inbound-image vision envelope from user message text.
|
||||
* The runtime expands uploaded images into:
|
||||
@@ -39,7 +46,7 @@ function cleanUserText(text: string): string {
|
||||
return stripInboundMediaVisionEnvelope(
|
||||
text
|
||||
// Remove [media attached: path (mime) | path] references
|
||||
.replace(/\s*\[media attached:[^\]]*\]/g, '')
|
||||
.replace(/\s*\[media attached:[^\]]*\]/gi, '')
|
||||
// Remove [message_id: uuid]
|
||||
.replace(/\s*\[message_id:\s*[^\]]+\]/g, '')
|
||||
// Remove Gateway-injected sender metadata block. Some transcripts use
|
||||
@@ -60,6 +67,22 @@ function cleanUserText(text: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
function mimeFromPath(filePath: string): string {
|
||||
const lower = filePath.toLowerCase();
|
||||
if (lower.endsWith('.png')) return 'image/png';
|
||||
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
|
||||
if (lower.endsWith('.gif')) return 'image/gif';
|
||||
if (lower.endsWith('.webp')) return 'image/webp';
|
||||
if (lower.endsWith('.bmp')) return 'image/bmp';
|
||||
if (lower.endsWith('.avif')) return 'image/avif';
|
||||
if (lower.endsWith('.svg')) return 'image/svg+xml';
|
||||
if (lower.endsWith('.pdf')) return 'application/pdf';
|
||||
if (lower.endsWith('.md') || lower.endsWith('.markdown')) return 'text/markdown';
|
||||
if (lower.endsWith('.txt')) return 'text/plain';
|
||||
if (lower.endsWith('.csv')) return 'text/csv';
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip `MEDIA:/path/to/file.ext` / `MEDIA:C:\path\file.ext` artifact markers from assistant text so
|
||||
* the chat bubble doesn't duplicate the file already surfaced as a card.
|
||||
@@ -441,10 +464,13 @@ export function extractMediaRefs(message: RawMessage | unknown): Array<{ filePat
|
||||
}
|
||||
|
||||
const refs: Array<{ filePath: string; mimeType: string }> = [];
|
||||
const regex = /\[media attached:\s*([^\s(]+)\s*\(([^)]+)\)\s*\|[^\]]*\]/g;
|
||||
const regex = /\[media attached:\s*([^\]|()]*?)(?:\s*\(([^)]+)\))?(?:\s*\|\s*([^\]]+))?\]/gi;
|
||||
let match;
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
refs.push({ filePath: match[1], mimeType: match[2] });
|
||||
const filePath = (match[3] || match[1] || '').trim();
|
||||
const mimeType = (match[2] || mimeFromPath(filePath)).trim();
|
||||
if (!filePath || /^media:\/\//i.test(filePath)) continue;
|
||||
refs.push({ filePath, mimeType });
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
@@ -569,10 +595,20 @@ export function formatTimestamp(timestamp: unknown): string {
|
||||
const date = new Date(ms);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const locale = globalThis.navigator?.languages?.[0] || globalThis.navigator?.language || undefined;
|
||||
const isChinese = typeof locale === 'string' && /^zh\b/i.test(locale);
|
||||
const formatRelative = (value: number, unit: Intl.RelativeTimeFormatUnit): string => {
|
||||
if (isChinese && typeof Intl.RelativeTimeFormat === 'function') {
|
||||
return new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(-value, unit);
|
||||
}
|
||||
if (unit === 'minute') return `${value}m ago`;
|
||||
if (unit === 'hour') return `${value}h ago`;
|
||||
return `${value}d ago`;
|
||||
};
|
||||
|
||||
if (diffMs < 60000) return 'just now';
|
||||
if (diffMs < 3600000) return `${Math.floor(diffMs / 60000)}m ago`;
|
||||
if (diffMs < 86400000) return `${Math.floor(diffMs / 3600000)}h ago`;
|
||||
if (diffMs < 60000) return isChinese ? '刚刚' : 'just now';
|
||||
if (diffMs < 3600000) return formatRelative(Math.floor(diffMs / 60000), 'minute');
|
||||
if (diffMs < 86400000) return formatRelative(Math.floor(diffMs / 3600000), 'hour');
|
||||
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
return date.toLocaleTimeString(locale ? [locale] : [], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
+55
-7
@@ -12,6 +12,7 @@ import { buildBaselineRunKey, captureBaseline, clearBaselines } from './baseline
|
||||
import { isCronSessionKey, sessionKeysAreEquivalent } from './chat/cron-session-utils';
|
||||
import { fetchCronSessionHistory } from '@/lib/cron-session-history';
|
||||
import { pickStartupSessionFallback } from './chat/session-selection';
|
||||
import { readLastChatSessionKey, writeLastChatSessionKey } from './chat/session-persistence';
|
||||
import {
|
||||
CHAT_HISTORY_DISK_FALLBACK_TIMEOUT_MS,
|
||||
CHAT_HISTORY_STARTUP_FALLBACK_RACE_MS,
|
||||
@@ -120,6 +121,8 @@ const DEFAULT_SESSION_RUN_STATE: SessionRunState = {
|
||||
const _sessionRunStateCache = new Map<string, SessionRunState>();
|
||||
let _sendGenerationCounter = 0;
|
||||
const _activeSendGenerationBySession = new Map<string, number>();
|
||||
const _pendingSendRunIdBySession = new Map<string, string>();
|
||||
let _lastAbortedRunId: string | null = null;
|
||||
const SESSION_LOAD_MIN_INTERVAL_MS = 1_200;
|
||||
const HISTORY_LOAD_MIN_INTERVAL_MS = 800;
|
||||
const CHAT_EVENT_DEDUPE_TTL_MS = 30_000;
|
||||
@@ -1830,12 +1833,16 @@ async function sendChatMessageViaHostApi(params: {
|
||||
message: string;
|
||||
deliver?: boolean;
|
||||
idempotencyKey: string;
|
||||
thinking?: string;
|
||||
}): Promise<{ runId?: string }> {
|
||||
return useGatewayStore.getState().rpc<{ runId?: string }>('chat.send', params, 120000);
|
||||
}
|
||||
|
||||
async function abortChatRunViaHostApi(sessionKey: string): Promise<void> {
|
||||
await useGatewayStore.getState().rpc('chat.abort', { sessionKey });
|
||||
async function abortChatRunViaHostApi(sessionKey: string, runId?: string | null): Promise<void> {
|
||||
await useGatewayStore.getState().rpc('chat.abort', {
|
||||
sessionKey,
|
||||
...(runId ? { runId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeAgentId(value: string | undefined | null): string {
|
||||
@@ -2664,7 +2671,10 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
});
|
||||
|
||||
const { currentSessionKey, sessions: localSessions } = get();
|
||||
let nextSessionKey = currentSessionKey || DEFAULT_SESSION_KEY;
|
||||
const persistedSessionKey = currentSessionKey === DEFAULT_SESSION_KEY
|
||||
? readLastChatSessionKey()
|
||||
: null;
|
||||
let nextSessionKey = persistedSessionKey || currentSessionKey || DEFAULT_SESSION_KEY;
|
||||
if (!nextSessionKey.startsWith('agent:')) {
|
||||
const canonicalMatch = canonicalBySuffix.get(nextSessionKey);
|
||||
if (canonicalMatch) {
|
||||
@@ -2690,6 +2700,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
]
|
||||
: dedupedSessions;
|
||||
|
||||
writeLastChatSessionKey(nextSessionKey);
|
||||
|
||||
const discoveredActivity = Object.fromEntries(
|
||||
sessionsWithCurrent
|
||||
.filter((session) => typeof session.updatedAt === 'number' && Number.isFinite(session.updatedAt))
|
||||
@@ -2806,6 +2818,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
// ── Switch session ──
|
||||
|
||||
switchSession: (key: string) => {
|
||||
writeLastChatSessionKey(key);
|
||||
if (key === get().currentSessionKey) return;
|
||||
// Stop any background polling for the old session before switching.
|
||||
// This prevents the poll timer from firing after the switch and loading
|
||||
@@ -2849,6 +2862,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
if (currentSessionKey === key) {
|
||||
// Switched away from deleted session — pick the first remaining or create new
|
||||
const next = remaining[0];
|
||||
const nextSessionKey = next?.key ?? DEFAULT_SESSION_KEY;
|
||||
writeLastChatSessionKey(nextSessionKey);
|
||||
set((s) => ({
|
||||
sessions: remaining,
|
||||
sessionLabels: Object.fromEntries(Object.entries(s.sessionLabels).filter(([k]) => k !== key)),
|
||||
@@ -2863,8 +2878,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
pendingFinal: false,
|
||||
lastUserMessageAt: null,
|
||||
pendingToolImages: [],
|
||||
currentSessionKey: next?.key ?? DEFAULT_SESSION_KEY,
|
||||
currentAgentId: getAgentIdFromSessionKey(next?.key ?? DEFAULT_SESSION_KEY),
|
||||
currentSessionKey: nextSessionKey,
|
||||
currentAgentId: getAgentIdFromSessionKey(nextSessionKey),
|
||||
}));
|
||||
if (next) {
|
||||
get().loadHistory();
|
||||
@@ -2897,6 +2912,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
// shows only the local transcript snapshot and loses the live execution UI.
|
||||
clearHistoryPoll();
|
||||
clearBaselines();
|
||||
writeLastChatSessionKey(newKey);
|
||||
set((s) => buildSessionSwitchPatch(s, newKey));
|
||||
},
|
||||
|
||||
@@ -3640,6 +3656,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
}
|
||||
|
||||
if (targetSessionKey !== get().currentSessionKey) {
|
||||
writeLastChatSessionKey(targetSessionKey);
|
||||
set((s) => buildSessionSwitchPatch(s, targetSessionKey));
|
||||
await get().loadHistory(true);
|
||||
}
|
||||
@@ -3784,6 +3801,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
const clearSendGenerationIfCurrent = () => {
|
||||
if (_activeSendGenerationBySession.get(currentSessionKey) === sendGeneration) {
|
||||
_activeSendGenerationBySession.delete(currentSessionKey);
|
||||
_pendingSendRunIdBySession.delete(currentSessionKey);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3817,6 +3835,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
|
||||
try {
|
||||
const idempotencyKey = crypto.randomUUID();
|
||||
_pendingSendRunIdBySession.set(currentSessionKey, idempotencyKey);
|
||||
const hasMedia = attachments && attachments.length > 0;
|
||||
if (hasMedia) {
|
||||
console.log('[sendMessage] Media paths:', attachments!.map(a => a.stagedPath));
|
||||
@@ -3838,6 +3857,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
}
|
||||
|
||||
let result: ChatSendWithMediaResult;
|
||||
const thinking = get().thinkingLevel?.trim() || undefined;
|
||||
|
||||
if (hasMedia) {
|
||||
result = await hostApi.chat.sendWithMedia({
|
||||
@@ -3845,6 +3865,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
message: trimmed || 'Process the attached file(s).',
|
||||
deliver: false,
|
||||
idempotencyKey,
|
||||
...(thinking ? { thinking } : {}),
|
||||
media: attachments.map((a) => ({
|
||||
filePath: a.stagedPath,
|
||||
mimeType: a.mimeType,
|
||||
@@ -3857,6 +3878,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
message: trimmed,
|
||||
deliver: false,
|
||||
idempotencyKey,
|
||||
...(thinking ? { thinking } : {}),
|
||||
});
|
||||
result = { success: true, result: rpcResult };
|
||||
}
|
||||
@@ -3881,12 +3903,18 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
|
||||
if (sendStillCurrent && canAttachToCurrentSession) {
|
||||
set({ activeRunId: returnedRunId });
|
||||
if (_pendingSendRunIdBySession.get(currentSessionKey) === idempotencyKey) {
|
||||
_pendingSendRunIdBySession.delete(currentSessionKey);
|
||||
}
|
||||
} else if (sendStillCurrent && latest.currentSessionKey !== currentSessionKey) {
|
||||
const cached = _sessionRunStateCache.get(currentSessionKey);
|
||||
if (cached?.sending
|
||||
&& cached.lastUserMessageAt === nowMs
|
||||
&& (cached.activeRunId == null || cached.activeRunId === returnedRunId)) {
|
||||
captureSessionRunState(currentSessionKey, { ...cached, activeRunId: returnedRunId });
|
||||
if (_pendingSendRunIdBySession.get(currentSessionKey) === idempotencyKey) {
|
||||
_pendingSendRunIdBySession.delete(currentSessionKey);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn('[sendMessage] Ignoring stale chat.send runId', {
|
||||
@@ -3910,12 +3938,16 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
abortRun: async () => {
|
||||
clearHistoryPoll();
|
||||
clearErrorRecoveryTimer();
|
||||
const { currentSessionKey } = get();
|
||||
const { currentSessionKey, activeRunId } = get();
|
||||
const pendingRunId = _pendingSendRunIdBySession.get(currentSessionKey) ?? null;
|
||||
const runId = activeRunId ?? pendingRunId;
|
||||
_pendingSendRunIdBySession.delete(currentSessionKey);
|
||||
_lastAbortedRunId = runId || '*';
|
||||
set({ sending: false, streamingText: '', streamingMessage: null, pendingFinal: false, lastUserMessageAt: null, pendingToolImages: [] });
|
||||
set({ streamingTools: [] });
|
||||
|
||||
try {
|
||||
await abortChatRunViaHostApi(currentSessionKey);
|
||||
await abortChatRunViaHostApi(currentSessionKey, runId);
|
||||
} catch (err) {
|
||||
set({ error: String(err) });
|
||||
}
|
||||
@@ -3939,6 +3971,22 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
return;
|
||||
}
|
||||
|
||||
const matchesAbortedRun = _lastAbortedRunId === '*' || (runId && runId === _lastAbortedRunId);
|
||||
if (matchesAbortedRun) {
|
||||
const messageStopReason = event.message && typeof event.message === 'object'
|
||||
? getMessageStopReason(event.message as Record<string, unknown>)
|
||||
: null;
|
||||
const isAbortConfirmation = eventState === 'aborted'
|
||||
|| eventState === 'cancelled'
|
||||
|| eventState === 'canceled'
|
||||
|| messageStopReason === 'aborted';
|
||||
if (isAbortConfirmation && runId) {
|
||||
_lastAbortedRunId = runId;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Only process events for the active run (or if no active run set).
|
||||
// Inbound channel traffic (Feishu/Telegram/etc.) on the current session uses a
|
||||
// different runId than a stale desktop activeRunId — still refresh history on finals.
|
||||
|
||||
@@ -210,6 +210,7 @@ export function createRuntimeSendActions(set: ChatSet, get: ChatGet): Pick<Runti
|
||||
}
|
||||
|
||||
let result: ChatSendWithMediaResult;
|
||||
const thinking = get().thinkingLevel?.trim() || undefined;
|
||||
|
||||
// Longer timeout for chat sends to tolerate high-latency networks (avoids connect error)
|
||||
const CHAT_SEND_TIMEOUT_MS = 120_000;
|
||||
@@ -220,6 +221,7 @@ export function createRuntimeSendActions(set: ChatSet, get: ChatGet): Pick<Runti
|
||||
message: trimmed || 'Process the attached file(s).',
|
||||
deliver: false,
|
||||
idempotencyKey,
|
||||
...(thinking ? { thinking } : {}),
|
||||
media: attachments.map((a) => ({
|
||||
filePath: a.stagedPath,
|
||||
mimeType: a.mimeType,
|
||||
@@ -234,6 +236,7 @@ export function createRuntimeSendActions(set: ChatSet, get: ChatGet): Pick<Runti
|
||||
message: trimmed,
|
||||
deliver: false,
|
||||
idempotencyKey,
|
||||
...(thinking ? { thinking } : {}),
|
||||
},
|
||||
CHAT_SEND_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { hostApi } from '@/lib/host-api';
|
||||
import { clearPendingOptimisticUserMessages, getCanonicalPrefixFromSessions, getMessageText, toMs } from './helpers';
|
||||
import { pickStartupSessionFallback } from './session-selection';
|
||||
import { readLastChatSessionKey, writeLastChatSessionKey } from './session-persistence';
|
||||
import { toSessionLabel } from './session-label-cleanup';
|
||||
import { DEFAULT_CANONICAL_PREFIX, DEFAULT_SESSION_KEY, type ChatSession, type RawMessage } from './types';
|
||||
import type { ChatGet, ChatSet, SessionHistoryActions } from './store-api';
|
||||
|
||||
@@ -20,12 +22,6 @@ function getAgentIdFromSessionKey(sessionKey: string): string {
|
||||
return agentId || 'main';
|
||||
}
|
||||
|
||||
function toSessionLabel(text: string, maxLength = 50): string {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return '';
|
||||
return trimmed.length > maxLength ? `${trimmed.slice(0, maxLength)}…` : trimmed;
|
||||
}
|
||||
|
||||
function applySessionBackendLabels(set: ChatSet, sessions: ChatSession[]): void {
|
||||
const labels = Object.fromEntries(
|
||||
sessions
|
||||
@@ -154,7 +150,10 @@ export function createSessionActions(
|
||||
});
|
||||
|
||||
const { currentSessionKey } = get();
|
||||
let nextSessionKey = currentSessionKey || DEFAULT_SESSION_KEY;
|
||||
const persistedSessionKey = currentSessionKey === DEFAULT_SESSION_KEY
|
||||
? readLastChatSessionKey()
|
||||
: null;
|
||||
let nextSessionKey = persistedSessionKey || currentSessionKey || DEFAULT_SESSION_KEY;
|
||||
if (!nextSessionKey.startsWith('agent:')) {
|
||||
const canonicalMatch = canonicalBySuffix.get(nextSessionKey);
|
||||
if (canonicalMatch) {
|
||||
@@ -178,6 +177,8 @@ export function createSessionActions(
|
||||
]
|
||||
: dedupedSessions;
|
||||
|
||||
writeLastChatSessionKey(nextSessionKey);
|
||||
|
||||
const discoveredActivity = Object.fromEntries(
|
||||
sessionsWithCurrent
|
||||
.filter((session) => typeof session.updatedAt === 'number' && Number.isFinite(session.updatedAt))
|
||||
@@ -236,18 +237,18 @@ export function createSessionActions(
|
||||
const firstUser = msgs.find((m) => m.role === 'user');
|
||||
const lastMsg = msgs[msgs.length - 1];
|
||||
const labelText = firstUser ? getMessageText(firstUser.content).trim() : '';
|
||||
const label = toSessionLabel(labelText);
|
||||
set((s) => {
|
||||
const next: Partial<typeof s> = {};
|
||||
if (labelText && !s.sessionLabels[session.key]?.trim()) {
|
||||
const truncated = labelText.length > 50 ? `${labelText.slice(0, 50)}…` : labelText;
|
||||
next.sessionLabels = { ...s.sessionLabels, [session.key]: truncated };
|
||||
if (label && !s.sessionLabels[session.key]?.trim()) {
|
||||
next.sessionLabels = { ...s.sessionLabels, [session.key]: label };
|
||||
}
|
||||
if (lastMsg?.timestamp) {
|
||||
next.sessionLastActivity = { ...s.sessionLastActivity, [session.key]: toMs(lastMsg.timestamp) };
|
||||
}
|
||||
return next;
|
||||
});
|
||||
finishSessionLabelHydration(session.key, version, labelText ? 'labeled' : 'empty');
|
||||
finishSessionLabelHydration(session.key, version, label ? 'labeled' : 'empty');
|
||||
} catch {
|
||||
finishSessionLabelHydration(session.key, version, 'error');
|
||||
}
|
||||
@@ -265,6 +266,7 @@ export function createSessionActions(
|
||||
// ── Switch session ──
|
||||
|
||||
switchSession: (key: string) => {
|
||||
writeLastChatSessionKey(key);
|
||||
const { currentSessionKey, messages, sessionLastActivity, sessionLabels } = get();
|
||||
// Only treat sessions with no history records and no activity timestamp as empty.
|
||||
// Relying solely on messages.length is unreliable because switchSession clears
|
||||
@@ -330,6 +332,8 @@ export function createSessionActions(
|
||||
if (currentSessionKey === key) {
|
||||
// Switched away from deleted session — pick the first remaining or create new
|
||||
const next = remaining[0];
|
||||
const nextSessionKey = next?.key ?? DEFAULT_SESSION_KEY;
|
||||
writeLastChatSessionKey(nextSessionKey);
|
||||
set((s) => ({
|
||||
sessions: remaining,
|
||||
sessionLabels: Object.fromEntries(Object.entries(s.sessionLabels).filter(([k]) => k !== key)),
|
||||
@@ -343,8 +347,8 @@ export function createSessionActions(
|
||||
pendingFinal: false,
|
||||
lastUserMessageAt: null,
|
||||
pendingToolImages: [],
|
||||
currentSessionKey: next?.key ?? DEFAULT_SESSION_KEY,
|
||||
currentAgentId: getAgentIdFromSessionKey(next?.key ?? DEFAULT_SESSION_KEY),
|
||||
currentSessionKey: nextSessionKey,
|
||||
currentAgentId: getAgentIdFromSessionKey(nextSessionKey),
|
||||
}));
|
||||
if (next) {
|
||||
get().loadHistory();
|
||||
@@ -374,6 +378,7 @@ export function createSessionActions(
|
||||
const prefix = getCanonicalPrefixFromSessions(get().sessions) ?? DEFAULT_CANONICAL_PREFIX;
|
||||
const newKey = `${prefix}:session-${Date.now()}`;
|
||||
const newSessionEntry: ChatSession = { key: newKey, displayName: newKey };
|
||||
writeLastChatSessionKey(newKey);
|
||||
set((s) => ({
|
||||
currentSessionKey: newKey,
|
||||
currentAgentId: getAgentIdFromSessionKey(newKey),
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
const DEFAULT_SESSION_LABEL_MAX_LENGTH = 50;
|
||||
|
||||
function cleanUserMetadata(text: string): string {
|
||||
return text
|
||||
.replace(/^Sender\s*\([^)]*\)\s*:\s*```[a-z]*\n[\s\S]*?```\s*/i, '')
|
||||
.replace(/^Sender\s*\([^)]*\)\s*:\s*\{[\s\S]*?\}\s*/i, '')
|
||||
.replace(/^Sender\s*\([^)]*\)\s*:[^\n]*(?:\n\s*)*/i, '')
|
||||
.replace(/^Sender\s*:\s*```[a-z]*\n[\s\S]*?```\s*/i, '')
|
||||
.replace(/^Sender\s*:\s*\{[\s\S]*?\}\s*/i, '')
|
||||
.replace(/^Sender\s*:[^\n]*(?:\n\s*)*/i, '')
|
||||
.replace(/^Conversation info\s*\([^)]*\):\s*```[a-z]*\n[\s\S]*?```\s*/i, '')
|
||||
.replace(/^Conversation info\s*\([^)]*\):\s*\{[\s\S]*?\}\s*/i, '');
|
||||
}
|
||||
|
||||
export function cleanSessionLabelText(text: string): string {
|
||||
return cleanUserMetadata(text)
|
||||
.replace(/\s*\[media attached:[^\]]*(?:\]|$)/gi, '')
|
||||
.replace(/\s*\[media attach[^\]]*$/gi, '')
|
||||
.replace(/\s*\[message_id:\s*[^\]]+(?:\]|$)/gi, '')
|
||||
.replace(/\s*\[message_id:[^\]]*$/gi, '')
|
||||
.replace(/^\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+[^\]]+\]\s*/i, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function toSessionLabel(text: string, maxLength = DEFAULT_SESSION_LABEL_MAX_LENGTH): string {
|
||||
const trimmed = cleanSessionLabelText(text);
|
||||
if (!trimmed) return '';
|
||||
return trimmed.length > maxLength ? `${trimmed.slice(0, maxLength)}…` : trimmed;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export const LAST_CHAT_SESSION_KEY = 'clawx.chat.lastSessionKey';
|
||||
|
||||
function getStorage(): Storage | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isPersistableSessionKey(value: string | null | undefined): value is string {
|
||||
return typeof value === 'string' && value.startsWith('agent:');
|
||||
}
|
||||
|
||||
export function readLastChatSessionKey(): string | null {
|
||||
const storage = getStorage();
|
||||
if (!storage) return null;
|
||||
try {
|
||||
const value = storage.getItem(LAST_CHAT_SESSION_KEY);
|
||||
return isPersistableSessionKey(value) ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLastChatSessionKey(sessionKey: string | null | undefined): void {
|
||||
const storage = getStorage();
|
||||
if (!storage) return;
|
||||
try {
|
||||
if (isPersistableSessionKey(sessionKey)) {
|
||||
storage.setItem(LAST_CHAT_SESSION_KEY, sessionKey);
|
||||
} else {
|
||||
storage.removeItem(LAST_CHAT_SESSION_KEY);
|
||||
}
|
||||
} catch {
|
||||
// localStorage can be unavailable in restricted renderer contexts.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { create } from 'zustand';
|
||||
import { chatCoreReducer } from '@/chat-core/openclaw-port/reducer';
|
||||
import { createInitialChatCoreState } from '@/chat-core/openclaw-port/state';
|
||||
import { selectVisibleChatItems } from '@/chat-core/openclaw-port/selectors';
|
||||
import type { ChatCoreAction } from '@/chat-core/openclaw-port/actions';
|
||||
import type {
|
||||
ApprovalDecision,
|
||||
ChatQueueAttachment,
|
||||
ChatCoreState,
|
||||
RawOpenClawMessage,
|
||||
VisibleChatItem,
|
||||
} from '@/chat-core/openclaw-port/types';
|
||||
import { createClawXChatCoreClient } from '@/chat-core/clawx-adapter/client';
|
||||
import { subscribeOpenClawChatHostEvents } from '@/chat-core/clawx-adapter/host-events';
|
||||
import { createQueueItem, isRecoverableSendError, sendQueuedItem } from '@/chat-core/openclaw-port/send';
|
||||
|
||||
type OpenClawChatSurfaceStore = {
|
||||
core: ChatCoreState;
|
||||
visibleItems: VisibleChatItem[];
|
||||
initialized: boolean;
|
||||
thinkingLevel: string | null;
|
||||
dispatch: (action: ChatCoreAction) => void;
|
||||
setSessionKey: (sessionKey: string, selectedAgentId?: string) => void;
|
||||
setThinkingLevel: (thinkingLevel: string | null) => void;
|
||||
loadHistory: () => Promise<void>;
|
||||
enqueueOptimisticUserMessage: (text: string, attachments?: OptimisticAttachmentInput[]) => void;
|
||||
executeComposerText: (text: string) => Promise<void>;
|
||||
abortRun: () => Promise<void>;
|
||||
abortLocalRun: () => void;
|
||||
resolveApproval: (id: string, decision: ApprovalDecision) => Promise<void>;
|
||||
initHostSubscriptions: () => void;
|
||||
disposeHostSubscriptions: () => void;
|
||||
};
|
||||
|
||||
type OptimisticAttachmentInput = {
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
fileSize: number;
|
||||
stagedPath: string;
|
||||
preview: string | null;
|
||||
};
|
||||
|
||||
let cleanupHostSubscriptions: (() => void) | null = null;
|
||||
const client = createClawXChatCoreClient();
|
||||
let historyRequestVersion = 0;
|
||||
let terminalHistoryReloadTimers: Array<ReturnType<typeof setTimeout>> = [];
|
||||
|
||||
function thinkingParams(thinkingLevel: string | null): Record<string, unknown> {
|
||||
const thinking = thinkingLevel?.trim();
|
||||
return thinking ? { thinking } : {};
|
||||
}
|
||||
|
||||
function clearTerminalHistoryReloadTimers() {
|
||||
for (const timer of terminalHistoryReloadTimers) clearTimeout(timer);
|
||||
terminalHistoryReloadTimers = [];
|
||||
}
|
||||
|
||||
function isTerminalAction(action: ChatCoreAction): boolean {
|
||||
if (action.type === 'chat.final' || action.type === 'chat.error') return true;
|
||||
return action.type === 'run.status' && action.status != null && (
|
||||
action.status.phase === 'done'
|
||||
|| action.status.phase === 'interrupted'
|
||||
|| action.status.phase === 'error'
|
||||
);
|
||||
}
|
||||
|
||||
export const useOpenClawChatSurfaceStore = create<OpenClawChatSurfaceStore>((set, get) => ({
|
||||
core: createInitialChatCoreState({ sessionKey: 'agent:main:main' }),
|
||||
visibleItems: [],
|
||||
initialized: false,
|
||||
thinkingLevel: null,
|
||||
dispatch: (action) => {
|
||||
set((state) => {
|
||||
const core = chatCoreReducer(state.core, action);
|
||||
return { core, visibleItems: selectVisibleChatItems(core) };
|
||||
});
|
||||
},
|
||||
setSessionKey: (sessionKey, selectedAgentId) => {
|
||||
const { core } = get();
|
||||
if (core.sessionKey === sessionKey && core.selectedAgentId === selectedAgentId) return;
|
||||
get().dispatch({ type: 'session.changed', sessionKey, selectedAgentId });
|
||||
},
|
||||
setThinkingLevel: (thinkingLevel) => {
|
||||
set({ thinkingLevel: thinkingLevel?.trim() || null });
|
||||
},
|
||||
loadHistory: async () => {
|
||||
const { core, dispatch } = get();
|
||||
const requestVersion = ++historyRequestVersion;
|
||||
dispatch({ type: 'history.requested', sessionKey: core.sessionKey, requestVersion });
|
||||
const response = await client.request<{ messages?: unknown[] }>('chat.history', {
|
||||
sessionKey: core.sessionKey,
|
||||
limit: 200,
|
||||
maxChars: 500000,
|
||||
});
|
||||
dispatch({
|
||||
type: 'history.loaded',
|
||||
sessionKey: core.sessionKey,
|
||||
requestVersion,
|
||||
messages: Array.isArray(response.messages)
|
||||
? response.messages as RawOpenClawMessage[]
|
||||
: [],
|
||||
hasMore: false,
|
||||
});
|
||||
},
|
||||
enqueueOptimisticUserMessage: (text, attachments) => {
|
||||
const trimmed = text.trim();
|
||||
const readyAttachments = (attachments ?? []).map((attachment): ChatQueueAttachment => ({
|
||||
fileName: attachment.fileName,
|
||||
mimeType: attachment.mimeType,
|
||||
fileSize: attachment.fileSize,
|
||||
preview: attachment.preview,
|
||||
filePath: attachment.stagedPath,
|
||||
source: 'user-upload',
|
||||
}));
|
||||
if (!trimmed && readyAttachments.length === 0) return;
|
||||
const item = createQueueItem({
|
||||
sessionKey: get().core.sessionKey,
|
||||
message: trimmed || (readyAttachments.length > 0 ? 'Process the attached file(s).' : ''),
|
||||
historyMessageCountAtEnqueue: get().core.history.messages.length,
|
||||
attachments: readyAttachments,
|
||||
});
|
||||
get().dispatch({ type: 'send.enqueued', item });
|
||||
},
|
||||
executeComposerText: async (text) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
if (trimmed.startsWith('/')) {
|
||||
const [nameWithSlash = '/', ...rest] = trimmed.split(/\s+/);
|
||||
const name = nameWithSlash.slice(1);
|
||||
const args = rest.join(' ');
|
||||
const { executeSlashCommand } = await import('@/chat-core/openclaw-port/slash-command-executor');
|
||||
const result = await executeSlashCommand(client, get().core.sessionKey, name, args);
|
||||
if (result.action === 'refresh') await get().loadHistory();
|
||||
return;
|
||||
}
|
||||
|
||||
const item = createQueueItem({
|
||||
sessionKey: get().core.sessionKey,
|
||||
message: trimmed,
|
||||
historyMessageCountAtEnqueue: get().core.history.messages.length,
|
||||
});
|
||||
get().dispatch({ type: 'send.enqueued', item });
|
||||
try {
|
||||
const ack = await sendQueuedItem(client, item, thinkingParams(get().thinkingLevel));
|
||||
if (ack.runId) get().dispatch({ type: 'send.acked', id: item.id, runId: ack.runId });
|
||||
} catch (error) {
|
||||
get().dispatch({
|
||||
type: 'send.failed',
|
||||
id: item.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
recoverable: isRecoverableSendError(error),
|
||||
});
|
||||
}
|
||||
},
|
||||
abortRun: async () => {
|
||||
const { core, dispatch } = get();
|
||||
const runId = core.send.activeRunId
|
||||
?? core.live.runId
|
||||
?? (core.runtime.runStatus?.phase === 'running' ? core.runtime.runStatus.runId ?? null : null);
|
||||
dispatch({ type: 'send.aborted', sessionKey: core.sessionKey, runId });
|
||||
try {
|
||||
await client.request('chat.abort', {
|
||||
sessionKey: core.sessionKey,
|
||||
...(runId ? { runId } : {}),
|
||||
}, 120000);
|
||||
} catch (error) {
|
||||
dispatch({
|
||||
type: 'run.status',
|
||||
status: {
|
||||
phase: 'error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
abortLocalRun: () => {
|
||||
const { core, dispatch } = get();
|
||||
const runId = core.send.activeRunId
|
||||
?? core.live.runId
|
||||
?? (core.runtime.runStatus?.phase === 'running' ? core.runtime.runStatus.runId ?? null : null);
|
||||
dispatch({ type: 'send.aborted', sessionKey: core.sessionKey, runId });
|
||||
},
|
||||
resolveApproval: async (id, decision) => {
|
||||
const approval = get().core.runtime.approvals.find((item) => (
|
||||
item.id === id
|
||||
|| item.approvalId === id
|
||||
|| item.approvalSlug === id
|
||||
|| item.itemId === id
|
||||
|| item.toolCallId === id
|
||||
));
|
||||
const approvalId = approval?.approvalId ?? approval?.id ?? id;
|
||||
const method = approval?.kind === 'plugin' || approvalId.startsWith('plugin:')
|
||||
? 'plugin.approval.resolve'
|
||||
: 'exec.approval.resolve';
|
||||
try {
|
||||
await client.request(method, { id: approvalId, decision }, 120000);
|
||||
get().dispatch({
|
||||
type: 'approval.resolved',
|
||||
ids: [
|
||||
id,
|
||||
approvalId,
|
||||
approval?.approvalSlug,
|
||||
approval?.itemId,
|
||||
approval?.toolCallId,
|
||||
].filter((value): value is string => typeof value === 'string' && value.trim().length > 0),
|
||||
});
|
||||
} catch (error) {
|
||||
get().dispatch({
|
||||
type: 'run.status',
|
||||
status: {
|
||||
phase: 'error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
initHostSubscriptions: () => {
|
||||
if (cleanupHostSubscriptions) return;
|
||||
cleanupHostSubscriptions = subscribeOpenClawChatHostEvents((action) => {
|
||||
get().dispatch(action);
|
||||
if (!isTerminalAction(action)) return;
|
||||
|
||||
const sessionKey = get().core.sessionKey;
|
||||
clearTerminalHistoryReloadTimers();
|
||||
for (const delayMs of [0, 500, 1500]) {
|
||||
const timer = setTimeout(() => {
|
||||
if (get().core.sessionKey !== sessionKey) return;
|
||||
void get().loadHistory().catch(() => {
|
||||
// The host bridge can briefly be unavailable during shutdown or
|
||||
// test teardown; the next explicit refresh/session load will retry.
|
||||
});
|
||||
}, delayMs);
|
||||
terminalHistoryReloadTimers.push(timer);
|
||||
}
|
||||
});
|
||||
set({ initialized: true });
|
||||
},
|
||||
disposeHostSubscriptions: () => {
|
||||
cleanupHostSubscriptions?.();
|
||||
cleanupHostSubscriptions = null;
|
||||
clearTerminalHistoryReloadTimers();
|
||||
set({ initialized: false });
|
||||
},
|
||||
}));
|
||||
@@ -17,11 +17,13 @@ function stableStringify(value: unknown): string {
|
||||
|
||||
const seededHistory = [
|
||||
{
|
||||
id: 'user-plain-markdown',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Please render a Markdown reply plainly.' }],
|
||||
content: 'Please render a Markdown reply plainly.',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
id: 'assistant-plain-markdown',
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'text',
|
||||
@@ -44,8 +46,14 @@ test.describe('ClawX assistant reply Markdown styling', () => {
|
||||
|
||||
try {
|
||||
await installIpcMocks(app, {
|
||||
gatewayStatus: { state: 'running', port: 18789, pid: 12345 },
|
||||
gatewayStatus: { state: 'running', port: 18789, pid: 12345, gatewayReady: true },
|
||||
gatewayRpc: {
|
||||
[stableStringify(['sessions.list', { includeDerivedTitles: true, includeLastMessage: true }])]: {
|
||||
success: true,
|
||||
result: {
|
||||
sessions: [{ key: SESSION_KEY, displayName: 'main' }],
|
||||
},
|
||||
},
|
||||
[stableStringify(['sessions.list', {}])]: {
|
||||
success: true,
|
||||
result: {
|
||||
@@ -60,6 +68,10 @@ test.describe('ClawX assistant reply Markdown styling', () => {
|
||||
success: true,
|
||||
result: { messages: seededHistory },
|
||||
},
|
||||
[stableStringify(['chat.history', null])]: {
|
||||
success: true,
|
||||
result: { messages: seededHistory },
|
||||
},
|
||||
},
|
||||
hostApi: {
|
||||
[stableStringify(['/api/gateway/status', 'GET'])]: {
|
||||
@@ -67,7 +79,7 @@ test.describe('ClawX assistant reply Markdown styling', () => {
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: { state: 'running', port: 18789, pid: 12345 },
|
||||
json: { state: 'running', port: 18789, pid: 12345, gatewayReady: true },
|
||||
},
|
||||
},
|
||||
[stableStringify(['/api/agents', 'GET'])]: {
|
||||
@@ -85,15 +97,8 @@ test.describe('ClawX assistant reply Markdown styling', () => {
|
||||
});
|
||||
|
||||
const page = await getStableWindow(app);
|
||||
try {
|
||||
await page.reload();
|
||||
} catch (error) {
|
||||
if (!String(error).includes('ERR_FILE_NOT_FOUND')) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('main-layout')).toBeVisible();
|
||||
await expect(page.getByTestId('openclaw-chat-surface')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
const root = document.documentElement;
|
||||
@@ -101,8 +106,9 @@ test.describe('ClawX assistant reply Markdown styling', () => {
|
||||
root.classList.add('light');
|
||||
});
|
||||
|
||||
const userBubble = page.locator('div.rounded-2xl.bg-brand').filter({ hasText: 'Please render a Markdown reply plainly.' }).first();
|
||||
const userBubble = page.getByTestId('chat-user-message-bubble').filter({ hasText: 'Please render a Markdown reply plainly.' }).first();
|
||||
await expect(userBubble).toBeVisible({ timeout: 30_000 });
|
||||
await expect(userBubble).toHaveClass(/bg-primary/);
|
||||
|
||||
const assistantProse = page.locator('.prose').filter({ hasText: 'Plain Markdown reply' }).first();
|
||||
await expect(assistantProse).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { closeElectronApp, expect, getStableWindow, installIpcMocks, test } from './fixtures/electron';
|
||||
|
||||
const SESSION_KEY = 'agent:main:main';
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (value == null || typeof value !== 'object') return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]`;
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`);
|
||||
return `{${entries.join(',')}}`;
|
||||
}
|
||||
|
||||
test.describe('ClawX empty chat welcome', () => {
|
||||
test('shows the welcome panel for an empty OpenClaw chat session', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
|
||||
try {
|
||||
await installIpcMocks(app, {
|
||||
gatewayStatus: { state: 'running', port: 18789, pid: 12345, gatewayReady: true },
|
||||
gatewayRpc: {
|
||||
[stableStringify(['sessions.list', { includeDerivedTitles: true, includeLastMessage: true }])]: {
|
||||
success: true,
|
||||
result: {
|
||||
sessions: [{ key: SESSION_KEY, displayName: 'main' }],
|
||||
},
|
||||
},
|
||||
[stableStringify(['sessions.list', {}])]: {
|
||||
success: true,
|
||||
result: {
|
||||
sessions: [{ key: SESSION_KEY, displayName: 'main' }],
|
||||
},
|
||||
},
|
||||
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200, maxChars: 500000 }])]: {
|
||||
success: true,
|
||||
result: { messages: [] },
|
||||
},
|
||||
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000, maxChars: 500000 }])]: {
|
||||
success: true,
|
||||
result: { messages: [] },
|
||||
},
|
||||
},
|
||||
hostApi: {
|
||||
[stableStringify(['/api/gateway/status', 'GET'])]: {
|
||||
ok: true,
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: { state: 'running', port: 18789, pid: 12345, gatewayReady: true },
|
||||
},
|
||||
},
|
||||
[stableStringify(['/api/settings', 'GET'])]: {
|
||||
ok: true,
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: {
|
||||
language: 'en',
|
||||
setupComplete: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
[stableStringify(['/api/agents', 'GET'])]: {
|
||||
ok: true,
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: {
|
||||
success: true,
|
||||
agents: [{ id: 'main', name: 'main' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const page = await getStableWindow(app);
|
||||
|
||||
await expect(page.getByTestId('openclaw-chat-surface')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId('chat-welcome')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText('What can I do for you?')).toBeVisible();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -21,10 +21,10 @@ test.describe('ClawX chat session date grouping', () => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
const nowMs = Date.now();
|
||||
const sessions = [
|
||||
{ key: MAIN_SESSION_KEY, displayName: 'Today conversation', updatedAt: nowMs - 60 * 60 * 1000 },
|
||||
{ key: `agent:main:session-${nowMs - 2 * DAY_MS}`, displayName: 'Week conversation', updatedAt: nowMs - 2 * DAY_MS },
|
||||
{ key: `agent:main:session-${nowMs - 10 * DAY_MS}`, displayName: 'Month conversation', updatedAt: nowMs - 10 * DAY_MS },
|
||||
{ key: `agent:main:session-${nowMs - 40 * DAY_MS}`, displayName: 'Older conversation', updatedAt: nowMs - 40 * DAY_MS },
|
||||
{ key: MAIN_SESSION_KEY, label: 'Today conversation', displayName: 'main', updatedAt: nowMs },
|
||||
{ key: `agent:main:session-${nowMs - 2 * DAY_MS}`, label: 'Week conversation', displayName: 'main', updatedAt: nowMs - 2 * DAY_MS },
|
||||
{ key: `agent:main:session-${nowMs - 10 * DAY_MS}`, label: 'Month conversation', displayName: 'main', updatedAt: nowMs - 10 * DAY_MS },
|
||||
{ key: `agent:main:session-${nowMs - 40 * DAY_MS}`, label: 'Older conversation', displayName: 'main', updatedAt: nowMs - 40 * DAY_MS },
|
||||
];
|
||||
|
||||
try {
|
||||
@@ -157,6 +157,11 @@ test.describe('ClawX chat session date grouping', () => {
|
||||
await page.getByTestId('sidebar-new-chat').click();
|
||||
|
||||
await expect(page.getByTestId('session-bucket-today').getByText(/agent:main:session-/)).toBeVisible();
|
||||
const newSessionButton = page.locator('[data-testid^="sidebar-session-agent:main:session-"]').first();
|
||||
const transitionProperties = await newSessionButton.evaluate((element) =>
|
||||
getComputedStyle(element).transitionProperty.split(',').map((property) => property.trim()),
|
||||
);
|
||||
expect(transitionProperties).not.toContain('padding');
|
||||
await expect(page.getByTestId('session-bucket-older')).toBeVisible();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
|
||||
@@ -0,0 +1,618 @@
|
||||
import { closeElectronApp, expect, getStableWindow, installIpcMocks, test } from './fixtures/electron';
|
||||
|
||||
const SESSION_KEY = 'agent:main:main';
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (value == null || typeof value !== 'object') return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]`;
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`);
|
||||
return `{${entries.join(',')}}`;
|
||||
}
|
||||
|
||||
test.describe('OpenClaw core Chat surface', () => {
|
||||
test('renders history on the default Chat page without duplicate user messages', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
try {
|
||||
await installIpcMocks(app, {
|
||||
gatewayStatus: { state: 'running', port: 18789, pid: 12345, gatewayReady: true },
|
||||
gatewayRpc: {
|
||||
[stableStringify(['sessions.list', { includeDerivedTitles: true, includeLastMessage: true }])]: {
|
||||
success: true,
|
||||
result: { sessions: [{ key: SESSION_KEY, displayName: 'main' }] },
|
||||
},
|
||||
[stableStringify(['sessions.list', {}])]: {
|
||||
success: true,
|
||||
result: { sessions: [{ key: SESSION_KEY, displayName: 'main' }] },
|
||||
},
|
||||
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200, maxChars: 500000 }])]: {
|
||||
success: true,
|
||||
result: {
|
||||
messages: [
|
||||
{ id: 'u1', role: 'user', content: 'hello' },
|
||||
{ id: 'a1', role: 'assistant', content: 'hi' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
hostApi: {
|
||||
[stableStringify(['/api/agents', 'GET'])]: {
|
||||
ok: true,
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: { success: true, agents: [{ id: 'main', name: 'Main' }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const page = await getStableWindow(app);
|
||||
await expect(page.getByTestId('openclaw-chat-surface')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText('hello')).toHaveCount(1);
|
||||
await expect(page.getByText('hi')).toBeVisible();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('renders OpenClaw assistant Markdown as rich Markdown', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
try {
|
||||
await installIpcMocks(app, {
|
||||
gatewayStatus: { state: 'running', port: 18789, pid: 12345, gatewayReady: true },
|
||||
gatewayRpc: {
|
||||
[stableStringify(['sessions.list', {}])]: {
|
||||
success: true,
|
||||
result: { sessions: [{ key: SESSION_KEY, displayName: 'main' }] },
|
||||
},
|
||||
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200, maxChars: 500000 }])]: {
|
||||
success: true,
|
||||
result: {
|
||||
messages: [
|
||||
{
|
||||
id: 'a1',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: [
|
||||
'### Rendered heading',
|
||||
'',
|
||||
'- **bold** item',
|
||||
'- `inlineCode()` item',
|
||||
].join('\n'),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
hostApi: {
|
||||
[stableStringify(['/api/agents', 'GET'])]: {
|
||||
ok: true,
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: { success: true, agents: [{ id: 'main', name: 'Main' }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const page = await getStableWindow(app);
|
||||
await expect(page.getByRole('heading', { name: 'Rendered heading', level: 3 })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.locator('strong', { hasText: 'bold' })).toBeVisible();
|
||||
await expect(page.locator('code', { hasText: 'inlineCode()' })).toBeVisible();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('renders split history tool use and result as one expandable card', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
try {
|
||||
await installIpcMocks(app, {
|
||||
gatewayStatus: { state: 'running', port: 18789, pid: 12345, gatewayReady: true },
|
||||
gatewayRpc: {
|
||||
[stableStringify(['sessions.list', {}])]: {
|
||||
success: true,
|
||||
result: { sessions: [{ key: SESSION_KEY, displayName: 'main' }] },
|
||||
},
|
||||
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200, maxChars: 500000 }])]: {
|
||||
success: true,
|
||||
result: {
|
||||
messages: [
|
||||
{
|
||||
id: 'assistant-tool-call',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', id: 'call-1', name: 'read', input: { filePath: '/tmp/a.md' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'tool-result',
|
||||
role: 'toolResult',
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'read',
|
||||
content: [{ type: 'text', text: 'file contents' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
hostApi: {
|
||||
[stableStringify(['/api/agents', 'GET'])]: {
|
||||
ok: true,
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: { success: true, agents: [{ id: 'main', name: 'Main' }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const page = await getStableWindow(app);
|
||||
await expect(page.getByTestId('chat-tool-card')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId('chat-tool-card')).toHaveCount(1);
|
||||
await expect(page.getByTestId('chat-tool-card')).toContainText('read');
|
||||
await expect(page.getByTestId('chat-tool-card')).toHaveClass(/w-\[50vw\]/);
|
||||
await expect(page.getByText('file contents')).toHaveCount(0);
|
||||
await page.getByRole('button', { name: /read/i }).click();
|
||||
await expect(page.getByTestId('chat-tool-card')).toContainText('/tmp/a.md');
|
||||
await expect(page.getByTestId('chat-tool-card')).toContainText('file contents');
|
||||
await expect(page.getByTestId('chat-tool-card-preview')).toBeVisible();
|
||||
await page.getByTestId('chat-tool-card-preview').click();
|
||||
await expect(page.getByTestId('artifact-panel-aside')).toBeVisible();
|
||||
await expect(page.getByTestId('artifact-panel-aside')).toContainText('a.md');
|
||||
await expect(page.getByRole('button', { name: 'Raw output' })).toHaveCount(0);
|
||||
await expect(page.getByTestId('chat-raw-output-panel')).toHaveCount(0);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('renders media-path history attachments without leaking media URL marker text', async ({ launchElectronApp }) => {
|
||||
const imagePath = '/tmp/loose history image.png';
|
||||
const preview = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
try {
|
||||
await installIpcMocks(app, {
|
||||
gatewayStatus: { state: 'running', port: 18789, pid: 12345, gatewayReady: true },
|
||||
gatewayRpc: {
|
||||
[stableStringify(['sessions.list', {}])]: {
|
||||
success: true,
|
||||
result: { sessions: [{ key: SESSION_KEY, displayName: 'main' }] },
|
||||
},
|
||||
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200, maxChars: 500000 }])]: {
|
||||
success: true,
|
||||
result: {
|
||||
messages: [
|
||||
{
|
||||
id: 'user-with-loose-media',
|
||||
role: 'user',
|
||||
content: 'Describe this image\n\n[media attached: media://inbound/loose-history-image.png (image/png)]',
|
||||
MediaPath: imagePath,
|
||||
MediaType: 'image/png',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
hostApi: {
|
||||
[stableStringify(['/api/agents', 'GET'])]: {
|
||||
ok: true,
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: { success: true, agents: [{ id: 'main', name: 'Main' }] },
|
||||
},
|
||||
},
|
||||
[stableStringify(['media', 'thumbnails', { paths: [{ filePath: imagePath, mimeType: 'image/png' }] }])]: {
|
||||
[imagePath]: { preview, fileSize: 68 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const page = await getStableWindow(app);
|
||||
await expect(page.getByText('Describe this image')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText(/media attached:/i)).toHaveCount(0);
|
||||
await expect(page.getByAltText('loose history image.png')).toBeVisible();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('renders persisted assistant text before a tool call in the same assistant row', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
try {
|
||||
await installIpcMocks(app, {
|
||||
gatewayStatus: { state: 'running', port: 18789, pid: 12345, gatewayReady: true },
|
||||
gatewayRpc: {
|
||||
[stableStringify(['sessions.list', {}])]: {
|
||||
success: true,
|
||||
result: { sessions: [{ key: SESSION_KEY, displayName: 'main' }] },
|
||||
},
|
||||
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200, maxChars: 500000 }])]: {
|
||||
success: true,
|
||||
result: {
|
||||
messages: [
|
||||
{
|
||||
id: 'assistant-tool-call',
|
||||
role: 'assistant',
|
||||
stopReason: 'toolUse',
|
||||
content: [
|
||||
{ type: 'text', phase: 'commentary', text: 'First explanation.' },
|
||||
{ type: 'tool_use', id: 'call-1', name: 'web_search', input: { query: 'tech trends' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'tool-result',
|
||||
role: 'toolResult',
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'web_search',
|
||||
content: [{ type: 'text', text: 'search results' }],
|
||||
},
|
||||
{
|
||||
id: 'assistant-gateway-fallback',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'First explanation.' }],
|
||||
openclawStreamFallback: { replacementText: 'First explanation.' },
|
||||
},
|
||||
{
|
||||
id: 'assistant-final',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Final explanation.' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
hostApi: {
|
||||
[stableStringify(['/api/agents', 'GET'])]: {
|
||||
ok: true,
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: { success: true, agents: [{ id: 'main', name: 'Main' }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const page = await getStableWindow(app);
|
||||
await expect(page.getByText('First explanation.')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText('First explanation.')).toHaveCount(1);
|
||||
await expect(page.getByTestId('chat-tool-card')).toContainText('web_search');
|
||||
await expect(page.getByText('Final explanation.')).toBeVisible();
|
||||
await expect(page.getByTestId('chat-assistant-avatar')).toHaveCount(1);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('shows production sends as optimistic user messages in the OpenClaw surface', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
try {
|
||||
await installIpcMocks(app, {
|
||||
gatewayStatus: { state: 'running', port: 18789, pid: 12345, gatewayReady: true },
|
||||
gatewayRpc: {
|
||||
[stableStringify(['sessions.list', { includeDerivedTitles: true, includeLastMessage: true }])]: {
|
||||
success: true,
|
||||
result: { sessions: [{ key: SESSION_KEY, displayName: 'main' }] },
|
||||
},
|
||||
[stableStringify(['sessions.list', {}])]: {
|
||||
success: true,
|
||||
result: { sessions: [{ key: SESSION_KEY, displayName: 'main' }] },
|
||||
},
|
||||
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200, maxChars: 500000 }])]: {
|
||||
success: true,
|
||||
result: { messages: [] },
|
||||
},
|
||||
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000, maxChars: 500000 }])]: {
|
||||
success: true,
|
||||
result: { messages: [] },
|
||||
},
|
||||
[stableStringify(['chat.send', null])]: {
|
||||
success: true,
|
||||
result: { runId: 'run-optimistic' },
|
||||
},
|
||||
},
|
||||
hostApi: {
|
||||
[stableStringify(['/api/agents', 'GET'])]: {
|
||||
ok: true,
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: { success: true, agents: [{ id: 'main', name: 'Main' }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const page = await getStableWindow(app);
|
||||
await expect(page.getByTestId('chat-composer-input')).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTestId('chat-composer-input').fill('show this immediately');
|
||||
await page.getByTestId('chat-composer-send').click();
|
||||
|
||||
await expect(page.getByTestId('chat-optimistic-user-message')).toContainText('show this immediately');
|
||||
await expect(page.getByTestId('chat-running-pulse')).toBeVisible();
|
||||
await expect(page.getByTestId('chat-run-status')).toHaveCount(0);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps live output visible while post-final history hydration is still empty', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
try {
|
||||
await installIpcMocks(app, {
|
||||
gatewayStatus: { state: 'running', port: 18789, pid: 12345, gatewayReady: true },
|
||||
gatewayRpc: {
|
||||
[stableStringify(['sessions.list', { includeDerivedTitles: true, includeLastMessage: true }])]: {
|
||||
success: true,
|
||||
result: { sessions: [{ key: SESSION_KEY, displayName: 'main' }] },
|
||||
},
|
||||
[stableStringify(['sessions.list', {}])]: {
|
||||
success: true,
|
||||
result: { sessions: [{ key: SESSION_KEY, displayName: 'main' }] },
|
||||
},
|
||||
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200, maxChars: 500000 }])]: {
|
||||
success: true,
|
||||
result: { messages: [] },
|
||||
},
|
||||
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000, maxChars: 500000 }])]: {
|
||||
success: true,
|
||||
result: { messages: [] },
|
||||
},
|
||||
[stableStringify(['chat.send', null])]: {
|
||||
success: true,
|
||||
result: { runId: 'run-hydration-empty' },
|
||||
},
|
||||
},
|
||||
hostApi: {
|
||||
[stableStringify(['/api/agents', 'GET'])]: {
|
||||
ok: true,
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: { success: true, agents: [{ id: 'main', name: 'Main' }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const page = await getStableWindow(app);
|
||||
await expect(page.getByTestId('chat-composer-input')).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTestId('chat-composer-input').fill('hydrate without blanking');
|
||||
await page.getByTestId('chat-composer-send').click();
|
||||
await expect(page.getByTestId('chat-optimistic-user-message')).toContainText('hydrate without blanking');
|
||||
|
||||
await app.evaluate(({ BrowserWindow }, payload) => {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send('gateway:agent-event', payload.assistant);
|
||||
win.webContents.send('gateway:agent-event', payload.done);
|
||||
}
|
||||
}, {
|
||||
assistant: {
|
||||
sessionKey: SESSION_KEY,
|
||||
runId: 'run-hydration-empty',
|
||||
stream: 'assistant',
|
||||
ts: 1000,
|
||||
data: {
|
||||
phase: 'final_answer',
|
||||
text: 'This live answer should stay visible.',
|
||||
},
|
||||
},
|
||||
done: {
|
||||
sessionKey: SESSION_KEY,
|
||||
runId: 'run-hydration-empty',
|
||||
stream: 'lifecycle',
|
||||
ts: 1001,
|
||||
data: { phase: 'done' },
|
||||
},
|
||||
});
|
||||
|
||||
await expect(page.getByText('This live answer should stay visible.')).toBeVisible();
|
||||
await page.waitForTimeout(1800);
|
||||
await expect(page.getByTestId('chat-optimistic-user-message')).toContainText('hydrate without blanking');
|
||||
await expect(page.getByText('This live answer should stay visible.')).toBeVisible();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('renders runtime indicators and resolves approval cards from upstream agent events', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
try {
|
||||
await installIpcMocks(app, {
|
||||
gatewayStatus: { state: 'running', port: 18789, pid: 12345, gatewayReady: true },
|
||||
gatewayRpc: {
|
||||
[stableStringify(['sessions.list', { includeDerivedTitles: true, includeLastMessage: true }])]: {
|
||||
success: true,
|
||||
result: { sessions: [{ key: SESSION_KEY, displayName: 'main' }] },
|
||||
},
|
||||
[stableStringify(['sessions.list', {}])]: {
|
||||
success: true,
|
||||
result: { sessions: [{ key: SESSION_KEY, displayName: 'main' }] },
|
||||
},
|
||||
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200, maxChars: 500000 }])]: {
|
||||
success: true,
|
||||
result: { messages: [] },
|
||||
},
|
||||
[stableStringify(['exec.approval.resolve', { id: 'approval-1', decision: 'allow-once' }])]: {
|
||||
success: true,
|
||||
result: { ok: true },
|
||||
},
|
||||
},
|
||||
hostApi: {
|
||||
[stableStringify(['/api/agents', 'GET'])]: {
|
||||
ok: true,
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: { success: true, agents: [{ id: 'main', name: 'Main' }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const page = await getStableWindow(app);
|
||||
await expect(page.getByTestId('openclaw-chat-surface')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await app.evaluate(({ BrowserWindow }, payload) => {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send('gateway:agent-event', payload.compaction);
|
||||
win.webContents.send('gateway:agent-event', payload.approval);
|
||||
}
|
||||
}, {
|
||||
compaction: {
|
||||
sessionKey: SESSION_KEY,
|
||||
runId: 'run-1',
|
||||
stream: 'compaction',
|
||||
data: { phase: 'start', messages: ['Memory pressure detected'] },
|
||||
},
|
||||
approval: {
|
||||
sessionKey: SESSION_KEY,
|
||||
runId: 'run-1',
|
||||
stream: 'approval',
|
||||
data: {
|
||||
phase: 'requested',
|
||||
status: 'pending',
|
||||
approvalId: 'approval-1',
|
||||
kind: 'exec',
|
||||
title: 'Command approval requested',
|
||||
command: 'git status',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(page.getByTestId('chat-runtime-indicator').filter({
|
||||
hasText: 'Memory pressure detected',
|
||||
})).toBeVisible();
|
||||
await expect(page.getByTestId('chat-approval-card')).toContainText('git status');
|
||||
|
||||
await page.getByRole('button', { name: 'Allow once' }).click();
|
||||
await expect(page.getByTestId('chat-approval-card')).toHaveCount(0);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('renders raw OpenClaw runtime items and localized composer running pulse', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
try {
|
||||
await installIpcMocks(app, {
|
||||
gatewayStatus: { state: 'running', port: 18789, pid: 12345, gatewayReady: true },
|
||||
gatewayRpc: {
|
||||
[stableStringify(['sessions.list', { includeDerivedTitles: true, includeLastMessage: true }])]: {
|
||||
success: true,
|
||||
result: { sessions: [{ key: SESSION_KEY, displayName: 'main' }] },
|
||||
},
|
||||
[stableStringify(['sessions.list', {}])]: {
|
||||
success: true,
|
||||
result: { sessions: [{ key: SESSION_KEY, displayName: 'main' }] },
|
||||
},
|
||||
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200, maxChars: 500000 }])]: {
|
||||
success: true,
|
||||
result: { messages: [] },
|
||||
},
|
||||
},
|
||||
hostApi: {
|
||||
[stableStringify(['/api/settings', 'GET'])]: {
|
||||
ok: true,
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: {
|
||||
language: 'zh',
|
||||
setupComplete: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
[stableStringify(['/api/agents', 'GET'])]: {
|
||||
ok: true,
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: { success: true, agents: [{ id: 'main', name: 'Main' }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const page = await getStableWindow(app);
|
||||
await expect(page.getByTestId('openclaw-chat-surface')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await app.evaluate(({ BrowserWindow }, payload) => {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send('gateway:agent-event', payload.lifecycle);
|
||||
win.webContents.send('gateway:agent-event', payload.thinking);
|
||||
win.webContents.send('gateway:agent-event', payload.command);
|
||||
win.webContents.send('gateway:agent-event', payload.patch);
|
||||
}
|
||||
}, {
|
||||
lifecycle: {
|
||||
sessionKey: SESSION_KEY,
|
||||
runId: 'run-items',
|
||||
stream: 'lifecycle',
|
||||
data: { phase: 'start' },
|
||||
},
|
||||
thinking: {
|
||||
sessionKey: SESSION_KEY,
|
||||
runId: 'run-items',
|
||||
stream: 'thinking',
|
||||
data: { text: 'Planning the edit path' },
|
||||
},
|
||||
command: {
|
||||
sessionKey: SESSION_KEY,
|
||||
runId: 'run-items',
|
||||
stream: 'command_output',
|
||||
data: {
|
||||
title: 'Run tests',
|
||||
command: 'pnpm test -- --runInBand',
|
||||
output: '2 tests passed',
|
||||
exitCode: 0,
|
||||
durationMs: 1250,
|
||||
},
|
||||
},
|
||||
patch: {
|
||||
sessionKey: SESSION_KEY,
|
||||
runId: 'run-items',
|
||||
stream: 'patch',
|
||||
data: {
|
||||
summary: 'Updated chat runtime item rendering',
|
||||
filePaths: ['src/pages/Chat/MessageList.tsx', 'src/pages/Chat/CommandCard.tsx'],
|
||||
fileCount: 2,
|
||||
added: 28,
|
||||
modified: 3,
|
||||
deleted: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(page.getByTestId('chat-running-pulse')).toBeVisible();
|
||||
await expect(page.getByTestId('chat-running-pulse')).toHaveText('AI 回复中');
|
||||
|
||||
await expect(page.getByTestId('chat-thinking-block')).toContainText('思考过程');
|
||||
await expect(page.getByText('Planning the edit path')).toHaveCount(0);
|
||||
await page.getByRole('button', { name: /思考过程/ }).click();
|
||||
await expect(page.getByTestId('chat-thinking-block')).toContainText('Planning the edit path');
|
||||
await expect(page.getByTestId('chat-command-card')).toContainText('Run tests');
|
||||
await expect(page.getByTestId('chat-command-card')).toContainText('pnpm test -- --runInBand');
|
||||
await expect(page.getByTestId('chat-command-card')).toContainText('2 tests passed');
|
||||
await expect(page.getByTestId('chat-command-card')).toContainText('1.3 秒');
|
||||
await expect(page.getByTestId('chat-patch-card')).toContainText('Updated chat runtime item rendering');
|
||||
await expect(page.getByTestId('chat-patch-card')).toContainText('2 个文件');
|
||||
await expect(page.getByTestId('chat-patch-card')).toContainText('+28');
|
||||
await expect(page.getByTestId('chat-patch-card')).toContainText('-1');
|
||||
await expect(page.getByRole('button', { name: 'Raw output' })).toHaveCount(0);
|
||||
await expect(page.getByTestId('chat-raw-output-panel')).toHaveCount(0);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -153,4 +153,78 @@ test.describe('ClawX chat question directory', () => {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('strips media attachment markers from question directory titles', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
const messages = [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Describe this image\n\n[media attached: /tmp/shot.png (image/png) | /tmp/shot.png]',
|
||||
timestamp: 4000,
|
||||
},
|
||||
{ role: 'assistant', content: 'Image described.', timestamp: 4001 },
|
||||
{ role: 'user', content: 'Continue with the summary.', timestamp: 4002 },
|
||||
{ role: 'assistant', content: 'Summary ready.', timestamp: 4003 },
|
||||
];
|
||||
|
||||
try {
|
||||
await installQuestionDirectoryMocks(app, messages);
|
||||
|
||||
const page = await getStableWindow(app);
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
try {
|
||||
await page.reload();
|
||||
} catch (error) {
|
||||
if (!String(error).includes('ERR_FILE_NOT_FOUND')) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('main-layout')).toBeVisible();
|
||||
await page.getByTestId('chat-question-directory-toggle').click();
|
||||
|
||||
const directory = page.getByTestId('chat-question-directory');
|
||||
await expect(directory).toBeVisible({ timeout: 30_000 });
|
||||
await expect(directory).toContainText('Describe this image');
|
||||
await expect(directory).not.toContainText('media attached');
|
||||
await expect(directory).not.toContainText('/tmp/shot.png');
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps the question directory as a compact top strip in narrow windows', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
|
||||
try {
|
||||
await installQuestionDirectoryMocks(app, seededHistory);
|
||||
|
||||
const page = await getStableWindow(app);
|
||||
await page.setViewportSize({ width: 760, height: 760 });
|
||||
try {
|
||||
await page.reload();
|
||||
} catch (error) {
|
||||
if (!String(error).includes('ERR_FILE_NOT_FOUND')) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('main-layout')).toBeVisible();
|
||||
await page.getByTestId('chat-question-directory-toggle').click();
|
||||
|
||||
const directory = page.getByTestId('chat-question-directory');
|
||||
const messageList = page.getByTestId('chat-scroll-container');
|
||||
await expect(directory).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const directoryBox = await directory.boundingBox();
|
||||
const messageListBox = await messageList.boundingBox();
|
||||
expect(directoryBox).not.toBeNull();
|
||||
expect(messageListBox).not.toBeNull();
|
||||
expect(directoryBox!.height).toBeLessThan(190);
|
||||
expect(directoryBox!.width).toBeLessThanOrEqual(messageListBox!.width + 2);
|
||||
expect(directoryBox!.y).toBeLessThan(messageListBox!.y);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,7 +116,7 @@ test.describe('ClawX chat run state events', () => {
|
||||
}
|
||||
});
|
||||
|
||||
await expect(page.getByTestId('chat-execution-graph')).toBeVisible();
|
||||
await expect(sendButton).toHaveAttribute('title', /Stop|停止/);
|
||||
|
||||
await app.evaluate(({ BrowserWindow }) => {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
|
||||
@@ -78,7 +78,7 @@ test.describe('ClawX chat skill trigger', () => {
|
||||
skills: [
|
||||
{
|
||||
name: 'create-skill',
|
||||
description: 'Create and refine reusable skills.',
|
||||
description: 'Create, refine, review, package, and document reusable skills with a very long description that should stay on one line.',
|
||||
source: 'workspace',
|
||||
sourceLabel: 'Workspace',
|
||||
manifestPath: '/tmp/workspace/skill/create-skill/SKILL.md',
|
||||
@@ -125,6 +125,121 @@ test.describe('ClawX chat skill trigger', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('shows skills from the slash command menu in the production composer', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
|
||||
try {
|
||||
await installIpcMocks(app, {
|
||||
gatewayStatus: { state: 'running', port: 18789, pid: 12345 },
|
||||
gatewayRpc: {
|
||||
[stableStringify(['sessions.list', {}])]: {
|
||||
success: true,
|
||||
result: {
|
||||
sessions: [{ key: SESSION_KEY, displayName: 'main' }],
|
||||
},
|
||||
},
|
||||
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200, maxChars: 500000 }])]: {
|
||||
success: true,
|
||||
result: { messages: [] },
|
||||
},
|
||||
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000, maxChars: 500000 }])]: {
|
||||
success: true,
|
||||
result: { messages: [] },
|
||||
},
|
||||
},
|
||||
hostApi: {
|
||||
[stableStringify(['/api/gateway/status', 'GET'])]: {
|
||||
ok: true,
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: { state: 'running', port: 18789, pid: 12345 },
|
||||
},
|
||||
},
|
||||
[stableStringify(['/api/settings', 'GET'])]: {
|
||||
ok: true,
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: {
|
||||
language: 'en',
|
||||
setupComplete: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
[stableStringify(['/api/agents', 'GET'])]: {
|
||||
ok: true,
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: {
|
||||
success: true,
|
||||
agents: [
|
||||
{ id: 'main', name: 'main' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
[stableStringify(['/api/skills/quick-access', 'POST'])]: {
|
||||
ok: true,
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: {
|
||||
success: true,
|
||||
skills: [
|
||||
{
|
||||
name: 'create-skill',
|
||||
description: 'Create and refine reusable skills.',
|
||||
source: 'workspace',
|
||||
sourceLabel: 'Workspace',
|
||||
manifestPath: '/tmp/workspace/skill/create-skill/SKILL.md',
|
||||
baseDir: '/tmp/workspace/skill/create-skill',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const page = await getStableWindow(app);
|
||||
|
||||
await expect(page.getByTestId('chat-composer-input')).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTestId('chat-composer-input').fill('/');
|
||||
|
||||
await expect(page.getByTestId('chat-slash-menu')).toBeVisible();
|
||||
await expect(page.getByTestId('chat-slash-skills-heading')).toHaveText('Skills');
|
||||
await expect(page.getByTestId('chat-slash-command-skills')).toHaveCount(0);
|
||||
await expect(page.getByTestId('chat-slash-skill-create-skill')).toContainText('/create-skill');
|
||||
await expect(page.getByTestId('chat-slash-skill-create-skill')).toHaveAttribute('aria-selected', 'true');
|
||||
const description = page.getByTestId('chat-slash-skill-create-skill').locator('span').nth(1);
|
||||
await expect(description).toHaveCSS('white-space', 'nowrap');
|
||||
await expect(description).toHaveCSS('overflow', 'hidden');
|
||||
await expect(description).toHaveCSS('text-overflow', 'ellipsis');
|
||||
|
||||
await page.getByTestId('chat-composer-input').press('ArrowDown');
|
||||
await expect(page.getByTestId('chat-slash-skill-create-skill')).toHaveAttribute('aria-selected', 'true');
|
||||
await page.getByTestId('chat-composer-input').press('Enter');
|
||||
await expect(page.getByTestId('chat-composer-input')).toHaveValue('/create-skill ');
|
||||
await expect(page.getByTestId('chat-composer-skill-token')).toHaveText('/create-skill');
|
||||
|
||||
await page.getByTestId('chat-composer-input').fill('/');
|
||||
await expect(page.getByTestId('chat-slash-menu')).toBeVisible();
|
||||
await page.getByTestId('chat-slash-skills-heading').click();
|
||||
await expect(page.getByPlaceholder('Search skills')).toHaveCount(0);
|
||||
await page.getByTestId('chat-composer-skill').click();
|
||||
await expect(page.getByPlaceholder('Search skills')).toBeVisible();
|
||||
await expect(page.getByTestId('chat-composer-skill-option-create-skill')).toBeVisible();
|
||||
|
||||
await page.getByTestId('chat-composer-skill-option-create-skill').click();
|
||||
await expect(page.getByTestId('chat-composer-input')).toHaveValue('/create-skill ');
|
||||
await expect(page.getByTestId('chat-composer-skill-token')).toHaveText('/create-skill');
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('clicking the composer skill token opens the preview sidebar', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
|
||||
|
||||
@@ -190,7 +190,7 @@ const errorRunHistory = [
|
||||
];
|
||||
|
||||
test.describe('ClawX chat execution graph', () => {
|
||||
test('renders internal yield status and linked subagent branch from mocked IPC', async ({ launchElectronApp }) => {
|
||||
test.skip('renders internal yield status and linked subagent branch from mocked IPC', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
|
||||
try {
|
||||
@@ -312,7 +312,7 @@ test.describe('ClawX chat execution graph', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('preserves long execution history counts and strips the full folded reply prefix', async ({ launchElectronApp }) => {
|
||||
test.skip('preserves long execution history counts and strips the full folded reply prefix', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
|
||||
try {
|
||||
|
||||
@@ -25,7 +25,7 @@ const cronTriggerHistory = [
|
||||
];
|
||||
|
||||
test.describe('ClawX cron run live status', () => {
|
||||
test('renders the execution graph live for a cron run without switching sessions', async ({ launchElectronApp }) => {
|
||||
test.skip('renders the execution graph live for a cron run without switching sessions', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
|
||||
try {
|
||||
@@ -147,7 +147,7 @@ test.describe('ClawX cron run live status', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('adopts an already-running cron run joined mid-flight (no run.started received)', async ({ launchElectronApp }) => {
|
||||
test.skip('adopts an already-running cron run joined mid-flight (no run.started received)', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
|
||||
try {
|
||||
|
||||
@@ -11,6 +11,7 @@ test.describe('hover-only scrollbar visibility', () => {
|
||||
|
||||
const scrollContainer = page.locator('[data-testid="models-page"] .overflow-y-auto').first();
|
||||
await expect(scrollContainer).toBeVisible();
|
||||
await page.mouse.move(1, 1);
|
||||
|
||||
const beforeHover = await scrollContainer.evaluate((element) => {
|
||||
const style = window.getComputedStyle(element);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { access, mkdir, readFile, rm, writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -1,54 +1,76 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { VisibleChatItem } from '@/chat-core/openclaw-port/types';
|
||||
import { Chat } from '@/pages/Chat';
|
||||
|
||||
const { gatewayState, agentsState } = vi.hoisted(() => ({
|
||||
gatewayState: { status: { state: 'running', port: 18789 } },
|
||||
agentsState: {
|
||||
agents: [{ id: 'main', name: 'main' }] as Array<Record<string, unknown>>,
|
||||
fetchAgents: vi.fn(),
|
||||
const chatState = {
|
||||
currentSessionKey: 'agent:main:main',
|
||||
currentAgentId: 'main',
|
||||
messages: [],
|
||||
sending: true,
|
||||
error: null as string | null,
|
||||
runError: null as string | null,
|
||||
lastUserMessageAt: Date.now(),
|
||||
sendMessage: vi.fn(),
|
||||
abortRun: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
clearError: vi.fn(),
|
||||
cleanupEmptySession: vi.fn(),
|
||||
};
|
||||
|
||||
const openClawSurfaceState = {
|
||||
visibleItems: [] as VisibleChatItem[],
|
||||
core: {
|
||||
runtime: {
|
||||
runStatus: { phase: 'running', runId: 'run-1' },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/gateway', () => ({
|
||||
useGatewayStore: (selector: (state: typeof gatewayState) => unknown) => selector(gatewayState),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/agents', () => ({
|
||||
useAgentsStore: (selector: (state: typeof agentsState) => unknown) => selector(agentsState),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/host-api', () => ({
|
||||
hostApiFetch: vi.fn().mockResolvedValue({ success: true, messages: [] }),
|
||||
}));
|
||||
initHostSubscriptions: vi.fn(),
|
||||
disposeHostSubscriptions: vi.fn(),
|
||||
setSessionKey: vi.fn(),
|
||||
setThinkingLevel: vi.fn(),
|
||||
loadHistory: vi.fn(),
|
||||
enqueueOptimisticUserMessage: vi.fn(),
|
||||
resolveApproval: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, params?: Record<string, unknown> | string) => {
|
||||
if (typeof params === 'string') return params;
|
||||
if (key === 'executionGraph.collapsedSummary') {
|
||||
return `collapsed ${String(params?.toolCount ?? '')} ${String(params?.processCount ?? '')}`.trim();
|
||||
}
|
||||
if (key === 'executionGraph.agentRun') return 'Main execution';
|
||||
if (key === 'executionGraph.title') return 'Execution Graph';
|
||||
if (key === 'executionGraph.collapseAction') return 'Collapse';
|
||||
if (key === 'executionGraph.thinkingLabel') return 'Thinking';
|
||||
if (key.startsWith('taskPanel.stepStatus.')) return key.split('.').at(-1) ?? key;
|
||||
return key;
|
||||
},
|
||||
t: (key: string, options?: string | Record<string, unknown>) => (
|
||||
typeof options === 'string' ? options : key
|
||||
),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-stick-to-bottom-instant', () => ({
|
||||
useStickToBottomInstant: vi.fn(() => ({
|
||||
contentRef: { current: null },
|
||||
scrollRef: { current: null },
|
||||
scrollToBottom: vi.fn(),
|
||||
isAtBottom: true,
|
||||
})),
|
||||
vi.mock('@/stores/gateway', () => ({
|
||||
useGatewayStore: (selector: (state: { status: { state: string; gatewayReady: boolean } }) => unknown) => selector({
|
||||
status: { state: 'running', gatewayReady: true },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-min-loading', () => ({
|
||||
useMinLoading: () => false,
|
||||
vi.mock('@/stores/agents', () => ({
|
||||
useAgentsStore: (selector: (state: { agents: Array<{ id: string; name: string }>; fetchAgents: () => void }) => unknown) => selector({
|
||||
agents: [{ id: 'main', name: 'main' }],
|
||||
fetchAgents: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/chat', () => ({
|
||||
useChatStore: (selector: (state: typeof chatState) => unknown) => selector(chatState),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/openclaw-chat-surface', () => ({
|
||||
useOpenClawChatSurfaceStore: (selector: (state: typeof openClawSurfaceState) => unknown) => selector(openClawSurfaceState),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/artifact-panel', () => ({
|
||||
useArtifactPanel: (selector: (state: { open: boolean; widthPct: number; openChanges: () => void; openPreview: () => void; close: () => void }) => unknown) => selector({
|
||||
open: false,
|
||||
widthPct: 34,
|
||||
openChanges: vi.fn(),
|
||||
openPreview: vi.fn(),
|
||||
close: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/pages/Chat/ChatToolbar', () => ({ ChatToolbar: () => null }));
|
||||
@@ -56,36 +78,32 @@ vi.mock('@/pages/Chat/ChatInput', () => ({ ChatInput: () => null }));
|
||||
|
||||
describe('Chat history reply while sending', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
chatState.sending = true;
|
||||
chatState.error = null;
|
||||
chatState.runError = null;
|
||||
chatState.lastUserMessageAt = Date.now();
|
||||
openClawSurfaceState.visibleItems = [
|
||||
{ kind: 'message', id: 'u1', message: { role: 'user', id: 'u1', content: '你好' } },
|
||||
{
|
||||
kind: 'message',
|
||||
id: 'a1',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
id: 'a1',
|
||||
content: [{ type: 'text', text: '你好,我在。' }],
|
||||
},
|
||||
},
|
||||
];
|
||||
openClawSurfaceState.core.runtime.runStatus = { phase: 'running', runId: 'run-1' };
|
||||
openClawSurfaceState.loadHistory.mockReset();
|
||||
openClawSurfaceState.loadHistory.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('shows assistant reply from history even when sending is still true', async () => {
|
||||
const { useChatStore } = await import('@/stores/chat');
|
||||
useChatStore.setState({
|
||||
messages: [
|
||||
{ role: 'user', id: 'u1', content: '你好' },
|
||||
{ role: 'assistant', id: 'a1', content: [{ type: 'text', text: '你好,我在。' }] },
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
runError: null,
|
||||
sending: true,
|
||||
activeRunId: 'run-1',
|
||||
streamingText: '',
|
||||
streamingMessage: null,
|
||||
streamingTools: [],
|
||||
pendingFinal: false,
|
||||
lastUserMessageAt: Date.now(),
|
||||
currentSessionKey: 'agent:main:main',
|
||||
currentAgentId: 'main',
|
||||
});
|
||||
|
||||
const { Chat } = await import('@/pages/Chat');
|
||||
it('shows assistant history while the legacy composer is still marked sending', () => {
|
||||
render(<Chat />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('你好,我在。')).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByText('Thinking')).toBeNull();
|
||||
expect(screen.getByText('你好,我在。')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('chat-running-pulse')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('chat-run-status')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { ChatInput } from '@/pages/Chat/ChatInput';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
const hostApiFetchMock = vi.hoisted(() => vi.fn());
|
||||
@@ -18,6 +19,7 @@ const { agentsState, chatState, gatewayState, providersState, artifactPanelMocks
|
||||
providersState: {
|
||||
accounts: [] as Array<Record<string, unknown>>,
|
||||
statuses: [] as Array<Record<string, unknown>>,
|
||||
vendors: [] as Array<Record<string, unknown>>,
|
||||
defaultAccountId: null as string | null,
|
||||
refreshProviderSnapshot: vi.fn(),
|
||||
},
|
||||
@@ -84,6 +86,10 @@ function translate(key: string, vars?: Record<string, unknown>): string {
|
||||
return 'Loading skills...';
|
||||
case 'composer.skillEmpty':
|
||||
return 'No matching skills found';
|
||||
case 'composer.slashCommands':
|
||||
return 'Slash commands';
|
||||
case 'composer.slashSkillsHeading':
|
||||
return 'Skills';
|
||||
case 'composer.pickAgent':
|
||||
return 'Choose agent';
|
||||
case 'composer.clearTarget':
|
||||
@@ -100,8 +106,14 @@ function translate(key: string, vars?: Record<string, unknown>): string {
|
||||
return 'Stop';
|
||||
case 'composer.gatewayConnected':
|
||||
return 'connected';
|
||||
case 'composer.gatewayConnectedState':
|
||||
return 'Gateway connected';
|
||||
case 'composer.gatewayStartingState':
|
||||
return 'Gateway starting';
|
||||
case 'composer.gatewayPid':
|
||||
return ` | PID: ${String(vars?.pid ?? '')}`;
|
||||
case 'composer.gatewayStatus':
|
||||
return `gateway ${String(vars?.state ?? '')} | port: ${String(vars?.port ?? '')} ${String(vars?.pid ?? '')}`.trim();
|
||||
return `${String(vars?.state ?? '')} | port: ${String(vars?.port ?? '')}${String(vars?.pid ?? '')}`;
|
||||
case 'composer.retryFailedAttachments':
|
||||
return 'Retry failed attachments';
|
||||
case 'composer.skillPreviewTooltip':
|
||||
@@ -119,10 +131,10 @@ vi.mock('react-i18next', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
function renderChatInput(onSend = vi.fn()) {
|
||||
function renderChatInput(onSend = vi.fn(), props: Partial<ComponentProps<typeof ChatInput>> = {}) {
|
||||
return render(
|
||||
<TooltipProvider>
|
||||
<ChatInput onSend={onSend} />
|
||||
<ChatInput onSend={onSend} {...props} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
}
|
||||
@@ -132,10 +144,12 @@ describe('ChatInput agent targeting', () => {
|
||||
agentsState.agents = [];
|
||||
agentsState.defaultModelRef = null;
|
||||
agentsState.updateAgentModel.mockReset();
|
||||
agentsState.updateAgentModel.mockResolvedValue(undefined);
|
||||
chatState.currentAgentId = 'main';
|
||||
gatewayState.status = { state: 'running', port: 18789 };
|
||||
providersState.accounts = [];
|
||||
providersState.statuses = [];
|
||||
providersState.vendors = [];
|
||||
providersState.defaultAccountId = null;
|
||||
providersState.refreshProviderSnapshot.mockReset();
|
||||
vi.mocked(hostApiFetchMock).mockReset();
|
||||
@@ -283,6 +297,34 @@ describe('ChatInput agent targeting', () => {
|
||||
expect(screen.getByTestId('chat-model-picker-button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('keeps an existing model override while provider model options are still loading', async () => {
|
||||
agentsState.agents = [
|
||||
{
|
||||
id: 'main',
|
||||
name: 'Main',
|
||||
isDefault: true,
|
||||
modelDisplay: 'glm-5.2',
|
||||
modelRef: 'custom-customec/glm-5.2',
|
||||
overrideModelRef: 'custom-customec/glm-5.2',
|
||||
inheritedModel: false,
|
||||
workspace: '~/.openclaw/workspace',
|
||||
agentDir: '~/.openclaw/agents/main/agent',
|
||||
mainSessionKey: 'agent:main:main',
|
||||
channelTypes: [],
|
||||
},
|
||||
];
|
||||
agentsState.defaultModelRef = 'custom-customcb/mimo-v2.5';
|
||||
providersState.accounts = [];
|
||||
providersState.statuses = [];
|
||||
|
||||
renderChatInput();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(providersState.refreshProviderSnapshot).toHaveBeenCalled();
|
||||
});
|
||||
expect(agentsState.updateAgentModel).not.toHaveBeenCalledWith('main', null);
|
||||
});
|
||||
|
||||
it('shows starting status while gateway is running but not yet ready', () => {
|
||||
gatewayState.status = { state: 'running', port: 18789, gatewayReady: false };
|
||||
agentsState.agents = [
|
||||
@@ -301,7 +343,7 @@ describe('ChatInput agent targeting', () => {
|
||||
|
||||
renderChatInput();
|
||||
|
||||
expect(screen.getByText(/gateway starting \| port: 18789/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('Gateway starting | port: 18789')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the skill trigger after the @ agent picker', () => {
|
||||
@@ -395,6 +437,227 @@ describe('ChatInput agent targeting', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps slash skill descriptions to one truncated line', async () => {
|
||||
const longDescription = 'Create, edit, review, redline, and polish PDF documents with a very long description that must not wrap in the slash menu.';
|
||||
agentsState.agents = [
|
||||
{
|
||||
id: 'main',
|
||||
name: 'Main',
|
||||
isDefault: true,
|
||||
modelDisplay: 'MiniMax',
|
||||
inheritedModel: true,
|
||||
workspace: '~/.openclaw/workspace',
|
||||
agentDir: '~/.openclaw/agents/main/agent',
|
||||
mainSessionKey: 'agent:main:main',
|
||||
channelTypes: [],
|
||||
},
|
||||
];
|
||||
vi.mocked(hostApiFetchMock).mockResolvedValue({
|
||||
success: true,
|
||||
skills: [
|
||||
{
|
||||
name: 'pdf',
|
||||
description: longDescription,
|
||||
source: 'openclaw',
|
||||
sourceLabel: 'OpenClaw',
|
||||
manifestPath: '/tmp/openclaw/skills/pdf/SKILL.md',
|
||||
baseDir: '/tmp/openclaw/skills/pdf',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
renderChatInput();
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '/' } });
|
||||
|
||||
const option = await screen.findByTestId('chat-slash-skill-pdf');
|
||||
const description = within(option).getByText(longDescription);
|
||||
expect(description).toHaveClass('truncate');
|
||||
expect(description).toHaveClass('whitespace-nowrap');
|
||||
});
|
||||
|
||||
it('renders the slash skills heading as non-interactive text', async () => {
|
||||
agentsState.agents = [
|
||||
{
|
||||
id: 'main',
|
||||
name: 'Main',
|
||||
isDefault: true,
|
||||
modelDisplay: 'MiniMax',
|
||||
inheritedModel: true,
|
||||
workspace: '~/.openclaw/workspace',
|
||||
agentDir: '~/.openclaw/agents/main/agent',
|
||||
mainSessionKey: 'agent:main:main',
|
||||
channelTypes: [],
|
||||
},
|
||||
];
|
||||
vi.mocked(hostApiFetchMock).mockResolvedValue({
|
||||
success: true,
|
||||
skills: [
|
||||
{
|
||||
name: 'create-skill',
|
||||
description: 'Create and refine reusable skills.',
|
||||
source: 'workspace',
|
||||
sourceLabel: 'Workspace',
|
||||
manifestPath: '/tmp/workspace/skill/create-skill/SKILL.md',
|
||||
baseDir: '/tmp/workspace/skill/create-skill',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
renderChatInput();
|
||||
|
||||
const textbox = screen.getByRole('textbox') as HTMLTextAreaElement;
|
||||
fireEvent.change(textbox, { target: { value: '/' } });
|
||||
const heading = await screen.findByTestId('chat-slash-skills-heading');
|
||||
|
||||
expect(heading).toHaveTextContent('Skills');
|
||||
expect(heading.tagName).not.toBe('BUTTON');
|
||||
fireEvent.click(heading);
|
||||
|
||||
expect(textbox).toHaveValue('/');
|
||||
expect(screen.queryByPlaceholderText('Search skills')).not.toBeInTheDocument();
|
||||
expect(await screen.findByTestId('chat-slash-skill-create-skill')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('selects slash skills with arrow keys from the textarea', async () => {
|
||||
const onSend = vi.fn();
|
||||
agentsState.agents = [
|
||||
{
|
||||
id: 'main',
|
||||
name: 'Main',
|
||||
isDefault: true,
|
||||
modelDisplay: 'MiniMax',
|
||||
inheritedModel: true,
|
||||
workspace: '~/.openclaw/workspace',
|
||||
agentDir: '~/.openclaw/agents/main/agent',
|
||||
mainSessionKey: 'agent:main:main',
|
||||
channelTypes: [],
|
||||
},
|
||||
];
|
||||
vi.mocked(hostApiFetchMock).mockResolvedValue({
|
||||
success: true,
|
||||
skills: [
|
||||
{
|
||||
name: 'create-skill',
|
||||
description: 'Create and refine reusable skills.',
|
||||
source: 'workspace',
|
||||
sourceLabel: 'Workspace',
|
||||
manifestPath: '/tmp/workspace/skill/create-skill/SKILL.md',
|
||||
baseDir: '/tmp/workspace/skill/create-skill',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
renderChatInput(onSend);
|
||||
|
||||
const textbox = screen.getByRole('textbox') as HTMLTextAreaElement;
|
||||
fireEvent.change(textbox, { target: { value: '/' } });
|
||||
expect(await screen.findByTestId('chat-slash-skills-heading')).toHaveTextContent('Skills');
|
||||
expect(await screen.findByTestId('chat-slash-skill-create-skill')).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
fireEvent.keyDown(textbox, { key: 'ArrowDown' });
|
||||
expect(await screen.findByTestId('chat-slash-skill-create-skill')).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
fireEvent.keyDown(textbox, { key: 'Enter' });
|
||||
|
||||
expect(textbox).toHaveValue('/create-skill ');
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('localizes the gateway footer status with port and PID labels', () => {
|
||||
gatewayState.status = { state: 'running', port: 18789, pid: 12345, gatewayReady: true };
|
||||
|
||||
renderChatInput();
|
||||
|
||||
expect(screen.getByText('Gateway connected | port: 18789 | PID: 12345')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears composer draft and attachments when the draft scope changes', () => {
|
||||
agentsState.agents = [
|
||||
{
|
||||
id: 'main',
|
||||
name: 'Main',
|
||||
isDefault: true,
|
||||
modelDisplay: 'MiniMax',
|
||||
inheritedModel: true,
|
||||
workspace: '~/.openclaw/workspace',
|
||||
agentDir: '~/.openclaw/agents/main/agent',
|
||||
mainSessionKey: 'agent:main:main',
|
||||
channelTypes: [],
|
||||
},
|
||||
];
|
||||
|
||||
const { rerender } = render(
|
||||
<TooltipProvider>
|
||||
<ChatInput onSend={vi.fn()} draftScopeKey="agent:main:first" />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const textbox = screen.getByRole('textbox') as HTMLTextAreaElement;
|
||||
fireEvent.change(textbox, { target: { value: '/find-skills ' } });
|
||||
expect(screen.getByTestId('chat-composer-skill-token')).toHaveTextContent('/find-skills');
|
||||
|
||||
rerender(
|
||||
<TooltipProvider>
|
||||
<ChatInput onSend={vi.fn()} draftScopeKey="agent:main:second" />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
expect(textbox).toHaveValue('');
|
||||
expect(screen.queryByTestId('chat-composer-skill-token')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not allow an immediate second click to stop a just-started send', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const onSend = vi.fn();
|
||||
const onStop = vi.fn();
|
||||
agentsState.agents = [
|
||||
{
|
||||
id: 'main',
|
||||
name: 'Main',
|
||||
isDefault: true,
|
||||
modelDisplay: 'MiniMax',
|
||||
inheritedModel: true,
|
||||
workspace: '~/.openclaw/workspace',
|
||||
agentDir: '~/.openclaw/agents/main/agent',
|
||||
mainSessionKey: 'agent:main:main',
|
||||
channelTypes: [],
|
||||
},
|
||||
];
|
||||
|
||||
const { rerender } = render(
|
||||
<TooltipProvider>
|
||||
<ChatInput onSend={onSend} onStop={onStop} sending={false} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'hello' } });
|
||||
fireEvent.click(screen.getByTitle('Send'));
|
||||
expect(onSend).toHaveBeenCalledWith('hello', undefined, null);
|
||||
|
||||
rerender(
|
||||
<TooltipProvider>
|
||||
<ChatInput onSend={onSend} onStop={onStop} sending />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const stopButton = screen.getByTitle('Stop');
|
||||
expect(stopButton).toBeDisabled();
|
||||
fireEvent.click(stopButton);
|
||||
expect(onStop).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
expect(stopButton).toBeEnabled();
|
||||
fireEvent.click(stopButton);
|
||||
expect(onStop).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('removes the full inline skill token with one backspace', async () => {
|
||||
agentsState.agents = [
|
||||
{
|
||||
@@ -619,6 +882,47 @@ describe('ChatInput agent targeting', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('opens the artifact preview once for a pointer click on the inline skill token', async () => {
|
||||
agentsState.agents = [
|
||||
{
|
||||
id: 'main',
|
||||
name: 'Main',
|
||||
isDefault: true,
|
||||
modelDisplay: 'MiniMax',
|
||||
inheritedModel: true,
|
||||
workspace: '~/.openclaw/workspace',
|
||||
agentDir: '~/.openclaw/agents/main/agent',
|
||||
mainSessionKey: 'agent:main:main',
|
||||
channelTypes: [],
|
||||
},
|
||||
];
|
||||
vi.mocked(hostApiFetchMock).mockResolvedValue({
|
||||
success: true,
|
||||
skills: [
|
||||
{
|
||||
name: 'create-skill',
|
||||
description: 'Create and refine reusable skills.',
|
||||
source: 'workspace',
|
||||
sourceLabel: 'Workspace',
|
||||
manifestPath: '/tmp/workspace/skill/create-skill/SKILL.md',
|
||||
baseDir: '/tmp/workspace/skill/create-skill',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
renderChatInput();
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '/create-skill ' } });
|
||||
const token = screen.getByTestId('chat-composer-skill-token');
|
||||
|
||||
fireEvent.mouseDown(token);
|
||||
fireEvent.click(token, { detail: 1 });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(artifactPanelMocks.openPreview).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('stages dropped folders via disk path instead of buffer upload', async () => {
|
||||
vi.mocked(hostApiFetchMock).mockResolvedValueOnce([{
|
||||
id: 'folder-id',
|
||||
|
||||
@@ -1,101 +1,66 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
const { gatewayState, agentsState } = vi.hoisted(() => ({
|
||||
gatewayState: { status: { state: 'running', port: 18789 } },
|
||||
agentsState: {
|
||||
agents: [{ id: 'main', name: 'main' }] as Array<Record<string, unknown>>,
|
||||
fetchAgents: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/gateway', () => ({
|
||||
useGatewayStore: (selector: (state: typeof gatewayState) => unknown) => selector(gatewayState),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/agents', () => ({
|
||||
useAgentsStore: (selector: (state: typeof agentsState) => unknown) => selector(agentsState),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/host-api', () => ({
|
||||
hostApiFetch: vi.fn().mockResolvedValue({ success: true, messages: [] }),
|
||||
}));
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { VisibleChatItem } from '@/chat-core/openclaw-port/types';
|
||||
import { ChatSurface } from '@/pages/Chat/ChatSurface';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, params?: Record<string, unknown> | string) => {
|
||||
if (typeof params === 'string') return params;
|
||||
if (key === 'executionGraph.collapsedSummary') {
|
||||
return `collapsed ${String(params?.toolCount ?? '')} ${String(params?.processCount ?? '')}`.trim();
|
||||
}
|
||||
if (key === 'executionGraph.agentRun') return 'Main execution';
|
||||
if (key === 'executionGraph.title') return 'Execution Graph';
|
||||
if (key === 'executionGraph.collapseAction') return 'Collapse';
|
||||
if (key === 'executionGraph.thinkingLabel') return 'Thinking';
|
||||
if (key.startsWith('taskPanel.stepStatus.')) return key.split('.').at(-1) ?? key;
|
||||
return key;
|
||||
t: (key: string, vars?: Record<string, unknown>) => {
|
||||
const values: Record<string, string> = {
|
||||
'toolCard.show': 'Show',
|
||||
'toolCard.hide': 'Hide',
|
||||
'toolCard.error': 'Error',
|
||||
'toolCard.calling': 'Calling {{tool}}',
|
||||
};
|
||||
const template = values[key] ?? key;
|
||||
return Object.entries(vars ?? {}).reduce(
|
||||
(text, [name, value]) => text.replaceAll(`{{${name}}}`, String(value)),
|
||||
template,
|
||||
);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-stick-to-bottom-instant', () => ({
|
||||
useStickToBottomInstant: vi.fn(() => ({
|
||||
contentRef: { current: null },
|
||||
scrollRef: { current: null },
|
||||
scrollToBottom: vi.fn(),
|
||||
isAtBottom: true,
|
||||
})),
|
||||
}));
|
||||
describe('OpenClaw leading tool history rendering', () => {
|
||||
it('renders leading tool calls as stable tool cards and keeps later messages visible', () => {
|
||||
const items: VisibleChatItem[] = [
|
||||
{
|
||||
kind: 'message',
|
||||
id: 'orphan-exec',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
id: 'orphan-exec',
|
||||
content: [
|
||||
{ type: 'toolCall', id: 'e1', name: 'exec', input: { command: 'pwd' } },
|
||||
{ type: 'tool_result', tool_use_id: 'e1', content: '/tmp/project' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'message',
|
||||
id: 'user-1',
|
||||
message: { role: 'user', id: 'user-1', content: 'Continue the task' },
|
||||
},
|
||||
{
|
||||
kind: 'message',
|
||||
id: 'reply',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
id: 'reply',
|
||||
content: [{ type: 'text', text: 'Finished.' }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock('@/hooks/use-min-loading', () => ({
|
||||
useMinLoading: () => false,
|
||||
}));
|
||||
render(<ChatSurface items={items} />);
|
||||
|
||||
vi.mock('@/pages/Chat/ChatToolbar', () => ({ ChatToolbar: () => null }));
|
||||
vi.mock('@/pages/Chat/ChatInput', () => ({ ChatInput: () => null }));
|
||||
|
||||
describe('Chat leading orphan tool folding', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('hides paginated-prefix tool rows and folds them into the first user execution graph', async () => {
|
||||
const { useChatStore } = await import('@/stores/chat');
|
||||
useChatStore.setState({
|
||||
messages: [
|
||||
{ role: 'assistant', id: 'orphan-exec', content: [{ type: 'toolCall', id: 'e1', name: 'exec', input: {} }] },
|
||||
{ role: 'assistant', id: 'orphan-image', content: [{ type: 'toolCall', id: 'i1', name: 'image', input: {} }] },
|
||||
{ role: 'user', id: 'user-1', content: 'Continue the task' },
|
||||
{ role: 'assistant', id: 'reply', content: [{ type: 'text', text: 'Finished.' }] },
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
runError: null,
|
||||
sending: false,
|
||||
activeRunId: null,
|
||||
streamingText: '',
|
||||
streamingMessage: null,
|
||||
streamingTools: [],
|
||||
pendingFinal: false,
|
||||
lastUserMessageAt: Date.now(),
|
||||
pendingToolImages: [],
|
||||
sessions: [{ key: 'agent:main:main' }],
|
||||
currentSessionKey: 'agent:main:main',
|
||||
currentAgentId: 'main',
|
||||
sessionLabels: {},
|
||||
sessionLastActivity: {},
|
||||
thinkingLevel: null,
|
||||
});
|
||||
|
||||
const { Chat } = await import('@/pages/Chat/index');
|
||||
render(<Chat />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('chat-execution-graph')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('chat-message-0')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('chat-message-1')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('chat-tool-card')).toHaveTextContent('exec');
|
||||
expect(screen.getByText('Continue the task')).toBeInTheDocument();
|
||||
expect(screen.getByText('Finished.')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Calling exec/i }));
|
||||
|
||||
expect(screen.getByTestId('chat-tool-card')).toHaveTextContent('pwd');
|
||||
expect(screen.getByTestId('chat-tool-card')).toHaveTextContent('/tmp/project');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,10 +34,12 @@ describe('chat store loadSessions startup selection', () => {
|
||||
vi.resetModules();
|
||||
gatewayRpcMock.mockReset();
|
||||
runtimeStatus.connectedAt = Date.now();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it('opens the latest non-cron session instead of a cron heartbeat session', async () => {
|
||||
@@ -153,4 +155,45 @@ describe('chat store loadSessions startup selection', () => {
|
||||
expect(useChatStore.getState().currentSessionKey).toBe('agent:main:main');
|
||||
expect(useChatStore.getState().sessions.some((session) => session.key === 'agent:main:main')).toBe(true);
|
||||
});
|
||||
|
||||
it('restores the last selected session on renderer reload before falling back to newest history', async () => {
|
||||
window.localStorage.setItem('clawx.chat.lastSessionKey', 'agent:main:session-persisted');
|
||||
|
||||
gatewayRpcMock.mockImplementation(async (method: string) => {
|
||||
if (method === 'sessions.list') {
|
||||
return {
|
||||
sessions: [
|
||||
{
|
||||
key: 'agent:main:session-newer',
|
||||
displayName: 'Newer chat',
|
||||
updatedAt: 9_000,
|
||||
},
|
||||
{
|
||||
key: 'agent:main:session-persisted',
|
||||
displayName: 'Persisted chat',
|
||||
updatedAt: 5_000,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (method === 'chat.history') {
|
||||
return { messages: [] };
|
||||
}
|
||||
throw new Error(`Unexpected gateway RPC: ${method}`);
|
||||
});
|
||||
|
||||
const { useChatStore } = await import('@/stores/chat');
|
||||
useChatStore.setState({
|
||||
currentSessionKey: 'agent:main:main',
|
||||
currentAgentId: 'main',
|
||||
sessions: [],
|
||||
messages: [],
|
||||
sessionLabels: {},
|
||||
sessionLastActivity: {},
|
||||
});
|
||||
|
||||
await useChatStore.getState().loadSessions();
|
||||
|
||||
expect(useChatStore.getState().currentSessionKey).toBe('agent:main:session-persisted');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { chatMessageAnchorId, formatTimestamp } from '@/pages/Chat/message-utils';
|
||||
|
||||
describe('chat message utils', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('formats relative timestamps using the active browser locale', () => {
|
||||
vi.setSystemTime(new Date('2026-06-22T10:00:00Z'));
|
||||
vi.stubGlobal('navigator', { language: 'zh-CN', languages: ['zh-CN'] });
|
||||
|
||||
expect(formatTimestamp(Date.parse('2026-06-22T09:59:40Z'))).toBe('刚刚');
|
||||
expect(formatTimestamp(Date.parse('2026-06-22T09:55:00Z'))).toContain('5');
|
||||
expect(formatTimestamp(Date.parse('2026-06-22T09:55:00Z'))).not.toContain('ago');
|
||||
});
|
||||
|
||||
it('builds stable DOM-safe chat message anchors from protocol ids', () => {
|
||||
expect(chatMessageAnchorId('message 你好/42')).toBe('chat-message-anchor-message%20%E4%BD%A0%E5%A5%BD%2F42');
|
||||
expect(chatMessageAnchorId(' ')).toBeUndefined();
|
||||
expect(chatMessageAnchorId(null)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -461,7 +461,7 @@ describe('ChatMessage reply styling', () => {
|
||||
};
|
||||
|
||||
const { container } = render(<ChatMessage message={message} />);
|
||||
const bubble = container.querySelector('.rounded-2xl.bg-brand');
|
||||
const bubble = container.querySelector('.rounded-2xl.bg-primary');
|
||||
expect(bubble).not.toBeNull();
|
||||
expect(bubble).toHaveTextContent('Keep the prompt bubble.');
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -147,4 +147,34 @@ describe('Chat question directory', () => {
|
||||
chatState.messages = originalMessages;
|
||||
}
|
||||
});
|
||||
|
||||
it('strips OpenClaw media attachment markers from directory titles', async () => {
|
||||
const originalMessages = chatState.messages;
|
||||
chatState.messages = [
|
||||
{
|
||||
role: 'user',
|
||||
content: '描述这张图\n\n[media attached: /tmp/shot.png (image/png) | /tmp/shot.png]',
|
||||
},
|
||||
{ role: 'assistant', content: 'reply 1' },
|
||||
{ role: 'user', content: '继续' },
|
||||
{ role: 'assistant', content: 'reply 2' },
|
||||
];
|
||||
|
||||
try {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<Chat />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByTestId('chat-question-directory-toggle'));
|
||||
|
||||
const directory = await screen.findByTestId('chat-question-directory');
|
||||
expect(directory).toHaveTextContent('描述这张图');
|
||||
expect(directory).not.toHaveTextContent('media attached');
|
||||
expect(directory).not.toHaveTextContent('/tmp/shot.png');
|
||||
} finally {
|
||||
chatState.messages = originalMessages;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
normalizeGatewayChatRuntimeEvent,
|
||||
normalizeGatewayChatRuntimeNotification,
|
||||
} from '../../electron/gateway/chat-runtime-events';
|
||||
|
||||
describe('gateway chat runtime event normalization', () => {
|
||||
it('preserves approval identifiers and command detail', () => {
|
||||
expect(normalizeGatewayChatRuntimeEvent({
|
||||
sessionKey: 'agent:main:main',
|
||||
agentId: 'main',
|
||||
runId: 'run-approval',
|
||||
seq: 12,
|
||||
ts: 1_782_200_000_000,
|
||||
stream: 'approval',
|
||||
data: {
|
||||
phase: 'requested',
|
||||
status: 'pending',
|
||||
kind: 'exec',
|
||||
approvalId: 'approval-1',
|
||||
approvalSlug: 'approval-slug',
|
||||
itemId: 'item-1',
|
||||
toolCallId: 'call-1',
|
||||
title: 'Command approval requested',
|
||||
command: 'echo APPROVAL_OK',
|
||||
message: 'Approve this command',
|
||||
expiresAtMs: 1_782_200_060_000,
|
||||
},
|
||||
})).toEqual({
|
||||
type: 'approval.updated',
|
||||
sessionKey: 'agent:main:main',
|
||||
runId: 'run-approval',
|
||||
seq: 12,
|
||||
ts: 1_782_200_000_000,
|
||||
approvalId: 'approval-1',
|
||||
approvalSlug: 'approval-slug',
|
||||
itemId: 'item-1',
|
||||
toolCallId: 'call-1',
|
||||
title: 'Command approval requested',
|
||||
kind: 'exec',
|
||||
phase: 'requested',
|
||||
status: 'pending',
|
||||
command: 'echo APPROVAL_OK',
|
||||
message: 'Approve this command',
|
||||
agentId: 'main',
|
||||
expiresAtMs: 1_782_200_060_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes native exec approval request notifications', () => {
|
||||
expect(normalizeGatewayChatRuntimeNotification('exec.approval.requested', {
|
||||
id: 'approval-native-1',
|
||||
createdAtMs: 1_782_200_000_000,
|
||||
expiresAtMs: 1_782_200_060_000,
|
||||
request: {
|
||||
command: 'printf APPROVAL_ALLOW_OK',
|
||||
cwd: '/tmp/demo',
|
||||
agentId: 'main',
|
||||
sessionKey: 'agent:main:main',
|
||||
toolCallId: 'call-approval',
|
||||
allowedDecisions: ['allow-once', 'deny'],
|
||||
},
|
||||
})).toEqual({
|
||||
type: 'approval.updated',
|
||||
runId: 'approval:approval-native-1',
|
||||
sessionKey: 'agent:main:main',
|
||||
ts: 1_782_200_000_000,
|
||||
approvalId: 'approval-native-1',
|
||||
itemId: undefined,
|
||||
toolCallId: 'call-approval',
|
||||
title: undefined,
|
||||
kind: 'exec',
|
||||
phase: 'requested',
|
||||
status: 'pending',
|
||||
command: 'printf APPROVAL_ALLOW_OK',
|
||||
message: undefined,
|
||||
detail: 'printf APPROVAL_ALLOW_OK',
|
||||
agentId: 'main',
|
||||
expiresAtMs: 1_782_200_060_000,
|
||||
allowedDecisions: ['allow-once', 'deny'],
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes native approval resolution notifications', () => {
|
||||
expect(normalizeGatewayChatRuntimeNotification('plugin.approval.resolved', {
|
||||
id: 'plugin:approval-1',
|
||||
decision: 'deny',
|
||||
ts: 1_782_200_001_000,
|
||||
request: {
|
||||
title: 'Dangerous plugin action',
|
||||
description: 'Plugin wants to mutate files',
|
||||
agentId: 'main',
|
||||
sessionKey: 'agent:main:main',
|
||||
},
|
||||
})).toEqual(expect.objectContaining({
|
||||
type: 'approval.updated',
|
||||
runId: 'approval:plugin:approval-1',
|
||||
sessionKey: 'agent:main:main',
|
||||
ts: 1_782_200_001_000,
|
||||
approvalId: 'plugin:approval-1',
|
||||
kind: 'plugin',
|
||||
phase: 'resolved',
|
||||
status: 'denied',
|
||||
detail: 'Plugin wants to mutate files',
|
||||
agentId: 'main',
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,7 @@ vi.mock('@/lib/host-api', () => ({
|
||||
|
||||
type ChatLikeState = {
|
||||
currentSessionKey: string;
|
||||
sessions: Array<{ key: string; displayName?: string; updatedAt?: number; status?: string; hasActiveRun?: boolean }>;
|
||||
sessions: Array<{ key: string; label?: string; displayName?: string; updatedAt?: number; status?: string; hasActiveRun?: boolean }>;
|
||||
messages: Array<{ role: string; timestamp?: number; content?: unknown }>;
|
||||
sessionLabels: Record<string, string>;
|
||||
sessionLastActivity: Record<string, number>;
|
||||
@@ -74,6 +74,7 @@ function makeHarness(initial?: Partial<ChatLikeState>) {
|
||||
describe('chat session actions', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
window.localStorage.removeItem('clawx.chat.lastSessionKey');
|
||||
gatewayRpcMock.mockResolvedValue({ success: true });
|
||||
sessionDeleteMock.mockResolvedValue({ success: true });
|
||||
sessionRenameMock.mockResolvedValue({ success: true });
|
||||
@@ -198,6 +199,30 @@ describe('chat session actions', () => {
|
||||
expect(h.read().sessions.find((session) => session.key === 'agent:main:cron:job-1')?.updatedAt).toBe(1773281731621);
|
||||
});
|
||||
|
||||
it('strips media attachment protocol markers from backend session labels', async () => {
|
||||
const { createSessionActions } = await import('@/stores/chat/session-actions');
|
||||
const h = makeHarness({
|
||||
currentSessionKey: 'agent:main:main',
|
||||
sessions: [],
|
||||
});
|
||||
const actions = createSessionActions(h.set as never, h.get as never);
|
||||
|
||||
gatewayRpcMock.mockResolvedValueOnce({
|
||||
success: true,
|
||||
result: {
|
||||
sessions: [{
|
||||
key: 'agent:main:session-1',
|
||||
label: 'Describe this image [media attached: /tmp/shot.png (image/png) | /tmp/shot.png]',
|
||||
updatedAt: 1773281700000,
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
await actions.loadSessions();
|
||||
|
||||
expect(h.read().sessionLabels['agent:main:session-1']).toBe('Describe this image');
|
||||
});
|
||||
|
||||
it('clears stale current-run state when sessions.list reports the current session is idle', async () => {
|
||||
const { createSessionActions } = await import('@/stores/chat/session-actions');
|
||||
const h = makeHarness({
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { cleanSessionLabelText, toSessionLabel } from '@/stores/chat/session-label-cleanup';
|
||||
|
||||
describe('session label cleanup', () => {
|
||||
it('removes media attachment markers and message ids', () => {
|
||||
expect(cleanSessionLabelText(
|
||||
'看这张图 [media attached: /tmp/shot.png (image/png) | /tmp/shot.png] [message_id: abc]',
|
||||
)).toBe('看这张图');
|
||||
});
|
||||
|
||||
it('removes already-truncated media markers from cached labels', () => {
|
||||
expect(cleanSessionLabelText(
|
||||
'手测图片附件:请描述这张 1x1 测试图片,简短回答。 [media attach…',
|
||||
)).toBe('手测图片附件:请描述这张 1x1 测试图片,简短回答。');
|
||||
});
|
||||
|
||||
it('collapses metadata and truncates after cleanup', () => {
|
||||
const raw = [
|
||||
'Sender (untrusted metadata): someone',
|
||||
'这是一个很长的会话标题,用于验证附件协议串被移除之后才进行截断 [media attached: /tmp/a.txt (text/plain) | /tmp/a.txt]',
|
||||
].join('\n');
|
||||
|
||||
expect(toSessionLabel(raw, 12)).toBe('这是一个很长的会话标题,…');
|
||||
});
|
||||
});
|
||||
@@ -931,6 +931,61 @@ describe('useChatStore startup history retry', () => {
|
||||
await sendPromise;
|
||||
});
|
||||
|
||||
it('aborts an in-flight send with its pending idempotency key before the send ack returns', async () => {
|
||||
const { useChatStore } = await import('@/stores/chat');
|
||||
let resolveSend: ((value: { runId: string }) => void) | null = null;
|
||||
|
||||
gatewayRpcMock.mockImplementation((method: string) => {
|
||||
if (method === 'chat.send') {
|
||||
return new Promise((resolve) => {
|
||||
resolveSend = resolve as (value: { runId: string }) => void;
|
||||
});
|
||||
}
|
||||
if (method === 'chat.abort') {
|
||||
return Promise.resolve({ aborted: true });
|
||||
}
|
||||
if (method === 'chat.history') {
|
||||
return Promise.resolve({ messages: [] });
|
||||
}
|
||||
return Promise.resolve({});
|
||||
});
|
||||
|
||||
useChatStore.setState({
|
||||
currentSessionKey: 'agent:main:main',
|
||||
currentAgentId: 'main',
|
||||
sessions: [{ key: 'agent:main:main' }],
|
||||
messages: [],
|
||||
sessionLabels: {},
|
||||
sessionLastActivity: {},
|
||||
sending: false,
|
||||
activeRunId: null,
|
||||
streamingText: '',
|
||||
streamingMessage: null,
|
||||
streamingTools: [],
|
||||
pendingFinal: false,
|
||||
lastUserMessageAt: null,
|
||||
pendingToolImages: [],
|
||||
error: null,
|
||||
loading: false,
|
||||
thinkingLevel: null,
|
||||
});
|
||||
|
||||
const sendPromise = useChatStore.getState().sendMessage('long running prompt');
|
||||
const sendCall = gatewayRpcMock.mock.calls.find(([method]) => method === 'chat.send');
|
||||
const sendParams = sendCall?.[1] as { idempotencyKey?: string } | undefined;
|
||||
expect(sendParams?.idempotencyKey).toEqual(expect.any(String));
|
||||
|
||||
await useChatStore.getState().abortRun();
|
||||
|
||||
expect(gatewayRpcMock).toHaveBeenCalledWith('chat.abort', {
|
||||
sessionKey: 'agent:main:main',
|
||||
runId: sendParams?.idempotencyKey,
|
||||
});
|
||||
|
||||
resolveSend?.({ runId: sendParams!.idempotencyKey! });
|
||||
await sendPromise;
|
||||
});
|
||||
|
||||
it('does not restore a pending optimistic message after deleting the session', async () => {
|
||||
const { useChatStore } = await import('@/stores/chat');
|
||||
let resolveSend: ((value: { runId: string }) => void) | null = null;
|
||||
|
||||
@@ -1,125 +1,70 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
const { gatewayState, agentsState } = vi.hoisted(() => ({
|
||||
gatewayState: {
|
||||
status: { state: 'running', port: 18789 },
|
||||
},
|
||||
agentsState: {
|
||||
agents: [{ id: 'main', name: 'main' }] as Array<Record<string, unknown>>,
|
||||
fetchAgents: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/gateway', () => ({
|
||||
useGatewayStore: (selector: (state: typeof gatewayState) => unknown) => selector(gatewayState),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/agents', () => ({
|
||||
useAgentsStore: (selector: (state: typeof agentsState) => unknown) => selector(agentsState),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/host-api', () => ({
|
||||
hostApiFetch: vi.fn().mockResolvedValue({ success: true, messages: [] }),
|
||||
}));
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { VisibleChatItem } from '@/chat-core/openclaw-port/types';
|
||||
import { ChatSurface } from '@/pages/Chat/ChatSurface';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, params?: Record<string, unknown> | string) => {
|
||||
if (typeof params === 'string') return params;
|
||||
if (key === 'executionGraph.collapsedSummary') {
|
||||
return `collapsed ${String(params?.toolCount ?? '')} ${String(params?.processCount ?? '')}`.trim();
|
||||
}
|
||||
if (key === 'executionGraph.agentRun') return 'Main execution';
|
||||
if (key === 'executionGraph.title') return 'Execution Graph';
|
||||
if (key === 'executionGraph.collapseAction') return 'Collapse';
|
||||
if (key === 'executionGraph.thinkingLabel') return 'Thinking';
|
||||
if (key.startsWith('taskPanel.stepStatus.')) return key.split('.').at(-1) ?? key;
|
||||
return key;
|
||||
t: (key: string, vars?: Record<string, unknown>) => {
|
||||
const values: Record<string, string> = {
|
||||
'toolCard.show': 'Show',
|
||||
'toolCard.hide': 'Hide',
|
||||
'toolCard.error': 'Error',
|
||||
'toolCard.calling': 'Calling {{tool}}',
|
||||
};
|
||||
const template = values[key] ?? key;
|
||||
return Object.entries(vars ?? {}).reduce(
|
||||
(text, [name, value]) => text.replaceAll(`{{${name}}}`, String(value)),
|
||||
template,
|
||||
);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-stick-to-bottom-instant', () => ({
|
||||
useStickToBottomInstant: vi.fn(() => ({
|
||||
contentRef: { current: null },
|
||||
scrollRef: { current: null },
|
||||
scrollToBottom: vi.fn(),
|
||||
isAtBottom: true,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-min-loading', () => ({
|
||||
useMinLoading: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('@/pages/Chat/ChatToolbar', () => ({
|
||||
ChatToolbar: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@/pages/Chat/ChatInput', () => ({
|
||||
ChatInput: () => null,
|
||||
}));
|
||||
|
||||
describe('Chat tool card suppression', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('does not render standalone tool cards for messages inside a user run segment', async () => {
|
||||
const { useChatStore } = await import('@/stores/chat');
|
||||
useChatStore.setState({
|
||||
messages: [
|
||||
{ role: 'user', content: 'Generate assets' },
|
||||
{
|
||||
describe('OpenClaw tool card rendering', () => {
|
||||
it('renders tool messages as expandable cards without the removed raw-output action', () => {
|
||||
const items: VisibleChatItem[] = [
|
||||
{
|
||||
kind: 'message',
|
||||
id: 'user-1',
|
||||
message: { id: 'user-1', role: 'user', content: 'Generate assets' },
|
||||
},
|
||||
{
|
||||
kind: 'message',
|
||||
id: 'tool-exec',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
id: 'tool-exec',
|
||||
content: [{ type: 'tool_use', id: 'exec-1', name: 'exec', input: { command: 'ls' } }],
|
||||
content: [
|
||||
{ type: 'tool_use', id: 'exec-1', name: 'exec', input: { command: 'ls' } },
|
||||
{ type: 'tool_result', tool_use_id: 'exec-1', content: 'dist\nsrc' },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
id: 'tool-image',
|
||||
content: [{ type: 'tool_use', id: 'image-1', name: 'image', input: { path: '/tmp/a.png' } }],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
id: 'tool-process',
|
||||
content: [{ type: 'tool_use', id: 'process-1', name: 'process', input: { action: 'list' } }],
|
||||
},
|
||||
{
|
||||
},
|
||||
{
|
||||
kind: 'message',
|
||||
id: 'reply',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
id: 'reply',
|
||||
content: [{ type: 'text', text: 'All done.' }],
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
runError: null,
|
||||
sending: false,
|
||||
activeRunId: null,
|
||||
streamingText: '',
|
||||
streamingMessage: null,
|
||||
streamingTools: [],
|
||||
pendingFinal: false,
|
||||
lastUserMessageAt: Date.now(),
|
||||
pendingToolImages: [],
|
||||
sessions: [{ key: 'agent:main:main' }],
|
||||
currentSessionKey: 'agent:main:main',
|
||||
currentAgentId: 'main',
|
||||
sessionLabels: {},
|
||||
sessionLastActivity: {},
|
||||
thinkingLevel: null,
|
||||
});
|
||||
},
|
||||
];
|
||||
|
||||
const { Chat } = await import('@/pages/Chat/index');
|
||||
render(<Chat />);
|
||||
render(<ChatSurface items={items} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('chat-execution-graph')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('exec')).not.toBeInTheDocument();
|
||||
const card = screen.getByTestId('chat-tool-card');
|
||||
expect(card).toHaveTextContent('exec');
|
||||
expect(card.className).toContain('w-[50vw]');
|
||||
expect(screen.getByText('Generate assets')).toBeInTheDocument();
|
||||
expect(screen.getByText('All done.')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('chat-execution-graph')).toBeInTheDocument();
|
||||
expect(screen.queryByText('dist')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Raw output' })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Calling exec/i }));
|
||||
|
||||
expect(card).toHaveTextContent('dist');
|
||||
expect(screen.queryByRole('button', { name: 'Raw output' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { basename, join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
const createFromPathMock = vi.hoisted(() => vi.fn(() => ({
|
||||
isEmpty: () => true,
|
||||
getSize: () => ({ width: 1, height: 1 }),
|
||||
resize: vi.fn(),
|
||||
toPNG: vi.fn(),
|
||||
})));
|
||||
|
||||
const userDataPath = join(tmpdir(), 'clawx-files-api-user-data');
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: vi.fn(() => userDataPath),
|
||||
},
|
||||
nativeImage: {
|
||||
createFromPath: createFromPathMock,
|
||||
},
|
||||
}));
|
||||
|
||||
describe('files api', () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
createFromPathMock.mockClear();
|
||||
testDir = await mkdtemp(join(tmpdir(), 'clawx-files-api-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('preserves the original filename in staged file paths for history echo', async () => {
|
||||
const sourcePath = join(testDir, 'manual attachment.txt');
|
||||
await writeFile(sourcePath, 'hello from staged file', 'utf8');
|
||||
|
||||
const { createFilesApi } = await import('../../electron/services/files-api');
|
||||
const filesApi = createFilesApi();
|
||||
|
||||
const [result] = await filesApi.stagePaths({ filePaths: [sourcePath] });
|
||||
|
||||
expect(result.fileName).toBe('manual attachment.txt');
|
||||
expect(basename(result.stagedPath)).toMatch(/manual_attachment\.txt$/);
|
||||
});
|
||||
|
||||
it('preserves the original buffer filename in staged file paths for history echo', async () => {
|
||||
const { createFilesApi } = await import('../../electron/services/files-api');
|
||||
const filesApi = createFilesApi();
|
||||
|
||||
const result = await filesApi.stageBuffer({
|
||||
fileName: 'screen shot.png',
|
||||
mimeType: 'image/png',
|
||||
base64: Buffer.from('fake image bytes').toString('base64'),
|
||||
});
|
||||
|
||||
expect(result.fileName).toBe('screen shot.png');
|
||||
expect(basename(result.stagedPath)).toMatch(/screen_shot\.png$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
dispatchJsonRpcNotification,
|
||||
dispatchProtocolEvent,
|
||||
} from '@electron/gateway/event-dispatch';
|
||||
|
||||
function makeEmitter() {
|
||||
const emit = vi.fn(() => true);
|
||||
return { emit };
|
||||
}
|
||||
|
||||
describe('Gateway upstream agent event forwarding', () => {
|
||||
it('emits upstream-shaped agent:event for protocol agent events', () => {
|
||||
const emitter = makeEmitter();
|
||||
const payload = {
|
||||
sessionKey: 'agent:main:main',
|
||||
runId: 'run-1',
|
||||
stream: 'tool',
|
||||
seq: 1,
|
||||
data: { phase: 'start', toolCallId: 'call-1', name: 'read' },
|
||||
};
|
||||
|
||||
dispatchProtocolEvent(emitter, 'agent', payload);
|
||||
|
||||
expect(emitter.emit).toHaveBeenCalledWith('agent:event', payload);
|
||||
expect(emitter.emit).toHaveBeenCalledWith('notification', { method: 'agent', params: payload });
|
||||
});
|
||||
|
||||
it('emits upstream-shaped agent:event for JSON-RPC agent notifications', () => {
|
||||
const emitter = makeEmitter();
|
||||
const payload = {
|
||||
sessionKey: 'agent:main:main',
|
||||
runId: 'run-2',
|
||||
stream: 'lifecycle',
|
||||
seq: 2,
|
||||
data: { phase: 'end' },
|
||||
};
|
||||
|
||||
dispatchJsonRpcNotification(emitter, {
|
||||
method: 'agent',
|
||||
params: payload,
|
||||
});
|
||||
|
||||
expect(emitter.emit).toHaveBeenCalledWith('agent:event', payload);
|
||||
expect(emitter.emit).toHaveBeenCalledWith('notification', {
|
||||
method: 'agent',
|
||||
params: payload,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { dispatchProtocolEvent } from '@electron/gateway/event-dispatch';
|
||||
import {
|
||||
dispatchJsonRpcNotification,
|
||||
dispatchProtocolEvent,
|
||||
} from '@electron/gateway/event-dispatch';
|
||||
|
||||
function createMockEmitter() {
|
||||
const emitted: Array<{ event: string; payload: unknown }> = [];
|
||||
@@ -134,6 +137,61 @@ describe('dispatchProtocolEvent', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches native approval protocol events as chat runtime events', () => {
|
||||
const emitter = createMockEmitter();
|
||||
dispatchProtocolEvent(emitter, 'exec.approval.requested', {
|
||||
id: 'approval-native-1',
|
||||
createdAtMs: 1_782_200_000_000,
|
||||
expiresAtMs: 1_782_200_060_000,
|
||||
request: {
|
||||
command: 'printf APPROVAL_ALLOW_OK',
|
||||
agentId: 'main',
|
||||
sessionKey: 'agent:main:main',
|
||||
},
|
||||
});
|
||||
|
||||
expect(emitter.emit).toHaveBeenCalledWith('chat:runtime-event', expect.objectContaining({
|
||||
type: 'approval.updated',
|
||||
runId: 'approval:approval-native-1',
|
||||
approvalId: 'approval-native-1',
|
||||
kind: 'exec',
|
||||
phase: 'requested',
|
||||
status: 'pending',
|
||||
command: 'printf APPROVAL_ALLOW_OK',
|
||||
sessionKey: 'agent:main:main',
|
||||
agentId: 'main',
|
||||
}));
|
||||
expect(emitter.emit).toHaveBeenCalledWith('notification', {
|
||||
method: 'exec.approval.requested',
|
||||
params: expect.objectContaining({ id: 'approval-native-1' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches native approval JSON-RPC notifications as chat runtime events', () => {
|
||||
const emitter = createMockEmitter();
|
||||
dispatchJsonRpcNotification(emitter, {
|
||||
jsonrpc: '2.0',
|
||||
method: 'exec.approval.resolved',
|
||||
params: {
|
||||
id: 'approval-native-1',
|
||||
decision: 'allow-once',
|
||||
ts: 1_782_200_001_000,
|
||||
request: {
|
||||
command: 'printf APPROVAL_ALLOW_OK',
|
||||
sessionKey: 'agent:main:main',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(emitter.emit).toHaveBeenCalledWith('chat:runtime-event', expect.objectContaining({
|
||||
type: 'approval.updated',
|
||||
approvalId: 'approval-native-1',
|
||||
phase: 'resolved',
|
||||
status: 'approved',
|
||||
sessionKey: 'agent:main:main',
|
||||
}));
|
||||
});
|
||||
|
||||
it('suppresses tick events', () => {
|
||||
const emitter = createMockEmitter();
|
||||
dispatchProtocolEvent(emitter, 'tick', {});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user