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:
1 parent
4a867b36ff
commit
77c57c3d45
84 files changed
+7860
-1266
No files matched your search
@@ -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 |
|
||||
Generated
+44
-3
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+16
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 }]],
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user