merge: 00001KVZ9JGK0 web console mvp
This commit is contained in:
commit
bf834e8352
625
crates/workspace-server/src/companion.rs
Normal file
625
crates/workspace-server/src/companion.rs
Normal file
|
|
@ -0,0 +1,625 @@
|
|||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use worker_runtime::catalog::{CapabilityRequest, ProfileSelector};
|
||||
|
||||
use crate::hosts::{
|
||||
DiagnosticSeverity, RuntimeDiagnostic, RuntimeRegistry, WorkerInputKind, WorkerInputRequest,
|
||||
WorkerOperationState, WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, WorkerSpawnRequest,
|
||||
WorkerSummary,
|
||||
};
|
||||
|
||||
const COMPANION_RUNTIME_ID: &str = "embedded-worker-runtime";
|
||||
const MAX_MESSAGE_CHARS: usize = 8_000;
|
||||
const PROVIDERLESS_RESPONSE: &str =
|
||||
include_str!("../../../resources/prompts/worker/web_companion_providerless.md");
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CompanionState {
|
||||
Ready,
|
||||
Busy,
|
||||
Error,
|
||||
Timeout,
|
||||
Cancelled,
|
||||
Accepted,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct CompanionStatusResponse {
|
||||
pub state: CompanionState,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub worker: Option<WorkerSummary>,
|
||||
pub transport: CompanionTransportSummary,
|
||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct CompanionTransportSummary {
|
||||
pub kind: String,
|
||||
pub completion: String,
|
||||
pub limitation: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct CompanionMessageRequest {
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Default)]
|
||||
pub struct CompanionCancelRequest {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct CompanionMessageResponse {
|
||||
pub state: CompanionState,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub worker: Option<WorkerSummary>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub user_item: Option<CompanionTranscriptItem>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub assistant_item: Option<CompanionTranscriptItem>,
|
||||
pub transcript: CompanionTranscriptProjection,
|
||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct CompanionTranscriptProjection {
|
||||
pub state: CompanionState,
|
||||
pub start: usize,
|
||||
pub limit: usize,
|
||||
pub total_items: usize,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub next_start: Option<usize>,
|
||||
pub items: Vec<CompanionTranscriptItem>,
|
||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct CompanionTranscriptItem {
|
||||
pub sequence: u64,
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
pub created_at: String,
|
||||
pub source: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct CompanionTranscript {
|
||||
items: Vec<CompanionTranscriptItem>,
|
||||
next_sequence: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CompanionWorkerState {
|
||||
state: CompanionState,
|
||||
worker: Option<WorkerSummary>,
|
||||
diagnostics: Vec<RuntimeDiagnostic>,
|
||||
}
|
||||
|
||||
pub struct CompanionConsole {
|
||||
runtime: Arc<RuntimeRegistry>,
|
||||
worker: Mutex<CompanionWorkerState>,
|
||||
transcript: Mutex<CompanionTranscript>,
|
||||
}
|
||||
|
||||
impl CompanionConsole {
|
||||
pub fn new(runtime: Arc<RuntimeRegistry>) -> Self {
|
||||
let initial = spawn_companion_worker(&runtime);
|
||||
Self {
|
||||
runtime,
|
||||
worker: Mutex::new(initial),
|
||||
transcript: Mutex::new(CompanionTranscript::default()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status(&self) -> CompanionStatusResponse {
|
||||
let worker = match self.worker.lock() {
|
||||
Ok(worker) => worker,
|
||||
Err(_) => {
|
||||
return CompanionStatusResponse {
|
||||
state: CompanionState::Error,
|
||||
worker: None,
|
||||
transport: providerless_transport(),
|
||||
diagnostics: vec![diagnostic(
|
||||
"companion_state_unavailable",
|
||||
DiagnosticSeverity::Error,
|
||||
"Companion state is unavailable",
|
||||
)],
|
||||
};
|
||||
}
|
||||
};
|
||||
CompanionStatusResponse {
|
||||
state: worker.state,
|
||||
worker: worker.worker.clone(),
|
||||
transport: providerless_transport(),
|
||||
diagnostics: worker.diagnostics.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn transcript(&self, start: usize, limit: usize) -> CompanionTranscriptProjection {
|
||||
let transcript = match self.transcript.lock() {
|
||||
Ok(transcript) => transcript,
|
||||
Err(_) => {
|
||||
return CompanionTranscriptProjection {
|
||||
state: CompanionState::Error,
|
||||
start,
|
||||
limit,
|
||||
total_items: 0,
|
||||
next_start: None,
|
||||
items: Vec::new(),
|
||||
diagnostics: vec![diagnostic(
|
||||
"companion_transcript_unavailable",
|
||||
DiagnosticSeverity::Error,
|
||||
"Companion transcript is unavailable",
|
||||
)],
|
||||
};
|
||||
}
|
||||
};
|
||||
project_transcript(&transcript, CompanionState::Ready, start, limit, Vec::new())
|
||||
}
|
||||
|
||||
pub fn send_message(&self, request: CompanionMessageRequest) -> CompanionMessageResponse {
|
||||
let content = request.content.trim().to_string();
|
||||
if content.is_empty() {
|
||||
return self.rejected_message_response(diagnostic(
|
||||
"companion_message_empty",
|
||||
DiagnosticSeverity::Warning,
|
||||
"Companion message content is empty",
|
||||
));
|
||||
}
|
||||
if content.chars().count() > MAX_MESSAGE_CHARS {
|
||||
return self.rejected_message_response(diagnostic(
|
||||
"companion_message_too_large",
|
||||
DiagnosticSeverity::Warning,
|
||||
format!("Companion message exceeds the {MAX_MESSAGE_CHARS} character limit"),
|
||||
));
|
||||
}
|
||||
|
||||
let mut transcript = match self.transcript.try_lock() {
|
||||
Ok(transcript) => transcript,
|
||||
Err(std::sync::TryLockError::WouldBlock) => {
|
||||
return self.busy_message_response();
|
||||
}
|
||||
Err(std::sync::TryLockError::Poisoned(_)) => {
|
||||
return self.error_message_response(diagnostic(
|
||||
"companion_transcript_unavailable",
|
||||
DiagnosticSeverity::Error,
|
||||
"Companion transcript is unavailable",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let (worker, mut diagnostics) = match self.current_worker() {
|
||||
Ok((Some(worker), diagnostics)) => (worker, diagnostics),
|
||||
Ok((None, diagnostics)) => {
|
||||
return response_from_locked_transcript(
|
||||
&transcript,
|
||||
CompanionState::Error,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
0,
|
||||
200,
|
||||
diagnostics,
|
||||
);
|
||||
}
|
||||
Err(diagnostic) => {
|
||||
return response_from_locked_transcript(
|
||||
&transcript,
|
||||
CompanionState::Error,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
0,
|
||||
200,
|
||||
vec![diagnostic],
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let user_item = transcript.push("user", content.clone(), "browser_request", "accepted");
|
||||
match self.runtime.send_input(
|
||||
&worker.runtime_id,
|
||||
&worker.worker_id,
|
||||
WorkerInputRequest {
|
||||
kind: WorkerInputKind::User,
|
||||
content,
|
||||
},
|
||||
) {
|
||||
Ok(result) if result.state == WorkerOperationState::Accepted => {
|
||||
diagnostics.extend(result.diagnostics);
|
||||
}
|
||||
Ok(result) => {
|
||||
diagnostics.extend(result.diagnostics);
|
||||
diagnostics.push(diagnostic(
|
||||
"companion_runtime_input_rejected",
|
||||
DiagnosticSeverity::Error,
|
||||
"Embedded Companion Worker rejected the browser message",
|
||||
));
|
||||
return response_from_locked_transcript(
|
||||
&transcript,
|
||||
CompanionState::Error,
|
||||
Some(worker),
|
||||
Some(user_item),
|
||||
None,
|
||||
0,
|
||||
200,
|
||||
diagnostics,
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
diagnostics.push(diagnostic(
|
||||
"companion_runtime_input_failed",
|
||||
DiagnosticSeverity::Error,
|
||||
format!("Embedded Companion Worker input failed: {error:?}"),
|
||||
));
|
||||
return response_from_locked_transcript(
|
||||
&transcript,
|
||||
CompanionState::Error,
|
||||
Some(worker),
|
||||
Some(user_item),
|
||||
None,
|
||||
0,
|
||||
200,
|
||||
diagnostics,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
diagnostics.push(diagnostic(
|
||||
"companion_providerless_boundary",
|
||||
DiagnosticSeverity::Info,
|
||||
"Real LLM completion is not connected in this MVP; response is the backend provider-less boundary text",
|
||||
));
|
||||
let assistant_item = transcript.push(
|
||||
"assistant",
|
||||
providerless_response_text(),
|
||||
"backend_providerless_boundary",
|
||||
"complete",
|
||||
);
|
||||
response_from_locked_transcript(
|
||||
&transcript,
|
||||
CompanionState::Accepted,
|
||||
Some(worker),
|
||||
Some(user_item),
|
||||
Some(assistant_item),
|
||||
0,
|
||||
200,
|
||||
diagnostics,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn cancel(&self, _request: CompanionCancelRequest) -> CompanionMessageResponse {
|
||||
let diagnostics = vec![diagnostic(
|
||||
"companion_cancel_no_active_run",
|
||||
DiagnosticSeverity::Info,
|
||||
"Provider-less Companion Console has no active generation to cancel",
|
||||
)];
|
||||
match self.transcript.lock() {
|
||||
Ok(transcript) => response_from_locked_transcript(
|
||||
&transcript,
|
||||
CompanionState::Cancelled,
|
||||
self.status().worker,
|
||||
None,
|
||||
None,
|
||||
0,
|
||||
200,
|
||||
diagnostics,
|
||||
),
|
||||
Err(_) => CompanionMessageResponse {
|
||||
state: CompanionState::Error,
|
||||
worker: self.status().worker,
|
||||
user_item: None,
|
||||
assistant_item: None,
|
||||
transcript: CompanionTranscriptProjection {
|
||||
state: CompanionState::Error,
|
||||
start: 0,
|
||||
limit: 200,
|
||||
total_items: 0,
|
||||
next_start: None,
|
||||
items: Vec::new(),
|
||||
diagnostics: vec![diagnostic(
|
||||
"companion_transcript_unavailable",
|
||||
DiagnosticSeverity::Error,
|
||||
"Companion transcript is unavailable",
|
||||
)],
|
||||
},
|
||||
diagnostics,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn current_worker(
|
||||
&self,
|
||||
) -> Result<(Option<WorkerSummary>, Vec<RuntimeDiagnostic>), RuntimeDiagnostic> {
|
||||
let worker = self.worker.lock().map_err(|_| {
|
||||
diagnostic(
|
||||
"companion_state_unavailable",
|
||||
DiagnosticSeverity::Error,
|
||||
"Companion state is unavailable",
|
||||
)
|
||||
})?;
|
||||
Ok((worker.worker.clone(), worker.diagnostics.clone()))
|
||||
}
|
||||
|
||||
fn rejected_message_response(&self, diagnostic: RuntimeDiagnostic) -> CompanionMessageResponse {
|
||||
match self.transcript.lock() {
|
||||
Ok(transcript) => response_from_locked_transcript(
|
||||
&transcript,
|
||||
CompanionState::Rejected,
|
||||
self.status().worker,
|
||||
None,
|
||||
None,
|
||||
0,
|
||||
200,
|
||||
vec![diagnostic],
|
||||
),
|
||||
Err(_) => CompanionMessageResponse {
|
||||
state: CompanionState::Rejected,
|
||||
worker: self.status().worker,
|
||||
user_item: None,
|
||||
assistant_item: None,
|
||||
transcript: CompanionTranscriptProjection {
|
||||
state: CompanionState::Error,
|
||||
start: 0,
|
||||
limit: 200,
|
||||
total_items: 0,
|
||||
next_start: None,
|
||||
items: Vec::new(),
|
||||
diagnostics: vec![diagnostic.clone()],
|
||||
},
|
||||
diagnostics: vec![diagnostic],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn busy_message_response(&self) -> CompanionMessageResponse {
|
||||
let diagnostic = diagnostic(
|
||||
"companion_busy",
|
||||
DiagnosticSeverity::Warning,
|
||||
"Companion Console is already processing a message",
|
||||
);
|
||||
match self.transcript.lock() {
|
||||
Ok(transcript) => response_from_locked_transcript(
|
||||
&transcript,
|
||||
CompanionState::Busy,
|
||||
self.status().worker,
|
||||
None,
|
||||
None,
|
||||
0,
|
||||
200,
|
||||
vec![diagnostic],
|
||||
),
|
||||
Err(_) => CompanionMessageResponse {
|
||||
state: CompanionState::Busy,
|
||||
worker: self.status().worker,
|
||||
user_item: None,
|
||||
assistant_item: None,
|
||||
transcript: CompanionTranscriptProjection {
|
||||
state: CompanionState::Busy,
|
||||
start: 0,
|
||||
limit: 200,
|
||||
total_items: 0,
|
||||
next_start: None,
|
||||
items: Vec::new(),
|
||||
diagnostics: vec![diagnostic.clone()],
|
||||
},
|
||||
diagnostics: vec![diagnostic],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn error_message_response(&self, diagnostic: RuntimeDiagnostic) -> CompanionMessageResponse {
|
||||
CompanionMessageResponse {
|
||||
state: CompanionState::Error,
|
||||
worker: self.status().worker,
|
||||
user_item: None,
|
||||
assistant_item: None,
|
||||
transcript: CompanionTranscriptProjection {
|
||||
state: CompanionState::Error,
|
||||
start: 0,
|
||||
limit: 200,
|
||||
total_items: 0,
|
||||
next_start: None,
|
||||
items: Vec::new(),
|
||||
diagnostics: vec![diagnostic.clone()],
|
||||
},
|
||||
diagnostics: vec![diagnostic],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CompanionTranscript {
|
||||
fn push(
|
||||
&mut self,
|
||||
role: impl Into<String>,
|
||||
content: impl Into<String>,
|
||||
source: impl Into<String>,
|
||||
status: impl Into<String>,
|
||||
) -> CompanionTranscriptItem {
|
||||
self.next_sequence = self.next_sequence.saturating_add(1);
|
||||
let item = CompanionTranscriptItem {
|
||||
sequence: self.next_sequence,
|
||||
role: role.into(),
|
||||
content: content.into(),
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
source: source.into(),
|
||||
status: status.into(),
|
||||
};
|
||||
self.items.push(item.clone());
|
||||
item
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_companion_worker(runtime: &RuntimeRegistry) -> CompanionWorkerState {
|
||||
let request = WorkerSpawnRequest {
|
||||
intent: WorkerSpawnIntent::WorkspaceCompanion,
|
||||
requested_worker_name: Some("workspace-companion".to_string()),
|
||||
acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted {
|
||||
expected_segments: 0,
|
||||
},
|
||||
profile: Some(ProfileSelector::RuntimeDefault),
|
||||
config_bundle: None,
|
||||
requested_capabilities: vec![CapabilityRequest::named("conversation")],
|
||||
};
|
||||
match runtime.spawn_worker(COMPANION_RUNTIME_ID, request) {
|
||||
Ok(result) if result.state == WorkerOperationState::Accepted => CompanionWorkerState {
|
||||
state: CompanionState::Ready,
|
||||
worker: result.worker,
|
||||
diagnostics: result.diagnostics,
|
||||
},
|
||||
Ok(result) => CompanionWorkerState {
|
||||
state: CompanionState::Error,
|
||||
worker: result.worker,
|
||||
diagnostics: result.diagnostics,
|
||||
},
|
||||
Err(error) => CompanionWorkerState {
|
||||
state: CompanionState::Error,
|
||||
worker: None,
|
||||
diagnostics: vec![diagnostic(
|
||||
"companion_worker_spawn_failed",
|
||||
DiagnosticSeverity::Error,
|
||||
format!("Companion Worker spawn failed: {error:?}"),
|
||||
)],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn response_from_locked_transcript(
|
||||
transcript: &CompanionTranscript,
|
||||
state: CompanionState,
|
||||
worker: Option<WorkerSummary>,
|
||||
user_item: Option<CompanionTranscriptItem>,
|
||||
assistant_item: Option<CompanionTranscriptItem>,
|
||||
start: usize,
|
||||
limit: usize,
|
||||
diagnostics: Vec<RuntimeDiagnostic>,
|
||||
) -> CompanionMessageResponse {
|
||||
CompanionMessageResponse {
|
||||
state,
|
||||
worker,
|
||||
user_item,
|
||||
assistant_item,
|
||||
transcript: project_transcript(transcript, state, start, limit, diagnostics.clone()),
|
||||
diagnostics,
|
||||
}
|
||||
}
|
||||
|
||||
fn project_transcript(
|
||||
transcript: &CompanionTranscript,
|
||||
state: CompanionState,
|
||||
start: usize,
|
||||
limit: usize,
|
||||
diagnostics: Vec<RuntimeDiagnostic>,
|
||||
) -> CompanionTranscriptProjection {
|
||||
let limit = limit.min(200);
|
||||
let total_items = transcript.items.len();
|
||||
let end = start.saturating_add(limit).min(total_items);
|
||||
let items = if start < total_items {
|
||||
transcript.items[start..end].to_vec()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
CompanionTranscriptProjection {
|
||||
state,
|
||||
start,
|
||||
limit,
|
||||
total_items,
|
||||
next_start: (end < total_items).then_some(end),
|
||||
items,
|
||||
diagnostics,
|
||||
}
|
||||
}
|
||||
|
||||
fn providerless_response_text() -> String {
|
||||
PROVIDERLESS_RESPONSE.trim().to_string()
|
||||
}
|
||||
|
||||
fn providerless_transport() -> CompanionTransportSummary {
|
||||
CompanionTransportSummary {
|
||||
kind: "providerless_backend_internal".to_string(),
|
||||
completion: "synchronous_request_response".to_string(),
|
||||
limitation: "No provider-backed LLM generation is wired in this MVP; browser messages are recorded by a backend-internal tools-less Companion Worker and receive a resource-defined boundary response.".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn diagnostic(
|
||||
code: impl Into<String>,
|
||||
severity: DiagnosticSeverity,
|
||||
message: impl Into<String>,
|
||||
) -> RuntimeDiagnostic {
|
||||
RuntimeDiagnostic {
|
||||
code: code.into(),
|
||||
severity,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::hosts::{EmbeddedWorkerRuntime, LocalWorkerRuntime, RuntimeRegistry};
|
||||
|
||||
#[test]
|
||||
fn companion_spawns_visible_worker_and_records_providerless_turn() {
|
||||
let registry = RuntimeRegistry::for_workspace(
|
||||
LocalWorkerRuntime::new("local:test", "/workspace/project", None),
|
||||
EmbeddedWorkerRuntime::new_memory("local:test"),
|
||||
);
|
||||
let registry = Arc::new(registry);
|
||||
let companion = CompanionConsole::new(registry.clone());
|
||||
|
||||
let status = companion.status();
|
||||
assert_eq!(status.state, CompanionState::Ready);
|
||||
let worker = status.worker.clone().expect("companion worker");
|
||||
assert_eq!(worker.runtime_id, COMPANION_RUNTIME_ID);
|
||||
assert_eq!(worker.role.as_deref(), Some("workspace_companion"));
|
||||
assert!(!worker.capabilities.can_stop);
|
||||
|
||||
let workers = registry.list_workers(10);
|
||||
assert!(
|
||||
workers
|
||||
.items
|
||||
.iter()
|
||||
.any(|item| item.worker_id == worker.worker_id)
|
||||
);
|
||||
|
||||
let response = companion.send_message(CompanionMessageRequest {
|
||||
content: "hello".to_string(),
|
||||
});
|
||||
assert_eq!(response.state, CompanionState::Accepted);
|
||||
assert_eq!(response.transcript.items.len(), 2);
|
||||
assert_eq!(response.transcript.items[0].role, "user");
|
||||
assert_eq!(response.transcript.items[1].role, "assistant");
|
||||
assert!(
|
||||
response.transcript.items[1]
|
||||
.content
|
||||
.contains("provider-less")
|
||||
);
|
||||
|
||||
let runtime_transcript = registry
|
||||
.transcript(COMPANION_RUNTIME_ID, &worker.worker_id, 0, 10)
|
||||
.unwrap();
|
||||
assert_eq!(runtime_transcript.items.len(), 1);
|
||||
assert_eq!(runtime_transcript.items[0].role, "user");
|
||||
|
||||
let browser_payload = serde_json::to_string(&(status, response)).unwrap();
|
||||
for forbidden in [
|
||||
"/workspace/project",
|
||||
"metadata.json",
|
||||
".jsonl",
|
||||
"/run/user/",
|
||||
] {
|
||||
assert!(
|
||||
!browser_payload.contains(forbidden),
|
||||
"companion projection leaked forbidden term {forbidden}: {browser_payload}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
//! it is not the product CLI facade. Existing `.yoi` Ticket and Objective files
|
||||
//! remain the canonical project records and are read through bounded bridge APIs.
|
||||
|
||||
pub mod companion;
|
||||
pub mod hosts;
|
||||
pub mod identity;
|
||||
pub mod observation;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ use futures::StreamExt;
|
|||
use serde::{Deserialize, Serialize};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use crate::companion::{
|
||||
CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse,
|
||||
CompanionStatusResponse, CompanionTranscriptProjection,
|
||||
};
|
||||
use crate::hosts::{
|
||||
ConfigBundleCheckResult, ConfigBundleSyncResult, DiagnosticSeverity, EmbeddedWorkerRuntime,
|
||||
HostSummary, LocalWorkerRuntime, RemoteRuntimeConfig, RemoteWorkerRuntime, RuntimeDiagnostic,
|
||||
|
|
@ -80,6 +84,7 @@ pub struct WorkspaceApi {
|
|||
store: Arc<dyn ControlPlaneStore>,
|
||||
records: LocalProjectRecordReader,
|
||||
runtime: Arc<RuntimeRegistry>,
|
||||
companion: Arc<CompanionConsole>,
|
||||
observation_proxy: BackendObservationProxy,
|
||||
}
|
||||
|
||||
|
|
@ -107,12 +112,14 @@ impl WorkspaceApi {
|
|||
.register(RemoteWorkerRuntime::new(remote_config).map_err(|err| err.into_error())?);
|
||||
}
|
||||
let runtime = Arc::new(runtime);
|
||||
let companion = Arc::new(CompanionConsole::new(runtime.clone()));
|
||||
let observation_proxy = BackendObservationProxy::new(config.runtime_event_sources.clone());
|
||||
Ok(Self {
|
||||
records: LocalProjectRecordReader::new(config.workspace_root.clone()),
|
||||
config,
|
||||
store,
|
||||
runtime,
|
||||
companion,
|
||||
observation_proxy,
|
||||
})
|
||||
}
|
||||
|
|
@ -154,6 +161,10 @@ pub fn build_router(api: WorkspaceApi) -> Router {
|
|||
.route("/api/hosts", get(list_hosts))
|
||||
.route("/api/runtimes", get(list_runtimes))
|
||||
.route("/api/workers", get(list_workers))
|
||||
.route("/api/companion/status", get(get_companion_status))
|
||||
.route("/api/companion/transcript", get(get_companion_transcript))
|
||||
.route("/api/companion/messages", post(post_companion_message))
|
||||
.route("/api/companion/cancel", post(post_companion_cancel))
|
||||
.route(
|
||||
"/api/runtimes/{runtime_id}/workers",
|
||||
post(create_runtime_worker),
|
||||
|
|
@ -221,6 +232,7 @@ pub struct ExtensionPoints {
|
|||
pub store: String,
|
||||
pub event_stream: ExtensionPointState,
|
||||
pub host_worker_bridge: ExtensionPointState,
|
||||
pub companion_console: ExtensionPointState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
|
|
@ -329,6 +341,10 @@ async fn get_workspace(State(api): State<WorkspaceApi>) -> ApiResult<Json<Worksp
|
|||
status: "read_only_local".to_string(),
|
||||
note: "Local Hosts and Workers are exposed as a read-only bridge over existing Worker metadata; no direct Worker socket, scheduling, or lifecycle control is implemented.".to_string(),
|
||||
},
|
||||
companion_console: ExtensionPointState {
|
||||
status: "providerless_mvp".to_string(),
|
||||
note: "Backend-internal tools-less Companion Worker is available through Workspace API status/transcript/message endpoints; v0 records browser messages and returns a resource-defined provider-less response instead of direct LLM generation.".to_string(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
|
@ -491,6 +507,35 @@ async fn list_workers(
|
|||
workers_response(api).map(Json)
|
||||
}
|
||||
|
||||
async fn get_companion_status(
|
||||
State(api): State<WorkspaceApi>,
|
||||
) -> ApiResult<Json<CompanionStatusResponse>> {
|
||||
Ok(Json(api.companion.status()))
|
||||
}
|
||||
|
||||
async fn get_companion_transcript(
|
||||
State(api): State<WorkspaceApi>,
|
||||
Query(query): Query<TranscriptQuery>,
|
||||
) -> ApiResult<Json<CompanionTranscriptProjection>> {
|
||||
let limit = query.limit.unwrap_or(api.config.max_records).min(200);
|
||||
let start = query.start.unwrap_or(0);
|
||||
Ok(Json(api.companion.transcript(start, limit)))
|
||||
}
|
||||
|
||||
async fn post_companion_message(
|
||||
State(api): State<WorkspaceApi>,
|
||||
Json(request): Json<CompanionMessageRequest>,
|
||||
) -> ApiResult<Json<CompanionMessageResponse>> {
|
||||
Ok(Json(api.companion.send_message(request)))
|
||||
}
|
||||
|
||||
async fn post_companion_cancel(
|
||||
State(api): State<WorkspaceApi>,
|
||||
Json(request): Json<CompanionCancelRequest>,
|
||||
) -> ApiResult<Json<CompanionMessageResponse>> {
|
||||
Ok(Json(api.companion.cancel(request)))
|
||||
}
|
||||
|
||||
async fn get_runtime_worker(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>,
|
||||
|
|
@ -1095,12 +1140,51 @@ mod tests {
|
|||
assert_eq!(runtimes["items"][0]["host_ids"][0], host_id);
|
||||
|
||||
let workers = get_json(app.clone(), "/api/workers").await;
|
||||
assert!(workers["items"].as_array().unwrap().is_empty());
|
||||
let worker_items = workers["items"].as_array().unwrap();
|
||||
let companion_worker = worker_items
|
||||
.iter()
|
||||
.find(|worker| worker["role"] == "workspace_companion")
|
||||
.expect("companion worker is visible through runtime worker API");
|
||||
assert_eq!(companion_worker["runtime_id"], "embedded-worker-runtime");
|
||||
assert_eq!(companion_worker["capabilities"]["can_stop"], false);
|
||||
assert_eq!(
|
||||
workers["diagnostics"][0]["code"],
|
||||
"local_pod_registry_unreadable"
|
||||
);
|
||||
|
||||
let companion_status = get_json(app.clone(), "/api/companion/status").await;
|
||||
assert_eq!(companion_status["state"], "ready");
|
||||
assert_eq!(companion_status["worker"]["role"], "workspace_companion");
|
||||
assert_eq!(
|
||||
companion_status["transport"]["kind"],
|
||||
"providerless_backend_internal"
|
||||
);
|
||||
assert!(!companion_status.to_string().contains("/workspace/demo"));
|
||||
|
||||
let companion_message = post_json(
|
||||
app.clone(),
|
||||
"/api/companion/messages",
|
||||
json!({ "content": "hello companion" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(companion_message["state"], "accepted");
|
||||
assert_eq!(companion_message["transcript"]["items"][0]["role"], "user");
|
||||
assert_eq!(
|
||||
companion_message["transcript"]["items"][1]["role"],
|
||||
"assistant"
|
||||
);
|
||||
assert!(
|
||||
companion_message["transcript"]["items"][1]["content"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("provider-less")
|
||||
);
|
||||
assert!(!companion_message.to_string().contains("/workspace/demo"));
|
||||
|
||||
let companion_transcript = get_json(app.clone(), "/api/companion/transcript").await;
|
||||
assert_eq!(companion_transcript["total_items"], 2);
|
||||
assert_eq!(companion_transcript["items"][1]["role"], "assistant");
|
||||
|
||||
let host_workers = get_json(app.clone(), &format!("/api/hosts/{host_id}/workers")).await;
|
||||
assert!(host_workers["items"].as_array().unwrap().is_empty());
|
||||
|
||||
|
|
|
|||
3
resources/prompts/worker/web_companion_providerless.md
Normal file
3
resources/prompts/worker/web_companion_providerless.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
You are connected to the Yoi Workspace Web Console MVP provider-less boundary.
|
||||
|
||||
I received your browser message through the backend-internal Companion Worker, but this MVP does not yet run a provider-backed LLM completion from the workspace server. The transcript/status/send path is active, tools-less, and scoped to conversation projection only. A later integration can replace this resource-defined boundary response with real Worker engine output without giving the browser runtime credentials, sockets, session paths, or filesystem authority.
|
||||
|
|
@ -440,3 +440,240 @@
|
|||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.console-shell {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.console-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.console-status {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 0.25rem;
|
||||
min-width: 10rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border-radius: 16px;
|
||||
background: #ecfeff;
|
||||
border: 1px solid #a5f3fc;
|
||||
color: #0f766e;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.console-status small {
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.console-status[data-state='busy'] {
|
||||
background: #fff7ed;
|
||||
border-color: #fed7aa;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.console-status[data-state='error'],
|
||||
.console-status[data-state='timeout'] {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.console-status[data-state='cancelled'],
|
||||
.console-status[data-state='rejected'] {
|
||||
background: #f8fafc;
|
||||
border-color: #cbd5e1;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.console-transport {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.console-transport div {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.console-transport dt {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.console-transport dd {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.console-transport p {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
color: #475569;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.console-diagnostics {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.diagnostic {
|
||||
margin: 0;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border-radius: 10px;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.diagnostic.warning,
|
||||
.diagnostic.warn {
|
||||
background: #fff7ed;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.diagnostic.error {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.transcript-card,
|
||||
.composer-card {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.transcript-list {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.transcript-item {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #dbeafe;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.transcript-item.user {
|
||||
margin-left: min(8vw, 4rem);
|
||||
background: #eff6ff;
|
||||
border-color: #bfdbfe;
|
||||
}
|
||||
|
||||
.transcript-item.assistant {
|
||||
margin-right: min(8vw, 4rem);
|
||||
background: #f0fdf4;
|
||||
border-color: #bbf7d0;
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
color: #64748b;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.message-meta strong {
|
||||
color: #0f172a;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.message-meta time {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.transcript-item p {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.55;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.composer-card label {
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.composer-card textarea {
|
||||
width: 100%;
|
||||
min-height: 8rem;
|
||||
resize: vertical;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 14px;
|
||||
padding: 0.85rem 1rem;
|
||||
font: inherit;
|
||||
color: #0f172a;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.composer-card textarea:disabled {
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.composer-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.composer-actions span {
|
||||
margin-right: auto;
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.composer-actions button {
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 0.65rem 1rem;
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.composer-actions button.secondary {
|
||||
background: #e2e8f0;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.composer-actions button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts">
|
||||
type Props = {
|
||||
currentPath?: string;
|
||||
};
|
||||
|
||||
let { currentPath = '/' }: Props = $props();
|
||||
|
||||
const active = $derived(currentPath === '/console');
|
||||
</script>
|
||||
|
||||
<section class="sidebar-section" aria-labelledby="companion-console-heading">
|
||||
<div class="section-heading-row">
|
||||
<h2 id="companion-console-heading">Console</h2>
|
||||
<span class="section-count">MVP</span>
|
||||
</div>
|
||||
<a class:active class="nav-item" href="/console" aria-current={active ? 'page' : undefined}>
|
||||
<span class="item-title">Companion Console</span>
|
||||
<span class="item-meta">status · transcript · send</span>
|
||||
</a>
|
||||
</section>
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
import CompanionNavSection from './CompanionNavSection.svelte';
|
||||
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
|
||||
import RepositoriesNavSection from './RepositoriesNavSection.svelte';
|
||||
import WorkersNavSection from './WorkersNavSection.svelte';
|
||||
|
|
@ -41,6 +42,7 @@
|
|||
</header>
|
||||
|
||||
<nav class="sidebar-sections" aria-label="Workspace sections">
|
||||
<CompanionNavSection {currentPath} />
|
||||
<RepositoriesNavSection {workspace} {currentPath} />
|
||||
<ObjectivesNavSection {currentPath} />
|
||||
<WorkersNavSection />
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export type WorkspaceResponse = {
|
|||
extension_points: {
|
||||
event_stream: ExtensionPoint;
|
||||
host_worker_bridge: ExtensionPoint;
|
||||
companion_console: ExtensionPoint;
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -209,3 +210,57 @@ export type ObjectiveListResponse = {
|
|||
invalid_records: InvalidProjectRecord[];
|
||||
record_authority: string;
|
||||
};
|
||||
|
||||
export type CompanionState =
|
||||
| 'ready'
|
||||
| 'busy'
|
||||
| 'error'
|
||||
| 'timeout'
|
||||
| 'cancelled'
|
||||
| 'accepted'
|
||||
| 'rejected';
|
||||
|
||||
export type CompanionTransportSummary = {
|
||||
kind: string;
|
||||
completion: string;
|
||||
limitation: string;
|
||||
};
|
||||
|
||||
export type CompanionStatusResponse = {
|
||||
state: CompanionState;
|
||||
worker?: Worker | null;
|
||||
transport: CompanionTransportSummary;
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type CompanionTranscriptItem = {
|
||||
sequence: number;
|
||||
role: 'user' | 'assistant' | 'system' | string;
|
||||
content: string;
|
||||
created_at: string;
|
||||
source: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CompanionTranscriptProjection = {
|
||||
state: CompanionState;
|
||||
start: number;
|
||||
limit: number;
|
||||
total_items: number;
|
||||
next_start?: number | null;
|
||||
items: CompanionTranscriptItem[];
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type CompanionMessageRequest = {
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type CompanionMessageResponse = {
|
||||
state: CompanionState;
|
||||
worker?: Worker | null;
|
||||
user_item?: CompanionTranscriptItem | null;
|
||||
assistant_item?: CompanionTranscriptItem | null;
|
||||
transcript: CompanionTranscriptProjection;
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
|
|
|||
281
web/workspace/src/routes/console/+page.svelte
Normal file
281
web/workspace/src/routes/console/+page.svelte
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
<script lang="ts">
|
||||
import WorkspaceSidebar from '$lib/workspace-sidebar/WorkspaceSidebar.svelte';
|
||||
import type {
|
||||
CompanionMessageResponse,
|
||||
CompanionState,
|
||||
CompanionStatusResponse,
|
||||
CompanionTranscriptItem,
|
||||
CompanionTranscriptProjection,
|
||||
Diagnostic,
|
||||
WorkspaceResponse
|
||||
} from '$lib/workspace-sidebar/types';
|
||||
|
||||
let workspace = $state<WorkspaceResponse | null>(null);
|
||||
let workspaceError = $state<string | null>(null);
|
||||
let status = $state<CompanionStatusResponse | null>(null);
|
||||
let transcript = $state<CompanionTranscriptProjection | null>(null);
|
||||
let draft = $state('');
|
||||
let operationState = $state<CompanionState>('ready');
|
||||
let error = $state<string | null>(null);
|
||||
let timeoutNotice = $state<string | null>(null);
|
||||
let requestId = 0;
|
||||
|
||||
const currentPath = '/console';
|
||||
const messages = $derived(transcript?.items ?? []);
|
||||
const diagnostics = $derived(mergeDiagnostics(status?.diagnostics ?? [], transcript?.diagnostics ?? []));
|
||||
const sending = $derived(operationState === 'busy');
|
||||
const canSend = $derived(draft.trim().length > 0 && !sending);
|
||||
|
||||
async function getJson<T>(path: string): Promise<T> {
|
||||
const response = await fetch(path);
|
||||
if (!response.ok) {
|
||||
throw new Error(`GET ${path} failed: ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function postJson<T>(path: string, body: unknown, timeoutMs = 45_000): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal
|
||||
});
|
||||
if (!response.ok) {
|
||||
let detail = '';
|
||||
try {
|
||||
detail = await response.text();
|
||||
} catch {
|
||||
detail = '';
|
||||
}
|
||||
throw new Error(`POST ${path} failed: ${response.status}${detail ? ` ${detail}` : ''}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
} catch (requestError) {
|
||||
if (requestError instanceof DOMException && requestError.name === 'AbortError') {
|
||||
operationState = 'timeout';
|
||||
timeoutNotice = 'Workspace server request timed out before a Companion response arrived.';
|
||||
}
|
||||
throw requestError;
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkspace() {
|
||||
workspaceError = null;
|
||||
try {
|
||||
workspace = await getJson<WorkspaceResponse>('/api/workspace');
|
||||
} catch (loadError) {
|
||||
workspaceError = loadError instanceof Error ? loadError.message : String(loadError);
|
||||
workspace = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCompanion() {
|
||||
error = null;
|
||||
timeoutNotice = null;
|
||||
try {
|
||||
const [nextStatus, nextTranscript] = await Promise.all([
|
||||
getJson<CompanionStatusResponse>('/api/companion/status'),
|
||||
getJson<CompanionTranscriptProjection>('/api/companion/transcript?limit=200')
|
||||
]);
|
||||
status = nextStatus;
|
||||
transcript = nextTranscript;
|
||||
operationState = nextStatus.state === 'error' ? 'error' : 'ready';
|
||||
} catch (loadError) {
|
||||
error = loadError instanceof Error ? loadError.message : String(loadError);
|
||||
operationState = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const content = draft.trim();
|
||||
if (!content || sending) {
|
||||
return;
|
||||
}
|
||||
const currentRequest = ++requestId;
|
||||
error = null;
|
||||
timeoutNotice = null;
|
||||
operationState = 'busy';
|
||||
try {
|
||||
const response = await postJson<CompanionMessageResponse>('/api/companion/messages', { content });
|
||||
if (currentRequest !== requestId) {
|
||||
return;
|
||||
}
|
||||
operationState = response.state;
|
||||
transcript = response.transcript;
|
||||
if (response.worker || status) {
|
||||
status = {
|
||||
state: response.state === 'accepted' ? 'ready' : response.state,
|
||||
worker: response.worker ?? status?.worker ?? null,
|
||||
transport: status?.transport ?? {
|
||||
kind: 'providerless_backend_internal',
|
||||
completion: 'synchronous_request_response',
|
||||
limitation: 'Companion transport metadata was not available during this response.'
|
||||
},
|
||||
diagnostics: response.diagnostics
|
||||
};
|
||||
}
|
||||
if (response.state === 'accepted') {
|
||||
draft = '';
|
||||
operationState = 'ready';
|
||||
} else if (response.state === 'busy') {
|
||||
error = 'Companion is busy with another message.';
|
||||
} else if (response.state === 'rejected') {
|
||||
error = diagnosticsToText(response.diagnostics) || 'Companion rejected the message.';
|
||||
} else if (response.state === 'error') {
|
||||
error = diagnosticsToText(response.diagnostics) || 'Companion returned an error.';
|
||||
}
|
||||
} catch (sendError) {
|
||||
if (currentRequest !== requestId) {
|
||||
return;
|
||||
}
|
||||
if (operationState !== 'timeout') {
|
||||
operationState = 'error';
|
||||
}
|
||||
error = sendError instanceof Error ? sendError.message : String(sendError);
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelMessage() {
|
||||
++requestId;
|
||||
operationState = 'cancelled';
|
||||
try {
|
||||
const response = await postJson<CompanionMessageResponse>('/api/companion/cancel', { reason: 'browser_cancel' }, 10_000);
|
||||
transcript = response.transcript;
|
||||
status = status
|
||||
? { ...status, state: response.state, diagnostics: response.diagnostics }
|
||||
: status;
|
||||
} catch (cancelError) {
|
||||
error = cancelError instanceof Error ? cancelError.message : String(cancelError);
|
||||
operationState = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
function mergeDiagnostics(...groups: Diagnostic[][]): Diagnostic[] {
|
||||
return groups.flat();
|
||||
}
|
||||
|
||||
function diagnosticsToText(items: Diagnostic[]): string {
|
||||
return items.map((item) => `${item.severity}: ${item.message}`).join('\n');
|
||||
}
|
||||
|
||||
function itemClass(item: CompanionTranscriptItem): string {
|
||||
if (item.role === 'assistant') {
|
||||
return 'assistant';
|
||||
}
|
||||
if (item.role === 'user') {
|
||||
return 'user';
|
||||
}
|
||||
return 'system';
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void loadWorkspace();
|
||||
void loadCompanion();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Companion Console · Yoi Workspace</title>
|
||||
<meta name="description" content="Workspace Companion Web Console MVP" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="workspace-layout">
|
||||
<WorkspaceSidebar {workspace} {workspaceError} {currentPath} />
|
||||
|
||||
<main class="shell console-shell">
|
||||
<section class="console-header card">
|
||||
<div>
|
||||
<p class="eyebrow">Backend-internal Companion</p>
|
||||
<h2>Companion Console</h2>
|
||||
<p class="section-note">
|
||||
Browser traffic stays behind Workspace API projections. No Worker socket, session path,
|
||||
runtime credential, or local session file is exposed to the frontend.
|
||||
</p>
|
||||
</div>
|
||||
<div class="console-status" data-state={operationState}>
|
||||
<span>{operationState}</span>
|
||||
{#if status?.worker}
|
||||
<small>{status.worker.label}</small>
|
||||
{:else}
|
||||
<small>worker pending</small>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if status?.transport}
|
||||
<section class="card console-transport" aria-label="Companion transport">
|
||||
<div>
|
||||
<dt>Transport</dt>
|
||||
<dd>{status.transport.kind}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Completion</dt>
|
||||
<dd>{status.transport.completion}</dd>
|
||||
</div>
|
||||
<p>{status.transport.limitation}</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if error || timeoutNotice || diagnostics.length > 0}
|
||||
<section class="card console-diagnostics" aria-label="Companion diagnostics">
|
||||
{#if timeoutNotice}
|
||||
<p class="diagnostic warning">{timeoutNotice}</p>
|
||||
{/if}
|
||||
{#if error}
|
||||
<p class="diagnostic error">{error}</p>
|
||||
{/if}
|
||||
{#each diagnostics as diagnostic}
|
||||
<p class={`diagnostic ${diagnostic.severity}`}>{diagnostic.code}: {diagnostic.message}</p>
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="card transcript-card" aria-label="Companion transcript">
|
||||
<div class="runtime-heading">
|
||||
<h3>Transcript</h3>
|
||||
<span>{transcript?.total_items ?? 0} items</span>
|
||||
</div>
|
||||
{#if messages.length === 0}
|
||||
<p class="empty-state">No Companion messages yet. Send a message to exercise the backend boundary.</p>
|
||||
{:else}
|
||||
<ol class="transcript-list">
|
||||
{#each messages as message (message.sequence)}
|
||||
<li class={`transcript-item ${itemClass(message)}`}>
|
||||
<div class="message-meta">
|
||||
<strong>{message.role}</strong>
|
||||
<span>{message.status}</span>
|
||||
<time datetime={message.created_at}>{message.created_at}</time>
|
||||
</div>
|
||||
<p>{message.content}</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<form class="card composer-card" onsubmit={sendMessage}>
|
||||
<label for="companion-message">Message Companion</label>
|
||||
<textarea
|
||||
id="companion-message"
|
||||
bind:value={draft}
|
||||
rows="4"
|
||||
maxlength="8000"
|
||||
placeholder="Ask or note something for the backend Companion boundary…"
|
||||
disabled={sending}
|
||||
></textarea>
|
||||
<div class="composer-actions">
|
||||
<span>{draft.trim().length}/8000</span>
|
||||
<button type="button" class="secondary" onclick={loadCompanion} disabled={sending}>Refresh</button>
|
||||
<button type="button" class="secondary" onclick={cancelMessage} disabled={!sending}>Cancel</button>
|
||||
<button type="submit" disabled={!canSend}>Send</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
Loading…
Reference in New Issue
Block a user