Nightly Build / build (push) Successful in 6m59s
- Add StreamDelta(SseDecoder) framing shared by OpenAI/Anthropic - OpenAiClient: stream=true + reasoning_content deltas, index-based tool_calls accumulation, usage from final chunk - AnthropicClient: message_start/content_block_*/message_delta events, thinking_delta->reasoning, input_json_delta->tool input - TokenDelta ServerEvent variant wired through ChatHub + WS broadcast - Frontend throttled flush (~15 Hz), pending bubble mutate-in-place, reasoning as collapsed-by-default <details> - Drop streaming bubble on error/llm_failed/model_fallback - i18n: chat.reasoning key added to en/fr/it
330 lines
14 KiB
Rust
330 lines
14 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
|
|
use crate::message_meta::Attachment;
|
|
|
|
// ── Client → Server ───────────────────────────────────────────────────────────
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct ClientMessage {
|
|
pub content: String,
|
|
/// Files attached to this message (uploaded beforehand via `POST /api/{source}/uploads`).
|
|
#[serde(default)]
|
|
pub attachments: Vec<Attachment>,
|
|
}
|
|
|
|
/// Typed data push from remote clients (iOS app, etc.).
|
|
/// Sent over the existing WebSocket as `{"type":"data","stream":"...","payload":{...}}`.
|
|
#[derive(Deserialize)]
|
|
pub struct InboundDataMessage {
|
|
pub stream: String,
|
|
pub payload: Value,
|
|
}
|
|
|
|
// ── Global event envelope ─────────────────────────────────────────────────────
|
|
|
|
/// Envelope that wraps every event on the global broadcast bus.
|
|
/// `source` is `None` for system/background events (cron, tic, plugins).
|
|
#[derive(Clone)]
|
|
pub struct GlobalEvent {
|
|
pub source: Option<String>,
|
|
pub session_id: Option<i64>,
|
|
pub event: ServerEvent,
|
|
}
|
|
|
|
// ── Server → Client ───────────────────────────────────────────────────────────
|
|
|
|
/// Which token stream a [`ServerEvent::TokenDelta`] belongs to.
|
|
#[derive(Clone, Copy, Serialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum TokenDeltaKind {
|
|
/// Visible answer text.
|
|
Content,
|
|
/// Model chain-of-thought (reasoning/thinking tokens).
|
|
Reasoning,
|
|
}
|
|
|
|
#[derive(Clone, Serialize)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub enum ServerEvent {
|
|
/// A tool call was started. DB status: running.
|
|
ToolStart {
|
|
tool_call_id: i64,
|
|
message_id: i64,
|
|
name: String,
|
|
arguments: Value,
|
|
/// Friendly, static card title ("Edit File", "Read File", or an MCP tool's
|
|
/// resolved friendly name). Separate from `name` (the raw LLM function id).
|
|
display_name: String,
|
|
/// Semantic icon key (`edit`/`read`/`shell`/`mcp`/…) the frontend maps to a
|
|
/// glyph + accent color. Never a glyph — the core commits to meaning, not look.
|
|
icon: String,
|
|
/// Concise human-readable label (≤60 chars): tool + primary argument.
|
|
label_short: String,
|
|
/// Verbose human-readable label (≤120 chars): tool + all meaningful arguments.
|
|
label_full: String,
|
|
/// Path to a single viewable file this call targets, if any. The
|
|
/// frontend renders it as a clickable link to the file viewer.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
path: Option<String>,
|
|
},
|
|
/// A tool call completed successfully. DB status: done.
|
|
ToolDone {
|
|
tool_call_id: i64,
|
|
result: String,
|
|
/// Result type tag: `"string"` (plain text) or `"json"` (structured
|
|
/// payload, e.g. MCP `structuredContent`). The frontend uses it to render
|
|
/// typed results instead of a raw text blob. Always populated by the
|
|
/// server; the frontend treats an absent/unknown value as plain text, so
|
|
/// older clients degrade gracefully.
|
|
result_type: String,
|
|
/// For a file-write tool: the file content before/after the write, so the
|
|
/// card renders the diff inline even for an auto-allowed write (one that
|
|
/// never emitted a `PendingWrite`). Absent for non-write tools.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
preview_old: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
preview_new: Option<String>,
|
|
},
|
|
/// A tool call failed. DB status: error.
|
|
ToolError {
|
|
tool_call_id: i64,
|
|
error: String,
|
|
},
|
|
/// A tool call was stopped by the user via `/stop`. DB status: cancelled.
|
|
/// Distinct from `ToolError`: a cancellation is deliberate, not a failure.
|
|
ToolCancelled {
|
|
tool_call_id: i64,
|
|
},
|
|
/// A tool call was denied by an approval policy or a human. DB status: rejected.
|
|
/// Distinct from `ToolError`: a denial is a policy decision, not a failure.
|
|
ToolRejected {
|
|
tool_call_id: i64,
|
|
reason: String,
|
|
},
|
|
/// A sub-agent stack frame was opened.
|
|
AgentStart {
|
|
stack_id: i64,
|
|
parent_tool_call_id: i64,
|
|
agent_id: String,
|
|
parent_agent_id: String,
|
|
depth: i64,
|
|
/// The prompt sent to the sub-agent (truncated to 500 chars by the sender).
|
|
prompt_preview: String,
|
|
},
|
|
/// A sub-agent stack frame was closed.
|
|
AgentDone {
|
|
stack_id: i64,
|
|
agent_id: String,
|
|
parent_agent_id: String,
|
|
/// The sub-agent's final response (truncated to 500 chars by the sender).
|
|
result_preview: String,
|
|
},
|
|
/// The assistant response is complete.
|
|
Done {
|
|
message_id: i64,
|
|
stack_id: i64,
|
|
content: String,
|
|
input_tokens: Option<u32>,
|
|
output_tokens: Option<u32>,
|
|
/// Chain-of-thought, when the model produced any. Lets non-streaming
|
|
/// providers surface the reasoning block live, not just from history.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
reasoning_content: Option<String>,
|
|
},
|
|
/// A fatal error occurred processing the request.
|
|
Error {
|
|
message: String,
|
|
},
|
|
/// The LLM was cut off by the token limit (finish_reason="length").
|
|
Truncated {
|
|
output_tokens: Option<u32>,
|
|
},
|
|
/// The LLM produced text alongside tool calls (reasoning before acting).
|
|
Thinking {
|
|
message_id: i64,
|
|
content: String,
|
|
input_tokens: Option<u32>,
|
|
output_tokens: Option<u32>,
|
|
/// Chain-of-thought of this tool-call round, when produced.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
reasoning_content: Option<String>,
|
|
},
|
|
/// One incremental token while the assistant response (or its reasoning)
|
|
/// is being generated. Best-effort: deltas ride the lossy broadcast bus and
|
|
/// a lagging client may miss some — the final `Done` is always authoritative
|
|
/// and carries the complete content.
|
|
TokenDelta {
|
|
kind: TokenDeltaKind,
|
|
delta: String,
|
|
},
|
|
/// A write operation requires user approval before executing (shows a diff).
|
|
PendingWrite {
|
|
request_id: i64,
|
|
tool_call_id: i64,
|
|
path: String,
|
|
old_content: Option<String>,
|
|
new_content: String,
|
|
},
|
|
/// A non-file tool call requires user approval before executing.
|
|
/// Used for MCP tools, execute_cmd, restart, and any other tool
|
|
/// that the ApprovalManager flags as `Require`.
|
|
ApprovalRequired {
|
|
request_id: i64,
|
|
tool_call_id: i64,
|
|
tool_name: String,
|
|
arguments: Value,
|
|
},
|
|
/// A sub-agent needs clarification from the user before continuing.
|
|
AgentQuestion {
|
|
request_id: i64,
|
|
tool_call_id: i64,
|
|
title: String,
|
|
question: String,
|
|
suggested_answers: Vec<String>,
|
|
},
|
|
/// A book file was written by a tool; the frontend should reload if it has it open.
|
|
FileChanged {
|
|
path: String,
|
|
},
|
|
/// Ask the frontend to open a file for the user, via `window.openFile(path)`:
|
|
/// the file-viewer page renders every kind (markdown / text / images / SVG /
|
|
/// PDF / LaTeX compiled server-side, and HTML live in an origin-isolated
|
|
/// iframe). Emitted by the `show_file_to_user` interface tool; `path` is the
|
|
/// caller's canonical **agent path** (`~/…`, `shared/{X}/…`, `projects/…`),
|
|
/// which the viewer fetches back through `GET /api/file`.
|
|
OpenFile {
|
|
path: String,
|
|
},
|
|
/// The active LLM model failed and the system switched to a fallback automatically.
|
|
ModelFallback {
|
|
from: String,
|
|
to: String,
|
|
reason: String,
|
|
},
|
|
/// All LLM fallback attempts were exhausted; the turn could not complete.
|
|
LlmFailed {
|
|
tried: Vec<String>,
|
|
last_error: String,
|
|
},
|
|
/// A new approval entered the Inbox. Emitted on the global bus when the
|
|
/// `ApprovalManager` registers a pending request, so bus subscribers (e.g.
|
|
/// the mobile-connector plugin) can re-snapshot the Inbox. Distinct from
|
|
/// `ApprovalRequired`, which is the per-session WS event carrying full args
|
|
/// for the active client.
|
|
ApprovalRequested {
|
|
request_id: i64,
|
|
tool_call_id: i64,
|
|
tool_name: String,
|
|
},
|
|
/// A pending approval or pending-write was resolved (approved or rejected).
|
|
/// Emitted on the global bus so all clients (e.g. Telegram) can update their UI.
|
|
ApprovalResolved {
|
|
request_id: i64,
|
|
tool_call_id: i64,
|
|
approved: bool,
|
|
},
|
|
/// A new clarification entered the Inbox. Emitted on the global bus when the
|
|
/// `ClarificationManager` registers a pending question. Distinct from
|
|
/// `AgentQuestion`, which is the per-session WS event for the active client.
|
|
ClarificationRequested {
|
|
request_id: i64,
|
|
title: String,
|
|
},
|
|
/// A pending clarification was resolved (answered). Emitted on the global bus
|
|
/// so all clients can update their Inbox view.
|
|
ClarificationResolved {
|
|
request_id: i64,
|
|
},
|
|
/// A server-initiated MCP elicitation entered the Inbox (e.g. an MCP server
|
|
/// asking for a sudo password mid tool-call). Carries only `request_id` +
|
|
/// `title` — never the requested value.
|
|
ElicitationRequested {
|
|
request_id: i64,
|
|
title: String,
|
|
},
|
|
/// A pending elicitation was resolved (accepted / declined / cancelled).
|
|
/// Emitted on the global bus so all clients can update their Inbox view.
|
|
ElicitationResolved {
|
|
request_id: i64,
|
|
},
|
|
/// The active session for a source was replaced (e.g. /new, /clear).
|
|
NewSession {
|
|
session_id: i64,
|
|
},
|
|
/// A user message was persisted to history; broadcast so every client (the
|
|
/// sender included) renders the bubble. Emitted at save time — when the row
|
|
/// is appended, either at turn start or at a round boundary for messages
|
|
/// injected mid-turn — so the bubble appears exactly where the agent saw it.
|
|
/// Clients render purely from this echo (no optimistic local rendering).
|
|
UserMessage {
|
|
/// Id of the `chat_history` row just written.
|
|
message_id: i64,
|
|
content: String,
|
|
/// Files attached to the message; lets secondary clients render chips live.
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
attachments: Vec<Attachment>,
|
|
},
|
|
/// Sent to a client right after it (re)connects, reporting whether a turn is
|
|
/// currently in flight for its session. Lets a reloaded page restore the
|
|
/// SEND→STOP button state instead of assuming idle.
|
|
TurnRunning {
|
|
running: bool,
|
|
},
|
|
/// The selected LLM client (model) for a source changed. Broadcast to every
|
|
/// client of the source so dropdowns/selects stay in sync. `client` is a
|
|
/// `client_names()` entry (typically `"auto"` or a model name). Driven by
|
|
/// `ChatHub::set_selected_client` — the backend is the single source of truth.
|
|
ClientSelected {
|
|
client: String,
|
|
},
|
|
/// The session security-group (permission group) changed. Broadcast to every
|
|
/// client of the source so the chat picker stays in sync — the twin of
|
|
/// `ClientSelected` for the model. `group` is the effective group id
|
|
/// (`"default"` when cleared). The backend is the single source of truth.
|
|
SecurityGroupSelected {
|
|
group: String,
|
|
},
|
|
}
|
|
|
|
impl ServerEvent {
|
|
pub fn to_json(&self) -> String {
|
|
serde_json::to_string(self).expect("ServerEvent serialization failed")
|
|
}
|
|
|
|
pub fn type_name(&self) -> &'static str {
|
|
match self {
|
|
Self::ToolStart { .. } => "tool_start",
|
|
Self::ToolDone { .. } => "tool_done",
|
|
Self::ToolError { .. } => "tool_error",
|
|
Self::ToolCancelled { .. } => "tool_cancelled",
|
|
Self::ToolRejected { .. } => "tool_rejected",
|
|
Self::AgentStart { .. } => "agent_start",
|
|
Self::AgentDone { .. } => "agent_done",
|
|
Self::Done { .. } => "done",
|
|
Self::Error { .. } => "error",
|
|
Self::Thinking { .. } => "thinking",
|
|
Self::TokenDelta { .. } => "token_delta",
|
|
Self::PendingWrite { .. } => "pending_write",
|
|
Self::ApprovalRequired { .. } => "approval_required",
|
|
Self::AgentQuestion { .. } => "agent_question",
|
|
Self::FileChanged { .. } => "file_changed",
|
|
Self::OpenFile { .. } => "open_file",
|
|
Self::Truncated { .. } => "truncated",
|
|
Self::ModelFallback { .. } => "model_fallback",
|
|
Self::LlmFailed { .. } => "llm_failed",
|
|
Self::ApprovalRequested { .. } => "approval_requested",
|
|
Self::ApprovalResolved { .. } => "approval_resolved",
|
|
Self::ClarificationRequested { .. } => "clarification_requested",
|
|
Self::ClarificationResolved { .. } => "clarification_resolved",
|
|
Self::ElicitationRequested { .. } => "elicitation_requested",
|
|
Self::ElicitationResolved { .. } => "elicitation_resolved",
|
|
Self::NewSession { .. } => "new_session",
|
|
Self::UserMessage { .. } => "user_message",
|
|
Self::TurnRunning { .. } => "turn_running",
|
|
Self::ClientSelected { .. } => "client_selected",
|
|
Self::SecurityGroupSelected { .. } => "security_group_selected",
|
|
}
|
|
}
|
|
}
|