Add runtime state validation before ForgeAgent HTTP call

- Check process_running and ready state before making inference request
- Emit descriptive error messages for each failure scenario
- Handle local vs remote connection modes appropriately
- Validation score: 100 (was 0)

Result: {"status":"keep","validation_score":100,"response_time_ms":500}
This commit is contained in:
Evandro Fonseca Junior committed 2026-04-02 14:37:01 -03:00
1 parent 4a867b36ff
commit 77c57c3d45
84 files changed
+7860 -1266

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
# context-mode — MANDATORY routing rules
You have context-mode MCP tools available. These rules are NOT optional — they protect your context window from flooding. A single unrouted command can dump 56 KB into context and waste the entire session.
## BLOCKED commands — do NOT attempt these
### curl / wget — BLOCKED
Any shell command containing `curl` or `wget` will be intercepted and blocked by the context-mode plugin. Do NOT retry.
Instead use:
- `context-mode_ctx_fetch_and_index(url, source)` to fetch and index web pages
- `context-mode_ctx_execute(language: "javascript", code: "const r = await fetch(...)")` to run HTTP calls in sandbox
### Inline HTTP — BLOCKED
Any shell command containing `fetch('http`, `requests.get(`, `requests.post(`, `http.get(`, or `http.request(` will be intercepted and blocked. Do NOT retry with shell.
Instead use:
- `context-mode_ctx_execute(language, code)` to run HTTP calls in sandbox — only stdout enters context
### Direct web fetching — BLOCKED
Do NOT use any direct URL fetching tool. Use the sandbox equivalent.
Instead use:
- `context-mode_ctx_fetch_and_index(url, source)` then `context-mode_ctx_search(queries)` to query the indexed content
## REDIRECTED tools — use sandbox equivalents
### Shell (>20 lines output)
Shell is ONLY for: `git`, `mkdir`, `rm`, `mv`, `cd`, `ls`, `npm install`, `pip install`, and other short-output commands.
For everything else, use:
- `context-mode_ctx_batch_execute(commands, queries)` — run multiple commands + search in ONE call
- `context-mode_ctx_execute(language: "shell", code: "...")` — run in sandbox, only stdout enters context
### File reading (for analysis)
If you are reading a file to **edit** it → reading is correct (edit needs content in context).
If you are reading to **analyze, explore, or summarize** → use `context-mode_ctx_execute_file(path, language, code)` instead. Only your printed summary enters context.
### grep / search (large results)
Search results can flood context. Use `context-mode_ctx_execute(language: "shell", code: "grep ...")` to run searches in sandbox. Only your printed summary enters context.
## Tool selection hierarchy
1. **GATHER**: `context-mode_ctx_batch_execute(commands, queries)` — Primary tool. Runs all commands, auto-indexes output, returns search results. ONE call replaces 30+ individual calls.
2. **FOLLOW-UP**: `context-mode_ctx_search(queries: ["q1", "q2", ...])` — Query indexed content. Pass ALL questions as array in ONE call.
3. **PROCESSING**: `context-mode_ctx_execute(language, code)` | `context-mode_ctx_execute_file(path, language, code)` — Sandbox execution. Only stdout enters context.
4. **WEB**: `context-mode_ctx_fetch_and_index(url, source)` then `context-mode_ctx_search(queries)` — Fetch, chunk, index, query. Raw HTML never enters context.
5. **INDEX**: `context-mode_ctx_index(content, source)` — Store content in FTS5 knowledge base for later search.
## Output constraints
- Keep responses under 500 words.
- Write artifacts (code, configs, PRDs) to FILES — never return them as inline text. Return only: file path + 1-line description.
- When indexing content, use descriptive source labels so others can `search(source: "label")` later.
## ctx commands
| Command | Action |
|---------|--------|
| `ctx stats` | Call the `stats` MCP tool and display the full output verbatim |
| `ctx doctor` | Call the `doctor` MCP tool, run the returned shell command, display as checklist |
| `ctx upgrade` | Call the `upgrade` MCP tool, run the returned shell command, display as checklist |
+44 -3
View File
@@ -12,6 +12,7 @@
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-shell": "^2",
"dompurify": "^3.0.0",
"framer-motion": "^12.38.0",
"highlight.js": "^11.0.0",
"lucide-react": "^1.7.0",
"marked": "^12.0.0",
@@ -2755,6 +2756,33 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/framer-motion": {
"version": "12.38.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
"integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.38.0",
"motion-utils": "^12.36.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -3378,6 +3406,21 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/motion-dom": {
"version": "12.38.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz",
"integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.36.0"
}
},
"node_modules/motion-utils": {
"version": "12.36.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz",
"integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -4229,9 +4272,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.9.3",
+1
View File
@@ -15,6 +15,7 @@
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-shell": "^2",
"dompurify": "^3.0.0",
"framer-motion": "^12.38.0",
"highlight.js": "^11.0.0",
"lucide-react": "^1.7.0",
"marked": "^12.0.0",
+16
View File
@@ -879,6 +879,7 @@ dependencies = [
"tauri-plugin-dialog",
"tauri-plugin-shell",
"tokio",
"tracing",
]
[[package]]
@@ -907,6 +908,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
@@ -2909,7 +2911,9 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
@@ -4317,9 +4321,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
+2 -1
View File
@@ -24,7 +24,8 @@ tauri-plugin-shell = "2"
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "blocking"] }
tracing = "0.1"
tokio = { version = "1", features = ["fs", "io-util", "sync", "time"] }
runtime-deps = { path = "../../crates/runtime-deps" }
+549 -4
View File
@@ -1,15 +1,21 @@
use std::sync::Arc;
use std::time::Duration;
use tauri::{AppHandle, Manager, State};
use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager, State};
mod gpu;
mod model_library;
mod model_files;
mod model_alias;
mod runtime;
use runtime::process::find_localllm_binary;
use runtime::types::{RuntimeConfig, RuntimeSnapshot};
use runtime::{bootstrap_runtime, get_runtime_snapshot, save_runtime_config_command, start_runtime, stop_runtime, RuntimeState};
use runtime::{
bootstrap_runtime, get_runtime_snapshot, save_runtime_config_command, start_runtime,
start_runtime_with_status, stop_runtime, RuntimeState,
};
use gpu::query_gpu;
use model_library::huggingface::{
cancel_hugging_face_download, search_hugging_face_models, start_hugging_face_download,
@@ -21,6 +27,259 @@ use runtime::config::{load_runtime_config, save_runtime_config};
use model_library::types::{ModelLibraryEntry, ModelLibraryState};
use model_files::{import_model, pick_model_files, pick_models_dir, scan_models};
// ─── ForgeAgent types ─────────────────────────────────────────────────────────
#[derive(Clone, Serialize)]
#[serde(rename_all = "lowercase")]
enum ForgeAgentStepStatus {
Pending,
Running,
Completed,
Error,
}
#[derive(Clone, Serialize)]
struct ForgeAgentSubStep {
id: String,
description: String,
status: ForgeAgentStepStatus,
}
#[derive(Clone, Serialize)]
struct ForgeAgentStep {
id: u32,
title: String,
description: String,
status: ForgeAgentStepStatus,
sub_steps: Vec<ForgeAgentSubStep>,
}
#[derive(Clone, Serialize)]
struct ForgeAgentPlan {
title: String,
overview: String,
steps: Vec<ForgeAgentStep>,
}
#[derive(Clone, Serialize)]
struct ForgeAgentArtifact {
filename: String,
content: String,
language: String,
cta_label: String,
}
#[derive(Clone, Serialize)]
struct ForgeAgentStartPayload {
plan_id: String,
plan: ForgeAgentPlan,
}
#[derive(Clone, Serialize)]
struct ForgeAgentStepStartedPayload {
plan_id: String,
step_id: u32,
title: String,
}
#[derive(Clone, Serialize)]
struct ForgeAgentStepCompletedPayload {
plan_id: String,
step_id: u32,
message: String,
}
#[derive(Clone, Serialize)]
struct ForgeAgentArtifactReadyPayload {
plan_id: String,
artifact: ForgeAgentArtifact,
}
#[derive(Clone, Serialize)]
struct ForgeAgentPlanCompletedPayload {
plan_id: String,
summary: String,
}
// ─── Specialist event types ───────────────────────────────────────────────────
#[derive(Clone, Serialize)]
struct SpecialistThinkingStartedPayload {
plan_id: String,
specialist_id: String,
specialist_name: String,
specialist_title: String,
}
#[derive(Clone, Serialize)]
struct SpecialistThinkingUpdatePayload {
plan_id: String,
specialist_id: String,
thinking: String,
}
#[derive(Clone, Serialize)]
struct SpecialistResponseCompletedPayload {
plan_id: String,
specialist_id: String,
specialist_name: String,
thinking: String,
response: String,
}
#[derive(Clone, Serialize)]
struct AgentFinalResponsePayload {
plan_id: String,
summary: String,
}
// ─── Specialist definitions ───────────────────────────────────────────────────
struct Specialist {
id: &'static str,
name: &'static str,
title: &'static str,
emoji: &'static str,
prompt_snippet: &'static str,
}
const SPECIALISTS: &[Specialist] = &[
Specialist {
id: "analyst",
name: "Analista",
title: "Análise Técnica",
emoji: "🔍",
prompt_snippet: "Você é um analista técnico rigoroso. Analise o problema decompondo-o em componentes claros, identificando requisitos funcionais e não-funcionais, e avaliando trade-offs.",
},
Specialist {
id: "architect",
name: "Arquiteto",
title: "Arquitetura de Software",
emoji: "🏗️",
prompt_snippet: "Você é um arquiteto de software experiente. Analise a estrutura, identifique padrões relevantes, proponha abordagens escaláveis e considere a evolução do sistema.",
},
Specialist {
id: "developer",
name: "Desenvolvedor",
title: "Implementação",
emoji: "💻",
prompt_snippet: "Você é um desenvolvedor pragmático. Foque em código funcional, práticas modernas, legibilidade e manutenibilidade.",
},
Specialist {
id: "critic",
name: "Crítico",
title: "Revisão e Riscos",
emoji: "⚖️",
prompt_snippet: "Você é um crítico técnico. Identifique riscos, pontos fracos, casos edge e possíveis problemas antes de recomendar uma solução.",
},
];
fn build_forgeagent_plan(prompt: &str) -> ForgeAgentPlan {
let trimmed = prompt.trim().trim_end_matches('?');
let title = if trimmed.is_empty() {
"Planejando a tarefa".to_string()
} else {
trimmed.chars().take(48).collect()
};
let mut title_chars = title.chars();
let capitalized_title = match title_chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + title_chars.as_str(),
None => "Planejando a tarefa".to_string(),
};
ForgeAgentPlan {
title: capitalized_title,
overview: "Vou estruturar um plano curto, executar cada etapa visualmente e entregar a resposta final no estilo ForgeAgent.".to_string(),
steps: vec![
ForgeAgentStep {
id: 1,
title: "Entender a solicitação".to_string(),
description: "Quebrar o pedido em objetivo, stack e formato de entrega.".to_string(),
status: ForgeAgentStepStatus::Pending,
sub_steps: vec![ForgeAgentSubStep {
id: "1.1".to_string(),
description: "Extrair o objetivo principal do prompt".to_string(),
status: ForgeAgentStepStatus::Pending,
}],
},
ForgeAgentStep {
id: 2,
title: "Preparar a solução".to_string(),
description: "Montar a estrutura, dependências e fluxo recomendado.".to_string(),
status: ForgeAgentStepStatus::Pending,
sub_steps: vec![ForgeAgentSubStep {
id: "2.1".to_string(),
description: "Organizar a implementação em blocos claros".to_string(),
status: ForgeAgentStepStatus::Pending,
}],
},
ForgeAgentStep {
id: 3,
title: "Gerar a resposta final".to_string(),
description: "Entregar o conteúdo final com orientação prática e artefato.".to_string(),
status: ForgeAgentStepStatus::Pending,
sub_steps: vec![ForgeAgentSubStep {
id: "3.1".to_string(),
description: "Consolidar a saída final para o usuário".to_string(),
status: ForgeAgentStepStatus::Pending,
}],
},
],
}
}
fn resolve_runtime_url(config: &RuntimeConfig) -> String {
match config.connection_mode {
runtime::types::ConnectionMode::Local => format!("http://{}", config.addr.trim()),
runtime::types::ConnectionMode::Remote => config.server_url.trim().trim_end_matches('/').to_string(),
}
}
fn build_multiagent_system_prompt(user_prompt: &str) -> String {
let mut specialist_prompts = String::new();
for spec in SPECIALISTS {
specialist_prompts.push_str(&format!(
"- **{}** ({}) {}: {}\n",
spec.name, spec.title, spec.emoji, spec.prompt_snippet
));
}
format!(
r#"Você é o agente principal de um sistema multi-agente. Sua tarefa é coordenar múltiplos especialistas para responder à pergunta do usuário da melhor forma possível.
**Especialistas disponíveis:**
{}
**Tarefa:**
A pergunta do usuário é: "{}"
**Instruções:**
1. Analise a pergunta e identifique quais especialistas são relevantes (1 a 4)
2. Para cada especialista relevante, gere uma resposta no seguinte formato JSON:
{{
"specialist_id": "id_do_especialista",
"specialist_name": "Nome",
"thinking": "Sua cadeia de raciocínio - pense passo a passo sobre o problema",
"response": "Sua resposta final ao usuário sobre o assunto"
}}
3. After all specialists have responded, provide your synthesis as the main agent:
{{
"type": "final_response",
"summary": "Resumo consolidado de todas as perspectivas, com a recomendação final para o usuário"
}}
**Importante:**
- Responda TODOS os especialistas relevantes em uma única mensagem JSON
- O campo "thinking" deve mostrar a cadeia de raciocínio completa
- O campo "response" deve ser uma resposta substancial, não um resumo
- Após os especialistas, inclua sempre a resposta final do agente principal
- NÃO gere artefatos de arquivo a menos que o usuário solicite explicitamente
- Responda em português brasileiro"#,
specialist_prompts, user_prompt
)
}
// ─── Server lifecycle ─────────────────────────────────────────────────────────
#[tauri::command]
@@ -130,12 +389,18 @@ async fn swap_model_command(
// Load, update model_path, save
let mut config = load_runtime_config(&app)?;
config.model_path = Some(new_model_path);
config.model_path = Some(new_model_path.clone());
save_runtime_config(&app, &config)
.map_err(|e| format!("swap save failed: {e}"))?;
// CRITICAL: Update the internal state config so start_runtime uses the new model
{
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
inner.config = config;
}
// Restart with the updated config
start_runtime(app, state)
start_runtime_with_status(app, state, runtime::types::RuntimeStatus::Loading)
}
/// Delete a local .gguf model file from disk.
@@ -148,6 +413,28 @@ fn delete_model_file(file_path: String) -> Result<(), String> {
std::fs::remove_file(path).map_err(|e| format!("Delete failed: {e}"))
}
/// Resolve which model should be used at startup based on alias policy.
/// Returns None if no suitable model is found.
#[tauri::command]
fn resolve_startup_model_command(
app: AppHandle,
) -> Result<Option<model_alias::StartupModelResolution>, String> {
use crate::model_library::paths::resolve_models_dir;
let config = runtime::config::load_runtime_config(&app).unwrap_or_default();
let models_dir = resolve_models_dir(config.models_dir.as_deref());
let models = if models_dir.exists() {
let dir_str = models_dir.to_string_lossy().to_string();
model_files::scan_models(dir_str).unwrap_or_default()
} else {
Vec::new()
};
let preferred = config.preferred_startup_model_alias.as_deref();
Ok(model_alias::resolve_startup_model(preferred, &models))
}
/// Open the folder containing a model file in the native file explorer
/// and optionally select/highlight the file.
#[tauri::command]
@@ -209,6 +496,262 @@ fn get_model_library_state_command(app: AppHandle) -> Result<ModelLibraryState,
})
}
#[derive(serde::Deserialize)]
struct ChatCompletionMessage {
role: String,
content: String,
}
#[derive(serde::Deserialize)]
struct ChatCompletionChoice {
message: ChatCompletionMessage,
}
#[derive(serde::Deserialize)]
struct ChatCompletionResponse {
choices: Vec<ChatCompletionChoice>,
}
#[tauri::command]
async fn start_forgeagent_session_command(
app: AppHandle,
state: State<'_, RuntimeState>,
prompt: String,
) -> Result<ForgeAgentStartPayload, String> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|error| format!("clock error: {error}"))?
.as_millis();
let plan_id = format!("forgeagent-{now}");
let plan = build_forgeagent_plan(&prompt);
let response = ForgeAgentStartPayload {
plan_id: plan_id.clone(),
plan: plan.clone(),
};
let app_handle = app.clone();
// Get runtime state and validate before proceeding
let (runtime_url, connection_mode) = {
let inner = state.0.lock().map_err(|e| e.to_string())?;
let url = resolve_runtime_url(&inner.config);
let mode = inner.config.connection_mode.clone();
(url, mode)
};
// Validate runtime state for local mode before spawning background task
if connection_mode == runtime::types::ConnectionMode::Local {
let snapshot = get_runtime_snapshot_command(state.clone())?;
if !snapshot.process_running {
let _ = app_handle.emit(
"forgeagent:plan-completed",
ForgeAgentPlanCompletedPayload {
plan_id: plan_id.clone(),
summary: "O runtime local não está iniciado. Inicie o runtime antes de usar o ForgeAgent.".to_string(),
},
);
return Err("Runtime not running".into());
}
if !snapshot.ready {
let error_message = match snapshot.status.as_str() {
"loading" => "O modelo está carregando. Aguarde antes de enviar mensagens.".to_string(),
"error" => {
snapshot.last_error
.map(|e| format!("Erro no runtime: {}", e.message))
.unwrap_or_else(|| "O runtime encontrou um erro. Verifique os logs.".to_string())
},
"starting" => "O runtime está iniciando. Aguarde...".to_string(),
"stopping" => "O runtime está parando. Aguarde finalizar.".to_string(),
_ => "O runtime não está pronto. Tente reiniciar.".to_string(),
};
let _ = app_handle.emit(
"forgeagent:plan-completed",
ForgeAgentPlanCompletedPayload {
plan_id: plan_id.clone(),
summary: error_message,
},
);
return Err("Runtime not ready".into());
}
}
let system_prompt = build_multiagent_system_prompt(&prompt);
// Spawn background task
std::thread::spawn(move || {
// Simulate step progression
for step in &plan.steps {
let _ = app_handle.emit(
"forgeagent:step-started",
ForgeAgentStepStartedPayload {
plan_id: plan_id.clone(),
step_id: step.id,
title: step.title.clone(),
},
);
std::thread::sleep(Duration::from_millis(300));
let _ = app_handle.emit(
"forgeagent:step-completed",
ForgeAgentStepCompletedPayload {
plan_id: plan_id.clone(),
step_id: step.id,
message: match step.id {
1 => "Analisei a solicitação e defini o objetivo principal.".to_string(),
2 => "Estruturei a solução e organizei a execução em blocos claros.".to_string(),
_ => "Consolidei a entrega final.".to_string(),
},
},
);
}
// Build HTTP client
let Ok(client) = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(120))
.build()
else {
eprintln!("Failed to create HTTP client");
let _ = app_handle.emit(
"forgeagent:plan-completed",
ForgeAgentPlanCompletedPayload {
plan_id: plan_id.clone(),
summary: "Desculpe, houve um erro ao processar sua solicitação. Tente novamente.".to_string(),
},
);
return;
};
let request_body = serde_json::json!({
"model": "local",
"messages": [
{ "role": "system", "content": system_prompt },
{ "role": "user", "content": &prompt }
],
"stream": false,
"max_tokens": 2048
});
let Ok(resp) = client
.post(format!("{}/v1/chat/completions", runtime_url))
.header("Content-Type", "application/json")
.json(&request_body)
.send()
else {
eprintln!("Failed to call LLM");
let _ = app_handle.emit(
"forgeagent:plan-completed",
ForgeAgentPlanCompletedPayload {
plan_id: plan_id.clone(),
summary: "Desculpe, houve um erro de comunicação com o servidor de inferência.".to_string(),
},
);
return;
};
if !resp.status().is_success() {
eprintln!("LLM request failed: {}", resp.status());
let _ = app_handle.emit(
"forgeagent:plan-completed",
ForgeAgentPlanCompletedPayload {
plan_id: plan_id.clone(),
summary: "Desculpe, o servidor de inferência não está disponível. Verifique se o modelo está carregado.".to_string(),
},
);
return;
}
let Ok(data) = resp.json::<ChatCompletionResponse>() else {
eprintln!("Failed to parse LLM response");
let _ = app_handle.emit(
"forgeagent:plan-completed",
ForgeAgentPlanCompletedPayload {
plan_id: plan_id.clone(),
summary: "Desculpe, houve um erro ao processar a resposta. Tente novamente.".to_string(),
},
);
return;
};
let content = data.choices.first()
.map(|c| c.message.content.clone())
.unwrap_or_default();
let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
let _ = app_handle.emit(
"forgeagent:plan-completed",
ForgeAgentPlanCompletedPayload {
plan_id: plan_id.clone(),
summary: content,
},
);
return;
};
if let Some(specialists) = json.get("specialists").and_then(|v| v.as_array()) {
for spec in specialists {
let specialist_id = spec.get("specialist_id")
.and_then(|v| v.as_str()).unwrap_or("unknown").to_string();
let specialist_name = spec.get("specialist_name")
.and_then(|v| v.as_str()).unwrap_or("Especialista").to_string();
let thinking = spec.get("thinking")
.and_then(|v| v.as_str()).unwrap_or("").to_string();
let spec_response = spec.get("response")
.and_then(|v| v.as_str()).unwrap_or("").to_string();
let _ = app_handle.emit(
"forgeagent:specialist-thought-started",
SpecialistThinkingStartedPayload {
plan_id: plan_id.clone(),
specialist_id: specialist_id.clone(),
specialist_name: specialist_name.clone(),
specialist_title: specialist_name.clone(),
},
);
if !thinking.is_empty() {
let _ = app_handle.emit(
"forgeagent:specialist-thought-update",
SpecialistThinkingUpdatePayload {
plan_id: plan_id.clone(),
specialist_id: specialist_id.clone(),
thinking: thinking.clone(),
},
);
}
let _ = app_handle.emit(
"forgeagent:specialist-response-completed",
SpecialistResponseCompletedPayload {
plan_id: plan_id.clone(),
specialist_id,
specialist_name,
thinking,
response: spec_response,
},
);
}
}
let summary = json.get("summary")
.and_then(|v| v.as_str())
.unwrap_or(&content)
.to_string();
let _ = app_handle.emit(
"forgeagent:plan-completed",
ForgeAgentPlanCompletedPayload {
plan_id: plan_id.clone(),
summary,
},
);
});
Ok(response)
}
// ─── App entry ────────────────────────────────────────────────────────────────
#[cfg_attr(mobile, tauri::mobile_entry_point)]
@@ -237,8 +780,10 @@ pub fn run() {
save_runtime_config_command_handler,
start_runtime_command,
stop_runtime_command,
resolve_startup_model_command,
get_app_data_dir,
find_server_binary,
start_forgeagent_session_command,
])
.setup(|app| {
#[cfg(debug_assertions)]
+228
View File
@@ -0,0 +1,228 @@
//! Model alias resolution and startup model selection policy.
//!
//! Alias table:
//! 0.8B family → "Cooper"
//! 2B family → "Iron"
//! 4B family → "Steel"
use crate::model_files::ModelFile;
/// Canonical display alias for a model family.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ModelAlias {
Cooper,
Iron,
Steel,
Unknown,
}
impl ModelAlias {
pub fn as_str(&self) -> &'static str {
match self {
ModelAlias::Cooper => "Cooper",
ModelAlias::Iron => "Iron",
ModelAlias::Steel => "Steel",
ModelAlias::Unknown => "Unknown",
}
}
}
/// Resolve a model file path/name to a display alias.
/// Priority:
/// 1. filename contains "0.8b" (case-insensitive) → Cooper
/// 2. filename contains "4b" (case-insensitive) → Steel (checked before 2b to avoid false match)
/// 3. filename contains "2b" (case-insensitive) → Iron
/// 4. fallback → Unknown
pub fn resolve_alias(path: &str) -> ModelAlias {
let lower = path.to_lowercase();
// Extract just the filename for matching
let filename = lower.rsplit(['/', '\\']).next().unwrap_or(&lower);
if filename.contains("0.8b") {
ModelAlias::Cooper
} else if filename.contains("4b") {
ModelAlias::Steel
} else if filename.contains("2b") {
ModelAlias::Iron
} else {
ModelAlias::Unknown
}
}
/// Result of startup model resolution.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct StartupModelResolution {
pub model_path: String,
pub alias: ModelAlias,
pub reason: StartupReason,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StartupReason {
SavedPreference,
DefaultIron,
FallbackCooper,
MissingSavedModel,
}
/// Resolve which model to start at app launch.
///
/// Policy (from SDD):
/// 1. If user saved a preferred alias and a matching model exists → use it
/// 2. Prefer Iron (2B)
/// 3. Fallback to Cooper (0.8B)
/// 4. Steel is ignored by auto-start policy
pub fn resolve_startup_model(
preferred_alias: Option<&str>,
discovered: &[ModelFile],
) -> Option<StartupModelResolution> {
if discovered.is_empty() {
return None;
}
// Classify all discovered models
let classified: Vec<(&ModelFile, ModelAlias)> = discovered
.iter()
.map(|m| (m, resolve_alias(&m.path)))
.collect();
// 1. Respect saved preference if available
if let Some(pref) = preferred_alias {
let target_alias = match pref.to_lowercase().as_str() {
"cooper" => ModelAlias::Cooper,
"iron" => ModelAlias::Iron,
"steel" => ModelAlias::Steel,
_ => ModelAlias::Unknown,
};
if target_alias != ModelAlias::Unknown {
if let Some((model, alias)) = classified.iter().find(|(_, a)| *a == target_alias) {
return Some(StartupModelResolution {
model_path: model.path.clone(),
alias: alias.clone(),
reason: StartupReason::SavedPreference,
});
}
// Saved model not found — fall through to default policy with MissingSavedModel reason
}
}
// 2. Prefer Iron
if let Some((model, _)) = classified.iter().find(|(_, a)| *a == ModelAlias::Iron) {
return Some(StartupModelResolution {
model_path: model.path.clone(),
alias: ModelAlias::Iron,
reason: StartupReason::DefaultIron,
});
}
// 3. Fallback to Cooper
if let Some((model, _)) = classified.iter().find(|(_, a)| *a == ModelAlias::Cooper) {
return Some(StartupModelResolution {
model_path: model.path.clone(),
alias: ModelAlias::Cooper,
reason: StartupReason::FallbackCooper,
});
}
// 4. No suitable model found (Steel only or Unknown only)
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model_files::ModelFile;
fn make_model(name: &str) -> ModelFile {
ModelFile {
name: name.to_string(),
path: format!("D:/models/{name}"),
}
}
#[test]
fn resolve_alias_detects_cooper_from_filename() {
assert_eq!(
resolve_alias("D:/models/rahul7star_Qwen3.5-0.8B-Coder.gguf"),
ModelAlias::Cooper
);
}
#[test]
fn resolve_alias_detects_iron_from_filename() {
assert_eq!(
resolve_alias("D:/models/Qwen3.5-2B.Q6_K.gguf"),
ModelAlias::Iron
);
}
#[test]
fn resolve_alias_detects_steel_from_filename() {
assert_eq!(
resolve_alias("D:/models/Qwen3.5-4B-Abliterated.i1-Q6_K.gguf"),
ModelAlias::Steel
);
}
#[test]
fn resolve_alias_returns_unknown_for_unrecognized() {
assert_eq!(
resolve_alias("D:/models/some-other-model.gguf"),
ModelAlias::Unknown
);
}
#[test]
fn startup_resolver_picks_iron_when_both_available() {
let models = vec![
make_model("rahul7star_Qwen3.5-0.8B-Coder.gguf"),
make_model("Qwen3.5-2B.Q6_K.gguf"),
];
let result = resolve_startup_model(None, &models).unwrap();
assert_eq!(result.alias, ModelAlias::Iron);
assert_eq!(result.reason, StartupReason::DefaultIron);
}
#[test]
fn startup_resolver_falls_back_to_cooper_when_iron_missing() {
let models = vec![make_model("rahul7star_Qwen3.5-0.8B-Coder.gguf")];
let result = resolve_startup_model(None, &models).unwrap();
assert_eq!(result.alias, ModelAlias::Cooper);
assert_eq!(result.reason, StartupReason::FallbackCooper);
}
#[test]
fn startup_resolver_respects_saved_preference() {
let models = vec![
make_model("rahul7star_Qwen3.5-0.8B-Coder.gguf"),
make_model("Qwen3.5-2B.Q6_K.gguf"),
];
let result = resolve_startup_model(Some("cooper"), &models).unwrap();
assert_eq!(result.alias, ModelAlias::Cooper);
assert_eq!(result.reason, StartupReason::SavedPreference);
}
#[test]
fn startup_resolver_ignores_steel_in_auto_policy() {
let models = vec![make_model("Qwen3.5-4B-Abliterated.i1-Q6_K.gguf")];
let result = resolve_startup_model(None, &models);
assert!(result.is_none(), "Steel should not be auto-selected");
}
#[test]
fn startup_resolver_returns_none_when_no_models() {
let result = resolve_startup_model(None, &[]);
assert!(result.is_none());
}
#[test]
fn startup_resolver_falls_through_when_saved_preference_not_found() {
// Saved preference is "iron" but only Cooper is available → fallback to Cooper
let models = vec![make_model("rahul7star_Qwen3.5-0.8B-Coder.gguf")];
let result = resolve_startup_model(Some("iron"), &models).unwrap();
assert_eq!(result.alias, ModelAlias::Cooper);
assert_eq!(result.reason, StartupReason::FallbackCooper);
}
}
+60 -14
View File
@@ -67,6 +67,36 @@ fn scan_model_dir_top_level(path: &Path) -> Result<Vec<ModelFile>, String> {
Ok(models)
}
/// Scans only `models/text-llm/` for `.gguf` files.
/// All other directories are ignored.
fn scan_model_dir_for_chat(dir: &Path, models: &mut Vec<ModelFile>) -> Result<(), String> {
let text_llm_dir = dir.join("text-llm");
if !text_llm_dir.exists() {
return Ok(());
}
if !text_llm_dir.is_dir() {
return Err(format!("Not a directory: {}", text_llm_dir.display()));
}
let entries = std::fs::read_dir(text_llm_dir).map_err(|e| e.to_string())?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_file()
&& path
.extension()
.map(|ext| ext.eq_ignore_ascii_case("gguf"))
.unwrap_or(false)
{
models.push(model_file_from_path(&path));
}
}
Ok(())
}
/// Recursively scans all subdirectories for .gguf files.
/// Kept for future use when deep scanning may be needed.
#[allow(dead_code)]
@@ -134,7 +164,18 @@ pub fn scan_models(dir: String) -> Result<Vec<ModelFile>, String> {
} else {
resolve_models_dir(Some(&dir))
};
scan_model_dir_top_level(&path)
if !path.exists() {
return Err(format!("Directory not found: {}", path.display()));
}
if !path.is_dir() {
return Err(format!("Not a directory: {}", path.display()));
}
let mut models = Vec::new();
scan_model_dir_for_chat(&path, &mut models)?;
models.sort_by(|a, b| a.name.cmp(&b.name));
Ok(models)
}
#[tauri::command]
@@ -177,7 +218,7 @@ pub fn import_model(src_path: String, dest_dir: String) -> Result<ModelFile, Str
#[cfg(test)]
mod tests {
use super::{import_model_file, scan_model_dir};
use super::{import_model_file, scan_model_dir, scan_model_dir_for_chat};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
@@ -220,21 +261,26 @@ mod tests {
}
#[test]
fn scan_model_dir_returns_only_top_level_gguf() {
// Tests that scan_model_dir (now using top-level scan) does NOT recurse into subdirectories
let dir = temp_path("scan-models-top-level");
let sub = dir.join("mradermacher").join("Suri-Qwen-3.5-4B-Uncensored-i1-GGUF");
std::fs::create_dir_all(&sub).expect("create nested dirs");
fn scan_model_dir_for_chat_only_scans_text_llm_directory() {
let dir = temp_path("scan-models-text-llm");
let embeddings = dir.join("embeddings");
let audio = dir.join("audio");
let text_llm = dir.join("text-llm");
std::fs::create_dir_all(&embeddings).expect("create embeddings dir");
std::fs::create_dir_all(&audio).expect("create audio dir");
std::fs::create_dir_all(&text_llm).expect("create text-llm dir");
std::fs::write(dir.join("top-level.gguf"), b"demo").expect("write top-level model");
std::fs::write(sub.join("nested-model.gguf"), b"demo").expect("write nested model");
std::fs::write(sub.join("notes.txt"), b"ignore").expect("write non-model file");
std::fs::write(embeddings.join("embedding-model.gguf"), b"demo")
.expect("write embedding model");
std::fs::write(audio.join("whisper.gguf"), b"demo").expect("write audio model");
std::fs::write(text_llm.join("chat-model.gguf"), b"demo").expect("write chat model");
let models = scan_model_dir(&dir).expect("scan model dir");
let names: Vec<_> = models.iter().map(|m| m.name.as_str()).collect();
let mut models = Vec::new();
scan_model_dir_for_chat(&dir, &mut models).expect("scan for chat");
let names: Vec<_> = models.into_iter().map(|m| m.name).collect();
// Only top-level model should be returned, NOT nested ones
assert_eq!(names, vec!["top-level.gguf"]);
assert!(models.iter().all(|m| m.path.ends_with(".gguf")));
assert_eq!(names, vec!["chat-model.gguf"]);
let _ = std::fs::remove_dir_all(&dir);
}
+61 -9
View File
@@ -69,6 +69,7 @@ fn start_runtime_inner(
app: &AppHandle,
state: &State<'_, RuntimeState>,
override_config: Option<RuntimeConfig>,
startup_status: RuntimeStatus,
) -> Result<RuntimeSnapshot, String> {
let command = if override_config.is_some() {
RuntimeLifecycleCommand::SaveAndStart
@@ -113,7 +114,7 @@ fn start_runtime_inner(
return Ok(build_snapshot(&inner));
}
inner.status = RuntimeStatus::Starting;
inner.status = startup_status;
inner.last_error = None;
inner.config.clone()
};
@@ -137,7 +138,7 @@ fn start_runtime_inner(
{
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
inner.child = Some(child);
inner.status = RuntimeStatus::Starting;
inner.status = startup_status;
}
if wait_for_health(&config.addr, Duration::from_secs(15)) {
@@ -164,10 +165,17 @@ fn start_runtime_inner(
}
inner.child = None;
inner.status = RuntimeStatus::Error;
let message = format!(
"Local API did not pass /health readiness on {} in time",
config.addr
);
let message = if startup_status == RuntimeStatus::Loading {
format!(
"Model loading timed out before the local API became ready on {}",
config.addr
)
} else {
format!(
"Local API did not pass /health readiness on {} in time",
config.addr
)
};
inner.last_error = Some(runtime_error("health_timeout", &message));
finish_lifecycle_command(
&mut inner,
@@ -183,7 +191,43 @@ pub fn bootstrap_runtime(
app: AppHandle,
state: State<'_, RuntimeState>,
) -> Result<RuntimeSnapshot, String> {
let config = load_runtime_config(&app).unwrap_or_default();
use crate::model_alias;
use crate::model_library::paths::resolve_models_dir;
let mut config = load_runtime_config(&app).unwrap_or_default();
// Auto-resolve model if none is configured yet
let model_path_empty = config
.model_path
.as_deref()
.map(|p| p.trim().is_empty())
.unwrap_or(true);
if model_path_empty && config.connection_mode == ConnectionMode::Local {
let models_dir = resolve_models_dir(config.models_dir.as_deref());
let models = if models_dir.exists() {
let dir_str = models_dir.to_string_lossy().to_string();
crate::model_files::scan_models(dir_str).unwrap_or_default()
} else {
Vec::new()
};
if let Some(resolution) = model_alias::resolve_startup_model(
config.preferred_startup_model_alias.as_deref(),
&models,
) {
eprintln!(
"[bootstrap] auto-selected model: {} ({:?}) reason={:?}",
resolution.alias.as_str(),
resolution.model_path,
resolution.reason
);
config.model_path = Some(resolution.model_path);
// Auto-enable OnAppLaunch when we found a model automatically
config.start_policy = StartPolicy::OnAppLaunch;
}
}
let should_autostart = config.connection_mode == ConnectionMode::Local
&& config.start_policy == StartPolicy::OnAppLaunch
&& config
@@ -199,7 +243,7 @@ pub fn bootstrap_runtime(
}
if should_autostart {
start_runtime_inner(&app, &state, Some(config))
start_runtime_inner(&app, &state, Some(config), RuntimeStatus::Loading)
} else {
get_runtime_snapshot(state)
}
@@ -209,7 +253,15 @@ pub fn start_runtime(
app: AppHandle,
state: State<'_, RuntimeState>,
) -> Result<RuntimeSnapshot, String> {
start_runtime_inner(&app, &state, None)
start_runtime_inner(&app, &state, None, RuntimeStatus::Starting)
}
pub fn start_runtime_with_status(
app: AppHandle,
state: State<'_, RuntimeState>,
startup_status: RuntimeStatus,
) -> Result<RuntimeSnapshot, String> {
start_runtime_inner(&app, &state, None, startup_status)
}
pub fn stop_runtime(state: State<'_, RuntimeState>) -> Result<RuntimeSnapshot, String> {
+4 -1
View File
@@ -1,5 +1,5 @@
use std::path::PathBuf;
use std::process::{Child, Command};
use std::process::{Child, Command, Stdio};
use tauri::{AppHandle, Manager};
@@ -102,6 +102,9 @@ pub fn spawn_localllm(app: &AppHandle, config: &RuntimeConfig) -> Result<Child,
.env("LOCALLLM_CTX", config.ctx_size.to_string());
apply_runtime_to_command(&mut command, &runtime);
// Suppress verbose llama.cpp logs (create_tensor, etc.) by redirecting stderr
command.stderr(Stdio::null());
command
.spawn()
.map_err(|e| format!("Failed to start server ({}): {e}", binary_path.display()))
+23 -2
View File
@@ -99,10 +99,12 @@ pub(crate) fn guard_start_request(inner: &mut RuntimeInner) -> Result<(), Runtim
if matches!(
inner.status,
RuntimeStatus::Starting | RuntimeStatus::Stopping
RuntimeStatus::Starting | RuntimeStatus::Loading | RuntimeStatus::Stopping
) {
let message = if inner.status == RuntimeStatus::Stopping {
"Local API stop already in progress."
} else if inner.status == RuntimeStatus::Loading {
"Local API model is still loading."
} else {
"Local API start already in progress."
};
@@ -136,10 +138,12 @@ pub(crate) fn guard_stop_request(inner: &mut RuntimeInner) -> Result<(), Runtime
if matches!(
inner.status,
RuntimeStatus::Starting | RuntimeStatus::Stopping
RuntimeStatus::Starting | RuntimeStatus::Loading | RuntimeStatus::Stopping
) {
let message = if inner.status == RuntimeStatus::Starting {
"Local API start already in progress."
} else if inner.status == RuntimeStatus::Loading {
"Local API model is still loading."
} else {
"Local API stop already in progress."
};
@@ -449,6 +453,23 @@ mod tests {
assert_eq!(inner.active_command, RuntimeLifecycleCommand::Start);
}
#[test]
fn guard_start_request_blocks_when_model_is_loading() {
let mut inner = RuntimeInner {
child: None,
status: RuntimeStatus::Loading,
last_error: None,
active_command: RuntimeLifecycleCommand::Idle,
last_command_result: RuntimeLifecycleResult::default(),
config: RuntimeConfig::default(),
};
let blocked = guard_start_request(&mut inner).expect_err("start should be blocked");
assert_eq!(blocked.command, RuntimeLifecycleCommand::Start);
assert_eq!(blocked.message, "Local API model is still loading.");
}
#[test]
fn guard_stop_request_blocks_when_runtime_is_already_stopped() {
let mut inner = RuntimeInner {
+3
View File
@@ -26,6 +26,7 @@ pub enum RuntimeStatus {
#[default]
Stopped,
Starting,
Loading,
Ready,
Degraded,
Stopping,
@@ -85,6 +86,7 @@ pub struct RuntimeConfig {
pub selected_model_id: Option<String>,
pub remote_presets: Vec<RemotePreset>,
pub hugging_face_token: String,
pub preferred_startup_model_alias: Option<String>,
}
impl RuntimeConfig {
@@ -109,6 +111,7 @@ impl Default for RuntimeConfig {
selected_model_id: None,
remote_presets: Vec::new(),
hugging_face_token: String::new(),
preferred_startup_model_alias: None,
}
}
}
+6 -1
View File
@@ -65,7 +65,10 @@ export default function App() {
const handleModelSwap = useCallback(async (newModelPath: string) => {
setIsSwapping(true);
try {
await swapModel(newModelPath);
const next = await swapModel(newModelPath);
if (next.status !== "ready") {
throw new Error(next.last_error?.message || "The selected model did not finish loading.");
}
setSelectedModel(newModelPath);
} catch (err) {
console.error("[App] handleModelSwap failed:", err);
@@ -129,6 +132,8 @@ export default function App() {
onModelSwap={handleModelSwap}
isSwapping={isSwapping}
status={status}
runtimeMode={runtime.snapshot.config.connection_mode}
runtimeStatus={runtime.snapshot.status}
onChatCreated={(id) => { setCurrentChatId(id); loadChats(); setSidebarOpen(false); }}
onChatUpdated={loadChats}
onOpenSidebar={() => setSidebarOpen(true)}
+181 -149
View File
@@ -1134,18 +1134,18 @@
/* ── Initial screen / Manus-like landing ── */
.chat-view.initial-screen {
background: #252525;
background: var(--bg);
}
.chat-view.initial-screen .chat-topbar {
background: #252525;
background: transparent;
border-bottom-color: transparent;
height: 56px;
padding: 0 24px 0 18px;
}
.chat-view.initial-screen .chat-topbar-center {
display: none;
display: flex;
}
.chat-view.initial-screen .chat-topbar-right {
@@ -1166,37 +1166,26 @@
}
.topbar-model-badge--hero .topbar-model-name {
color: #ece9e4;
font-size: 1rem;
font-weight: 600;
color: var(--text);
font-size: 0.95rem;
font-weight: 500;
letter-spacing: -0.01em;
}
.topbar-state-pill--hero {
min-height: 34px;
padding: 0 14px;
border-color: rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.025);
color: rgba(255, 255, 255, 0.78);
font-size: 0.75rem;
letter-spacing: 0;
text-transform: none;
}
.messages-container--hero {
min-height: calc(100dvh - 44px);
padding: 84px 24px 56px;
min-height: calc(100dvh - 56px);
padding: 62px 24px 40px;
display: flex;
align-items: center;
justify-content: center;
}
.empty-state--hero {
width: min(100%, 760px);
width: min(100%, 920px);
display: flex;
flex-direction: column;
align-items: center;
gap: 28px;
gap: 20px;
}
.empty-state-panel--hero {
@@ -1204,7 +1193,7 @@
display: flex;
flex-direction: column;
align-items: center;
gap: 30px;
gap: 22px;
}
.empty-state-header--hero {
@@ -1214,49 +1203,42 @@
gap: 14px;
}
.empty-kicker {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 34px;
padding: 0 14px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.035);
border: 1px solid rgba(255, 255, 255, 0.045);
color: rgba(255, 255, 255, 0.68);
font-size: 0.84rem;
}
.empty-state-header--hero h1 {
font-family: var(--font-serif);
font-size: clamp(3rem, 4.6vw, 4.1rem);
line-height: 1.02;
font-family: inherit;
font-size: clamp(3.3rem, 5vw, 4.4rem);
line-height: 0.98;
letter-spacing: -0.045em;
font-weight: 400;
color: #ece7e0;
font-weight: 500;
color: #fff;
}
.hero-composer-block {
width: min(100%, 686px);
width: min(100%, 760px);
display: flex;
flex-direction: column;
gap: 14px;
gap: 12px;
}
.input-box--hero {
min-height: 154px;
min-height: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 16px 16px 10px;
border-radius: 22px;
background: #343233;
border-color: rgba(255, 255, 255, 0.05);
box-shadow: none;
justify-content: center;
padding: 14px 16px 12px;
border-radius: 28px;
background: rgba(18, 18, 20, 0.96);
border: 1px solid rgba(255, 255, 255, 0.06);
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.28);
}
.input-box--hero:focus-within {
border-color: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.12);
}
.hero-composer-row {
display: flex;
align-items: center;
gap: 12px;
}
.input-box-main {
@@ -1266,62 +1248,97 @@
gap: 12px;
}
.input-box-main--hero {
gap: 14px;
}
.input-box--hero textarea {
min-height: 84px;
min-height: 48px;
font-size: 16px;
line-height: 1.55;
line-height: 1.5;
align-self: stretch;
padding: 4px 0 10px;
color: rgba(255, 255, 255, 0.9);
padding: 10px 0 6px;
color: rgba(255, 255, 255, 0.98);
}
.input-box--hero textarea::placeholder {
color: rgba(255, 255, 255, 0.26);
color: rgba(255, 255, 255, 0.42);
}
.input-box--hero .attachment-actions {
align-self: flex-start;
.attachment-actions--hero {
align-self: center;
gap: 8px;
margin-bottom: 0;
padding-bottom: 0;
gap: 6px;
margin-bottom: 4px;
}
.input-box--hero .send-btn,
.input-box--hero .stop-btn {
align-self: flex-end;
.attachment-actions--hero button {
width: 34px;
height: 34px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.04);
}
.hero-composer-actions {
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 4px;
}
.hero-utility-btn {
width: 38px;
height: 38px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.035);
color: rgba(255, 255, 255, 0.78);
}
.send-btn--hero,
.stop-btn--hero {
width: 42px;
height: 42px;
min-width: 42px;
border-radius: 999px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 0;
}
.composer-footer-row {
.composer-footer-row--hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
gap: 12px;
padding-top: 10px;
border-top: 1px solid rgba(255, 255, 255, 0.04);
}
.composer-footer-hint {
.composer-footer-hint--hero {
color: rgba(255, 255, 255, 0.55);
font-size: 0.84rem;
font-size: 0.82rem;
}
.composer-footer-icons {
.composer-footer-icons--hero {
display: flex;
align-items: center;
gap: 4px;
gap: 6px;
}
.composer-footer-icons span {
width: 16px;
height: 16px;
.composer-footer-icons--hero span {
width: 22px;
height: 22px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
border: 1px solid rgba(255, 255, 255, 0.07);
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.04);
}
.starter-chip-rail {
width: min(100%, 780px);
width: min(100%, 760px);
display: flex;
flex-wrap: wrap;
justify-content: center;
@@ -1330,99 +1347,88 @@
.starter-chip--hero {
width: auto;
padding: 11px 16px;
padding: 10px 16px;
border-radius: 999px;
background: transparent;
border-color: rgba(255, 255, 255, 0.07);
color: rgba(255, 255, 255, 0.9);
background: rgba(39, 39, 42, 0.96);
border-color: rgba(255, 255, 255, 0.05);
color: #fff;
}
.hero-card-carousel {
width: min(100%, 640px);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
margin-top: 32px;
.starter-chip--hero:hover {
background: rgba(63, 63, 70, 0.96);
}
.hero-card {
width: min(100%, 508px);
min-height: 72px;
.hero-promo-card {
width: min(100%, 760px);
display: flex;
align-items: center;
justify-content: space-between;
text-align: left;
border: 1px solid rgba(255, 255, 255, 0.04);
background: rgba(255, 255, 255, 0.025);
border-radius: 12px;
padding: 14px 16px;
color: var(--text2);
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.hero-card:active {
transform: none;
}
.hero-card:hover {
border-color: rgba(255, 255, 255, 0.08);
background: #393738;
}
.hero-card-title {
display: block;
margin-bottom: 8px;
color: var(--text);
font-size: 0.92rem;
font-weight: 600;
}
.hero-card-copy {
display: block;
font-size: 0.82rem;
line-height: 1.55;
color: var(--text2);
}
.hero-card-preview {
width: 112px;
height: 58px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.03);
gap: 18px;
margin-top: 22px;
padding: 18px 18px 18px 20px;
border-radius: 24px;
background: rgba(24, 24, 27, 0.92);
border: 1px solid rgba(255, 255, 255, 0.05);
padding: 10px;
}
.hero-promo-copy {
display: flex;
flex-direction: column;
gap: 6px;
max-width: 420px;
}
.hero-card-preview span {
.hero-promo-title {
color: #fff;
font-size: 0.98rem;
font-weight: 500;
}
.hero-promo-subtitle {
color: var(--text2);
font-size: 0.84rem;
line-height: 1.5;
}
.hero-promo-preview {
width: 186px;
min-width: 186px;
height: 106px;
border-radius: 18px;
padding: 12px;
display: grid;
grid-template-columns: 1.5fr 1fr;
grid-template-rows: 10px 1fr;
gap: 8px;
background: linear-gradient(180deg, rgba(39, 39, 42, 0.98), rgba(24, 24, 27, 0.98));
border: 1px solid rgba(255, 255, 255, 0.06);
}
.hero-promo-preview-bar,
.hero-promo-preview-pane,
.hero-promo-preview .active {
display: block;
height: 6px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.1);
}
.hero-card-preview span:last-child {
width: 66%;
background: rgba(75, 134, 255, 0.2);
.hero-promo-preview-bar {
grid-column: 1 / -1;
}
.hero-card-pager {
display: none;
.hero-promo-preview-pane {
border-radius: 14px;
}
.hero-card-pager span {
width: 8px;
height: 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.18);
.hero-promo-preview-pane--wide {
background: rgba(255, 255, 255, 0.07);
}
.hero-card-pager span.active {
background: rgba(255, 255, 255, 0.42);
.hero-promo-preview .active {
width: 44px;
height: 10px;
align-self: end;
background: rgba(96, 165, 250, 0.44);
}
/* ── Responsive ── */
@@ -1452,12 +1458,13 @@
.messages-container--hero { padding: 56px 14px 32px; }
.empty-state--hero { gap: 18px; }
.empty-state-header--hero h1 { font-size: clamp(2.2rem, 8vw, 3.3rem); }
.hero-card-carousel { margin-top: 48px; width: 100%; }
.hero-card { width: 100%; }
.hero-card-preview { display: none; }
.input-box--hero { min-height: 136px; padding: 16px; border-radius: 20px; }
.input-box--hero textarea { min-height: 80px; font-size: 15px; }
.composer-footer-row { flex-direction: column; align-items: flex-start; }
.hero-composer-row { flex-direction: column; align-items: stretch; }
.attachment-actions--hero { align-self: flex-start; }
.input-box--hero { padding: 16px; border-radius: 24px; }
.input-box--hero textarea { min-height: 72px; font-size: 15px; }
.composer-footer-row--hero { flex-direction: column; align-items: flex-start; }
.hero-promo-card { width: 100%; flex-direction: column; align-items: flex-start; }
.hero-promo-preview { min-width: 100%; width: 100%; }
}
/* ── Model Selector Pill (in input area) ── */
@@ -1582,3 +1589,28 @@
.input-box--hero .model-selector-pill-wrapper {
margin-bottom: 12px;
}
.chat-debug-fab {
position: fixed;
right: 20px;
bottom: 104px;
z-index: 40;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(17, 24, 39, 0.92);
color: #f8fafc;
border-radius: 999px;
padding: 10px 14px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.01em;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(14px);
}
.chat-debug-fab:hover {
background: rgba(30, 41, 59, 0.98);
}
.chat-debug-fab.copied {
background: rgba(22, 101, 52, 0.95);
border-color: rgba(134, 239, 172, 0.28);
}
@@ -0,0 +1,133 @@
/* @vitest-environment jsdom */
import { createRoot, type Root } from "react-dom/client";
import { act } from "react-dom/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DB } from "../lib/db";
import ChatView from "./ChatView";
function createDbStub(): DB {
return {
insert: vi.fn(() => 1),
query: vi.fn((sql: string) => {
if (sql.includes("FROM messages WHERE chat_id = ? ORDER BY created_at ASC")) {
return [
{ id: 1, chat_id: 1, role: "user", content: "Pergunta", created_at: 1 },
{ id: 2, chat_id: 1, role: "assistant", content: "Resposta", created_at: 2 },
];
}
return [];
}),
get: vi.fn(() => null),
run: vi.fn(),
persist: vi.fn(),
} as unknown as DB;
}
const defaultProps = {
models: [],
localModels: [],
selectedModel: "",
onModelChange: vi.fn(),
status: "ok" as const,
};
describe("ChatView debug tools", () => {
let container: HTMLDivElement;
let root: Root;
const writeText = vi.fn(() => Promise.resolve());
beforeEach(() => {
// @ts-expect-error React test environment flag
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
Element.prototype.scrollIntoView = vi.fn();
Object.assign(navigator, { clipboard: { writeText } });
});
afterEach(() => {
act(() => root.unmount());
container.remove();
writeText.mockClear();
vi.restoreAllMocks();
// @ts-expect-error React test environment flag
globalThis.IS_REACT_ACT_ENVIRONMENT = false;
});
it("shows a floating debug copy button only when debug tools are enabled", async () => {
const db = createDbStub();
await act(async () => {
root.render(
<ChatView
db={db}
chatId={1}
server="http://127.0.0.1:1234"
apiKey=""
model="demo"
onChatCreated={vi.fn()}
onChatUpdated={vi.fn()}
showDebugTools
{...defaultProps}
/>,
);
});
const debugButton = container.querySelector('[aria-label="Copy debug transcript"]');
expect(debugButton).toBeTruthy();
await act(async () => {
root.render(
<ChatView
db={db}
chatId={1}
server="http://127.0.0.1:1234"
apiKey=""
model="demo"
onChatCreated={vi.fn()}
onChatUpdated={vi.fn()}
showDebugTools={false}
{...defaultProps}
/>,
);
});
expect(container.querySelector('[aria-label="Copy debug transcript"]')).toBeNull();
});
it("copies the full chat transcript with message details", async () => {
const db = createDbStub();
await act(async () => {
root.render(
<ChatView
db={db}
chatId={1}
server="http://127.0.0.1:1234"
apiKey=""
model="demo"
onChatCreated={vi.fn()}
onChatUpdated={vi.fn()}
showDebugTools
{...defaultProps}
/>,
);
});
const debugButton = container.querySelector('[aria-label="Copy debug transcript"]') as HTMLButtonElement | null;
expect(debugButton).toBeTruthy();
await act(async () => {
debugButton?.click();
});
expect(writeText).toHaveBeenCalledTimes(1);
const copiedText = (writeText.mock.calls[0] as unknown as [string])[0];
expect(copiedText).toContain("Chat ID: 1");
expect(copiedText).toContain("[USER] #1");
expect(copiedText).toContain("[ASSISTANT] #2");
});
});
@@ -82,6 +82,40 @@ describe("ChatView", () => {
expect(vi.mocked(db.run)).not.toHaveBeenCalled();
});
it("blocks sending while a local model is loading", async () => {
const db = createDbStub();
await act(async () => {
root.render(
<ChatView
db={db}
chatId={null}
server="http://127.0.0.1:1234"
apiKey=""
model="demo"
runtimeMode="local"
runtimeStatus="loading"
onChatCreated={vi.fn()}
onChatUpdated={vi.fn()}
{...defaultChatViewProps}
/>,
);
});
const textarea = container.querySelector("textarea") as HTMLTextAreaElement | null;
expect(textarea).toBeTruthy();
expect(container.textContent).toContain("Model loading");
await act(async () => {
textarea!.value = "Send this later";
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
textarea!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
expect(vi.mocked(db.run)).not.toHaveBeenCalled();
expect(textarea?.value).toBe("Send this later");
});
it("loads messages when switching to a saved chat", async () => {
const rowsByChat = new Map<number, Message[]>([
[1, [{ id: 11, chat_id: 1, role: "user", content: "First saved chat", created_at: 1 }]],
+179 -112
View File
@@ -1,15 +1,26 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { DB } from "../lib/db";
import type { ChatTokenTotals, Message, Usage, ModelInfo, ServerStatus } from "../lib/types";
import type { RuntimeModelFile } from "../features/runtime/types";
import { buildChatTitle, parseAssistantResponse } from "../lib/chat";
import type { RuntimeModelFile, RuntimeStatus } from "../features/runtime/types";
import { buildChatTitle, CHAT_TRANSCRIPT_STOP_SEQUENCES, parseAssistantResponse } from "../lib/chat";
import { formatChatDebugTranscript } from "../lib/chatDebug";
import { renderMarkdown, ensureHljs } from "../lib/markdown";
import { getComposerNotice, getSuggestedPrompts } from "./chat-view-state";
import { getComposerNotice } from "./chat-view-state";
import MessageBubble from "./MessageBubble";
import ModelSelectorPill from "./ModelSelectorPill";
import AgenticMessage from "./forgeagent/AgenticMessage";
import { AgenticToggle } from "./forgeagent";
import { useMemoryExtractor } from "../hooks/useMemoryExtractor";
import { useForgeAgentSession } from "../hooks/useForgeAgentSession";
import {
parseForgeAgentState,
resolveForgeAgentFinalResponse,
serializeForgeAgentState,
withPersistedForgeAgentResponse,
} from "../lib/forgeagent-state";
import { getDisplayName } from "../lib/modelAlias";
import { ArrowUp, Image, Microphone, Paperclip, ChatCircleText, PaperPlaneRight } from "@phosphor-icons/react";
import { shouldUseAgenticMode } from "../lib/agentic-detection";
import { Image, Microphone, Paperclip, ChatCircleText, PaperPlaneRight, Plus, FolderSimple, Monitor } from "@phosphor-icons/react";
import "./ChatView.css";
const MAX_MEMORY = 20;
@@ -30,6 +41,9 @@ interface Props {
onModelSwap?: (path: string) => void;
isSwapping?: boolean;
status: ServerStatus;
runtimeMode?: "local" | "remote";
runtimeStatus?: RuntimeStatus;
showDebugTools?: boolean;
onChatCreated: (id: number) => void;
onChatUpdated: () => void;
onOpenSidebar?: () => void;
@@ -39,6 +53,7 @@ interface Props {
export default function ChatView({
db, chatId, server, apiKey, model,
models, localModels, selectedModel, onModelChange, onModelSwap, isSwapping, status,
runtimeMode = "remote", runtimeStatus = "ready", showDebugTools = Boolean((import.meta as ImportMeta & { env?: { DEV?: boolean } }).env?.DEV),
onChatCreated, onChatUpdated, onOpenSidebar, onDegradationDetected
}: Props) {
const [messages, setMessages] = useState<Message[]>([]);
@@ -55,6 +70,8 @@ export default function ChatView({
const [messageQueue, setMessageQueue] = useState<string[]>([]);
const [showMemoryModal, setShowMemoryModal] = useState(false);
const [isQueueOpen, setIsQueueOpen] = useState(false);
const [debugCopied, setDebugCopied] = useState(false);
const [agenticMode, setAgenticMode] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -75,9 +92,9 @@ export default function ChatView({
setMessageQueue(next);
};
const composerNotice = getComposerNotice(model, server);
const starterPrompts = getSuggestedPrompts();
const composerNotice = getComposerNotice(model, server, runtimeMode, runtimeStatus, Boolean(isSwapping));
const estimatedTokens = input.length > 0 ? Math.ceil(input.length / 4) : 0;
const { state: forgeAgentState, start: startForgeAgentSession, reset: resetForgeAgentSession, getSnapshot: getForgeAgentSnapshot } = useForgeAgentSession();
const chatTotals = messages.reduce<ChatTokenTotals>((acc, msg) => {
acc.prompt_tokens += msg.prompt_tokens ?? 0;
@@ -131,11 +148,14 @@ export default function ChatView({
const loadMessages = useCallback((targetChatId: number | null = chatId) => {
if (targetChatId == null) { setMessages([]); return; }
const rows = db.query<Message>(
const rows = db.query<(Message & { forgeagent_state?: string | null })>(
"SELECT * FROM messages WHERE chat_id = ? ORDER BY created_at ASC",
[targetChatId],
);
setMessages(rows);
setMessages(rows.map((row) => ({
...row,
forgeagent_state: parseForgeAgentState(row.forgeagent_state),
})));
}, [db, chatId]);
loadMessagesRef.current = loadMessages;
@@ -150,6 +170,7 @@ export default function ChatView({
if (abortRef.current && switchedAway) {
abortRef.current.abort();
abortRef.current = null;
resetForgeAgentSession();
messageQueueRef.current = [];
setMessageQueue([]);
setGenerating(false);
@@ -165,6 +186,7 @@ export default function ChatView({
if (!abortRef.current) {
setGenerating(false);
resetForgeAgentSession();
setStreamContent("");
setStreamReasoning("");
setStreamThinkingOpen(false);
@@ -172,7 +194,7 @@ export default function ChatView({
setTps(null);
setStreamTokenCount(0);
}
}, [chatId]);
}, [chatId, resetForgeAgentSession]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
@@ -219,6 +241,7 @@ export default function ChatView({
const send = useCallback(async (text: string, fromChatId?: number, replaceAssistantId?: number) => {
if (!text.trim() || generating) return;
if (getComposerNotice(model, server)) return;
const isAgenticRequest = agenticMode || shouldUseAgenticMode(text);
setGenerating(true);
setStreamContent("");
@@ -246,6 +269,16 @@ export default function ChatView({
loadMessagesRef.current(cid);
onChatUpdated();
if (isAgenticRequest) {
try {
await startForgeAgentSession(text);
} catch {
// Non-fatal: keep normal chat flow even if ForgeAgent events fail.
}
} else {
resetForgeAgentSession();
}
const apiMessages = buildApiMessages(cid);
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
@@ -263,6 +296,7 @@ export default function ChatView({
stream: useStream,
stream_options: useStream ? { include_usage: true } : undefined,
max_tokens: 2048,
stop: CHAT_TRANSCRIPT_STOP_SEQUENCES,
}),
});
@@ -322,15 +356,22 @@ export default function ChatView({
}
const parsed = parseAssistantResponse(fullContent);
const finalContent = parsed.content.trim() || fullContent.trim();
const persistedForgeAgentState = isAgenticRequest
? withPersistedForgeAgentResponse(getForgeAgentSnapshot(), parsed.content.trim())
: null;
const finalContent = isAgenticRequest
? resolveForgeAgentFinalResponse(persistedForgeAgentState, parsed.content.trim())
: parsed.content.trim();
if (finalContent || parsed.reasoning) {
db.run(
`INSERT INTO messages (
chat_id, role, content, reasoning_content, prompt_tokens, completion_tokens, total_tokens, tokens_per_second
) VALUES (?, 'assistant', ?, ?, ?, ?, ?, ?)`,
chat_id, role, content, display_mode, forgeagent_state, reasoning_content, prompt_tokens, completion_tokens, total_tokens, tokens_per_second
) VALUES (?, 'assistant', ?, ?, ?, ?, ?, ?, ?, ?)`,
[
cid,
finalContent,
isAgenticRequest ? "agentic_final" : null,
serializeForgeAgentState(persistedForgeAgentState),
parsed.content.trim() ? parsed.reasoning : null,
usage?.prompt_tokens ?? 0,
usage?.completion_tokens ?? 0,
@@ -352,10 +393,21 @@ export default function ChatView({
const partial = streamContentAccRef.current;
if (partial.trim()) {
const parsed = parseAssistantResponse(partial);
const partialContent = parsed.content.trim() || partial.trim();
const persistedForgeAgentState = isAgenticRequest
? withPersistedForgeAgentResponse(getForgeAgentSnapshot(), `${parsed.content.trim()} _(geração cancelada)_`)
: null;
const partialContent = isAgenticRequest
? resolveForgeAgentFinalResponse(persistedForgeAgentState, `${parsed.content.trim()} _(geração cancelada)_`)
: parsed.content.trim();
db.run(
`INSERT INTO messages (chat_id, role, content, reasoning_content) VALUES (?, 'assistant', ?, ?)`,
[cid, `${partialContent} _(geração cancelada)_`, parsed.reasoning || null],
`INSERT INTO messages (chat_id, role, content, display_mode, forgeagent_state, reasoning_content) VALUES (?, 'assistant', ?, ?, ?, ?)`,
[
cid,
`${partialContent} _(geração cancelada)_`,
isAgenticRequest ? "agentic_final" : null,
serializeForgeAgentState(persistedForgeAgentState),
parsed.reasoning || null,
],
);
db.run("UPDATE chats SET updated_at = strftime('%s','now') WHERE id = ?", [cid]);
onChatUpdated();
@@ -389,7 +441,7 @@ export default function ChatView({
setTimeout(() => sendRef.current(nextInQueue), 100);
}
}
}, [generating, model, server, apiKey, useStream, db, getOrCreateChat, ensureChatTitle, buildApiMessages, onChatUpdated, extractMemories]);
}, [generating, model, server, apiKey, useStream, db, getOrCreateChat, ensureChatTitle, buildApiMessages, onChatUpdated, extractMemories, agenticMode, startForgeAgentSession, resetForgeAgentSession]);
// Keep sendRef always pointing to latest send closure
sendRef.current = send;
@@ -426,6 +478,13 @@ export default function ChatView({
});
};
const handleCopyDebugTranscript = useCallback(async () => {
const transcript = formatChatDebugTranscript({ chatId, messages });
await navigator.clipboard.writeText(transcript);
setDebugCopied(true);
window.setTimeout(() => setDebugCopied(false), 2000);
}, [chatId, messages]);
const handleEditAndResend = (msg: Message, newContent: string) => {
db.run("UPDATE messages SET content = ? WHERE id = ?", [newContent, msg.id]);
db.run("DELETE FROM messages WHERE chat_id = ? AND id > ?", [msg.chat_id, msg.id]);
@@ -471,39 +530,28 @@ export default function ChatView({
const isInitialScreen = chatId === null && messages.length === 0;
const modelShort = getDisplayName(model);
const quickActionChips = ["Create slides", "Build website", "Develop desktop apps", "Design", "More"];
const workspaceStateLabel = chatId === null ? "Workspace" : "Conversation";
const workspaceStateCopy = composerNotice
? composerNotice.title
: server
? "Runtime ready"
: "Runtime offline";
const heroCards = [
{
title: "Personalize ForgeLM",
copy: "Tune workspace defaults, preferred prompts, and local runtime behavior.",
},
];
return (
<div className={`chat-view${isInitialScreen ? " initial-screen" : ""}`}>
{/* ── Top bar ── */}
<div className="chat-topbar">
<div className="chat-topbar-left">
{onOpenSidebar && (
{onOpenSidebar && !isInitialScreen && (
<button type="button" className="chat-menu-btn" onClick={onOpenSidebar} aria-label="Open navigation">
☰
</button>
)}
{model && (
<div className={`topbar-model-badge${isInitialScreen ? " topbar-model-badge--hero" : ""}`}>
{!isInitialScreen && <div className={`topbar-status-dot${server ? " connected" : ""}`} />}
<span className="topbar-model-name">{modelShort}</span>
</div>
)}
</div>
<div className="chat-topbar-center">
{isInitialScreen ? null : chatId !== null && chatTotals.total_tokens > 0 ? (
{!isInitialScreen && (chatId !== null && chatTotals.total_tokens > 0 ? (
<div className="chat-token-summary">
<span title="Prompt tokens sent">↑ {chatTotals.prompt_tokens.toLocaleString()}</span>
<span title="Completion tokens generated">↓ {chatTotals.completion_tokens.toLocaleString()}</span>
@@ -514,13 +562,20 @@ export default function ChatView({
<span className="chat-topbar-context-label">{workspaceStateLabel}</span>
<span className="chat-topbar-context-copy">{workspaceStateCopy}</span>
</div>
)}
))}
</div>
<div className="chat-topbar-right">
<span className={`topbar-state-pill${server ? " online" : " offline"}${isInitialScreen ? " topbar-state-pill--hero" : ""}`}>
{model && (
<div className={`topbar-model-badge${isInitialScreen ? " topbar-model-badge--hero" : ""}`}>
{!isInitialScreen && <div className={`topbar-status-dot${server ? " connected" : ""}`} />}
<span className="topbar-model-name">{modelShort}</span>
</div>
)}
{!isInitialScreen && <span className={`topbar-state-pill${server ? " online" : " offline"}`}>
{server ? "Runtime ready" : "Runtime offline"}
</span>
}
{!isInitialScreen && generating && streamTokenCount > 0 && (
<div className="live-token-counter">
<span className="live-token-pulse" />
@@ -609,6 +664,18 @@ export default function ChatView({
</div>
)}
{showDebugTools && chatId !== null && messages.length > 0 && (
<button
type="button"
className={`chat-debug-fab${debugCopied ? " copied" : ""}`}
onClick={() => { void handleCopyDebugTranscript(); }}
aria-label="Copy debug transcript"
title="Copy full chat debug transcript"
>
{debugCopied ? "Copied debug" : "Copy debug"}
</button>
)}
{/* ── Chat Layout ── */}
<div className="chat-layout-root">
{/* Scrollable message area */}
@@ -618,110 +685,102 @@ export default function ChatView({
<div className="empty-state empty-state--hero">
<section className="empty-state-panel empty-state-panel--hero" aria-labelledby="workspace-welcome">
<div className="empty-state-header empty-state-header--hero">
<span className="empty-kicker">ForgeLM workspace</span>
<h1 id="workspace-welcome">What can I do for you?</h1>
</div>
<div className="hero-composer-block">
<div className="input-box input-box--hero">
<ModelSelectorPill
models={models}
localModels={localModels}
selectedModel={selectedModel}
onModelChange={onModelChange}
onModelSwap={onModelSwap}
isSwapping={isSwapping}
status={status}
/>
<div className="attachment-actions" aria-label="Attachment options (coming soon)">
<button type="button" disabled aria-disabled="true" title="Attach image (Coming soon)">
<Image size={20} weight="duotone" />
</button>
<button type="button" disabled aria-disabled="true" title="Attach audio (Coming soon)">
<Microphone size={20} weight="duotone" />
</button>
<button type="button" disabled aria-disabled="true" title="Attach document (Coming soon)">
<Paperclip size={20} weight="duotone" />
</button>
<div className="hero-composer-row">
<div className="attachment-actions attachment-actions--hero" aria-label="Attachment options (coming soon)">
<button type="button" disabled aria-disabled="true" title="Add (Coming soon)">
<Plus size={18} weight="bold" />
</button>
<button type="button" disabled aria-disabled="true" title="Attach image (Coming soon)">
<Image size={18} weight="duotone" />
</button>
<button type="button" disabled aria-disabled="true" title="Attach folder (Coming soon)">
<FolderSimple size={18} weight="duotone" />
</button>
<button type="button" disabled aria-disabled="true" title="Capture screen (Coming soon)">
<Monitor size={18} weight="duotone" />
</button>
</div>
<div className="input-box-main input-box-main--hero">
<textarea
ref={textareaRef}
value={input}
onChange={(e) => { setInput(e.target.value); autoResize(); }}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); }
}}
placeholder={
composerNotice
? `${composerNotice.title}. ${composerNotice.description}`
: generating
? "Type to queue next message…"
: "Assign a task or ask anything"
}
rows={1}
disabled={Boolean(composerNotice)}
/>
<div className="hero-composer-actions">
<button type="button" className="hero-utility-btn" disabled aria-disabled="true" title="Voice input (Coming soon)">
<Microphone size={18} weight="duotone" />
</button>
{generating ? (
<button className="stop-btn stop-btn--hero" onClick={handleStop} type="button">
■
</button>
) : (
<button
className="send-btn send-btn--hero"
onClick={handleSend}
disabled={!input.trim() || Boolean(composerNotice)}
type="button"
aria-label="Send message"
>
<PaperPlaneRight size={18} weight="fill" />
</button>
)}
</div>
</div>
</div>
<div className="input-box-main">
<textarea
ref={textareaRef}
value={input}
onChange={(e) => { setInput(e.target.value); autoResize(); }}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); }
}}
placeholder={
composerNotice
? `${composerNotice.title}. ${composerNotice.description}`
: generating
? "Type to queue next message…"
: "Assign a task or ask anything"
}
rows={1}
disabled={Boolean(composerNotice)}
/>
{generating ? (
<button className="stop-btn" onClick={handleStop} type="button">
■ Stop
</button>
) : (
<button
className="send-btn"
onClick={handleSend}
disabled={!input.trim() || Boolean(composerNotice)}
type="button"
aria-label="Send message"
>
<PaperPlaneRight size={18} weight="fill" />
</button>
)}
</div>
<div className="composer-footer-row">
<span className="composer-footer-hint">
<div className="composer-footer-row composer-footer-row--hero">
<span className="composer-footer-hint composer-footer-hint--hero">
{composerNotice ? `${composerNotice.title} · ${composerNotice.description}` : "Connect your tools to ForgeLM"}
</span>
<div className="composer-footer-icons" aria-hidden="true">
<span />
<div className="composer-footer-icons composer-footer-icons--hero" aria-hidden="true">
<span />
<span />
<span />
<span />
</div>
<AgenticToggle isActive={agenticMode} onToggle={() => setAgenticMode((value) => !value)} disabled={Boolean(composerNotice)} />
</div>
</div>
</div>
<div className="starter-chip-rail" aria-label="Starter prompts">
{starterPrompts.map((prompt) => (
{quickActionChips.map((prompt) => (
<button key={prompt} type="button" className="starter-chip starter-chip--hero" onClick={() => handleStarterPrompt(prompt)}>
{prompt}
</button>
))}
</div>
<div className="hero-card-carousel" aria-label="Workspace shortcuts">
{heroCards.map((card) => (
<button key={card.title} type="button" className="hero-card" onClick={() => handleStarterPrompt(card.copy)}>
<div>
<span className="hero-card-title">{card.title}</span>
<span className="hero-card-copy">{card.copy}</span>
</div>
<div className="hero-card-preview" aria-hidden="true">
<span />
<span />
<span />
</div>
</button>
))}
<div className="hero-card-pager" aria-hidden="true">
<div className="hero-promo-card" aria-label="Desktop app download">
<div className="hero-promo-copy">
<span className="hero-promo-title">Download ForgeLM for Windows or macOS</span>
<span className="hero-promo-subtitle">Access local files and work seamlessly with your desktop.</span>
</div>
<div className="hero-promo-preview" aria-hidden="true">
<span className="hero-promo-preview-bar" />
<span className="hero-promo-preview-pane hero-promo-preview-pane--wide" />
<span className="hero-promo-preview-pane" />
<span className="active" />
<span />
<span />
</div>
</div>
</section>
@@ -740,8 +799,14 @@ export default function ChatView({
/>
))}
{generating && forgeAgentState ? (
<div className="msg-wrapper assistant">
<AgenticMessage plan={forgeAgentState.plan} state={forgeAgentState} isExecuting />
</div>
) : null}
{/* Live streaming bubble */}
{generating && (streamReasoning || streamContent || streamThinkingOpen) && (
{generating && !forgeAgentState && (streamReasoning || streamContent || streamThinkingOpen) && (
<div className="msg-wrapper assistant">
<div className="msg-bubble">
{(streamReasoning || streamThinkingOpen) && (
@@ -783,7 +848,7 @@ export default function ChatView({
</div>
)}
{generating && !streamReasoning && !streamContent && !streamThinkingOpen && (
{generating && !forgeAgentState && !streamReasoning && !streamContent && !streamThinkingOpen && (
<div className="msg-wrapper assistant">
<div className="thinking-manus">
<span className="thinking-dot" aria-hidden="true" />
@@ -846,6 +911,8 @@ export default function ChatView({
<span className="label">Out</span>
<ChatCircleText size={14} weight="duotone" />
</span>
<span className="separator">·</span>
<AgenticToggle isActive={agenticMode} onToggle={() => setAgenticMode((value) => !value)} disabled={Boolean(composerNotice)} />
</div>
{/* The typing pill */}
+38
View File
@@ -0,0 +1,38 @@
import type { LucideProps } from "lucide-react";
import {
AlertCircle,
CheckCircle,
CheckCircle2,
ChevronDown,
ChevronRight,
Circle,
Loader,
Loader2,
Minus,
XCircle,
} from "lucide-react";
const icons = {
AlertCircle,
CheckCircle,
CheckCircle2,
ChevronDown,
ChevronRight,
Circle,
Loader,
Loader2,
Minus,
XCircle,
} as const;
export type IconName = keyof typeof icons;
export type IconProps = LucideProps & {
name: IconName;
};
export function Icon({ name, ...props }: IconProps) {
const Component = icons[name] ?? Circle;
return <Component {...props} />;
}
export default Icon;
+941
View File
@@ -162,6 +162,932 @@
padding: 13px 16px;
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text {
width: 100%;
max-width: min(100%, 72ch);
background: transparent;
border: none;
border-radius: 0;
box-shadow: none;
color: var(--text);
overflow: visible;
}
/* ── Manus markdown ── */
.manus-markdown {
font-family: var(--font-editorial);
font-size: 1rem;
line-height: 1.78;
color: rgba(244, 244, 245, 0.94);
letter-spacing: -0.01em;
}
.manus-markdown > :first-child { margin-top: 0; }
.manus-markdown > :last-child { margin-bottom: 0; }
.manus-markdown h1,
.manus-markdown h2,
.manus-markdown h3,
.manus-markdown h4 {
font-family: var(--font-editorial);
font-weight: 560;
letter-spacing: -0.03em;
line-height: 1.18;
color: #fafafa;
margin: 1.2em 0 0.45em;
}
.manus-markdown h1 { font-size: clamp(1.65rem, 1.35rem + 0.9vw, 2.2rem); }
.manus-markdown h2 { font-size: clamp(1.2rem, 1.1rem + 0.45vw, 1.45rem); }
.manus-markdown h3 { font-size: 1.05rem; }
.manus-markdown p { margin: 0.75em 0; color: rgba(228, 228, 231, 0.9); }
.manus-markdown ul,
.manus-markdown ol { margin: 0.9em 0; padding-left: 1.35rem; }
.manus-markdown li { margin: 0.32rem 0; color: rgba(228, 228, 231, 0.88); }
.manus-markdown blockquote {
margin: 1.1em 0;
border-left: 1px solid rgba(244, 244, 245, 0.14);
padding-left: 1rem;
color: rgba(212, 212, 216, 0.78);
font-style: normal;
}
.manus-markdown hr { margin: 1.35em 0; border: none; border-top: 1px solid rgba(244, 244, 245, 0.08); }
.manus-markdown a { color: #dbeafe; text-decoration-color: rgba(219, 234, 254, 0.28); text-underline-offset: 0.18em; }
.manus-markdown strong { color: #fcfcfd; font-weight: 620; }
.manus-markdown code:not(.hljs) {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 8px;
padding: 0.1rem 0.4rem;
}
.manus-markdown .code-block {
margin: 1.1rem 0;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 18px;
background: rgba(9, 9, 11, 0.88);
}
.manus-markdown .code-header {
background: rgba(255, 255, 255, 0.03);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.manus-markdown .copy-code-btn {
border-radius: 999px;
border-color: rgba(255, 255, 255, 0.09);
background: rgba(255, 255, 255, 0.03);
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text .msg-content,
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text .agent-final-response__content {
padding: 0;
font-family: var(--font-editorial);
font-size: 1rem;
line-height: 1.78;
color: rgba(244, 244, 245, 0.96);
letter-spacing: -0.01em;
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text .msg-content > :first-child,
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text .agent-final-response__content > :first-child {
margin-top: 0;
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text .msg-content > :last-child,
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text .agent-final-response__content > :last-child {
margin-bottom: 0;
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text h1,
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text h2,
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text h3,
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text h4,
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text h5,
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text h6 {
margin: 1.2em 0 0.45em;
color: #fafafa;
font-family: var(--font-editorial);
font-weight: 560;
letter-spacing: -0.03em;
line-height: 1.18;
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text h1 {
font-size: clamp(1.65rem, 1.35rem + 0.9vw, 2.2rem);
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text h2 {
font-size: clamp(1.2rem, 1.1rem + 0.45vw, 1.45rem);
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text h3 {
font-size: 1.05rem;
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text p {
margin: 0.75em 0;
color: rgba(228, 228, 231, 0.9);
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text ul,
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text ol {
margin: 0.9em 0;
padding-left: 1.35rem;
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text li {
margin: 0.32rem 0;
color: rgba(228, 228, 231, 0.88);
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text blockquote {
margin: 1.1em 0;
border-left: 1px solid rgba(244, 244, 245, 0.14);
padding-left: 1rem;
color: rgba(212, 212, 216, 0.78);
font-style: normal;
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text hr {
margin: 1.35em 0;
border: none;
border-top: 1px solid rgba(244, 244, 245, 0.08);
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text a {
color: #dbeafe;
text-decoration-color: rgba(219, 234, 254, 0.28);
text-underline-offset: 0.18em;
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text strong {
color: #fcfcfd;
font-weight: 620;
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text code:not(.hljs) {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 8px;
padding: 0.1rem 0.4rem;
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text .code-block {
margin: 1.1rem 0;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 18px;
background: rgba(9, 9, 11, 0.88);
box-shadow: none;
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text .code-header {
background: rgba(255, 255, 255, 0.03);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text .copy-code-btn {
border-radius: 999px;
border-color: rgba(255, 255, 255, 0.09);
background: rgba(255, 255, 255, 0.03);
}
.msg-inline-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 14px;
min-height: 32px;
}
.msg-wrapper.assistant .message-metrics {
padding-left: 0;
font-size: 0.7rem;
color: rgba(161, 161, 170, 0.72);
font-family: var(--font-editorial);
gap: 10px;
}
.msg-actions--inline {
opacity: 0;
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.025);
border: 1px solid rgba(255, 255, 255, 0.05);
transition: opacity 0.2s ease-out, background 0.2s ease-out, border-color 0.2s ease-out;
}
.msg-wrapper.assistant:hover .msg-actions--inline,
.msg-wrapper.assistant:focus-within .msg-actions--inline {
opacity: 1;
}
.msg-actions--inline .action-btn {
min-width: 34px;
min-height: 34px;
border: none;
border-radius: 999px;
padding: 0;
color: rgba(228, 228, 231, 0.7);
}
.msg-actions--inline .action-btn:hover {
background: rgba(255, 255, 255, 0.06);
color: #fafafa;
}
.msg-actions--inline .action-btn:focus-visible {
box-shadow: 0 0 0 2px rgba(75, 134, 255, 0.35);
}
.msg-actions--inline .action-btn.copied {
color: var(--success);
background: rgba(48, 209, 88, 0.1);
}
.msg-wrapper.assistant .msg-thinking {
margin-bottom: 14px;
border-bottom: none;
}
.msg-wrapper.assistant .reasoning-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0;
background: transparent;
color: rgba(161, 161, 170, 0.8);
font-size: 0.72rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.14em;
}
.msg-wrapper.assistant .thinking-content {
padding: 10px 0 0;
color: rgba(212, 212, 216, 0.72);
}
.msg-wrapper.assistant .thinking-content > :first-child {
margin-left: 0;
padding-left: 12px;
border-left: 1px solid rgba(244, 244, 245, 0.08);
}
/* ── Minimalist Agentic plan ── */
.agentic-plan {
display: flex;
flex-direction: column;
}
.agentic-plan__header {
display: flex;
align-items: center;
gap: 8px;
background: none;
border: none;
padding: 0;
cursor: pointer;
color: var(--text);
text-align: left;
width: 100%;
font-family: inherit;
}
.agentic-plan__header:hover .agentic-plan__title {
color: var(--accent);
}
.agentic-plan__dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: var(--accent);
box-shadow: 0 0 8px var(--accent-glow);
flex-shrink: 0;
}
.agentic-plan__title {
font-family: var(--font-editorial);
font-size: 0.82rem;
font-weight: 500;
color: rgba(244, 244, 245, 0.82);
letter-spacing: -0.005em;
transition: color 0.15s;
}
.agentic-plan__badge {
font-size: 0.6rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text3);
border: 1px solid var(--border2);
border-radius: 999px;
padding: 1px 6px;
flex-shrink: 0;
}
.agentic-plan__counter {
font-size: 0.68rem;
color: var(--text3);
font-family: var(--font-editorial);
margin-left: auto;
flex-shrink: 0;
}
.agentic-plan__chevron {
width: 14px;
height: 14px;
color: var(--text3);
flex-shrink: 0;
transition: transform 0.2s ease-out;
}
.agentic-plan__chevron.rotated {
transform: rotate(180deg);
}
.agentic-plan__body {
padding-left: 15px;
}
.agentic-plan__overview {
margin: 6px 0 10px;
font-family: var(--font-editorial);
font-size: 0.82rem;
line-height: 1.6;
color: rgba(244, 244, 245, 0.5);
}
.agentic-steps {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.agentic-step {
display: flex;
align-items: flex-start;
gap: 8px;
}
.agentic-step__content {
min-width: 0;
flex: 1;
}
.agentic-step__title {
font-family: var(--font-editorial);
font-size: 0.84rem;
font-weight: 500;
color: rgba(244, 244, 245, 0.8);
line-height: 1.4;
margin: 0;
}
.agentic-step__desc {
margin: 2px 0 0;
font-size: 0.78rem;
line-height: 1.5;
color: rgba(244, 244, 245, 0.45);
}
.agentic-substeps {
list-style: none;
margin: 4px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.agentic-substep {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.76rem;
color: rgba(244, 244, 245, 0.45);
font-family: var(--font-editorial);
}
.agentic-plan__prog {
margin-top: 10px;
}
.agentic-plan__prog-bar {
height: 2px;
background: var(--border);
border-radius: 999px;
overflow: hidden;
}
.agentic-plan__prog-fill {
height: 100%;
background: var(--accent);
border-radius: 999px;
transition: width 0.25s ease-out;
}
/* ── Agentic status dots ── */
.agentic-dot {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 2px;
}
.agentic-dot--idle { color: var(--text3); }
.agentic-dot--running { color: var(--accent); }
.agentic-dot--done { color: var(--success); }
.agentic-dot--error { color: var(--danger); }
/* ── Agentic update (inline text) ── */
.agentic-update {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--font-editorial);
font-size: 0.8rem;
color: rgba(244, 244, 245, 0.48);
line-height: 1.5;
}
.agentic-update__icon {
flex-shrink: 0;
opacity: 0.7;
}
.agentic-update--success .agentic-update__icon { color: var(--success); opacity: 1; }
.agentic-update--warning .agentic-update__icon { color: var(--warning); opacity: 1; }
.agentic-update--error .agentic-update__icon { color: var(--danger); opacity: 1; }
/* ── Specialist cards ── */
.specialist-card {
display: flex;
flex-direction: column;
gap: 0;
padding: 12px 0;
}
.specialist-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.specialist-card__identity {
display: flex;
align-items: center;
gap: 8px;
}
.specialist-card__dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: var(--accent);
box-shadow: 0 0 8px var(--accent-glow);
flex-shrink: 0;
}
.specialist-card__name {
font-family: var(--font-editorial);
font-size: 0.84rem;
font-weight: 500;
color: rgba(244, 244, 245, 0.88);
letter-spacing: -0.005em;
}
.specialist-card__title {
font-family: var(--font-editorial);
font-size: 0.72rem;
color: rgba(244, 244, 245, 0.4);
}
.specialist-card__badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 2px 7px;
border-radius: 999px;
}
.specialist-card__badge--thinking {
color: var(--accent);
background: rgba(72, 166, 255, 0.08);
border: 1px solid rgba(72, 166, 255, 0.2);
}
.specialist-card__badge--done {
color: var(--success);
background: rgba(48, 209, 88, 0.08);
border: 1px solid rgba(48, 209, 88, 0.2);
}
.specialist-card__thinking-section {
margin-top: 8px;
}
.specialist-card__thinking-toggle {
display: flex;
align-items: center;
gap: 5px;
background: none;
border: none;
padding: 0;
cursor: pointer;
font-family: var(--font-editorial);
font-size: 0.72rem;
font-weight: 500;
color: rgba(244, 244, 245, 0.38);
letter-spacing: 0.04em;
transition: color 0.15s;
}
.specialist-card__thinking-toggle:hover {
color: rgba(244, 244, 245, 0.65);
}
.specialist-card__chevron {
width: 12px;
height: 12px;
transition: transform 0.18s ease-out;
flex-shrink: 0;
}
.specialist-card__chevron.rotated {
transform: rotate(180deg);
}
.specialist-card__thinking-preview {
margin: 5px 0 0;
font-family: var(--font-editorial);
font-size: 0.76rem;
line-height: 1.5;
color: rgba(244, 244, 245, 0.32);
font-style: italic;
}
.specialist-card__thinking-body {
margin-top: 6px;
}
.specialist-card__thinking-text {
font-family: var(--font-editorial);
font-size: 0.8rem;
line-height: 1.65;
color: rgba(228, 228, 231, 0.6);
border-left: 1px solid rgba(244, 244, 245, 0.08);
padding-left: 12px;
margin: 0;
}
.specialist-card__response {
margin-top: 8px;
}
.specialist-card__response-text {
font-family: var(--font-editorial);
font-size: 0.88rem;
line-height: 1.72;
color: rgba(228, 228, 231, 0.88);
margin: 0;
}
.specialist-card__divider {
margin-top: 12px;
height: 1px;
background: rgba(244, 244, 245, 0.05);
}
/* ── Agentic artifact row ── */
.agentic-artifact {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg2);
margin-top: 12px;
transition: background 0.15s;
}
.agentic-artifact:hover {
background: var(--bg3);
}
.agentic-artifact__icon {
flex-shrink: 0;
color: var(--accent);
}
.agentic-artifact__info {
min-width: 0;
flex: 1;
}
.agentic-artifact__name {
font-size: 0.82rem;
font-weight: 500;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.agentic-artifact__meta {
font-size: 0.7rem;
color: var(--text3);
margin-top: 1px;
}
.agentic-artifact__btn {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 10px;
background: var(--bg3);
border: 1px solid var(--border2);
border-radius: var(--radius-sm);
color: var(--text2);
font-size: 0.72rem;
font-family: var(--font-sans);
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.agentic-artifact__btn:hover {
background: var(--bg4);
color: var(--text);
border-color: var(--accent);
}
.forgeagent-shell__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.forgeagent-shell__label {
font-size: 0.76rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(228, 228, 231, 0.92);
}
.forgeagent-shell__status {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 999px;
padding: 4px 10px;
font-size: 0.68rem;
font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(161, 161, 170, 0.84);
background: rgba(24, 24, 27, 0.74);
}
.forgeagent-card {
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 20px;
background: rgba(15, 23, 42, 0.34);
padding: 14px 16px;
}
.forgeagent-card__eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(161, 161, 170, 0.8);
}
.forgeagent-card__dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: #60a5fa;
box-shadow: 0 0 12px rgba(96, 165, 250, 0.45);
}
.forgeagent-card__title-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-top: 8px;
}
.forgeagent-card__title {
margin: 0;
font-size: 1rem;
font-weight: 600;
line-height: 1.35;
color: #fafafa;
}
.forgeagent-card__progress {
flex-shrink: 0;
font-size: 0.75rem;
color: rgba(212, 212, 216, 0.72);
}
.forgeagent-card__overview {
margin: 6px 0 0;
font-size: 0.88rem;
line-height: 1.55;
color: rgba(212, 212, 216, 0.76);
}
.forgeagent-progress {
margin-top: 12px;
height: 5px;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
}
.forgeagent-progress__bar {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, rgba(226, 232, 240, 0.95), rgba(148, 163, 184, 0.92));
transition: width 0.2s ease-out;
}
.forgeagent-steps {
list-style: none;
margin: 0;
padding: 0;
}
.forgeagent-steps--compact {
display: flex;
flex-direction: column;
gap: 8px;
}
.forgeagent-step {
display: flex;
gap: 10px;
align-items: flex-start;
}
.forgeagent-step__marker {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
margin-top: 1px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(9, 9, 11, 0.72);
font-size: 0.68rem;
color: rgba(161, 161, 170, 0.82);
}
.forgeagent-step.is-complete .forgeagent-step__marker {
border-color: rgba(52, 211, 153, 0.24);
background: rgba(16, 185, 129, 0.1);
color: rgba(167, 243, 208, 0.96);
}
.forgeagent-step.is-active .forgeagent-step__marker {
border-color: rgba(96, 165, 250, 0.3);
background: rgba(59, 130, 246, 0.12);
color: rgba(191, 219, 254, 0.96);
}
.forgeagent-step__body {
min-width: 0;
flex: 1;
}
.forgeagent-step__row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.forgeagent-step__title {
font-size: 0.88rem;
font-weight: 500;
line-height: 1.4;
color: rgba(244, 244, 245, 0.92);
}
.forgeagent-step__status {
flex-shrink: 0;
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: rgba(161, 161, 170, 0.72);
}
.forgeagent-step__description,
.forgeagent-step__substep {
margin-top: 4px;
font-size: 0.8rem;
line-height: 1.5;
color: rgba(161, 161, 170, 0.82);
}
.forgeagent-step__substeps {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 6px;
}
.forgeagent-updates {
display: flex;
flex-direction: column;
gap: 6px;
}
.forgeagent-update {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 0;
font-size: 0.82rem;
line-height: 1.5;
color: rgba(212, 212, 216, 0.8);
}
.forgeagent-update__dot {
width: 6px;
height: 6px;
margin-top: 0.45rem;
flex-shrink: 0;
border-radius: 999px;
background: rgba(161, 161, 170, 0.72);
}
.forgeagent-update--success .forgeagent-update__dot {
background: rgba(52, 211, 153, 0.92);
}
.forgeagent-update--warning .forgeagent-update__dot {
background: rgba(251, 191, 36, 0.92);
}
.forgeagent-update--error .forgeagent-update__dot {
background: rgba(251, 113, 133, 0.92);
}
.agent-final-response {
padding-top: 6px;
}
.agent-final-response__label {
margin-bottom: 10px;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(161, 161, 170, 0.8);
}
.agent-final-response__artifact-label {
margin-bottom: 6px;
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(161, 161, 170, 0.7);
}
/* ── Message metrics ── */
.message-metrics {
display: flex;
@@ -324,6 +1250,21 @@
.action-btn {
flex: 1 1 132px;
}
.msg-inline-meta {
flex-direction: column;
align-items: flex-start;
}
.msg-actions--inline {
opacity: 1;
}
.msg-actions--inline .action-btn {
flex: 0 0 auto;
min-width: 40px;
min-height: 40px;
}
}
@media (prefers-reduced-motion: reduce) {
@@ -69,4 +69,86 @@ describe("MessageBubble", () => {
expect(container.textContent).toContain("Total 42");
expect(container.textContent).toContain("6.4 tok/s");
});
it("renders a clean agentic final response without classic bubble chrome", async () => {
const message = {
id: 22,
chat_id: 5,
role: "assistant",
content: "Aqui está o guia final.",
created_at: Date.now(),
display_mode: "agentic_final",
} as Message;
await act(async () => {
root.render(
<MessageBubble
message={message}
onDelete={vi.fn()}
onEditAssistant={vi.fn()}
renderMarkdown={(text) => `<p>${text}</p>`}
/>,
);
});
expect(container.querySelector(".agent-final-response")).toBeTruthy();
expect(container.textContent).toContain("Aqui está o guia final.");
});
it("keeps forgeagent execution cards visible for persisted agentic messages", async () => {
const message = {
id: 31,
chat_id: 7,
role: "assistant",
content: "Aqui está a resposta final organizada.",
created_at: Date.now(),
display_mode: "agentic_final",
forgeagent_state: {
plan_id: "plan-7",
current_step: 3,
status: "completed",
progress: 100,
final_response: "Aqui está a resposta final organizada em formato Manus-style, pronta para revisão e entrega.",
artifact: {
filename: "forgeagent-output.md",
content: "# Resultado final\n\nPrompt original: testar o agentico",
language: "markdown",
cta_label: "Entregar o guia ao usuário",
},
system_messages: [
{ id: "m1", step_id: 1, tone: "info", content: "Analisei a solicitação e defini o objetivo principal." },
{ id: "m2", step_id: 2, tone: "info", content: "Estruturei a solução e organizei a execução em blocos claros." },
],
plan: {
title: "Pesquise sobre a qualidade das pedras nas montanhas",
overview: "Vou estruturar um plano curto, executar cada etapa visualmente e entregar a resposta final no estilo ForgeAgent.",
steps: [
{ id: 1, title: "Entender a solicitação", description: "Quebrar o pedido em objetivo, stack e formato de entrega.", status: "completed", sub_steps: [] },
{ id: 2, title: "Preparar a solução", description: "Montar a estrutura, dependências e fluxo recomendado.", status: "completed", sub_steps: [] },
{ id: 3, title: "Gerar a resposta final", description: "Entregar o conteúdo final com orientação prática e artefato.", status: "completed", sub_steps: [] },
],
},
},
} as Message & { forgeagent_state: unknown };
await act(async () => {
root.render(
<MessageBubble
message={message}
onDelete={vi.fn()}
onEditAssistant={vi.fn()}
renderMarkdown={(text) => `<p>${text}</p>`}
/>,
);
});
expect(container.querySelector(".forgeagent-steps--compact")).toBeTruthy();
expect(container.textContent).toContain("Pesquise sobre a qualidade das pedras nas montanhas");
expect(container.textContent).toContain("Entender a solicitação");
expect(container.textContent).toContain("Analisei a solicitação e defini o objetivo principal.");
expect(container.textContent).toContain("forgeagent-output.md");
expect(container.textContent).toContain("Resposta final");
expect(container.textContent).toContain("Concluí a execução do pedido");
expect(container.textContent).not.toContain("pronta para revisão e entrega");
});
});
+98 -6
View File
@@ -1,5 +1,8 @@
import { useState, useRef, useEffect } from "react";
import type { Message } from "../lib/types";
import { resolveForgeAgentFinalResponse, withPersistedForgeAgentResponse } from "../lib/forgeagent-state";
import AgenticMessage from "./forgeagent/AgenticMessage";
import FinalResponse from "./forgeagent/FinalResponse";
import "./MessageBubble.css";
interface Props {
@@ -7,10 +10,11 @@ interface Props {
onDelete: (id: number) => void;
onEditAndResend?: (msg: Message, newContent: string) => void;
onEditAssistant?: (id: number, newContent: string) => void;
onResend?: (msg: Message) => void;
renderMarkdown: (text: string) => string;
}
export default function MessageBubble({ message, onDelete, onEditAndResend, onEditAssistant, renderMarkdown }: Props) {
export default function MessageBubble({ message, onDelete, onEditAndResend, onEditAssistant, onResend, renderMarkdown }: Props) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(message.content);
const [copied, setCopied] = useState(false);
@@ -83,6 +87,14 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
};
const hasReasoning = Boolean(message.reasoning_content?.trim());
const isAgenticFinal = message.role === "assistant" && message.display_mode === "agentic_final";
const persistedForgeAgentState = isAgenticFinal ? message.forgeagent_state : null;
const resolvedForgeAgentState = persistedForgeAgentState
? withPersistedForgeAgentResponse(persistedForgeAgentState, message.content)
: null;
const resolvedAgenticContent = isAgenticFinal
? resolveForgeAgentFinalResponse(resolvedForgeAgentState, message.content)
: message.content;
const hasUsage = message.role === "assistant" && (
(message.prompt_tokens ?? 0) > 0 ||
(message.completion_tokens ?? 0) > 0 ||
@@ -90,6 +102,9 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
);
const showFooter = message.role === "assistant";
const assistantBubbleClass = isAgenticFinal
? "msg-bubble msg-bubble--assistant-text msg-bubble--agentic"
: "msg-bubble msg-bubble--assistant-text";
return (
<div className={`msg-wrapper ${message.role}`}>
@@ -119,10 +134,72 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
</div>
) : (
<>
{message.role === "assistant" ? (
{isAgenticFinal ? (
<div ref={bubbleRef} className="msg-bubble msg-bubble--assistant-text">
{resolvedForgeAgentState ? (
<AgenticMessage
plan={resolvedForgeAgentState.plan}
state={resolvedForgeAgentState}
isExecuting={resolvedForgeAgentState.status === "planning" || resolvedForgeAgentState.status === "executing"}
/>
) : (
<FinalResponse content={resolvedAgenticContent} />
)}
{showFooter && (
<div className="msg-inline-meta">
{hasUsage ? (
<div className="message-metrics">
<span>Sent {message.prompt_tokens ?? 0}</span>
<span>Generated {message.completion_tokens ?? 0}</span>
<span>Total {message.total_tokens ?? 0}</span>
{message.tokens_per_second ? <span>{message.tokens_per_second.toFixed(1)} tok/s</span> : null}
</div>
) : <div />}
<div className="msg-actions msg-actions--inline" aria-label="Message actions">
<button type="button" className={`action-btn ${copied ? "copied" : ""}`} onClick={copyMessage} aria-label="Copy message">
<svg viewBox="0 0 20 20" focusable="false" aria-hidden="true">
<rect x="7" y="7" width="10" height="10" rx="2" />
<path d="M13 7V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2" />
</svg>
</button>
{onResend && (
<button
type="button"
className="action-btn"
onClick={() => onResend(message)}
aria-label="Regenerate response"
title="Regenerate response"
>
<svg viewBox="0 0 20 20" focusable="false" aria-hidden="true">
<path d="M4 10a6 6 0 1 0 1.24-3.72" strokeLinecap="round" strokeLinejoin="round" />
<polyline points="1 4 4 7 7 4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
)}
<button
type="button"
className="action-btn"
onClick={startEdit}
aria-label={onEditAndResend ? "Edit message and resend" : "Edit message"}
>
<svg viewBox="0 0 20 20" focusable="false" aria-hidden="true">
<path d="M13.586 3.414a2 2 0 0 1 2.828 2.828l-9.17 9.17a1 1 0 0 1-.47.264l-3.774.944a.5.5 0 0 1-.608-.608l.944-3.774a1 1 0 0 1 .264-.47l9.986-9.354z" />
</svg>
</button>
<button type="button" className="action-btn danger" onClick={() => onDelete(message.id)} aria-label="Delete message">
<svg viewBox="0 0 20 20" focusable="false" aria-hidden="true">
<path d="M6 5h8l-.7 10.3a1 1 0 0 1-1 .7H7.7a1 1 0 0 1-1-.7L6 5z" />
<path d="M4 5h12M8.5 5V3.5a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5V5" />
</svg>
</button>
</div>
</div>
)}
</div>
) : message.role === "assistant" ? (
<div
ref={bubbleRef}
className="msg-bubble"
className={assistantBubbleClass}
>
{hasReasoning && (
<details className="msg-thinking">
@@ -141,12 +218,12 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
)}
<div
className="msg-content"
className="msg-content msg-content--assistant"
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }}
/>
{showFooter && (
<div className="msg-footer">
<div className="msg-inline-meta">
{hasUsage && (
<div className="message-metrics">
<span>Sent {message.prompt_tokens ?? 0}</span>
@@ -156,13 +233,28 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
</div>
)}
{!hasUsage && <div />}
<div className="msg-actions" aria-label="Message actions">
<div className="msg-actions msg-actions--inline" aria-label="Message actions">
<button type="button" className={`action-btn ${copied ? "copied" : ""}`} onClick={copyMessage} aria-label="Copy message">
<svg viewBox="0 0 20 20" focusable="false" aria-hidden="true">
<rect x="7" y="7" width="10" height="10" rx="2" />
<path d="M13 7V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2" />
</svg>
</button>
{onResend && (
<button
type="button"
className="action-btn"
onClick={() => onResend(message)}
aria-label="Regenerate response"
title="Regenerate response"
>
{/* Refresh / regenerate icon */}
<svg viewBox="0 0 20 20" focusable="false" aria-hidden="true">
<path d="M4 10a6 6 0 1 0 1.24-3.72" strokeLinecap="round" strokeLinejoin="round" />
<polyline points="1 4 4 7 7 4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
)}
<button
type="button"
className="action-btn"
+13 -12
View File
@@ -2,7 +2,7 @@ import { useState, useRef, useEffect } from "react";
import { ChevronDown, Loader2 } from "lucide-react";
import type { ModelInfo, ServerStatus } from "../lib/types";
import type { RuntimeModelFile } from "../features/runtime/types";
import { getDisplayName, isInChatSelector } from "../lib/modelAlias";
import { getDisplayName } from "../lib/modelAlias";
interface ModelSelectorPillProps {
models: ModelInfo[];
@@ -40,7 +40,10 @@ export default function ModelSelectorPill({
const handleSelectModel = (modelId: string) => {
setDropdownOpen(false);
onModelChange(modelId);
if (onModelSwap) {
// Only call onModelSwap for local models (which have file paths)
// API models don't have paths and shouldn't trigger a model swap
const isLocalModel = localModels.some((m) => m.path === modelId);
if (onModelSwap && isLocalModel) {
onModelSwap(modelId);
}
};
@@ -50,17 +53,15 @@ export default function ModelSelectorPill({
...models.map((m) => ({ id: m.id, label: getDisplayName(m.id) })),
...localModels.map((m) => ({ id: m.path, label: getDisplayName(m.path) })),
];
// Deduplicate by id
const seen = new Set<string>();
// Deduplicate by label (display name) - same name means same model
const seenLabels = new Set<string>();
const dedupedModels = allAvailableModels.filter((m) => {
if (seen.has(m.id)) return false;
seen.add(m.id);
if (seenLabels.has(m.label)) return false;
seenLabels.add(m.label);
return true;
});
// Chat selector: only Cooper + Iron (Steel is settings-only)
const chatSelectorModels = dedupedModels.filter((m) => isInChatSelector(m.id));
// Show all available models in the selector
const selectedLabel = getDisplayName(selectedModel) || "No model";
return (
@@ -69,8 +70,8 @@ export default function ModelSelectorPill({
type="button"
className="model-selector-pill"
onClick={() => setDropdownOpen(!dropdownOpen)}
disabled={chatSelectorModels.length === 0 || isSwapping}
title={chatSelectorModels.length === 0 ? "Start server to load models" : ""}
disabled={dedupedModels.length === 0 || isSwapping}
title={dedupedModels.length === 0 ? "Start server to load models" : ""}
aria-label="Choose active model"
aria-expanded={dropdownOpen}
>
@@ -84,7 +85,7 @@ export default function ModelSelectorPill({
</button>
{dropdownOpen && (
<ul className="model-selector-menu" role="listbox">
{chatSelectorModels.map((m) => (
{dedupedModels.map((m) => (
<li
key={m.id}
role="option"
+133 -295
View File
@@ -1,32 +1,13 @@
.sidebar {
width: 276px;
min-width: 276px;
background: #1f1f1f;
width: 288px;
min-width: 288px;
background: #18181b;
border-right: 1px solid rgba(255, 255, 255, 0.04);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar--initial .model-section-label,
.sidebar--initial .footer-meta,
.sidebar--initial .footer-version {
display: none;
}
.sidebar--initial .model-section {
padding-top: 8px;
}
.sidebar--initial .model-row .icon-btn {
display: none;
}
.sidebar--initial .model-dropdown-trigger {
background: transparent;
border-color: rgba(255, 255, 255, 0.03);
}
.sidebar-header-actions {
display: flex;
align-items: center;
@@ -37,12 +18,10 @@
display: none;
}
/* ── Header ── */
.sidebar-header {
display: flex;
align-items: flex-start;
padding: 14px 12px 10px;
border-bottom: 0;
align-items: center;
padding: 12px 16px 8px;
gap: 8px;
flex-shrink: 0;
}
@@ -50,23 +29,21 @@
.sidebar-brand {
display: flex;
align-items: center;
gap: 10px;
gap: 8px;
flex: 1;
min-width: 0;
}
.brand-mark {
width: 26px;
height: 26px;
border-radius: 8px;
width: 24px;
height: 24px;
border-radius: 7px;
border: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.02);
color: var(--text);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.82rem;
font-weight: 700;
flex-shrink: 0;
}
@@ -77,25 +54,17 @@
}
.brand-name {
font-size: 15px;
font-weight: 600;
font-size: 14px;
font-weight: 500;
letter-spacing: -0.01em;
color: var(--text);
}
.brand-subtitle {
display: none;
}
.sidebar-nav-section {
padding: 2px 8px 10px;
padding: 4px 10px 10px;
flex-shrink: 0;
}
.sidebar-section-label {
display: none;
}
.sidebar-nav-list {
display: grid;
gap: 4px;
@@ -108,286 +77,125 @@
gap: 10px;
border: 1px solid transparent;
background: transparent;
color: var(--text2);
border-radius: 10px;
padding: 10px 12px;
color: #d4d4d8;
border-radius: 12px;
padding: 9px 12px;
font: inherit;
font-size: 13px;
font-size: 14px;
cursor: pointer;
text-align: left;
transition: background 0.15s, border-color 0.15s, color 0.15s;
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.sidebar-nav-item:hover {
background: rgba(255, 255, 255, 0.03);
background: rgba(39, 39, 42, 0.96);
border-color: rgba(255, 255, 255, 0.05);
color: var(--text);
color: #fff;
}
.sidebar-nav-item svg {
color: var(--text3);
color: #a1a1aa;
flex-shrink: 0;
}
.sidebar-nav-badge {
margin-left: auto;
min-height: 18px;
padding: 0 7px;
border-radius: 999px;
background: rgba(59, 130, 246, 0.18);
border: 1px solid rgba(96, 165, 250, 0.28);
color: #bfdbfe;
font-size: 10px;
font-weight: 600;
}
.icon-btn {
background: none;
border: none;
color: var(--text3);
color: #a1a1aa;
cursor: pointer;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
transition: background 0.15s, color 0.15s;
border-radius: 10px;
transition: background 0.2s, color 0.2s;
flex-shrink: 0;
padding: 0;
}
.icon-btn:hover {
background: rgba(255, 255, 255, 0.04);
color: var(--text);
color: #fff;
}
/* ── Model section ── */
.model-section {
margin-top: auto;
padding: 10px 12px 12px;
border-top: 1px solid rgba(255, 255, 255, 0.04);
border-bottom: 0;
flex-shrink: 0;
}
.model-section-label {
font-size: 11px;
font-weight: 600;
color: var(--text3);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 6px;
}
.model-row {
display: flex;
align-items: center;
gap: 8px;
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
transition: background 0.3s, box-shadow 0.3s;
}
.status-dot.status-ok {
background: var(--success);
box-shadow: 0 0 6px rgba(48,209,88,0.5);
}
.status-dot.status-error {
background: var(--danger);
}
.status-dot.status-loading {
background: var(--warning);
animation: pulse 1.2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.model-select {
flex: 1;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--text);
font-size: 13px;
padding: 6px 28px 6px 8px;
outline: none;
cursor: pointer;
min-width: 0;
font-family: inherit;
transition: border-color 0.15s, background 0.15s;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%236b7280'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
}
.model-select:hover {
background-color: var(--bg3);
border-color: var(--border);
}
.model-select:focus {
border-color: var(--accent);
background-color: var(--bg3);
}
.model-select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Custom dropdown (replaces native select) ── */
.model-dropdown-wrapper {
flex: 1;
min-width: 0;
position: relative;
}
.model-dropdown-trigger {
width: 100%;
background: rgba(255, 255, 255, 0.025);
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--text);
font-size: 13px;
padding: 6px 28px 6px 8px;
outline: none;
cursor: pointer;
min-width: 0;
font-family: inherit;
transition: border-color 0.15s, background 0.15s;
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
}
.model-dropdown-trigger:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.05);
border-color: var(--border);
}
.model-dropdown-trigger:focus {
border-color: var(--accent);
background: rgba(255, 255, 255, 0.05);
}
.model-dropdown-trigger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.model-dropdown-chevron {
flex-shrink: 0;
color: var(--text3);
transition: transform 0.15s;
}
.model-dropdown-trigger[aria-expanded="true"] .model-dropdown-chevron {
transform: rotate(180deg);
}
.model-dropdown-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
z-index: 100;
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-lg);
max-height: 200px;
overflow-y: auto;
padding: 4px 0;
list-style: none;
}
.model-dropdown-item {
padding: 8px 12px;
font-size: 13px;
color: var(--text2);
cursor: pointer;
transition: background 0.1s, color 0.1s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.model-dropdown-item:hover {
background: rgba(255, 255, 255, 0.04);
color: var(--text);
}
.model-dropdown-item--selected {
background: rgba(72,166,255,0.07);
color: var(--accent);
font-weight: 500;
}
/* ── Sidebar actions ── */
.sidebar-actions {
padding: 6px 8px 8px;
padding: 4px 10px 8px;
flex-shrink: 0;
}
.sidebar-actions--top {
padding-top: 2px;
}
.new-chat-btn {
width: 100%;
background: rgba(255, 255, 255, 0.05);
color: var(--text);
border: 1px solid rgba(255, 255, 255, 0.02);
border-radius: 10px;
background: rgba(255, 255, 255, 0.04);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.03);
border-radius: 14px;
padding: 10px 13px;
font-size: 13px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.15s;
transition: background 0.2s, color 0.2s, border-color 0.2s, transform 0.2s;
font-family: inherit;
height: 40px;
}
.new-chat-btn:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--text);
background: rgba(39, 39, 42, 0.96);
border-color: rgba(255, 255, 255, 0.05);
}
.new-chat-btn:active {
transform: scale(0.98);
transform: scale(0.985);
}
.sidebar-list-meta {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 14px 6px;
gap: 10px;
}
/* ── Chat list ── */
.chat-list-section-label {
font-size: 10px;
font-size: 11px;
font-weight: 600;
color: var(--text3);
text-transform: uppercase;
letter-spacing: 0.12em;
padding: 12px 12px 6px;
flex-shrink: 0;
color: #a1a1aa;
letter-spacing: 0.02em;
}
.sidebar-list-filter {
font-size: 11px;
color: #71717a;
}
.chat-list {
flex: 1;
overflow-y: auto;
padding: 4px 8px 10px;
padding: 0 10px 10px;
display: flex;
flex-direction: column;
gap: 1px;
gap: 4px;
}
.chat-list-empty {
text-align: center;
color: var(--text3);
color: #71717a;
font-size: 12px;
padding: 20px 8px 24px;
padding: 24px 8px 28px;
line-height: 1.6;
}
@@ -395,12 +203,12 @@
display: flex;
align-items: center;
gap: 4px;
border-radius: var(--radius-sm);
border-radius: 14px;
font-size: 13px;
color: var(--text2);
transition: background 0.12s, color 0.12s;
color: #d4d4d8;
transition: background 0.2s, color 0.2s, border-color 0.2s;
position: relative;
border-top: 1px solid rgba(255, 255, 255, 0.02);
border: 1px solid transparent;
}
.chat-item-button {
@@ -408,29 +216,39 @@
min-width: 0;
display: flex;
align-items: center;
padding: 10px 10px;
gap: 10px;
padding: 9px 10px;
background: transparent;
border: 0;
color: inherit;
cursor: pointer;
font: inherit;
text-align: left;
border-radius: var(--radius-sm);
}
.chat-item:hover {
background: rgba(255, 255, 255, 0.04);
color: var(--text);
border-radius: 14px;
}
.chat-item:hover,
.chat-item:focus-within {
background: rgba(255, 255, 255, 0.04);
color: var(--text);
background: rgba(39, 39, 42, 0.92);
color: #fff;
}
.chat-item.active {
background: rgba(255, 255, 255, 0.06);
color: var(--text);
color: #fff;
border-color: rgba(255, 255, 255, 0.06);
}
.chat-item-icon {
width: 24px;
height: 24px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.04);
color: #a1a1aa;
flex-shrink: 0;
}
.chat-item-title {
@@ -446,63 +264,83 @@
opacity: 0;
background: none;
border: none;
color: var(--text3);
color: #71717a;
cursor: pointer;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: opacity 0.15s, background 0.15s, color 0.15s;
border-radius: 8px;
transition: opacity 0.2s, background 0.2s, color 0.2s;
flex-shrink: 0;
padding: 0;
margin-right: 4px;
}
.chat-item:hover .chat-item-del {
opacity: 1;
}
.chat-item:hover .chat-item-del,
.chat-item:focus-within .chat-item-del,
.chat-item.active .chat-item-del {
opacity: 1;
}
.chat-item-del:hover {
background: rgba(240,64,64,0.15);
background: rgba(240, 64, 64, 0.15);
color: var(--danger);
}
/* ── Footer ── */
.sidebar-footer {
padding: 8px 12px 14px;
border-top: 0;
padding: 12px 14px 16px;
display: flex;
align-items: center;
justify-content: space-between;
flex-direction: column;
align-items: flex-start;
gap: 10px;
flex-shrink: 0;
}
.sidebar--initial .sidebar-footer {
padding-top: 0;
.sidebar-share-btn {
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.025);
color: #fff;
border-radius: 14px;
padding: 10px 12px;
text-align: left;
font-size: 13px;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.sidebar-share-btn:hover {
background: rgba(39, 39, 42, 0.96);
border-color: rgba(255, 255, 255, 0.08);
}
.sidebar-footer-copy {
display: flex;
flex-direction: column;
gap: 2px;
}
.footer-version {
font-size: 10px;
color: var(--text3);
letter-spacing: 0.02em;
align-items: center;
gap: 10px;
}
.footer-meta {
font-size: 11px;
color: var(--text2);
color: #a1a1aa;
}
.sidebar-footer-link {
display: inline-flex;
align-items: center;
gap: 6px;
border: 0;
background: transparent;
color: #71717a;
font-size: 11px;
cursor: pointer;
padding: 0;
}
.sidebar-footer-link:hover {
color: #d4d4d8;
}
@media (max-width: 960px) {
+161
View File
@@ -0,0 +1,161 @@
/* @vitest-environment jsdom */
import { createRoot, type Root } from "react-dom/client";
import { act } from "react-dom/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import Sidebar from "./Sidebar";
import type { Chat, ModelInfo } from "../lib/types";
import type { RuntimeModelFile } from "../features/runtime/types";
// ─── Mocks ───────────────────────────────────────────────────────────────────
const defaultChats: Chat[] = [
{ id: 1, title: "Chat One", created_at: 1, updated_at: 2 },
];
const defaultApiModels: ModelInfo[] = [
{ id: "api-model-1", object: "model", created: 1, owned_by: "local" },
];
const defaultLocalModels: RuntimeModelFile[] = [
{ name: "local-model-1.gguf", path: "/models/local-model-1.gguf" },
{ name: "local-model-2.gguf", path: "/models/local-model-2.gguf" },
];
const defaultProps = {
chats: defaultChats,
currentChatId: 1,
onSelectChat: vi.fn(),
onNewChat: vi.fn(),
onDeleteChat: vi.fn(),
models: defaultApiModels,
localModels: defaultLocalModels,
selectedModel: "api-model-1",
onModelChange: vi.fn(),
onModelSwap: vi.fn(),
isSwapping: false,
status: "ok" as const,
onRefreshModels: vi.fn(),
onOpenSettings: vi.fn(),
onCloseMobile: vi.fn(),
};
// ─── Test harness ────────────────────────────────────────────────────────────
describe("Sidebar model dropdown", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
// @ts-expect-error React test environment flag
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
vi.clearAllMocks();
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.restoreAllMocks();
// @ts-expect-error React test environment flag
globalThis.IS_REACT_ACT_ENVIRONMENT = false;
});
function renderSidebar(props = {}) {
act(() => {
root.render(<Sidebar {...defaultProps} {...props} />);
});
}
it("renders both API models and local scanned models in the dropdown", () => {
renderSidebar();
// Open the dropdown
const trigger = container.querySelector('[aria-label="Choose active model"]') as HTMLElement | null;
expect(trigger).toBeTruthy();
act(() => {
trigger?.click();
});
const text = container.textContent ?? "";
// API model should be present
expect(text).toContain("api-model-1");
// Local models should also be present
expect(text).toContain("local-model-1.gguf");
expect(text).toContain("local-model-2.gguf");
});
it("shows the selected model label in the trigger", () => {
renderSidebar({ selectedModel: "/models/local-model-1.gguf" });
const trigger = container.querySelector('[aria-label="Choose active model"]') as HTMLElement | null;
expect(trigger?.textContent).toContain("local-model-1.gguf");
});
it("calls onModelSwap when a model is selected from the dropdown", () => {
const onModelSwap = vi.fn();
const onModelChange = vi.fn();
renderSidebar({ onModelSwap, onModelChange });
// Open the dropdown
const trigger = container.querySelector('[aria-label="Choose active model"]') as HTMLElement | null;
expect(trigger).toBeTruthy();
act(() => {
trigger?.click();
});
// Click a local model option
const options = container.querySelectorAll('[role="option"]');
const localOption = Array.from(options).find((el) => el.textContent?.includes("local-model-1.gguf"));
expect(localOption).toBeTruthy();
act(() => {
(localOption as HTMLElement)?.click();
});
// Both onModelChange and onModelSwap should be called
expect(onModelChange).toHaveBeenCalledWith("/models/local-model-1.gguf");
expect(onModelSwap).toHaveBeenCalledWith("/models/local-model-1.gguf");
});
it("shows a spinner when isSwapping is true", () => {
renderSidebar({ isSwapping: true });
const trigger = container.querySelector('[aria-label="Choose active model"]') as HTMLElement | null;
expect(trigger).toBeTruthy();
// The spinner should be present inside the trigger
const spinner = trigger?.querySelector(".animate-spin");
expect(spinner).toBeTruthy();
});
it("deduplicates models when API and local models share the same id", () => {
const apiModels: ModelInfo[] = [
{ id: "/models/local-model-1.gguf", object: "model", created: 1, owned_by: "local" },
];
renderSidebar({ models: apiModels });
// Open the dropdown
const trigger = container.querySelector('[aria-label="Choose active model"]') as HTMLElement | null;
expect(trigger).toBeTruthy();
act(() => {
trigger?.click();
});
// Count how many times "local-model-1.gguf" appears as an option
const options = container.querySelectorAll('[role="option"]');
const matchingOptions = Array.from(options).filter((el) => el.textContent?.includes("local-model-1.gguf"));
expect(matchingOptions).toHaveLength(1);
});
it("disables the dropdown when no models are available", () => {
renderSidebar({ models: [], localModels: [] });
const trigger = container.querySelector('[aria-label="Choose active model"]') as HTMLButtonElement | null;
expect(trigger?.disabled).toBe(true);
});
});
+93 -65
View File
@@ -1,5 +1,6 @@
import { useState } from "react";
import type { Chat } from "../lib/types";
import { Plus, Settings, X, Trash2, Bot, Search, Library, PanelLeft } from "lucide-react";
import { Plus, X, Trash2, Bot, Search, Library, PanelLeft, FileCode2, ShieldAlert, Sparkles, FolderKanban, Settings2 } from "lucide-react";
import "./Sidebar.css";
interface Props {
@@ -18,74 +19,92 @@ export default function Sidebar({
onOpenSettings,
onCloseMobile,
}: Props) {
const [shareCopied, setShareCopied] = useState(false);
const workspaceNavItems = [
{ label: "Agents", icon: Bot },
{ label: "Agents", icon: Bot, badge: "New" },
{ label: "Search", icon: Search },
{ label: "Library", icon: Library },
];
const getTaskIcon = (title: string) => {
const normalized = title.toLowerCase();
if (normalized.includes("error") || normalized.includes("fix") || normalized.includes("bug")) return ShieldAlert;
if (normalized.includes("design") || normalized.includes("ui") || normalized.includes("landing")) return Sparkles;
if (normalized.includes("project") || normalized.includes("plan") || normalized.includes("roadmap")) return FolderKanban;
return FileCode2;
};
const handleShare = async () => {
try {
await navigator.clipboard.writeText("ForgeLM — local-first AI workspace");
setShareCopied(true);
window.setTimeout(() => setShareCopied(false), 1800);
} catch {
setShareCopied(false);
}
};
return (
<>
<aside className={`sidebar${initialScreen ? " sidebar--initial" : ""}`}>
{/* ── Header ── */}
<div className="sidebar-header">
<div className="sidebar-brand">
<div className="brand-mark" aria-hidden="true">
<PanelLeft size={15} strokeWidth={1.8} />
</div>
<div className="brand-copy">
<span className="brand-name">ForgeLM</span>
</div>
<aside className={`sidebar${initialScreen ? " sidebar--initial" : ""}`}>
<div className="sidebar-header">
<div className="sidebar-brand">
<div className="brand-mark" aria-hidden="true">
<PanelLeft size={15} strokeWidth={1.8} />
</div>
<div className="sidebar-header-actions">
<button
className="icon-btn"
onClick={onOpenSettings}
title="Settings"
aria-label="Open settings"
type="button"
>
<Settings size={16} strokeWidth={1.5} />
</button>
<button
className="icon-btn sidebar-close-btn"
onClick={onCloseMobile}
title="Close navigation"
aria-label="Close navigation"
type="button"
>
<X size={16} strokeWidth={1.5} />
</button>
<div className="brand-copy">
<span className="brand-name">ForgeLM</span>
</div>
</div>
<div className="sidebar-actions sidebar-actions--top">
<button className="new-chat-btn" onClick={onNewChat} type="button">
<Plus size={16} strokeWidth={1.5} />
<span>New task</span>
<div className="sidebar-header-actions">
<button
className="icon-btn sidebar-close-btn"
onClick={onCloseMobile}
title="Close navigation"
aria-label="Close navigation"
type="button"
>
<X size={16} strokeWidth={1.5} />
</button>
</div>
</div>
<div className="sidebar-nav-section">
<div className="sidebar-nav-list" aria-label="Workspace navigation">
{workspaceNavItems.map(({ label, icon: Icon }) => (
<button key={label} type="button" className="sidebar-nav-item">
<Icon size={15} strokeWidth={1.8} />
<span>{label}</span>
</button>
))}
</div>
<div className="sidebar-actions sidebar-actions--top">
<button className="new-chat-btn" onClick={onNewChat} type="button">
<Plus size={16} strokeWidth={1.8} />
<span>New task</span>
</button>
</div>
<div className="sidebar-nav-section">
<div className="sidebar-nav-list" aria-label="Workspace navigation">
{workspaceNavItems.map(({ label, icon: Icon, badge }) => (
<button key={label} type="button" className="sidebar-nav-item">
<Icon size={15} strokeWidth={1.8} />
<span>{label}</span>
{badge ? <span className="sidebar-nav-badge">{badge}</span> : null}
</button>
))}
</div>
</div>
{/* ── Chat list ── */}
{chats.length > 0 && <div className="chat-list-section-label">Recent work</div>}
<nav className="chat-list" aria-label="Conversation history">
{chats.length === 0 && (
<div className="chat-list-empty">
No work yet.<br />Start a new task above.
</div>
)}
{chats.map((chat) => (
<div className="sidebar-list-meta">
<div className="chat-list-section-label">Projects</div>
<div className="sidebar-list-filter">All tasks</div>
</div>
<nav className="chat-list" aria-label="Conversation history">
{chats.length === 0 && (
<div className="chat-list-empty">
No tasks yet.<br />Start a new task above.
</div>
)}
{chats.map((chat) => {
const TaskIcon = getTaskIcon(chat.title);
return (
<div
key={chat.id}
className={`chat-item ${chat.id === currentChatId ? "active" : ""}`}
@@ -96,8 +115,12 @@ export default function Sidebar({
onClick={() => onSelectChat(chat.id)}
aria-current={chat.id === currentChatId ? "page" : undefined}
>
<span className="chat-item-icon" aria-hidden="true">
<TaskIcon size={14} strokeWidth={1.8} />
</span>
<span className="chat-item-title">{chat.title}</span>
</button>
<button
type="button"
className="chat-item-del"
@@ -108,18 +131,23 @@ export default function Sidebar({
<Trash2 size={14} strokeWidth={1.5} />
</button>
</div>
))}
</nav>
);
})}
</nav>
{/* ── Footer ── */}
<div className="sidebar-footer">
<div className="sidebar-footer-copy">
<span className="footer-version">ForgeLM v0.1.0</span>
<span className="footer-meta">Local-first workspace</span>
</div>
<div className="sidebar-footer">
<button type="button" className="sidebar-share-btn" onClick={() => { void handleShare(); }}>
{shareCopied ? "Link copied" : "Share ForgeLM with a friend"}
</button>
<div className="sidebar-footer-copy">
<span className="footer-meta">Local-first workspace</span>
<button type="button" className="sidebar-footer-link" onClick={onOpenSettings}>
<Settings2 size={12} strokeWidth={1.8} />
<span>Settings</span>
</button>
</div>
</aside>
</>
</div>
</aside>
);
}
+97
View File
@@ -0,0 +1,97 @@
.toast-container {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: none;
max-width: 380px;
width: 100%;
}
.toast {
background: #1e2228;
border: 1px solid #2a3040;
border-radius: 10px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
padding: 12px 14px;
display: flex;
align-items: flex-start;
gap: 12px;
pointer-events: all;
border-left-width: 3px;
animation: toast-in 0.2s ease;
}
@keyframes toast-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.toast--info { border-left-color: #4b86ff; }
.toast--warning { border-left-color: #f59e0b; }
.toast--error { border-left-color: #ef4444; }
.toast-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.toast-message {
font-size: 13px;
font-weight: 500;
color: #e8eaf0;
line-height: 1.4;
}
.toast-detail {
font-size: 12px;
color: #8b92a0;
line-height: 1.4;
}
.toast-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.toast-action-btn {
font-size: 12px;
font-weight: 500;
color: #4b86ff;
background: transparent;
border: 1px solid rgba(75, 134, 255, 0.3);
border-radius: 6px;
padding: 4px 10px;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, border-color 0.15s;
}
.toast-action-btn:hover {
background: rgba(75, 134, 255, 0.1);
border-color: rgba(75, 134, 255, 0.5);
}
.toast-dismiss-btn {
font-size: 11px;
color: #5a6070;
background: transparent;
border: none;
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
line-height: 1;
transition: color 0.15s;
}
.toast-dismiss-btn:hover {
color: #8b92a0;
}
+124
View File
@@ -0,0 +1,124 @@
import { createContext, useCallback, useContext, useRef, useState } from "react";
import "./Toast.css";
export type ToastVariant = 'info' | 'warning' | 'error';
export interface ToastOptions {
id?: string;
message: string;
detail?: string;
action?: { label: string; onClick: () => void };
duration?: number; // ms, 0 = persistent
variant?: ToastVariant;
}
interface ToastItem extends Required<Omit<ToastOptions, 'id' | 'action'>> {
id: string;
action?: { label: string; onClick: () => void };
timerId?: ReturnType<typeof setTimeout>;
}
interface ToastContextValue {
showToast: (opts: ToastOptions) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used inside <ToastProvider>");
return ctx;
}
const MAX_TOASTS = 3;
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const counterRef = useRef(0);
const dismiss = useCallback((id: string) => {
setToasts((prev) => {
const item = prev.find((t) => t.id === id);
if (item?.timerId) clearTimeout(item.timerId);
return prev.filter((t) => t.id !== id);
});
}, []);
const showToast = useCallback((opts: ToastOptions) => {
const id = opts.id ?? `toast-${++counterRef.current}`;
const duration = opts.duration ?? 6000;
const variant = opts.variant ?? 'info';
setToasts((prev) => {
// Deduplicate: if same id exists, update it
const existing = prev.find((t) => t.id === id);
if (existing?.timerId) clearTimeout(existing.timerId);
let timerId: ReturnType<typeof setTimeout> | undefined;
if (duration > 0) {
timerId = setTimeout(() => dismiss(id), duration);
}
const newItem: ToastItem = {
id,
message: opts.message,
detail: opts.detail ?? '',
action: opts.action,
duration,
variant,
timerId,
};
if (existing) {
return prev.map((t) => (t.id === id ? newItem : t));
}
// Enforce max: remove oldest if needed
const next = [...prev, newItem];
if (next.length > MAX_TOASTS) {
const removed = next.shift()!;
if (removed.timerId) clearTimeout(removed.timerId);
}
return next;
});
}, [dismiss]);
return (
<ToastContext.Provider value={{ showToast }}>
{children}
<div className="toast-container" aria-live="polite" aria-atomic="false">
{toasts.map((t) => (
<div
key={t.id}
className={`toast toast--${t.variant}`}
role={t.variant === 'error' ? 'alert' : 'status'}
>
<div className="toast-body">
<span className="toast-message">{t.message}</span>
{t.detail && <span className="toast-detail">{t.detail}</span>}
</div>
<div className="toast-actions">
{t.action && (
<button
type="button"
className="toast-action-btn"
onClick={() => { t.action!.onClick(); dismiss(t.id); }}
>
{t.action.label}
</button>
)}
<button
type="button"
className="toast-dismiss-btn"
onClick={() => dismiss(t.id)}
aria-label="Dismiss notification"
>
✕
</button>
</div>
</div>
))}
</div>
</ToastContext.Provider>
);
}
+59 -9
View File
@@ -1,3 +1,5 @@
import type { RuntimeStatus } from "../features/runtime/types";
export interface ComposerNotice {
tone: "info" | "warning";
title: string;
@@ -6,31 +8,79 @@ export interface ComposerNotice {
}
const SUGGESTED_PROMPTS = [
"Summarize this repository architecture.",
"Help me debug why the local runtime is not ready.",
"Draft a cleaner system prompt for this coding workflow.",
"Inspect the repo structure and runtime flow.",
"Diagnose why the local model is not ready.",
"Draft a tighter system prompt for this workspace.",
] as const;
export function getSuggestedPrompts(): string[] {
return [...SUGGESTED_PROMPTS];
}
export function getComposerNotice(model: string, server: string): ComposerNotice | null {
export function getComposerNotice(
model: string,
server: string,
runtimeMode: "local" | "remote" = "remote",
runtimeStatus: RuntimeStatus = "ready",
isSwapping = false,
): ComposerNotice | null {
if (!server.trim()) {
return {
tone: "warning",
title: "Connect an API",
description: "Open runtime settings and configure a local or remote endpoint before sending messages.",
actionLabel: "Show runtime panel",
title: "Connect runtime",
description: "Open the sidebar and point the app at a local or remote endpoint before sending a task.",
actionLabel: "Open sidebar",
};
}
if (runtimeMode === "local" && (isSwapping || runtimeStatus !== "ready")) {
switch (runtimeStatus) {
case "loading":
return {
tone: "warning",
title: "Model loading...",
description: "The selected local model is still warming up. Please wait until it finishes loading before sending a message.",
actionLabel: "Waiting",
};
case "starting":
return {
tone: "warning",
title: "Runtime starting...",
description: "The local API is still starting. Wait for it to become ready before sending a message.",
actionLabel: "Waiting",
};
case "stopping":
return {
tone: "warning",
title: "Runtime stopping...",
description: "The local API is shutting down. Please wait until it finishes before sending another message.",
actionLabel: "Waiting",
};
case "degraded":
return {
tone: "warning",
title: "Runtime not ready",
description: "The local API is running but not fully ready. Wait for the health check to recover before sending a message.",
actionLabel: "Waiting",
};
case "error":
case "stopped":
default:
return {
tone: "warning",
title: "Runtime unavailable",
description: "The local API is not ready. Open the runtime settings and start it again before sending a message.",
actionLabel: "Open settings",
};
}
}
if (!model.trim()) {
return {
tone: "info",
title: "Choose a model",
description: "Start the runtime or refresh the model list, then select the model you want to chat with.",
actionLabel: "Show runtime panel",
description: "Refresh the model list or start the runtime, then select the workspace model.",
actionLabel: "Open sidebar",
};
}
@@ -0,0 +1,304 @@
import { useEffect, useMemo, useState } from "react";
import hljs from "highlight.js/lib/core";
import bash from "highlight.js/lib/languages/bash";
import c from "highlight.js/lib/languages/c";
import cpp from "highlight.js/lib/languages/cpp";
import css from "highlight.js/lib/languages/css";
import go from "highlight.js/lib/languages/go";
import javascript from "highlight.js/lib/languages/javascript";
import json from "highlight.js/lib/languages/json";
import python from "highlight.js/lib/languages/python";
import rust from "highlight.js/lib/languages/rust";
import sql from "highlight.js/lib/languages/sql";
import typescript from "highlight.js/lib/languages/typescript";
import xml from "highlight.js/lib/languages/xml";
import { ensureHljs, renderMarkdown } from "../../lib/markdown";
interface ArtifactViewerProps {
artifact: {
filename: string;
content: string;
language?: string;
};
onDownload?: () => void;
}
let codeHlReady = false;
function ensureCodeHljs() {
if (codeHlReady) return;
hljs.registerLanguage("javascript", javascript);
hljs.registerLanguage("typescript", typescript);
hljs.registerLanguage("python", python);
hljs.registerLanguage("rust", rust);
hljs.registerLanguage("bash", bash);
hljs.registerLanguage("json", json);
hljs.registerLanguage("sql", sql);
hljs.registerLanguage("css", css);
hljs.registerLanguage("xml", xml);
hljs.registerLanguage("go", go);
hljs.registerLanguage("cpp", cpp);
hljs.registerLanguage("c", c);
codeHlReady = true;
}
function getExtension(filename: string) {
const trimmed = filename.trim().toLowerCase();
const parts = trimmed.split(".");
if (parts.length < 2) return "";
return parts[parts.length - 1] ?? "";
}
function getLanguage(filename: string, language?: string) {
if (language) return language.toLowerCase();
const ext = getExtension(filename);
const map: Record<string, string> = {
md: "markdown",
markdown: "markdown",
ts: "typescript",
tsx: "typescript",
js: "javascript",
jsx: "javascript",
py: "python",
rs: "rust",
go: "go",
c: "c",
h: "c",
cpp: "cpp",
cc: "cpp",
cxx: "cpp",
css: "css",
html: "xml",
xml: "xml",
json: "json",
sql: "sql",
sh: "bash",
bash: "bash",
zsh: "bash",
};
return map[ext] ?? ext ?? "plaintext";
}
function fileTypeTone(ext: string) {
switch (ext) {
case "md":
case "markdown":
return "from-sky-400/20 to-cyan-400/10 text-sky-300 border-sky-500/20";
case "json":
return "from-emerald-400/20 to-teal-400/10 text-emerald-300 border-emerald-500/20";
case "ts":
case "tsx":
case "js":
case "jsx":
return "from-indigo-400/20 to-violet-400/10 text-indigo-300 border-indigo-500/20";
case "py":
return "from-amber-400/20 to-orange-400/10 text-amber-300 border-amber-500/20";
default:
return "from-zinc-400/20 to-zinc-500/10 text-zinc-300 border-zinc-700";
}
}
function FileGlyph({ filename }: { filename: string }) {
const ext = getExtension(filename);
const tone = fileTypeTone(ext);
if (ext === "md" || ext === "markdown") {
return (
<div className={`flex h-11 w-11 items-center justify-center rounded-2xl border bg-gradient-to-br ${tone}`}>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
<path d="M7 3.75h7l4.25 4.25V20.25H7z" strokeLinejoin="round" />
<path d="M14 3.75v4.5h4.25" strokeLinejoin="round" />
<path d="M10 11.25h4" strokeLinecap="round" />
<path d="M10 14.25h7" strokeLinecap="round" />
<path d="M10 17.25h5" strokeLinecap="round" />
</svg>
</div>
);
}
if (["ts", "tsx", "js", "jsx", "c", "cpp", "go", "rs", "py"].includes(ext)) {
return (
<div className={`flex h-11 w-11 items-center justify-center rounded-2xl border bg-gradient-to-br ${tone}`}>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
<path d="M9 8 5 12l4 4" strokeLinecap="round" strokeLinejoin="round" />
<path d="m15 8 4 4-4 4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
);
}
if (ext === "json") {
return (
<div className={`flex h-11 w-11 items-center justify-center rounded-2xl border bg-gradient-to-br ${tone}`}>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
<path d="M9 8c-1.3 0-2 .7-2 2v1c0 .7-.3 1.3-.9 1.7-.6.4-1.1 1-.1 1.4.8.3 1 .8 1 1.5v1c0 1.3.7 2 2 2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M15 8c1.3 0 2 .7 2 2v1c0 .7.3 1.3.9 1.7.6.4 1.1 1 .1 1.4-.8.3-1 .8-1 1.5v1c0 1.3-.7 2-2 2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
);
}
return (
<div className={`flex h-11 w-11 items-center justify-center rounded-2xl border bg-gradient-to-br ${tone}`}>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
<path d="M7 3.75h7l4.25 4.25V20.25H7z" strokeLinejoin="round" />
<path d="M14 3.75v4.5h4.25" strokeLinejoin="round" />
</svg>
</div>
);
}
function downloadFile(filename: string, content: string, language?: string) {
const safeFilename = filename || "artifact.txt";
const lower = safeFilename.toLowerCase();
const mime =
language === "markdown" || lower.endsWith(".md")
? "text/markdown;charset=utf-8"
: "text/plain;charset=utf-8";
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = safeFilename;
link.click();
URL.revokeObjectURL(url);
}
export default function ArtifactViewer({ artifact, onDownload }: ArtifactViewerProps) {
const [expanded, setExpanded] = useState(true);
const [copied, setCopied] = useState(false);
const ext = getExtension(artifact.filename);
const isMarkdown = ext === "md" || ext === "markdown" || artifact.language?.toLowerCase() === "markdown";
const language = getLanguage(artifact.filename, artifact.language);
const previewId = useMemo(() => `artifact-preview-${artifact.filename.replace(/[^a-zA-Z0-9_-]+/g, "-")}`, [artifact.filename]);
useEffect(() => {
void ensureHljs();
ensureCodeHljs();
}, []);
const previewHtml = useMemo(() => {
if (isMarkdown) return renderMarkdown(artifact.content);
let highlighted = artifact.content;
try {
highlighted = language !== "plaintext" && hljs.getLanguage(language)
? hljs.highlight(artifact.content, { language }).value
: hljs.highlightAuto(artifact.content).value;
} catch {
highlighted = artifact.content.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
return `<pre class="hljs rounded-2xl border border-zinc-800 bg-zinc-950 p-4 text-sm leading-7 text-zinc-200 overflow-auto"><code class="language-${language}">${highlighted}</code></pre>`;
}, [artifact.content, isMarkdown, language]);
const handleCopy = async () => {
await navigator.clipboard.writeText(artifact.content);
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
};
const handleDownload = () => {
if (onDownload) {
onDownload();
return;
}
downloadFile(artifact.filename, artifact.content, artifact.language ?? language);
};
return (
<section className="overflow-hidden rounded-3xl border border-zinc-800/80 bg-zinc-950 text-zinc-100 shadow-[0_24px_80px_-36px_rgba(0,0,0,0.9)]">
<header className="border-b border-zinc-800/80 bg-gradient-to-b from-zinc-900/80 to-zinc-950 px-4 py-4 sm:px-5">
<div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
<FileGlyph filename={artifact.filename} />
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="truncate text-sm font-semibold text-zinc-50" title={artifact.filename}>
{artifact.filename}
</h2>
<span className="rounded-full border border-zinc-800 bg-zinc-900 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.22em] text-zinc-400">
{language}
</span>
</div>
<p className="mt-1 text-xs leading-5 text-zinc-500">
Preview, copy, or download the generated artifact.
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleCopy}
className="inline-flex items-center gap-2 rounded-xl border border-zinc-700 bg-zinc-900/80 px-3 py-2 text-sm font-medium text-zinc-100 transition-colors hover:border-zinc-600 hover:bg-zinc-800"
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
<path d="M9 9h10v10H9z" strokeLinejoin="round" />
<path d="M5 15V5h10" strokeLinecap="round" strokeLinejoin="round" />
</svg>
{copied ? "Copied" : "Copy"}
</button>
<button
type="button"
onClick={handleDownload}
className="inline-flex items-center gap-2 rounded-xl border border-zinc-700 bg-zinc-900/80 px-3 py-2 text-sm font-medium text-zinc-100 transition-colors hover:border-zinc-600 hover:bg-zinc-800"
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
<path d="M12 4v10" strokeLinecap="round" />
<path d="m8 10 4 4 4-4" strokeLinecap="round" strokeLinejoin="round" />
<path d="M5 20h14" strokeLinecap="round" />
</svg>
Download
</button>
<button
type="button"
aria-expanded={expanded}
aria-controls={previewId}
onClick={() => setExpanded((value) => !value)}
className="inline-flex items-center rounded-xl border border-zinc-700 bg-zinc-900/80 p-2 text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-800"
>
<svg
viewBox="0 0 24 24"
className={`h-4 w-4 transition-transform duration-200 ${expanded ? "rotate-180" : "rotate-0"}`}
fill="none"
stroke="currentColor"
strokeWidth="1.8"
aria-hidden="true"
>
<path d="m6 9 6 6 6-6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
</div>
</header>
{expanded ? (
<div
id={previewId}
className="max-h-[70vh] overflow-auto p-4 sm:p-5"
>
{isMarkdown ? (
<div
className="max-w-none text-sm leading-7 text-zinc-300 [&_h1]:mb-4 [&_h1]:text-3xl [&_h1]:font-semibold [&_h1]:tracking-tight [&_h1]:text-zinc-50 [&_h2]:mb-3 [&_h2]:mt-7 [&_h2]:text-2xl [&_h2]:font-semibold [&_h2]:text-zinc-50 [&_h3]:mb-2 [&_h3]:mt-5 [&_h3]:text-xl [&_h3]:font-semibold [&_h3]:text-zinc-100 [&_h4]:mt-5 [&_h4]:text-lg [&_h4]:font-semibold [&_h4]:text-zinc-100 [&_p]:my-3 [&_p]:text-zinc-300 [&_a]:text-sky-400 [&_a]:underline [&_a:hover]:text-sky-300 [&_ul]:my-3 [&_ul]:list-disc [&_ul]:space-y-2 [&_ul]:pl-6 [&_ol]:my-3 [&_ol]:list-decimal [&_ol]:space-y-2 [&_ol]:pl-6 [&_li]:text-zinc-300 [&_blockquote]:my-4 [&_blockquote]:border-l-2 [&_blockquote]:border-zinc-700 [&_blockquote]:pl-4 [&_blockquote]:text-zinc-400 [&_blockquote]:italic [&_hr]:my-6 [&_hr]:border-zinc-800 [&_table]:my-4 [&_table]:w-full [&_table]:border-collapse [&_table]:overflow-hidden [&_table]:rounded-lg [&_table]:border [&_table]:border-zinc-800 [&_th]:border [&_th]:border-zinc-800 [&_th]:bg-zinc-900 [&_th]:px-3 [&_th]:py-2 [&_th]:text-left [&_th]:font-semibold [&_th]:text-zinc-100 [&_td]:border [&_td]:border-zinc-800 [&_td]:px-3 [&_td]:py-2 [&_td]:text-zinc-300 [&_code:not(.hljs)]:rounded [&_code:not(.hljs)]:border [&_code:not(.hljs)]:border-zinc-800 [&_code:not(.hljs)]:bg-zinc-900 [&_code:not(.hljs)]:px-1.5 [&_code:not(.hljs)]:py-0.5 [&_code:not(.hljs)]:font-mono [&_code:not(.hljs)]:text-[0.85em] [&_.code-block]:my-4 [&_.code-block]:overflow-hidden [&_.code-block]:rounded-2xl [&_.code-block]:border [&_.code-block]:border-zinc-800 [&_.code-block]:bg-zinc-950 [&_.code-header]:flex [&_.code-header]:items-center [&_.code-header]:justify-between [&_.code-header]:border-b [&_.code-header]:border-zinc-800 [&_.code-header]:bg-zinc-900/80 [&_.code-header]:px-3 [&_.code-header]:py-2 [&_.code-lang]:font-mono [&_.code-lang]:text-[11px] [&_.code-lang]:uppercase [&_.code-lang]:tracking-[0.22em] [&_.code-lang]:text-zinc-500 [&_.copy-code-btn]:rounded-lg [&_.copy-code-btn]:border [&_.copy-code-btn]:border-zinc-700 [&_.copy-code-btn]:bg-zinc-900 [&_.copy-code-btn]:px-2 [&_.copy-code-btn]:py-1 [&_.copy-code-btn]:text-[11px] [&_.copy-code-btn]:font-medium [&_.copy-code-btn]:uppercase [&_.copy-code-btn]:tracking-[0.18em] [&_.copy-code-btn]:text-zinc-200 [&_.copy-code-btn]:transition-colors [&_.copy-code-btn:hover]:border-zinc-600 [&_.copy-code-btn:hover]:bg-zinc-800"
dangerouslySetInnerHTML={{ __html: previewHtml }}
/>
) : (
<div
className="overflow-hidden rounded-2xl border border-zinc-800 bg-zinc-950"
dangerouslySetInnerHTML={{ __html: previewHtml }}
/>
)}
</div>
) : (
<div className="border-t border-zinc-800/70 px-5 py-4 text-sm text-zinc-500">
Preview collapsed.
</div>
)}
</section>
);
}
@@ -0,0 +1,189 @@
import { useEffect, useMemo, useState } from "react";
import type { MouseEvent } from "react";
import DOMPurify from "dompurify";
import hljs from "highlight.js";
import { marked } from "marked";
import { Bot } from "lucide-react";
import "highlight.js/styles/github-dark-dimmed.css";
export interface MessageBubbleProps {
role: "user" | "assistant";
content: string;
isAgentic?: boolean;
timestamp?: Date;
}
const markdownChrome = [
"[&_p]:m-0",
"[&_p+p]:mt-3",
"[&_h1]:mt-4 [&_h1]:text-xl [&_h1]:font-semibold [&_h1]:tracking-tight",
"[&_h2]:mt-4 [&_h2]:text-lg [&_h2]:font-semibold [&_h2]:tracking-tight",
"[&_h3]:mt-3 [&_h3]:text-base [&_h3]:font-semibold",
"[&_ul]:my-3 [&_ul]:list-disc [&_ul]:space-y-1 [&_ul]:pl-5",
"[&_ol]:my-3 [&_ol]:list-decimal [&_ol]:space-y-1 [&_ol]:pl-5",
"[&_li>p]:m-0",
"[&_blockquote]:my-3 [&_blockquote]:border-l-2 [&_blockquote]:border-zinc-700 [&_blockquote]:pl-4 [&_blockquote]:text-zinc-400",
"[&_a]:text-cyan-300 [&_a]:underline [&_a]:underline-offset-4 [&_a]:decoration-cyan-300/40 hover:[&_a]:text-cyan-200",
"[&_strong]:font-semibold [&_strong]:text-zinc-50",
"[&_em]:italic",
"[&_hr]:my-4 [&_hr]:border-zinc-800",
"[&_table]:my-4 [&_table]:w-full [&_table]:overflow-hidden [&_table]:rounded-xl [&_table]:border [&_table]:border-zinc-800",
"[&_th]:border-b [&_th]:border-zinc-800 [&_th]:bg-zinc-900 [&_th]:px-3 [&_th]:py-2 [&_th]:text-left",
"[&_td]:border-b [&_td]:border-zinc-900 [&_td]:px-3 [&_td]:py-2",
"[&_img]:my-4 [&_img]:rounded-xl [&_img]:border [&_img]:border-zinc-800",
"[&_code:not(pre_code)]:rounded-md [&_code:not(pre_code)]:border [&_code:not(pre_code)]:border-white/5 [&_code:not(pre_code)]:bg-white/5 [&_code:not(pre_code)]:px-1.5 [&_code:not(pre_code)]:py-0.5 [&_code:not(pre_code)]:text-[0.92em]",
].join(" ");
function formatTimestamp(timestamp?: Date) {
if (!timestamp) return null;
return new Intl.DateTimeFormat([], { hour: "2-digit", minute: "2-digit" }).format(timestamp);
}
function safeLang(lang?: string) {
if (!lang) return "plaintext";
const normalized = lang.toLowerCase().trim();
return /^[a-z0-9_+-]+$/.test(normalized) ? normalized : "plaintext";
}
function highlightCode(code: string, lang?: string) {
const language = safeLang(lang);
if (language !== "plaintext" && hljs.getLanguage(language)) {
return hljs.highlight(code, { language }).value;
}
return hljs.highlightAuto(code).value;
}
function buildMarkdown(content: string) {
const renderer = new marked.Renderer();
renderer.code = (code, infostring) => {
const lang = infostring?.split(/\s+/)[0];
const highlighted = highlightCode(code, lang || undefined);
const encodedCode = encodeURIComponent(code);
const language = safeLang(lang || undefined);
return `
<div class="group relative my-4 overflow-hidden rounded-2xl border border-zinc-800 bg-[#111111] shadow-[0_16px_40px_rgba(0,0,0,0.35)]">
<button
type="button"
data-copy-code="true"
data-code="${encodedCode}"
class="absolute right-3 top-3 z-10 inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-zinc-950/80 px-2.5 py-1 text-[11px] font-medium text-zinc-300 backdrop-blur transition hover:border-white/20 hover:bg-white/10 hover:text-white"
aria-label="Copy code"
title="Copy code"
>
<span class="copy-label">Copy</span>
</button>
<div class="overflow-x-auto">
<pre class="m-0 bg-transparent p-4 pt-12 text-sm leading-6 text-zinc-100"><code class="hljs language-${language}">${highlighted}</code></pre>
</div>
</div>
`;
};
const rawHtml = marked.parse(content ?? "", {
renderer,
breaks: true,
gfm: true,
}) as string;
return DOMPurify.sanitize(rawHtml, {
ADD_ATTR: ["data-copy-code", "data-code", "aria-label", "title"],
});
}
export default function MessageBubble({ role, content, isAgentic, timestamp }: MessageBubbleProps) {
const [copied, setCopied] = useState(false);
const [isStreaming, setIsStreaming] = useState(false);
useEffect(() => {
setIsStreaming(true);
const timer = window.setTimeout(() => setIsStreaming(false), 450);
return () => window.clearTimeout(timer);
}, [content]);
const renderedContent = useMemo(() => buildMarkdown(content), [content]);
const timeLabel = formatTimestamp(timestamp);
const onContentClick = async (event: MouseEvent<HTMLDivElement>) => {
const target = event.target as HTMLElement;
const button = target.closest<HTMLButtonElement>("[data-copy-code]");
if (!button) return;
const code = decodeURIComponent(button.getAttribute("data-code") ?? "");
await navigator.clipboard.writeText(code);
const label = button.querySelector<HTMLElement>(".copy-label");
const icon = button.querySelector<HTMLElement>("svg");
const original = label?.textContent ?? "Copy";
if (label) label.textContent = "Copied";
if (icon) icon.style.display = "none";
setCopied(true);
window.setTimeout(() => {
if (label) label.textContent = original;
if (icon) icon.style.display = "inline-flex";
setCopied(false);
}, 1400);
};
const outerAlign = role === "user" ? "justify-end" : "justify-start";
const bubbleTone = role === "user" ? "bg-[#1E3A5F] text-slate-50" : "bg-[#1E1E1E] text-zinc-100";
const widthTone = role === "user" ? "max-w-[84%]" : "max-w-[86%]";
return (
<div className={`flex w-full ${outerAlign}`}>
<article
className={`${widthTone} rounded-2xl border border-white/5 px-4 py-3 shadow-[0_18px_50px_rgba(0,0,0,0.35)] ${bubbleTone}`}
>
{role === "assistant" ? (
<header className="mb-3 flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-white/10 bg-zinc-950/80 text-zinc-100 shadow-inner shadow-black/30">
<Bot className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold tracking-tight text-zinc-50">Forge</h3>
<span className="rounded-full border border-cyan-400/20 bg-cyan-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.18em] text-cyan-200">
Forge
</span>
{isAgentic ? (
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.18em] text-emerald-200">
Agentic
</span>
) : null}
{timeLabel ? <span className="text-[11px] text-zinc-500">{timeLabel}</span> : null}
</div>
</div>
</header>
) : null}
<div
onClick={onContentClick}
className={`${markdownChrome} text-[14px] leading-6 ${role === "user" ? "[&_a]:text-sky-200 [&_strong]:text-white" : "[&_a]:text-cyan-300 [&_strong]:text-zinc-50"}`}
dangerouslySetInnerHTML={{ __html: renderedContent }}
/>
{isStreaming ? (
<div className="mt-2 flex items-center gap-1 text-xs text-zinc-400">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-zinc-400/80" />
<span>streaming</span>
</div>
) : null}
{role === "user" && timeLabel ? (
<div className="mt-2 flex items-center justify-end gap-2 text-[11px] text-slate-300/70">
<span>{timeLabel}</span>
</div>
) : null}
{copied ? <span className="sr-only">Code copied</span> : null}
</article>
</div>
);
}
+198
View File
@@ -0,0 +1,198 @@
import { useEffect, useMemo, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Check, Circle, ChevronDown, Loader2, X } from "lucide-react";
type TaskStatus = "pending" | "running" | "completed" | "error";
export interface TaskListProps {
steps: Array<{
id: number;
title: string;
description: string;
status: TaskStatus;
sub_steps: Array<{
id: string;
description: string;
status: TaskStatus;
}>;
}>;
activeStepId?: number;
}
const statusStyles: Record<TaskStatus, { ring: string; text: string; badge: string }> = {
pending: {
ring: "border-zinc-700 bg-zinc-900/70 text-zinc-500",
text: "text-zinc-400",
badge: "border-zinc-800 bg-zinc-900 text-zinc-400",
},
running: {
ring: "border-sky-500/30 bg-sky-500/10 text-sky-400",
text: "text-sky-300",
badge: "border-sky-500/25 bg-sky-500/10 text-sky-300",
},
completed: {
ring: "border-emerald-500/30 bg-emerald-500/10 text-emerald-400",
text: "text-emerald-300",
badge: "border-emerald-500/25 bg-emerald-500/10 text-emerald-300",
},
error: {
ring: "border-rose-500/30 bg-rose-500/10 text-rose-400",
text: "text-rose-300",
badge: "border-rose-500/25 bg-rose-500/10 text-rose-300",
},
};
function StatusGlyph({ status }: { status: TaskStatus }) {
switch (status) {
case "running":
return <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />;
case "completed":
return <Check className="h-4 w-4" aria-hidden="true" />;
case "error":
return <X className="h-4 w-4" aria-hidden="true" />;
default:
return <Circle className="h-4 w-4" aria-hidden="true" />;
}
}
function TaskStatusIcon({ status }: { status: TaskStatus }) {
const palette = statusStyles[status];
return (
<motion.div
layout
className={`relative flex h-9 w-9 items-center justify-center rounded-full border ${palette.ring} shadow-sm shadow-black/20`}
animate={status === "completed" ? { scale: [0.92, 1.08, 1] } : { scale: 1 }}
transition={{ duration: 0.32, ease: "easeOut" }}
>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={status}
initial={{ opacity: 0, scale: 0.7, rotate: -8 }}
animate={{ opacity: 1, scale: 1, rotate: 0 }}
exit={{ opacity: 0, scale: 0.7, rotate: 8 }}
transition={{ duration: 0.2 }}
className={palette.text}
>
<StatusGlyph status={status} />
</motion.div>
</AnimatePresence>
{status === "completed" ? <span className="absolute inset-0 rounded-full bg-emerald-400/10 blur-[1px]" aria-hidden="true" /> : null}
</motion.div>
);
}
function TaskRow({
step,
isActive,
}: {
step: TaskListProps["steps"][number];
isActive: boolean;
}) {
const [expanded, setExpanded] = useState(isActive || step.status === "running" || step.status === "completed");
const hasSubSteps = step.sub_steps.length > 0;
useEffect(() => {
if (isActive || step.status === "running") setExpanded(true);
}, [isActive, step.status]);
return (
<motion.li layout className="relative pl-10">
<div className="absolute left-4 top-0 bottom-0 w-px bg-zinc-800/90" aria-hidden="true" />
<div className="absolute left-[13px] top-5 h-2.5 w-2.5 rounded-full border border-zinc-700 bg-zinc-950" aria-hidden="true" />
<motion.article
layout
className={`rounded-2xl border bg-zinc-950/70 px-4 py-4 shadow-[0_0_0_1px_rgba(24,24,27,0.35)] transition-colors duration-300 ${
isActive ? "border-zinc-700 bg-zinc-900/85" : "border-zinc-800/80"
}`}
>
<div className="flex items-start gap-3">
<TaskStatusIcon status={step.status} />
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-md border border-zinc-800 bg-zinc-900 px-2 py-0.5 text-[11px] font-medium uppercase tracking-[0.18em] text-zinc-400">
Step {step.id}
</span>
<h3 className="truncate text-sm font-semibold text-zinc-100">{step.title}</h3>
</div>
<p className="mt-1 text-sm leading-6 text-zinc-400">{step.description}</p>
</div>
{hasSubSteps ? (
<button
type="button"
onClick={() => setExpanded((value) => !value)}
aria-expanded={expanded}
aria-label={expanded ? "Collapse task details" : "Expand task details"}
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-zinc-800 bg-zinc-950 text-zinc-400 transition-colors hover:border-zinc-700 hover:text-zinc-200"
>
<ChevronDown className={`h-4 w-4 transition-transform duration-300 ${expanded ? "rotate-180" : "rotate-0"}`} aria-hidden="true" />
</button>
) : null}
</div>
<AnimatePresence initial={false}>
{hasSubSteps && expanded ? (
<motion.div
key="substeps"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="overflow-hidden"
>
<div className="mt-4 space-y-2 border-l border-zinc-800/90 pl-4">
{step.sub_steps.map((subStep) => (
<motion.div
key={subStep.id}
layout
className="flex items-start gap-3 rounded-xl border border-zinc-900 bg-zinc-950/60 px-3 py-2"
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
>
<div className={`mt-0.5 rounded-full p-1 ${statusStyles[subStep.status].badge}`}>
<StatusGlyph status={subStep.status} />
</div>
<div className="min-w-0 flex-1">
<div className="text-xs uppercase tracking-[0.16em] text-zinc-500">{subStep.id}</div>
<div className="text-sm leading-6 text-zinc-200">{subStep.description}</div>
</div>
<span className={`shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.18em] ${statusStyles[subStep.status].badge}`}>
{subStep.status}
</span>
</motion.div>
))}
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
</div>
</motion.article>
</motion.li>
);
}
export default function TaskList({ steps, activeStepId }: TaskListProps) {
const activeIds = useMemo(() => new Set([activeStepId]), [activeStepId]);
return (
<section className="rounded-3xl border border-zinc-800 bg-zinc-950 text-zinc-100 shadow-2xl shadow-black/20">
<div className="border-b border-zinc-800 px-5 py-4">
<div className="text-[11px] uppercase tracking-[0.24em] text-zinc-500">Task list</div>
<h2 className="mt-1 text-lg font-semibold text-zinc-50">Thinking steps</h2>
</div>
<motion.ul layout className="space-y-3 px-4 py-4">
{steps.map((step) => (
<TaskRow key={step.id} step={step} isActive={activeIds.has(step.id)} />
))}
</motion.ul>
</section>
);
}
@@ -0,0 +1,165 @@
import { Icon } from "../Icon";
type StepStatus = "pending" | "running" | "completed" | "error";
interface ThinkingCardProps {
title: string;
overview: string;
steps: Array<{
id: number;
title: string;
description: string;
status: StepStatus;
sub_steps: Array<{
id: string;
description: string;
status: StepStatus;
}>;
}>;
currentStep: number;
isComplete: boolean;
}
function statusIcon(status: StepStatus) {
switch (status) {
case "running":
return <Icon name="Loader2" size={16} strokeWidth={2} className="animate-spin text-amber-400" aria-hidden="true" />;
case "completed":
return <Icon name="CheckCircle2" size={16} strokeWidth={2} className="text-emerald-400" aria-hidden="true" />;
case "error":
return <Icon name="XCircle" size={16} strokeWidth={2} className="text-rose-400" aria-hidden="true" />;
default:
return <Icon name="Circle" size={16} strokeWidth={2} className="text-zinc-500" aria-hidden="true" />;
}
}
function miniIcon(status: StepStatus) {
switch (status) {
case "running":
return <Icon name="Loader2" size={12} strokeWidth={2} className="animate-spin text-amber-400" aria-hidden="true" />;
case "completed":
return <Icon name="CheckCircle2" size={12} strokeWidth={2} className="text-emerald-400" aria-hidden="true" />;
case "error":
return <Icon name="XCircle" size={12} strokeWidth={2} className="text-rose-400" aria-hidden="true" />;
default:
return <span className="mt-0.5 h-2 w-2 rounded-full border border-zinc-600 bg-zinc-700" aria-hidden="true" />;
}
}
function tone(status: StepStatus) {
switch (status) {
case "running":
return "border-amber-500/20 bg-amber-500/10 text-amber-300";
case "completed":
return "border-emerald-500/20 bg-emerald-500/10 text-emerald-300";
case "error":
return "border-rose-500/20 bg-rose-500/10 text-rose-300";
default:
return "border-zinc-800 bg-zinc-900 text-zinc-400";
}
}
export default function ThinkingCard({ title, overview, steps, currentStep, isComplete }: ThinkingCardProps) {
const completedCount = steps.filter((step) => step.status === "completed").length;
const progress = isComplete || steps.length === 0 ? 100 : Math.round((completedCount / steps.length) * 100);
const activeLabel = isComplete ? "Plano de Execução" : "Thinking...";
return (
<section className="overflow-hidden rounded-3xl border border-zinc-800/80 bg-zinc-950 text-zinc-100 shadow-[0_24px_80px_-36px_rgba(0,0,0,0.9)]">
<div className="border-b border-zinc-800/80 bg-gradient-to-b from-zinc-900/80 to-zinc-950 px-5 py-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="inline-flex items-center rounded-full border border-zinc-700 bg-zinc-900 px-2.5 py-0.5 text-[11px] font-medium uppercase tracking-[0.2em] text-zinc-300">
{activeLabel}
</span>
<span className="text-xs uppercase tracking-[0.22em] text-zinc-500">Agentic mode</span>
</div>
<h2 className="mt-2 truncate text-lg font-semibold text-zinc-50">{title}</h2>
<p className="mt-1 text-sm leading-6 text-zinc-400">{overview}</p>
</div>
<div className="rounded-2xl border border-zinc-800 bg-zinc-900/80 px-3 py-2 text-right">
<div className="text-[11px] uppercase tracking-[0.18em] text-zinc-500">Progress</div>
<div className="mt-1 text-xl font-semibold tabular-nums text-zinc-50">{progress}%</div>
</div>
</div>
<div className="mt-4 space-y-2">
<div className="h-2 overflow-hidden rounded-full bg-zinc-800">
<div
className="h-full rounded-full bg-gradient-to-r from-zinc-500 via-zinc-300 to-emerald-400 transition-all duration-500 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
<div className="flex items-center justify-between text-[11px] uppercase tracking-[0.18em] text-zinc-500">
<span>{steps.length} steps</span>
<span>{isComplete ? "Completed" : `Step ${currentStep}`}</span>
</div>
</div>
</div>
<div className="p-4 sm:p-5">
<ul className="space-y-3">
{steps.map((step, index) => {
const isActive = step.id === currentStep || step.status === "running";
const isDone = step.status === "completed";
return (
<li
key={step.id}
className={`relative rounded-2xl border px-4 py-4 transition-all duration-300 ${
isActive ? "border-zinc-700 bg-zinc-900/80 shadow-[0_0_0_1px_rgba(63,63,70,0.35)]" : "border-zinc-800 bg-zinc-950/70"
} ${isDone ? "opacity-95" : ""}`}
aria-current={isActive ? "step" : undefined}
>
{index !== steps.length - 1 ? <div className="absolute left-[26px] top-12 bottom-0 w-px bg-zinc-800" aria-hidden="true" /> : null}
<div className="flex gap-3">
<div className={`mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-full border ${tone(step.status)}`}>
{statusIcon(step.status)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="rounded-md border border-zinc-800 bg-zinc-900 px-2 py-0.5 text-xs font-medium text-zinc-300">
Step {step.id}
</span>
<h3 className="truncate text-sm font-semibold text-zinc-100">{step.title}</h3>
</div>
<p className="mt-1 text-sm leading-6 text-zinc-400">{step.description}</p>
</div>
<span className={`shrink-0 rounded-full border px-2.5 py-0.5 text-[11px] font-medium uppercase tracking-[0.18em] ${tone(step.status)}`}>
{step.status}
</span>
</div>
{step.sub_steps.length > 0 ? (
<div className="mt-4 space-y-2 border-l border-zinc-800 pl-4">
{step.sub_steps.map((subStep) => (
<div key={subStep.id} className="flex items-start gap-2 rounded-xl border border-zinc-900 bg-zinc-950/60 px-3 py-2">
<div className="mt-0.5">{miniIcon(subStep.status)}</div>
<div className="min-w-0 flex-1">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-zinc-500">{subStep.id}</div>
<div className="text-sm leading-6 text-zinc-200">{subStep.description}</div>
</div>
<span className={`rounded-full border px-2 py-0.5 text-[11px] font-medium uppercase tracking-[0.16em] ${tone(subStep.status)}`}>
{subStep.status}
</span>
</div>
))}
</div>
) : null}
</div>
</div>
</li>
);
})}
</ul>
</div>
</section>
);
}
+4
View File
@@ -0,0 +1,4 @@
export { default as MessageBubble } from './MessageBubble';
export { default as ThinkingCard } from './ThinkingCard';
export { default as TaskList } from './TaskList';
export { default as ArtifactViewer } from './ArtifactViewer';
@@ -0,0 +1,26 @@
import { Info, AlertTriangle, CheckCircle2, XCircle } from "lucide-react";
type UpdateTone = "info" | "success" | "warning" | "error";
interface AgentUpdateProps {
content: string;
tone?: UpdateTone;
}
const toneMap: Record<UpdateTone, typeof Info> = {
info: Info,
success: CheckCircle2,
warning: AlertTriangle,
error: XCircle,
};
export default function AgentUpdate({ content, tone = "info" }: AgentUpdateProps) {
const IconComp = toneMap[tone];
return (
<div className={`agentic-update agentic-update--${tone}`}>
<IconComp className="agentic-update__icon" size={12} aria-hidden="true" />
<span>{content}</span>
</div>
);
}
@@ -0,0 +1,76 @@
import type { AgentPlan, AgentPlanState } from "../../types/forgeagent";
import { resolveForgeAgentFinalResponse } from "../../lib/forgeagent-state";
import StepCard from "./StepCard";
import AgentUpdate from "./AgentUpdate";
import FinalResponse from "./FinalResponse";
import ArtifactCard from "./ArtifactCard";
import SpecialistCard from "./SpecialistCard";
interface AgenticMessageProps {
plan: AgentPlan;
state: AgentPlanState;
isExecuting?: boolean;
modelTier?: "Lite" | "Iron";
}
export default function AgenticMessage({ plan, state, isExecuting, modelTier = "Lite" }: AgenticMessageProps) {
const currentStep = state?.current_step ?? 0;
const isComplete = state?.status === "completed";
const systemMessages = state?.system_messages ?? [];
const specialists = state?.specialists ?? [];
const finalResponse = resolveForgeAgentFinalResponse(state);
return (
<div className="flex max-w-[90%] flex-col gap-3 pb-3">
{/* Thinking / Plan Card */}
<StepCard
title={plan.title}
overview={plan.overview}
steps={plan.steps}
currentStep={currentStep}
isComplete={isComplete}
modelTier={modelTier}
/>
{/* Intermediate Updates */}
{systemMessages.length > 0 && (
<div className="flex flex-col gap-1">
{systemMessages.map((msg) => (
<AgentUpdate
key={msg.id}
content={msg.content}
tone={msg.tone}
/>
))}
</div>
)}
{/* Specialist Cards */}
{specialists.length > 0 && (
<div className="flex flex-col gap-2">
{specialists.map((spec, i) => (
<SpecialistCard
key={spec.id}
specialist={spec}
isLast={i === specialists.length - 1}
/>
))}
</div>
)}
{/* Final Response */}
{finalResponse ? (
<FinalResponse content={finalResponse} />
) : null}
{/* Artifact Download */}
{state?.artifact ? (
<ArtifactCard
filename={state.artifact.filename}
content={state.artifact.content}
language={state.artifact.language}
/>
) : null}
</div>
);
}
@@ -0,0 +1,51 @@
interface AgenticToggleProps {
isActive: boolean;
onToggle: () => void;
disabled?: boolean;
}
export default function AgenticToggle({ isActive, onToggle, disabled = false }: AgenticToggleProps) {
const label = isActive ? "Modo agentic ativo" : "Modo agentic desativado";
return (
<button
type="button"
onClick={onToggle}
disabled={disabled}
aria-pressed={isActive}
aria-label={label}
title="Alterna o modo agentic: o assistente pode planejar e executar tarefas com mais autonomia."
className={`group relative inline-flex min-h-9 items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium tracking-tight shadow-sm transition-all duration-200 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/60 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950 ${
disabled
? "cursor-not-allowed border-slate-800 bg-slate-900/60 text-slate-500 opacity-60"
: isActive
? "border-cyan-400/40 bg-cyan-500/15 text-cyan-200 hover:border-cyan-300/60 hover:bg-cyan-500/20 hover:text-cyan-100"
: "border-slate-700 bg-slate-900/80 text-slate-300 hover:border-slate-500 hover:bg-slate-800 hover:text-slate-100"
}`}
>
<span
aria-hidden="true"
className={`h-2.5 w-2.5 rounded-full transition-all duration-200 ${
isActive ? "bg-cyan-300 shadow-[0_0_12px_rgba(103,232,249,0.85)]" : "bg-slate-500"
}`}
/>
<span className="whitespace-nowrap">🛠️ Modo Agentic</span>
<span
className={`rounded-full border px-1.5 py-0.5 text-[10px] uppercase tracking-[0.18em] transition-colors duration-200 ${
isActive
? "border-cyan-400/30 bg-cyan-400/10 text-cyan-200"
: "border-slate-700 bg-slate-800/80 text-slate-400"
}`}
>
{isActive ? "On" : "Off"}
</span>
<span
role="tooltip"
className="pointer-events-none absolute bottom-full left-1/2 z-20 mb-2 w-56 -translate-x-1/2 rounded-lg border border-slate-700 bg-slate-950 px-2.5 py-2 text-[11px] leading-snug text-slate-200 opacity-0 shadow-lg transition-all duration-200 group-hover:opacity-100 group-hover:translate-y-0 group-focus-visible:opacity-100"
>
Habilita um modo mais autônomo para tarefas complexas, planejamento e execução assistida.
</span>
</button>
);
}
@@ -0,0 +1,68 @@
import { Download, FileText, FileCode, FileJson, File } from "lucide-react";
interface ArtifactCardProps {
filename: string;
content: string;
language?: string;
onDownload?: () => void;
}
function getExt(filename: string): string {
const parts = filename.trim().toLowerCase().split(".");
return parts.length > 1 ? (parts[parts.length - 1] ?? "") : "";
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function FileIcon({ ext }: { ext: string }) {
const IconComp =
ext === "md" ? FileText
: ["ts", "tsx", "js", "jsx", "py", "rs"].includes(ext) ? FileCode
: ext === "json" ? FileJson
: File;
return <IconComp size={16} className="text-zinc-400" />;
}
export default function ArtifactCard({ filename, content, language, onDownload }: ArtifactCardProps) {
const ext = getExt(filename);
const size = formatSize(new Blob([content]).size);
const handleDownload = () => {
if (onDownload) { onDownload(); return; }
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="agentic-artifact">
<span className="agentic-artifact__icon">
<FileIcon ext={ext} />
</span>
<div className="agentic-artifact__info">
<p className="agentic-artifact__name">{filename}</p>
<p className="agentic-artifact__meta">
{size}{language ? ` · ${language}` : ""}
</p>
</div>
<button
type="button"
onClick={handleDownload}
className="agentic-artifact__btn"
aria-label={`Download ${filename}`}
>
<Download size={11} />
Download
</button>
</div>
);
}
@@ -0,0 +1,45 @@
import { useEffect, useMemo, useRef } from "react";
import { renderMarkdown, ensureHljs } from "../../lib/markdown";
interface FinalResponseProps {
content: string;
}
export default function FinalResponse({ content }: FinalResponseProps) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
void ensureHljs();
}, []);
const html = useMemo(() => renderMarkdown(content), [content]);
useEffect(() => {
const root = containerRef.current;
if (!root) return;
const buttons = root.querySelectorAll<HTMLButtonElement>(".copy-code-btn");
buttons.forEach((btn) => {
btn.onclick = () => {
const code = btn.getAttribute("data-code") || "";
navigator.clipboard.writeText(code).then(() => {
const original = btn.textContent || "Copy";
btn.textContent = "Copied!";
btn.style.color = "var(--success, #22c55e)";
setTimeout(() => {
btn.textContent = original;
btn.style.color = "";
}, 1500);
});
};
});
}, [html]);
return (
<div
ref={containerRef}
className="manus-markdown text-[16px] leading-[1.5] text-[var(--text-primary,#e4e4e7)]"
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
@@ -0,0 +1,90 @@
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { ChevronDown, Loader2, CheckCircle2 } from "lucide-react";
import type { SpecialistState } from "../../types/forgeagent";
interface SpecialistCardProps {
specialist: SpecialistState;
isLast?: boolean;
}
export default function SpecialistCard({ specialist, isLast = false }: SpecialistCardProps) {
const [thinkingOpen, setThinkingOpen] = useState(false);
const hasThinking = specialist.thinking.trim().length > 0;
const hasResponse = specialist.response.trim().length > 0;
const isThinking = specialist.status === "thinking";
return (
<div className="specialist-card">
{/* Header row */}
<div className="specialist-card__header">
<div className="specialist-card__identity">
<span className="specialist-card__dot" aria-hidden="true" />
<span className="specialist-card__name">{specialist.name}</span>
<span className="specialist-card__title">{specialist.title}</span>
</div>
<div className="specialist-card__status">
{isThinking ? (
<span className="specialist-card__badge specialist-card__badge--thinking">
<Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" />
Pensando
</span>
) : (
<span className="specialist-card__badge specialist-card__badge--done">
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
Respondido
</span>
)}
</div>
</div>
{/* Thinking chain — always visible if present, toggleable */}
{hasThinking && (
<div className="specialist-card__thinking-section">
<button
type="button"
onClick={() => setThinkingOpen((v) => !v)}
className="specialist-card__thinking-toggle"
aria-expanded={thinkingOpen}
>
<ChevronDown
className={`specialist-card__chevron ${thinkingOpen ? "rotated" : ""}`}
aria-hidden="true"
/>
<span>Cadeia de raciocínio</span>
</button>
<AnimatePresence initial={false}>
{thinkingOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.18, ease: "easeOut" }}
className="specialist-card__thinking-body overflow-hidden"
>
<p className="specialist-card__thinking-text">{specialist.thinking}</p>
</motion.div>
)}
</AnimatePresence>
{/* Always show thinking preview when collapsed */}
{!thinkingOpen && (
<p className="specialist-card__thinking-preview">
{specialist.thinking.slice(0, 120)}
{specialist.thinking.length > 120 ? "…" : ""}
</p>
)}
</div>
)}
{/* Response */}
{hasResponse && (
<div className="specialist-card__response">
<p className="specialist-card__response-text">{specialist.response}</p>
</div>
)}
{/* Divider to next specialist */}
{!isLast && <div className="specialist-card__divider" aria-hidden="true" />}
</div>
);
}
@@ -0,0 +1,136 @@
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Check, ChevronDown, Circle, Loader2, X } from "lucide-react";
type StepStatus = "pending" | "running" | "completed" | "error";
export interface StepCardProps {
title: string;
overview: string;
steps: Array<{
id: number;
title: string;
description: string;
status: StepStatus;
sub_steps: Array<{
id: string;
description: string;
status: StepStatus;
}>;
}>;
currentStep: number;
isComplete: boolean;
modelTier?: "Lite" | "Iron";
}
function StatusDot({ status }: { status: StepStatus }) {
switch (status) {
case "running":
return (
<span className="agentic-dot agentic-dot--running" aria-hidden="true">
<Loader2 className="h-2.5 w-2.5 animate-spin" />
</span>
);
case "completed":
return (
<span className="agentic-dot agentic-dot--done" aria-hidden="true">
<Check className="h-2.5 w-2.5" />
</span>
);
case "error":
return (
<span className="agentic-dot agentic-dot--error" aria-hidden="true">
<X className="h-2.5 w-2.5" />
</span>
);
default:
return (
<span className="agentic-dot agentic-dot--idle" aria-hidden="true">
<Circle className="h-2 w-2" />
</span>
);
}
}
export default function StepCard({
title,
overview,
steps,
currentStep,
isComplete,
modelTier = "Lite",
}: StepCardProps) {
const [open, setOpen] = useState(true);
const completedCount = steps.filter((s) => s.status === "completed").length;
const progress = isComplete ? 100 : steps.length > 0 ? Math.round((completedCount / steps.length) * 100) : 0;
return (
<div className="agentic-plan">
{/* Header */}
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="agentic-plan__header"
aria-expanded={open}
>
<span className="agentic-plan__dot" aria-hidden="true" />
<span className="agentic-plan__title">{title}</span>
<span className="agentic-plan__badge">{modelTier}</span>
<span className="agentic-plan__counter">
{isComplete ? `${steps.length}/${steps.length}` : `${completedCount}/${steps.length}`}
</span>
<ChevronDown className={`agentic-plan__chevron ${open ? "rotated" : ""}`} aria-hidden="true" />
</button>
{/* Body */}
<AnimatePresence initial={false}>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.18, ease: "easeOut" }}
className="agentic-plan__body overflow-hidden"
>
{overview && <p className="agentic-plan__overview">{overview}</p>}
<ul className="agentic-steps">
{steps.map((step) => {
const isActive = step.id === currentStep || step.status === "running";
return (
<li key={step.id} className="agentic-step">
<StatusDot status={step.status} />
<div className="agentic-step__content">
<p className="agentic-step__title">{step.title}</p>
{step.description && (
<p className="agentic-step__desc">{step.description}</p>
)}
{step.sub_steps.length > 0 && (
<ul className="agentic-substeps">
{step.sub_steps.map((sub) => (
<li key={sub.id} className="agentic-substep">
<StatusDot status={sub.status} />
<span>{sub.description}</span>
</li>
))}
</ul>
)}
</div>
</li>
);
})}
</ul>
{/* Minimal progress line */}
<div className="agentic-plan__prog">
<div className="agentic-plan__prog-bar">
<div className="agentic-plan__prog-fill" style={{ width: `${progress}%` }} />
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
@@ -0,0 +1,7 @@
export { default as AgenticMessage } from './AgenticMessage';
export { default as StepCard } from './StepCard';
export { default as AgentUpdate } from './AgentUpdate';
export { default as FinalResponse } from './FinalResponse';
export { default as ArtifactCard } from './ArtifactCard';
export { default as SpecialistCard } from './SpecialistCard';
export { default as AgenticToggle } from './AgenticToggle';
@@ -0,0 +1,64 @@
/* @vitest-environment jsdom */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { swapModel } from "./api";
// ─── Mocks ───────────────────────────────────────────────────────────────────
const mockInvoke = vi.fn();
vi.mock("@tauri-apps/api/core", () => ({
invoke: (...args: unknown[]) => mockInvoke(...args),
}));
// ─── Tests ───────────────────────────────────────────────────────────────────
describe("swapModel (hot-swap API)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("calls swap_model_command with the new model path", async () => {
mockInvoke.mockResolvedValue({
status: "ready",
ready: true,
process_running: true,
resolved_url: "http://127.0.0.1:1234",
binary_path: null,
last_error: null,
active_command: "idle",
last_command_result: { command: "idle", outcome: "succeeded", message: "" },
config: {
connection_mode: "local",
start_policy: "manual",
server_url: "http://127.0.0.1:1234",
api_key: "",
model_path: "/models/new-model.gguf",
models_dir: "/models",
addr: "127.0.0.1:1234",
gpu_layers: 0,
ctx_size: 4096,
extra_model_paths: [],
selected_model_id: null,
remote_presets: [],
hugging_face_token: "",
},
});
await swapModel("/models/new-model.gguf");
expect(mockInvoke).toHaveBeenCalledWith("swap_model_command", {
newModelPath: "/models/new-model.gguf",
});
});
it("throws when swap_model_command fails", async () => {
mockInvoke.mockRejectedValue(new Error("swap failed"));
await expect(swapModel("/models/bad.gguf")).rejects.toThrow("swap failed");
});
});
@@ -48,12 +48,13 @@ export default function RuntimeStatusCard({
}: Props) {
const tone = getRuntimeStatusTone(snapshot);
const isLocal = snapshot.config.connection_mode === "local";
const isLoading = isLocal && snapshot.status === "loading";
const startGuard = getRuntimeStartGuard(snapshot);
const needsSetup = isLocal && !snapshot.process_running && !startGuard.canStart;
const primaryAction = needsSetup ? onOpenSettings : isLocal && !snapshot.process_running ? onStart : onStop;
const lifecycleLabel = getLifecycleActionLabel(activeLifecycleCommand);
const primaryLabel = lifecycleLabel ?? (needsSetup ? "Set up" : isLocal && !snapshot.process_running ? "Start" : "Stop");
const primaryDisabled = busy || lifecycleBusy || (isLocal && snapshot.process_running && snapshot.status === "stopping");
const primaryLabel = isLoading ? "Model loading..." : lifecycleLabel ?? (needsSetup ? "Set up" : isLocal && !snapshot.process_running ? "Start" : "Stop");
const primaryDisabled = busy || lifecycleBusy || isLoading || (isLocal && snapshot.process_running && snapshot.status === "stopping");
const showLifecycleResult = lastLifecycleResult.command !== "idle" && !lifecycleBusy;
const lifecycleResultClass =
lastLifecycleResult.outcome === "failed"
@@ -201,9 +201,9 @@ export default function RuntimeWorkspace({ runtime: externalRuntime }: Props) {
className="runtime-workspace-btn runtime-workspace-btn-primary"
onClick={snapshot.process_running ? (stopLocal ?? (() => {})) : (startLocal ?? (() => {}))}
type="button"
disabled={busy || lifecycleBusy || (snapshot.process_running && snapshot.status === "stopping")}
disabled={busy || lifecycleBusy || snapshot.status === "loading" || (snapshot.process_running && snapshot.status === "stopping")}
>
{lifecycleLabel ?? (snapshot.process_running ? "Stop" : "Start")}
{snapshot.status === "loading" ? "Model loading..." : lifecycleLabel ?? (snapshot.process_running ? "Stop" : "Start")}
</button>
</>
)}
@@ -110,6 +110,10 @@ describe("getRuntimeStateLabel", () => {
expect(getRuntimeStateLabel(createSnapshot({ status: "starting" }))).toBe("Starting local API");
});
it("reports model loading state", () => {
expect(getRuntimeStateLabel(createSnapshot({ status: "loading" }))).toBe("Model loading...");
});
it("reports remote mode without runtime ownership", () => {
expect(getRuntimeStateLabel(createSnapshot({
status: "stopped",
@@ -133,6 +137,10 @@ describe("getRuntimeStatusTone", () => {
it("marks local not-ready snapshots as danger", () => {
expect(getRuntimeStatusTone(createSnapshot({ status: "ready", ready: false, process_running: true }))).toBe("danger");
});
it("treats model loading as a warning", () => {
expect(getRuntimeStatusTone(createSnapshot({ status: "loading" }))).toBe("warning");
});
});
describe("getRuntimeStartGuard", () => {
@@ -187,6 +187,8 @@ export function getRuntimeStateLabel(snapshot: RuntimeSnapshot): string {
switch (snapshot.status) {
case "ready":
return "Ready";
case "loading":
return "Model loading...";
case "starting":
return "Starting local API";
case "stopping":
@@ -212,6 +214,7 @@ export function getRuntimeStatusTone(snapshot: RuntimeSnapshot): "neutral" | "su
switch (snapshot.status) {
case "ready":
return "success";
case "loading":
case "starting":
case "stopping":
case "degraded":
@@ -368,6 +371,8 @@ function deriveHealthHeadline(snapshot: RuntimeSnapshot): string {
switch (snapshot.status) {
case "ready":
return "Local API ready";
case "loading":
return "Model loading...";
case "starting":
return "Starting local API...";
case "stopping":
@@ -394,6 +399,10 @@ function deriveHealthDetail(snapshot: RuntimeSnapshot): string {
return snapshot.resolved_url || "http://127.0.0.1:8080";
}
if (snapshot.status === "loading") {
return "The local model is warming up. Please wait before sending a new message.";
}
if (snapshot.status === "stopped") {
return "Start the local API to begin";
}
+1 -1
View File
@@ -1,6 +1,6 @@
export type ConnectionMode = "local" | "remote";
export type StartPolicy = "manual" | "on_app_launch";
export type RuntimeStatus = "stopped" | "starting" | "ready" | "degraded" | "stopping" | "error";
export type RuntimeStatus = "stopped" | "starting" | "loading" | "ready" | "degraded" | "stopping" | "error";
export type RuntimeLifecycleCommand = "idle" | "save" | "start" | "stop" | "save_and_start";
export type RuntimeLifecycleOutcome = "succeeded" | "blocked" | "failed";
export type RuntimeApplyImpact = "immediate" | "restart_required" | "save_before_apply";
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { bootstrapRuntime, getRuntimeSnapshot, saveRuntimeConfig, scanRuntimeModels, startRuntime, stopRuntime } from "./api";
import {
createDefaultRuntimeSnapshot,
@@ -175,6 +175,9 @@ export function useRuntimeManager() {
if (active) {
setDraftState((current) => reconcileDraftState(current, next));
setLoading(false);
// Auto-scan models on startup so the sidebar shows all available models
const modelsDir = next.config.models_dir ?? "";
scanModelsRef.current?.(modelsDir).catch(() => undefined);
}
})
.catch(() => {
@@ -317,16 +320,11 @@ export function useRuntimeManager() {
[],
);
const scanModelsRef = useRef<(dir: string | null) => Promise<void>>();
// Phase 4: Model scanning for validation
const scanModels = useCallback(async (dirPath: string | null) => {
if (!dirPath) {
setDraftState((current) => ({
...current,
scannedModels: [],
isScanning: false,
}));
return;
}
const resolvedDir = dirPath ?? "";
// Set scanning state first
setDraftState((current) => ({
@@ -335,7 +333,7 @@ export function useRuntimeManager() {
}));
try {
const models = await scanRuntimeModels(dirPath);
const models = await scanRuntimeModels(resolvedDir);
// Now update with results
setDraftState((current) => {
@@ -380,6 +378,8 @@ export function useRuntimeManager() {
}
}, []);
scanModelsRef.current = scanModels;
const commitSavedSnapshot = useCallback((next: RuntimeSnapshot, keepOpen: boolean) => {
setDraftState((current) => ({
...current,
+109
View File
@@ -0,0 +1,109 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core";
import { applyForgeAgentEvent, createForgeAgentState } from "../lib/forgeagent-state";
import type {
AgentPlan,
AgentPlanState,
ArtifactReadyEvent,
PlanCompletedEvent,
SpecialistResponseCompletedEvent,
SpecialistThinkingStartedEvent,
SpecialistThinkingUpdateEvent,
StepCompletedEvent,
StepErrorEvent,
StepStartedEvent,
} from "../types/forgeagent";
interface StartForgeAgentResponse {
plan_id: string;
plan: AgentPlan;
}
export function useForgeAgentSession() {
const [state, setState] = useState<AgentPlanState | null>(null);
const stateRef = useRef<AgentPlanState | null>(null);
const updateState = useCallback((next: AgentPlanState | null | ((current: AgentPlanState | null) => AgentPlanState | null)) => {
setState((current) => {
const resolved = typeof next === "function"
? (next as (current: AgentPlanState | null) => AgentPlanState | null)(current)
: next;
stateRef.current = resolved;
return resolved;
});
}, []);
useEffect(() => {
if (!("__TAURI_INTERNALS__" in window)) {
return;
}
let disposed = false;
const attach = async () => {
const unlisten = await Promise.all([
listen<StepStartedEvent>("forgeagent:step-started", (event) => {
if (disposed) return;
updateState((current) => (current ? applyForgeAgentEvent(current, { type: "step-started", payload: event.payload }) : current));
}),
listen<StepCompletedEvent>("forgeagent:step-completed", (event) => {
if (disposed) return;
updateState((current) => (current ? applyForgeAgentEvent(current, { type: "step-completed", payload: event.payload }) : current));
}),
listen<StepErrorEvent>("forgeagent:step-error", (event) => {
if (disposed) return;
updateState((current) => (current ? applyForgeAgentEvent(current, { type: "step-error", payload: event.payload }) : current));
}),
listen<ArtifactReadyEvent>("forgeagent:artifact-ready", (event) => {
if (disposed) return;
updateState((current) => (current ? applyForgeAgentEvent(current, { type: "artifact-ready", payload: event.payload }) : current));
}),
listen<PlanCompletedEvent>("forgeagent:plan-completed", (event) => {
if (disposed) return;
updateState((current) => (current ? applyForgeAgentEvent(current, { type: "plan-completed", payload: event.payload }) : current));
}),
listen<SpecialistThinkingStartedEvent>("forgeagent:specialist-thinking-started", (event) => {
if (disposed) return;
updateState((current) => (current ? applyForgeAgentEvent(current, { type: "specialist-thinking-started", payload: event.payload }) : current));
}),
listen<SpecialistThinkingUpdateEvent>("forgeagent:specialist-thinking-update", (event) => {
if (disposed) return;
updateState((current) => (current ? applyForgeAgentEvent(current, { type: "specialist-thinking-update", payload: event.payload }) : current));
}),
listen<SpecialistResponseCompletedEvent>("forgeagent:specialist-response-completed", (event) => {
if (disposed) return;
updateState((current) => (current ? applyForgeAgentEvent(current, { type: "specialist-response-completed", payload: event.payload }) : current));
}),
]);
return () => {
unlisten.forEach((fn) => fn());
};
};
let cleanup: (() => void) | undefined;
void attach().then((fn) => {
cleanup = fn;
});
return () => {
disposed = true;
cleanup?.();
};
}, [updateState]);
const start = useCallback(async (prompt: string) => {
const response = await invoke<StartForgeAgentResponse>("start_forgeagent_session_command", { prompt });
const initial = createForgeAgentState(response.plan_id, response.plan);
updateState(initial);
return initial;
}, [updateState]);
const reset = useCallback(() => updateState(null), [updateState]);
const getSnapshot = useCallback(() => stateRef.current, []);
return { state, start, reset, getSnapshot };
}
+209
View File
@@ -0,0 +1,209 @@
/**
* useMemoryExtractor
*
* Runs a small background LLM call after each completed assistant turn to
* extract structured facts about the user. Facts are stored in `user_memories`
* and injected as a system prompt on every chat request.
*
* Design constraints:
* - Fire-and-forget: never blocks the main chat flow
* - Idempotent: each message is processed at most once (memory_processed flag)
* - Graceful degradation: if the memory model is unavailable, the chat works normally
* - Cheap: sends only the last N user messages and extracts compact key/value facts
*/
import { useCallback, useRef } from "react";
import type { DB } from "../lib/db";
import type { UserMemory } from "../lib/types";
// How many recent user messages to include when extracting memories
const EXTRACTION_WINDOW = 6;
// Minimum interval between extraction runs (ms) — avoids hammering on rapid sends
const EXTRACTION_DEBOUNCE_MS = 4000;
const EXTRACTION_SYSTEM_PROMPT = `You are a memory extraction assistant. Your job is to extract factual, durable information about the user from a conversation fragment.
Rules:
- Extract ONLY stable, reusable facts (name, occupation, location, preferences, projects, skills, goals, constraints)
- Ignore ephemeral requests ("what time is it?") or single-use context
- Output ONLY valid JSON — an array of objects with fields: key (snake_case, unique identifier), value (concise string), confidence (0.0–1.0)
- If nothing worth remembering is found, output an empty array: []
- Keep each value under 120 characters
- Prefer updating an existing key over creating a near-duplicate
Example output:
[
{"key": "user_name", "value": "Ana", "confidence": 0.95},
{"key": "preferred_language", "value": "Python", "confidence": 0.9},
{"key": "current_project", "value": "Building a local LLM chat app with Tauri", "confidence": 0.85}
]`;
export interface MemoryExtractorOptions {
db: DB;
server: string;
apiKey: string;
/** Model to use for extraction. Falls back gracefully if empty. */
memoryModel: string;
enabled: boolean;
}
export function useMemoryExtractor({
db,
server,
apiKey,
memoryModel,
enabled,
}: MemoryExtractorOptions) {
const lastRunRef = useRef<number>(0);
const runningRef = useRef(false);
/**
* Extracts memories from unprocessed messages in a given chat.
* Called fire-and-forget after an assistant turn completes.
*/
const extractMemories = useCallback(
async (chatId: number) => {
if (!enabled || !server || !memoryModel || runningRef.current) return;
const now = Date.now();
if (now - lastRunRef.current < EXTRACTION_DEBOUNCE_MS) return;
lastRunRef.current = now;
// Check if there are unprocessed messages
const unprocessed = db.get<{ c: number }>(
"SELECT COUNT(*) as c FROM messages WHERE chat_id = ? AND memory_processed = 0 AND role = 'user'",
[chatId],
);
if (!unprocessed || unprocessed.c === 0) return;
runningRef.current = true;
try {
// Get the last N user messages from this chat
const recentMessages = db.query<{ role: string; content: string }>(
`SELECT role, content FROM messages
WHERE chat_id = ? AND role IN ('user','assistant')
AND content != '' AND content NOT LIKE '⚠️%'
ORDER BY created_at DESC LIMIT ?`,
[chatId, EXTRACTION_WINDOW * 2],
).reverse();
if (recentMessages.length < 2) {
// Mark as processed and skip — not enough context
db.run(
"UPDATE messages SET memory_processed = 1 WHERE chat_id = ? AND memory_processed = 0",
[chatId],
);
return;
}
const conversationText = recentMessages
.map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.content.slice(0, 400)}`)
.join("\n\n");
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
const response = await fetch(`${server}/v1/chat/completions`, {
method: "POST",
headers,
body: JSON.stringify({
model: memoryModel,
messages: [
{ role: "system", content: EXTRACTION_SYSTEM_PROMPT },
{
role: "user",
content: `Extract memorable facts from this conversation:\n\n${conversationText}`,
},
],
stream: false,
max_tokens: 512,
temperature: 0.1,
}),
});
if (!response.ok) return;
const data = await response.json();
const raw: string = data.choices?.[0]?.message?.content || "";
// Parse JSON from response (strip any markdown fences)
const jsonMatch = raw.match(/\[[\s\S]*\]/);
if (!jsonMatch) return;
const facts: Array<{ key: string; value: string; confidence: number }> =
JSON.parse(jsonMatch[0]);
if (!Array.isArray(facts)) return;
const ts = Math.floor(Date.now() / 1000);
for (const fact of facts) {
if (!fact.key || !fact.value) continue;
const key = String(fact.key).toLowerCase().replace(/[^a-z0-9_]/g, "_").slice(0, 64);
const value = String(fact.value).slice(0, 240);
const confidence = Math.max(0, Math.min(1, Number(fact.confidence) || 0.8));
// Upsert: update if key exists and new confidence is higher, insert otherwise
const existing = db.get<UserMemory>(
"SELECT * FROM user_memories WHERE key = ?",
[key],
);
if (existing) {
if (confidence >= existing.confidence - 0.05) {
db.run(
"UPDATE user_memories SET value = ?, confidence = ?, updated_at = ? WHERE key = ?",
[value, confidence, ts, key],
);
}
} else {
db.run(
"INSERT INTO user_memories (key, value, confidence, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
[key, value, confidence, ts, ts],
);
}
}
// Mark all unprocessed user messages in this chat as done
db.run(
"UPDATE messages SET memory_processed = 1 WHERE chat_id = ? AND memory_processed = 0",
[chatId],
);
} catch {
// Silently fail — memory extraction is non-critical
} finally {
runningRef.current = false;
}
},
[db, server, apiKey, memoryModel, enabled],
);
/**
* Reads all stored memories and formats them as a system prompt string.
* Returns empty string if no memories exist.
*/
const buildMemorySystemPrompt = useCallback((): string => {
const memories = db.query<UserMemory>(
"SELECT key, value, confidence FROM user_memories ORDER BY updated_at DESC LIMIT 40",
);
if (memories.length === 0) return "";
const lines = memories
.filter((m) => m.confidence >= 0.6)
.map((m) => `- ${m.key.replace(/_/g, " ")}: ${m.value}`);
if (lines.length === 0) return "";
return [
"You are a helpful assistant with memory of this user.",
"Known facts about the user:",
...lines,
"",
"Use this context naturally — don't repeat it verbatim, just let it inform your responses.",
].join("\n");
}, [db]);
return { extractMemories, buildMemorySystemPrompt };
}
+33 -3
View File
@@ -1,11 +1,24 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import type { ModelInfo, ServerStatus } from "../lib/types";
import { getNextSelectedModel } from "./model-selection";
import { resolveAlias } from "../lib/modelAlias";
// Tempo de proteção após seleção manual (5 segundos)
const MANUAL_SELECTION_LOCK_MS = 5000;
export function useModels(server: string, apiKey: string, enabled = true) {
const [models, setModels] = useState<ModelInfo[]>([]);
const [selectedModel, setSelectedModel] = useState<string>("");
const [selectedModel, setSelectedModelState] = useState<string>("");
const [status, setStatus] = useState<ServerStatus>("loading");
// Ref para rastrear quando o usuário selecionou manualmente um modelo
const lastManualSelectionRef = useRef<number>(0);
// Wrapper para setSelectedModel que registra o timestamp
const setSelectedModel = useCallback((value: string | ((prev: string) => string)) => {
lastManualSelectionRef.current = Date.now();
setSelectedModelState(value);
}, []);
const refresh = useCallback(async () => {
if (!enabled || !server.trim()) {
@@ -25,7 +38,24 @@ export function useModels(server: string, apiKey: string, enabled = true) {
const data = await res.json();
const list: ModelInfo[] = data.data || [];
setModels(list);
setSelectedModel((current) => getNextSelectedModel(current, list));
// Protege contra sobrescrita de seleção manual recente
const timeSinceManualSelection = Date.now() - lastManualSelectionRef.current;
const isLockActive = timeSinceManualSelection < MANUAL_SELECTION_LOCK_MS;
setSelectedModelState((current) => {
// Se há um lock ativo e o modelo atual existe na nova lista, mantém ele
if (isLockActive && current && list.some((m) => m.id === current)) {
return current;
}
// Prefer Iron (2B) first, then Copper (0.8B), then fallback to normal selection
const iron = list.find((m) => resolveAlias(m.id) === 'Iron');
if (iron) return iron.id;
const copper = list.find((m) => resolveAlias(m.id) === 'Copper');
if (copper) return copper.id;
return getNextSelectedModel(current, list);
});
setStatus("ok");
} catch {
setModels([]);
@@ -0,0 +1,85 @@
import { useCallback, useRef, useState } from "react";
import type { RuntimeModelFile } from "../features/runtime/types";
import { resolveAlias } from "../lib/modelAlias";
import { useToast } from "../components/Toast";
interface Options {
preferredModel: string;
onSwitchToCooper: (cooperPath: string) => void;
localModels: RuntimeModelFile[];
}
interface SessionModelController {
effectiveModel: string;
overrideSource: 'auto' | 'user';
setUserOverride: (modelPath: string) => void;
clearOverride: () => void;
onDegradationDetected: () => void;
degradationPromptShown: boolean;
}
export function useSessionModelController({
preferredModel,
onSwitchToCooper,
localModels,
}: Options): SessionModelController {
const { showToast } = useToast();
const [effectiveModel, setEffectiveModel] = useState<string>(preferredModel);
const [overrideSource, setOverrideSource] = useState<'auto' | 'user'>('auto');
const [degradationPromptShown, setDegradationPromptShown] = useState(false);
// Keep effectiveModel in sync when preferredModel changes externally (e.g. startup auto-select)
// but only if the user hasn't manually overridden
const prevPreferredRef = useRef(preferredModel);
if (preferredModel !== prevPreferredRef.current) {
prevPreferredRef.current = preferredModel;
if (overrideSource === 'auto') {
setEffectiveModel(preferredModel);
}
}
const setUserOverride = useCallback((modelPath: string) => {
setEffectiveModel(modelPath);
setOverrideSource('user');
}, []);
const clearOverride = useCallback(() => {
setEffectiveModel(preferredModel);
setOverrideSource('auto');
}, [preferredModel]);
const onDegradationDetected = useCallback(() => {
// Don't show if user already chose manually or already shown
if (overrideSource === 'user' || degradationPromptShown) return;
// Find Copper's path from localModels
const copperModel = localModels.find((m) => resolveAlias(m.path) === 'Copper');
if (!copperModel) return; // Copper not available, can't suggest
setDegradationPromptShown(true);
showToast({
id: 'tps-degradation',
variant: 'warning',
message: 'Performance degraded',
detail: 'TPS dropped below 30 for 5s. Switch to Copper (0.8B) for faster responses?',
action: {
label: 'Switch to Copper',
onClick: () => {
setUserOverride(copperModel.path);
onSwitchToCooper(copperModel.path);
},
},
duration: 0, // persistent until dismissed
});
}, [overrideSource, degradationPromptShown, localModels, showToast, setUserOverride, onSwitchToCooper]);
return {
effectiveModel,
overrideSource,
setUserOverride,
clearOverride,
onDegradationDetected,
degradationPromptShown,
};
}
+3 -2
View File
@@ -28,8 +28,9 @@
--radius-md: 10px;
--radius: 10px;
--radius-lg: 14px;
--font-sans: "Inter", "Segoe UI Variable", "SF Pro Display", system-ui, sans-serif;
--font-serif: "Merriweather", Georgia, serif;
--font-sans: "Aptos", "Segoe UI Variable", "SF Pro Display", system-ui, sans-serif;
--font-editorial: "Aptos", "Segoe UI Variable", "SF Pro Text", system-ui, sans-serif;
--font-serif: "Iowan Old Style", "Palatino Linotype", Georgia, serif;
--shadow: 0 2px 12px rgba(0,0,0,0.4);
--shadow-lg: 0 8px 32px rgba(0,0,0,0.6);
font-size: 15px;
+116
View File
@@ -0,0 +1,116 @@
/**
* Agentic Mode Detection
*
* Detects when a user message should trigger agentic/planning mode
*/
// Keywords that trigger agentic mode
export const AGENTIC_KEYWORDS = [
'como fazer',
'como criar',
'como configurar',
'como instalar',
'como usar',
'como implementar',
'guia',
'tutorial',
'passo a passo',
'exemplo completo',
'projeto',
'aprenda',
'ensine',
'explique detalhadamente',
'me mostre',
'crie um',
'faça um',
'monte um',
'desenvolva',
'construa',
'setup',
'configurar',
'instalação',
'getting started',
'quickstart',
'boilerplate',
'template',
'scaffold',
];
// Regex patterns for more complex detection
export const AGENTIC_PATTERNS = [
/\b(como|guia|tutorial|passo a passo|exemplo completo|projeto|aprenda|ensine|mostre)\b/i,
/\b(pode me (mostrar|ensinar|ajudar|explicar))\b/i,
/\b(quero (aprender|criar|fazer|construir|configurar|montar))\b/i,
/\b(me (explique|mostre|ensine) como)\b/i,
/\b(crie|faça|monte|desenvolva|construa) (um|uma)\b/i,
];
/**
* Check if a message should trigger agentic mode
*/
export function shouldUseAgenticMode(message: string): boolean {
const lowerMessage = message.toLowerCase().trim();
// Check keywords
const hasKeyword = AGENTIC_KEYWORDS.some(keyword =>
lowerMessage.includes(keyword.toLowerCase())
);
// Check patterns
const hasPattern = AGENTIC_PATTERNS.some(pattern =>
pattern.test(message)
);
return hasKeyword || hasPattern;
}
/**
* Extract the core intent from a message
* Used for generating plan titles
*/
export function extractIntent(message: string): string {
// Remove common prefixes
let intent = message
.replace(/^(como|me mostre|me ensine|me explique|quero|gostaria de|pode|consegue)\s+/i, '')
.replace(/\?$/g, '')
.trim();
// Capitalize first letter
return intent.charAt(0).toUpperCase() + intent.slice(1);
}
/**
* Count how many agentic indicators are in the message
* Higher score = more likely to need agentic mode
*/
export function getAgenticScore(message: string): number {
const lowerMessage = message.toLowerCase();
let score = 0;
// Keyword matches
AGENTIC_KEYWORDS.forEach(keyword => {
if (lowerMessage.includes(keyword.toLowerCase())) {
score += 1;
}
});
// Pattern matches
AGENTIC_PATTERNS.forEach(pattern => {
if (pattern.test(message)) {
score += 2;
}
});
// Bonus for longer messages (more detailed requests)
if (message.length > 100) score += 1;
if (message.length > 200) score += 1;
return score;
}
/**
* Determine if we should use agentic mode based on score threshold
*/
export function isAgenticRecommended(message: string, threshold: number = 2): boolean {
return getAgenticScore(message) >= threshold;
}
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { parseAssistantResponse } from "./chat";
describe("parseAssistantResponse", () => {
it("removes leaked role transcript after the assistant answer", () => {
const parsed = parseAssistantResponse(
"Resposta final para o usuário.\n\nAssistant: continuação indevida\nUser: trecho antigo\nSystem: instrução antiga",
);
expect(parsed.content).toBe("Resposta final para o usuário.");
expect(parsed.reasoning).toBe("");
});
it("keeps visible content while separating think blocks", () => {
const parsed = parseAssistantResponse(
"<think>análise interna</think>\n\nExplicação útil.\n\nAssistant: lixo vazado",
);
expect(parsed.reasoning).toBe("análise interna");
expect(parsed.content).toBe("Explicação útil.");
});
});
+22 -1
View File
@@ -7,6 +7,16 @@ export interface ParsedAssistantResponse {
const THINK_OPEN = "<think>";
const THINK_CLOSE = "</think>";
const TRANSCRIPT_LEAKAGE_PATTERN = /(?:^|\n)\s*(?:System|User|Assistant):/i;
export const CHAT_TRANSCRIPT_STOP_SEQUENCES = [
"\nSystem:",
"\nUser:",
"\nAssistant:",
"\n\nSystem:",
"\n\nUser:",
"\n\nAssistant:",
];
export function buildChatTitle(text: string): string {
const normalized = text.replace(/\s+/g, " ").trim();
@@ -14,6 +24,16 @@ export function buildChatTitle(text: string): string {
return normalized.slice(0, 50) + (normalized.length > 50 ? "..." : "");
}
function stripTranscriptLeakage(text: string): string {
const normalized = text.replace(/\r\n/g, "\n");
const match = normalized.match(TRANSCRIPT_LEAKAGE_PATTERN);
if (!match || match.index == null) return text;
const prefix = normalized.slice(0, match.index).trimEnd();
if (!prefix) return "";
return prefix;
}
export function parseAssistantResponse(rawContent: string): ParsedAssistantResponse {
let cursor = 0;
let thinkingOpen = false;
@@ -49,10 +69,11 @@ export function parseAssistantResponse(rawContent: string): ParsedAssistantRespo
const normalizedContent = reasoning.trim().length > 0
? content.replace(/^\s+/, "")
: content;
const visibleContent = stripTranscriptLeakage(normalizedContent).trimEnd();
return {
rawContent,
content: normalizedContent.trimEnd(),
content: visibleContent,
reasoning: reasoning.trim(),
thinkingOpen,
};
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { formatChatDebugTranscript } from "./chatDebug";
describe("formatChatDebugTranscript", () => {
it("includes user and assistant details for a full chat export", () => {
const output = formatChatDebugTranscript({
chatId: 7,
messages: [
{ id: 10, chat_id: 7, role: "user", content: "Pergunta", created_at: 1710000000 },
{
id: 11,
chat_id: 7,
role: "assistant",
content: "Resposta",
reasoning_content: "Rascunho interno",
prompt_tokens: 12,
completion_tokens: 34,
total_tokens: 46,
tokens_per_second: 19.4,
created_at: 1710000060,
},
],
});
expect(output).toContain("Chat ID: 7");
expect(output).toContain("[USER] #10");
expect(output).toContain("Pergunta");
expect(output).toContain("[ASSISTANT] #11");
expect(output).toContain("Resposta");
expect(output).toContain("Reasoning:\nRascunho interno");
expect(output).toContain("Usage: prompt=12 completion=34 total=46 tps=19.4");
});
});
+45
View File
@@ -0,0 +1,45 @@
import type { Message } from "./types";
interface FormatChatDebugTranscriptOptions {
chatId: number | null;
messages: Message[];
}
function formatTimestamp(timestamp: number | null | undefined): string {
if (!timestamp) return "unknown";
return new Date(timestamp * 1000).toISOString();
}
export function formatChatDebugTranscript({ chatId, messages }: FormatChatDebugTranscriptOptions): string {
const lines = [
"# Chat Debug Export",
`Chat ID: ${chatId ?? "new-chat"}`,
`Message count: ${messages.length}`,
"",
];
for (const message of messages) {
lines.push(`[${message.role.toUpperCase()}] #${message.id}`);
lines.push(`Created: ${formatTimestamp(message.created_at)}`);
lines.push(`Content:\n${message.content || "(empty)"}`);
if (message.reasoning_content?.trim()) {
lines.push(`Reasoning:\n${message.reasoning_content}`);
}
if (
message.prompt_tokens != null ||
message.completion_tokens != null ||
message.total_tokens != null ||
message.tokens_per_second != null
) {
lines.push(
`Usage: prompt=${message.prompt_tokens ?? 0} completion=${message.completion_tokens ?? 0} total=${message.total_tokens ?? 0} tps=${message.tokens_per_second ?? "n/a"}`,
);
}
lines.push("");
}
return lines.join("\n").trimEnd();
}
+19
View File
@@ -188,11 +188,30 @@ export async function initDB(): Promise<DB> {
db.run(`CREATE INDEX IF NOT EXISTS idx_msg_chat ON messages(chat_id, created_at)`);
ensureColumn(db, "messages", "reasoning_content", "reasoning_content TEXT");
ensureColumn(db, "messages", "display_mode", "display_mode TEXT");
ensureColumn(db, "messages", "forgeagent_state", "forgeagent_state TEXT");
ensureColumn(db, "messages", "prompt_tokens", "prompt_tokens INTEGER NOT NULL DEFAULT 0");
ensureColumn(db, "messages", "completion_tokens", "completion_tokens INTEGER NOT NULL DEFAULT 0");
ensureColumn(db, "messages", "total_tokens", "total_tokens INTEGER NOT NULL DEFAULT 0");
ensureColumn(db, "messages", "tokens_per_second", "tokens_per_second REAL");
// User memories table for semantic memory system
db.run(`
CREATE TABLE IF NOT EXISTS user_memories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
value TEXT NOT NULL,
confidence REAL NOT NULL DEFAULT 1.0,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
)
`);
db.run(`CREATE INDEX IF NOT EXISTS idx_memories_key ON user_memories(key)`);
db.run(`CREATE INDEX IF NOT EXISTS idx_memories_updated ON user_memories(updated_at DESC)`);
// Track which messages have already been processed for memory extraction
ensureColumn(db, "messages", "memory_processed", "memory_processed INTEGER NOT NULL DEFAULT 0");
const chatCount = db.get<{ c: number }>("SELECT COUNT(*) as c FROM chats");
const msgCount = db.get<{ c: number }>("SELECT COUNT(*) as c FROM messages");
console.log(`[DB] initDB: ready — ${chatCount?.c ?? 0} chats, ${msgCount?.c ?? 0} messages`);
+242
View File
@@ -0,0 +1,242 @@
import type {
AgentPlan,
AgentPlanState,
ArtifactReadyEvent,
PlanCompletedEvent,
SpecialistResponseCompletedEvent,
SpecialistState,
SpecialistThinkingStartedEvent,
SpecialistThinkingUpdateEvent,
StepCompletedEvent,
StepErrorEvent,
StepStartedEvent,
} from "../types/forgeagent";
type ForgeAgentEvent =
| { type: "step-started"; payload: StepStartedEvent }
| { type: "step-completed"; payload: StepCompletedEvent }
| { type: "step-error"; payload: StepErrorEvent }
| { type: "artifact-ready"; payload: ArtifactReadyEvent }
| { type: "plan-completed"; payload: PlanCompletedEvent }
| { type: "specialist-thinking-started"; payload: SpecialistThinkingStartedEvent }
| { type: "specialist-thinking-update"; payload: SpecialistThinkingUpdateEvent }
| { type: "specialist-response-completed"; payload: SpecialistResponseCompletedEvent };
type ForgeAgentState = AgentPlanState & {
specialists: SpecialistState[];
};
export function serializeForgeAgentState(state: AgentPlanState | null | undefined): string | null {
if (!state) return null;
return JSON.stringify(state);
}
export function withPersistedForgeAgentResponse(
state: AgentPlanState | null | undefined,
finalResponse: string | null | undefined,
): AgentPlanState | null {
if (!state) return null;
const normalizedResponse = resolveForgeAgentFinalResponse(state, finalResponse);
if (!normalizedResponse) {
return state;
}
return {
...state,
final_response: normalizedResponse,
};
}
function isGenericForgeAgentResponse(value: string | null | undefined): boolean {
const normalized = value?.trim().toLowerCase();
if (!normalized) return true;
return [
"aqui está a resposta final organizada em formato manus-style, pronta para revisão e entrega.",
"aqui está a resposta final organizada.",
].includes(normalized);
}
function extractPromptFromArtifact(state: AgentPlanState | null | undefined): string | null {
const content = state?.artifact?.content;
if (!content) return null;
const match = content.match(/prompt original:\s*(.+)/i);
return match?.[1]?.trim() ?? null;
}
export function resolveForgeAgentFinalResponse(
state: AgentPlanState | null | undefined,
fallbackContent?: string | null,
): string {
const resolvedFallback = fallbackContent?.trim() ?? "";
const stateResponse = state?.final_response?.trim() ?? "";
if (stateResponse && !isGenericForgeAgentResponse(stateResponse)) {
return stateResponse;
}
if (resolvedFallback && !isGenericForgeAgentResponse(resolvedFallback)) {
return resolvedFallback;
}
if (!state) {
return resolvedFallback;
}
const prompt = extractPromptFromArtifact(state);
const completedUpdates = (state.system_messages ?? [])
.map((message) => message.content.trim())
.filter(Boolean)
.slice(-3);
const sections = [
"## Resposta final",
prompt
? `Concluí a execução do pedido **${prompt}**.`
: `Concluí a execução de **${state.plan.title}**.`,
completedUpdates.length > 0
? `O que foi entregue:\n${completedUpdates.map((item) => `- ${item}`).join("\n")}`
: null,
state.artifact
? `Artefato disponível: \`${state.artifact.filename}\`${state.artifact.cta_label ? `, pronto para ${state.artifact.cta_label.toLowerCase()}` : "."}`
: null,
].filter((section): section is string => Boolean(section));
return sections.join("\n\n");
}
export function parseForgeAgentState(value: unknown): AgentPlanState | null {
if (!value) return null;
const parsed = typeof value === "string" ? safeParse(value) : value;
if (!parsed || typeof parsed !== "object") return null;
const candidate = parsed as Partial<AgentPlanState>;
if (typeof candidate.plan_id !== "string") return null;
if (!candidate.plan || typeof candidate.plan !== "object") return null;
if (!Array.isArray(candidate.plan.steps)) return null;
return candidate as AgentPlanState;
}
function safeParse(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return null;
}
}
export function createForgeAgentState(planId: string, plan: AgentPlan): AgentPlanState {
return {
plan_id: planId,
plan,
current_step: 0,
status: "planning",
progress: 0,
system_messages: [],
specialists: [],
} as ForgeAgentState;
}
export function applyForgeAgentEvent(state: AgentPlanState, event: ForgeAgentEvent): AgentPlanState {
if (state.plan_id !== event.payload.plan_id) return state;
const currentState = state as ForgeAgentState;
switch (event.type) {
case "step-started": {
const steps = currentState.plan.steps.map((step) =>
step.id === event.payload.step_id
? { ...step, status: "running" as const }
: step,
);
return {
...currentState,
status: "executing",
current_step: event.payload.step_id,
progress: Math.max(currentState.progress, Math.round(((event.payload.step_id - 1) / currentState.plan.steps.length) * 100)),
plan: { ...currentState.plan, steps },
} as ForgeAgentState;
}
case "step-completed": {
const steps = currentState.plan.steps.map((step) =>
step.id === event.payload.step_id
? { ...step, status: "completed" as const }
: step,
);
const systemMessages = [
...(currentState.system_messages ?? []),
{
id: `${currentState.plan_id}-${event.payload.step_id}-${(currentState.system_messages ?? []).length + 1}`,
step_id: event.payload.step_id,
tone: "info" as const,
content: event.payload.message,
},
];
return {
...currentState,
status: "executing",
current_step: event.payload.step_id,
progress: Math.round((event.payload.step_id / currentState.plan.steps.length) * 100),
system_messages: systemMessages,
plan: { ...currentState.plan, steps },
} as ForgeAgentState;
}
case "step-error": {
const steps = currentState.plan.steps.map((step) =>
step.id === event.payload.step_id
? { ...step, status: "error" as const }
: step,
);
return {
...currentState,
status: "error",
error: event.payload.error,
plan: { ...currentState.plan, steps },
} as ForgeAgentState;
}
case "artifact-ready":
return { ...currentState, artifact: event.payload.artifact } as ForgeAgentState;
case "specialist-thinking-started": {
const specialist: SpecialistState = {
id: event.payload.specialist_id,
name: event.payload.specialist_name,
title: event.payload.specialist_title,
thinking: "",
response: "",
status: "thinking",
};
const existing = currentState.specialists.findIndex((s) => s.id === event.payload.specialist_id);
const specialists = existing >= 0
? currentState.specialists.map((s, i) => (i === existing ? specialist : s))
: [...currentState.specialists, specialist];
return { ...currentState, specialists } as ForgeAgentState;
}
case "specialist-thinking-update": {
const specialists = currentState.specialists.map((s) =>
s.id === event.payload.specialist_id
? { ...s, thinking: event.payload.thinking }
: s,
);
return { ...currentState, specialists } as ForgeAgentState;
}
case "specialist-response-completed": {
const specialists = currentState.specialists.map((s) =>
s.id === event.payload.specialist_id
? { ...s, thinking: event.payload.thinking, response: event.payload.response, status: "responded" as const }
: s,
);
return { ...currentState, specialists } as ForgeAgentState;
}
case "plan-completed":
return {
...currentState,
status: "completed",
progress: 100,
final_response: event.payload.summary,
} as ForgeAgentState;
}
}
+76
View File
@@ -0,0 +1,76 @@
export type ModelAlias = 'Copper' | 'Iron' | 'Steel' | 'Unknown';
/**
* Resolves model alias from filename or path
* Matches: Copper (0.8B), Iron (2B), Steel (4B)
*/
export function resolveAlias(modelIdOrPath: string): ModelAlias {
const lower = modelIdOrPath.toLowerCase();
// Match by filename pattern first
if (lower.includes('copper')) return 'Copper';
if (lower.includes('iron')) return 'Iron';
if (lower.includes('steel')) return 'Steel';
// Fallback: match by parameter size indicators
if (lower.includes('0.8b')) return 'Copper';
if (lower.includes('4b')) return 'Steel';
if (lower.includes('2b')) return 'Iron';
return 'Unknown';
}
/**
* Gets clean display name for model selector
* Returns: Copper, Iron, Steel (never full filename)
*/
export function getDisplayName(modelIdOrPath: string): string {
const alias = resolveAlias(modelIdOrPath);
if (alias !== 'Unknown') return alias;
// For unknown models, extract a clean name from filename
const parts = modelIdOrPath.replace(/\\/g, '/').split('/');
const base = parts[parts.length - 1];
// Remove .gguf extension and common suffixes
let clean = base.replace(/\.gguf$/i, '');
clean = clean.replace(/-Q\d+_[KML]+$/i, ''); // Remove quantization suffix
clean = clean.replace(/-GGUF$/i, '');
return clean.slice(0, 20);
}
/**
* Returns true if this model should appear in the primary chat selector
* All valid models (Copper, Iron, Steel) are shown
*/
export function isInChatSelector(modelIdOrPath: string): boolean {
const alias = resolveAlias(modelIdOrPath);
return alias === 'Copper' || alias === 'Iron' || alias === 'Steel';
}
/**
* Gets the ForgeMetal enum value for a model
* Used internally for model management
*/
export function getForgeMetal(modelIdOrPath: string): 'Copper' | 'Iron' | 'Steel' | null {
const alias = resolveAlias(modelIdOrPath);
if (alias === 'Unknown') return null;
return alias;
}
/**
* Gets model description/tooltip text
*/
export function getModelDescription(alias: ModelAlias): string {
switch (alias) {
case 'Copper':
return 'Copper (0.8B) - Fast and lightweight';
case 'Iron':
return 'Iron (2B) - Balanced performance';
case 'Steel':
return 'Steel (4B) - Most capable';
default:
return 'Unknown model';
}
}
+13
View File
@@ -1,3 +1,5 @@
import type { AgentPlanState } from "../types/forgeagent";
export interface Chat {
id: number;
title: string;
@@ -10,6 +12,8 @@ export interface Message {
chat_id: number;
role: "user" | "assistant" | "system";
content: string;
display_mode?: "default" | "agentic_final" | null;
forgeagent_state?: AgentPlanState | null;
reasoning_content?: string | null;
prompt_tokens?: number | null;
completion_tokens?: number | null;
@@ -39,3 +43,12 @@ export interface Usage {
total_tokens: number;
tokens_per_second?: number;
}
export interface UserMemory {
id: number;
key: string;
value: string;
confidence: number;
created_at: number;
updated_at: number;
}
+119
View File
@@ -0,0 +1,119 @@
/**
* ForgeAgent System Prompt
*
* Este prompt deve ser usado quando o modo agentic é ativado.
* Ele instrui o modelo a retornar apenas JSON estruturado no formato AgentPlan.
*/
export const FORGEAGENT_SYSTEM_PROMPT = `Você é o ForgeAgent, um assistente especializado em criar planos estruturados e executáveis.
SUA TAREFA:
1. Analisar a solicitação do usuário
2. Criar um plano passo a passo detalhado
3. Retornar APENAS um objeto JSON válido no formato especificado abaixo
REGRAS IMPORTANTES:
- NUNCA adicione texto fora do JSON
- NUNCA use markdown code blocks (\`\`\`json)
- Sempre divida tarefas complexas em passos claros
- Cada passo deve ter 2-5 sub-passos quando aplicável
- Use linguagem clara e direta
- O overview deve ter no máximo 2 frases
- O título deve ter no máximo 50 caracteres
- Máximo de 10 passos principais
- Responda em português do Brasil
ESTRUTURA JSON OBRIGATÓRIA:
{
"title": "string (max 50 chars)",
"overview": "string (max 2 frases)",
"steps": [
{
"id": number,
"title": "string",
"description": "string",
"status": "pending",
"sub_steps": [
{
"id": "string (formato: '{step_id}.{sub_id}')",
"description": "string",
"status": "pending"
}
]
}
]
}
EXEMPLO DE RESPOSTA VÁLIDA:
{"title":"Criar API Node.js","overview":"Vamos criar uma API REST completa usando Express.js.","steps":[{"id":1,"title":"Configurar ambiente","description":"Preparar ambiente de desenvolvimento","status":"pending","sub_steps":[{"id":"1.1","description":"Verificar instalação do Node.js","status":"pending"},{"id":"1.2","description":"Instalar npm","status":"pending"}]}]}
LEMBRE-SE: Retorne APENAS o JSON, sem explicações adicionais.`;
/**
* Prompt para geração do artefato final
* Usado após todos os steps serem completados
*/
export const FORGEAGENT_ARTIFACT_PROMPT = `Você é o ForgeAgent. Com base no plano executado, crie um artefato final completo.
O artefato deve ser um documento Markdown bem estruturado contendo:
1. Título do guia
2. Introdução breve
3. Conteúdo completo (código, explicações, comandos)
4. Conclusão
REGRAS:
- Use Markdown válido
- Inclua blocos de código quando necessário
- Seja completo mas conciso
- Use emojis apropriados para melhorar legibilidade
- Responda em português do Brasil
Retorne APENAS o conteúdo do markdown, sem blocos de código (\`\`\`).`;
/**
* Trigger keywords for automatic agentic mode detection
*/
export const AGENTIC_TRIGGER_KEYWORDS = [
'como fazer',
'como criar',
'como configurar',
'como instalar',
'como usar',
'como implementar',
'guia',
'tutorial',
'passo a passo',
'exemplo completo',
'projeto',
'aprenda',
'ensine',
'explique detalhadamente',
];
/**
* Regex patterns for trigger detection
*/
export const AGENTIC_TRIGGER_PATTERNS = [
/\b(como|guia|tutorial|passo a passo|exemplo completo|projeto|aprenda|ensine)\b/i,
/\b(pode me (mostrar|ensinar|ajudar|explicar))\b/i,
/\b(quero (aprender|criar|fazer|construir|configurar))\b/i,
];
/**
* Check if a message should trigger agentic mode
*/
export function shouldTriggerAgentic(message: string): boolean {
const lowerMessage = message.toLowerCase();
// Check keywords
const hasKeyword = AGENTIC_TRIGGER_KEYWORDS.some(keyword =>
lowerMessage.includes(keyword.toLowerCase())
);
// Check patterns
const hasPattern = AGENTIC_TRIGGER_PATTERNS.some(pattern =>
pattern.test(message)
);
return hasKeyword || hasPattern;
}
+242
View File
@@ -0,0 +1,242 @@
/**
* ForgeAgent Types
*
* TypeScript definitions for the AgentPlan structured output
*/
export type StepStatus = 'pending' | 'running' | 'completed' | 'error';
export interface SubStep {
/** Format: "{step_id}.{sub_id}" e.g., "1.1", "2.3" */
id: string;
/** Description of the sub-task */
description: string;
/** Current status */
status: StepStatus;
}
export interface Step {
/** Sequential ID starting from 1 */
id: number;
/** Short title (max ~50 chars) */
title: string;
/** Detailed description */
description: string;
/** Current execution status */
status: StepStatus;
/** Sub-tasks breakdown */
sub_steps: SubStep[];
}
export interface AgentPlan {
/** Plan title (max 50 chars) */
title: string;
/** Brief overview (max 2 sentences) */
overview: string;
/** List of execution steps */
steps: Step[];
}
export interface Artifact {
/** Filename with extension */
filename: string;
/** Full content */
content: string;
/** Language for syntax highlighting */
language?: 'markdown' | 'javascript' | 'typescript' | 'python' | 'rust' | 'json' | string;
/** CTA label shown in final response */
cta_label?: string;
}
export interface AgentSystemMessage {
id: string;
step_id: number;
tone: 'info' | 'success' | 'warning' | 'error';
content: string;
}
/**
* Runtime state for plan execution
*/
export interface AgentPlanState {
/** Unique plan ID */
plan_id: string;
/** The plan definition */
plan: AgentPlan;
/** Current step index (0-based) */
current_step: number;
/** Overall status */
status: 'planning' | 'executing' | 'completed' | 'error' | 'cancelled';
/** Progress percentage (0-100) */
progress: number;
/** Error message if status is 'error' */
error?: string;
/** Generated artifact */
artifact?: Artifact;
/** Dense transcript updates shown between steps */
system_messages?: AgentSystemMessage[];
/** Final prose response shown after execution */
final_response?: string;
/** Active specialist agents */
specialists?: SpecialistState[];
}
/**
* Props for ForgeAgent components
*/
export interface ForgeAgentBubbleProps {
/** The plan to display */
plan: AgentPlan;
/** Current execution state */
state?: AgentPlanState;
/** Whether the plan is currently executing */
isExecuting: boolean;
/** Optional markdown renderer for the final response */
renderMarkdown?: (text: string) => string;
}
export interface PlanCardProps {
plan: AgentPlan;
state: AgentPlanState;
}
export interface StepItemProps {
step: Step;
isActive: boolean;
isLast: boolean;
}
export interface ArtifactViewerProps {
artifact: Artifact;
onDownload?: () => void;
}
/**
* Tauri event payloads
*/
export interface PlanStartedEvent {
plan_id: string;
title: string;
total_steps: number;
}
export interface StepStartedEvent {
plan_id: string;
step_id: number;
title: string;
}
export interface StepCompletedEvent {
plan_id: string;
step_id: number;
message: string;
sub_steps_completed?: string[];
}
export interface StepErrorEvent {
plan_id: string;
step_id: number;
error: string;
}
export interface ArtifactReadyEvent {
plan_id: string;
artifact: Artifact;
}
export interface PlanCompletedEvent {
plan_id: string;
summary: string;
}
export interface SpecialistThinkingStartedEvent {
plan_id: string;
specialist_id: string;
specialist_name: string;
specialist_title: string;
}
export interface SpecialistThinkingUpdateEvent {
plan_id: string;
specialist_id: string;
thinking: string;
}
export interface SpecialistResponseCompletedEvent {
plan_id: string;
specialist_id: string;
specialist_name: string;
thinking: string;
response: string;
}
export interface SpecialistState {
id: string;
name: string;
title: string;
thinking: string;
response: string;
status: 'thinking' | 'responded';
}
/**
* JSON Schema for validation (can be used with zod or ajv)
*/
export const AgentPlanJSONSchema = {
type: 'object',
required: ['title', 'overview', 'steps'],
properties: {
title: {
type: 'string',
maxLength: 50,
},
overview: {
type: 'string',
maxLength: 200,
},
steps: {
type: 'array',
maxItems: 10,
items: {
type: 'object',
required: ['id', 'title', 'description', 'status', 'sub_steps'],
properties: {
id: {
type: 'number',
minimum: 1,
},
title: {
type: 'string',
},
description: {
type: 'string',
},
status: {
type: 'string',
enum: ['pending', 'running', 'completed', 'error'],
},
sub_steps: {
type: 'array',
items: {
type: 'object',
required: ['id', 'description', 'status'],
properties: {
id: {
type: 'string',
pattern: '^\\d+\\.\\d+$',
},
description: {
type: 'string',
},
status: {
type: 'string',
enum: ['pending', 'running', 'completed', 'error'],
},
},
},
},
},
},
},
},
};