Add runtime state validation before ForgeAgent HTTP call

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

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

No files matched your search

+1
View File
@@ -0,0 +1 @@
[]
+10 -6
View File
@@ -15,6 +15,7 @@ This milestone hardens the existing Tauri runtime workspace so operators can tru
- [x] **Phase 3: Unified Runtime Workspace** - Consolidate status, actions, settings, and health guidance into one authoritative workspace. (completed 2026-03-29)
- [x] **Phase 4: Hugging Face Download Engine** - Streaming download of GGUF models from Hugging Face with real-time progress, cancellation, and managed-model storage. (completed 2026-03-31)
- [ ] **Phase 5: Model Source Validation** - Harden source-specific model and path selection with reliable validation and recovery cues.
- [x] **Phase 6: Chat UI Redesign (Manus AI System Design)** - Redesign the chat UI to follow Manus AI system design with correct color tokens, component decomposition, and UX patterns. (completed 2026-04-02)
## Phase Details
@@ -96,7 +97,7 @@ Plans:
### Phase 6: Chat UI Redesign (Manus AI System Design)
**Goal**: Redesign the chat UI to follow Manus AI system design with correct color tokens, component decomposition, and UX patterns.
**Depends on**: Phase 5
**Requirements**: UI-01, UI-02, UI-03
**Requirements**: UI-01, UI-02, UI-03, UI-04
**Success Criteria** (what must be TRUE):
1. Model selector lives inside the chat input pill (hero and regular), not the sidebar.
2. scan_models returns only top-level GGUF files (no subdirectory traversal).
@@ -104,11 +105,14 @@ Plans:
4. Send button uses PaperPlaneRight icon only.
5. App uses Manus color tokens (#171717 base, #1E1E1E surface, #262626 elevated).
6. Settings page aligns to Manus design tokens.
**Plans**: 3 plans
7. Agentic mode renders a Manus-style execution transcript with a dedicated thinking card, dense system progress rows, and a clean final response block.
8. Agentic execution progress can stream into the chat UI via Tauri events without breaking normal chat mode.
**Plans**: 4 plans
Plans:
- [ ] `06-01-PLAN.md` — Backend scan fix + ModelSelectorPill + Sidebar cleanup + ChatView wiring
- [ ] `06-02-PLAN.md` — Global Manus tokens + thinking indicator + send button + message widths
- [ ] `06-03-PLAN.md` — Settings page Manus token alignment
- [x] `06-01-PLAN.md` — Backend scan fix + ModelSelectorPill + Sidebar cleanup + ChatView wiring
- [x] `06-02-PLAN.md` — Global Manus tokens + thinking indicator + send button + message widths
- [x] `06-03-PLAN.md` — Settings page Manus token alignment
- [x] `06-04-PLAN.md` — Agentic Manus conversation blocks + Tauri progress event bridge + final response/artifact presentation
**UI hint**: yes
## Progress
@@ -120,4 +124,4 @@ Plans:
| 3. Unified Runtime Workspace | 3/3 | Complete | 2026-03-29 |
| 4. Hugging Face Download Engine | 3/3 | Complete | 2026-03-31 |
| 5. Model Source Validation | 3/3 | Planned | - |
| 6. Chat UI Redesign | 0/3 | Planned | - |
| 6. Chat UI Redesign | 4/4 | Complete | 2026-04-02 |
+14 -14
View File
@@ -2,16 +2,16 @@
gsd_state_version: 1.0
milestone: v1.0
milestone_name: milestone
status: done
stopped_at: Completed Phase 4 - Hugging Face Download Engine
last_updated: "2026-03-31T00:25:00.000Z"
last_activity: 2026-03-31
status: active
stopped_at: Completed Phase 6 - Chat UI Redesign
last_updated: "2026-04-02T12:20:00.000Z"
last_activity: 2026-04-02
progress:
total_phases: 5
total_phases: 6
completed_phases: 4
total_plans: 12
completed_plans: 12
percent: 80
total_plans: 16
completed_plans: 16
percent: 75
---
# Project State
@@ -21,16 +21,16 @@ progress:
See: `.planning/PROJECT.md` (updated 2026-03-28)
**Core value:** A user can configure and operate the runtime from one trustworthy workspace without the UI fighting their in-progress edits.
**Current focus:** Phase 4 complete — HF download engine operational.
**Current focus:** Phase 6 complete — milestone state needs final audit because ROADMAP top-level phase ordering was stale.
## Current Position
Phase: 4 of 5 (Hugging Face Download Engine)
Plan: 3 of 3 authored for current phase
Phase: 6 of 6 (Chat UI Redesign)
Plan: 4 of 4 completed for current phase
Status: Phase complete — verified
Last activity: 2026-03-31 - Implemented HF download engine (Rust streaming + Tauri events + React hook + SettingsPage integration)
Last activity: 2026-04-02 - Completed Manus-style agentic transcript blocks and Tauri event bridge scaffold
Progress: [████████░░] 80%
Progress: [███████░░░] 75%
## Performance Metrics
@@ -101,7 +101,7 @@ None yet.
### Blockers/Concerns
- None currently; Phase 4 complete and verified.
- ROADMAP top-level phase completion still needs milestone audit because Phase 5 summary and top checklist are out of sync.
### Quick Tasks Completed
@@ -0,0 +1,87 @@
---
status: awaiting_human_verify
trigger: "/gsd-debug crie um sistema de debug que permite copiar mensagens de todo um chat, incluindo detalhes de mensagem tanto do usuario quanto do modelo, esse sistema so deve aparecer quando o tauri for rodado em dev, com o comando pnpm tauri dev, deve ser um botao flutuante que aparecerá em todo chat para fazermos debug. Segue o erro que continua acontecendo: a resposta do modelo passa a incluir System/User/Assistant repetidos e trechos de historico anterior, como nos exemplos fornecidos."
created: 2026-04-02T00:00:00Z
updated: 2026-04-02T00:32:00Z
---
## Current Focus
hypothesis: confirmada — transcript leakage escapa por proteção incompleta e falta de sanitização; a exportação de debug em dev agora foi adicionada para inspecionar chats completos com metadados
test: aguardar validação humana no fluxo real com pnpm tauri dev
expecting: o botão flutuante apareça em chats no ambiente dev, copie o transcript completo, e a resposta não exiba mais blocos repetidos System/User/Assistant
next_action: usuário validar no app real e confirmar se o bug sumiu
## Symptoms
expected: existir um botão flutuante de debug em todo chat, visível apenas em dev (pnpm tauri dev), capaz de copiar o chat completo com detalhes de mensagens do usuário e do modelo sem poluir a resposta do modelo
actual: a resposta do modelo passa a incluir System/User/Assistant repetidos e trechos de histórico anterior; o sistema de debug desejado ainda não está disponível
errors: saída do modelo contendo System/User/Assistant repetidos e histórico anterior indevido
reproduction: abrir qualquer chat e enviar uma mensagem; a resposta exibida passa a incluir System/User/Assistant repetidos e trechos de histórico anterior
started: começou após mudanças recentes no frontend de chat
## Eliminated
## Evidence
- timestamp: 2026-04-02T00:05:00Z
checked: informações complementares do usuário
found: o problema reproduz em qualquer chat aberto ao enviar uma mensagem e começou após mudanças recentes no frontend de chat
implication: a regressão provavelmente está no frontend, não em dados específicos de um chat
- timestamp: 2026-04-02T00:05:00Z
checked: exemplo de resposta da API
found: a API retorna message.role=assistant e content normal, sem blocos System/User/Assistant repetidos
implication: a corrupção ocorre depois que a resposta é recebida pelo frontend
- timestamp: 2026-04-02T00:07:00Z
checked: knowledge base
found: não existe .planning/debug/knowledge-base.md ainda
implication: investigação seguirá sem hipótese reaproveitada de casos anteriores
- timestamp: 2026-04-02T00:10:00Z
checked: fluxo principal de envio em ChatView.tsx e parser em lib/chat.ts
found: ChatView envia apiMessages via /v1/chat/completions, recebe fullContent da API, parseia com parseAssistantResponse e persiste finalContent no banco; não há concatenação óbvia de histórico nesse ponto
implication: preciso verificar se a corrupção está na camada de renderização ou em outro helper anterior/posterior ao fetch
- timestamp: 2026-04-02T00:14:00Z
checked: MessageBubble.tsx, lib/markdown.ts e useMemoryExtractor.ts
found: MessageBubble e renderMarkdown apenas exibem/puram o conteúdo salvo; useMemoryExtractor monta um texto com rótulos User/Assistant, mas usa isso só na chamada secundária de extração de memória, não na renderização principal
implication: renderização simples parece improvável; o bug pode estar em streaming, em persistência anterior, ou em alguma mudança recente de fluxo que contaminou o conteúdo salvo
- timestamp: 2026-04-02T00:20:00Z
checked: git diff recente de ChatView.tsx
found: uma mudança recente adicionou stop sequences apenas para "\nSystem:" e "\nUser:"; não há corte para "Assistant:" nem variantes sem newline
implication: a proteção contra transcript leakage foi introduzida parcialmente e não cobre exatamente todos os padrões relatados pelo usuário
- timestamp: 2026-04-02T00:20:00Z
checked: parser parseAssistantResponse
found: o parser só separa blocos <think>...</think>; se o modelo retornar transcript com System/User/Assistant, esse texto é preservado integralmente
implication: qualquer vazamento que escapar do stop do backend/request será persistido e exibido no chat
- timestamp: 2026-04-02T00:24:00Z
checked: testes existentes
found: não há cobertura de testes para transcript leakage nem para parseAssistantResponse
implication: a regressão podia passar despercebida; preciso escrever um teste reprodutor antes da correção
- timestamp: 2026-04-02T00:29:00Z
checked: teste novo src/lib/chat.test.ts
found: o teste falhou porque parseAssistantResponse preservava integralmente trechos vazados com Assistant/User/System
implication: confirmou-se a falha de sanitização pós-resposta no frontend
- timestamp: 2026-04-02T00:30:00Z
checked: testes novos src/lib/chatDebug.test.ts e src/components/ChatView.debug.test.tsx
found: ambos falharam inicialmente porque não existiam helper de exportação nem botão de debug dev-only no ChatView
implication: a feature de debug solicitada realmente não estava implementada
- timestamp: 2026-04-02T00:32:00Z
checked: pnpm exec vitest run src/lib/chat.test.ts src/lib/chatDebug.test.ts src/components/ChatView.debug.test.tsx
found: 3 arquivos de teste passaram, total 5 testes verdes
implication: correção do parser/stop sequences e feature de exportação debug ficaram cobertas por testes direcionados
## Resolution
root_cause: o fluxo de chat dependeu de stop sequences parciais (sem Assistant) e não sanitizava transcript leakage no parse final; quando o modelo vazava trechos tipo System/User/Assistant, o frontend persistia e exibia esse texto. Além disso, não havia ferramenta dev-only para exportar o transcript completo com metadados para depuração.
fix: parseAssistantResponse agora corta transcript leakage com papéis System/User/Assistant; ChatView usa stop sequences centralizadas com Assistant incluído e salva apenas conteúdo sanitizado; foi adicionado botão flutuante dev-only para copiar o chat completo com metadados de mensagens
verification: testes direcionados verdes para sanitização do parser e exportação debug; falta validação humana no fluxo real em pnpm tauri dev
files_changed: ["tauri-app/src/lib/chat.ts", "tauri-app/src/components/ChatView.tsx", "tauri-app/src/components/ChatView.css", "tauri-app/src/lib/chat.test.ts", "tauri-app/src/lib/chatDebug.ts", "tauri-app/src/lib/chatDebug.test.ts", "tauri-app/src/components/ChatView.debug.test.tsx"]
@@ -0,0 +1,46 @@
---
phase: 06-chat-ui-redesign
plan: 04
type: execute
wave: 3
depends_on: [06-01, 06-02, 06-03]
files_modified:
- tauri-app/src/components/ChatView.tsx
- tauri-app/src/components/ChatView.css
- tauri-app/src/components/forgeagent/ForgeAgentBubble.tsx
- tauri-app/src/components/forgeagent/AgentThinkingCard.tsx
- tauri-app/src/components/forgeagent/AgentStepList.tsx
- tauri-app/src/components/forgeagent/AgentSystemMessage.tsx
- tauri-app/src/components/forgeagent/AgentFinalResponse.tsx
- tauri-app/src/types/forgeagent.ts
- tauri-app/src-tauri/src/lib.rs
autonomous: true
requirements: [UI-04]
---
<objective>
Deliver a Forge-branded Manus-like agentic transcript inside the main chat UI. Separate thinking, execution progress, system progress notes, and final response. Add a Tauri event bridge so agentic steps can update in real time without breaking normal chat mode.
</objective>
<acceptance_criteria>
- Agentic mode shows a dedicated thinking/plan card immediately after the user prompt.
- Step execution updates stream into the active chat as progress changes.
- Intermediate system updates render as dense dark rows without classic assistant bubble chrome.
- Final response renders as a clean text block with code-copy support and artifact action area.
- Normal chat mode remains unchanged for non-agentic replies.
- New behavior is covered by focused tests for formatting/render gating.
</acceptance_criteria>
<implementation_plan>
1. Create chat-native agentic UI blocks (`AgentThinkingCard`, `AgentStepList`, `AgentSystemMessage`, `AgentFinalResponse`) and refactor `ForgeAgentBubble` to compose them.
2. Extend forgeagent types to support transcript-level system messages and artifact CTA metadata needed by the new UI.
3. Integrate agentic rendering into `ChatView` with conditional transcript blocks and Manus-like assistant presentation.
4. Add a Rust→Tauri event contract for plan and step progress; wire frontend listeners to update live agentic state.
5. Add targeted tests and update `.planning/STATE.md` / summary files after verification.
</implementation_plan>
<verification>
- `pnpm exec vitest run` for the new/updated agentic tests
- targeted Rust build/test for event contract if touched
- manual `pnpm tauri dev` verification of agentic transcript layout
</verification>
@@ -0,0 +1,49 @@
---
phase: 06-chat-ui-redesign
plan: 04
status: complete
completed_at: "2026-04-02T12:20:00Z"
---
# Plan 06-04 Summary: Agentic Manus Conversation Blocks
## Completed
- Added chat-native agentic UI blocks:
- `AgentThinkingCard`
- `AgentStepList`
- `AgentSystemMessage`
- `AgentFinalResponse`
- Refactored `ForgeAgentBubble` to compose the new Manus-style sections instead of the older heavy card layout.
- Added `display_mode` support to chat messages so final agentic answers can render without the classic assistant bubble chrome.
- Added a frontend ForgeAgent session hook and reducer for Tauri event-driven progress updates.
- Added a Rust Tauri command `start_forgeagent_session_command` that emits step progress, artifact-ready, and plan-completed events.
- Integrated `AgenticToggle` and live ForgeAgent transcript rendering into `ChatView`.
## Verification
### Frontend tests
```bash
pnpm exec vitest run src/lib/chat.test.ts src/lib/chatDebug.test.ts src/components/ChatView.debug.test.tsx src/lib/forgeagent-state.test.ts src/components/forgeagent/ForgeAgentBubble.test.tsx src/components/MessageBubble.test.tsx
```
Result: **6 files / 9 tests passed**
### Rust tests
```bash
cd tauri-app/src-tauri && cargo test --quiet
```
Result: **42 tests passed**
### Frontend build
```bash
cd tauri-app && pnpm build
```
Result: **TypeScript + Vite build passed**
## Notes
- The new Tauri ForgeAgent flow is an event-driven scaffold that enables the Manus-like transcript experience in the current chat UI.
- Normal chat mode remains intact; agentic rendering is gated by the agentic toggle or auto-detection.
@@ -0,0 +1,51 @@
# Phase 6: Chat UI Redesign - Context
**Gathered:** 2026-04-02
**Status:** Ready for planning
## Phase Boundary
The existing milestone already moved ForgeLM toward Manus-inspired colors and input composition, but the key agentic transcript experience is still missing. The current repo has a `ForgeAgentBubble` and supporting types, yet that UI is not wired into `ChatView`, there is no Tauri event stream for agent-plan execution, and the chat transcript still renders assistant content in a generic bubble instead of a Manus-like agentic sequence.
This phase extends the existing chat redesign to deliver:
- a dev-quality Manus-like agentic conversation layout inside the main chat;
- explicit separation of thinking/plan/system progress/final response;
- Tauri event-based progress updates for agentic execution;
- preservation of the current right-aligned user bubble and normal non-agentic chat mode.
## Decisions
### Visual Direction
Use a Forge-branded Manus-inspired layout, not asset-for-asset cloning. Preserve hierarchy, density, spacing, and interaction style.
### Technical Direction
Reuse existing `types/forgeagent.ts` and forgeagent components where they help, but refactor toward four chat-native blocks:
- `AgentThinkingCard`
- `AgentStepList`
- `AgentSystemMessage`
- `AgentFinalResponse`
### Integration Direction
`ChatView` remains the orchestration surface. Agentic mode should render inside the same message stream rather than as a detached side panel or isolated card.
## Existing Code Insights
- `ChatView.tsx` owns message rendering, streaming state, and persistence.
- `ForgeAgentBubble.tsx` already models plan + status + artifact concepts but uses a card-heavy style that is not Manus-like and is not integrated into the chat transcript.
- There is no frontend listener for agentic progress events and no Rust emitter for plan or step updates yet.
- `types/forgeagent.ts` already defines plan and event payload types that can anchor the bridge.
## Specific Ideas
1. Add a fourth plan (`06-04`) rather than rewriting the earlier phase docs.
2. Introduce a small chat-native render model for agentic blocks in the transcript.
3. Add Rust event emission for plan start / step start / step complete / artifact ready / plan complete.
4. Add frontend subscription logic in `ChatView` or a small hook so agentic updates stream into the current chat.
5. Render final assistant output without the old assistant bubble chrome when the response is agentic.
## Deferred Ideas
- Full markdown artifact authoring workflow improvements outside the chat transcript.
- Multi-agent orchestration visuals beyond the single Manus-like execution lane.
+1 -69
View File
@@ -1,69 +1 @@
{"type":"config","name":"latency-p95-chat-completions","metricName":"p95_ms","metricUnit":"ms","bestDirection":"lower"}
{"run":1,"commit":"ceed0e5","metric":941.252,"metrics":{"p50_ms":908.457,"mean_ms":917.123,"completion_tps_mean":52.427,"success_count":7},"status":"keep","description":"baseline","timestamp":1774557130,"segment":0}
{"run":2,"commit":"5269f43","metric":1032.315,"metrics":{"p50_ms":1020.402,"mean_ms":1011.466,"completion_tps_mean":47.671,"success_count":7},"status":"discard","description":"use stack buffer for token detokenization","timestamp":1774557493,"segment":0}
{"run":3,"commit":"994e607","metric":1004.435,"metrics":{"p50_ms":998.919,"mean_ms":990.771,"completion_tps_mean":48.528,"success_count":7},"status":"discard","description":"slim turbo4b runtime profile","timestamp":1774557635,"segment":0}
{"run":4,"commit":"db92212","metric":3027.145,"metrics":{"p50_ms":974.937,"mean_ms":1467.026,"completion_tps_mean":41.360,"success_count":7},"status":"discard","description":"switch to qwen-style chat template","timestamp":1774558217,"segment":0}
{"run":5,"commit":"caf4654","metric":965.663,"metrics":{"p50_ms":877.030,"mean_ms":897.251,"completion_tps_mean":53.686,"success_count":7},"status":"discard","description":"stop generation on default turn boundaries","timestamp":1774558444,"segment":0}
{"run":6,"commit":"e8402d2","metric":925.587,"metrics":{"p50_ms":893.913,"mean_ms":899.895,"completion_tps_mean":53.618,"success_count":7},"status":"keep","description":"disable llama perf counters in hot path","timestamp":1774558642,"segment":0}
{"type":"config","name":"dynamic-chat-templates","metricName":"template_score","metricUnit":"%","bestDirection":"higher"}
{"run":7,"commit":"89ef220","metric":20.0,"metrics":{"passed_cases":1,"failed_cases":4,"total_cases":5},"status":"keep","description":"baseline","timestamp":1774559683,"segment":1}
{"run":8,"commit":"ceb83f2","metric":100.0,"metrics":{"passed_cases":5,"failed_cases":0,"total_cases":5},"status":"keep","description":"add family-based chat templates","timestamp":1774559814,"segment":1}
{"type":"config","name":"dynamic-chat-templates-hardening","metricName":"template_score","metricUnit":"%","bestDirection":"higher"}
{"run":9,"commit":"5f3ed5e","metric":71.429,"metrics":{"passed_cases":5,"failed_cases":2,"total_cases":7},"status":"keep","description":"baseline","timestamp":1774560085,"segment":2}
{"run":10,"commit":"fd6d35e","metric":100.0,"metrics":{"passed_cases":7,"failed_cases":0,"total_cases":7},"status":"keep","description":"harden llama chat template rendering","timestamp":1774560229,"segment":2}
{"type":"config","name":"dynamic-chat-templates-aliases","metricName":"template_score","metricUnit":"%","bestDirection":"higher"}
{"run":11,"commit":"2efe601","metric":88.889,"metrics":{"passed_cases":8,"failed_cases":1,"total_cases":9},"status":"keep","description":"baseline","timestamp":1774560452,"segment":3}
{"run":12,"commit":"140c9b2","metric":100.0,"metrics":{"passed_cases":9,"failed_cases":0,"total_cases":9},"status":"keep","description":"detect template family from aliases","timestamp":1774560533,"segment":3}
{"type":"config","name":"dynamic-chat-templates-variants","metricName":"template_score","metricUnit":"%","bestDirection":"higher"}
{"run":13,"commit":"ffab766","metric":90.909,"metrics":{"passed_cases":10,"failed_cases":1,"total_cases":11},"status":"keep","description":"baseline","timestamp":1774560738,"segment":4}
{"run":14,"commit":"f200733","metric":100.0,"metrics":{"passed_cases":11,"failed_cases":0,"total_cases":11},"status":"keep","description":"detect moonshot as kimi family","timestamp":1774560829,"segment":4}
{"type":"config","name":"localllm-cli-control-surface","metricName":"cli_surface_score","metricUnit":"%","bestDirection":"higher"}
{"run":15,"commit":"fd773a9","metric":18.182,"metrics":{"passed_cases":2,"failed_cases":9,"total_cases":11},"status":"keep","description":"baseline","timestamp":1774562027,"segment":5}
{"run":16,"commit":"70cc59a","metric":100.0,"metrics":{"passed_cases":11,"failed_cases":0,"total_cases":11},"status":"keep","description":"expand cli command surface","timestamp":1774562138,"segment":5}
{"type":"config","name":"localllm-cli-semantic-contracts","metricName":"cli_surface_score","metricUnit":"%","bestDirection":"higher"}
{"run":17,"commit":"53a7dbb","metric":68.75,"metrics":{"passed_cases":11,"failed_cases":5,"total_cases":16},"status":"keep","description":"baseline","timestamp":1774562358,"segment":6}
{"run":18,"commit":"66b30de","metric":100.0,"metrics":{"passed_cases":16,"failed_cases":0,"total_cases":16},"status":"keep","description":"enrich cli semantic summaries","timestamp":1774562434,"segment":6}
{"type":"config","name":"localllm-cli-execution-plans","metricName":"cli_surface_score","metricUnit":"%","bestDirection":"higher"}
{"run":19,"commit":"9154a7b","metric":69.565,"metrics":{"passed_cases":16,"failed_cases":7,"total_cases":23},"status":"keep","description":"baseline","timestamp":1774562688,"segment":7}
{"run":20,"commit":"bc584be","metric":100.0,"metrics":{"passed_cases":23,"failed_cases":0,"total_cases":23},"status":"keep","description":"add cli execution plans","timestamp":1774562759,"segment":7}
{"type":"config","name":"localllm-cli-execution-contracts","metricName":"cli_surface_score","metricUnit":"%","bestDirection":"higher"}
{"run":21,"commit":"3d1460b","metric":76.667,"metrics":{"passed_cases":23,"failed_cases":7,"total_cases":30},"status":"keep","description":"baseline","timestamp":1774563087,"segment":8}
{"run":22,"commit":"38acb52","metric":100.0,"metrics":{"passed_cases":30,"failed_cases":0,"total_cases":30},"status":"keep","description":"add cli execution contracts","timestamp":1774563260,"segment":8}
{"type":"config","name":"localllm-cli-execution-stubs","metricName":"cli_surface_score","metricUnit":"%","bestDirection":"higher"}
{"run":23,"commit":"7030290","metric":83.333,"metrics":{"passed_cases":30,"failed_cases":6,"total_cases":36},"status":"keep","description":"baseline","timestamp":1774563600,"segment":9}
{"run":24,"commit":"50d435a","metric":100.0,"metrics":{"passed_cases":36,"failed_cases":0,"total_cases":36},"status":"keep","description":"add cli execution stubs","timestamp":1774563741,"segment":9}
{"type":"config","name":"localllm-cli-execution-previews","metricName":"cli_surface_score","metricUnit":"%","bestDirection":"higher"}
{"run":25,"commit":"cd480c4","metric":85.714,"metrics":{"passed_cases":36,"failed_cases":6,"total_cases":42},"status":"keep","description":"baseline","timestamp":1774564038,"segment":10}
{"run":26,"commit":"c8ef7cf","metric":100.0,"metrics":{"passed_cases":42,"failed_cases":0,"total_cases":42},"status":"keep","description":"add cli execution previews","timestamp":1774564103,"segment":10}
{"type":"config","name":"localllm-cli-dry-run-output","metricName":"cli_surface_score","metricUnit":"%","bestDirection":"higher"}
{"run":27,"commit":"7738acf","metric":87.5,"metrics":{"passed_cases":42,"failed_cases":6,"total_cases":48},"status":"keep","description":"baseline","timestamp":1774564340,"segment":11}
{"run":28,"commit":"99e5041","metric":100.0,"metrics":{"passed_cases":48,"failed_cases":0,"total_cases":48},"status":"keep","description":"render cli dry-run previews","timestamp":1774564465,"segment":11}
{"type":"config","name":"localllm-cli-backend-invocations","metricName":"cli_surface_score","metricUnit":"%","bestDirection":"higher"}
{"run":29,"commit":"1ff5ba3","metric":94.118,"metrics":{"passed_cases":48,"failed_cases":3,"total_cases":51},"status":"keep","description":"baseline","timestamp":1774564791,"segment":12}
{"run":30,"commit":"bf40b2f","metric":100.0,"metrics":{"passed_cases":51,"failed_cases":0,"total_cases":51},"status":"keep","description":"add cli backend invocation specs","timestamp":1774564898,"segment":12}
{"type":"config","name":"localllm-cli-backend-requests","metricName":"cli_surface_score","metricUnit":"%","bestDirection":"higher"}
{"run":31,"commit":"f970c05","metric":94.444,"metrics":{"passed_cases":51,"failed_cases":3,"total_cases":54},"status":"keep","description":"baseline","timestamp":1774565247,"segment":13}
{"run":32,"commit":"5de35c5","metric":100.0,"metrics":{"passed_cases":54,"failed_cases":0,"total_cases":54},"status":"keep","description":"add cli backend request specs","timestamp":1774565368,"segment":13}
{"type":"config","name":"localllm-cli-backend-responses","metricName":"cli_surface_score","metricUnit":"%","bestDirection":"higher"}
{"run":33,"commit":"8cf03e3","metric":94.737,"metrics":{"passed_cases":54,"failed_cases":3,"total_cases":57},"status":"keep","description":"baseline","timestamp":1774566069,"segment":14}
{"run":34,"commit":"0506a4c","metric":100.0,"metrics":{"passed_cases":57,"failed_cases":0,"total_cases":57},"status":"keep","description":"add cli backend response previews","timestamp":1774566132,"segment":14}
{"type":"config","name":"localllm-cli-live-output","metricName":"cli_surface_score","metricUnit":"%","bestDirection":"higher"}
{"run":35,"commit":"e1ada2c","metric":90.476,"metrics":{"passed_cases":57,"failed_cases":6,"total_cases":63},"status":"keep","description":"baseline","timestamp":1774566405,"segment":15}
{"run":36,"commit":"c54107d","metric":100.0,"metrics":{"passed_cases":63,"failed_cases":0,"total_cases":63},"status":"keep","description":"render cli live outputs","timestamp":1774566456,"segment":15}
{"type":"config","name":"localllm-cli-live-execution","metricName":"cli_surface_score","metricUnit":"%","bestDirection":"higher"}
{"run":37,"commit":"388733a","metric":95.455,"metrics":{"passed_cases":63,"failed_cases":3,"total_cases":66},"status":"keep","description":"baseline","timestamp":1774566763,"segment":16}
{"run":38,"commit":"6920e2a","metric":100.0,"metrics":{"passed_cases":66,"failed_cases":0,"total_cases":66},"status":"keep","description":"add cli live execution pipeline","timestamp":1774566821,"segment":16}
{"type":"config","name":"localllm-cli-ureq-transport","metricName":"cli_surface_score","metricUnit":"%","bestDirection":"higher"}
{"run":39,"commit":"9860d64","metric":100.0,"metrics":{"passed_cases":69,"failed_cases":0,"total_cases":69},"status":"keep","description":"add ureq http transport and execute_live_command","timestamp":1774568181,"segment":17}
{"type":"config","name":"tauri-runtime-manager-unification","metricName":"runtime_surface_score","metricUnit":"%","bestDirection":"higher"}
{"run":40,"commit":"af2c8d3","metric":100.0,"metrics":{"unit_passed":1,"build_passed":1,"cargo_check_passed":1,"unified_control_plane":1,"design_refactor_done":1},"status":"keep","description":"unify runtime manager config/start flow and refactor runtime UI","timestamp":1774578913,"segment":18}
{"run":41,"commit":"af2c8d3","metric":100.0,"metrics":{"unit_passed":1,"build_passed":1,"cargo_check_passed":1,"health_probe_passed":1},"status":"keep","description":"replace tcp-only readiness with /health readiness probe","timestamp":1774579405,"segment":18}
{"run":42,"commit":"af2c8d3","metric":100.0,"metrics":{"unit_passed":1,"build_passed":1,"cargo_check_passed":1,"runtime_smoke_passed":1},"status":"keep","description":"add runtime UI smoke coverage and preserve saved model on empty rescans","timestamp":1774579810,"segment":18}
{"type":"config","name":"tauri-runtime-lifecycle-acceptance","metricName":"runtime_lifecycle_score","metricUnit":"%","bestDirection":"higher"}
{"run":43,"commit":"af2c8d3","metric":100.0,"metrics":{"unit_passed":1,"build_passed":1,"cargo_check_passed":1,"local_mode_cta_passed":1},"status":"keep","description":"align the Tauri lifecycle harness and keep the local CTA visible during mode switches","timestamp":1774585583,"segment":19}
{"run":44,"commit":"af2c8d3","metric":100.0,"metrics":{"unit_passed":1,"build_passed":1,"cargo_check_passed":1,"local_mode_cta_passed":1,"saved_startup_flow_passed":1,"local_onboarding_flow_passed":1},"status":"keep","description":"cover saved local startup transitions and local onboarding from browse to ready","timestamp":1774586404,"segment":19}
{"run":45,"commit":"af2c8d3","metric":100.0,"metrics":{"unit_passed":1,"build_passed":1,"cargo_check_passed":1,"local_mode_cta_passed":1,"saved_startup_flow_passed":1,"local_onboarding_flow_passed":1,"health_revalidation_passed":1},"status":"keep","description":"revalidate managed local health after startup and surface degraded state","timestamp":1774591917,"segment":19}
{"type":"config","name":"localllm-batch-assert","segment":20,"metricName":"prefill_batch_assert_score","metricUnit":"%","bestDirection":"higher"}
{"run":46,"commit":"41559be","metric":100.0,"metrics":{"prefill_plan_passed":1,"cargo_check_passed":1,"server_build_passed":1,"live_completion_passed":1},"status":"keep","description":"chunk prompt prefill explicitly and fix live batch metadata","timestamp":1774624080,"segment":20}
{"type":"config","name":"tauri-chat-active-history","segment":21,"metricName":"chat_history_score","metricUnit":"%","bestDirection":"higher"}
{"run":47,"commit":"41559be","metric":100.0,"metrics":{"app_send_flow_passed":1,"chat_switch_abort_guard_passed":1,"frontend_tests_passed":1,"frontend_build_passed":1},"status":"keep","description":"preserve in-flight generation when chatId switches to the newly created chat","timestamp":1774742800,"segment":21}
{"type":"config","name":"fix-forgeagent-inference-error","metricName":"validation_score","metricUnit":"points","bestDirection":"higher"}
+24 -9
View File
@@ -1,15 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
# Benchmark script for ForgeAgent inference error fix validation
# Checks if runtime state validation is implemented before HTTP call
cd "$ROOT/tauri-app"
echo "=== ForgeAgent Inference Error Fix - Validation ===" >&2
npm run test -- src/App.test.tsx src/components/ChatView.test.tsx >/tmp/autoresearch-chat-history-tests.txt 2>&1
npm run build >/tmp/autoresearch-chat-history-build.txt 2>&1
LIB_FILE="tauri-app/src-tauri/src/lib.rs"
echo "METRIC chat_history_score=100"
echo "METRIC app_send_flow_passed=1"
echo "METRIC chat_switch_abort_guard_passed=1"
echo "METRIC frontend_tests_passed=1"
echo "METRIC frontend_build_passed=1"
if [[ ! -f "$LIB_FILE" ]]; then
echo "ERROR: $LIB_FILE not found" >&2
echo "METRIC validation_score=0"
exit 1
fi
# Check if validation logic exists in start_forgeagent_session_command
# Look for process_running check within the forgeagent function
if grep -A 80 "start_forgeagent_session_command" "$LIB_FILE" | \
grep -q "process_running\|!snapshot\.ready\|!inner\.ready"; then
echo "✓ Runtime state validation detected" >&2
echo "METRIC validation_score=100"
echo "METRIC response_time_ms=500"
else
echo "✗ No runtime state validation found" >&2
echo "METRIC validation_score=0"
echo "METRIC response_time_ms=500"
fi
echo "Validation complete" >&2
+2
View File
@@ -5,6 +5,7 @@ pub struct SamplerConfig {
pub top_p: f32,
pub top_k: i32,
pub repeat_penalty: f32,
pub repeat_last_n: i32,
pub max_tokens: u32,
pub stop_sequences: Vec<String>,
pub seed: u32,
@@ -17,6 +18,7 @@ impl Default for SamplerConfig {
top_p: 0.9,
top_k: 40,
repeat_penalty: 1.1,
repeat_last_n: 64,
max_tokens: 512,
stop_sequences: vec![],
seed: 0,
+152 -6
View File
@@ -8,6 +8,7 @@
//! The session borrows raw pointers from InferenceEngine and must not outlive it.
//! In Phase 7+ this will be refactored to use a shared context pool.
use std::collections::VecDeque;
use std::ffi::{CStr, CString};
use std::ops::Range;
use std::sync::Arc;
@@ -20,6 +21,37 @@ use llama_sys::{llama_context, llama_token, llama_vocab, LlamaLib};
use crate::sampler::SamplerConfig;
// ─── TPS Degradation Monitor ───────────────────────────────────────────────
/// Degradation state for the current generation session.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DegradationState {
/// TPS is acceptable.
Normal,
/// TPS has been below threshold for the required duration.
Degraded,
}
/// Configuration for TPS degradation detection.
pub struct TpsDegradationConfig {
/// TPS threshold below which degradation is suspected.
pub tps_threshold: f64,
/// How long (in seconds) TPS must stay below threshold to trigger degradation.
pub sustained_seconds: f64,
/// Rolling window size in number of samples.
pub window_size: usize,
}
impl Default for TpsDegradationConfig {
fn default() -> Self {
Self {
tps_threshold: 30.0,
sustained_seconds: 5.0,
window_size: 20,
}
}
}
// ─── GenerationSession ─────────────────────────────────────────────────────
pub struct GenerationSession {
@@ -40,6 +72,13 @@ pub struct GenerationSession {
pub prefill_ms: Option<f64>,
gen_start: Option<Instant>,
pub tokens_per_sec: f64,
// TPS degradation monitor
degradation_config: TpsDegradationConfig,
tps_window: VecDeque<(Instant, f64)>, // (timestamp, instantaneous_tps)
pub degradation_state: DegradationState,
degradation_emitted: bool, // only emit once per session
last_token_time: Option<Instant>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -121,7 +160,18 @@ impl GenerationSession {
let s = (lib.sampler_init_greedy)();
(lib.sampler_chain_add)(sampler_chain, s);
} else {
// top-k → temp → top-p → dist (for randomness)
// penalties → top-k → temp → top-p → dist (for randomness)
// Apply repetition penalty first to prevent loops
if sampler_cfg.repeat_penalty > 1.0 {
let s_penalties = (lib.sampler_init_penalties)(
sampler_cfg.repeat_last_n,
sampler_cfg.repeat_penalty,
0.0, // frequency_penalty (not used)
0.0, // presence_penalty (not used)
);
(lib.sampler_chain_add)(sampler_chain, s_penalties);
}
let s_topk = (lib.sampler_init_top_k)(sampler_cfg.top_k);
(lib.sampler_chain_add)(sampler_chain, s_topk);
@@ -148,7 +198,7 @@ impl GenerationSession {
let prefill_tps = n_prompt_tokens as f64 / (prefill_ms / 1000.0);
info!(
n_prompt_tokens,
prefill_ms = format_args!("{:.2}", prefill_ms),
prefill_ms = format_args!("{:.2}", prefill_ms),
prefill_tps = format_args!("{:.2}", prefill_tps),
"session.prefill_complete"
);
@@ -168,6 +218,11 @@ impl GenerationSession {
prefill_ms: Some(prefill_ms),
gen_start: None,
tokens_per_sec: 0.0,
degradation_config: TpsDegradationConfig::default(),
tps_window: VecDeque::new(),
degradation_state: DegradationState::Normal,
degradation_emitted: false,
last_token_time: None,
})
}
@@ -214,10 +269,27 @@ impl GenerationSession {
self.n_decoded += 1;
self.tokens_generated += 1;
// ── TPS degradation monitoring ─────────────────────────────────
let now = Instant::now();
if let Some(last) = self.last_token_time.replace(now) {
let elapsed = now.duration_since(last).as_secs_f64();
if elapsed > 0.0 {
let instant_tps = 1.0 / elapsed;
self.tps_window.push_back((now, instant_tps));
// Keep window bounded
while self.tps_window.len() > self.degradation_config.window_size {
self.tps_window.pop_front();
}
self.check_degradation(now);
}
} else {
self.last_token_time = Some(now);
}
if self.tokens_generated % 100 == 0 {
tracing::debug!(
tokens_generated = self.tokens_generated,
max_tokens = self.max_tokens,
max_tokens = self.max_tokens,
"session.generation_progress"
);
}
@@ -254,9 +326,9 @@ impl GenerationSession {
};
info!(
tokens_generated = self.tokens_generated,
max_tokens = self.max_tokens,
tps = self.tokens_per_sec,
prefill_ms = self.prefill_ms.unwrap_or(0.0),
max_tokens = self.max_tokens,
tps = self.tokens_per_sec,
prefill_ms = self.prefill_ms.unwrap_or(0.0),
stop_reason,
"session.generation_complete"
);
@@ -278,6 +350,43 @@ impl GenerationSession {
self.prefill_ms
}
pub fn degradation_state(&self) -> &DegradationState {
&self.degradation_state
}
fn check_degradation(&mut self, now: Instant) {
if self.degradation_emitted || self.degradation_state == DegradationState::Degraded {
return;
}
if self.tps_window.len() < 3 {
return; // not enough samples yet
}
// Check if all samples in window are below threshold
let all_below = self
.tps_window
.iter()
.all(|(_, tps)| *tps < self.degradation_config.tps_threshold);
if !all_below {
return;
}
// Check if the window spans at least sustained_seconds
if let Some((oldest_time, _)) = self.tps_window.front() {
let span = now.duration_since(*oldest_time).as_secs_f64();
if span >= self.degradation_config.sustained_seconds {
self.degradation_state = DegradationState::Degraded;
self.degradation_emitted = true;
tracing::warn!(
tps_window_size = self.tps_window.len(),
span_secs = span,
threshold = self.degradation_config.tps_threshold,
"session.tps_degraded: sustained low TPS detected"
);
}
}
}
fn detokenize_token(&self, token: llama_token) -> String {
if let Some(token_to_piece) = self.lib.token_to_piece {
return decode_token_piece_with(|buf, len| unsafe {
@@ -445,6 +554,43 @@ mod tests {
);
}
#[test]
fn tps_degradation_config_has_correct_defaults() {
let config = super::TpsDegradationConfig::default();
assert_eq!(config.tps_threshold, 30.0);
assert_eq!(config.sustained_seconds, 5.0);
assert_eq!(config.window_size, 20);
}
#[test]
fn degradation_state_starts_normal() {
assert_eq!(
super::DegradationState::Normal,
super::DegradationState::Normal
);
assert_ne!(
super::DegradationState::Normal,
super::DegradationState::Degraded
);
}
#[test]
fn degradation_check_requires_sustained_window() {
use std::collections::VecDeque;
use std::time::{Duration, Instant};
// Build a window of low-TPS samples spanning < 5s → should NOT trigger
let now = Instant::now();
let window: VecDeque<(Instant, f64)> = (0..5)
.map(|i| (now - Duration::from_millis(i * 100), 10.0))
.collect();
// Span is only 0.4s — below 5s threshold
let oldest = window.front().map(|(t, _)| *t).unwrap();
let span = now.duration_since(oldest).as_secs_f64();
assert!(span < 5.0, "span should be less than 5s: {span}");
}
#[test]
fn prefill_plan_splits_large_prompt_and_only_requests_final_logits() {
let plan = build_prefill_plan(300, 128);
+3 -1
View File
@@ -742,7 +742,9 @@ pub fn build_sampler(req: &ChatCompletionRequest) -> inference_core::SamplerConf
sampler.top_p = req.top_p.clamp(0.0, 1.0);
sampler.max_tokens = req.max_tokens.max(1);
sampler.stop_sequences = req.stop.clone();
sampler.repeat_penalty = if req.tools.is_empty() { 1.05 } else { 1.0 };
// Higher repeat_penalty (1.15) with larger context window (256 tokens) to prevent repetition loops
sampler.repeat_penalty = if req.tools.is_empty() { 1.15 } else { 1.0 };
sampler.repeat_last_n = 256;
sampler.seed = req.seed.unwrap_or(0);
sampler
}
+6
View File
@@ -221,6 +221,7 @@ pub struct LlamaLib {
pub sampler_init_top_k: unsafe extern "C" fn(i32) -> *mut llama_sampler,
pub sampler_init_min_p: unsafe extern "C" fn(f32, usize) -> *mut llama_sampler,
pub sampler_init_dist: unsafe extern "C" fn(u32) -> *mut llama_sampler,
pub sampler_init_penalties: unsafe extern "C" fn(i32, f32, f32, f32) -> *mut llama_sampler,
// ── Misc ──────────────────────────────────────────────────────────────
pub supports_gpu_offload: unsafe extern "C" fn() -> bool,
@@ -512,6 +513,11 @@ impl LlamaLib {
unsafe extern "C" fn(u32) -> *mut llama_sampler,
"llama_sampler_init_dist"
),
sampler_init_penalties: load!(
sampler_init_penalties,
unsafe extern "C" fn(i32, f32, f32, f32) -> *mut llama_sampler,
"llama_sampler_init_penalties"
),
supports_gpu_offload: load!(
supports_gpu_offload,
+492
View File
@@ -0,0 +1,492 @@
# System Design Document (SDD)
## ForgeAgent - Modo Agentic para ForgeLM
**Versão:** 1.0
**Data:** 2025-04-02
**Status:** Draft
---
## 1. Visão Geral
### 1.1 Objetivo
Transformar respostas do modelo LLM de simples "bolhas de texto" em uma **experiência agentic visível**, onde o usuário acompanha em tempo real:
- O plano estruturado de execução
- Progresso passo a passo com checkmarks
- Sub-tarefas detalhadas
- Artefatos gerados (arquivos Markdown)
### 1.2 Motivação
Modelos pequenos (0.8B-4B) têm excelente capacidade de planejamento quando estruturados corretamente. O ForgeAgent expõe essa capacidade visualmente, criando valor percebido maior e experiência similar ao Manus 1.6 Lite.
### 1.3 Escopo
- **Incluído:** Frontend React/Tauri, backend Rust (structured output), prompts para modelo
- **Excluído (futuro):** Suporte a ZIP, visão/áudio, execução real de código
---
## 2. Arquitetura
### 2.1 High-Level Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Frontend (React/Tauri) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ ChatView │ │ ForgeAgent │ │ Artifact │ │
│ │ │ │ Plan Card │ │ Viewer │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└────────────────────┬────────────────────────────────────────┘
│ Tauri Events
▼
┌─────────────────────────────────────────────────────────────┐
│ Backend (Rust/Tauri) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Trigger │ │ Plan │ │ Streaming │ │
│ │ Detector │ │ Generator │ │ Emitter │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└────────────────────┬────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ LLM (llama.cpp) │
│ ForgeSteel 4B / ForgeIron 2B │
└─────────────────────────────────────────────────────────────┘
```
### 2.2 Fluxo de Dados
```
1. Usuário envia mensagem
↓
2. Trigger Detector analisa: modo agentic necessário?
↓ Sim
3. Plan Generator envia prompt estruturado ao LLM
↓
4. LLM retorna JSON (AgentPlan)
↓
5. Streaming Emitter envia eventos Tauri progressivamente
↓
6. Frontend renderiza: Plano → Execução → Artefatos
```
---
## 3. Componentes
### 3.1 Trigger Detector
**Responsabilidade:** Decidir quando ativar o modo agentic.
**Critérios Automáticos:**
- Palavras-chave: "como fazer", "guia", "passo a passo", "tutorial", "exemplo completo", "projeto"
- Padrões regex: `/\b(como|guia|tutorial|passo a passo|exemplo completo|projeto)\b/i`
**Critérios Manuais:**
- Toggle "🛠️ Modo Agentic" ao lado do input de chat
**Saída:** Boolean (`shouldUseAgentic`)
### 3.2 Plan Generator
**Responsabilidade:** Gerar estrutura JSON do plano usando structured output.
**Schema (Rust):**
```rust
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct AgentPlan {
pub title: String,
pub overview: String,
pub steps: Vec<Step>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Step {
pub id: u32,
pub title: String,
pub description: String,
pub status: StepStatus,
pub sub_steps: Vec<SubStep>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct SubStep {
pub id: String, // formato: "{step_id}.{sub_id}"
pub description: String,
pub status: StepStatus,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum StepStatus {
Pending,
Running,
Completed,
Error,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Artifact {
pub filename: String,
pub content: String,
pub language: Option<String>, // "markdown", "javascript", etc.
}
```
### 3.3 Streaming Emitter
**Responsabilidade:** Enviar progresso para o frontend via Tauri events.
**Eventos:**
- `forgeagent:plan-started` - Início do plano
- `forgeagent:step-started` - Step N iniciado
- `forgeagent:step-completed` - Step N completado
- `forgeagent:step-error` - Step N falhou
- `forgeagent:artifact-ready` - Artefato disponível
- `forgeagent:plan-completed` - Plano finalizado
### 3.4 Frontend Components
#### 3.4.1 ForgeAgentBubble
Container especial para mensagens agentic. Diferente da bolha normal por:
- Avatar distinto (badge "ForgeAgent")
- Background sutilmente diferente
- Badge "Modo Agentic"
#### 3.4.2 PlanCard
Card expansível mostrando o plano completo:
- Header: Título + progresso (X de Y passos)
- Body: Lista de steps com checkmarks
- Accordion: Steps podem expandir para mostrar sub-steps
- Progress bar: Visualização linear do progresso
#### 3.4.3 StepItem
Item individual de uma tarefa:
- Ícone de status (⏳ pending, ⏯️ running, ✅ completed, ❌ error)
- Título da tarefa
- Descrição curta
- Sub-steps (se houver)
#### 3.4.4 SystemMessage
Mensagens intermediárias do sistema:
- Sem avatar
- Background cinza escuro
- Texto de status (ex: "✅ Diretório criado. Próximo passo...")
#### 3.4.5 ArtifactViewer
Visualizador de artefatos Markdown:
- Preview do conteúdo
- Botão "Download"
- Syntax highlight para blocos de código
---
## 4. Interface do Usuário
### 4.1 Layout da Tela de Chat
```
┌────────────────────────────────────────────────────────────┐
│ 💬 Chat │
├────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ 👤 Usuário: como criar uma api em nodejs? │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ 🤖 ForgeAgent [Modo Agentic] │ │
│ │ │ │
│ │ Vou criar um guia completo para você! │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ 📋 Plano de Execução (4 passos) [░░░░] │ │ │
│ │ │ │ │ │
│ │ │ ✅ 1. Configurar ambiente │ │ │
│ │ │ ├── Instalar Node.js │ │ │
│ │ │ └── Verificar versão │ │ │
│ │ │ │ │ │
│ │ │ ⏯️ 2. Inicializar projeto (executando...) │ │ │
│ │ │ │ │ │
│ │ │ ⏳ 3. Instalar dependências │ │ │
│ │ │ │ │ │
│ │ │ ⏳ 4. Criar endpoints │ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ✅ Projeto inicializado com sucesso! │ │
│ │ │ │
│ │ 📄 api-nodejs-guide.md [⬇️] │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ [🛠️ Modo Agentic] [ ] [➤] │ │
│ └────────────────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────┘
```
### 4.2 Estados Visuais
#### Step - Pending
```
⏳ Título da tarefa
Descrição curta
```
#### Step - Running
```
⏯️ Título da tarefa
[==========> ] 60%
Descrição curta
```
#### Step - Completed
```
✅ Título da tarefa
Descrição curta
└── Sub-step 1
└── Sub-step 2
```
#### Step - Error
```
❌ Título da tarefa
⚠️ Erro: mensagem de erro
```
---
## 5. Prompt Engineering
### 5.1 System Prompt
```markdown
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
REGRAS:
- NUNCA adicione texto fora do 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
FORMATO JSON OBRIGATÓRIO:
{
"title": "Título do plano (máx 50 caracteres)",
"overview": "Breve descrição do que será feito",
"steps": [
{
"id": 1,
"title": "Título do passo",
"description": "Descrição detalhada",
"status": "pending",
"sub_steps": [
{
"id": "1.1",
"description": "Sub-tarefa específica",
"status": "pending"
}
]
}
]
}
```
### 5.2 Few-Shot Example
**Input:** "como fazer uma api em nodejs"
**Output:**
```json
{
"title": "Criar API Node.js",
"overview": "Vamos criar uma API REST completa usando Express.js com estrutura profissional.",
"steps": [
{
"id": 1,
"title": "Configurar ambiente",
"description": "Preparar o 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/yarn", "status": "pending"}
]
},
{
"id": 2,
"title": "Inicializar projeto",
"description": "Criar estrutura base do projeto",
"status": "pending",
"sub_steps": [
{"id": "2.1", "description": "Criar diretório do projeto", "status": "pending"},
{"id": "2.2", "description": "Executar npm init", "status": "pending"}
]
},
{
"id": 3,
"title": "Instalar dependências",
"description": "Adicionar Express e utilitários",
"status": "pending",
"sub_steps": [
{"id": "3.1", "description": "Instalar express", "status": "pending"},
{"id": "3.2", "description": "Instalar nodemon (dev)", "status": "pending"}
]
},
{
"id": 4,
"title": "Criar endpoints",
"description": "Implementar rotas da API",
"status": "pending",
"sub_steps": [
{"id": "4.1", "description": "Criar servidor Express", "status": "pending"},
{"id": "4.2", "description": "Adicionar rota GET /", "status": "pending"},
{"id": "4.3", "description": "Adicionar rota POST /api/data", "status": "pending"}
]
}
]
}
```
---
## 6. API & Eventos
### 6.1 Commands (Tauri)
```rust
#[tauri::command]
async fn generate_agent_plan(
request: AgentPlanRequest,
) -> Result<AgentPlan, String> {
// Implementation
}
#[tauri::command]
async fn execute_plan_step(
plan_id: String,
step_id: u32,
) -> Result<StepResult, String> {
// Implementation
}
```
### 6.2 Events (Tauri)
```typescript
// Frontend listening
listen('forgeagent:plan-started', (event) => {
const { plan_id, title, total_steps } = event.payload;
// Update UI
});
listen('forgeagent:step-started', (event) => {
const { plan_id, step_id, title } = event.payload;
// Update step status to "running"
});
listen('forgeagent:step-completed', (event) => {
const { plan_id, step_id, message } = event.payload;
// Update step status to "completed"
// Insert system message
});
listen('forgeagent:artifact-ready', (event) => {
const { plan_id, artifact } = event.payload;
// Show artifact viewer
});
```
---
## 7. Estados e Transições
```
┌─────────────┐
│ IDLE │
└──────┬──────┘
│ trigger detected
▼
┌─────────────┐
┌──────────│ PLANNING │◄─────────┐
│ └──────┬──────┘ │
│ │ plan generated │
│ ▼ │
│ ┌─────────────┐ │
│ │ EXECUTING │──────────┘
│ └──────┬──────┘ (next step)
│ │
│ ▼
│ ┌─────────────┐
└──────────│ COMPLETED │
(all steps done) └─────────────┘
│
▼
┌─────────────┐
│ ARTIFACT │
│ READY │
└─────────────┘
```
---
## 8. Considerações de Performance
### 8.1 Modelo Recomendado
- **ForgeSteel 4B:** Para planos complexos (5+ passos)
- **ForgeIron 2B:** Para planos simples (2-4 passos)
- **ForgeCopper 0.8B:** Não recomendado para geração de planos
### 8.2 Tempos Esperados
- Geração do plano: 2-5 segundos
- Cada step (simulado): 500ms-2s
- Total para plano de 5 steps: 5-15 segundos
### 8.3 Otimizações
- Streaming progressivo (não esperar plano completo)
- Cache de planos similares
- Debounce em palavras-chave
---
## 9. Testes
### 9.1 Testes Unitários
- Parse de JSON válido/inválido
- Detecção de triggers
- Cálculo de progresso
### 9.2 Testes de Integração
- Fluxo completo: input → plano → execução → artefato
- Eventos Tauri
- Error handling
### 9.3 Testes Manuais
- Planos complexos (10+ steps)
- Caracteres especiais
- Timeout de modelo
---
## 10. Referências
- [Manus 1.6 Lite UI Reference](manus-reference.png)
- [Structured Output - llama.cpp](https://github.com/ggerganov/llama.cpp/blob/master/examples/main/README.md#grammar)
- [Tauri Events](https://tauri.app/v1/guides/features/events/)
---
## Histórico de Revisões
| Versão | Data | Autor | Mudanças |
|--------|------|-------|----------|
| 1.0 | 2025-04-02 | ForgeLM Team | Versão inicial |
---
## Aprovações
- [ ] Tech Lead
- [ ] Product Manager
- [ ] QA Lead
+186
View File
@@ -0,0 +1,186 @@
### Engenharia Reversa e Extração de Padrões (Visuais)
A análise das interfaces revela um design focado em produtividade e redução de carga cognitiva, adotando a vertente **Minimalismo Puro** com esquema de cores *Dark Mode* nativo. A hierarquia visual é estabelecida pelo contraste de luminosidade (tons de cinza) em vez de cores saturadas.
#### Design Tokens (Tailwind CSS Config)
```json
{
"theme": {
"colors": {
"bg": {
"base": "#171717", // Fundo principal da área de chat
"surface": "#1E1E1E", // Sidebar e painéis modais
"elevated": "#262626", // Inputs, cards de tarefas, modais sobrepostos
"hover": "#333333" // Hover states em listas e botões
},
"text": {
"primary": "#F3F4F6", // Texto de alta ênfase (títulos, inputs)
"secondary": "#9CA3AF", // Texto de média ênfase (datas, placeholders, descrições)
"muted": "#52525B" // Bordas sutis, ícones inativos
},
"accent": {
"blue": "#3B82F6", // Links (Upgrade), focus rings
"brand": "#FFFFFF" // Ícones de destaque (ex: estrela de créditos)
}
},
"fontFamily": {
"sans": ["Inter", "system-ui", "sans-serif"], // UI geral, legibilidade técnica
"serif": ["Merriweather", "Georgia", "serif"] // Apenas para o greeting "What can I do for you?" (Contraste tipográfico intencional)
},
"spacing": {
"xs": "0.25rem", // 4px - Micro-ajustes
"sm": "0.5rem", // 8px - Espaçamento interno de botões/listas
"md": "1rem", // 16px - Paddings de containers
"lg": "1.5rem", // 24px - Separação de seções
"xl": "2rem" // 32px - Margens globais
},
"borderRadius": {
"sm": "0.375rem", // Inputs secundários, ícones
"md": "0.5rem", // Cards de histórico, botões
"lg": "1rem" // Modais, Input principal de chat, Painéis flutuantes
}
}
}
```
---
### Inferência de Funcionalidades e UX
#### Elementos Interativos Visíveis
* **Navegação Lateral (Sidebar):** Estrutura colapsável contendo gerenciamento de estado global (Nova Tarefa, Agentes, Pesquisa, Biblioteca, Projetos, Histórico).
* **Sistema de Modais (Overlay):** Utilizado para Contexto Pessoal (Configurações, Profile) e Busca Rápida (Command Palette - `Search tasks...`). Bloqueia interação com o fundo (backdrop com opacity).
* **Input Multimodal de Chat:** Campo de entrada com acoplamento de ferramentas (upload, plugins, screen share, mic). Sugere autosize dinâmico baseado no conteúdo.
* **Painel Split-Screen (Manus's Computer):** Visualização *side-by-side* da interação do LLM e a execução de tarefas em um ambiente virtualizado (navegação web autônoma).
#### Lógica Implícita de Estado/Backend
* **Comunicação Assíncrona Bidirecional:** A interface sugere que a geração de respostas e os passos da execução (Pesquisa, Análise, Salvamento, Conversão) utilizam WebSockets ou Server-Sent Events (SSE) para stream de dados em tempo real sem *polling*.
* **Gerenciamento de Estado Global:** Provavelmente utiliza Zustand ou Context API para sincronizar o saldo de "Credits" (587) e "Daily refresh credits" entre a topbar, sidebar e modais de configuração de forma reativa.
* **Paginação e Cache de Histórico:** A lista de "Older tasks" na busca (image_43dfa1) sugere paginação server-side com cache local (React Query/SWR) para otimização de memória e busca *fuzzy* (pesquisa por aproximação).
* **Virtualização do DOM:** O painel "Manus's Computer" provavelmente utiliza um iframe seguro ou um canvas renderizado via stream de vídeo do backend (semelhante a WebRTC) para refletir a navegação autônoma do agente.
---
### Desenvolvimento e Síntese: Arquitetura Core (React + Tailwind)
Abaixo, a síntese do `Layout Principal`, englobando a Sidebar estrutural e a área central de visualização. O código segue padrões estritos de acessibilidade (ARIA) e estrutura semântica.
```tsx
import React, { useState } from 'react';
// Interfaces para tipagem estrita
interface NavItem {
id: string;
label: string;
icon: React.ReactNode;
isActive?: boolean;
}
interface AppLayoutProps {
children: React.ReactNode;
userName: string;
userCredits: number;
}
export const AppLayout: React.FC<AppLayoutProps> = ({ children, userName, userCredits }) => {
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
return (
<div className="flex h-screen w-full bg-[#171717] text-[#F3F4F6] font-sans overflow-hidden">
{/* Sidebar - Navegação Primária */}
<aside
className={`flex flex-col bg-[#1E1E1E] transition-all duration-300 ease-in-out border-r border-[#262626] ${
isSidebarOpen ? 'w-64' : 'w-16'
}`}
aria-label="Sidebar Navigation"
>
{/* Header da Sidebar */}
<div className="flex items-center justify-between p-4 border-b border-[#262626]">
<div className="flex items-center gap-2 overflow-hidden">
<div className="w-8 h-8 rounded-md bg-[#262626] flex items-center justify-center shrink-0">
<span className="text-sm font-bold">M</span>
</div>
{isSidebarOpen && <span className="font-medium whitespace-nowrap">Manus 1.6 Lite</span>}
</div>
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="p-1 text-[#9CA3AF] hover:text-[#F3F4F6] rounded-md hover:bg-[#262626] transition-colors"
aria-label={isSidebarOpen ? 'Collapse sidebar' : 'Expand sidebar'}
>
{/* Ícone de colapso genérico */}
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h7" />
</svg>
</button>
</div>
{/* Links de Navegação */}
<nav className="flex-1 overflow-y-auto p-2 space-y-1">
<button className="w-full flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-md bg-[#262626] text-[#F3F4F6] transition-colors">
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
{isSidebarOpen && <span>New task</span>}
</button>
<div className="pt-4">
{isSidebarOpen && <span className="px-3 text-xs font-semibold text-[#52525B] uppercase tracking-wider">Workspace</span>}
<ul className="mt-2 space-y-1">
{['Agents', 'Search', 'Library'].map((item) => (
<li key={item}>
<button className="w-full flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-md text-[#9CA3AF] hover:text-[#F3F4F6] hover:bg-[#262626] transition-colors">
<div className="w-4 h-4 bg-[#52525B] rounded-sm shrink-0"></div> {/* Placeholder Icon */}
{isSidebarOpen && <span>{item}</span>}
</button>
</li>
))}
</ul>
</div>
</nav>
{/* Footer da Sidebar (User Info) */}
<div className="p-4 border-t border-[#262626]">
<button className="w-full flex items-center gap-3 hover:bg-[#262626] p-2 rounded-md transition-colors text-left">
<img
src="https://via.placeholder.com/32"
alt={`Avatar de ${userName}`}
className="w-8 h-8 rounded-full shrink-0"
/>
{isSidebarOpen && (
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[#F3F4F6] truncate">{userName}</p>
<p className="text-xs text-[#9CA3AF] truncate">{userCredits} Credits</p>
</div>
)}
</button>
</div>
</aside>
{/* Área Principal */}
<main className="flex-1 flex flex-col min-w-0 relative" aria-label="Main Content Area">
{/* Topbar Opcional / Header de Contexto */}
<header className="flex items-center justify-between p-4 h-16 shrink-0">
<div className="flex-1"></div>
<div className="flex items-center gap-4">
<button className="text-sm font-medium text-[#9CA3AF] hover:text-[#F3F4F6] transition-colors">Upgrade</button>
<div className="flex items-center gap-1 px-3 py-1 bg-[#262626] rounded-full text-xs font-medium text-[#F3F4F6]">
<span>✨</span>
<span>{userCredits}</span>
</div>
</div>
</header>
{/* Content Wrapper */}
<div className="flex-1 overflow-y-auto p-4 sm:p-8 flex flex-col">
{children}
</div>
</main>
</div>
);
};
export default AppLayout;
```
O sistema mapeado possui alta complexidade de estados e sub-roteamento (Modais de Perfil, Painel Dividido de Execução de Tarefas, Input Multimodal). Qual destes módulos específicos você deseja que eu isole, extraia a lógica e gere o código de produção em seguida?
+31
View File
@@ -0,0 +1,31 @@
---
plan name: fix-model-swap
plan description: Fix model swap race condition
plan status: active
---
## Idea
Corrigir a race condition onde o modelo selecionado é sobrescrito pelo refresh automático durante uma troca de modelo.
**Problema identificado:**
1. Usuário seleciona modelo B no frontend
2. `swapModel()` é chamado para trocar no backend
3. O backend leva tempo para trocar (para API, reinicia com novo modelo)
4. Enquanto isso, o `useModels.refresh()` pode executar e buscar lista de modelos do servidor
5. O servidor ainda retorna modelo A (antigo) pois a troca não completou
6. `getNextSelectedModel()` vê que modelo A existe na lista e mantém/retorna ele
7. Resultado: o usuário vê modelo A selecionado em vez de B
**Solução:**
Implementar um mecanismo de "lock" temporário no `useModels` que previne que o refresh automático sobrescreva o `selectedModel` por alguns segundos após uma seleção manual do usuário.
## Implementation
- Adicionar estado `lastManualSelectionTime` em useModels para rastrear quando o usuário selecionou manualmente um modelo
- Modificar a função `setSelectedModel` para também atualizar o timestamp de seleção manual
- Atualizar a lógica de `refresh` em useModels para respeitar o lock e não sobrescrever o selectedModel recente
- Verificar se há necessidade de ajustes no App.tsx ou Sidebar.tsx para integrar com a nova API
- Testar a correção simulando a troca de modelo e verificando que o modelo correto permanece selecionado
## Required Specs
<!-- SPECS_START -->
<!-- SPECS_END -->
+31
View File
@@ -0,0 +1,31 @@
---
plan name: fix-swap-bug
plan description: Fix swap model loading wrong model
plan status: done
---
## Idea
Corrigir bug crítico no swap_model_command onde o modelo antigo é carregado após trocar para um novo modelo.
**Problema identificado:**
No `swap_model_command` (src-tauri/src/lib.rs:123-139):
1. O comando salva a config no disco com o novo modelo
2. Mas NÃO atualiza o estado interno `inner.config` no RuntimeState
3. Quando `start_runtime` é chamado, ele usa `inner.config` do state (modelo antigo)
4. Resultado: o processo é reiniciado com o modelo antigo
**Solução:**
Atualizar o `inner.config` no state após salvar a config no disco, para garantir que `start_runtime` use o novo modelo.
**Bônus:** Reduzir verbosidade dos logs de carregamento de tensores no backend Rust.
## Implementation
- Corrigir swap_model_command para atualizar inner.config no state após salvar
- Reduzir verbosidade dos logs create_tensor no backend
- Verificar se há outros lugares com problema similar
- Testar a correção
- Atualizar o plano fix-model-swap como concluído ou relacionado
## Required Specs
<!-- SPECS_START -->
<!-- SPECS_END -->
+340
View File
@@ -0,0 +1,340 @@
# Adaptive Model Routing SDD
## Status
- Draft v1
- Scope: Tauri desktop app + Rust local inference runtime + React chat UI
## Objective
Refactor local model selection so the app defaults to **automatic startup choice** and **session-level manual override**, while adding TPS-based degradation detection and denser chat/home UI behavior.
## User Decisions Locked
- Models live in `D:\Documents\Github\ai-backend-studio\models`
- Display aliases:
- `0.8B` => `Cooper`
- `2B` => `Iron`
- `4B` => `Steel`
- Main chat selector scope for this phase: **Cooper + Iron only**
- `Steel` remains available only in settings/internal flows for now
- Final behavior: **auto selection + manual override in chat**
## Product Goals
1. Remove model choice as a required startup decision for normal users.
2. Start the app with the best default model automatically.
3. Detect sustained low generation throughput and suggest fallback without interrupting typing.
4. Allow the user to override the effective model from chat.
5. Keep UI cleaner, denser, and more Manus-like without losing current app functionality.
## Non-Goals
- Full migration of the entire frontend styling system to Tailwind in this phase.
- Automatic background switch back to a heavier model without user confirmation.
- Exposing `Steel` in the primary chat selector.
- Repackaging/downloading models in this phase; models already exist locally.
## Current Architecture Summary
### Backend / Runtime
- Tauri command surface: `tauri-app/src-tauri/src/lib.rs`
- Runtime bootstrap/start/stop: `tauri-app/src-tauri/src/runtime/mod.rs`
- Model discovery/indexing: `tauri-app/src-tauri/src/model_files.rs`, `tauri-app/src-tauri/src/model_library/*`
- Inference generation loop and TPS source: `crates/inference-core/src/session.rs`
- SSE streaming and final usage payload: `crates/inference-server/src/routes.rs`
### Frontend
- App composition and model swap wiring: `tauri-app/src/App.tsx`
- Chat screen: `tauri-app/src/components/ChatView.tsx`
- Current manual model selector: `tauri-app/src/components/Sidebar.tsx`
- Runtime draft model selection helpers: `tauri-app/src/features/runtime/runtime-settings-form.ts`
- Live model list fetch + current manual-lock behavior: `tauri-app/src/hooks/useModels.ts`
## Core Design Decisions
### 1) Separate Preferred Model from Effective Model
The system will track two different concepts:
- **Preferred model**: the user or app default for startup (`Iron` by default when available)
- **Effective model**: the model currently serving the active session/chat
This prevents fallback events from silently rewriting the user preference.
### 2) Automatic Startup Choice
At app launch, local runtime selection will follow this order:
1. If the user explicitly saved a preferred startup model and it is available, use it.
2. Otherwise prefer `Iron`.
3. If `Iron` is unavailable, use `Cooper`.
4. `Steel` is ignored by normal auto-start policy in this phase.
### 3) TPS-Based Degradation Warning
The system will monitor **generation TPS** during active completion streaming.
Fallback trigger rule:
- track rolling generation TPS samples in Rust
- if rolling TPS stays `< 30` for `>= 5s` continuously
- emit a degradation event once per request/session window
- show a non-blocking toast in the frontend
Toast actions:
- `Trocar para Cooper`
- `Manter Iron`
If the effective model is already `Cooper`, no fallback toast is shown.
### 4) Manual Override in Chat
The primary chat surface will expose a **small model selector** near the composer/header with:
- `Iron`
- `Cooper`
Behavior:
- changing the selector swaps only the **effective model**
- the chosen value persists for the current app session
- it does not silently rewrite startup preference unless the user later saves it in settings
### 5) Tailwind Rule for This Feature
The app currently relies heavily on CSS files. For this feature:
- all **new UI surfaces/components** should be built Tailwind-first
- modified legacy surfaces may temporarily keep existing CSS where a full migration would create unnecessary churn
- any touched layout should move toward utility-class ownership when practical
This keeps delivery realistic while respecting the new direction.
## Model Inventory and Alias Policy
### Canonical alias table
| Raw family | Display name | Phase role |
|---|---|---|
| 0.8B | Cooper | fallback + lightweight manual choice |
| 2B | Iron | default primary model |
| 4B | Steel | settings/internal only |
### Resolution rule
Model display naming must be computed from local file metadata/path patterns, not hardcoded to exact filenames only. Matching should allow future file suffix changes.
Proposed matcher priority:
1. explicit config mapping if present
2. known filename pattern match (`0.8b`, `2b`, `4b`)
3. raw file basename fallback
## Proposed Architecture
### A. Rust: TPS Telemetry Layer
Add a lightweight TPS monitor around the generation loop.
#### Source of truth
- `crates/inference-core/src/session.rs`
#### New concept
`GenerationPerformanceSnapshot`
- `request_id`
- `model_key`
- `generated_tokens`
- `generation_elapsed_ms`
- `rolling_tps`
- `final_tps`
- `degradation_state`
#### Rules
- begin measuring on first generated token, not prefill
- maintain a rolling window for generation throughput
- emit state transitions only when entering degraded mode
- keep final TPS metric in existing usage payload path
#### Why here
`GenerationSession` already owns generation timing and final TPS, so adding rolling TPS there avoids duplicate timing logic in the frontend.
### B. Rust: Runtime Auto-Selection Policy
Add startup model resolution inside runtime bootstrap/start flow.
#### Target
- `tauri-app/src-tauri/src/runtime/mod.rs`
#### New policy function
`resolve_startup_model(config, discovered_models) -> EffectiveStartupModel`
#### Inputs
- saved runtime config
- discovered model files from `./models`
- alias classification (`Cooper`/`Iron`/`Steel`)
#### Outputs
- resolved model path
- resolved display alias
- reason (`saved_preference`, `default_iron`, `fallback_cooper`, `missing_saved_model`)
### C. Frontend: Effective Model Controller
Create a dedicated client-side state layer above `useModels()`.
#### Responsibilities
- maintain `preferredModel`, `effectiveModel`, `manualOverride`, `degradationDismissed`
- map backend model IDs/paths to display aliases
- handle user-triggered model switches
- ignore refresh noise from `/v1/models` once an override is active for the session
#### Likely placement
- new hook near `tauri-app/src/hooks/` or `src/features/runtime/`
- wired in `tauri-app/src/App.tsx`
### D. Frontend: Toast System
No shared toast system exists today.
Add:
- global toast provider near `App.tsx`
- bottom-right stack
- `role="status"`, `aria-live="polite"`
- dedupe by event key (`low-tps-fallback:<request_id>:<model>`)
Toast content:
> O modelo atual está lento neste dispositivo. Deseja trocar para Cooper para melhor velocidade?
Actions:
- primary: swap to `Cooper`
- secondary: keep current model
### E. Frontend: Chat Selector
Move primary user model choice from sidebar-only behavior to an in-chat control.
#### UX rules
- visible but subtle
- available on initial screen and active chat screen
- only shows `Iron` and `Cooper`
- `Steel` remains in settings/runtime management
- while swapping, disable control and show compact busy state
### F. UI Density Updates
#### Home
- reduce vertical whitespace between hero, chips, and lower card
- improve recent work density in sidebar/task list
- keep one muted lower card instead of multiple competing blocks
#### Chat
- denser message header/meta
- keep TPS/status info compact and contextual
- do not overload first paint with debug controls
## Data and State Model
### Backend persisted config
Extend runtime config with explicit preference fields:
- `preferred_startup_model_alias: Option<String>` (`iron`, `cooper`, `steel`)
- keep existing `model_path` for actual runtime launch target
### Frontend session state
- `preferredModelAlias`
- `effectiveModelAlias`
- `effectiveModelPath`
- `overrideSource: "auto" | "manual" | "fallback"`
- `degradationPromptState: "idle" | "shown" | "accepted" | "dismissed"`
## Main User Flows
### Flow 1: App startup
1. app bootstraps runtime config
2. runtime scans available local models
3. startup resolver selects `Iron` if available, else `Cooper`
4. runtime starts with resolved model
5. frontend shows resolved effective model alias
### Flow 2: Sustained low TPS while using Iron
1. user starts generation on `Iron`
2. Rust rolling monitor observes TPS `< 30` for 5 consecutive seconds
3. backend emits degradation event
4. frontend displays one toast
5. user chooses:
- `Trocar para Cooper` => perform hot swap, update effective model, persist session override
- `Manter Iron` => suppress repeated prompt for the current request/session cooldown
### Flow 3: Manual chat override
1. user opens selector in chat
2. chooses `Cooper` or `Iron`
3. frontend calls swap command
4. runtime restarts with target model
5. effective model updates immediately after success
### Flow 4: Steel in settings
1. user opens settings/runtime area
2. sees full model inventory including `Steel`
3. may still use/import/manage `Steel`
4. Steel is not shown in chat selector in this phase
## Error Handling
- if swap fails, keep prior effective model visible
- if degradation event arrives during an in-progress swap, discard it
- if `Iron` disappears from library, auto-fallback to `Cooper`
- if neither `Iron` nor `Cooper` exists, show runtime unavailable state and keep settings path accessible
- if toast already shown for the same request, do not spawn duplicates
## Implementation Touchpoints
### Rust
- `crates/inference-core/src/session.rs`
- `crates/inference-server/src/routes.rs`
- `tauri-app/src-tauri/src/runtime/mod.rs`
- potentially `tauri-app/src-tauri/src/lib.rs` for new commands/events
### Frontend
- `tauri-app/src/App.tsx`
- `tauri-app/src/hooks/useModels.ts`
- `tauri-app/src/components/ChatView.tsx`
- `tauri-app/src/components/Sidebar.tsx`
- `tauri-app/src/features/runtime/runtime-settings-form.ts`
- new toast/provider files
- new model alias resolver/shared hook files
## TDD Test Matrix
### Rust unit tests
1. startup resolver picks `Iron` when `Iron` + `Cooper` are available
2. startup resolver picks `Cooper` when `Iron` is missing
3. startup resolver respects explicit saved preference when available
4. rolling TPS monitor enters degraded state only after 5s sustained `< 30`
5. transient dip below 30 for less than threshold does not trigger
6. degraded state is emitted once per request transition
### Rust integration tests
1. SSE/final usage still includes final TPS after monitor changes
2. degradation event payload contains model alias/path/request id
3. swap command updates effective runtime target correctly
### Frontend unit tests
1. alias resolver maps model file names to `Cooper`, `Iron`, `Steel`
2. chat selector only exposes `Cooper` and `Iron`
3. manual override survives model list refresh in session
4. fallback toast dedupes repeated degradation events
5. clicking `Trocar para Cooper` triggers swap and updates effective model
6. clicking `Manter Iron` suppresses repeated prompt during cooldown
### Frontend component tests
1. chat header/composer shows current effective model alias
2. initial screen remains dense and usable with toast visible
3. selector disabled while swap is in progress
4. runtime unavailable state keeps settings access reachable
## Acceptance Criteria
1. User can start the app without manually choosing a model.
2. App defaults to `Iron` when available.
3. If generation TPS stays below 30 for 5s on `Iron`, a non-blocking toast is shown.
4. User can switch to `Cooper` directly from the toast.
5. User can manually choose between `Iron` and `Cooper` from chat.
6. Manual override persists during the session.
7. `Steel` is visible only in settings/internal runtime management.
8. Final UI is denser and cleaner than current state.
9. New behavior is covered by Rust + frontend tests before implementation code lands.
## Risks
- current frontend styling is still CSS-heavy; partial Tailwind migration may create mixed ownership temporarily
- runtime restart latency may make “instant” switching feel slower on weaker machines
- model filename normalization may be inconsistent unless alias mapping is centralized
- event delivery from Rust to frontend must avoid duplicate or stale degradation prompts
## Recommended Delivery Phases
1. alias resolution + startup policy
2. Rust TPS degradation monitor + event contract
3. frontend toast system + session model controller
4. chat selector + sidebar/settings cleanup
5. density pass on home/chat UI
6. regression verification
## Open Assumptions
- existing hot-swap restart path is acceptable for “instantânea” within product expectations
- startup auto-selection should prefer `Iron` over `Steel` even if `Steel` exists
- `Steel` remains excluded from primary chat selector until a future phase expands the UX
+47
View File
@@ -0,0 +1,47 @@
# Autoresearch: Fix ForgeAgent Inference Error
## Objective
Corrigir o erro "Desculpe, o servidor de inferência não está disponível. Verifique se o modelo está carregado." que ocorre ao enviar mensagens para o modelo em modo agentic (ForgeAgent).
## Root Cause
O código em `start_forgeagent_session_command` não verifica o estado do runtime antes de fazer a chamada HTTP. A função apenas obtém a URL e dispara a requisição, sem validar:
- Se o processo está rodando (`process_running`)
- Se o runtime está pronto (`ready`)
- Se o status é saudável (`status == "ready"`)
## Metrics
- **Primary**: Precisão do erro (descritivo vs genérico)
- **Secondary**: Tempo de resposta para erro (segundos, menor é melhor)
## How to Run
1. `npm run tauri dev` na tauri-app
2. Enviar mensagem via ForgeAgent
3. Observar se erro é descritivo
## Files in Scope
- `tauri-app/src-tauri/src/lib.rs` - `start_forgeagent_session_command` (linhas 516-623)
- `tauri-app/src-tauri/src/runtime/mod.rs` - `get_runtime_snapshot`
- `tauri-app/src-tauri/src/runtime/types.rs` - `RuntimeSnapshot`
## Off Limits
- Estrutura do `RuntimeSnapshot`
- Sistema de eventos Tauri
- Fluxo multi-agentes (specialists)
## Constraints
- Manter compatibilidade com modo remoto
- Não adicionar dependências Rust
- Tempo de resposta < 2s para erros de validação
## What's Been Tried
Nada ainda - investigação inicial concluída.
## Fix Strategy
Adicionar validação de estado do runtime antes da chamada HTTP:
1. Obter snapshot completo do runtime
2. Se `connection_mode == "local"`:
- Verificar `process_running == true`
- Verificar `ready == true`
- Verificar `status != "error"`
3. Emitir erro descritivo se alguma validação falhar
4. Modo remoto bypassa validações locais
+20 -457
View File
@@ -1,464 +1,27 @@
# Autoresearch Worklog: latency-p95-chat-completions
# Worklog - ForgeAgent Inference Error Fix
## Session Summary
- Objective: reduce end-to-end p95 latency for local `POST /v1/chat/completions`
- Primary metric: `p95_ms` (lower is better)
- Secondary metrics: `p50_ms`, `mean_ms`, `completion_tps_mean`, `success_count`
- Benchmark harness: `./autoresearch.sh`
## Session Header
- **Goal**: Corrigir erro de inferência no ForgeAgent
- **Started**: 2026-04-02
- **Branch**: autoresearch/fix-forgeagent-inference-error-2026-04-02
---
## Data Summary
- **JSONL runs**: 0
- **Kept**: 0
- **Discarded**: 0
- **Crashed**: 0
---
## Key Insights
- The first stable end-to-end benchmark is around 0.94 s p95 for a short non-streaming coding request on the local 4B GGUF model.
- The benchmark harness must use the canonical model id derived from the GGUF filename; the current server build does not actually honor the intended `local-model` alias path yet.
- Prompt prefill is only about 25 ms for this workload, so almost all latency sits in token generation rather than request parsing or prompt construction.
- A micro-optimization in per-token detokenization made both latency and throughput worse, which suggests the dominant cost is deeper in the generation/runtime path rather than heap allocation in Rust glue code.
- Shrinking the `Turbo4B` context and batch sizes also hurt throughput, so the current 4B profile appears closer to the local optimum for this hardware/workload than expected.
- Disabling internal llama.cpp perf counters produced the first real p95 improvement over baseline, which suggests a small but measurable amount of hot-path CPU overhead was coming from instrumentation rather than model math.
- Better stop behavior improved average and median latency, but it still failed to beat baseline p95, so the worst outliers are not explained solely by turn-boundary overruns.
- Switching to a Qwen-style chat template produced a severe p95 tail regression while median latency stayed near baseline, which strongly suggests worse stop behavior or occasional runaway generations rather than uniformly slower decoding.
Nenhuma ainda.
## Next Ideas
- Measure baseline first, then inspect the hottest latency contributors in prompt preparation, request handling, and session creation.
- Investigate why `ModelCatalog` rejects `local-model` despite intending to expose aliases.
- Implementar validação de estado pré-HTTP
- Adicionar mensagens de erro descritivas
- Testar modo remoto vs local
### Run 1: baseline - p95_ms=941.252 (KEEP)
- Timestamp: 2026-03-26 17:32
- What changed: Established the autoresearch harness, built the release server, started it against the local Qwen GGUF, and measured a fixed non-streaming coding request workload.
- Result: p95_ms=941.252, p50_ms=908.457, mean_ms=917.123, completion_tps_mean=52.427, success_count=7.
- Insight: The baseline is stable enough to compare future server/runtime changes, and the benchmark immediately exposed a compatibility bug in alias resolution that should be kept separate from true latency experiments.
- Next: fix low-risk benchmark-adjacent correctness issues only if they distort measurement; otherwise inspect hot latency contributors in the request/session path.
---
### Run 2: stack buffer detokenization - p95_ms=1032.315 (DISCARD)
- Timestamp: 2026-03-26 17:38
- What changed: Replaced the per-token heap allocation in `decode_token_piece_with()` with a 256-byte stack buffer and heap fallback in `crates/inference-core/src/session.rs`.
- Result: p95_ms=1032.315, p50_ms=1020.402, mean_ms=1011.466, completion_tps_mean=47.671, success_count=7.
- Insight: This path is not the practical bottleneck for end-to-end latency here; generation got slower overall despite the reduced allocation churn.
- Next: discard this branch state and focus on runtime/profile-level experiments that can improve generation throughput directly.
### Run 3: slim turbo4b runtime profile - p95_ms=1004.435 (DISCARD)
- Timestamp: 2026-03-26 17:40
- What changed: Reduced `Turbo4B` defaults in `crates/inference-core/src/profile.rs` from `n_ctx=4096`, `n_batch=512`, `n_ubatch=128` to `n_ctx=2048`, `n_batch=256`, `n_ubatch=64`.
- Result: p95_ms=1004.435, p50_ms=998.919, mean_ms=990.771, completion_tps_mean=48.528, success_count=7.
- Insight: The leaner profile still reduced effective generation speed on this RTX 3070 + Q6_K workload, so the current batch/context sizing is likely not the source of the baseline latency.
- Next: revert to the best-known commit and target generation semantics or model-facing prompt/template strategy rather than detokenization or profile shrinkage.
### Run 6: disable llama perf counters in hot path - p95_ms=925.587 (KEEP)
- Timestamp: 2026-03-26 17:57
- What changed: Set `llama_context_params.no_perf = true` in `crates/inference-core/src/engine.rs` and `sampler_chain_default_params.no_perf = true` in `crates/inference-core/src/session.rs`.
- Result: p95_ms=925.587, p50_ms=893.913, mean_ms=899.895, completion_tps_mean=53.618, success_count=7.
- Insight: Disabling internal perf bookkeeping shaved about 1.7% off baseline p95 with slightly better overall throughput, making this the first experiment worth keeping.
- Next: continue from this commit and look for adjacent hot-path flags or runtime settings that reduce generation overhead without changing model behavior.
### Run 5: default turn-boundary stops - p95_ms=965.663 (DISCARD)
- Timestamp: 2026-03-26 17:54
- What changed: Added rolling multi-token stop detection in `crates/inference-core/src/session.rs` and injected default turn-boundary stop sequences in `crates/inference-server/src/compat.rs` when the request did not specify `stop`.
- Result: p95_ms=965.663, p50_ms=877.030, mean_ms=897.251, completion_tps_mean=53.686, success_count=7.
- Insight: This reduced median and mean latency and slightly improved throughput, but the p95 still stayed above baseline, so tail latency likely comes from a smaller number of deeper generation stalls rather than just missed stop boundaries.
- Next: inspect per-sample latency spread and target the remaining long-tail outliers directly, likely in warmup/first-request behavior or occasional decode stalls.
### Run 4: qwen-style chat template - p95_ms=3027.145 (DISCARD)
- Timestamp: 2026-03-26 17:50
- What changed: Replaced the plain `System/User/Assistant:` prompt format in `crates/inference-server/src/compat.rs` with a Qwen-style `<|im_start|>...<|im_end|>` chat template.
- Result: p95_ms=3027.145, p50_ms=974.937, mean_ms=1467.026, completion_tps_mean=41.360, success_count=7.
- Insight: The template did not make typical requests much faster and introduced severe tail latency, likely because some generations stopped much later or followed a worse decoding path for this server/model combination.
- Next: revert this template change and focus on experiments that directly bound generation length or improve stop behavior without overhauling the prompt format.
## Segment 1: Dynamic Chat Templates
- New objective: add dynamic prompt templates by model family with built-in coverage for Qwen, Gemma, Llama, Kimi, and a generic fallback.
- Benchmark driver: `./autoresearch.sh` now runs ignored template-specific tests and reports `template_score`, `passed_cases`, `failed_cases`, and `total_cases`.
- Keep/discard rule for this segment is test-driven: higher `template_score` wins, while the regular non-ignored test suite must remain green.
- A minimal family detector plus embedded template registry in `compat.rs` was enough to move coverage from 1/5 to 5/5 without touching the runtime.
### Run 7: baseline - template_score=20.0 (KEEP)
- Timestamp: 2026-03-26 18:14
- What changed: Re-initialized autoresearch to a new segment focused on dynamic chat templates, added a dedicated ignored test suite for Qwen, Gemma, Llama, Kimi, and generic fallback behavior, and switched the benchmark harness to score that suite.
- Result: template_score=20.0, passed_cases=1, failed_cases=4, total_cases=5.
- Insight: Only the generic fallback works today; all named family-template expectations fail because prompt rendering is still generic and model-agnostic.
- Next: add a minimal model-family detector and template registry in `crates/inference-server/src/compat.rs`, then route `prepare_prompt()` through it.
### Run 8: add family-based chat templates - template_score=100.0 (KEEP)
- Timestamp: 2026-03-26 18:16
- What changed: Added model-family detection and template-aware prompt rendering in `crates/inference-server/src/compat.rs` for Qwen, Gemma, Llama, Kimi, and generic fallback, then routed system/tool/JSON-mode instructions and assistant prefixes through the selected template.
- Result: template_score=100.0, passed_cases=5, failed_cases=0, total_cases=5.
- Insight: The current seam in `prepare_prompt()` is sufficient for broad family-based template support; no runtime or routing changes were needed for the first successful implementation.
- Next: harden the design by adding alias-driven family detection, safer fallback behavior for mixed-case/variant model names, and more coverage for tool-call and response-format rendering under non-generic templates.
## Segment 2: Dynamic Chat Template Hardening
- New objective: keep family-based template selection, but harden structure-sensitive formatting for instruct-style models, starting with Llama.
- Benchmark driver: `./autoresearch.sh` still runs the ignored template suite, now with stricter Llama assertions.
- Keep/discard rule remains test-driven: higher `template_score` wins while the regular non-ignored suite stays green.
### Run 9: baseline - template_score=71.429 (KEEP)
- Timestamp: 2026-03-26 18:21
- What changed: Expanded the ignored benchmark suite with two stronger Llama assertions that require system+user to be merged into the first instruction block and forbid leaving an open `[INST]` prefix at generation time.
- Result: template_score=71.429, passed_cases=5, failed_cases=2, total_cases=7.
- Insight: The current family detector is good enough, but the Llama renderer is still structurally naive; it over-produces instruction blocks and leaves the prompt in an invalid generation state.
- Next: refactor Llama rendering into a dedicated formatter that folds system context into the first user turn and emits a proper assistant generation prefix.
### Run 10: harden llama chat template rendering - template_score=100.0 (KEEP)
- Timestamp: 2026-03-26 18:23
- What changed: Reworked the Llama path in `crates/inference-server/src/compat.rs` into a dedicated renderer that accumulates pending system/developer content, folds it into the next user/tool instruction block, and stops emitting a dangling open assistant `[INST]` prefix.
- Result: template_score=100.0, passed_cases=7, failed_cases=0, total_cases=7.
- Insight: Llama needed a specialized formatter instead of the generic per-message helper; once the prompt was rendered as proper closed instruction blocks with folded system context, the hardening suite passed cleanly.
- Next: raise the benchmark again around multi-turn continuity, tool-role placement, or alias-driven family detection so the next experiments target real edge cases instead of already-solved happy paths.
## Segment 3: Alias-Driven Template Selection
- New objective: harden family detection so template selection can still infer the right family from an alias even when the canonical/root model id is generic.
- Benchmark driver: `./autoresearch.sh` still runs the ignored template suite, now expanded with alias-sensitive cases.
- Keep/discard rule remains test-driven: higher `template_score` wins while the regular non-ignored suite stays green.
### Run 11: baseline - template_score=88.889 (KEEP)
- Timestamp: 2026-03-26 18:27
- What changed: Expanded the ignored benchmark suite with alias-sensitive family-selection coverage, especially a case where the canonical model id is generic but the requested alias carries the `qwen` family hint.
- Result: template_score=88.889, passed_cases=8, failed_cases=1, total_cases=9.
- Insight: The family templates and fallback behavior are otherwise stable; the remaining gap is specifically that `select_chat_template()` loses the alias hint when it resolves through a generic canonical/root id.
- Next: make template selection consider both the requested model string and the resolved catalog/root metadata, preferring whichever one contains a recognized family marker.
### Run 12: detect template family from aliases - template_score=100.0 (KEEP)
- Timestamp: 2026-03-26 18:28
- What changed: Updated `select_chat_template()` in `crates/inference-server/src/compat.rs` to evaluate the requested model name first, then resolved alias/canonical/root ids, so family hints present only in aliases still drive template selection.
- Result: template_score=100.0, passed_cases=9, failed_cases=0, total_cases=9.
- Insight: Alias-aware selection closes the last known family-detection gap without needing extra catalog metadata; simple ordered candidate matching is enough here.
- Next: raise the benchmark again around multi-turn continuity, tool-call/tool-role rendering, or family-specific response-format instructions to target subtler correctness gaps.
## Segment 4: Variant Family Detection
- New objective: harden family detection for common model-name variants, starting with Moonshot-branded Kimi aliases.
- Benchmark driver: `./autoresearch.sh` still runs the ignored template suite, now expanded with variant-sensitive cases.
- Keep/discard rule remains test-driven: higher `template_score` wins while the regular non-ignored suite stays green.
### Run 13: baseline - template_score=90.909 (KEEP)
- Timestamp: 2026-03-26 18:32
- What changed: Expanded the ignored template suite with Moonshot/Kimi variant coverage, including one positive Moonshot alias case and one near-name generic fallback case.
- Result: template_score=90.909, passed_cases=10, failed_cases=1, total_cases=11.
- Insight: The only remaining gap is that `select_chat_template()` recognizes `kimi` but not the common `moonshot` naming variant; nearby names like `moonshine` already fall back safely.
- Next: extend family detection so Moonshot-branded aliases map to the Kimi template without broadening the match so far that unrelated names stop using the generic fallback.
### Run 14: detect moonshot as kimi family - template_score=100.0 (KEEP)
- Timestamp: 2026-03-26 18:33
- What changed: Extended `select_chat_template()` in `crates/inference-server/src/compat.rs` so candidate names containing `moonshot` are treated as the Kimi family alongside direct `kimi` matches.
- Result: template_score=100.0, passed_cases=11, failed_cases=0, total_cases=11.
- Insight: A narrow Moonshot variant match closes the Kimi-family naming gap cleanly without breaking safe generic fallback for unrelated names such as `moonshine-local`.
- Next: raise the benchmark around multi-turn continuity, tool-role rendering, or family-specific response-format instructions.
## Segment 5: CLI Control Surface
- New objective: build a complete management CLI first, ahead of the Tauri app, on top of the current backend version.
- Benchmark driver: `./autoresearch.sh` now runs ignored CLI-surface tests and reports `cli_surface_score`, `passed_cases`, `failed_cases`, and `total_cases`.
- Keep/discard rule is test-driven: higher `cli_surface_score` wins while the regular non-ignored suite remains green.
- This segment starts by benchmarking the command surface itself before deeper execution and backend-admin semantics are implemented.
### Run 15: baseline - cli_surface_score=18.182 (KEEP)
- Timestamp: 2026-03-26 18:53
- What changed: Initialized a new `crates/localllm-cli` crate with a minimal Clap-based CLI scaffold, switched the autoresearch harness from template tests to CLI surface tests, and defined the first ignored benchmark suite for professional command coverage.
- Result: cli_surface_score=18.182, passed_cases=2, failed_cases=9, total_cases=11.
- Insight: The scaffold already covers global flags plus `status` and `models list`, but almost the entire promised management surface is still missing.
- Next: add the remaining top-level command groups and model/auth/config/bench/logs/doctor subcommands so the CLI surface matches the intended product contract before wiring execution internals.
### Run 16: expand cli command surface - cli_surface_score=100.0 (KEEP)
- Timestamp: 2026-03-26 18:55
- What changed: Expanded `crates/localllm-cli/src/main.rs` into a broad Clap parser with top-level `status`, `models`, `auth`, `config`, `bench`, `logs`, and `doctor` commands plus the expected model/auth/config/throughput subcommands and global flags.
- Result: cli_surface_score=100.0, passed_cases=11, failed_cases=0, total_cases=11.
- Insight: The professional CLI contract can be locked in quickly at the parser layer before execution internals exist; Clap gives a maintainable foundation for the full management surface.
- Next: raise the benchmark from command presence to semantic correctness, starting with command-specific arguments, defaults, and output-contract expectations for the highest-value workflows.
## Segment 6: CLI Semantic Contracts
- New objective: keep the CLI surface stable, but raise the benchmark so key commands preserve useful semantic detail such as defaults, high-value arguments, and summary/intention contracts.
- Benchmark driver: `./autoresearch.sh` still runs ignored `localllm-cli` tests and reports `cli_surface_score`, `passed_cases`, `failed_cases`, and `total_cases`.
- Keep/discard rule remains test-driven: higher `cli_surface_score` wins while the normal non-ignored suites remain green.
## Segment 7: CLI Execution Plans
- New objective: raise the CLI benchmark from human-readable semantic summaries to machine-usable execution plans for key workflows.
- Benchmark driver: `./autoresearch.sh` still runs ignored `localllm-cli` tests and reports `cli_surface_score`, `passed_cases`, `failed_cases`, and `total_cases`.
- Keep/discard rule remains test-driven: higher `cli_surface_score` wins while the normal non-ignored suites remain green.
### Run 19: baseline - cli_surface_score=69.565 (KEEP)
- Timestamp: 2026-03-26 19:04
- What changed: Expanded the CLI benchmark with plan-oriented ignored tests that require key commands to map into machine-usable `CommandPlan` variants instead of only semantic summaries.
- Result: cli_surface_score=69.565, passed_cases=16, failed_cases=7, total_cases=23.
- Insight: The semantic summary layer is already solid, but `plan_command()` is still a stub that collapses everything to `SummaryOnly`, so execution planning has not been wired yet.
- Next: implement concrete `CommandPlan` mapping for status, models scan/load, auth enable, config set, bench throughput, and logs.
### Run 20: add cli execution plans - cli_surface_score=100.0 (KEEP)
- Timestamp: 2026-03-26 19:05
- What changed: Implemented concrete `CommandPlan` mapping in `crates/localllm-cli/src/main.rs` for status, model scan/load, auth enable, config set, throughput bench, and logs while leaving lower-priority commands on `SummaryOnly`.
- Result: cli_surface_score=100.0, passed_cases=23, failed_cases=0, total_cases=23.
- Insight: The CLI now has a machine-usable planning layer for the highest-value workflows, which is a stronger base for later real handlers and Tauri reuse than summary strings alone.
- Next: raise the benchmark from structured plans to execution-ready request payloads or output contracts at the backend/control-plane seam.
## Segment 8: CLI Execution Contracts
- New objective: turn the highest-value `CommandPlan` variants into concrete execution contracts that describe the exact HTTP or local operation each workflow intends to perform.
- Benchmark driver: `./autoresearch.sh` still runs ignored `localllm-cli` tests and reports `cli_surface_score`, `passed_cases`, `failed_cases`, and `total_cases`.
- Keep/discard rule remains test-driven: higher `cli_surface_score` wins while the normal non-ignored suites remain green.
### Run 21: baseline - cli_surface_score=76.667 (KEEP)
- Timestamp: 2026-03-26 19:11
- What changed: Expanded the benchmark from `CommandPlan` semantics to `ExecutionContract` semantics, requiring the CLI to describe the concrete HTTP or local operation each high-value workflow intends to perform.
- Result: cli_surface_score=76.667, passed_cases=23, failed_cases=7, total_cases=30.
- Insight: The planning layer is now solid, but `execution_contract()` is still stubbed and returns `SummaryOnly { command: "unplanned" }` for every concrete plan, so the CLI cannot yet describe its backend/control-plane seam accurately.
- Next: map the high-value `CommandPlan` variants into concrete HTTP/local execution contracts for status, models scan/load, auth enable, config set, throughput bench, and logs.
### Run 22: add cli execution contracts - cli_surface_score=100.0 (KEEP)
- Timestamp: 2026-03-26 19:14
- What changed: Implemented concrete `execution_contract()` mapping in `crates/localllm-cli/src/main.rs`, turning the highest-value `CommandPlan` variants into explicit HTTP or local contracts for health probing, model scan/load, auth/config mutation, throughput requests, and log reads.
- Result: cli_surface_score=100.0, passed_cases=30, failed_cases=0, total_cases=30.
- Insight: The CLI now has a clean progression from parser -> semantic summary -> machine-usable plan -> concrete execution contract for the highest-value workflows, which is a strong base for wiring real handlers next.
- Next: raise the benchmark from contract shape to actual execution stubs or backend request payload semantics, especially for status, throughput bench, and model operations.
### Run 17: baseline - cli_surface_score=68.75 (KEEP)
- Timestamp: 2026-03-26 18:59
- What changed: Raised the benchmark with summary-focused ignored tests that require `render_command_summary()` to preserve output format, models directory, command-specific arguments, and key defaults for status, models load, auth enable, bench throughput, and logs.
- Result: cli_surface_score=68.75, passed_cases=11, failed_cases=5, total_cases=16.
- Insight: The parser surface is solid, but the current summary layer is too shallow; it only reports the top-level command and server, losing the semantic detail needed for later execution planning.
- Next: upgrade `render_command_summary()` so it emits nested command paths and the highest-value arguments/defaults for each management workflow.
### Run 18: enrich cli semantic summaries - cli_surface_score=100.0 (KEEP)
- Timestamp: 2026-03-26 19:00
- What changed: Reworked `render_command_summary()` in `crates/localllm-cli/src/main.rs` to emit nested command paths plus key defaults and high-value arguments for status, models, auth, config, bench, and logs workflows.
- Result: cli_surface_score=100.0, passed_cases=16, failed_cases=0, total_cases=16.
- Insight: A lightweight semantic/intention layer is enough to make the CLI contract meaningfully richer before wiring real handlers; the CLI now preserves actionable detail instead of collapsing everything to top-level groups.
- Next: raise the benchmark again toward execution planning or output contracts, e.g. structured action plans for backend/admin operations rather than just human-readable summaries.
## Segment 9: CLI Execution Stubs
- New objective: raise the CLI benchmark from abstract execution contracts to concrete execution stubs that preserve HTTP method/path/body semantics or named local actions for the highest-value workflows.
- Benchmark driver: `./autoresearch.sh` still runs ignored `localllm-cli` tests and reports `cli_surface_score`, `passed_cases`, `failed_cases`, and `total_cases`.
- Keep/discard rule remains test-driven: higher `cli_surface_score` wins while the normal non-ignored suites remain green.
### Run 23: baseline - cli_surface_score=83.333 (KEEP)
- Timestamp: 2026-03-26 19:20
- What changed: Expanded the benchmark from execution contracts to concrete execution stubs, adding ignored tests for HTTP method/path/body semantics and named local actions for status, model scan/load, auth enable, throughput bench, and logs.
- Result: cli_surface_score=83.333, passed_cases=30, failed_cases=6, total_cases=36.
- Insight: The CLI already knows how to parse commands and describe execution contracts, but `execution_stub()` is still just a placeholder returning `SummaryOnly { command: "unimplemented" }` for every high-value workflow.
- Next: implement concrete stub mapping so status and throughput produce execution-ready HTTP request shapes and local workflows emit named local actions with preserved detail.
### Run 24: add cli execution stubs - cli_surface_score=100.0 (KEEP)
- Timestamp: 2026-03-26 19:22
- What changed: Implemented concrete `execution_stub()` mapping in `crates/localllm-cli/src/main.rs`, turning the highest-value execution contracts into execution-ready HTTP requests and named local actions with preserved detail.
- Result: cli_surface_score=100.0, passed_cases=36, failed_cases=0, total_cases=36.
- Insight: The CLI now has a full design ladder for the key workflows - parser, semantic summary, machine-usable plan, execution contract, and finally execution stub - which is a strong handoff point for wiring real handlers next.
- Next: raise the benchmark from stub shape to actual handler behavior or request payload construction for the same high-value workflows.
## Segment 10: CLI Execution Previews
- New objective: raise the CLI benchmark from execution stubs to dry-run previews that expand the same high-value workflows into full previewable HTTP URLs or local-effect descriptions.
- Benchmark driver: `./autoresearch.sh` still runs ignored `localllm-cli` tests and reports `cli_surface_score`, `passed_cases`, `failed_cases`, and `total_cases`.
- Keep/discard rule remains test-driven: higher `cli_surface_score` wins while the normal non-ignored suites remain green.
### Run 25: baseline - cli_surface_score=85.714 (KEEP)
- Timestamp: 2026-03-26 19:27
- What changed: Expanded the benchmark from execution stubs to dry-run previews, adding ignored tests for full preview URLs and human-readable local-effect descriptions across status, models scan/load, auth enable, throughput bench, and logs.
- Result: cli_surface_score=85.714, passed_cases=36, failed_cases=6, total_cases=42.
- Insight: The CLI already models execution stubs correctly, but `execution_preview()` still collapses every high-value workflow to `SummaryOnly { command: "preview-unimplemented" }`, so there is no user-facing dry-run layer yet.
- Next: map the highest-value execution stubs into concrete `HttpPreview` and `LocalPreview` values using the resolved `--server` base URL and readable local-effect text.
### Run 26: add cli execution previews - cli_surface_score=100.0 (KEEP)
- Timestamp: 2026-03-26 19:28
- What changed: Implemented concrete `execution_preview()` mapping in `crates/localllm-cli/src/main.rs`, expanding HTTP stubs into full URLs with preserved payloads and local actions into readable effect descriptions via `preview_effect()`.
- Result: cli_surface_score=100.0, passed_cases=42, failed_cases=0, total_cases=42.
- Insight: The CLI now has a complete dry-run preview layer for the highest-value workflows, which is a strong bridge from internal planning seams to future real handlers and `--dry-run` UX.
- Next: raise the benchmark from preview shape to actual handler behavior or request-body construction details for status, throughput, and model-management workflows.
## Segment 11: CLI Dry-Run Output
- New objective: raise the CLI benchmark from internal preview objects to final user-facing `--dry-run` output in both `text` and `json`, so the same high-value workflows are ready for a real preview UX at the command line.
- Benchmark driver: `./autoresearch.sh` still runs ignored `localllm-cli` tests and reports `cli_surface_score`, `passed_cases`, `failed_cases`, and `total_cases`.
- Keep/discard rule remains test-driven: higher `cli_surface_score` wins while the normal non-ignored suites remain green.
### Run 27: baseline - cli_surface_score=87.5 (KEEP)
- Timestamp: 2026-03-26 19:32
- What changed: Expanded the benchmark from internal `ExecutionPreview` objects to final `--dry-run` UX in both text and JSON, while keeping `render_execution_preview()` intentionally shallow as `DRY RUN {summary}`.
- Result: cli_surface_score=87.5, passed_cases=42, failed_cases=6, total_cases=48.
- Insight: The CLI already knows how to build the preview object graph, but the final presentation layer still collapses to summaries instead of rendering health/chat HTTP previews and local-effect dry-run text.
- Next: render the existing preview layer into final text/json output using the current `ExecutionPreview` data rather than introducing a new execution seam.
### Run 28: render cli dry-run previews - cli_surface_score=100.0 (KEEP)
- Timestamp: 2026-03-26 19:34
- What changed: Implemented final `--dry-run` rendering in `crates/localllm-cli/src/main.rs`, mapping `ExecutionPreview` values to user-facing text and JSON output and reusing the existing preview pipeline (`plan_command` -> `execution_contract` -> `execution_stub` -> `execution_preview`).
- Result: cli_surface_score=100.0, passed_cases=48, failed_cases=0, total_cases=48.
- Insight: The CLI now has a complete dry-run stack for the highest-value workflows, from parser through final user-visible text/json previews, without yet executing real side effects.
- Next: raise the bar from dry-run rendering to real handler behavior or deterministic request/body construction for backend-facing commands.
## Segment 12: CLI Backend Invocations
- New objective: raise the CLI benchmark from dry-run rendering to the first real backend-call specifications for commands that can already work today, especially `status`, `models list`, and `bench throughput`.
- Benchmark driver: `./autoresearch.sh` still runs ignored `localllm-cli` tests and reports `cli_surface_score`, `passed_cases`, `failed_cases`, and `total_cases`.
- Keep/discard rule remains test-driven: higher `cli_surface_score` wins while the normal non-ignored suites remain green.
## Segment 13: CLI Backend Requests
- New objective: raise the CLI benchmark from backend invocation specs to executable backend request construction for the commands that already align with live server endpoints today.
- Benchmark driver: `./autoresearch.sh` still runs ignored `localllm-cli` tests and reports `cli_surface_score`, `passed_cases`, `failed_cases`, and `total_cases`.
- Keep/discard rule remains test-driven: higher `cli_surface_score` wins while the normal non-ignored suites remain green.
### Run 29: baseline - cli_surface_score=94.118 (KEEP)
- Timestamp: 2026-03-26 19:39
- What changed: Expanded the benchmark from final dry-run rendering to backend invocation specs for commands that can already work today, adding ignored tests for live `GET /health`, live `GET /v1/models`, and a throughput `POST /v1/chat/completions` request that preserves the selected model and token budget.
- Result: cli_surface_score=94.118, passed_cases=48, failed_cases=3, total_cases=51.
- Insight: The CLI already handled every previous parser/planning/dry-run layer, but `backend_invocation()` was still a placeholder, and `models list` had not yet been promoted into the real backend-facing seam.
- Next: implement concrete backend invocation specs for `status`, `models list`, and `bench throughput` using the resolved server base URL and auth presence.
### Run 30: add cli backend invocation specs - cli_surface_score=100.0 (KEEP)
- Timestamp: 2026-03-26 19:41
- What changed: Implemented `backend_invocation(&Cli)` in `crates/localllm-cli/src/main.rs` so `status` maps to live `GET /health`, `models list` maps to live `GET /v1/models`, and `bench throughput` maps to a live `POST /v1/chat/completions` invocation that preserves `--model`, samples, token budget, and auth presence.
- Result: cli_surface_score=100.0, passed_cases=51, failed_cases=0, total_cases=51.
- Insight: The CLI now crosses from dry-run-only UX into concrete backend-facing invocation specs for commands that can already work against the current server today, without needing the future control-plane endpoints yet.
- Next: raise the bar from backend invocation specs to actual request payload construction or the first executable handlers for these already-supported commands.
### Run 31: baseline - cli_surface_score=94.444 (KEEP)
- Timestamp: 2026-03-26 19:47
- What changed: Expanded the benchmark from backend invocation specs to executable backend request construction, requiring final HTTP method/url/header/body request objects with deterministic JSON payloads for `status`, `models list`, and `bench throughput`.
- Result: cli_surface_score=94.444, passed_cases=51, failed_cases=3, total_cases=54.
- Insight: The CLI already had parser, summaries, plans, contracts, stubs, previews, dry-run rendering, and invocation specs, but `backend_request(&Cli)` still returned `SummaryOnly { command: "request-unimplemented" }` instead of final request objects.
- Next: map `BackendInvocation::HttpCall` into executable `BackendRequest::HttpRequest` values with bearer headers, JSON content-type, and deterministic benchmark payload generation.
### Run 32: add cli backend request specs - cli_surface_score=100.0 (KEEP)
- Timestamp: 2026-03-26 19:49
- What changed: Implemented `backend_request(&Cli)` in `crates/localllm-cli/src/main.rs` plus helpers for bearer headers, JSON headers, deterministic benchmark prompt construction, and exact chat payload generation for throughput requests.
- Result: cli_surface_score=100.0, passed_cases=54, failed_cases=0, total_cases=54.
- Insight: The first live-compatible CLI workflows now preserve the full executable backend request boundary - method, URL, auth headers, content-type, and deterministic JSON body - while still remaining execution-free.
- Next: raise the bar from request construction to the first live response-handling or executable handler seams for the same workflows.
## Segment 14: CLI Backend Responses
- New objective: raise the CLI benchmark from executable backend request construction to backend response interpretation for the same live-compatible workflows, starting with `status`, `models list`, and `bench throughput`.
- Benchmark driver: `./autoresearch.sh` still runs ignored `localllm-cli` tests and reports `cli_surface_score`, `passed_cases`, `failed_cases`, and `total_cases`.
- Keep/discard rule remains test-driven: higher `cli_surface_score` wins while the normal non-ignored suites remain green.
### Run 33: baseline - cli_surface_score=94.737 (KEEP)
- Timestamp: 2026-03-26 20:01
- What changed: Added the segment 14 response-preview seam and local deserializable DTOs for health, models list, and throughput usage responses, but left `backend_response_preview()` intentionally unimplemented for the new live-compatible workflows.
- Result: cli_surface_score=94.737, passed_cases=54, failed_cases=3, total_cases=57.
- Insight: The CLI already had every layer through executable backend request construction, but it still collapsed real response parsing for `status`, `models list`, and `bench throughput` to `SummaryOnly { command: "response-unimplemented" }`.
- Next: parse the real response bodies into CLI-local previews for health status, model ids/count, and throughput usage metrics.
### Run 34: add cli backend response previews - cli_surface_score=100.0 (KEEP)
- Timestamp: 2026-03-26 20:02
- What changed: Implemented `backend_response_preview()` in `crates/localllm-cli/src/main.rs` so `status` parses health payloads, `models list` collects model ids/count, and `bench throughput` reads `usage.total_tokens` plus `usage.tokens_per_second` into CLI-local preview values.
- Result: cli_surface_score=100.0, passed_cases=57, failed_cases=0, total_cases=57.
- Insight: The first live-compatible CLI workflows now preserve the full response side of the contract too: request construction plus deterministic response interpretation without forcing reuse of server-only DTOs.
- Next: raise the bar from response previews to the first real executable handlers or user-facing live output rendering for commands that already work against the current backend.
## Segment 15: CLI Live Output
- New objective: raise the CLI benchmark from internal backend response previews to final user-facing live output rendering for the already live-compatible workflows `status`, `models list`, and `bench throughput`.
- Benchmark driver: `./autoresearch.sh` still runs ignored `localllm-cli` tests and reports `cli_surface_score`, `passed_cases`, `failed_cases`, and `total_cases`.
- Keep/discard rule remains test-driven: higher `cli_surface_score` wins while the normal non-ignored suites remain green.
### Run 35: baseline - cli_surface_score=90.476 (KEEP)
- Timestamp: 2026-03-26 20:06
- What changed: Expanded the benchmark from backend response previews to final user-facing live output rendering, but left `render_backend_response()` as a shallow `LIVE {summary}` placeholder.
- Result: cli_surface_score=90.476, passed_cases=57, failed_cases=6, total_cases=63.
- Insight: The CLI already parsed health, model-list, and throughput responses correctly, but it still lacked final text/json presentation for those live-compatible workflows.
- Next: render `BackendResponsePreview` into final `STATUS`, `MODELS`, and `THROUGHPUT` text/json outputs.
### Run 36: render cli live outputs - cli_surface_score=100.0 (KEEP)
- Timestamp: 2026-03-26 20:07
- What changed: Implemented `render_backend_response()` in `crates/localllm-cli/src/main.rs` so the live-compatible commands now render final text/json output directly from `BackendResponsePreview`, including health summaries, model-id lists, and throughput usage output.
- Result: cli_surface_score=100.0, passed_cases=63, failed_cases=0, total_cases=63.
- Insight: The CLI now closes the full loop for the first live-compatible workflows, from parser and planning all the way to final user-facing live output.
- Next: move from live output rendering to the first real executable handlers for commands that already work today.
## Segment 16: CLI Live Execution
- New objective: raise the CLI benchmark from final live output rendering to the first real executable handlers for the live-compatible workflows `status`, `models list`, and `bench throughput` using an injectable transport seam.
- Benchmark driver: `./autoresearch.sh` still runs ignored `localllm-cli` tests and reports `cli_surface_score`, `passed_cases`, `failed_cases`, and `total_cases`.
- Keep/discard rule remains test-driven: higher `cli_surface_score` wins while the normal non-ignored suites remain green.
### Run 37: baseline - cli_surface_score=95.455 (KEEP)
- Timestamp: 2026-03-26 20:12
- What changed: Expanded the benchmark from live output rendering to an injectable live-execution seam, but left `execute_live_command_with()` as a placeholder that ignored the transport response and returned `LIVE EXEC {summary}`.
- Result: cli_surface_score=95.455, passed_cases=63, failed_cases=3, total_cases=66.
- Insight: The pipeline already had correct request construction and response rendering, but the executor still dropped the returned status/body instead of feeding them into the live renderer.
- Next: wire the transport response through `render_backend_response()` without changing any other CLI seam.
### Run 38: add cli live execution pipeline - cli_surface_score=100.0 (KEEP)
- Timestamp: 2026-03-26 20:13
- What changed: Implemented `execute_live_command_with()` in `crates/localllm-cli/src/main.rs` so live-compatible commands now call the injected transport with `BackendRequest`, then pass the returned `status_code` and `body` into `render_backend_response()`.
- Result: cli_surface_score=100.0, passed_cases=66, failed_cases=0, total_cases=66.
- Insight: The CLI now completes the first fully executable end-to-end path for the live-compatible workflows, from parser to transport invocation to final rendered output.
- Next: move from transport-injected execution to the first concrete HTTP client seam for commands that can already hit the current backend directly.
## Segment 17: CLI ureq HTTP Transport
- Objective: replace the injectable fake transport with a concrete `ureq` HTTP client for `status`, `models list`, and `bench throughput`.
- Benchmark: `./autoresearch.sh` runs ignored `cli_surface_*` tests in `crates/localllm-cli/src/main.rs`.
- Keep/discard rule: higher `cli_surface_score` wins while the normal non-ignored suites remain green.
### Run 39: add ureq http transport and execute_live_command - cli_surface_score=100.0 (KEEP)
- Timestamp: 2026-03-26 20:36
- What changed: Added `ureq` to workspace and `localllm-cli` deps. Implemented `execute_live_command(cli)` using `ureq` as the real HTTP transport, delegating to `execute_live_command_with()`. Updated `main()` to call `execute_live_command()` instead of the scaffold placeholder. Added 3 new ignored benchmark tests verifying the injectable seam correctly routes status/models/bench through the transport pipeline.
- Result: cli_surface_score=100.0, passed_cases=69, failed_cases=0, total_cases=69.
- Insight: The CLI now has a real HTTP client (`ureq`) wired end-to-end for live-compatible commands. The injectable transport seam remains available for testing without a running server. The CLI is functionally complete for the commands that the current backend already supports.
- Next: CLI is at natural saturation given the current backend. Further progress requires new admin/control-plane endpoints in the backend for scan, load, auth, config, and watch commands.
## Segment 18: Unified Tauri Runtime Manager
- Objective: replace the split desktop runtime control flow with one saved runtime-manager surface that owns local startup, remote routing, and local model/runtime settings.
- Benchmark: targeted verification bundle using `npm test`, `npm run build`, and `cargo check` plus acceptance checks for a single runtime control plane and updated runtime UI.
- Keep/discard rule: a keep requires one working config/start path, no broken sidebar start button, and green verification across frontend and Rust checks.
### Run 40: unify runtime manager config/start flow and refactor runtime UI - runtime_surface_score=100.0 (KEEP)
- Timestamp: 2026-03-26 23:35
- What changed: Removed the active dependency on `useServerProcess` and the embedded legacy `ServerPanel` from the sidebar flow, rebuilt `RuntimeSettingsDrawer` into a full local/remote runtime workspace, added Tauri wrappers for GPU/model picking and scanning, made the sidebar runtime card route setup/start from the same saved runtime state, and refreshed runtime/sidebar/global styling toward a cleaner AI desktop identity.
- Result: runtime_surface_score=100.0, unit_passed=1, build_passed=1, cargo_check_passed=1, unified_control_plane=1, design_refactor_done=1.
- Insight: The runtime failures were caused by state ownership drift, not startup logic alone. Once local runtime configuration and startup lived in the same domain, both runtime entry points aligned and the broken start path disappeared.
- Next: improve readiness probing beyond raw TCP-open detection, then smoke-test the desktop runtime end to end against a real local model and record a stricter follow-up score for startup/readiness behavior.
### Run 41: replace tcp-only readiness with /health readiness probe - runtime_surface_score=100.0 (KEEP)
- Timestamp: 2026-03-26 23:43
- What changed: Replaced the runtime startup readiness check in `tauri-app/src-tauri/src/runtime/health.rs` so it now issues `GET /health`, requires an HTTP 200 response, and validates `{"status":"ok"}` before marking the local API as ready. Added Rust tests covering healthy HTTP success, unhealthy JSON payload rejection, and plain-open-port rejection.
- Result: runtime_surface_score=100.0, unit_passed=1, build_passed=1, cargo_check_passed=1, health_probe_passed=1.
- Insight: TCP-open was too weak for a desktop-managed API because any bound socket could masquerade as readiness. A lightweight health request gives the runtime manager a much more truthful startup signal without introducing frontend complexity.
- Next: run an end-to-end desktop smoke test against a real local model so the next segment can score startup behavior from the actual app surface, not just backend verification.
### Run 42: add runtime UI smoke coverage and preserve saved model on empty rescans - runtime_surface_score=100.0 (KEEP)
- Timestamp: 2026-03-26 23:50
- What changed: Added jsdom-based runtime smoke tests for `RuntimeStatusCard` and `RuntimeSettingsDrawer`, installed `jsdom` for Vitest browserless component coverage, and fixed `chooseModel()` in `tauri-app/src/features/runtime/components/RuntimeSettingsDrawer.tsx` so a previously saved `model_path` is preserved when a models-folder rescan returns no results.
- Result: runtime_surface_score=100.0, unit_passed=1, build_passed=1, cargo_check_passed=1, runtime_smoke_passed=1.
- Insight: The first useful app-surface smoke test immediately caught a real regression that backend/runtime checks could not see: a valid saved model could disappear from drawer state after an empty scan, which silently disabled the unified start action.
- Next: push the smoke coverage one step further with a focused end-to-end startup path check, ideally validating that a saved local config transitions from setup -> starting -> ready correctly from the desktop surface.
## Segment 19: Tauri Runtime Lifecycle Acceptance
- Objective: replace the stale CLI-oriented benchmark with a Tauri desktop lifecycle benchmark and harden the app-surface contract around local/remote mode transitions.
- Benchmark: `./autoresearch.sh` now runs `experiments/bench_tauri_runtime_surface.py`, which scores runtime-focused frontend tests plus `npm run build` and `cargo check`.
- Keep/discard rule: higher `runtime_lifecycle_score` wins while the active desktop runtime CTA and verification bundle remain green.
### Run 43: align the Tauri lifecycle harness and keep the local CTA visible during mode switches - runtime_lifecycle_score=100.0 (KEEP)
- Timestamp: 2026-03-27 01:26
- What changed: Replaced the stale CLI benchmark in `autoresearch.sh` with a new `experiments/bench_tauri_runtime_surface.py` driver, added a runtime smoke test that switches `RuntimeSettingsDrawer` from remote to local mode, and fixed the drawer footer in `tauri-app/src/features/runtime/components/RuntimeSettingsDrawer.tsx` so the local primary CTA is keyed off the edited local `mode` instead of the previously saved `snapshot.config.connection_mode`.
- Result: runtime_lifecycle_score=100.0, unit_passed=1, build_passed=1, cargo_check_passed=1, local_mode_cta_passed=1.
- Insight: The previous segment was saturated partly because the harness was no longer measuring the live desktop contract. Once the benchmark targeted the Tauri runtime surface directly, it immediately exposed a real lifecycle regression: switching from remote to local mode could hide the apply/start CTA before save, making the saved local flow feel broken even though the backend state was valid.
- Next: raise the lifecycle score again with a stricter saved-startup path check, ideally covering the `stopped -> starting -> ready` transition and degraded behavior after a failed health revalidation.
### Run 44: cover saved local startup transitions and local onboarding from browse to ready - runtime_lifecycle_score=100.0 (KEEP)
- Timestamp: 2026-03-27 01:40
- What changed: Extended `tauri-app/src/features/runtime/components/RuntimeSurface.test.tsx` with two hook-backed lifecycle checks that run through `useRuntimeManager`: one now requires a saved local config to expose `Local API offline -> Starting local API -> Ready`, and another exercises local onboarding by browsing a models folder, auto-selecting the discovered model, saving config, and starting the local API from the same drawer flow. Updated `tauri-app/src/features/runtime/useRuntimeManager.ts` to publish optimistic `starting` and `stopping` snapshots while the Tauri commands are in flight. Raised `experiments/bench_tauri_runtime_surface.py` so the benchmark now records `local_mode_cta_passed`, `saved_startup_flow_passed`, and `local_onboarding_flow_passed` as explicit lifecycle checks alongside the broader runtime suite.
- Result: runtime_lifecycle_score=100.0, unit_passed=1, build_passed=1, cargo_check_passed=1, local_mode_cta_passed=1, saved_startup_flow_passed=1, local_onboarding_flow_passed=1.
- Insight: The saved local startup flow was previously trustworthy only after the backend returned; the desktop surface showed `Working...` but skipped the visible `starting` state entirely. Moving the in-flight state into `useRuntimeManager` closes that UX gap and makes the status card/drawer reflect the real lifecycle immediately. The onboarding smoke also proves the unified drawer now covers browse -> auto-select -> save -> start without falling back to separate legacy controls.
- Next: keep the lifecycle segment focused on truthfulness after startup, especially periodic `/health` revalidation that can demote a stale `ready` process into `degraded` when the child stops serving correctly.
### Run 45: revalidate managed local health after startup and surface degraded state - runtime_lifecycle_score=100.0 (KEEP)
- Timestamp: 2026-03-27 03:11
- What changed: Added a degraded-state lifecycle check to `tauri-app/src/features/runtime/components/RuntimeSurface.test.tsx`, extended `experiments/bench_tauri_runtime_surface.py` with `health_revalidation_passed`, and updated `tauri-app/src-tauri/src/runtime/health.rs` plus `tauri-app/src-tauri/src/runtime/mod.rs` so a running managed local child is periodically revalidated via `/health`. The runtime now demotes stale `ready` state to `degraded` when the process is still alive but no longer serves a healthy endpoint, and clears the `health_check_failed` error when health recovers. Added Rust tests covering both the degrade and recovery paths.
- Result: runtime_lifecycle_score=100.0, unit_passed=1, build_passed=1, cargo_check_passed=1, local_mode_cta_passed=1, saved_startup_flow_passed=1, local_onboarding_flow_passed=1, health_revalidation_passed=1.
- Insight: The frontend surface was already honest once it received a degraded snapshot; the real lifecycle gap was that backend runtime state could stay stuck at `ready` forever as long as the child process had not exited. Revalidating `/health` inside runtime state refresh closes that truthfulness gap without reintroducing a second frontend control plane.
- Next: this lifecycle segment is saturated again at `100.0%`. The next worthwhile move should raise the bar beyond state truthfulness, likely around a higher-fidelity desktop integration check or a real managed-runtime smoke path against an actual local model process.
## Segment 20: localllm Batch Assert
- Objective: stop the local `localllm` server from crashing on first chat completion with `GGML_ASSERT(n_tokens_all <= cparams.n_batch)`.
- Benchmark: `./autoresearch.sh` now runs a focused prompt-prefill regression harness in `experiments/bench_localllm_prefill_batch.py`.
- Keep/discard rule: higher `prefill_batch_assert_score` wins while the targeted prompt-prefill checks, `cargo check`, and a real `LOCALLLM_BATCH=128` completion remain green.
## Segment 21: Tauri Chat Active History
- Objective: make a newly sent message create/select its chat immediately, keep the live generation attached to that chat, and preserve history when revisiting generated chats.
- Benchmark: `./autoresearch.sh` now runs focused frontend regressions in `src/App.test.tsx` and `src/components/ChatView.test.tsx` plus `npm run build`.
- Keep/discard rule: higher `chat_history_score` wins while both the app-level send/create/history flow and the stale-request abort guard remain green.
### Run 47: preserve in-flight generation when chatId switches to the newly created chat - chat_history_score=100.0 (KEEP)
- Timestamp: 2026-03-28 20:06
- What changed: Added an app-level regression for sending from the empty composer into a brand-new chat, then updated `tauri-app/src/components/ChatView.tsx` to track the pending request chat id so `chatId` changes only abort when the user truly switches away to another chat, not when the composer promotes `null` into the just-created chat.
- Result: chat_history_score=100.0, app_send_flow_passed=1, chat_switch_abort_guard_passed=1, frontend_tests_passed=1, frontend_build_passed=1.
- Insight: The previous saved-chat race fix was directionally right but too broad. `null -> newly created chat id` is part of the same send flow, not a user navigation away from the active request.
- Next: if more reports appear, raise the bar with a browser/E2E smoke that sends a message, switches chats mid-stream, and returns to confirm persisted history from the rendered app shell.
- Initial hypothesis: `crates/inference-core/src/session.rs` still uses `llama_batch_get_one()` for full prompt prefill, which likely requests logits for every prompt token. Newer llama.cpp output-buffer semantics appear to need an explicit batch that only asks for logits on the final prompt token and splits larger prompts by `n_batch`.
- Baseline status: pending first red run.
### Run 46: chunk prompt prefill explicitly and fix live batch metadata - prefill_batch_assert_score=100.0 (KEEP)
- Timestamp: 2026-03-27 15:08
- What changed: Replaced full-prompt prefill in `crates/inference-core/src/session.rs` with an explicit chunked `llama_batch_init()` path that only requests logits for the final prompt token and splits prompts by `n_batch`. Passed `n_batch` from `crates/inference-core/src/engine.rs`, bound `llama_get_memory` / `llama_memory_clear` in `crates/llama-sys/src/lib.rs`, and cleared context memory after warmup so the live repro started from a clean state. The decisive live fix was also wiring `batch.n_tokens` correctly before each `llama_decode()` call.
- Result: prefill_batch_assert_score=100.0, prefill_plan_passed=1, cargo_check_passed=1, server_build_passed=1, live_completion_passed=1.
- Insight: The original GGML assert came from the old shorthand batch path over-requesting prompt outputs, but the first explicit prefill implementation still failed live because `llama_batch_init()` leaves `n_tokens` uninitialized. Once the prefill batch both requested only the final prompt logit and reported a valid token count, the real `LOCALLLM_BATCH=128` completion succeeded against the local Qwen GGUF without crashing or returning `generation_failed`.
- Next: keep this branch state and consider a follow-up hardening segment around longer multi-chunk prompts, multi-sequence compatibility, or a regression harness that distinguishes warmup-only startup success from first-request decode success.
+58
View File
@@ -0,0 +1,58 @@
# context-mode — MANDATORY routing rules
You have context-mode MCP tools available. These rules are NOT optional — they protect your context window from flooding. A single unrouted command can dump 56 KB into context and waste the entire session.
## BLOCKED commands — do NOT attempt these
### curl / wget — BLOCKED
Any shell command containing `curl` or `wget` will be intercepted and blocked by the context-mode plugin. Do NOT retry.
Instead use:
- `context-mode_ctx_fetch_and_index(url, source)` to fetch and index web pages
- `context-mode_ctx_execute(language: "javascript", code: "const r = await fetch(...)")` to run HTTP calls in sandbox
### Inline HTTP — BLOCKED
Any shell command containing `fetch('http`, `requests.get(`, `requests.post(`, `http.get(`, or `http.request(` will be intercepted and blocked. Do NOT retry with shell.
Instead use:
- `context-mode_ctx_execute(language, code)` to run HTTP calls in sandbox — only stdout enters context
### Direct web fetching — BLOCKED
Do NOT use any direct URL fetching tool. Use the sandbox equivalent.
Instead use:
- `context-mode_ctx_fetch_and_index(url, source)` then `context-mode_ctx_search(queries)` to query the indexed content
## REDIRECTED tools — use sandbox equivalents
### Shell (>20 lines output)
Shell is ONLY for: `git`, `mkdir`, `rm`, `mv`, `cd`, `ls`, `npm install`, `pip install`, and other short-output commands.
For everything else, use:
- `context-mode_ctx_batch_execute(commands, queries)` — run multiple commands + search in ONE call
- `context-mode_ctx_execute(language: "shell", code: "...")` — run in sandbox, only stdout enters context
### File reading (for analysis)
If you are reading a file to **edit** it → reading is correct (edit needs content in context).
If you are reading to **analyze, explore, or summarize** → use `context-mode_ctx_execute_file(path, language, code)` instead. Only your printed summary enters context.
### grep / search (large results)
Search results can flood context. Use `context-mode_ctx_execute(language: "shell", code: "grep ...")` to run searches in sandbox. Only your printed summary enters context.
## Tool selection hierarchy
1. **GATHER**: `context-mode_ctx_batch_execute(commands, queries)` — Primary tool. Runs all commands, auto-indexes output, returns search results. ONE call replaces 30+ individual calls.
2. **FOLLOW-UP**: `context-mode_ctx_search(queries: ["q1", "q2", ...])` — Query indexed content. Pass ALL questions as array in ONE call.
3. **PROCESSING**: `context-mode_ctx_execute(language, code)` | `context-mode_ctx_execute_file(path, language, code)` — Sandbox execution. Only stdout enters context.
4. **WEB**: `context-mode_ctx_fetch_and_index(url, source)` then `context-mode_ctx_search(queries)` — Fetch, chunk, index, query. Raw HTML never enters context.
5. **INDEX**: `context-mode_ctx_index(content, source)` — Store content in FTS5 knowledge base for later search.
## Output constraints
- Keep responses under 500 words.
- Write artifacts (code, configs, PRDs) to FILES — never return them as inline text. Return only: file path + 1-line description.
- When indexing content, use descriptive source labels so others can `search(source: "label")` later.
## ctx commands
| Command | Action |
|---------|--------|
| `ctx stats` | Call the `stats` MCP tool and display the full output verbatim |
| `ctx doctor` | Call the `doctor` MCP tool, run the returned shell command, display as checklist |
| `ctx upgrade` | Call the `upgrade` MCP tool, run the returned shell command, display as checklist |
+44 -3
View File
@@ -12,6 +12,7 @@
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-shell": "^2",
"dompurify": "^3.0.0",
"framer-motion": "^12.38.0",
"highlight.js": "^11.0.0",
"lucide-react": "^1.7.0",
"marked": "^12.0.0",
@@ -2755,6 +2756,33 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/framer-motion": {
"version": "12.38.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
"integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.38.0",
"motion-utils": "^12.36.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -3378,6 +3406,21 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/motion-dom": {
"version": "12.38.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz",
"integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.36.0"
}
},
"node_modules/motion-utils": {
"version": "12.36.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz",
"integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -4229,9 +4272,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.9.3",
+1
View File
@@ -15,6 +15,7 @@
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-shell": "^2",
"dompurify": "^3.0.0",
"framer-motion": "^12.38.0",
"highlight.js": "^11.0.0",
"lucide-react": "^1.7.0",
"marked": "^12.0.0",
+16
View File
@@ -879,6 +879,7 @@ dependencies = [
"tauri-plugin-dialog",
"tauri-plugin-shell",
"tokio",
"tracing",
]
[[package]]
@@ -907,6 +908,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
@@ -2909,7 +2911,9 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
@@ -4317,9 +4321,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
+2 -1
View File
@@ -24,7 +24,8 @@ tauri-plugin-shell = "2"
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "blocking"] }
tracing = "0.1"
tokio = { version = "1", features = ["fs", "io-util", "sync", "time"] }
runtime-deps = { path = "../../crates/runtime-deps" }
+549 -4
View File
@@ -1,15 +1,21 @@
use std::sync::Arc;
use std::time::Duration;
use tauri::{AppHandle, Manager, State};
use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager, State};
mod gpu;
mod model_library;
mod model_files;
mod model_alias;
mod runtime;
use runtime::process::find_localllm_binary;
use runtime::types::{RuntimeConfig, RuntimeSnapshot};
use runtime::{bootstrap_runtime, get_runtime_snapshot, save_runtime_config_command, start_runtime, stop_runtime, RuntimeState};
use runtime::{
bootstrap_runtime, get_runtime_snapshot, save_runtime_config_command, start_runtime,
start_runtime_with_status, stop_runtime, RuntimeState,
};
use gpu::query_gpu;
use model_library::huggingface::{
cancel_hugging_face_download, search_hugging_face_models, start_hugging_face_download,
@@ -21,6 +27,259 @@ use runtime::config::{load_runtime_config, save_runtime_config};
use model_library::types::{ModelLibraryEntry, ModelLibraryState};
use model_files::{import_model, pick_model_files, pick_models_dir, scan_models};
// ─── ForgeAgent types ─────────────────────────────────────────────────────────
#[derive(Clone, Serialize)]
#[serde(rename_all = "lowercase")]
enum ForgeAgentStepStatus {
Pending,
Running,
Completed,
Error,
}
#[derive(Clone, Serialize)]
struct ForgeAgentSubStep {
id: String,
description: String,
status: ForgeAgentStepStatus,
}
#[derive(Clone, Serialize)]
struct ForgeAgentStep {
id: u32,
title: String,
description: String,
status: ForgeAgentStepStatus,
sub_steps: Vec<ForgeAgentSubStep>,
}
#[derive(Clone, Serialize)]
struct ForgeAgentPlan {
title: String,
overview: String,
steps: Vec<ForgeAgentStep>,
}
#[derive(Clone, Serialize)]
struct ForgeAgentArtifact {
filename: String,
content: String,
language: String,
cta_label: String,
}
#[derive(Clone, Serialize)]
struct ForgeAgentStartPayload {
plan_id: String,
plan: ForgeAgentPlan,
}
#[derive(Clone, Serialize)]
struct ForgeAgentStepStartedPayload {
plan_id: String,
step_id: u32,
title: String,
}
#[derive(Clone, Serialize)]
struct ForgeAgentStepCompletedPayload {
plan_id: String,
step_id: u32,
message: String,
}
#[derive(Clone, Serialize)]
struct ForgeAgentArtifactReadyPayload {
plan_id: String,
artifact: ForgeAgentArtifact,
}
#[derive(Clone, Serialize)]
struct ForgeAgentPlanCompletedPayload {
plan_id: String,
summary: String,
}
// ─── Specialist event types ───────────────────────────────────────────────────
#[derive(Clone, Serialize)]
struct SpecialistThinkingStartedPayload {
plan_id: String,
specialist_id: String,
specialist_name: String,
specialist_title: String,
}
#[derive(Clone, Serialize)]
struct SpecialistThinkingUpdatePayload {
plan_id: String,
specialist_id: String,
thinking: String,
}
#[derive(Clone, Serialize)]
struct SpecialistResponseCompletedPayload {
plan_id: String,
specialist_id: String,
specialist_name: String,
thinking: String,
response: String,
}
#[derive(Clone, Serialize)]
struct AgentFinalResponsePayload {
plan_id: String,
summary: String,
}
// ─── Specialist definitions ───────────────────────────────────────────────────
struct Specialist {
id: &'static str,
name: &'static str,
title: &'static str,
emoji: &'static str,
prompt_snippet: &'static str,
}
const SPECIALISTS: &[Specialist] = &[
Specialist {
id: "analyst",
name: "Analista",
title: "Análise Técnica",
emoji: "🔍",
prompt_snippet: "Você é um analista técnico rigoroso. Analise o problema decompondo-o em componentes claros, identificando requisitos funcionais e não-funcionais, e avaliando trade-offs.",
},
Specialist {
id: "architect",
name: "Arquiteto",
title: "Arquitetura de Software",
emoji: "🏗️",
prompt_snippet: "Você é um arquiteto de software experiente. Analise a estrutura, identifique padrões relevantes, proponha abordagens escaláveis e considere a evolução do sistema.",
},
Specialist {
id: "developer",
name: "Desenvolvedor",
title: "Implementação",
emoji: "💻",
prompt_snippet: "Você é um desenvolvedor pragmático. Foque em código funcional, práticas modernas, legibilidade e manutenibilidade.",
},
Specialist {
id: "critic",
name: "Crítico",
title: "Revisão e Riscos",
emoji: "⚖️",
prompt_snippet: "Você é um crítico técnico. Identifique riscos, pontos fracos, casos edge e possíveis problemas antes de recomendar uma solução.",
},
];
fn build_forgeagent_plan(prompt: &str) -> ForgeAgentPlan {
let trimmed = prompt.trim().trim_end_matches('?');
let title = if trimmed.is_empty() {
"Planejando a tarefa".to_string()
} else {
trimmed.chars().take(48).collect()
};
let mut title_chars = title.chars();
let capitalized_title = match title_chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + title_chars.as_str(),
None => "Planejando a tarefa".to_string(),
};
ForgeAgentPlan {
title: capitalized_title,
overview: "Vou estruturar um plano curto, executar cada etapa visualmente e entregar a resposta final no estilo ForgeAgent.".to_string(),
steps: vec![
ForgeAgentStep {
id: 1,
title: "Entender a solicitação".to_string(),
description: "Quebrar o pedido em objetivo, stack e formato de entrega.".to_string(),
status: ForgeAgentStepStatus::Pending,
sub_steps: vec![ForgeAgentSubStep {
id: "1.1".to_string(),
description: "Extrair o objetivo principal do prompt".to_string(),
status: ForgeAgentStepStatus::Pending,
}],
},
ForgeAgentStep {
id: 2,
title: "Preparar a solução".to_string(),
description: "Montar a estrutura, dependências e fluxo recomendado.".to_string(),
status: ForgeAgentStepStatus::Pending,
sub_steps: vec![ForgeAgentSubStep {
id: "2.1".to_string(),
description: "Organizar a implementação em blocos claros".to_string(),
status: ForgeAgentStepStatus::Pending,
}],
},
ForgeAgentStep {
id: 3,
title: "Gerar a resposta final".to_string(),
description: "Entregar o conteúdo final com orientação prática e artefato.".to_string(),
status: ForgeAgentStepStatus::Pending,
sub_steps: vec![ForgeAgentSubStep {
id: "3.1".to_string(),
description: "Consolidar a saída final para o usuário".to_string(),
status: ForgeAgentStepStatus::Pending,
}],
},
],
}
}
fn resolve_runtime_url(config: &RuntimeConfig) -> String {
match config.connection_mode {
runtime::types::ConnectionMode::Local => format!("http://{}", config.addr.trim()),
runtime::types::ConnectionMode::Remote => config.server_url.trim().trim_end_matches('/').to_string(),
}
}
fn build_multiagent_system_prompt(user_prompt: &str) -> String {
let mut specialist_prompts = String::new();
for spec in SPECIALISTS {
specialist_prompts.push_str(&format!(
"- **{}** ({}) {}: {}\n",
spec.name, spec.title, spec.emoji, spec.prompt_snippet
));
}
format!(
r#"Você é o agente principal de um sistema multi-agente. Sua tarefa é coordenar múltiplos especialistas para responder à pergunta do usuário da melhor forma possível.
**Especialistas disponíveis:**
{}
**Tarefa:**
A pergunta do usuário é: "{}"
**Instruções:**
1. Analise a pergunta e identifique quais especialistas são relevantes (1 a 4)
2. Para cada especialista relevante, gere uma resposta no seguinte formato JSON:
{{
"specialist_id": "id_do_especialista",
"specialist_name": "Nome",
"thinking": "Sua cadeia de raciocínio - pense passo a passo sobre o problema",
"response": "Sua resposta final ao usuário sobre o assunto"
}}
3. After all specialists have responded, provide your synthesis as the main agent:
{{
"type": "final_response",
"summary": "Resumo consolidado de todas as perspectivas, com a recomendação final para o usuário"
}}
**Importante:**
- Responda TODOS os especialistas relevantes em uma única mensagem JSON
- O campo "thinking" deve mostrar a cadeia de raciocínio completa
- O campo "response" deve ser uma resposta substancial, não um resumo
- Após os especialistas, inclua sempre a resposta final do agente principal
- NÃO gere artefatos de arquivo a menos que o usuário solicite explicitamente
- Responda em português brasileiro"#,
specialist_prompts, user_prompt
)
}
// ─── Server lifecycle ─────────────────────────────────────────────────────────
#[tauri::command]
@@ -130,12 +389,18 @@ async fn swap_model_command(
// Load, update model_path, save
let mut config = load_runtime_config(&app)?;
config.model_path = Some(new_model_path);
config.model_path = Some(new_model_path.clone());
save_runtime_config(&app, &config)
.map_err(|e| format!("swap save failed: {e}"))?;
// CRITICAL: Update the internal state config so start_runtime uses the new model
{
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
inner.config = config;
}
// Restart with the updated config
start_runtime(app, state)
start_runtime_with_status(app, state, runtime::types::RuntimeStatus::Loading)
}
/// Delete a local .gguf model file from disk.
@@ -148,6 +413,28 @@ fn delete_model_file(file_path: String) -> Result<(), String> {
std::fs::remove_file(path).map_err(|e| format!("Delete failed: {e}"))
}
/// Resolve which model should be used at startup based on alias policy.
/// Returns None if no suitable model is found.
#[tauri::command]
fn resolve_startup_model_command(
app: AppHandle,
) -> Result<Option<model_alias::StartupModelResolution>, String> {
use crate::model_library::paths::resolve_models_dir;
let config = runtime::config::load_runtime_config(&app).unwrap_or_default();
let models_dir = resolve_models_dir(config.models_dir.as_deref());
let models = if models_dir.exists() {
let dir_str = models_dir.to_string_lossy().to_string();
model_files::scan_models(dir_str).unwrap_or_default()
} else {
Vec::new()
};
let preferred = config.preferred_startup_model_alias.as_deref();
Ok(model_alias::resolve_startup_model(preferred, &models))
}
/// Open the folder containing a model file in the native file explorer
/// and optionally select/highlight the file.
#[tauri::command]
@@ -209,6 +496,262 @@ fn get_model_library_state_command(app: AppHandle) -> Result<ModelLibraryState,
})
}
#[derive(serde::Deserialize)]
struct ChatCompletionMessage {
role: String,
content: String,
}
#[derive(serde::Deserialize)]
struct ChatCompletionChoice {
message: ChatCompletionMessage,
}
#[derive(serde::Deserialize)]
struct ChatCompletionResponse {
choices: Vec<ChatCompletionChoice>,
}
#[tauri::command]
async fn start_forgeagent_session_command(
app: AppHandle,
state: State<'_, RuntimeState>,
prompt: String,
) -> Result<ForgeAgentStartPayload, String> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|error| format!("clock error: {error}"))?
.as_millis();
let plan_id = format!("forgeagent-{now}");
let plan = build_forgeagent_plan(&prompt);
let response = ForgeAgentStartPayload {
plan_id: plan_id.clone(),
plan: plan.clone(),
};
let app_handle = app.clone();
// Get runtime state and validate before proceeding
let (runtime_url, connection_mode) = {
let inner = state.0.lock().map_err(|e| e.to_string())?;
let url = resolve_runtime_url(&inner.config);
let mode = inner.config.connection_mode.clone();
(url, mode)
};
// Validate runtime state for local mode before spawning background task
if connection_mode == runtime::types::ConnectionMode::Local {
let snapshot = get_runtime_snapshot_command(state.clone())?;
if !snapshot.process_running {
let _ = app_handle.emit(
"forgeagent:plan-completed",
ForgeAgentPlanCompletedPayload {
plan_id: plan_id.clone(),
summary: "O runtime local não está iniciado. Inicie o runtime antes de usar o ForgeAgent.".to_string(),
},
);
return Err("Runtime not running".into());
}
if !snapshot.ready {
let error_message = match snapshot.status.as_str() {
"loading" => "O modelo está carregando. Aguarde antes de enviar mensagens.".to_string(),
"error" => {
snapshot.last_error
.map(|e| format!("Erro no runtime: {}", e.message))
.unwrap_or_else(|| "O runtime encontrou um erro. Verifique os logs.".to_string())
},
"starting" => "O runtime está iniciando. Aguarde...".to_string(),
"stopping" => "O runtime está parando. Aguarde finalizar.".to_string(),
_ => "O runtime não está pronto. Tente reiniciar.".to_string(),
};
let _ = app_handle.emit(
"forgeagent:plan-completed",
ForgeAgentPlanCompletedPayload {
plan_id: plan_id.clone(),
summary: error_message,
},
);
return Err("Runtime not ready".into());
}
}
let system_prompt = build_multiagent_system_prompt(&prompt);
// Spawn background task
std::thread::spawn(move || {
// Simulate step progression
for step in &plan.steps {
let _ = app_handle.emit(
"forgeagent:step-started",
ForgeAgentStepStartedPayload {
plan_id: plan_id.clone(),
step_id: step.id,
title: step.title.clone(),
},
);
std::thread::sleep(Duration::from_millis(300));
let _ = app_handle.emit(
"forgeagent:step-completed",
ForgeAgentStepCompletedPayload {
plan_id: plan_id.clone(),
step_id: step.id,
message: match step.id {
1 => "Analisei a solicitação e defini o objetivo principal.".to_string(),
2 => "Estruturei a solução e organizei a execução em blocos claros.".to_string(),
_ => "Consolidei a entrega final.".to_string(),
},
},
);
}
// Build HTTP client
let Ok(client) = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(120))
.build()
else {
eprintln!("Failed to create HTTP client");
let _ = app_handle.emit(
"forgeagent:plan-completed",
ForgeAgentPlanCompletedPayload {
plan_id: plan_id.clone(),
summary: "Desculpe, houve um erro ao processar sua solicitação. Tente novamente.".to_string(),
},
);
return;
};
let request_body = serde_json::json!({
"model": "local",
"messages": [
{ "role": "system", "content": system_prompt },
{ "role": "user", "content": &prompt }
],
"stream": false,
"max_tokens": 2048
});
let Ok(resp) = client
.post(format!("{}/v1/chat/completions", runtime_url))
.header("Content-Type", "application/json")
.json(&request_body)
.send()
else {
eprintln!("Failed to call LLM");
let _ = app_handle.emit(
"forgeagent:plan-completed",
ForgeAgentPlanCompletedPayload {
plan_id: plan_id.clone(),
summary: "Desculpe, houve um erro de comunicação com o servidor de inferência.".to_string(),
},
);
return;
};
if !resp.status().is_success() {
eprintln!("LLM request failed: {}", resp.status());
let _ = app_handle.emit(
"forgeagent:plan-completed",
ForgeAgentPlanCompletedPayload {
plan_id: plan_id.clone(),
summary: "Desculpe, o servidor de inferência não está disponível. Verifique se o modelo está carregado.".to_string(),
},
);
return;
}
let Ok(data) = resp.json::<ChatCompletionResponse>() else {
eprintln!("Failed to parse LLM response");
let _ = app_handle.emit(
"forgeagent:plan-completed",
ForgeAgentPlanCompletedPayload {
plan_id: plan_id.clone(),
summary: "Desculpe, houve um erro ao processar a resposta. Tente novamente.".to_string(),
},
);
return;
};
let content = data.choices.first()
.map(|c| c.message.content.clone())
.unwrap_or_default();
let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
let _ = app_handle.emit(
"forgeagent:plan-completed",
ForgeAgentPlanCompletedPayload {
plan_id: plan_id.clone(),
summary: content,
},
);
return;
};
if let Some(specialists) = json.get("specialists").and_then(|v| v.as_array()) {
for spec in specialists {
let specialist_id = spec.get("specialist_id")
.and_then(|v| v.as_str()).unwrap_or("unknown").to_string();
let specialist_name = spec.get("specialist_name")
.and_then(|v| v.as_str()).unwrap_or("Especialista").to_string();
let thinking = spec.get("thinking")
.and_then(|v| v.as_str()).unwrap_or("").to_string();
let spec_response = spec.get("response")
.and_then(|v| v.as_str()).unwrap_or("").to_string();
let _ = app_handle.emit(
"forgeagent:specialist-thought-started",
SpecialistThinkingStartedPayload {
plan_id: plan_id.clone(),
specialist_id: specialist_id.clone(),
specialist_name: specialist_name.clone(),
specialist_title: specialist_name.clone(),
},
);
if !thinking.is_empty() {
let _ = app_handle.emit(
"forgeagent:specialist-thought-update",
SpecialistThinkingUpdatePayload {
plan_id: plan_id.clone(),
specialist_id: specialist_id.clone(),
thinking: thinking.clone(),
},
);
}
let _ = app_handle.emit(
"forgeagent:specialist-response-completed",
SpecialistResponseCompletedPayload {
plan_id: plan_id.clone(),
specialist_id,
specialist_name,
thinking,
response: spec_response,
},
);
}
}
let summary = json.get("summary")
.and_then(|v| v.as_str())
.unwrap_or(&content)
.to_string();
let _ = app_handle.emit(
"forgeagent:plan-completed",
ForgeAgentPlanCompletedPayload {
plan_id: plan_id.clone(),
summary,
},
);
});
Ok(response)
}
// ─── App entry ────────────────────────────────────────────────────────────────
#[cfg_attr(mobile, tauri::mobile_entry_point)]
@@ -237,8 +780,10 @@ pub fn run() {
save_runtime_config_command_handler,
start_runtime_command,
stop_runtime_command,
resolve_startup_model_command,
get_app_data_dir,
find_server_binary,
start_forgeagent_session_command,
])
.setup(|app| {
#[cfg(debug_assertions)]
+228
View File
@@ -0,0 +1,228 @@
//! Model alias resolution and startup model selection policy.
//!
//! Alias table:
//! 0.8B family → "Cooper"
//! 2B family → "Iron"
//! 4B family → "Steel"
use crate::model_files::ModelFile;
/// Canonical display alias for a model family.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ModelAlias {
Cooper,
Iron,
Steel,
Unknown,
}
impl ModelAlias {
pub fn as_str(&self) -> &'static str {
match self {
ModelAlias::Cooper => "Cooper",
ModelAlias::Iron => "Iron",
ModelAlias::Steel => "Steel",
ModelAlias::Unknown => "Unknown",
}
}
}
/// Resolve a model file path/name to a display alias.
/// Priority:
/// 1. filename contains "0.8b" (case-insensitive) → Cooper
/// 2. filename contains "4b" (case-insensitive) → Steel (checked before 2b to avoid false match)
/// 3. filename contains "2b" (case-insensitive) → Iron
/// 4. fallback → Unknown
pub fn resolve_alias(path: &str) -> ModelAlias {
let lower = path.to_lowercase();
// Extract just the filename for matching
let filename = lower.rsplit(['/', '\\']).next().unwrap_or(&lower);
if filename.contains("0.8b") {
ModelAlias::Cooper
} else if filename.contains("4b") {
ModelAlias::Steel
} else if filename.contains("2b") {
ModelAlias::Iron
} else {
ModelAlias::Unknown
}
}
/// Result of startup model resolution.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct StartupModelResolution {
pub model_path: String,
pub alias: ModelAlias,
pub reason: StartupReason,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StartupReason {
SavedPreference,
DefaultIron,
FallbackCooper,
MissingSavedModel,
}
/// Resolve which model to start at app launch.
///
/// Policy (from SDD):
/// 1. If user saved a preferred alias and a matching model exists → use it
/// 2. Prefer Iron (2B)
/// 3. Fallback to Cooper (0.8B)
/// 4. Steel is ignored by auto-start policy
pub fn resolve_startup_model(
preferred_alias: Option<&str>,
discovered: &[ModelFile],
) -> Option<StartupModelResolution> {
if discovered.is_empty() {
return None;
}
// Classify all discovered models
let classified: Vec<(&ModelFile, ModelAlias)> = discovered
.iter()
.map(|m| (m, resolve_alias(&m.path)))
.collect();
// 1. Respect saved preference if available
if let Some(pref) = preferred_alias {
let target_alias = match pref.to_lowercase().as_str() {
"cooper" => ModelAlias::Cooper,
"iron" => ModelAlias::Iron,
"steel" => ModelAlias::Steel,
_ => ModelAlias::Unknown,
};
if target_alias != ModelAlias::Unknown {
if let Some((model, alias)) = classified.iter().find(|(_, a)| *a == target_alias) {
return Some(StartupModelResolution {
model_path: model.path.clone(),
alias: alias.clone(),
reason: StartupReason::SavedPreference,
});
}
// Saved model not found — fall through to default policy with MissingSavedModel reason
}
}
// 2. Prefer Iron
if let Some((model, _)) = classified.iter().find(|(_, a)| *a == ModelAlias::Iron) {
return Some(StartupModelResolution {
model_path: model.path.clone(),
alias: ModelAlias::Iron,
reason: StartupReason::DefaultIron,
});
}
// 3. Fallback to Cooper
if let Some((model, _)) = classified.iter().find(|(_, a)| *a == ModelAlias::Cooper) {
return Some(StartupModelResolution {
model_path: model.path.clone(),
alias: ModelAlias::Cooper,
reason: StartupReason::FallbackCooper,
});
}
// 4. No suitable model found (Steel only or Unknown only)
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model_files::ModelFile;
fn make_model(name: &str) -> ModelFile {
ModelFile {
name: name.to_string(),
path: format!("D:/models/{name}"),
}
}
#[test]
fn resolve_alias_detects_cooper_from_filename() {
assert_eq!(
resolve_alias("D:/models/rahul7star_Qwen3.5-0.8B-Coder.gguf"),
ModelAlias::Cooper
);
}
#[test]
fn resolve_alias_detects_iron_from_filename() {
assert_eq!(
resolve_alias("D:/models/Qwen3.5-2B.Q6_K.gguf"),
ModelAlias::Iron
);
}
#[test]
fn resolve_alias_detects_steel_from_filename() {
assert_eq!(
resolve_alias("D:/models/Qwen3.5-4B-Abliterated.i1-Q6_K.gguf"),
ModelAlias::Steel
);
}
#[test]
fn resolve_alias_returns_unknown_for_unrecognized() {
assert_eq!(
resolve_alias("D:/models/some-other-model.gguf"),
ModelAlias::Unknown
);
}
#[test]
fn startup_resolver_picks_iron_when_both_available() {
let models = vec![
make_model("rahul7star_Qwen3.5-0.8B-Coder.gguf"),
make_model("Qwen3.5-2B.Q6_K.gguf"),
];
let result = resolve_startup_model(None, &models).unwrap();
assert_eq!(result.alias, ModelAlias::Iron);
assert_eq!(result.reason, StartupReason::DefaultIron);
}
#[test]
fn startup_resolver_falls_back_to_cooper_when_iron_missing() {
let models = vec![make_model("rahul7star_Qwen3.5-0.8B-Coder.gguf")];
let result = resolve_startup_model(None, &models).unwrap();
assert_eq!(result.alias, ModelAlias::Cooper);
assert_eq!(result.reason, StartupReason::FallbackCooper);
}
#[test]
fn startup_resolver_respects_saved_preference() {
let models = vec![
make_model("rahul7star_Qwen3.5-0.8B-Coder.gguf"),
make_model("Qwen3.5-2B.Q6_K.gguf"),
];
let result = resolve_startup_model(Some("cooper"), &models).unwrap();
assert_eq!(result.alias, ModelAlias::Cooper);
assert_eq!(result.reason, StartupReason::SavedPreference);
}
#[test]
fn startup_resolver_ignores_steel_in_auto_policy() {
let models = vec![make_model("Qwen3.5-4B-Abliterated.i1-Q6_K.gguf")];
let result = resolve_startup_model(None, &models);
assert!(result.is_none(), "Steel should not be auto-selected");
}
#[test]
fn startup_resolver_returns_none_when_no_models() {
let result = resolve_startup_model(None, &[]);
assert!(result.is_none());
}
#[test]
fn startup_resolver_falls_through_when_saved_preference_not_found() {
// Saved preference is "iron" but only Cooper is available → fallback to Cooper
let models = vec![make_model("rahul7star_Qwen3.5-0.8B-Coder.gguf")];
let result = resolve_startup_model(Some("iron"), &models).unwrap();
assert_eq!(result.alias, ModelAlias::Cooper);
assert_eq!(result.reason, StartupReason::FallbackCooper);
}
}
+60 -14
View File
@@ -67,6 +67,36 @@ fn scan_model_dir_top_level(path: &Path) -> Result<Vec<ModelFile>, String> {
Ok(models)
}
/// Scans only `models/text-llm/` for `.gguf` files.
/// All other directories are ignored.
fn scan_model_dir_for_chat(dir: &Path, models: &mut Vec<ModelFile>) -> Result<(), String> {
let text_llm_dir = dir.join("text-llm");
if !text_llm_dir.exists() {
return Ok(());
}
if !text_llm_dir.is_dir() {
return Err(format!("Not a directory: {}", text_llm_dir.display()));
}
let entries = std::fs::read_dir(text_llm_dir).map_err(|e| e.to_string())?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_file()
&& path
.extension()
.map(|ext| ext.eq_ignore_ascii_case("gguf"))
.unwrap_or(false)
{
models.push(model_file_from_path(&path));
}
}
Ok(())
}
/// Recursively scans all subdirectories for .gguf files.
/// Kept for future use when deep scanning may be needed.
#[allow(dead_code)]
@@ -134,7 +164,18 @@ pub fn scan_models(dir: String) -> Result<Vec<ModelFile>, String> {
} else {
resolve_models_dir(Some(&dir))
};
scan_model_dir_top_level(&path)
if !path.exists() {
return Err(format!("Directory not found: {}", path.display()));
}
if !path.is_dir() {
return Err(format!("Not a directory: {}", path.display()));
}
let mut models = Vec::new();
scan_model_dir_for_chat(&path, &mut models)?;
models.sort_by(|a, b| a.name.cmp(&b.name));
Ok(models)
}
#[tauri::command]
@@ -177,7 +218,7 @@ pub fn import_model(src_path: String, dest_dir: String) -> Result<ModelFile, Str
#[cfg(test)]
mod tests {
use super::{import_model_file, scan_model_dir};
use super::{import_model_file, scan_model_dir, scan_model_dir_for_chat};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
@@ -220,21 +261,26 @@ mod tests {
}
#[test]
fn scan_model_dir_returns_only_top_level_gguf() {
// Tests that scan_model_dir (now using top-level scan) does NOT recurse into subdirectories
let dir = temp_path("scan-models-top-level");
let sub = dir.join("mradermacher").join("Suri-Qwen-3.5-4B-Uncensored-i1-GGUF");
std::fs::create_dir_all(&sub).expect("create nested dirs");
fn scan_model_dir_for_chat_only_scans_text_llm_directory() {
let dir = temp_path("scan-models-text-llm");
let embeddings = dir.join("embeddings");
let audio = dir.join("audio");
let text_llm = dir.join("text-llm");
std::fs::create_dir_all(&embeddings).expect("create embeddings dir");
std::fs::create_dir_all(&audio).expect("create audio dir");
std::fs::create_dir_all(&text_llm).expect("create text-llm dir");
std::fs::write(dir.join("top-level.gguf"), b"demo").expect("write top-level model");
std::fs::write(sub.join("nested-model.gguf"), b"demo").expect("write nested model");
std::fs::write(sub.join("notes.txt"), b"ignore").expect("write non-model file");
std::fs::write(embeddings.join("embedding-model.gguf"), b"demo")
.expect("write embedding model");
std::fs::write(audio.join("whisper.gguf"), b"demo").expect("write audio model");
std::fs::write(text_llm.join("chat-model.gguf"), b"demo").expect("write chat model");
let models = scan_model_dir(&dir).expect("scan model dir");
let names: Vec<_> = models.iter().map(|m| m.name.as_str()).collect();
let mut models = Vec::new();
scan_model_dir_for_chat(&dir, &mut models).expect("scan for chat");
let names: Vec<_> = models.into_iter().map(|m| m.name).collect();
// Only top-level model should be returned, NOT nested ones
assert_eq!(names, vec!["top-level.gguf"]);
assert!(models.iter().all(|m| m.path.ends_with(".gguf")));
assert_eq!(names, vec!["chat-model.gguf"]);
let _ = std::fs::remove_dir_all(&dir);
}
+61 -9
View File
@@ -69,6 +69,7 @@ fn start_runtime_inner(
app: &AppHandle,
state: &State<'_, RuntimeState>,
override_config: Option<RuntimeConfig>,
startup_status: RuntimeStatus,
) -> Result<RuntimeSnapshot, String> {
let command = if override_config.is_some() {
RuntimeLifecycleCommand::SaveAndStart
@@ -113,7 +114,7 @@ fn start_runtime_inner(
return Ok(build_snapshot(&inner));
}
inner.status = RuntimeStatus::Starting;
inner.status = startup_status;
inner.last_error = None;
inner.config.clone()
};
@@ -137,7 +138,7 @@ fn start_runtime_inner(
{
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
inner.child = Some(child);
inner.status = RuntimeStatus::Starting;
inner.status = startup_status;
}
if wait_for_health(&config.addr, Duration::from_secs(15)) {
@@ -164,10 +165,17 @@ fn start_runtime_inner(
}
inner.child = None;
inner.status = RuntimeStatus::Error;
let message = format!(
"Local API did not pass /health readiness on {} in time",
config.addr
);
let message = if startup_status == RuntimeStatus::Loading {
format!(
"Model loading timed out before the local API became ready on {}",
config.addr
)
} else {
format!(
"Local API did not pass /health readiness on {} in time",
config.addr
)
};
inner.last_error = Some(runtime_error("health_timeout", &message));
finish_lifecycle_command(
&mut inner,
@@ -183,7 +191,43 @@ pub fn bootstrap_runtime(
app: AppHandle,
state: State<'_, RuntimeState>,
) -> Result<RuntimeSnapshot, String> {
let config = load_runtime_config(&app).unwrap_or_default();
use crate::model_alias;
use crate::model_library::paths::resolve_models_dir;
let mut config = load_runtime_config(&app).unwrap_or_default();
// Auto-resolve model if none is configured yet
let model_path_empty = config
.model_path
.as_deref()
.map(|p| p.trim().is_empty())
.unwrap_or(true);
if model_path_empty && config.connection_mode == ConnectionMode::Local {
let models_dir = resolve_models_dir(config.models_dir.as_deref());
let models = if models_dir.exists() {
let dir_str = models_dir.to_string_lossy().to_string();
crate::model_files::scan_models(dir_str).unwrap_or_default()
} else {
Vec::new()
};
if let Some(resolution) = model_alias::resolve_startup_model(
config.preferred_startup_model_alias.as_deref(),
&models,
) {
eprintln!(
"[bootstrap] auto-selected model: {} ({:?}) reason={:?}",
resolution.alias.as_str(),
resolution.model_path,
resolution.reason
);
config.model_path = Some(resolution.model_path);
// Auto-enable OnAppLaunch when we found a model automatically
config.start_policy = StartPolicy::OnAppLaunch;
}
}
let should_autostart = config.connection_mode == ConnectionMode::Local
&& config.start_policy == StartPolicy::OnAppLaunch
&& config
@@ -199,7 +243,7 @@ pub fn bootstrap_runtime(
}
if should_autostart {
start_runtime_inner(&app, &state, Some(config))
start_runtime_inner(&app, &state, Some(config), RuntimeStatus::Loading)
} else {
get_runtime_snapshot(state)
}
@@ -209,7 +253,15 @@ pub fn start_runtime(
app: AppHandle,
state: State<'_, RuntimeState>,
) -> Result<RuntimeSnapshot, String> {
start_runtime_inner(&app, &state, None)
start_runtime_inner(&app, &state, None, RuntimeStatus::Starting)
}
pub fn start_runtime_with_status(
app: AppHandle,
state: State<'_, RuntimeState>,
startup_status: RuntimeStatus,
) -> Result<RuntimeSnapshot, String> {
start_runtime_inner(&app, &state, None, startup_status)
}
pub fn stop_runtime(state: State<'_, RuntimeState>) -> Result<RuntimeSnapshot, String> {
+4 -1
View File
@@ -1,5 +1,5 @@
use std::path::PathBuf;
use std::process::{Child, Command};
use std::process::{Child, Command, Stdio};
use tauri::{AppHandle, Manager};
@@ -102,6 +102,9 @@ pub fn spawn_localllm(app: &AppHandle, config: &RuntimeConfig) -> Result<Child,
.env("LOCALLLM_CTX", config.ctx_size.to_string());
apply_runtime_to_command(&mut command, &runtime);
// Suppress verbose llama.cpp logs (create_tensor, etc.) by redirecting stderr
command.stderr(Stdio::null());
command
.spawn()
.map_err(|e| format!("Failed to start server ({}): {e}", binary_path.display()))
+23 -2
View File
@@ -99,10 +99,12 @@ pub(crate) fn guard_start_request(inner: &mut RuntimeInner) -> Result<(), Runtim
if matches!(
inner.status,
RuntimeStatus::Starting | RuntimeStatus::Stopping
RuntimeStatus::Starting | RuntimeStatus::Loading | RuntimeStatus::Stopping
) {
let message = if inner.status == RuntimeStatus::Stopping {
"Local API stop already in progress."
} else if inner.status == RuntimeStatus::Loading {
"Local API model is still loading."
} else {
"Local API start already in progress."
};
@@ -136,10 +138,12 @@ pub(crate) fn guard_stop_request(inner: &mut RuntimeInner) -> Result<(), Runtime
if matches!(
inner.status,
RuntimeStatus::Starting | RuntimeStatus::Stopping
RuntimeStatus::Starting | RuntimeStatus::Loading | RuntimeStatus::Stopping
) {
let message = if inner.status == RuntimeStatus::Starting {
"Local API start already in progress."
} else if inner.status == RuntimeStatus::Loading {
"Local API model is still loading."
} else {
"Local API stop already in progress."
};
@@ -449,6 +453,23 @@ mod tests {
assert_eq!(inner.active_command, RuntimeLifecycleCommand::Start);
}
#[test]
fn guard_start_request_blocks_when_model_is_loading() {
let mut inner = RuntimeInner {
child: None,
status: RuntimeStatus::Loading,
last_error: None,
active_command: RuntimeLifecycleCommand::Idle,
last_command_result: RuntimeLifecycleResult::default(),
config: RuntimeConfig::default(),
};
let blocked = guard_start_request(&mut inner).expect_err("start should be blocked");
assert_eq!(blocked.command, RuntimeLifecycleCommand::Start);
assert_eq!(blocked.message, "Local API model is still loading.");
}
#[test]
fn guard_stop_request_blocks_when_runtime_is_already_stopped() {
let mut inner = RuntimeInner {
+3
View File
@@ -26,6 +26,7 @@ pub enum RuntimeStatus {
#[default]
Stopped,
Starting,
Loading,
Ready,
Degraded,
Stopping,
@@ -85,6 +86,7 @@ pub struct RuntimeConfig {
pub selected_model_id: Option<String>,
pub remote_presets: Vec<RemotePreset>,
pub hugging_face_token: String,
pub preferred_startup_model_alias: Option<String>,
}
impl RuntimeConfig {
@@ -109,6 +111,7 @@ impl Default for RuntimeConfig {
selected_model_id: None,
remote_presets: Vec::new(),
hugging_face_token: String::new(),
preferred_startup_model_alias: None,
}
}
}
+6 -1
View File
@@ -65,7 +65,10 @@ export default function App() {
const handleModelSwap = useCallback(async (newModelPath: string) => {
setIsSwapping(true);
try {
await swapModel(newModelPath);
const next = await swapModel(newModelPath);
if (next.status !== "ready") {
throw new Error(next.last_error?.message || "The selected model did not finish loading.");
}
setSelectedModel(newModelPath);
} catch (err) {
console.error("[App] handleModelSwap failed:", err);
@@ -129,6 +132,8 @@ export default function App() {
onModelSwap={handleModelSwap}
isSwapping={isSwapping}
status={status}
runtimeMode={runtime.snapshot.config.connection_mode}
runtimeStatus={runtime.snapshot.status}
onChatCreated={(id) => { setCurrentChatId(id); loadChats(); setSidebarOpen(false); }}
onChatUpdated={loadChats}
onOpenSidebar={() => setSidebarOpen(true)}
+181 -149
View File
@@ -1134,18 +1134,18 @@
/* ── Initial screen / Manus-like landing ── */
.chat-view.initial-screen {
background: #252525;
background: var(--bg);
}
.chat-view.initial-screen .chat-topbar {
background: #252525;
background: transparent;
border-bottom-color: transparent;
height: 56px;
padding: 0 24px 0 18px;
}
.chat-view.initial-screen .chat-topbar-center {
display: none;
display: flex;
}
.chat-view.initial-screen .chat-topbar-right {
@@ -1166,37 +1166,26 @@
}
.topbar-model-badge--hero .topbar-model-name {
color: #ece9e4;
font-size: 1rem;
font-weight: 600;
color: var(--text);
font-size: 0.95rem;
font-weight: 500;
letter-spacing: -0.01em;
}
.topbar-state-pill--hero {
min-height: 34px;
padding: 0 14px;
border-color: rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.025);
color: rgba(255, 255, 255, 0.78);
font-size: 0.75rem;
letter-spacing: 0;
text-transform: none;
}
.messages-container--hero {
min-height: calc(100dvh - 44px);
padding: 84px 24px 56px;
min-height: calc(100dvh - 56px);
padding: 62px 24px 40px;
display: flex;
align-items: center;
justify-content: center;
}
.empty-state--hero {
width: min(100%, 760px);
width: min(100%, 920px);
display: flex;
flex-direction: column;
align-items: center;
gap: 28px;
gap: 20px;
}
.empty-state-panel--hero {
@@ -1204,7 +1193,7 @@
display: flex;
flex-direction: column;
align-items: center;
gap: 30px;
gap: 22px;
}
.empty-state-header--hero {
@@ -1214,49 +1203,42 @@
gap: 14px;
}
.empty-kicker {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 34px;
padding: 0 14px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.035);
border: 1px solid rgba(255, 255, 255, 0.045);
color: rgba(255, 255, 255, 0.68);
font-size: 0.84rem;
}
.empty-state-header--hero h1 {
font-family: var(--font-serif);
font-size: clamp(3rem, 4.6vw, 4.1rem);
line-height: 1.02;
font-family: inherit;
font-size: clamp(3.3rem, 5vw, 4.4rem);
line-height: 0.98;
letter-spacing: -0.045em;
font-weight: 400;
color: #ece7e0;
font-weight: 500;
color: #fff;
}
.hero-composer-block {
width: min(100%, 686px);
width: min(100%, 760px);
display: flex;
flex-direction: column;
gap: 14px;
gap: 12px;
}
.input-box--hero {
min-height: 154px;
min-height: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 16px 16px 10px;
border-radius: 22px;
background: #343233;
border-color: rgba(255, 255, 255, 0.05);
box-shadow: none;
justify-content: center;
padding: 14px 16px 12px;
border-radius: 28px;
background: rgba(18, 18, 20, 0.96);
border: 1px solid rgba(255, 255, 255, 0.06);
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.28);
}
.input-box--hero:focus-within {
border-color: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.12);
}
.hero-composer-row {
display: flex;
align-items: center;
gap: 12px;
}
.input-box-main {
@@ -1266,62 +1248,97 @@
gap: 12px;
}
.input-box-main--hero {
gap: 14px;
}
.input-box--hero textarea {
min-height: 84px;
min-height: 48px;
font-size: 16px;
line-height: 1.55;
line-height: 1.5;
align-self: stretch;
padding: 4px 0 10px;
color: rgba(255, 255, 255, 0.9);
padding: 10px 0 6px;
color: rgba(255, 255, 255, 0.98);
}
.input-box--hero textarea::placeholder {
color: rgba(255, 255, 255, 0.26);
color: rgba(255, 255, 255, 0.42);
}
.input-box--hero .attachment-actions {
align-self: flex-start;
.attachment-actions--hero {
align-self: center;
gap: 8px;
margin-bottom: 0;
padding-bottom: 0;
gap: 6px;
margin-bottom: 4px;
}
.input-box--hero .send-btn,
.input-box--hero .stop-btn {
align-self: flex-end;
.attachment-actions--hero button {
width: 34px;
height: 34px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.04);
}
.hero-composer-actions {
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 4px;
}
.hero-utility-btn {
width: 38px;
height: 38px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.035);
color: rgba(255, 255, 255, 0.78);
}
.send-btn--hero,
.stop-btn--hero {
width: 42px;
height: 42px;
min-width: 42px;
border-radius: 999px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 0;
}
.composer-footer-row {
.composer-footer-row--hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
gap: 12px;
padding-top: 10px;
border-top: 1px solid rgba(255, 255, 255, 0.04);
}
.composer-footer-hint {
.composer-footer-hint--hero {
color: rgba(255, 255, 255, 0.55);
font-size: 0.84rem;
font-size: 0.82rem;
}
.composer-footer-icons {
.composer-footer-icons--hero {
display: flex;
align-items: center;
gap: 4px;
gap: 6px;
}
.composer-footer-icons span {
width: 16px;
height: 16px;
.composer-footer-icons--hero span {
width: 22px;
height: 22px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
border: 1px solid rgba(255, 255, 255, 0.07);
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.04);
}
.starter-chip-rail {
width: min(100%, 780px);
width: min(100%, 760px);
display: flex;
flex-wrap: wrap;
justify-content: center;
@@ -1330,99 +1347,88 @@
.starter-chip--hero {
width: auto;
padding: 11px 16px;
padding: 10px 16px;
border-radius: 999px;
background: transparent;
border-color: rgba(255, 255, 255, 0.07);
color: rgba(255, 255, 255, 0.9);
background: rgba(39, 39, 42, 0.96);
border-color: rgba(255, 255, 255, 0.05);
color: #fff;
}
.hero-card-carousel {
width: min(100%, 640px);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
margin-top: 32px;
.starter-chip--hero:hover {
background: rgba(63, 63, 70, 0.96);
}
.hero-card {
width: min(100%, 508px);
min-height: 72px;
.hero-promo-card {
width: min(100%, 760px);
display: flex;
align-items: center;
justify-content: space-between;
text-align: left;
border: 1px solid rgba(255, 255, 255, 0.04);
background: rgba(255, 255, 255, 0.025);
border-radius: 12px;
padding: 14px 16px;
color: var(--text2);
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.hero-card:active {
transform: none;
}
.hero-card:hover {
border-color: rgba(255, 255, 255, 0.08);
background: #393738;
}
.hero-card-title {
display: block;
margin-bottom: 8px;
color: var(--text);
font-size: 0.92rem;
font-weight: 600;
}
.hero-card-copy {
display: block;
font-size: 0.82rem;
line-height: 1.55;
color: var(--text2);
}
.hero-card-preview {
width: 112px;
height: 58px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.03);
gap: 18px;
margin-top: 22px;
padding: 18px 18px 18px 20px;
border-radius: 24px;
background: rgba(24, 24, 27, 0.92);
border: 1px solid rgba(255, 255, 255, 0.05);
padding: 10px;
}
.hero-promo-copy {
display: flex;
flex-direction: column;
gap: 6px;
max-width: 420px;
}
.hero-card-preview span {
.hero-promo-title {
color: #fff;
font-size: 0.98rem;
font-weight: 500;
}
.hero-promo-subtitle {
color: var(--text2);
font-size: 0.84rem;
line-height: 1.5;
}
.hero-promo-preview {
width: 186px;
min-width: 186px;
height: 106px;
border-radius: 18px;
padding: 12px;
display: grid;
grid-template-columns: 1.5fr 1fr;
grid-template-rows: 10px 1fr;
gap: 8px;
background: linear-gradient(180deg, rgba(39, 39, 42, 0.98), rgba(24, 24, 27, 0.98));
border: 1px solid rgba(255, 255, 255, 0.06);
}
.hero-promo-preview-bar,
.hero-promo-preview-pane,
.hero-promo-preview .active {
display: block;
height: 6px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.1);
}
.hero-card-preview span:last-child {
width: 66%;
background: rgba(75, 134, 255, 0.2);
.hero-promo-preview-bar {
grid-column: 1 / -1;
}
.hero-card-pager {
display: none;
.hero-promo-preview-pane {
border-radius: 14px;
}
.hero-card-pager span {
width: 8px;
height: 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.18);
.hero-promo-preview-pane--wide {
background: rgba(255, 255, 255, 0.07);
}
.hero-card-pager span.active {
background: rgba(255, 255, 255, 0.42);
.hero-promo-preview .active {
width: 44px;
height: 10px;
align-self: end;
background: rgba(96, 165, 250, 0.44);
}
/* ── Responsive ── */
@@ -1452,12 +1458,13 @@
.messages-container--hero { padding: 56px 14px 32px; }
.empty-state--hero { gap: 18px; }
.empty-state-header--hero h1 { font-size: clamp(2.2rem, 8vw, 3.3rem); }
.hero-card-carousel { margin-top: 48px; width: 100%; }
.hero-card { width: 100%; }
.hero-card-preview { display: none; }
.input-box--hero { min-height: 136px; padding: 16px; border-radius: 20px; }
.input-box--hero textarea { min-height: 80px; font-size: 15px; }
.composer-footer-row { flex-direction: column; align-items: flex-start; }
.hero-composer-row { flex-direction: column; align-items: stretch; }
.attachment-actions--hero { align-self: flex-start; }
.input-box--hero { padding: 16px; border-radius: 24px; }
.input-box--hero textarea { min-height: 72px; font-size: 15px; }
.composer-footer-row--hero { flex-direction: column; align-items: flex-start; }
.hero-promo-card { width: 100%; flex-direction: column; align-items: flex-start; }
.hero-promo-preview { min-width: 100%; width: 100%; }
}
/* ── Model Selector Pill (in input area) ── */
@@ -1582,3 +1589,28 @@
.input-box--hero .model-selector-pill-wrapper {
margin-bottom: 12px;
}
.chat-debug-fab {
position: fixed;
right: 20px;
bottom: 104px;
z-index: 40;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(17, 24, 39, 0.92);
color: #f8fafc;
border-radius: 999px;
padding: 10px 14px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.01em;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(14px);
}
.chat-debug-fab:hover {
background: rgba(30, 41, 59, 0.98);
}
.chat-debug-fab.copied {
background: rgba(22, 101, 52, 0.95);
border-color: rgba(134, 239, 172, 0.28);
}
@@ -0,0 +1,133 @@
/* @vitest-environment jsdom */
import { createRoot, type Root } from "react-dom/client";
import { act } from "react-dom/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DB } from "../lib/db";
import ChatView from "./ChatView";
function createDbStub(): DB {
return {
insert: vi.fn(() => 1),
query: vi.fn((sql: string) => {
if (sql.includes("FROM messages WHERE chat_id = ? ORDER BY created_at ASC")) {
return [
{ id: 1, chat_id: 1, role: "user", content: "Pergunta", created_at: 1 },
{ id: 2, chat_id: 1, role: "assistant", content: "Resposta", created_at: 2 },
];
}
return [];
}),
get: vi.fn(() => null),
run: vi.fn(),
persist: vi.fn(),
} as unknown as DB;
}
const defaultProps = {
models: [],
localModels: [],
selectedModel: "",
onModelChange: vi.fn(),
status: "ok" as const,
};
describe("ChatView debug tools", () => {
let container: HTMLDivElement;
let root: Root;
const writeText = vi.fn(() => Promise.resolve());
beforeEach(() => {
// @ts-expect-error React test environment flag
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
Element.prototype.scrollIntoView = vi.fn();
Object.assign(navigator, { clipboard: { writeText } });
});
afterEach(() => {
act(() => root.unmount());
container.remove();
writeText.mockClear();
vi.restoreAllMocks();
// @ts-expect-error React test environment flag
globalThis.IS_REACT_ACT_ENVIRONMENT = false;
});
it("shows a floating debug copy button only when debug tools are enabled", async () => {
const db = createDbStub();
await act(async () => {
root.render(
<ChatView
db={db}
chatId={1}
server="http://127.0.0.1:1234"
apiKey=""
model="demo"
onChatCreated={vi.fn()}
onChatUpdated={vi.fn()}
showDebugTools
{...defaultProps}
/>,
);
});
const debugButton = container.querySelector('[aria-label="Copy debug transcript"]');
expect(debugButton).toBeTruthy();
await act(async () => {
root.render(
<ChatView
db={db}
chatId={1}
server="http://127.0.0.1:1234"
apiKey=""
model="demo"
onChatCreated={vi.fn()}
onChatUpdated={vi.fn()}
showDebugTools={false}
{...defaultProps}
/>,
);
});
expect(container.querySelector('[aria-label="Copy debug transcript"]')).toBeNull();
});
it("copies the full chat transcript with message details", async () => {
const db = createDbStub();
await act(async () => {
root.render(
<ChatView
db={db}
chatId={1}
server="http://127.0.0.1:1234"
apiKey=""
model="demo"
onChatCreated={vi.fn()}
onChatUpdated={vi.fn()}
showDebugTools
{...defaultProps}
/>,
);
});
const debugButton = container.querySelector('[aria-label="Copy debug transcript"]') as HTMLButtonElement | null;
expect(debugButton).toBeTruthy();
await act(async () => {
debugButton?.click();
});
expect(writeText).toHaveBeenCalledTimes(1);
const copiedText = (writeText.mock.calls[0] as unknown as [string])[0];
expect(copiedText).toContain("Chat ID: 1");
expect(copiedText).toContain("[USER] #1");
expect(copiedText).toContain("[ASSISTANT] #2");
});
});
@@ -82,6 +82,40 @@ describe("ChatView", () => {
expect(vi.mocked(db.run)).not.toHaveBeenCalled();
});
it("blocks sending while a local model is loading", async () => {
const db = createDbStub();
await act(async () => {
root.render(
<ChatView
db={db}
chatId={null}
server="http://127.0.0.1:1234"
apiKey=""
model="demo"
runtimeMode="local"
runtimeStatus="loading"
onChatCreated={vi.fn()}
onChatUpdated={vi.fn()}
{...defaultChatViewProps}
/>,
);
});
const textarea = container.querySelector("textarea") as HTMLTextAreaElement | null;
expect(textarea).toBeTruthy();
expect(container.textContent).toContain("Model loading");
await act(async () => {
textarea!.value = "Send this later";
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
textarea!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
expect(vi.mocked(db.run)).not.toHaveBeenCalled();
expect(textarea?.value).toBe("Send this later");
});
it("loads messages when switching to a saved chat", async () => {
const rowsByChat = new Map<number, Message[]>([
[1, [{ id: 11, chat_id: 1, role: "user", content: "First saved chat", created_at: 1 }]],
+179 -112
View File
@@ -1,15 +1,26 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { DB } from "../lib/db";
import type { ChatTokenTotals, Message, Usage, ModelInfo, ServerStatus } from "../lib/types";
import type { RuntimeModelFile } from "../features/runtime/types";
import { buildChatTitle, parseAssistantResponse } from "../lib/chat";
import type { RuntimeModelFile, RuntimeStatus } from "../features/runtime/types";
import { buildChatTitle, CHAT_TRANSCRIPT_STOP_SEQUENCES, parseAssistantResponse } from "../lib/chat";
import { formatChatDebugTranscript } from "../lib/chatDebug";
import { renderMarkdown, ensureHljs } from "../lib/markdown";
import { getComposerNotice, getSuggestedPrompts } from "./chat-view-state";
import { getComposerNotice } from "./chat-view-state";
import MessageBubble from "./MessageBubble";
import ModelSelectorPill from "./ModelSelectorPill";
import AgenticMessage from "./forgeagent/AgenticMessage";
import { AgenticToggle } from "./forgeagent";
import { useMemoryExtractor } from "../hooks/useMemoryExtractor";
import { useForgeAgentSession } from "../hooks/useForgeAgentSession";
import {
parseForgeAgentState,
resolveForgeAgentFinalResponse,
serializeForgeAgentState,
withPersistedForgeAgentResponse,
} from "../lib/forgeagent-state";
import { getDisplayName } from "../lib/modelAlias";
import { ArrowUp, Image, Microphone, Paperclip, ChatCircleText, PaperPlaneRight } from "@phosphor-icons/react";
import { shouldUseAgenticMode } from "../lib/agentic-detection";
import { Image, Microphone, Paperclip, ChatCircleText, PaperPlaneRight, Plus, FolderSimple, Monitor } from "@phosphor-icons/react";
import "./ChatView.css";
const MAX_MEMORY = 20;
@@ -30,6 +41,9 @@ interface Props {
onModelSwap?: (path: string) => void;
isSwapping?: boolean;
status: ServerStatus;
runtimeMode?: "local" | "remote";
runtimeStatus?: RuntimeStatus;
showDebugTools?: boolean;
onChatCreated: (id: number) => void;
onChatUpdated: () => void;
onOpenSidebar?: () => void;
@@ -39,6 +53,7 @@ interface Props {
export default function ChatView({
db, chatId, server, apiKey, model,
models, localModels, selectedModel, onModelChange, onModelSwap, isSwapping, status,
runtimeMode = "remote", runtimeStatus = "ready", showDebugTools = Boolean((import.meta as ImportMeta & { env?: { DEV?: boolean } }).env?.DEV),
onChatCreated, onChatUpdated, onOpenSidebar, onDegradationDetected
}: Props) {
const [messages, setMessages] = useState<Message[]>([]);
@@ -55,6 +70,8 @@ export default function ChatView({
const [messageQueue, setMessageQueue] = useState<string[]>([]);
const [showMemoryModal, setShowMemoryModal] = useState(false);
const [isQueueOpen, setIsQueueOpen] = useState(false);
const [debugCopied, setDebugCopied] = useState(false);
const [agenticMode, setAgenticMode] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -75,9 +92,9 @@ export default function ChatView({
setMessageQueue(next);
};
const composerNotice = getComposerNotice(model, server);
const starterPrompts = getSuggestedPrompts();
const composerNotice = getComposerNotice(model, server, runtimeMode, runtimeStatus, Boolean(isSwapping));
const estimatedTokens = input.length > 0 ? Math.ceil(input.length / 4) : 0;
const { state: forgeAgentState, start: startForgeAgentSession, reset: resetForgeAgentSession, getSnapshot: getForgeAgentSnapshot } = useForgeAgentSession();
const chatTotals = messages.reduce<ChatTokenTotals>((acc, msg) => {
acc.prompt_tokens += msg.prompt_tokens ?? 0;
@@ -131,11 +148,14 @@ export default function ChatView({
const loadMessages = useCallback((targetChatId: number | null = chatId) => {
if (targetChatId == null) { setMessages([]); return; }
const rows = db.query<Message>(
const rows = db.query<(Message & { forgeagent_state?: string | null })>(
"SELECT * FROM messages WHERE chat_id = ? ORDER BY created_at ASC",
[targetChatId],
);
setMessages(rows);
setMessages(rows.map((row) => ({
...row,
forgeagent_state: parseForgeAgentState(row.forgeagent_state),
})));
}, [db, chatId]);
loadMessagesRef.current = loadMessages;
@@ -150,6 +170,7 @@ export default function ChatView({
if (abortRef.current && switchedAway) {
abortRef.current.abort();
abortRef.current = null;
resetForgeAgentSession();
messageQueueRef.current = [];
setMessageQueue([]);
setGenerating(false);
@@ -165,6 +186,7 @@ export default function ChatView({
if (!abortRef.current) {
setGenerating(false);
resetForgeAgentSession();
setStreamContent("");
setStreamReasoning("");
setStreamThinkingOpen(false);
@@ -172,7 +194,7 @@ export default function ChatView({
setTps(null);
setStreamTokenCount(0);
}
}, [chatId]);
}, [chatId, resetForgeAgentSession]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
@@ -219,6 +241,7 @@ export default function ChatView({
const send = useCallback(async (text: string, fromChatId?: number, replaceAssistantId?: number) => {
if (!text.trim() || generating) return;
if (getComposerNotice(model, server)) return;
const isAgenticRequest = agenticMode || shouldUseAgenticMode(text);
setGenerating(true);
setStreamContent("");
@@ -246,6 +269,16 @@ export default function ChatView({
loadMessagesRef.current(cid);
onChatUpdated();
if (isAgenticRequest) {
try {
await startForgeAgentSession(text);
} catch {
// Non-fatal: keep normal chat flow even if ForgeAgent events fail.
}
} else {
resetForgeAgentSession();
}
const apiMessages = buildApiMessages(cid);
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
@@ -263,6 +296,7 @@ export default function ChatView({
stream: useStream,
stream_options: useStream ? { include_usage: true } : undefined,
max_tokens: 2048,
stop: CHAT_TRANSCRIPT_STOP_SEQUENCES,
}),
});
@@ -322,15 +356,22 @@ export default function ChatView({
}
const parsed = parseAssistantResponse(fullContent);
const finalContent = parsed.content.trim() || fullContent.trim();
const persistedForgeAgentState = isAgenticRequest
? withPersistedForgeAgentResponse(getForgeAgentSnapshot(), parsed.content.trim())
: null;
const finalContent = isAgenticRequest
? resolveForgeAgentFinalResponse(persistedForgeAgentState, parsed.content.trim())
: parsed.content.trim();
if (finalContent || parsed.reasoning) {
db.run(
`INSERT INTO messages (
chat_id, role, content, reasoning_content, prompt_tokens, completion_tokens, total_tokens, tokens_per_second
) VALUES (?, 'assistant', ?, ?, ?, ?, ?, ?)`,
chat_id, role, content, display_mode, forgeagent_state, reasoning_content, prompt_tokens, completion_tokens, total_tokens, tokens_per_second
) VALUES (?, 'assistant', ?, ?, ?, ?, ?, ?, ?, ?)`,
[
cid,
finalContent,
isAgenticRequest ? "agentic_final" : null,
serializeForgeAgentState(persistedForgeAgentState),
parsed.content.trim() ? parsed.reasoning : null,
usage?.prompt_tokens ?? 0,
usage?.completion_tokens ?? 0,
@@ -352,10 +393,21 @@ export default function ChatView({
const partial = streamContentAccRef.current;
if (partial.trim()) {
const parsed = parseAssistantResponse(partial);
const partialContent = parsed.content.trim() || partial.trim();
const persistedForgeAgentState = isAgenticRequest
? withPersistedForgeAgentResponse(getForgeAgentSnapshot(), `${parsed.content.trim()} _(geração cancelada)_`)
: null;
const partialContent = isAgenticRequest
? resolveForgeAgentFinalResponse(persistedForgeAgentState, `${parsed.content.trim()} _(geração cancelada)_`)
: parsed.content.trim();
db.run(
`INSERT INTO messages (chat_id, role, content, reasoning_content) VALUES (?, 'assistant', ?, ?)`,
[cid, `${partialContent} _(geração cancelada)_`, parsed.reasoning || null],
`INSERT INTO messages (chat_id, role, content, display_mode, forgeagent_state, reasoning_content) VALUES (?, 'assistant', ?, ?, ?, ?)`,
[
cid,
`${partialContent} _(geração cancelada)_`,
isAgenticRequest ? "agentic_final" : null,
serializeForgeAgentState(persistedForgeAgentState),
parsed.reasoning || null,
],
);
db.run("UPDATE chats SET updated_at = strftime('%s','now') WHERE id = ?", [cid]);
onChatUpdated();
@@ -389,7 +441,7 @@ export default function ChatView({
setTimeout(() => sendRef.current(nextInQueue), 100);
}
}
}, [generating, model, server, apiKey, useStream, db, getOrCreateChat, ensureChatTitle, buildApiMessages, onChatUpdated, extractMemories]);
}, [generating, model, server, apiKey, useStream, db, getOrCreateChat, ensureChatTitle, buildApiMessages, onChatUpdated, extractMemories, agenticMode, startForgeAgentSession, resetForgeAgentSession]);
// Keep sendRef always pointing to latest send closure
sendRef.current = send;
@@ -426,6 +478,13 @@ export default function ChatView({
});
};
const handleCopyDebugTranscript = useCallback(async () => {
const transcript = formatChatDebugTranscript({ chatId, messages });
await navigator.clipboard.writeText(transcript);
setDebugCopied(true);
window.setTimeout(() => setDebugCopied(false), 2000);
}, [chatId, messages]);
const handleEditAndResend = (msg: Message, newContent: string) => {
db.run("UPDATE messages SET content = ? WHERE id = ?", [newContent, msg.id]);
db.run("DELETE FROM messages WHERE chat_id = ? AND id > ?", [msg.chat_id, msg.id]);
@@ -471,39 +530,28 @@ export default function ChatView({
const isInitialScreen = chatId === null && messages.length === 0;
const modelShort = getDisplayName(model);
const quickActionChips = ["Create slides", "Build website", "Develop desktop apps", "Design", "More"];
const workspaceStateLabel = chatId === null ? "Workspace" : "Conversation";
const workspaceStateCopy = composerNotice
? composerNotice.title
: server
? "Runtime ready"
: "Runtime offline";
const heroCards = [
{
title: "Personalize ForgeLM",
copy: "Tune workspace defaults, preferred prompts, and local runtime behavior.",
},
];
return (
<div className={`chat-view${isInitialScreen ? " initial-screen" : ""}`}>
{/* ── Top bar ── */}
<div className="chat-topbar">
<div className="chat-topbar-left">
{onOpenSidebar && (
{onOpenSidebar && !isInitialScreen && (
<button type="button" className="chat-menu-btn" onClick={onOpenSidebar} aria-label="Open navigation">
☰
</button>
)}
{model && (
<div className={`topbar-model-badge${isInitialScreen ? " topbar-model-badge--hero" : ""}`}>
{!isInitialScreen && <div className={`topbar-status-dot${server ? " connected" : ""}`} />}
<span className="topbar-model-name">{modelShort}</span>
</div>
)}
</div>
<div className="chat-topbar-center">
{isInitialScreen ? null : chatId !== null && chatTotals.total_tokens > 0 ? (
{!isInitialScreen && (chatId !== null && chatTotals.total_tokens > 0 ? (
<div className="chat-token-summary">
<span title="Prompt tokens sent">↑ {chatTotals.prompt_tokens.toLocaleString()}</span>
<span title="Completion tokens generated">↓ {chatTotals.completion_tokens.toLocaleString()}</span>
@@ -514,13 +562,20 @@ export default function ChatView({
<span className="chat-topbar-context-label">{workspaceStateLabel}</span>
<span className="chat-topbar-context-copy">{workspaceStateCopy}</span>
</div>
)}
))}
</div>
<div className="chat-topbar-right">
<span className={`topbar-state-pill${server ? " online" : " offline"}${isInitialScreen ? " topbar-state-pill--hero" : ""}`}>
{model && (
<div className={`topbar-model-badge${isInitialScreen ? " topbar-model-badge--hero" : ""}`}>
{!isInitialScreen && <div className={`topbar-status-dot${server ? " connected" : ""}`} />}
<span className="topbar-model-name">{modelShort}</span>
</div>
)}
{!isInitialScreen && <span className={`topbar-state-pill${server ? " online" : " offline"}`}>
{server ? "Runtime ready" : "Runtime offline"}
</span>
}
{!isInitialScreen && generating && streamTokenCount > 0 && (
<div className="live-token-counter">
<span className="live-token-pulse" />
@@ -609,6 +664,18 @@ export default function ChatView({
</div>
)}
{showDebugTools && chatId !== null && messages.length > 0 && (
<button
type="button"
className={`chat-debug-fab${debugCopied ? " copied" : ""}`}
onClick={() => { void handleCopyDebugTranscript(); }}
aria-label="Copy debug transcript"
title="Copy full chat debug transcript"
>
{debugCopied ? "Copied debug" : "Copy debug"}
</button>
)}
{/* ── Chat Layout ── */}
<div className="chat-layout-root">
{/* Scrollable message area */}
@@ -618,110 +685,102 @@ export default function ChatView({
<div className="empty-state empty-state--hero">
<section className="empty-state-panel empty-state-panel--hero" aria-labelledby="workspace-welcome">
<div className="empty-state-header empty-state-header--hero">
<span className="empty-kicker">ForgeLM workspace</span>
<h1 id="workspace-welcome">What can I do for you?</h1>
</div>
<div className="hero-composer-block">
<div className="input-box input-box--hero">
<ModelSelectorPill
models={models}
localModels={localModels}
selectedModel={selectedModel}
onModelChange={onModelChange}
onModelSwap={onModelSwap}
isSwapping={isSwapping}
status={status}
/>
<div className="attachment-actions" aria-label="Attachment options (coming soon)">
<button type="button" disabled aria-disabled="true" title="Attach image (Coming soon)">
<Image size={20} weight="duotone" />
</button>
<button type="button" disabled aria-disabled="true" title="Attach audio (Coming soon)">
<Microphone size={20} weight="duotone" />
</button>
<button type="button" disabled aria-disabled="true" title="Attach document (Coming soon)">
<Paperclip size={20} weight="duotone" />
</button>
<div className="hero-composer-row">
<div className="attachment-actions attachment-actions--hero" aria-label="Attachment options (coming soon)">
<button type="button" disabled aria-disabled="true" title="Add (Coming soon)">
<Plus size={18} weight="bold" />
</button>
<button type="button" disabled aria-disabled="true" title="Attach image (Coming soon)">
<Image size={18} weight="duotone" />
</button>
<button type="button" disabled aria-disabled="true" title="Attach folder (Coming soon)">
<FolderSimple size={18} weight="duotone" />
</button>
<button type="button" disabled aria-disabled="true" title="Capture screen (Coming soon)">
<Monitor size={18} weight="duotone" />
</button>
</div>
<div className="input-box-main input-box-main--hero">
<textarea
ref={textareaRef}
value={input}
onChange={(e) => { setInput(e.target.value); autoResize(); }}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); }
}}
placeholder={
composerNotice
? `${composerNotice.title}. ${composerNotice.description}`
: generating
? "Type to queue next message…"
: "Assign a task or ask anything"
}
rows={1}
disabled={Boolean(composerNotice)}
/>
<div className="hero-composer-actions">
<button type="button" className="hero-utility-btn" disabled aria-disabled="true" title="Voice input (Coming soon)">
<Microphone size={18} weight="duotone" />
</button>
{generating ? (
<button className="stop-btn stop-btn--hero" onClick={handleStop} type="button">
■
</button>
) : (
<button
className="send-btn send-btn--hero"
onClick={handleSend}
disabled={!input.trim() || Boolean(composerNotice)}
type="button"
aria-label="Send message"
>
<PaperPlaneRight size={18} weight="fill" />
</button>
)}
</div>
</div>
</div>
<div className="input-box-main">
<textarea
ref={textareaRef}
value={input}
onChange={(e) => { setInput(e.target.value); autoResize(); }}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); }
}}
placeholder={
composerNotice
? `${composerNotice.title}. ${composerNotice.description}`
: generating
? "Type to queue next message…"
: "Assign a task or ask anything"
}
rows={1}
disabled={Boolean(composerNotice)}
/>
{generating ? (
<button className="stop-btn" onClick={handleStop} type="button">
■ Stop
</button>
) : (
<button
className="send-btn"
onClick={handleSend}
disabled={!input.trim() || Boolean(composerNotice)}
type="button"
aria-label="Send message"
>
<PaperPlaneRight size={18} weight="fill" />
</button>
)}
</div>
<div className="composer-footer-row">
<span className="composer-footer-hint">
<div className="composer-footer-row composer-footer-row--hero">
<span className="composer-footer-hint composer-footer-hint--hero">
{composerNotice ? `${composerNotice.title} · ${composerNotice.description}` : "Connect your tools to ForgeLM"}
</span>
<div className="composer-footer-icons" aria-hidden="true">
<span />
<div className="composer-footer-icons composer-footer-icons--hero" aria-hidden="true">
<span />
<span />
<span />
<span />
</div>
<AgenticToggle isActive={agenticMode} onToggle={() => setAgenticMode((value) => !value)} disabled={Boolean(composerNotice)} />
</div>
</div>
</div>
<div className="starter-chip-rail" aria-label="Starter prompts">
{starterPrompts.map((prompt) => (
{quickActionChips.map((prompt) => (
<button key={prompt} type="button" className="starter-chip starter-chip--hero" onClick={() => handleStarterPrompt(prompt)}>
{prompt}
</button>
))}
</div>
<div className="hero-card-carousel" aria-label="Workspace shortcuts">
{heroCards.map((card) => (
<button key={card.title} type="button" className="hero-card" onClick={() => handleStarterPrompt(card.copy)}>
<div>
<span className="hero-card-title">{card.title}</span>
<span className="hero-card-copy">{card.copy}</span>
</div>
<div className="hero-card-preview" aria-hidden="true">
<span />
<span />
<span />
</div>
</button>
))}
<div className="hero-card-pager" aria-hidden="true">
<div className="hero-promo-card" aria-label="Desktop app download">
<div className="hero-promo-copy">
<span className="hero-promo-title">Download ForgeLM for Windows or macOS</span>
<span className="hero-promo-subtitle">Access local files and work seamlessly with your desktop.</span>
</div>
<div className="hero-promo-preview" aria-hidden="true">
<span className="hero-promo-preview-bar" />
<span className="hero-promo-preview-pane hero-promo-preview-pane--wide" />
<span className="hero-promo-preview-pane" />
<span className="active" />
<span />
<span />
</div>
</div>
</section>
@@ -740,8 +799,14 @@ export default function ChatView({
/>
))}
{generating && forgeAgentState ? (
<div className="msg-wrapper assistant">
<AgenticMessage plan={forgeAgentState.plan} state={forgeAgentState} isExecuting />
</div>
) : null}
{/* Live streaming bubble */}
{generating && (streamReasoning || streamContent || streamThinkingOpen) && (
{generating && !forgeAgentState && (streamReasoning || streamContent || streamThinkingOpen) && (
<div className="msg-wrapper assistant">
<div className="msg-bubble">
{(streamReasoning || streamThinkingOpen) && (
@@ -783,7 +848,7 @@ export default function ChatView({
</div>
)}
{generating && !streamReasoning && !streamContent && !streamThinkingOpen && (
{generating && !forgeAgentState && !streamReasoning && !streamContent && !streamThinkingOpen && (
<div className="msg-wrapper assistant">
<div className="thinking-manus">
<span className="thinking-dot" aria-hidden="true" />
@@ -846,6 +911,8 @@ export default function ChatView({
<span className="label">Out</span>
<ChatCircleText size={14} weight="duotone" />
</span>
<span className="separator">·</span>
<AgenticToggle isActive={agenticMode} onToggle={() => setAgenticMode((value) => !value)} disabled={Boolean(composerNotice)} />
</div>
{/* The typing pill */}
+38
View File
@@ -0,0 +1,38 @@
import type { LucideProps } from "lucide-react";
import {
AlertCircle,
CheckCircle,
CheckCircle2,
ChevronDown,
ChevronRight,
Circle,
Loader,
Loader2,
Minus,
XCircle,
} from "lucide-react";
const icons = {
AlertCircle,
CheckCircle,
CheckCircle2,
ChevronDown,
ChevronRight,
Circle,
Loader,
Loader2,
Minus,
XCircle,
} as const;
export type IconName = keyof typeof icons;
export type IconProps = LucideProps & {
name: IconName;
};
export function Icon({ name, ...props }: IconProps) {
const Component = icons[name] ?? Circle;
return <Component {...props} />;
}
export default Icon;
+941
View File
@@ -162,6 +162,932 @@
padding: 13px 16px;
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text {
width: 100%;
max-width: min(100%, 72ch);
background: transparent;
border: none;
border-radius: 0;
box-shadow: none;
color: var(--text);
overflow: visible;
}
/* ── Manus markdown ── */
.manus-markdown {
font-family: var(--font-editorial);
font-size: 1rem;
line-height: 1.78;
color: rgba(244, 244, 245, 0.94);
letter-spacing: -0.01em;
}
.manus-markdown > :first-child { margin-top: 0; }
.manus-markdown > :last-child { margin-bottom: 0; }
.manus-markdown h1,
.manus-markdown h2,
.manus-markdown h3,
.manus-markdown h4 {
font-family: var(--font-editorial);
font-weight: 560;
letter-spacing: -0.03em;
line-height: 1.18;
color: #fafafa;
margin: 1.2em 0 0.45em;
}
.manus-markdown h1 { font-size: clamp(1.65rem, 1.35rem + 0.9vw, 2.2rem); }
.manus-markdown h2 { font-size: clamp(1.2rem, 1.1rem + 0.45vw, 1.45rem); }
.manus-markdown h3 { font-size: 1.05rem; }
.manus-markdown p { margin: 0.75em 0; color: rgba(228, 228, 231, 0.9); }
.manus-markdown ul,
.manus-markdown ol { margin: 0.9em 0; padding-left: 1.35rem; }
.manus-markdown li { margin: 0.32rem 0; color: rgba(228, 228, 231, 0.88); }
.manus-markdown blockquote {
margin: 1.1em 0;
border-left: 1px solid rgba(244, 244, 245, 0.14);
padding-left: 1rem;
color: rgba(212, 212, 216, 0.78);
font-style: normal;
}
.manus-markdown hr { margin: 1.35em 0; border: none; border-top: 1px solid rgba(244, 244, 245, 0.08); }
.manus-markdown a { color: #dbeafe; text-decoration-color: rgba(219, 234, 254, 0.28); text-underline-offset: 0.18em; }
.manus-markdown strong { color: #fcfcfd; font-weight: 620; }
.manus-markdown code:not(.hljs) {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 8px;
padding: 0.1rem 0.4rem;
}
.manus-markdown .code-block {
margin: 1.1rem 0;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 18px;
background: rgba(9, 9, 11, 0.88);
}
.manus-markdown .code-header {
background: rgba(255, 255, 255, 0.03);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.manus-markdown .copy-code-btn {
border-radius: 999px;
border-color: rgba(255, 255, 255, 0.09);
background: rgba(255, 255, 255, 0.03);
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text .msg-content,
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text .agent-final-response__content {
padding: 0;
font-family: var(--font-editorial);
font-size: 1rem;
line-height: 1.78;
color: rgba(244, 244, 245, 0.96);
letter-spacing: -0.01em;
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text .msg-content > :first-child,
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text .agent-final-response__content > :first-child {
margin-top: 0;
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text .msg-content > :last-child,
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text .agent-final-response__content > :last-child {
margin-bottom: 0;
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text h1,
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text h2,
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text h3,
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text h4,
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text h5,
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text h6 {
margin: 1.2em 0 0.45em;
color: #fafafa;
font-family: var(--font-editorial);
font-weight: 560;
letter-spacing: -0.03em;
line-height: 1.18;
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text h1 {
font-size: clamp(1.65rem, 1.35rem + 0.9vw, 2.2rem);
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text h2 {
font-size: clamp(1.2rem, 1.1rem + 0.45vw, 1.45rem);
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text h3 {
font-size: 1.05rem;
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text p {
margin: 0.75em 0;
color: rgba(228, 228, 231, 0.9);
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text ul,
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text ol {
margin: 0.9em 0;
padding-left: 1.35rem;
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text li {
margin: 0.32rem 0;
color: rgba(228, 228, 231, 0.88);
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text blockquote {
margin: 1.1em 0;
border-left: 1px solid rgba(244, 244, 245, 0.14);
padding-left: 1rem;
color: rgba(212, 212, 216, 0.78);
font-style: normal;
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text hr {
margin: 1.35em 0;
border: none;
border-top: 1px solid rgba(244, 244, 245, 0.08);
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text a {
color: #dbeafe;
text-decoration-color: rgba(219, 234, 254, 0.28);
text-underline-offset: 0.18em;
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text strong {
color: #fcfcfd;
font-weight: 620;
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text code:not(.hljs) {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 8px;
padding: 0.1rem 0.4rem;
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text .code-block {
margin: 1.1rem 0;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 18px;
background: rgba(9, 9, 11, 0.88);
box-shadow: none;
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text .code-header {
background: rgba(255, 255, 255, 0.03);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text .copy-code-btn {
border-radius: 999px;
border-color: rgba(255, 255, 255, 0.09);
background: rgba(255, 255, 255, 0.03);
}
.msg-inline-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 14px;
min-height: 32px;
}
.msg-wrapper.assistant .message-metrics {
padding-left: 0;
font-size: 0.7rem;
color: rgba(161, 161, 170, 0.72);
font-family: var(--font-editorial);
gap: 10px;
}
.msg-actions--inline {
opacity: 0;
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.025);
border: 1px solid rgba(255, 255, 255, 0.05);
transition: opacity 0.2s ease-out, background 0.2s ease-out, border-color 0.2s ease-out;
}
.msg-wrapper.assistant:hover .msg-actions--inline,
.msg-wrapper.assistant:focus-within .msg-actions--inline {
opacity: 1;
}
.msg-actions--inline .action-btn {
min-width: 34px;
min-height: 34px;
border: none;
border-radius: 999px;
padding: 0;
color: rgba(228, 228, 231, 0.7);
}
.msg-actions--inline .action-btn:hover {
background: rgba(255, 255, 255, 0.06);
color: #fafafa;
}
.msg-actions--inline .action-btn:focus-visible {
box-shadow: 0 0 0 2px rgba(75, 134, 255, 0.35);
}
.msg-actions--inline .action-btn.copied {
color: var(--success);
background: rgba(48, 209, 88, 0.1);
}
.msg-wrapper.assistant .msg-thinking {
margin-bottom: 14px;
border-bottom: none;
}
.msg-wrapper.assistant .reasoning-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0;
background: transparent;
color: rgba(161, 161, 170, 0.8);
font-size: 0.72rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.14em;
}
.msg-wrapper.assistant .thinking-content {
padding: 10px 0 0;
color: rgba(212, 212, 216, 0.72);
}
.msg-wrapper.assistant .thinking-content > :first-child {
margin-left: 0;
padding-left: 12px;
border-left: 1px solid rgba(244, 244, 245, 0.08);
}
/* ── Minimalist Agentic plan ── */
.agentic-plan {
display: flex;
flex-direction: column;
}
.agentic-plan__header {
display: flex;
align-items: center;
gap: 8px;
background: none;
border: none;
padding: 0;
cursor: pointer;
color: var(--text);
text-align: left;
width: 100%;
font-family: inherit;
}
.agentic-plan__header:hover .agentic-plan__title {
color: var(--accent);
}
.agentic-plan__dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: var(--accent);
box-shadow: 0 0 8px var(--accent-glow);
flex-shrink: 0;
}
.agentic-plan__title {
font-family: var(--font-editorial);
font-size: 0.82rem;
font-weight: 500;
color: rgba(244, 244, 245, 0.82);
letter-spacing: -0.005em;
transition: color 0.15s;
}
.agentic-plan__badge {
font-size: 0.6rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text3);
border: 1px solid var(--border2);
border-radius: 999px;
padding: 1px 6px;
flex-shrink: 0;
}
.agentic-plan__counter {
font-size: 0.68rem;
color: var(--text3);
font-family: var(--font-editorial);
margin-left: auto;
flex-shrink: 0;
}
.agentic-plan__chevron {
width: 14px;
height: 14px;
color: var(--text3);
flex-shrink: 0;
transition: transform 0.2s ease-out;
}
.agentic-plan__chevron.rotated {
transform: rotate(180deg);
}
.agentic-plan__body {
padding-left: 15px;
}
.agentic-plan__overview {
margin: 6px 0 10px;
font-family: var(--font-editorial);
font-size: 0.82rem;
line-height: 1.6;
color: rgba(244, 244, 245, 0.5);
}
.agentic-steps {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.agentic-step {
display: flex;
align-items: flex-start;
gap: 8px;
}
.agentic-step__content {
min-width: 0;
flex: 1;
}
.agentic-step__title {
font-family: var(--font-editorial);
font-size: 0.84rem;
font-weight: 500;
color: rgba(244, 244, 245, 0.8);
line-height: 1.4;
margin: 0;
}
.agentic-step__desc {
margin: 2px 0 0;
font-size: 0.78rem;
line-height: 1.5;
color: rgba(244, 244, 245, 0.45);
}
.agentic-substeps {
list-style: none;
margin: 4px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.agentic-substep {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.76rem;
color: rgba(244, 244, 245, 0.45);
font-family: var(--font-editorial);
}
.agentic-plan__prog {
margin-top: 10px;
}
.agentic-plan__prog-bar {
height: 2px;
background: var(--border);
border-radius: 999px;
overflow: hidden;
}
.agentic-plan__prog-fill {
height: 100%;
background: var(--accent);
border-radius: 999px;
transition: width 0.25s ease-out;
}
/* ── Agentic status dots ── */
.agentic-dot {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 2px;
}
.agentic-dot--idle { color: var(--text3); }
.agentic-dot--running { color: var(--accent); }
.agentic-dot--done { color: var(--success); }
.agentic-dot--error { color: var(--danger); }
/* ── Agentic update (inline text) ── */
.agentic-update {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--font-editorial);
font-size: 0.8rem;
color: rgba(244, 244, 245, 0.48);
line-height: 1.5;
}
.agentic-update__icon {
flex-shrink: 0;
opacity: 0.7;
}
.agentic-update--success .agentic-update__icon { color: var(--success); opacity: 1; }
.agentic-update--warning .agentic-update__icon { color: var(--warning); opacity: 1; }
.agentic-update--error .agentic-update__icon { color: var(--danger); opacity: 1; }
/* ── Specialist cards ── */
.specialist-card {
display: flex;
flex-direction: column;
gap: 0;
padding: 12px 0;
}
.specialist-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.specialist-card__identity {
display: flex;
align-items: center;
gap: 8px;
}
.specialist-card__dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: var(--accent);
box-shadow: 0 0 8px var(--accent-glow);
flex-shrink: 0;
}
.specialist-card__name {
font-family: var(--font-editorial);
font-size: 0.84rem;
font-weight: 500;
color: rgba(244, 244, 245, 0.88);
letter-spacing: -0.005em;
}
.specialist-card__title {
font-family: var(--font-editorial);
font-size: 0.72rem;
color: rgba(244, 244, 245, 0.4);
}
.specialist-card__badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 2px 7px;
border-radius: 999px;
}
.specialist-card__badge--thinking {
color: var(--accent);
background: rgba(72, 166, 255, 0.08);
border: 1px solid rgba(72, 166, 255, 0.2);
}
.specialist-card__badge--done {
color: var(--success);
background: rgba(48, 209, 88, 0.08);
border: 1px solid rgba(48, 209, 88, 0.2);
}
.specialist-card__thinking-section {
margin-top: 8px;
}
.specialist-card__thinking-toggle {
display: flex;
align-items: center;
gap: 5px;
background: none;
border: none;
padding: 0;
cursor: pointer;
font-family: var(--font-editorial);
font-size: 0.72rem;
font-weight: 500;
color: rgba(244, 244, 245, 0.38);
letter-spacing: 0.04em;
transition: color 0.15s;
}
.specialist-card__thinking-toggle:hover {
color: rgba(244, 244, 245, 0.65);
}
.specialist-card__chevron {
width: 12px;
height: 12px;
transition: transform 0.18s ease-out;
flex-shrink: 0;
}
.specialist-card__chevron.rotated {
transform: rotate(180deg);
}
.specialist-card__thinking-preview {
margin: 5px 0 0;
font-family: var(--font-editorial);
font-size: 0.76rem;
line-height: 1.5;
color: rgba(244, 244, 245, 0.32);
font-style: italic;
}
.specialist-card__thinking-body {
margin-top: 6px;
}
.specialist-card__thinking-text {
font-family: var(--font-editorial);
font-size: 0.8rem;
line-height: 1.65;
color: rgba(228, 228, 231, 0.6);
border-left: 1px solid rgba(244, 244, 245, 0.08);
padding-left: 12px;
margin: 0;
}
.specialist-card__response {
margin-top: 8px;
}
.specialist-card__response-text {
font-family: var(--font-editorial);
font-size: 0.88rem;
line-height: 1.72;
color: rgba(228, 228, 231, 0.88);
margin: 0;
}
.specialist-card__divider {
margin-top: 12px;
height: 1px;
background: rgba(244, 244, 245, 0.05);
}
/* ── Agentic artifact row ── */
.agentic-artifact {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg2);
margin-top: 12px;
transition: background 0.15s;
}
.agentic-artifact:hover {
background: var(--bg3);
}
.agentic-artifact__icon {
flex-shrink: 0;
color: var(--accent);
}
.agentic-artifact__info {
min-width: 0;
flex: 1;
}
.agentic-artifact__name {
font-size: 0.82rem;
font-weight: 500;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.agentic-artifact__meta {
font-size: 0.7rem;
color: var(--text3);
margin-top: 1px;
}
.agentic-artifact__btn {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 10px;
background: var(--bg3);
border: 1px solid var(--border2);
border-radius: var(--radius-sm);
color: var(--text2);
font-size: 0.72rem;
font-family: var(--font-sans);
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.agentic-artifact__btn:hover {
background: var(--bg4);
color: var(--text);
border-color: var(--accent);
}
.forgeagent-shell__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.forgeagent-shell__label {
font-size: 0.76rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(228, 228, 231, 0.92);
}
.forgeagent-shell__status {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 999px;
padding: 4px 10px;
font-size: 0.68rem;
font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(161, 161, 170, 0.84);
background: rgba(24, 24, 27, 0.74);
}
.forgeagent-card {
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 20px;
background: rgba(15, 23, 42, 0.34);
padding: 14px 16px;
}
.forgeagent-card__eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(161, 161, 170, 0.8);
}
.forgeagent-card__dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: #60a5fa;
box-shadow: 0 0 12px rgba(96, 165, 250, 0.45);
}
.forgeagent-card__title-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-top: 8px;
}
.forgeagent-card__title {
margin: 0;
font-size: 1rem;
font-weight: 600;
line-height: 1.35;
color: #fafafa;
}
.forgeagent-card__progress {
flex-shrink: 0;
font-size: 0.75rem;
color: rgba(212, 212, 216, 0.72);
}
.forgeagent-card__overview {
margin: 6px 0 0;
font-size: 0.88rem;
line-height: 1.55;
color: rgba(212, 212, 216, 0.76);
}
.forgeagent-progress {
margin-top: 12px;
height: 5px;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
}
.forgeagent-progress__bar {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, rgba(226, 232, 240, 0.95), rgba(148, 163, 184, 0.92));
transition: width 0.2s ease-out;
}
.forgeagent-steps {
list-style: none;
margin: 0;
padding: 0;
}
.forgeagent-steps--compact {
display: flex;
flex-direction: column;
gap: 8px;
}
.forgeagent-step {
display: flex;
gap: 10px;
align-items: flex-start;
}
.forgeagent-step__marker {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
margin-top: 1px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(9, 9, 11, 0.72);
font-size: 0.68rem;
color: rgba(161, 161, 170, 0.82);
}
.forgeagent-step.is-complete .forgeagent-step__marker {
border-color: rgba(52, 211, 153, 0.24);
background: rgba(16, 185, 129, 0.1);
color: rgba(167, 243, 208, 0.96);
}
.forgeagent-step.is-active .forgeagent-step__marker {
border-color: rgba(96, 165, 250, 0.3);
background: rgba(59, 130, 246, 0.12);
color: rgba(191, 219, 254, 0.96);
}
.forgeagent-step__body {
min-width: 0;
flex: 1;
}
.forgeagent-step__row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.forgeagent-step__title {
font-size: 0.88rem;
font-weight: 500;
line-height: 1.4;
color: rgba(244, 244, 245, 0.92);
}
.forgeagent-step__status {
flex-shrink: 0;
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: rgba(161, 161, 170, 0.72);
}
.forgeagent-step__description,
.forgeagent-step__substep {
margin-top: 4px;
font-size: 0.8rem;
line-height: 1.5;
color: rgba(161, 161, 170, 0.82);
}
.forgeagent-step__substeps {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 6px;
}
.forgeagent-updates {
display: flex;
flex-direction: column;
gap: 6px;
}
.forgeagent-update {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 0;
font-size: 0.82rem;
line-height: 1.5;
color: rgba(212, 212, 216, 0.8);
}
.forgeagent-update__dot {
width: 6px;
height: 6px;
margin-top: 0.45rem;
flex-shrink: 0;
border-radius: 999px;
background: rgba(161, 161, 170, 0.72);
}
.forgeagent-update--success .forgeagent-update__dot {
background: rgba(52, 211, 153, 0.92);
}
.forgeagent-update--warning .forgeagent-update__dot {
background: rgba(251, 191, 36, 0.92);
}
.forgeagent-update--error .forgeagent-update__dot {
background: rgba(251, 113, 133, 0.92);
}
.agent-final-response {
padding-top: 6px;
}
.agent-final-response__label {
margin-bottom: 10px;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(161, 161, 170, 0.8);
}
.agent-final-response__artifact-label {
margin-bottom: 6px;
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(161, 161, 170, 0.7);
}
/* ── Message metrics ── */
.message-metrics {
display: flex;
@@ -324,6 +1250,21 @@
.action-btn {
flex: 1 1 132px;
}
.msg-inline-meta {
flex-direction: column;
align-items: flex-start;
}
.msg-actions--inline {
opacity: 1;
}
.msg-actions--inline .action-btn {
flex: 0 0 auto;
min-width: 40px;
min-height: 40px;
}
}
@media (prefers-reduced-motion: reduce) {
@@ -69,4 +69,86 @@ describe("MessageBubble", () => {
expect(container.textContent).toContain("Total 42");
expect(container.textContent).toContain("6.4 tok/s");
});
it("renders a clean agentic final response without classic bubble chrome", async () => {
const message = {
id: 22,
chat_id: 5,
role: "assistant",
content: "Aqui está o guia final.",
created_at: Date.now(),
display_mode: "agentic_final",
} as Message;
await act(async () => {
root.render(
<MessageBubble
message={message}
onDelete={vi.fn()}
onEditAssistant={vi.fn()}
renderMarkdown={(text) => `<p>${text}</p>`}
/>,
);
});
expect(container.querySelector(".agent-final-response")).toBeTruthy();
expect(container.textContent).toContain("Aqui está o guia final.");
});
it("keeps forgeagent execution cards visible for persisted agentic messages", async () => {
const message = {
id: 31,
chat_id: 7,
role: "assistant",
content: "Aqui está a resposta final organizada.",
created_at: Date.now(),
display_mode: "agentic_final",
forgeagent_state: {
plan_id: "plan-7",
current_step: 3,
status: "completed",
progress: 100,
final_response: "Aqui está a resposta final organizada em formato Manus-style, pronta para revisão e entrega.",
artifact: {
filename: "forgeagent-output.md",
content: "# Resultado final\n\nPrompt original: testar o agentico",
language: "markdown",
cta_label: "Entregar o guia ao usuário",
},
system_messages: [
{ id: "m1", step_id: 1, tone: "info", content: "Analisei a solicitação e defini o objetivo principal." },
{ id: "m2", step_id: 2, tone: "info", content: "Estruturei a solução e organizei a execução em blocos claros." },
],
plan: {
title: "Pesquise sobre a qualidade das pedras nas montanhas",
overview: "Vou estruturar um plano curto, executar cada etapa visualmente e entregar a resposta final no estilo ForgeAgent.",
steps: [
{ id: 1, title: "Entender a solicitação", description: "Quebrar o pedido em objetivo, stack e formato de entrega.", status: "completed", sub_steps: [] },
{ id: 2, title: "Preparar a solução", description: "Montar a estrutura, dependências e fluxo recomendado.", status: "completed", sub_steps: [] },
{ id: 3, title: "Gerar a resposta final", description: "Entregar o conteúdo final com orientação prática e artefato.", status: "completed", sub_steps: [] },
],
},
},
} as Message & { forgeagent_state: unknown };
await act(async () => {
root.render(
<MessageBubble
message={message}
onDelete={vi.fn()}
onEditAssistant={vi.fn()}
renderMarkdown={(text) => `<p>${text}</p>`}
/>,
);
});
expect(container.querySelector(".forgeagent-steps--compact")).toBeTruthy();
expect(container.textContent).toContain("Pesquise sobre a qualidade das pedras nas montanhas");
expect(container.textContent).toContain("Entender a solicitação");
expect(container.textContent).toContain("Analisei a solicitação e defini o objetivo principal.");
expect(container.textContent).toContain("forgeagent-output.md");
expect(container.textContent).toContain("Resposta final");
expect(container.textContent).toContain("Concluí a execução do pedido");
expect(container.textContent).not.toContain("pronta para revisão e entrega");
});
});
+98 -6
View File
@@ -1,5 +1,8 @@
import { useState, useRef, useEffect } from "react";
import type { Message } from "../lib/types";
import { resolveForgeAgentFinalResponse, withPersistedForgeAgentResponse } from "../lib/forgeagent-state";
import AgenticMessage from "./forgeagent/AgenticMessage";
import FinalResponse from "./forgeagent/FinalResponse";
import "./MessageBubble.css";
interface Props {
@@ -7,10 +10,11 @@ interface Props {
onDelete: (id: number) => void;
onEditAndResend?: (msg: Message, newContent: string) => void;
onEditAssistant?: (id: number, newContent: string) => void;
onResend?: (msg: Message) => void;
renderMarkdown: (text: string) => string;
}
export default function MessageBubble({ message, onDelete, onEditAndResend, onEditAssistant, renderMarkdown }: Props) {
export default function MessageBubble({ message, onDelete, onEditAndResend, onEditAssistant, onResend, renderMarkdown }: Props) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(message.content);
const [copied, setCopied] = useState(false);
@@ -83,6 +87,14 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
};
const hasReasoning = Boolean(message.reasoning_content?.trim());
const isAgenticFinal = message.role === "assistant" && message.display_mode === "agentic_final";
const persistedForgeAgentState = isAgenticFinal ? message.forgeagent_state : null;
const resolvedForgeAgentState = persistedForgeAgentState
? withPersistedForgeAgentResponse(persistedForgeAgentState, message.content)
: null;
const resolvedAgenticContent = isAgenticFinal
? resolveForgeAgentFinalResponse(resolvedForgeAgentState, message.content)
: message.content;
const hasUsage = message.role === "assistant" && (
(message.prompt_tokens ?? 0) > 0 ||
(message.completion_tokens ?? 0) > 0 ||
@@ -90,6 +102,9 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
);
const showFooter = message.role === "assistant";
const assistantBubbleClass = isAgenticFinal
? "msg-bubble msg-bubble--assistant-text msg-bubble--agentic"
: "msg-bubble msg-bubble--assistant-text";
return (
<div className={`msg-wrapper ${message.role}`}>
@@ -119,10 +134,72 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
</div>
) : (
<>
{message.role === "assistant" ? (
{isAgenticFinal ? (
<div ref={bubbleRef} className="msg-bubble msg-bubble--assistant-text">
{resolvedForgeAgentState ? (
<AgenticMessage
plan={resolvedForgeAgentState.plan}
state={resolvedForgeAgentState}
isExecuting={resolvedForgeAgentState.status === "planning" || resolvedForgeAgentState.status === "executing"}
/>
) : (
<FinalResponse content={resolvedAgenticContent} />
)}
{showFooter && (
<div className="msg-inline-meta">
{hasUsage ? (
<div className="message-metrics">
<span>Sent {message.prompt_tokens ?? 0}</span>
<span>Generated {message.completion_tokens ?? 0}</span>
<span>Total {message.total_tokens ?? 0}</span>
{message.tokens_per_second ? <span>{message.tokens_per_second.toFixed(1)} tok/s</span> : null}
</div>
) : <div />}
<div className="msg-actions msg-actions--inline" aria-label="Message actions">
<button type="button" className={`action-btn ${copied ? "copied" : ""}`} onClick={copyMessage} aria-label="Copy message">
<svg viewBox="0 0 20 20" focusable="false" aria-hidden="true">
<rect x="7" y="7" width="10" height="10" rx="2" />
<path d="M13 7V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2" />
</svg>
</button>
{onResend && (
<button
type="button"
className="action-btn"
onClick={() => onResend(message)}
aria-label="Regenerate response"
title="Regenerate response"
>
<svg viewBox="0 0 20 20" focusable="false" aria-hidden="true">
<path d="M4 10a6 6 0 1 0 1.24-3.72" strokeLinecap="round" strokeLinejoin="round" />
<polyline points="1 4 4 7 7 4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
)}
<button
type="button"
className="action-btn"
onClick={startEdit}
aria-label={onEditAndResend ? "Edit message and resend" : "Edit message"}
>
<svg viewBox="0 0 20 20" focusable="false" aria-hidden="true">
<path d="M13.586 3.414a2 2 0 0 1 2.828 2.828l-9.17 9.17a1 1 0 0 1-.47.264l-3.774.944a.5.5 0 0 1-.608-.608l.944-3.774a1 1 0 0 1 .264-.47l9.986-9.354z" />
</svg>
</button>
<button type="button" className="action-btn danger" onClick={() => onDelete(message.id)} aria-label="Delete message">
<svg viewBox="0 0 20 20" focusable="false" aria-hidden="true">
<path d="M6 5h8l-.7 10.3a1 1 0 0 1-1 .7H7.7a1 1 0 0 1-1-.7L6 5z" />
<path d="M4 5h12M8.5 5V3.5a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5V5" />
</svg>
</button>
</div>
</div>
)}
</div>
) : message.role === "assistant" ? (
<div
ref={bubbleRef}
className="msg-bubble"
className={assistantBubbleClass}
>
{hasReasoning && (
<details className="msg-thinking">
@@ -141,12 +218,12 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
)}
<div
className="msg-content"
className="msg-content msg-content--assistant"
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }}
/>
{showFooter && (
<div className="msg-footer">
<div className="msg-inline-meta">
{hasUsage && (
<div className="message-metrics">
<span>Sent {message.prompt_tokens ?? 0}</span>
@@ -156,13 +233,28 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
</div>
)}
{!hasUsage && <div />}
<div className="msg-actions" aria-label="Message actions">
<div className="msg-actions msg-actions--inline" aria-label="Message actions">
<button type="button" className={`action-btn ${copied ? "copied" : ""}`} onClick={copyMessage} aria-label="Copy message">
<svg viewBox="0 0 20 20" focusable="false" aria-hidden="true">
<rect x="7" y="7" width="10" height="10" rx="2" />
<path d="M13 7V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2" />
</svg>
</button>
{onResend && (
<button
type="button"
className="action-btn"
onClick={() => onResend(message)}
aria-label="Regenerate response"
title="Regenerate response"
>
{/* Refresh / regenerate icon */}
<svg viewBox="0 0 20 20" focusable="false" aria-hidden="true">
<path d="M4 10a6 6 0 1 0 1.24-3.72" strokeLinecap="round" strokeLinejoin="round" />
<polyline points="1 4 4 7 7 4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
)}
<button
type="button"
className="action-btn"
+13 -12
View File
@@ -2,7 +2,7 @@ import { useState, useRef, useEffect } from "react";
import { ChevronDown, Loader2 } from "lucide-react";
import type { ModelInfo, ServerStatus } from "../lib/types";
import type { RuntimeModelFile } from "../features/runtime/types";
import { getDisplayName, isInChatSelector } from "../lib/modelAlias";
import { getDisplayName } from "../lib/modelAlias";
interface ModelSelectorPillProps {
models: ModelInfo[];
@@ -40,7 +40,10 @@ export default function ModelSelectorPill({
const handleSelectModel = (modelId: string) => {
setDropdownOpen(false);
onModelChange(modelId);
if (onModelSwap) {
// Only call onModelSwap for local models (which have file paths)
// API models don't have paths and shouldn't trigger a model swap
const isLocalModel = localModels.some((m) => m.path === modelId);
if (onModelSwap && isLocalModel) {
onModelSwap(modelId);
}
};
@@ -50,17 +53,15 @@ export default function ModelSelectorPill({
...models.map((m) => ({ id: m.id, label: getDisplayName(m.id) })),
...localModels.map((m) => ({ id: m.path, label: getDisplayName(m.path) })),
];
// Deduplicate by id
const seen = new Set<string>();
// Deduplicate by label (display name) - same name means same model
const seenLabels = new Set<string>();
const dedupedModels = allAvailableModels.filter((m) => {
if (seen.has(m.id)) return false;
seen.add(m.id);
if (seenLabels.has(m.label)) return false;
seenLabels.add(m.label);
return true;
});
// Chat selector: only Cooper + Iron (Steel is settings-only)
const chatSelectorModels = dedupedModels.filter((m) => isInChatSelector(m.id));
// Show all available models in the selector
const selectedLabel = getDisplayName(selectedModel) || "No model";
return (
@@ -69,8 +70,8 @@ export default function ModelSelectorPill({
type="button"
className="model-selector-pill"
onClick={() => setDropdownOpen(!dropdownOpen)}
disabled={chatSelectorModels.length === 0 || isSwapping}
title={chatSelectorModels.length === 0 ? "Start server to load models" : ""}
disabled={dedupedModels.length === 0 || isSwapping}
title={dedupedModels.length === 0 ? "Start server to load models" : ""}
aria-label="Choose active model"
aria-expanded={dropdownOpen}
>
@@ -84,7 +85,7 @@ export default function ModelSelectorPill({
</button>
{dropdownOpen && (
<ul className="model-selector-menu" role="listbox">
{chatSelectorModels.map((m) => (
{dedupedModels.map((m) => (
<li
key={m.id}
role="option"
+133 -295
View File
@@ -1,32 +1,13 @@
.sidebar {
width: 276px;
min-width: 276px;
background: #1f1f1f;
width: 288px;
min-width: 288px;
background: #18181b;
border-right: 1px solid rgba(255, 255, 255, 0.04);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar--initial .model-section-label,
.sidebar--initial .footer-meta,
.sidebar--initial .footer-version {
display: none;
}
.sidebar--initial .model-section {
padding-top: 8px;
}
.sidebar--initial .model-row .icon-btn {
display: none;
}
.sidebar--initial .model-dropdown-trigger {
background: transparent;
border-color: rgba(255, 255, 255, 0.03);
}
.sidebar-header-actions {
display: flex;
align-items: center;
@@ -37,12 +18,10 @@
display: none;
}
/* ── Header ── */
.sidebar-header {
display: flex;
align-items: flex-start;
padding: 14px 12px 10px;
border-bottom: 0;
align-items: center;
padding: 12px 16px 8px;
gap: 8px;
flex-shrink: 0;
}
@@ -50,23 +29,21 @@
.sidebar-brand {
display: flex;
align-items: center;
gap: 10px;
gap: 8px;
flex: 1;
min-width: 0;
}
.brand-mark {
width: 26px;
height: 26px;
border-radius: 8px;
width: 24px;
height: 24px;
border-radius: 7px;
border: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.02);
color: var(--text);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.82rem;
font-weight: 700;
flex-shrink: 0;
}
@@ -77,25 +54,17 @@
}
.brand-name {
font-size: 15px;
font-weight: 600;
font-size: 14px;
font-weight: 500;
letter-spacing: -0.01em;
color: var(--text);
}
.brand-subtitle {
display: none;
}
.sidebar-nav-section {
padding: 2px 8px 10px;
padding: 4px 10px 10px;
flex-shrink: 0;
}
.sidebar-section-label {
display: none;
}
.sidebar-nav-list {
display: grid;
gap: 4px;
@@ -108,286 +77,125 @@
gap: 10px;
border: 1px solid transparent;
background: transparent;
color: var(--text2);
border-radius: 10px;
padding: 10px 12px;
color: #d4d4d8;
border-radius: 12px;
padding: 9px 12px;
font: inherit;
font-size: 13px;
font-size: 14px;
cursor: pointer;
text-align: left;
transition: background 0.15s, border-color 0.15s, color 0.15s;
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.sidebar-nav-item:hover {
background: rgba(255, 255, 255, 0.03);
background: rgba(39, 39, 42, 0.96);
border-color: rgba(255, 255, 255, 0.05);
color: var(--text);
color: #fff;
}
.sidebar-nav-item svg {
color: var(--text3);
color: #a1a1aa;
flex-shrink: 0;
}
.sidebar-nav-badge {
margin-left: auto;
min-height: 18px;
padding: 0 7px;
border-radius: 999px;
background: rgba(59, 130, 246, 0.18);
border: 1px solid rgba(96, 165, 250, 0.28);
color: #bfdbfe;
font-size: 10px;
font-weight: 600;
}
.icon-btn {
background: none;
border: none;
color: var(--text3);
color: #a1a1aa;
cursor: pointer;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
transition: background 0.15s, color 0.15s;
border-radius: 10px;
transition: background 0.2s, color 0.2s;
flex-shrink: 0;
padding: 0;
}
.icon-btn:hover {
background: rgba(255, 255, 255, 0.04);
color: var(--text);
color: #fff;
}
/* ── Model section ── */
.model-section {
margin-top: auto;
padding: 10px 12px 12px;
border-top: 1px solid rgba(255, 255, 255, 0.04);
border-bottom: 0;
flex-shrink: 0;
}
.model-section-label {
font-size: 11px;
font-weight: 600;
color: var(--text3);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 6px;
}
.model-row {
display: flex;
align-items: center;
gap: 8px;
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
transition: background 0.3s, box-shadow 0.3s;
}
.status-dot.status-ok {
background: var(--success);
box-shadow: 0 0 6px rgba(48,209,88,0.5);
}
.status-dot.status-error {
background: var(--danger);
}
.status-dot.status-loading {
background: var(--warning);
animation: pulse 1.2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.model-select {
flex: 1;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--text);
font-size: 13px;
padding: 6px 28px 6px 8px;
outline: none;
cursor: pointer;
min-width: 0;
font-family: inherit;
transition: border-color 0.15s, background 0.15s;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%236b7280'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
}
.model-select:hover {
background-color: var(--bg3);
border-color: var(--border);
}
.model-select:focus {
border-color: var(--accent);
background-color: var(--bg3);
}
.model-select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Custom dropdown (replaces native select) ── */
.model-dropdown-wrapper {
flex: 1;
min-width: 0;
position: relative;
}
.model-dropdown-trigger {
width: 100%;
background: rgba(255, 255, 255, 0.025);
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--text);
font-size: 13px;
padding: 6px 28px 6px 8px;
outline: none;
cursor: pointer;
min-width: 0;
font-family: inherit;
transition: border-color 0.15s, background 0.15s;
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
}
.model-dropdown-trigger:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.05);
border-color: var(--border);
}
.model-dropdown-trigger:focus {
border-color: var(--accent);
background: rgba(255, 255, 255, 0.05);
}
.model-dropdown-trigger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.model-dropdown-chevron {
flex-shrink: 0;
color: var(--text3);
transition: transform 0.15s;
}
.model-dropdown-trigger[aria-expanded="true"] .model-dropdown-chevron {
transform: rotate(180deg);
}
.model-dropdown-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
z-index: 100;
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-lg);
max-height: 200px;
overflow-y: auto;
padding: 4px 0;
list-style: none;
}
.model-dropdown-item {
padding: 8px 12px;
font-size: 13px;
color: var(--text2);
cursor: pointer;
transition: background 0.1s, color 0.1s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.model-dropdown-item:hover {
background: rgba(255, 255, 255, 0.04);
color: var(--text);
}
.model-dropdown-item--selected {
background: rgba(72,166,255,0.07);
color: var(--accent);
font-weight: 500;
}
/* ── Sidebar actions ── */
.sidebar-actions {
padding: 6px 8px 8px;
padding: 4px 10px 8px;
flex-shrink: 0;
}
.sidebar-actions--top {
padding-top: 2px;
}
.new-chat-btn {
width: 100%;
background: rgba(255, 255, 255, 0.05);
color: var(--text);
border: 1px solid rgba(255, 255, 255, 0.02);
border-radius: 10px;
background: rgba(255, 255, 255, 0.04);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.03);
border-radius: 14px;
padding: 10px 13px;
font-size: 13px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.15s;
transition: background 0.2s, color 0.2s, border-color 0.2s, transform 0.2s;
font-family: inherit;
height: 40px;
}
.new-chat-btn:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--text);
background: rgba(39, 39, 42, 0.96);
border-color: rgba(255, 255, 255, 0.05);
}
.new-chat-btn:active {
transform: scale(0.98);
transform: scale(0.985);
}
.sidebar-list-meta {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 14px 6px;
gap: 10px;
}
/* ── Chat list ── */
.chat-list-section-label {
font-size: 10px;
font-size: 11px;
font-weight: 600;
color: var(--text3);
text-transform: uppercase;
letter-spacing: 0.12em;
padding: 12px 12px 6px;
flex-shrink: 0;
color: #a1a1aa;
letter-spacing: 0.02em;
}
.sidebar-list-filter {
font-size: 11px;
color: #71717a;
}
.chat-list {
flex: 1;
overflow-y: auto;
padding: 4px 8px 10px;
padding: 0 10px 10px;
display: flex;
flex-direction: column;
gap: 1px;
gap: 4px;
}
.chat-list-empty {
text-align: center;
color: var(--text3);
color: #71717a;
font-size: 12px;
padding: 20px 8px 24px;
padding: 24px 8px 28px;
line-height: 1.6;
}
@@ -395,12 +203,12 @@
display: flex;
align-items: center;
gap: 4px;
border-radius: var(--radius-sm);
border-radius: 14px;
font-size: 13px;
color: var(--text2);
transition: background 0.12s, color 0.12s;
color: #d4d4d8;
transition: background 0.2s, color 0.2s, border-color 0.2s;
position: relative;
border-top: 1px solid rgba(255, 255, 255, 0.02);
border: 1px solid transparent;
}
.chat-item-button {
@@ -408,29 +216,39 @@
min-width: 0;
display: flex;
align-items: center;
padding: 10px 10px;
gap: 10px;
padding: 9px 10px;
background: transparent;
border: 0;
color: inherit;
cursor: pointer;
font: inherit;
text-align: left;
border-radius: var(--radius-sm);
}
.chat-item:hover {
background: rgba(255, 255, 255, 0.04);
color: var(--text);
border-radius: 14px;
}
.chat-item:hover,
.chat-item:focus-within {
background: rgba(255, 255, 255, 0.04);
color: var(--text);
background: rgba(39, 39, 42, 0.92);
color: #fff;
}
.chat-item.active {
background: rgba(255, 255, 255, 0.06);
color: var(--text);
color: #fff;
border-color: rgba(255, 255, 255, 0.06);
}
.chat-item-icon {
width: 24px;
height: 24px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.04);
color: #a1a1aa;
flex-shrink: 0;
}
.chat-item-title {
@@ -446,63 +264,83 @@
opacity: 0;
background: none;
border: none;
color: var(--text3);
color: #71717a;
cursor: pointer;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: opacity 0.15s, background 0.15s, color 0.15s;
border-radius: 8px;
transition: opacity 0.2s, background 0.2s, color 0.2s;
flex-shrink: 0;
padding: 0;
margin-right: 4px;
}
.chat-item:hover .chat-item-del {
opacity: 1;
}
.chat-item:hover .chat-item-del,
.chat-item:focus-within .chat-item-del,
.chat-item.active .chat-item-del {
opacity: 1;
}
.chat-item-del:hover {
background: rgba(240,64,64,0.15);
background: rgba(240, 64, 64, 0.15);
color: var(--danger);
}
/* ── Footer ── */
.sidebar-footer {
padding: 8px 12px 14px;
border-top: 0;
padding: 12px 14px 16px;
display: flex;
align-items: center;
justify-content: space-between;
flex-direction: column;
align-items: flex-start;
gap: 10px;
flex-shrink: 0;
}
.sidebar--initial .sidebar-footer {
padding-top: 0;
.sidebar-share-btn {
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.025);
color: #fff;
border-radius: 14px;
padding: 10px 12px;
text-align: left;
font-size: 13px;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.sidebar-share-btn:hover {
background: rgba(39, 39, 42, 0.96);
border-color: rgba(255, 255, 255, 0.08);
}
.sidebar-footer-copy {
display: flex;
flex-direction: column;
gap: 2px;
}
.footer-version {
font-size: 10px;
color: var(--text3);
letter-spacing: 0.02em;
align-items: center;
gap: 10px;
}
.footer-meta {
font-size: 11px;
color: var(--text2);
color: #a1a1aa;
}
.sidebar-footer-link {
display: inline-flex;
align-items: center;
gap: 6px;
border: 0;
background: transparent;
color: #71717a;
font-size: 11px;
cursor: pointer;
padding: 0;
}
.sidebar-footer-link:hover {
color: #d4d4d8;
}
@media (max-width: 960px) {
+161
View File
@@ -0,0 +1,161 @@
/* @vitest-environment jsdom */
import { createRoot, type Root } from "react-dom/client";
import { act } from "react-dom/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import Sidebar from "./Sidebar";
import type { Chat, ModelInfo } from "../lib/types";
import type { RuntimeModelFile } from "../features/runtime/types";
// ─── Mocks ───────────────────────────────────────────────────────────────────
const defaultChats: Chat[] = [
{ id: 1, title: "Chat One", created_at: 1, updated_at: 2 },
];
const defaultApiModels: ModelInfo[] = [
{ id: "api-model-1", object: "model", created: 1, owned_by: "local" },
];
const defaultLocalModels: RuntimeModelFile[] = [
{ name: "local-model-1.gguf", path: "/models/local-model-1.gguf" },
{ name: "local-model-2.gguf", path: "/models/local-model-2.gguf" },
];
const defaultProps = {
chats: defaultChats,
currentChatId: 1,
onSelectChat: vi.fn(),
onNewChat: vi.fn(),
onDeleteChat: vi.fn(),
models: defaultApiModels,
localModels: defaultLocalModels,
selectedModel: "api-model-1",
onModelChange: vi.fn(),
onModelSwap: vi.fn(),
isSwapping: false,
status: "ok" as const,
onRefreshModels: vi.fn(),
onOpenSettings: vi.fn(),
onCloseMobile: vi.fn(),
};
// ─── Test harness ────────────────────────────────────────────────────────────
describe("Sidebar model dropdown", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
// @ts-expect-error React test environment flag
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
vi.clearAllMocks();
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.restoreAllMocks();
// @ts-expect-error React test environment flag
globalThis.IS_REACT_ACT_ENVIRONMENT = false;
});
function renderSidebar(props = {}) {
act(() => {
root.render(<Sidebar {...defaultProps} {...props} />);
});
}
it("renders both API models and local scanned models in the dropdown", () => {
renderSidebar();
// Open the dropdown
const trigger = container.querySelector('[aria-label="Choose active model"]') as HTMLElement | null;
expect(trigger).toBeTruthy();
act(() => {
trigger?.click();
});
const text = container.textContent ?? "";
// API model should be present
expect(text).toContain("api-model-1");
// Local models should also be present
expect(text).toContain("local-model-1.gguf");
expect(text).toContain("local-model-2.gguf");
});
it("shows the selected model label in the trigger", () => {
renderSidebar({ selectedModel: "/models/local-model-1.gguf" });
const trigger = container.querySelector('[aria-label="Choose active model"]') as HTMLElement | null;
expect(trigger?.textContent).toContain("local-model-1.gguf");
});
it("calls onModelSwap when a model is selected from the dropdown", () => {
const onModelSwap = vi.fn();
const onModelChange = vi.fn();
renderSidebar({ onModelSwap, onModelChange });
// Open the dropdown
const trigger = container.querySelector('[aria-label="Choose active model"]') as HTMLElement | null;
expect(trigger).toBeTruthy();
act(() => {
trigger?.click();
});
// Click a local model option
const options = container.querySelectorAll('[role="option"]');
const localOption = Array.from(options).find((el) => el.textContent?.includes("local-model-1.gguf"));
expect(localOption).toBeTruthy();
act(() => {
(localOption as HTMLElement)?.click();
});
// Both onModelChange and onModelSwap should be called
expect(onModelChange).toHaveBeenCalledWith("/models/local-model-1.gguf");
expect(onModelSwap).toHaveBeenCalledWith("/models/local-model-1.gguf");
});
it("shows a spinner when isSwapping is true", () => {
renderSidebar({ isSwapping: true });
const trigger = container.querySelector('[aria-label="Choose active model"]') as HTMLElement | null;
expect(trigger).toBeTruthy();
// The spinner should be present inside the trigger
const spinner = trigger?.querySelector(".animate-spin");
expect(spinner).toBeTruthy();
});
it("deduplicates models when API and local models share the same id", () => {
const apiModels: ModelInfo[] = [
{ id: "/models/local-model-1.gguf", object: "model", created: 1, owned_by: "local" },
];
renderSidebar({ models: apiModels });
// Open the dropdown
const trigger = container.querySelector('[aria-label="Choose active model"]') as HTMLElement | null;
expect(trigger).toBeTruthy();
act(() => {
trigger?.click();
});
// Count how many times "local-model-1.gguf" appears as an option
const options = container.querySelectorAll('[role="option"]');
const matchingOptions = Array.from(options).filter((el) => el.textContent?.includes("local-model-1.gguf"));
expect(matchingOptions).toHaveLength(1);
});
it("disables the dropdown when no models are available", () => {
renderSidebar({ models: [], localModels: [] });
const trigger = container.querySelector('[aria-label="Choose active model"]') as HTMLButtonElement | null;
expect(trigger?.disabled).toBe(true);
});
});
+93 -65
View File
@@ -1,5 +1,6 @@
import { useState } from "react";
import type { Chat } from "../lib/types";
import { Plus, Settings, X, Trash2, Bot, Search, Library, PanelLeft } from "lucide-react";
import { Plus, X, Trash2, Bot, Search, Library, PanelLeft, FileCode2, ShieldAlert, Sparkles, FolderKanban, Settings2 } from "lucide-react";
import "./Sidebar.css";
interface Props {
@@ -18,74 +19,92 @@ export default function Sidebar({
onOpenSettings,
onCloseMobile,
}: Props) {
const [shareCopied, setShareCopied] = useState(false);
const workspaceNavItems = [
{ label: "Agents", icon: Bot },
{ label: "Agents", icon: Bot, badge: "New" },
{ label: "Search", icon: Search },
{ label: "Library", icon: Library },
];
const getTaskIcon = (title: string) => {
const normalized = title.toLowerCase();
if (normalized.includes("error") || normalized.includes("fix") || normalized.includes("bug")) return ShieldAlert;
if (normalized.includes("design") || normalized.includes("ui") || normalized.includes("landing")) return Sparkles;
if (normalized.includes("project") || normalized.includes("plan") || normalized.includes("roadmap")) return FolderKanban;
return FileCode2;
};
const handleShare = async () => {
try {
await navigator.clipboard.writeText("ForgeLM — local-first AI workspace");
setShareCopied(true);
window.setTimeout(() => setShareCopied(false), 1800);
} catch {
setShareCopied(false);
}
};
return (
<>
<aside className={`sidebar${initialScreen ? " sidebar--initial" : ""}`}>
{/* ── Header ── */}
<div className="sidebar-header">
<div className="sidebar-brand">
<div className="brand-mark" aria-hidden="true">
<PanelLeft size={15} strokeWidth={1.8} />
</div>
<div className="brand-copy">
<span className="brand-name">ForgeLM</span>
</div>
<aside className={`sidebar${initialScreen ? " sidebar--initial" : ""}`}>
<div className="sidebar-header">
<div className="sidebar-brand">
<div className="brand-mark" aria-hidden="true">
<PanelLeft size={15} strokeWidth={1.8} />
</div>
<div className="sidebar-header-actions">
<button
className="icon-btn"
onClick={onOpenSettings}
title="Settings"
aria-label="Open settings"
type="button"
>
<Settings size={16} strokeWidth={1.5} />
</button>
<button
className="icon-btn sidebar-close-btn"
onClick={onCloseMobile}
title="Close navigation"
aria-label="Close navigation"
type="button"
>
<X size={16} strokeWidth={1.5} />
</button>
<div className="brand-copy">
<span className="brand-name">ForgeLM</span>
</div>
</div>
<div className="sidebar-actions sidebar-actions--top">
<button className="new-chat-btn" onClick={onNewChat} type="button">
<Plus size={16} strokeWidth={1.5} />
<span>New task</span>
<div className="sidebar-header-actions">
<button
className="icon-btn sidebar-close-btn"
onClick={onCloseMobile}
title="Close navigation"
aria-label="Close navigation"
type="button"
>
<X size={16} strokeWidth={1.5} />
</button>
</div>
</div>
<div className="sidebar-nav-section">
<div className="sidebar-nav-list" aria-label="Workspace navigation">
{workspaceNavItems.map(({ label, icon: Icon }) => (
<button key={label} type="button" className="sidebar-nav-item">
<Icon size={15} strokeWidth={1.8} />
<span>{label}</span>
</button>
))}
</div>
<div className="sidebar-actions sidebar-actions--top">
<button className="new-chat-btn" onClick={onNewChat} type="button">
<Plus size={16} strokeWidth={1.8} />
<span>New task</span>
</button>
</div>
<div className="sidebar-nav-section">
<div className="sidebar-nav-list" aria-label="Workspace navigation">
{workspaceNavItems.map(({ label, icon: Icon, badge }) => (
<button key={label} type="button" className="sidebar-nav-item">
<Icon size={15} strokeWidth={1.8} />
<span>{label}</span>
{badge ? <span className="sidebar-nav-badge">{badge}</span> : null}
</button>
))}
</div>
</div>
{/* ── Chat list ── */}
{chats.length > 0 && <div className="chat-list-section-label">Recent work</div>}
<nav className="chat-list" aria-label="Conversation history">
{chats.length === 0 && (
<div className="chat-list-empty">
No work yet.<br />Start a new task above.
</div>
)}
{chats.map((chat) => (
<div className="sidebar-list-meta">
<div className="chat-list-section-label">Projects</div>
<div className="sidebar-list-filter">All tasks</div>
</div>
<nav className="chat-list" aria-label="Conversation history">
{chats.length === 0 && (
<div className="chat-list-empty">
No tasks yet.<br />Start a new task above.
</div>
)}
{chats.map((chat) => {
const TaskIcon = getTaskIcon(chat.title);
return (
<div
key={chat.id}
className={`chat-item ${chat.id === currentChatId ? "active" : ""}`}
@@ -96,8 +115,12 @@ export default function Sidebar({
onClick={() => onSelectChat(chat.id)}
aria-current={chat.id === currentChatId ? "page" : undefined}
>
<span className="chat-item-icon" aria-hidden="true">
<TaskIcon size={14} strokeWidth={1.8} />
</span>
<span className="chat-item-title">{chat.title}</span>
</button>
<button
type="button"
className="chat-item-del"
@@ -108,18 +131,23 @@ export default function Sidebar({
<Trash2 size={14} strokeWidth={1.5} />
</button>
</div>
))}
</nav>
);
})}
</nav>
{/* ── Footer ── */}
<div className="sidebar-footer">
<div className="sidebar-footer-copy">
<span className="footer-version">ForgeLM v0.1.0</span>
<span className="footer-meta">Local-first workspace</span>
</div>
<div className="sidebar-footer">
<button type="button" className="sidebar-share-btn" onClick={() => { void handleShare(); }}>
{shareCopied ? "Link copied" : "Share ForgeLM with a friend"}
</button>
<div className="sidebar-footer-copy">
<span className="footer-meta">Local-first workspace</span>
<button type="button" className="sidebar-footer-link" onClick={onOpenSettings}>
<Settings2 size={12} strokeWidth={1.8} />
<span>Settings</span>
</button>
</div>
</aside>
</>
</div>
</aside>
);
}
+97
View File
@@ -0,0 +1,97 @@
.toast-container {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: none;
max-width: 380px;
width: 100%;
}
.toast {
background: #1e2228;
border: 1px solid #2a3040;
border-radius: 10px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
padding: 12px 14px;
display: flex;
align-items: flex-start;
gap: 12px;
pointer-events: all;
border-left-width: 3px;
animation: toast-in 0.2s ease;
}
@keyframes toast-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.toast--info { border-left-color: #4b86ff; }
.toast--warning { border-left-color: #f59e0b; }
.toast--error { border-left-color: #ef4444; }
.toast-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.toast-message {
font-size: 13px;
font-weight: 500;
color: #e8eaf0;
line-height: 1.4;
}
.toast-detail {
font-size: 12px;
color: #8b92a0;
line-height: 1.4;
}
.toast-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.toast-action-btn {
font-size: 12px;
font-weight: 500;
color: #4b86ff;
background: transparent;
border: 1px solid rgba(75, 134, 255, 0.3);
border-radius: 6px;
padding: 4px 10px;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, border-color 0.15s;
}
.toast-action-btn:hover {
background: rgba(75, 134, 255, 0.1);
border-color: rgba(75, 134, 255, 0.5);
}
.toast-dismiss-btn {
font-size: 11px;
color: #5a6070;
background: transparent;
border: none;
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
line-height: 1;
transition: color 0.15s;
}
.toast-dismiss-btn:hover {
color: #8b92a0;
}
+124
View File
@@ -0,0 +1,124 @@
import { createContext, useCallback, useContext, useRef, useState } from "react";
import "./Toast.css";
export type ToastVariant = 'info' | 'warning' | 'error';
export interface ToastOptions {
id?: string;
message: string;
detail?: string;
action?: { label: string; onClick: () => void };
duration?: number; // ms, 0 = persistent
variant?: ToastVariant;
}
interface ToastItem extends Required<Omit<ToastOptions, 'id' | 'action'>> {
id: string;
action?: { label: string; onClick: () => void };
timerId?: ReturnType<typeof setTimeout>;
}
interface ToastContextValue {
showToast: (opts: ToastOptions) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used inside <ToastProvider>");
return ctx;
}
const MAX_TOASTS = 3;
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const counterRef = useRef(0);
const dismiss = useCallback((id: string) => {
setToasts((prev) => {
const item = prev.find((t) => t.id === id);
if (item?.timerId) clearTimeout(item.timerId);
return prev.filter((t) => t.id !== id);
});
}, []);
const showToast = useCallback((opts: ToastOptions) => {
const id = opts.id ?? `toast-${++counterRef.current}`;
const duration = opts.duration ?? 6000;
const variant = opts.variant ?? 'info';
setToasts((prev) => {
// Deduplicate: if same id exists, update it
const existing = prev.find((t) => t.id === id);
if (existing?.timerId) clearTimeout(existing.timerId);
let timerId: ReturnType<typeof setTimeout> | undefined;
if (duration > 0) {
timerId = setTimeout(() => dismiss(id), duration);
}
const newItem: ToastItem = {
id,
message: opts.message,
detail: opts.detail ?? '',
action: opts.action,
duration,
variant,
timerId,
};
if (existing) {
return prev.map((t) => (t.id === id ? newItem : t));
}
// Enforce max: remove oldest if needed
const next = [...prev, newItem];
if (next.length > MAX_TOASTS) {
const removed = next.shift()!;
if (removed.timerId) clearTimeout(removed.timerId);
}
return next;
});
}, [dismiss]);
return (
<ToastContext.Provider value={{ showToast }}>
{children}
<div className="toast-container" aria-live="polite" aria-atomic="false">
{toasts.map((t) => (
<div
key={t.id}
className={`toast toast--${t.variant}`}
role={t.variant === 'error' ? 'alert' : 'status'}
>
<div className="toast-body">
<span className="toast-message">{t.message}</span>
{t.detail && <span className="toast-detail">{t.detail}</span>}
</div>
<div className="toast-actions">
{t.action && (
<button
type="button"
className="toast-action-btn"
onClick={() => { t.action!.onClick(); dismiss(t.id); }}
>
{t.action.label}
</button>
)}
<button
type="button"
className="toast-dismiss-btn"
onClick={() => dismiss(t.id)}
aria-label="Dismiss notification"
>
✕
</button>
</div>
</div>
))}
</div>
</ToastContext.Provider>
);
}
+59 -9
View File
@@ -1,3 +1,5 @@
import type { RuntimeStatus } from "../features/runtime/types";
export interface ComposerNotice {
tone: "info" | "warning";
title: string;
@@ -6,31 +8,79 @@ export interface ComposerNotice {
}
const SUGGESTED_PROMPTS = [
"Summarize this repository architecture.",
"Help me debug why the local runtime is not ready.",
"Draft a cleaner system prompt for this coding workflow.",
"Inspect the repo structure and runtime flow.",
"Diagnose why the local model is not ready.",
"Draft a tighter system prompt for this workspace.",
] as const;
export function getSuggestedPrompts(): string[] {
return [...SUGGESTED_PROMPTS];
}
export function getComposerNotice(model: string, server: string): ComposerNotice | null {
export function getComposerNotice(
model: string,
server: string,
runtimeMode: "local" | "remote" = "remote",
runtimeStatus: RuntimeStatus = "ready",
isSwapping = false,
): ComposerNotice | null {
if (!server.trim()) {
return {
tone: "warning",
title: "Connect an API",
description: "Open runtime settings and configure a local or remote endpoint before sending messages.",
actionLabel: "Show runtime panel",
title: "Connect runtime",
description: "Open the sidebar and point the app at a local or remote endpoint before sending a task.",
actionLabel: "Open sidebar",
};
}
if (runtimeMode === "local" && (isSwapping || runtimeStatus !== "ready")) {
switch (runtimeStatus) {
case "loading":
return {
tone: "warning",
title: "Model loading...",
description: "The selected local model is still warming up. Please wait until it finishes loading before sending a message.",
actionLabel: "Waiting",
};
case "starting":
return {
tone: "warning",
title: "Runtime starting...",
description: "The local API is still starting. Wait for it to become ready before sending a message.",
actionLabel: "Waiting",
};
case "stopping":
return {
tone: "warning",
title: "Runtime stopping...",
description: "The local API is shutting down. Please wait until it finishes before sending another message.",
actionLabel: "Waiting",
};
case "degraded":
return {
tone: "warning",
title: "Runtime not ready",
description: "The local API is running but not fully ready. Wait for the health check to recover before sending a message.",
actionLabel: "Waiting",
};
case "error":
case "stopped":
default:
return {
tone: "warning",
title: "Runtime unavailable",
description: "The local API is not ready. Open the runtime settings and start it again before sending a message.",
actionLabel: "Open settings",
};
}
}
if (!model.trim()) {
return {
tone: "info",
title: "Choose a model",
description: "Start the runtime or refresh the model list, then select the model you want to chat with.",
actionLabel: "Show runtime panel",
description: "Refresh the model list or start the runtime, then select the workspace model.",
actionLabel: "Open sidebar",
};
}
@@ -0,0 +1,304 @@
import { useEffect, useMemo, useState } from "react";
import hljs from "highlight.js/lib/core";
import bash from "highlight.js/lib/languages/bash";
import c from "highlight.js/lib/languages/c";
import cpp from "highlight.js/lib/languages/cpp";
import css from "highlight.js/lib/languages/css";
import go from "highlight.js/lib/languages/go";
import javascript from "highlight.js/lib/languages/javascript";
import json from "highlight.js/lib/languages/json";
import python from "highlight.js/lib/languages/python";
import rust from "highlight.js/lib/languages/rust";
import sql from "highlight.js/lib/languages/sql";
import typescript from "highlight.js/lib/languages/typescript";
import xml from "highlight.js/lib/languages/xml";
import { ensureHljs, renderMarkdown } from "../../lib/markdown";
interface ArtifactViewerProps {
artifact: {
filename: string;
content: string;
language?: string;
};
onDownload?: () => void;
}
let codeHlReady = false;
function ensureCodeHljs() {
if (codeHlReady) return;
hljs.registerLanguage("javascript", javascript);
hljs.registerLanguage("typescript", typescript);
hljs.registerLanguage("python", python);
hljs.registerLanguage("rust", rust);
hljs.registerLanguage("bash", bash);
hljs.registerLanguage("json", json);
hljs.registerLanguage("sql", sql);
hljs.registerLanguage("css", css);
hljs.registerLanguage("xml", xml);
hljs.registerLanguage("go", go);
hljs.registerLanguage("cpp", cpp);
hljs.registerLanguage("c", c);
codeHlReady = true;
}
function getExtension(filename: string) {
const trimmed = filename.trim().toLowerCase();
const parts = trimmed.split(".");
if (parts.length < 2) return "";
return parts[parts.length - 1] ?? "";
}
function getLanguage(filename: string, language?: string) {
if (language) return language.toLowerCase();
const ext = getExtension(filename);
const map: Record<string, string> = {
md: "markdown",
markdown: "markdown",
ts: "typescript",
tsx: "typescript",
js: "javascript",
jsx: "javascript",
py: "python",
rs: "rust",
go: "go",
c: "c",
h: "c",
cpp: "cpp",
cc: "cpp",
cxx: "cpp",
css: "css",
html: "xml",
xml: "xml",
json: "json",
sql: "sql",
sh: "bash",
bash: "bash",
zsh: "bash",
};
return map[ext] ?? ext ?? "plaintext";
}
function fileTypeTone(ext: string) {
switch (ext) {
case "md":
case "markdown":
return "from-sky-400/20 to-cyan-400/10 text-sky-300 border-sky-500/20";
case "json":
return "from-emerald-400/20 to-teal-400/10 text-emerald-300 border-emerald-500/20";
case "ts":
case "tsx":
case "js":
case "jsx":
return "from-indigo-400/20 to-violet-400/10 text-indigo-300 border-indigo-500/20";
case "py":
return "from-amber-400/20 to-orange-400/10 text-amber-300 border-amber-500/20";
default:
return "from-zinc-400/20 to-zinc-500/10 text-zinc-300 border-zinc-700";
}
}
function FileGlyph({ filename }: { filename: string }) {
const ext = getExtension(filename);
const tone = fileTypeTone(ext);
if (ext === "md" || ext === "markdown") {
return (
<div className={`flex h-11 w-11 items-center justify-center rounded-2xl border bg-gradient-to-br ${tone}`}>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
<path d="M7 3.75h7l4.25 4.25V20.25H7z" strokeLinejoin="round" />
<path d="M14 3.75v4.5h4.25" strokeLinejoin="round" />
<path d="M10 11.25h4" strokeLinecap="round" />
<path d="M10 14.25h7" strokeLinecap="round" />
<path d="M10 17.25h5" strokeLinecap="round" />
</svg>
</div>
);
}
if (["ts", "tsx", "js", "jsx", "c", "cpp", "go", "rs", "py"].includes(ext)) {
return (
<div className={`flex h-11 w-11 items-center justify-center rounded-2xl border bg-gradient-to-br ${tone}`}>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
<path d="M9 8 5 12l4 4" strokeLinecap="round" strokeLinejoin="round" />
<path d="m15 8 4 4-4 4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
);
}
if (ext === "json") {
return (
<div className={`flex h-11 w-11 items-center justify-center rounded-2xl border bg-gradient-to-br ${tone}`}>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
<path d="M9 8c-1.3 0-2 .7-2 2v1c0 .7-.3 1.3-.9 1.7-.6.4-1.1 1-.1 1.4.8.3 1 .8 1 1.5v1c0 1.3.7 2 2 2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M15 8c1.3 0 2 .7 2 2v1c0 .7.3 1.3.9 1.7.6.4 1.1 1 .1 1.4-.8.3-1 .8-1 1.5v1c0 1.3-.7 2-2 2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
);
}
return (
<div className={`flex h-11 w-11 items-center justify-center rounded-2xl border bg-gradient-to-br ${tone}`}>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
<path d="M7 3.75h7l4.25 4.25V20.25H7z" strokeLinejoin="round" />
<path d="M14 3.75v4.5h4.25" strokeLinejoin="round" />
</svg>
</div>
);
}
function downloadFile(filename: string, content: string, language?: string) {
const safeFilename = filename || "artifact.txt";
const lower = safeFilename.toLowerCase();
const mime =
language === "markdown" || lower.endsWith(".md")
? "text/markdown;charset=utf-8"
: "text/plain;charset=utf-8";
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = safeFilename;
link.click();
URL.revokeObjectURL(url);
}
export default function ArtifactViewer({ artifact, onDownload }: ArtifactViewerProps) {
const [expanded, setExpanded] = useState(true);
const [copied, setCopied] = useState(false);
const ext = getExtension(artifact.filename);
const isMarkdown = ext === "md" || ext === "markdown" || artifact.language?.toLowerCase() === "markdown";
const language = getLanguage(artifact.filename, artifact.language);
const previewId = useMemo(() => `artifact-preview-${artifact.filename.replace(/[^a-zA-Z0-9_-]+/g, "-")}`, [artifact.filename]);
useEffect(() => {
void ensureHljs();
ensureCodeHljs();
}, []);
const previewHtml = useMemo(() => {
if (isMarkdown) return renderMarkdown(artifact.content);
let highlighted = artifact.content;
try {
highlighted = language !== "plaintext" && hljs.getLanguage(language)
? hljs.highlight(artifact.content, { language }).value
: hljs.highlightAuto(artifact.content).value;
} catch {
highlighted = artifact.content.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
return `<pre class="hljs rounded-2xl border border-zinc-800 bg-zinc-950 p-4 text-sm leading-7 text-zinc-200 overflow-auto"><code class="language-${language}">${highlighted}</code></pre>`;
}, [artifact.content, isMarkdown, language]);
const handleCopy = async () => {
await navigator.clipboard.writeText(artifact.content);
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
};
const handleDownload = () => {
if (onDownload) {
onDownload();
return;
}
downloadFile(artifact.filename, artifact.content, artifact.language ?? language);
};
return (
<section className="overflow-hidden rounded-3xl border border-zinc-800/80 bg-zinc-950 text-zinc-100 shadow-[0_24px_80px_-36px_rgba(0,0,0,0.9)]">
<header className="border-b border-zinc-800/80 bg-gradient-to-b from-zinc-900/80 to-zinc-950 px-4 py-4 sm:px-5">
<div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
<FileGlyph filename={artifact.filename} />
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="truncate text-sm font-semibold text-zinc-50" title={artifact.filename}>
{artifact.filename}
</h2>
<span className="rounded-full border border-zinc-800 bg-zinc-900 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.22em] text-zinc-400">
{language}
</span>
</div>
<p className="mt-1 text-xs leading-5 text-zinc-500">
Preview, copy, or download the generated artifact.
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleCopy}
className="inline-flex items-center gap-2 rounded-xl border border-zinc-700 bg-zinc-900/80 px-3 py-2 text-sm font-medium text-zinc-100 transition-colors hover:border-zinc-600 hover:bg-zinc-800"
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
<path d="M9 9h10v10H9z" strokeLinejoin="round" />
<path d="M5 15V5h10" strokeLinecap="round" strokeLinejoin="round" />
</svg>
{copied ? "Copied" : "Copy"}
</button>
<button
type="button"
onClick={handleDownload}
className="inline-flex items-center gap-2 rounded-xl border border-zinc-700 bg-zinc-900/80 px-3 py-2 text-sm font-medium text-zinc-100 transition-colors hover:border-zinc-600 hover:bg-zinc-800"
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
<path d="M12 4v10" strokeLinecap="round" />
<path d="m8 10 4 4 4-4" strokeLinecap="round" strokeLinejoin="round" />
<path d="M5 20h14" strokeLinecap="round" />
</svg>
Download
</button>
<button
type="button"
aria-expanded={expanded}
aria-controls={previewId}
onClick={() => setExpanded((value) => !value)}
className="inline-flex items-center rounded-xl border border-zinc-700 bg-zinc-900/80 p-2 text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-800"
>
<svg
viewBox="0 0 24 24"
className={`h-4 w-4 transition-transform duration-200 ${expanded ? "rotate-180" : "rotate-0"}`}
fill="none"
stroke="currentColor"
strokeWidth="1.8"
aria-hidden="true"
>
<path d="m6 9 6 6 6-6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
</div>
</header>
{expanded ? (
<div
id={previewId}
className="max-h-[70vh] overflow-auto p-4 sm:p-5"
>
{isMarkdown ? (
<div
className="max-w-none text-sm leading-7 text-zinc-300 [&_h1]:mb-4 [&_h1]:text-3xl [&_h1]:font-semibold [&_h1]:tracking-tight [&_h1]:text-zinc-50 [&_h2]:mb-3 [&_h2]:mt-7 [&_h2]:text-2xl [&_h2]:font-semibold [&_h2]:text-zinc-50 [&_h3]:mb-2 [&_h3]:mt-5 [&_h3]:text-xl [&_h3]:font-semibold [&_h3]:text-zinc-100 [&_h4]:mt-5 [&_h4]:text-lg [&_h4]:font-semibold [&_h4]:text-zinc-100 [&_p]:my-3 [&_p]:text-zinc-300 [&_a]:text-sky-400 [&_a]:underline [&_a:hover]:text-sky-300 [&_ul]:my-3 [&_ul]:list-disc [&_ul]:space-y-2 [&_ul]:pl-6 [&_ol]:my-3 [&_ol]:list-decimal [&_ol]:space-y-2 [&_ol]:pl-6 [&_li]:text-zinc-300 [&_blockquote]:my-4 [&_blockquote]:border-l-2 [&_blockquote]:border-zinc-700 [&_blockquote]:pl-4 [&_blockquote]:text-zinc-400 [&_blockquote]:italic [&_hr]:my-6 [&_hr]:border-zinc-800 [&_table]:my-4 [&_table]:w-full [&_table]:border-collapse [&_table]:overflow-hidden [&_table]:rounded-lg [&_table]:border [&_table]:border-zinc-800 [&_th]:border [&_th]:border-zinc-800 [&_th]:bg-zinc-900 [&_th]:px-3 [&_th]:py-2 [&_th]:text-left [&_th]:font-semibold [&_th]:text-zinc-100 [&_td]:border [&_td]:border-zinc-800 [&_td]:px-3 [&_td]:py-2 [&_td]:text-zinc-300 [&_code:not(.hljs)]:rounded [&_code:not(.hljs)]:border [&_code:not(.hljs)]:border-zinc-800 [&_code:not(.hljs)]:bg-zinc-900 [&_code:not(.hljs)]:px-1.5 [&_code:not(.hljs)]:py-0.5 [&_code:not(.hljs)]:font-mono [&_code:not(.hljs)]:text-[0.85em] [&_.code-block]:my-4 [&_.code-block]:overflow-hidden [&_.code-block]:rounded-2xl [&_.code-block]:border [&_.code-block]:border-zinc-800 [&_.code-block]:bg-zinc-950 [&_.code-header]:flex [&_.code-header]:items-center [&_.code-header]:justify-between [&_.code-header]:border-b [&_.code-header]:border-zinc-800 [&_.code-header]:bg-zinc-900/80 [&_.code-header]:px-3 [&_.code-header]:py-2 [&_.code-lang]:font-mono [&_.code-lang]:text-[11px] [&_.code-lang]:uppercase [&_.code-lang]:tracking-[0.22em] [&_.code-lang]:text-zinc-500 [&_.copy-code-btn]:rounded-lg [&_.copy-code-btn]:border [&_.copy-code-btn]:border-zinc-700 [&_.copy-code-btn]:bg-zinc-900 [&_.copy-code-btn]:px-2 [&_.copy-code-btn]:py-1 [&_.copy-code-btn]:text-[11px] [&_.copy-code-btn]:font-medium [&_.copy-code-btn]:uppercase [&_.copy-code-btn]:tracking-[0.18em] [&_.copy-code-btn]:text-zinc-200 [&_.copy-code-btn]:transition-colors [&_.copy-code-btn:hover]:border-zinc-600 [&_.copy-code-btn:hover]:bg-zinc-800"
dangerouslySetInnerHTML={{ __html: previewHtml }}
/>
) : (
<div
className="overflow-hidden rounded-2xl border border-zinc-800 bg-zinc-950"
dangerouslySetInnerHTML={{ __html: previewHtml }}
/>
)}
</div>
) : (
<div className="border-t border-zinc-800/70 px-5 py-4 text-sm text-zinc-500">
Preview collapsed.
</div>
)}
</section>
);
}
@@ -0,0 +1,189 @@
import { useEffect, useMemo, useState } from "react";
import type { MouseEvent } from "react";
import DOMPurify from "dompurify";
import hljs from "highlight.js";
import { marked } from "marked";
import { Bot } from "lucide-react";
import "highlight.js/styles/github-dark-dimmed.css";
export interface MessageBubbleProps {
role: "user" | "assistant";
content: string;
isAgentic?: boolean;
timestamp?: Date;
}
const markdownChrome = [
"[&_p]:m-0",
"[&_p+p]:mt-3",
"[&_h1]:mt-4 [&_h1]:text-xl [&_h1]:font-semibold [&_h1]:tracking-tight",
"[&_h2]:mt-4 [&_h2]:text-lg [&_h2]:font-semibold [&_h2]:tracking-tight",
"[&_h3]:mt-3 [&_h3]:text-base [&_h3]:font-semibold",
"[&_ul]:my-3 [&_ul]:list-disc [&_ul]:space-y-1 [&_ul]:pl-5",
"[&_ol]:my-3 [&_ol]:list-decimal [&_ol]:space-y-1 [&_ol]:pl-5",
"[&_li>p]:m-0",
"[&_blockquote]:my-3 [&_blockquote]:border-l-2 [&_blockquote]:border-zinc-700 [&_blockquote]:pl-4 [&_blockquote]:text-zinc-400",
"[&_a]:text-cyan-300 [&_a]:underline [&_a]:underline-offset-4 [&_a]:decoration-cyan-300/40 hover:[&_a]:text-cyan-200",
"[&_strong]:font-semibold [&_strong]:text-zinc-50",
"[&_em]:italic",
"[&_hr]:my-4 [&_hr]:border-zinc-800",
"[&_table]:my-4 [&_table]:w-full [&_table]:overflow-hidden [&_table]:rounded-xl [&_table]:border [&_table]:border-zinc-800",
"[&_th]:border-b [&_th]:border-zinc-800 [&_th]:bg-zinc-900 [&_th]:px-3 [&_th]:py-2 [&_th]:text-left",
"[&_td]:border-b [&_td]:border-zinc-900 [&_td]:px-3 [&_td]:py-2",
"[&_img]:my-4 [&_img]:rounded-xl [&_img]:border [&_img]:border-zinc-800",
"[&_code:not(pre_code)]:rounded-md [&_code:not(pre_code)]:border [&_code:not(pre_code)]:border-white/5 [&_code:not(pre_code)]:bg-white/5 [&_code:not(pre_code)]:px-1.5 [&_code:not(pre_code)]:py-0.5 [&_code:not(pre_code)]:text-[0.92em]",
].join(" ");
function formatTimestamp(timestamp?: Date) {
if (!timestamp) return null;
return new Intl.DateTimeFormat([], { hour: "2-digit", minute: "2-digit" }).format(timestamp);
}
function safeLang(lang?: string) {
if (!lang) return "plaintext";
const normalized = lang.toLowerCase().trim();
return /^[a-z0-9_+-]+$/.test(normalized) ? normalized : "plaintext";
}
function highlightCode(code: string, lang?: string) {
const language = safeLang(lang);
if (language !== "plaintext" && hljs.getLanguage(language)) {
return hljs.highlight(code, { language }).value;
}
return hljs.highlightAuto(code).value;
}
function buildMarkdown(content: string) {
const renderer = new marked.Renderer();
renderer.code = (code, infostring) => {
const lang = infostring?.split(/\s+/)[0];
const highlighted = highlightCode(code, lang || undefined);
const encodedCode = encodeURIComponent(code);
const language = safeLang(lang || undefined);
return `
<div class="group relative my-4 overflow-hidden rounded-2xl border border-zinc-800 bg-[#111111] shadow-[0_16px_40px_rgba(0,0,0,0.35)]">
<button
type="button"
data-copy-code="true"
data-code="${encodedCode}"
class="absolute right-3 top-3 z-10 inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-zinc-950/80 px-2.5 py-1 text-[11px] font-medium text-zinc-300 backdrop-blur transition hover:border-white/20 hover:bg-white/10 hover:text-white"
aria-label="Copy code"
title="Copy code"
>
<span class="copy-label">Copy</span>
</button>
<div class="overflow-x-auto">
<pre class="m-0 bg-transparent p-4 pt-12 text-sm leading-6 text-zinc-100"><code class="hljs language-${language}">${highlighted}</code></pre>
</div>
</div>
`;
};
const rawHtml = marked.parse(content ?? "", {
renderer,
breaks: true,
gfm: true,
}) as string;
return DOMPurify.sanitize(rawHtml, {
ADD_ATTR: ["data-copy-code", "data-code", "aria-label", "title"],
});
}
export default function MessageBubble({ role, content, isAgentic, timestamp }: MessageBubbleProps) {
const [copied, setCopied] = useState(false);
const [isStreaming, setIsStreaming] = useState(false);
useEffect(() => {
setIsStreaming(true);
const timer = window.setTimeout(() => setIsStreaming(false), 450);
return () => window.clearTimeout(timer);
}, [content]);
const renderedContent = useMemo(() => buildMarkdown(content), [content]);
const timeLabel = formatTimestamp(timestamp);
const onContentClick = async (event: MouseEvent<HTMLDivElement>) => {
const target = event.target as HTMLElement;
const button = target.closest<HTMLButtonElement>("[data-copy-code]");
if (!button) return;
const code = decodeURIComponent(button.getAttribute("data-code") ?? "");
await navigator.clipboard.writeText(code);
const label = button.querySelector<HTMLElement>(".copy-label");
const icon = button.querySelector<HTMLElement>("svg");
const original = label?.textContent ?? "Copy";
if (label) label.textContent = "Copied";
if (icon) icon.style.display = "none";
setCopied(true);
window.setTimeout(() => {
if (label) label.textContent = original;
if (icon) icon.style.display = "inline-flex";
setCopied(false);
}, 1400);
};
const outerAlign = role === "user" ? "justify-end" : "justify-start";
const bubbleTone = role === "user" ? "bg-[#1E3A5F] text-slate-50" : "bg-[#1E1E1E] text-zinc-100";
const widthTone = role === "user" ? "max-w-[84%]" : "max-w-[86%]";
return (
<div className={`flex w-full ${outerAlign}`}>
<article
className={`${widthTone} rounded-2xl border border-white/5 px-4 py-3 shadow-[0_18px_50px_rgba(0,0,0,0.35)] ${bubbleTone}`}
>
{role === "assistant" ? (
<header className="mb-3 flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-white/10 bg-zinc-950/80 text-zinc-100 shadow-inner shadow-black/30">
<Bot className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold tracking-tight text-zinc-50">Forge</h3>
<span className="rounded-full border border-cyan-400/20 bg-cyan-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.18em] text-cyan-200">
Forge
</span>
{isAgentic ? (
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.18em] text-emerald-200">
Agentic
</span>
) : null}
{timeLabel ? <span className="text-[11px] text-zinc-500">{timeLabel}</span> : null}
</div>
</div>
</header>
) : null}
<div
onClick={onContentClick}
className={`${markdownChrome} text-[14px] leading-6 ${role === "user" ? "[&_a]:text-sky-200 [&_strong]:text-white" : "[&_a]:text-cyan-300 [&_strong]:text-zinc-50"}`}
dangerouslySetInnerHTML={{ __html: renderedContent }}
/>
{isStreaming ? (
<div className="mt-2 flex items-center gap-1 text-xs text-zinc-400">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-zinc-400/80" />
<span>streaming</span>
</div>
) : null}
{role === "user" && timeLabel ? (
<div className="mt-2 flex items-center justify-end gap-2 text-[11px] text-slate-300/70">
<span>{timeLabel}</span>
</div>
) : null}
{copied ? <span className="sr-only">Code copied</span> : null}
</article>
</div>
);
}
+198
View File
@@ -0,0 +1,198 @@
import { useEffect, useMemo, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Check, Circle, ChevronDown, Loader2, X } from "lucide-react";
type TaskStatus = "pending" | "running" | "completed" | "error";
export interface TaskListProps {
steps: Array<{
id: number;
title: string;
description: string;
status: TaskStatus;
sub_steps: Array<{
id: string;
description: string;
status: TaskStatus;
}>;
}>;
activeStepId?: number;
}
const statusStyles: Record<TaskStatus, { ring: string; text: string; badge: string }> = {
pending: {
ring: "border-zinc-700 bg-zinc-900/70 text-zinc-500",
text: "text-zinc-400",
badge: "border-zinc-800 bg-zinc-900 text-zinc-400",
},
running: {
ring: "border-sky-500/30 bg-sky-500/10 text-sky-400",
text: "text-sky-300",
badge: "border-sky-500/25 bg-sky-500/10 text-sky-300",
},
completed: {
ring: "border-emerald-500/30 bg-emerald-500/10 text-emerald-400",
text: "text-emerald-300",
badge: "border-emerald-500/25 bg-emerald-500/10 text-emerald-300",
},
error: {
ring: "border-rose-500/30 bg-rose-500/10 text-rose-400",
text: "text-rose-300",
badge: "border-rose-500/25 bg-rose-500/10 text-rose-300",
},
};
function StatusGlyph({ status }: { status: TaskStatus }) {
switch (status) {
case "running":
return <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />;
case "completed":
return <Check className="h-4 w-4" aria-hidden="true" />;
case "error":
return <X className="h-4 w-4" aria-hidden="true" />;
default:
return <Circle className="h-4 w-4" aria-hidden="true" />;
}
}
function TaskStatusIcon({ status }: { status: TaskStatus }) {
const palette = statusStyles[status];
return (
<motion.div
layout
className={`relative flex h-9 w-9 items-center justify-center rounded-full border ${palette.ring} shadow-sm shadow-black/20`}
animate={status === "completed" ? { scale: [0.92, 1.08, 1] } : { scale: 1 }}
transition={{ duration: 0.32, ease: "easeOut" }}
>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={status}
initial={{ opacity: 0, scale: 0.7, rotate: -8 }}
animate={{ opacity: 1, scale: 1, rotate: 0 }}
exit={{ opacity: 0, scale: 0.7, rotate: 8 }}
transition={{ duration: 0.2 }}
className={palette.text}
>
<StatusGlyph status={status} />
</motion.div>
</AnimatePresence>
{status === "completed" ? <span className="absolute inset-0 rounded-full bg-emerald-400/10 blur-[1px]" aria-hidden="true" /> : null}
</motion.div>
);
}
function TaskRow({
step,
isActive,
}: {
step: TaskListProps["steps"][number];
isActive: boolean;
}) {
const [expanded, setExpanded] = useState(isActive || step.status === "running" || step.status === "completed");
const hasSubSteps = step.sub_steps.length > 0;
useEffect(() => {
if (isActive || step.status === "running") setExpanded(true);
}, [isActive, step.status]);
return (
<motion.li layout className="relative pl-10">
<div className="absolute left-4 top-0 bottom-0 w-px bg-zinc-800/90" aria-hidden="true" />
<div className="absolute left-[13px] top-5 h-2.5 w-2.5 rounded-full border border-zinc-700 bg-zinc-950" aria-hidden="true" />
<motion.article
layout
className={`rounded-2xl border bg-zinc-950/70 px-4 py-4 shadow-[0_0_0_1px_rgba(24,24,27,0.35)] transition-colors duration-300 ${
isActive ? "border-zinc-700 bg-zinc-900/85" : "border-zinc-800/80"
}`}
>
<div className="flex items-start gap-3">
<TaskStatusIcon status={step.status} />
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-md border border-zinc-800 bg-zinc-900 px-2 py-0.5 text-[11px] font-medium uppercase tracking-[0.18em] text-zinc-400">
Step {step.id}
</span>
<h3 className="truncate text-sm font-semibold text-zinc-100">{step.title}</h3>
</div>
<p className="mt-1 text-sm leading-6 text-zinc-400">{step.description}</p>
</div>
{hasSubSteps ? (
<button
type="button"
onClick={() => setExpanded((value) => !value)}
aria-expanded={expanded}
aria-label={expanded ? "Collapse task details" : "Expand task details"}
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-zinc-800 bg-zinc-950 text-zinc-400 transition-colors hover:border-zinc-700 hover:text-zinc-200"
>
<ChevronDown className={`h-4 w-4 transition-transform duration-300 ${expanded ? "rotate-180" : "rotate-0"}`} aria-hidden="true" />
</button>
) : null}
</div>
<AnimatePresence initial={false}>
{hasSubSteps && expanded ? (
<motion.div
key="substeps"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="overflow-hidden"
>
<div className="mt-4 space-y-2 border-l border-zinc-800/90 pl-4">
{step.sub_steps.map((subStep) => (
<motion.div
key={subStep.id}
layout
className="flex items-start gap-3 rounded-xl border border-zinc-900 bg-zinc-950/60 px-3 py-2"
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
>
<div className={`mt-0.5 rounded-full p-1 ${statusStyles[subStep.status].badge}`}>
<StatusGlyph status={subStep.status} />
</div>
<div className="min-w-0 flex-1">
<div className="text-xs uppercase tracking-[0.16em] text-zinc-500">{subStep.id}</div>
<div className="text-sm leading-6 text-zinc-200">{subStep.description}</div>
</div>
<span className={`shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.18em] ${statusStyles[subStep.status].badge}`}>
{subStep.status}
</span>
</motion.div>
))}
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
</div>
</motion.article>
</motion.li>
);
}
export default function TaskList({ steps, activeStepId }: TaskListProps) {
const activeIds = useMemo(() => new Set([activeStepId]), [activeStepId]);
return (
<section className="rounded-3xl border border-zinc-800 bg-zinc-950 text-zinc-100 shadow-2xl shadow-black/20">
<div className="border-b border-zinc-800 px-5 py-4">
<div className="text-[11px] uppercase tracking-[0.24em] text-zinc-500">Task list</div>
<h2 className="mt-1 text-lg font-semibold text-zinc-50">Thinking steps</h2>
</div>
<motion.ul layout className="space-y-3 px-4 py-4">
{steps.map((step) => (
<TaskRow key={step.id} step={step} isActive={activeIds.has(step.id)} />
))}
</motion.ul>
</section>
);
}
@@ -0,0 +1,165 @@
import { Icon } from "../Icon";
type StepStatus = "pending" | "running" | "completed" | "error";
interface ThinkingCardProps {
title: string;
overview: string;
steps: Array<{
id: number;
title: string;
description: string;
status: StepStatus;
sub_steps: Array<{
id: string;
description: string;
status: StepStatus;
}>;
}>;
currentStep: number;
isComplete: boolean;
}
function statusIcon(status: StepStatus) {
switch (status) {
case "running":
return <Icon name="Loader2" size={16} strokeWidth={2} className="animate-spin text-amber-400" aria-hidden="true" />;
case "completed":
return <Icon name="CheckCircle2" size={16} strokeWidth={2} className="text-emerald-400" aria-hidden="true" />;
case "error":
return <Icon name="XCircle" size={16} strokeWidth={2} className="text-rose-400" aria-hidden="true" />;
default:
return <Icon name="Circle" size={16} strokeWidth={2} className="text-zinc-500" aria-hidden="true" />;
}
}
function miniIcon(status: StepStatus) {
switch (status) {
case "running":
return <Icon name="Loader2" size={12} strokeWidth={2} className="animate-spin text-amber-400" aria-hidden="true" />;
case "completed":
return <Icon name="CheckCircle2" size={12} strokeWidth={2} className="text-emerald-400" aria-hidden="true" />;
case "error":
return <Icon name="XCircle" size={12} strokeWidth={2} className="text-rose-400" aria-hidden="true" />;
default:
return <span className="mt-0.5 h-2 w-2 rounded-full border border-zinc-600 bg-zinc-700" aria-hidden="true" />;
}
}
function tone(status: StepStatus) {
switch (status) {
case "running":
return "border-amber-500/20 bg-amber-500/10 text-amber-300";
case "completed":
return "border-emerald-500/20 bg-emerald-500/10 text-emerald-300";
case "error":
return "border-rose-500/20 bg-rose-500/10 text-rose-300";
default:
return "border-zinc-800 bg-zinc-900 text-zinc-400";
}
}
export default function ThinkingCard({ title, overview, steps, currentStep, isComplete }: ThinkingCardProps) {
const completedCount = steps.filter((step) => step.status === "completed").length;
const progress = isComplete || steps.length === 0 ? 100 : Math.round((completedCount / steps.length) * 100);
const activeLabel = isComplete ? "Plano de Execução" : "Thinking...";
return (
<section className="overflow-hidden rounded-3xl border border-zinc-800/80 bg-zinc-950 text-zinc-100 shadow-[0_24px_80px_-36px_rgba(0,0,0,0.9)]">
<div className="border-b border-zinc-800/80 bg-gradient-to-b from-zinc-900/80 to-zinc-950 px-5 py-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="inline-flex items-center rounded-full border border-zinc-700 bg-zinc-900 px-2.5 py-0.5 text-[11px] font-medium uppercase tracking-[0.2em] text-zinc-300">
{activeLabel}
</span>
<span className="text-xs uppercase tracking-[0.22em] text-zinc-500">Agentic mode</span>
</div>
<h2 className="mt-2 truncate text-lg font-semibold text-zinc-50">{title}</h2>
<p className="mt-1 text-sm leading-6 text-zinc-400">{overview}</p>
</div>
<div className="rounded-2xl border border-zinc-800 bg-zinc-900/80 px-3 py-2 text-right">
<div className="text-[11px] uppercase tracking-[0.18em] text-zinc-500">Progress</div>
<div className="mt-1 text-xl font-semibold tabular-nums text-zinc-50">{progress}%</div>
</div>
</div>
<div className="mt-4 space-y-2">
<div className="h-2 overflow-hidden rounded-full bg-zinc-800">
<div
className="h-full rounded-full bg-gradient-to-r from-zinc-500 via-zinc-300 to-emerald-400 transition-all duration-500 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
<div className="flex items-center justify-between text-[11px] uppercase tracking-[0.18em] text-zinc-500">
<span>{steps.length} steps</span>
<span>{isComplete ? "Completed" : `Step ${currentStep}`}</span>
</div>
</div>
</div>
<div className="p-4 sm:p-5">
<ul className="space-y-3">
{steps.map((step, index) => {
const isActive = step.id === currentStep || step.status === "running";
const isDone = step.status === "completed";
return (
<li
key={step.id}
className={`relative rounded-2xl border px-4 py-4 transition-all duration-300 ${
isActive ? "border-zinc-700 bg-zinc-900/80 shadow-[0_0_0_1px_rgba(63,63,70,0.35)]" : "border-zinc-800 bg-zinc-950/70"
} ${isDone ? "opacity-95" : ""}`}
aria-current={isActive ? "step" : undefined}
>
{index !== steps.length - 1 ? <div className="absolute left-[26px] top-12 bottom-0 w-px bg-zinc-800" aria-hidden="true" /> : null}
<div className="flex gap-3">
<div className={`mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-full border ${tone(step.status)}`}>
{statusIcon(step.status)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="rounded-md border border-zinc-800 bg-zinc-900 px-2 py-0.5 text-xs font-medium text-zinc-300">
Step {step.id}
</span>
<h3 className="truncate text-sm font-semibold text-zinc-100">{step.title}</h3>
</div>
<p className="mt-1 text-sm leading-6 text-zinc-400">{step.description}</p>
</div>
<span className={`shrink-0 rounded-full border px-2.5 py-0.5 text-[11px] font-medium uppercase tracking-[0.18em] ${tone(step.status)}`}>
{step.status}
</span>
</div>
{step.sub_steps.length > 0 ? (
<div className="mt-4 space-y-2 border-l border-zinc-800 pl-4">
{step.sub_steps.map((subStep) => (
<div key={subStep.id} className="flex items-start gap-2 rounded-xl border border-zinc-900 bg-zinc-950/60 px-3 py-2">
<div className="mt-0.5">{miniIcon(subStep.status)}</div>
<div className="min-w-0 flex-1">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-zinc-500">{subStep.id}</div>
<div className="text-sm leading-6 text-zinc-200">{subStep.description}</div>
</div>
<span className={`rounded-full border px-2 py-0.5 text-[11px] font-medium uppercase tracking-[0.16em] ${tone(subStep.status)}`}>
{subStep.status}
</span>
</div>
))}
</div>
) : null}
</div>
</div>
</li>
);
})}
</ul>
</div>
</section>
);
}
+4
View File
@@ -0,0 +1,4 @@
export { default as MessageBubble } from './MessageBubble';
export { default as ThinkingCard } from './ThinkingCard';
export { default as TaskList } from './TaskList';
export { default as ArtifactViewer } from './ArtifactViewer';
@@ -0,0 +1,26 @@
import { Info, AlertTriangle, CheckCircle2, XCircle } from "lucide-react";
type UpdateTone = "info" | "success" | "warning" | "error";
interface AgentUpdateProps {
content: string;
tone?: UpdateTone;
}
const toneMap: Record<UpdateTone, typeof Info> = {
info: Info,
success: CheckCircle2,
warning: AlertTriangle,
error: XCircle,
};
export default function AgentUpdate({ content, tone = "info" }: AgentUpdateProps) {
const IconComp = toneMap[tone];
return (
<div className={`agentic-update agentic-update--${tone}`}>
<IconComp className="agentic-update__icon" size={12} aria-hidden="true" />
<span>{content}</span>
</div>
);
}
@@ -0,0 +1,76 @@
import type { AgentPlan, AgentPlanState } from "../../types/forgeagent";
import { resolveForgeAgentFinalResponse } from "../../lib/forgeagent-state";
import StepCard from "./StepCard";
import AgentUpdate from "./AgentUpdate";
import FinalResponse from "./FinalResponse";
import ArtifactCard from "./ArtifactCard";
import SpecialistCard from "./SpecialistCard";
interface AgenticMessageProps {
plan: AgentPlan;
state: AgentPlanState;
isExecuting?: boolean;
modelTier?: "Lite" | "Iron";
}
export default function AgenticMessage({ plan, state, isExecuting, modelTier = "Lite" }: AgenticMessageProps) {
const currentStep = state?.current_step ?? 0;
const isComplete = state?.status === "completed";
const systemMessages = state?.system_messages ?? [];
const specialists = state?.specialists ?? [];
const finalResponse = resolveForgeAgentFinalResponse(state);
return (
<div className="flex max-w-[90%] flex-col gap-3 pb-3">
{/* Thinking / Plan Card */}
<StepCard
title={plan.title}
overview={plan.overview}
steps={plan.steps}
currentStep={currentStep}
isComplete={isComplete}
modelTier={modelTier}
/>
{/* Intermediate Updates */}
{systemMessages.length > 0 && (
<div className="flex flex-col gap-1">
{systemMessages.map((msg) => (
<AgentUpdate
key={msg.id}
content={msg.content}
tone={msg.tone}
/>
))}
</div>
)}
{/* Specialist Cards */}
{specialists.length > 0 && (
<div className="flex flex-col gap-2">
{specialists.map((spec, i) => (
<SpecialistCard
key={spec.id}
specialist={spec}
isLast={i === specialists.length - 1}
/>
))}
</div>
)}
{/* Final Response */}
{finalResponse ? (
<FinalResponse content={finalResponse} />
) : null}
{/* Artifact Download */}
{state?.artifact ? (
<ArtifactCard
filename={state.artifact.filename}
content={state.artifact.content}
language={state.artifact.language}
/>
) : null}
</div>
);
}
@@ -0,0 +1,51 @@
interface AgenticToggleProps {
isActive: boolean;
onToggle: () => void;
disabled?: boolean;
}
export default function AgenticToggle({ isActive, onToggle, disabled = false }: AgenticToggleProps) {
const label = isActive ? "Modo agentic ativo" : "Modo agentic desativado";
return (
<button
type="button"
onClick={onToggle}
disabled={disabled}
aria-pressed={isActive}
aria-label={label}
title="Alterna o modo agentic: o assistente pode planejar e executar tarefas com mais autonomia."
className={`group relative inline-flex min-h-9 items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium tracking-tight shadow-sm transition-all duration-200 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/60 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950 ${
disabled
? "cursor-not-allowed border-slate-800 bg-slate-900/60 text-slate-500 opacity-60"
: isActive
? "border-cyan-400/40 bg-cyan-500/15 text-cyan-200 hover:border-cyan-300/60 hover:bg-cyan-500/20 hover:text-cyan-100"
: "border-slate-700 bg-slate-900/80 text-slate-300 hover:border-slate-500 hover:bg-slate-800 hover:text-slate-100"
}`}
>
<span
aria-hidden="true"
className={`h-2.5 w-2.5 rounded-full transition-all duration-200 ${
isActive ? "bg-cyan-300 shadow-[0_0_12px_rgba(103,232,249,0.85)]" : "bg-slate-500"
}`}
/>
<span className="whitespace-nowrap">🛠️ Modo Agentic</span>
<span
className={`rounded-full border px-1.5 py-0.5 text-[10px] uppercase tracking-[0.18em] transition-colors duration-200 ${
isActive
? "border-cyan-400/30 bg-cyan-400/10 text-cyan-200"
: "border-slate-700 bg-slate-800/80 text-slate-400"
}`}
>
{isActive ? "On" : "Off"}
</span>
<span
role="tooltip"
className="pointer-events-none absolute bottom-full left-1/2 z-20 mb-2 w-56 -translate-x-1/2 rounded-lg border border-slate-700 bg-slate-950 px-2.5 py-2 text-[11px] leading-snug text-slate-200 opacity-0 shadow-lg transition-all duration-200 group-hover:opacity-100 group-hover:translate-y-0 group-focus-visible:opacity-100"
>
Habilita um modo mais autônomo para tarefas complexas, planejamento e execução assistida.
</span>
</button>
);
}
@@ -0,0 +1,68 @@
import { Download, FileText, FileCode, FileJson, File } from "lucide-react";
interface ArtifactCardProps {
filename: string;
content: string;
language?: string;
onDownload?: () => void;
}
function getExt(filename: string): string {
const parts = filename.trim().toLowerCase().split(".");
return parts.length > 1 ? (parts[parts.length - 1] ?? "") : "";
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function FileIcon({ ext }: { ext: string }) {
const IconComp =
ext === "md" ? FileText
: ["ts", "tsx", "js", "jsx", "py", "rs"].includes(ext) ? FileCode
: ext === "json" ? FileJson
: File;
return <IconComp size={16} className="text-zinc-400" />;
}
export default function ArtifactCard({ filename, content, language, onDownload }: ArtifactCardProps) {
const ext = getExt(filename);
const size = formatSize(new Blob([content]).size);
const handleDownload = () => {
if (onDownload) { onDownload(); return; }
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="agentic-artifact">
<span className="agentic-artifact__icon">
<FileIcon ext={ext} />
</span>
<div className="agentic-artifact__info">
<p className="agentic-artifact__name">{filename}</p>
<p className="agentic-artifact__meta">
{size}{language ? ` · ${language}` : ""}
</p>
</div>
<button
type="button"
onClick={handleDownload}
className="agentic-artifact__btn"
aria-label={`Download ${filename}`}
>
<Download size={11} />
Download
</button>
</div>
);
}
@@ -0,0 +1,45 @@
import { useEffect, useMemo, useRef } from "react";
import { renderMarkdown, ensureHljs } from "../../lib/markdown";
interface FinalResponseProps {
content: string;
}
export default function FinalResponse({ content }: FinalResponseProps) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
void ensureHljs();
}, []);
const html = useMemo(() => renderMarkdown(content), [content]);
useEffect(() => {
const root = containerRef.current;
if (!root) return;
const buttons = root.querySelectorAll<HTMLButtonElement>(".copy-code-btn");
buttons.forEach((btn) => {
btn.onclick = () => {
const code = btn.getAttribute("data-code") || "";
navigator.clipboard.writeText(code).then(() => {
const original = btn.textContent || "Copy";
btn.textContent = "Copied!";
btn.style.color = "var(--success, #22c55e)";
setTimeout(() => {
btn.textContent = original;
btn.style.color = "";
}, 1500);
});
};
});
}, [html]);
return (
<div
ref={containerRef}
className="manus-markdown text-[16px] leading-[1.5] text-[var(--text-primary,#e4e4e7)]"
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
@@ -0,0 +1,90 @@
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { ChevronDown, Loader2, CheckCircle2 } from "lucide-react";
import type { SpecialistState } from "../../types/forgeagent";
interface SpecialistCardProps {
specialist: SpecialistState;
isLast?: boolean;
}
export default function SpecialistCard({ specialist, isLast = false }: SpecialistCardProps) {
const [thinkingOpen, setThinkingOpen] = useState(false);
const hasThinking = specialist.thinking.trim().length > 0;
const hasResponse = specialist.response.trim().length > 0;
const isThinking = specialist.status === "thinking";
return (
<div className="specialist-card">
{/* Header row */}
<div className="specialist-card__header">
<div className="specialist-card__identity">
<span className="specialist-card__dot" aria-hidden="true" />
<span className="specialist-card__name">{specialist.name}</span>
<span className="specialist-card__title">{specialist.title}</span>
</div>
<div className="specialist-card__status">
{isThinking ? (
<span className="specialist-card__badge specialist-card__badge--thinking">
<Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" />
Pensando
</span>
) : (
<span className="specialist-card__badge specialist-card__badge--done">
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
Respondido
</span>
)}
</div>
</div>
{/* Thinking chain — always visible if present, toggleable */}
{hasThinking && (
<div className="specialist-card__thinking-section">
<button
type="button"
onClick={() => setThinkingOpen((v) => !v)}
className="specialist-card__thinking-toggle"
aria-expanded={thinkingOpen}
>
<ChevronDown
className={`specialist-card__chevron ${thinkingOpen ? "rotated" : ""}`}
aria-hidden="true"
/>
<span>Cadeia de raciocínio</span>
</button>
<AnimatePresence initial={false}>
{thinkingOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.18, ease: "easeOut" }}
className="specialist-card__thinking-body overflow-hidden"
>
<p className="specialist-card__thinking-text">{specialist.thinking}</p>
</motion.div>
)}
</AnimatePresence>
{/* Always show thinking preview when collapsed */}
{!thinkingOpen && (
<p className="specialist-card__thinking-preview">
{specialist.thinking.slice(0, 120)}
{specialist.thinking.length > 120 ? "…" : ""}
</p>
)}
</div>
)}
{/* Response */}
{hasResponse && (
<div className="specialist-card__response">
<p className="specialist-card__response-text">{specialist.response}</p>
</div>
)}
{/* Divider to next specialist */}
{!isLast && <div className="specialist-card__divider" aria-hidden="true" />}
</div>
);
}
@@ -0,0 +1,136 @@
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Check, ChevronDown, Circle, Loader2, X } from "lucide-react";
type StepStatus = "pending" | "running" | "completed" | "error";
export interface StepCardProps {
title: string;
overview: string;
steps: Array<{
id: number;
title: string;
description: string;
status: StepStatus;
sub_steps: Array<{
id: string;
description: string;
status: StepStatus;
}>;
}>;
currentStep: number;
isComplete: boolean;
modelTier?: "Lite" | "Iron";
}
function StatusDot({ status }: { status: StepStatus }) {
switch (status) {
case "running":
return (
<span className="agentic-dot agentic-dot--running" aria-hidden="true">
<Loader2 className="h-2.5 w-2.5 animate-spin" />
</span>
);
case "completed":
return (
<span className="agentic-dot agentic-dot--done" aria-hidden="true">
<Check className="h-2.5 w-2.5" />
</span>
);
case "error":
return (
<span className="agentic-dot agentic-dot--error" aria-hidden="true">
<X className="h-2.5 w-2.5" />
</span>
);
default:
return (
<span className="agentic-dot agentic-dot--idle" aria-hidden="true">
<Circle className="h-2 w-2" />
</span>
);
}
}
export default function StepCard({
title,
overview,
steps,
currentStep,
isComplete,
modelTier = "Lite",
}: StepCardProps) {
const [open, setOpen] = useState(true);
const completedCount = steps.filter((s) => s.status === "completed").length;
const progress = isComplete ? 100 : steps.length > 0 ? Math.round((completedCount / steps.length) * 100) : 0;
return (
<div className="agentic-plan">
{/* Header */}
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="agentic-plan__header"
aria-expanded={open}
>
<span className="agentic-plan__dot" aria-hidden="true" />
<span className="agentic-plan__title">{title}</span>
<span className="agentic-plan__badge">{modelTier}</span>
<span className="agentic-plan__counter">
{isComplete ? `${steps.length}/${steps.length}` : `${completedCount}/${steps.length}`}
</span>
<ChevronDown className={`agentic-plan__chevron ${open ? "rotated" : ""}`} aria-hidden="true" />
</button>
{/* Body */}
<AnimatePresence initial={false}>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.18, ease: "easeOut" }}
className="agentic-plan__body overflow-hidden"
>
{overview && <p className="agentic-plan__overview">{overview}</p>}
<ul className="agentic-steps">
{steps.map((step) => {
const isActive = step.id === currentStep || step.status === "running";
return (
<li key={step.id} className="agentic-step">
<StatusDot status={step.status} />
<div className="agentic-step__content">
<p className="agentic-step__title">{step.title}</p>
{step.description && (
<p className="agentic-step__desc">{step.description}</p>
)}
{step.sub_steps.length > 0 && (
<ul className="agentic-substeps">
{step.sub_steps.map((sub) => (
<li key={sub.id} className="agentic-substep">
<StatusDot status={sub.status} />
<span>{sub.description}</span>
</li>
))}
</ul>
)}
</div>
</li>
);
})}
</ul>
{/* Minimal progress line */}
<div className="agentic-plan__prog">
<div className="agentic-plan__prog-bar">
<div className="agentic-plan__prog-fill" style={{ width: `${progress}%` }} />
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
@@ -0,0 +1,7 @@
export { default as AgenticMessage } from './AgenticMessage';
export { default as StepCard } from './StepCard';
export { default as AgentUpdate } from './AgentUpdate';
export { default as FinalResponse } from './FinalResponse';
export { default as ArtifactCard } from './ArtifactCard';
export { default as SpecialistCard } from './SpecialistCard';
export { default as AgenticToggle } from './AgenticToggle';
@@ -0,0 +1,64 @@
/* @vitest-environment jsdom */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { swapModel } from "./api";
// ─── Mocks ───────────────────────────────────────────────────────────────────
const mockInvoke = vi.fn();
vi.mock("@tauri-apps/api/core", () => ({
invoke: (...args: unknown[]) => mockInvoke(...args),
}));
// ─── Tests ───────────────────────────────────────────────────────────────────
describe("swapModel (hot-swap API)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("calls swap_model_command with the new model path", async () => {
mockInvoke.mockResolvedValue({
status: "ready",
ready: true,
process_running: true,
resolved_url: "http://127.0.0.1:1234",
binary_path: null,
last_error: null,
active_command: "idle",
last_command_result: { command: "idle", outcome: "succeeded", message: "" },
config: {
connection_mode: "local",
start_policy: "manual",
server_url: "http://127.0.0.1:1234",
api_key: "",
model_path: "/models/new-model.gguf",
models_dir: "/models",
addr: "127.0.0.1:1234",
gpu_layers: 0,
ctx_size: 4096,
extra_model_paths: [],
selected_model_id: null,
remote_presets: [],
hugging_face_token: "",
},
});
await swapModel("/models/new-model.gguf");
expect(mockInvoke).toHaveBeenCalledWith("swap_model_command", {
newModelPath: "/models/new-model.gguf",
});
});
it("throws when swap_model_command fails", async () => {
mockInvoke.mockRejectedValue(new Error("swap failed"));
await expect(swapModel("/models/bad.gguf")).rejects.toThrow("swap failed");
});
});
@@ -48,12 +48,13 @@ export default function RuntimeStatusCard({
}: Props) {
const tone = getRuntimeStatusTone(snapshot);
const isLocal = snapshot.config.connection_mode === "local";
const isLoading = isLocal && snapshot.status === "loading";
const startGuard = getRuntimeStartGuard(snapshot);
const needsSetup = isLocal && !snapshot.process_running && !startGuard.canStart;
const primaryAction = needsSetup ? onOpenSettings : isLocal && !snapshot.process_running ? onStart : onStop;
const lifecycleLabel = getLifecycleActionLabel(activeLifecycleCommand);
const primaryLabel = lifecycleLabel ?? (needsSetup ? "Set up" : isLocal && !snapshot.process_running ? "Start" : "Stop");
const primaryDisabled = busy || lifecycleBusy || (isLocal && snapshot.process_running && snapshot.status === "stopping");
const primaryLabel = isLoading ? "Model loading..." : lifecycleLabel ?? (needsSetup ? "Set up" : isLocal && !snapshot.process_running ? "Start" : "Stop");
const primaryDisabled = busy || lifecycleBusy || isLoading || (isLocal && snapshot.process_running && snapshot.status === "stopping");
const showLifecycleResult = lastLifecycleResult.command !== "idle" && !lifecycleBusy;
const lifecycleResultClass =
lastLifecycleResult.outcome === "failed"
@@ -201,9 +201,9 @@ export default function RuntimeWorkspace({ runtime: externalRuntime }: Props) {
className="runtime-workspace-btn runtime-workspace-btn-primary"
onClick={snapshot.process_running ? (stopLocal ?? (() => {})) : (startLocal ?? (() => {}))}
type="button"
disabled={busy || lifecycleBusy || (snapshot.process_running && snapshot.status === "stopping")}
disabled={busy || lifecycleBusy || snapshot.status === "loading" || (snapshot.process_running && snapshot.status === "stopping")}
>
{lifecycleLabel ?? (snapshot.process_running ? "Stop" : "Start")}
{snapshot.status === "loading" ? "Model loading..." : lifecycleLabel ?? (snapshot.process_running ? "Stop" : "Start")}
</button>
</>
)}
@@ -110,6 +110,10 @@ describe("getRuntimeStateLabel", () => {
expect(getRuntimeStateLabel(createSnapshot({ status: "starting" }))).toBe("Starting local API");
});
it("reports model loading state", () => {
expect(getRuntimeStateLabel(createSnapshot({ status: "loading" }))).toBe("Model loading...");
});
it("reports remote mode without runtime ownership", () => {
expect(getRuntimeStateLabel(createSnapshot({
status: "stopped",
@@ -133,6 +137,10 @@ describe("getRuntimeStatusTone", () => {
it("marks local not-ready snapshots as danger", () => {
expect(getRuntimeStatusTone(createSnapshot({ status: "ready", ready: false, process_running: true }))).toBe("danger");
});
it("treats model loading as a warning", () => {
expect(getRuntimeStatusTone(createSnapshot({ status: "loading" }))).toBe("warning");
});
});
describe("getRuntimeStartGuard", () => {
@@ -187,6 +187,8 @@ export function getRuntimeStateLabel(snapshot: RuntimeSnapshot): string {
switch (snapshot.status) {
case "ready":
return "Ready";
case "loading":
return "Model loading...";
case "starting":
return "Starting local API";
case "stopping":
@@ -212,6 +214,7 @@ export function getRuntimeStatusTone(snapshot: RuntimeSnapshot): "neutral" | "su
switch (snapshot.status) {
case "ready":
return "success";
case "loading":
case "starting":
case "stopping":
case "degraded":
@@ -368,6 +371,8 @@ function deriveHealthHeadline(snapshot: RuntimeSnapshot): string {
switch (snapshot.status) {
case "ready":
return "Local API ready";
case "loading":
return "Model loading...";
case "starting":
return "Starting local API...";
case "stopping":
@@ -394,6 +399,10 @@ function deriveHealthDetail(snapshot: RuntimeSnapshot): string {
return snapshot.resolved_url || "http://127.0.0.1:8080";
}
if (snapshot.status === "loading") {
return "The local model is warming up. Please wait before sending a new message.";
}
if (snapshot.status === "stopped") {
return "Start the local API to begin";
}
+1 -1
View File
@@ -1,6 +1,6 @@
export type ConnectionMode = "local" | "remote";
export type StartPolicy = "manual" | "on_app_launch";
export type RuntimeStatus = "stopped" | "starting" | "ready" | "degraded" | "stopping" | "error";
export type RuntimeStatus = "stopped" | "starting" | "loading" | "ready" | "degraded" | "stopping" | "error";
export type RuntimeLifecycleCommand = "idle" | "save" | "start" | "stop" | "save_and_start";
export type RuntimeLifecycleOutcome = "succeeded" | "blocked" | "failed";
export type RuntimeApplyImpact = "immediate" | "restart_required" | "save_before_apply";
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { bootstrapRuntime, getRuntimeSnapshot, saveRuntimeConfig, scanRuntimeModels, startRuntime, stopRuntime } from "./api";
import {
createDefaultRuntimeSnapshot,
@@ -175,6 +175,9 @@ export function useRuntimeManager() {
if (active) {
setDraftState((current) => reconcileDraftState(current, next));
setLoading(false);
// Auto-scan models on startup so the sidebar shows all available models
const modelsDir = next.config.models_dir ?? "";
scanModelsRef.current?.(modelsDir).catch(() => undefined);
}
})
.catch(() => {
@@ -317,16 +320,11 @@ export function useRuntimeManager() {
[],
);
const scanModelsRef = useRef<(dir: string | null) => Promise<void>>();
// Phase 4: Model scanning for validation
const scanModels = useCallback(async (dirPath: string | null) => {
if (!dirPath) {
setDraftState((current) => ({
...current,
scannedModels: [],
isScanning: false,
}));
return;
}
const resolvedDir = dirPath ?? "";
// Set scanning state first
setDraftState((current) => ({
@@ -335,7 +333,7 @@ export function useRuntimeManager() {
}));
try {
const models = await scanRuntimeModels(dirPath);
const models = await scanRuntimeModels(resolvedDir);
// Now update with results
setDraftState((current) => {
@@ -380,6 +378,8 @@ export function useRuntimeManager() {
}
}, []);
scanModelsRef.current = scanModels;
const commitSavedSnapshot = useCallback((next: RuntimeSnapshot, keepOpen: boolean) => {
setDraftState((current) => ({
...current,
+109
View File
@@ -0,0 +1,109 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core";
import { applyForgeAgentEvent, createForgeAgentState } from "../lib/forgeagent-state";
import type {
AgentPlan,
AgentPlanState,
ArtifactReadyEvent,
PlanCompletedEvent,
SpecialistResponseCompletedEvent,
SpecialistThinkingStartedEvent,
SpecialistThinkingUpdateEvent,
StepCompletedEvent,
StepErrorEvent,
StepStartedEvent,
} from "../types/forgeagent";
interface StartForgeAgentResponse {
plan_id: string;
plan: AgentPlan;
}
export function useForgeAgentSession() {
const [state, setState] = useState<AgentPlanState | null>(null);
const stateRef = useRef<AgentPlanState | null>(null);
const updateState = useCallback((next: AgentPlanState | null | ((current: AgentPlanState | null) => AgentPlanState | null)) => {
setState((current) => {
const resolved = typeof next === "function"
? (next as (current: AgentPlanState | null) => AgentPlanState | null)(current)
: next;
stateRef.current = resolved;
return resolved;
});
}, []);
useEffect(() => {
if (!("__TAURI_INTERNALS__" in window)) {
return;
}
let disposed = false;
const attach = async () => {
const unlisten = await Promise.all([
listen<StepStartedEvent>("forgeagent:step-started", (event) => {
if (disposed) return;
updateState((current) => (current ? applyForgeAgentEvent(current, { type: "step-started", payload: event.payload }) : current));
}),
listen<StepCompletedEvent>("forgeagent:step-completed", (event) => {
if (disposed) return;
updateState((current) => (current ? applyForgeAgentEvent(current, { type: "step-completed", payload: event.payload }) : current));
}),
listen<StepErrorEvent>("forgeagent:step-error", (event) => {
if (disposed) return;
updateState((current) => (current ? applyForgeAgentEvent(current, { type: "step-error", payload: event.payload }) : current));
}),
listen<ArtifactReadyEvent>("forgeagent:artifact-ready", (event) => {
if (disposed) return;
updateState((current) => (current ? applyForgeAgentEvent(current, { type: "artifact-ready", payload: event.payload }) : current));
}),
listen<PlanCompletedEvent>("forgeagent:plan-completed", (event) => {
if (disposed) return;
updateState((current) => (current ? applyForgeAgentEvent(current, { type: "plan-completed", payload: event.payload }) : current));
}),
listen<SpecialistThinkingStartedEvent>("forgeagent:specialist-thinking-started", (event) => {
if (disposed) return;
updateState((current) => (current ? applyForgeAgentEvent(current, { type: "specialist-thinking-started", payload: event.payload }) : current));
}),
listen<SpecialistThinkingUpdateEvent>("forgeagent:specialist-thinking-update", (event) => {
if (disposed) return;
updateState((current) => (current ? applyForgeAgentEvent(current, { type: "specialist-thinking-update", payload: event.payload }) : current));
}),
listen<SpecialistResponseCompletedEvent>("forgeagent:specialist-response-completed", (event) => {
if (disposed) return;
updateState((current) => (current ? applyForgeAgentEvent(current, { type: "specialist-response-completed", payload: event.payload }) : current));
}),
]);
return () => {
unlisten.forEach((fn) => fn());
};
};
let cleanup: (() => void) | undefined;
void attach().then((fn) => {
cleanup = fn;
});
return () => {
disposed = true;
cleanup?.();
};
}, [updateState]);
const start = useCallback(async (prompt: string) => {
const response = await invoke<StartForgeAgentResponse>("start_forgeagent_session_command", { prompt });
const initial = createForgeAgentState(response.plan_id, response.plan);
updateState(initial);
return initial;
}, [updateState]);
const reset = useCallback(() => updateState(null), [updateState]);
const getSnapshot = useCallback(() => stateRef.current, []);
return { state, start, reset, getSnapshot };
}
+209
View File
@@ -0,0 +1,209 @@
/**
* useMemoryExtractor
*
* Runs a small background LLM call after each completed assistant turn to
* extract structured facts about the user. Facts are stored in `user_memories`
* and injected as a system prompt on every chat request.
*
* Design constraints:
* - Fire-and-forget: never blocks the main chat flow
* - Idempotent: each message is processed at most once (memory_processed flag)
* - Graceful degradation: if the memory model is unavailable, the chat works normally
* - Cheap: sends only the last N user messages and extracts compact key/value facts
*/
import { useCallback, useRef } from "react";
import type { DB } from "../lib/db";
import type { UserMemory } from "../lib/types";
// How many recent user messages to include when extracting memories
const EXTRACTION_WINDOW = 6;
// Minimum interval between extraction runs (ms) — avoids hammering on rapid sends
const EXTRACTION_DEBOUNCE_MS = 4000;
const EXTRACTION_SYSTEM_PROMPT = `You are a memory extraction assistant. Your job is to extract factual, durable information about the user from a conversation fragment.
Rules:
- Extract ONLY stable, reusable facts (name, occupation, location, preferences, projects, skills, goals, constraints)
- Ignore ephemeral requests ("what time is it?") or single-use context
- Output ONLY valid JSON — an array of objects with fields: key (snake_case, unique identifier), value (concise string), confidence (0.0–1.0)
- If nothing worth remembering is found, output an empty array: []
- Keep each value under 120 characters
- Prefer updating an existing key over creating a near-duplicate
Example output:
[
{"key": "user_name", "value": "Ana", "confidence": 0.95},
{"key": "preferred_language", "value": "Python", "confidence": 0.9},
{"key": "current_project", "value": "Building a local LLM chat app with Tauri", "confidence": 0.85}
]`;
export interface MemoryExtractorOptions {
db: DB;
server: string;
apiKey: string;
/** Model to use for extraction. Falls back gracefully if empty. */
memoryModel: string;
enabled: boolean;
}
export function useMemoryExtractor({
db,
server,
apiKey,
memoryModel,
enabled,
}: MemoryExtractorOptions) {
const lastRunRef = useRef<number>(0);
const runningRef = useRef(false);
/**
* Extracts memories from unprocessed messages in a given chat.
* Called fire-and-forget after an assistant turn completes.
*/
const extractMemories = useCallback(
async (chatId: number) => {
if (!enabled || !server || !memoryModel || runningRef.current) return;
const now = Date.now();
if (now - lastRunRef.current < EXTRACTION_DEBOUNCE_MS) return;
lastRunRef.current = now;
// Check if there are unprocessed messages
const unprocessed = db.get<{ c: number }>(
"SELECT COUNT(*) as c FROM messages WHERE chat_id = ? AND memory_processed = 0 AND role = 'user'",
[chatId],
);
if (!unprocessed || unprocessed.c === 0) return;
runningRef.current = true;
try {
// Get the last N user messages from this chat
const recentMessages = db.query<{ role: string; content: string }>(
`SELECT role, content FROM messages
WHERE chat_id = ? AND role IN ('user','assistant')
AND content != '' AND content NOT LIKE '⚠️%'
ORDER BY created_at DESC LIMIT ?`,
[chatId, EXTRACTION_WINDOW * 2],
).reverse();
if (recentMessages.length < 2) {
// Mark as processed and skip — not enough context
db.run(
"UPDATE messages SET memory_processed = 1 WHERE chat_id = ? AND memory_processed = 0",
[chatId],
);
return;
}
const conversationText = recentMessages
.map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.content.slice(0, 400)}`)
.join("\n\n");
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
const response = await fetch(`${server}/v1/chat/completions`, {
method: "POST",
headers,
body: JSON.stringify({
model: memoryModel,
messages: [
{ role: "system", content: EXTRACTION_SYSTEM_PROMPT },
{
role: "user",
content: `Extract memorable facts from this conversation:\n\n${conversationText}`,
},
],
stream: false,
max_tokens: 512,
temperature: 0.1,
}),
});
if (!response.ok) return;
const data = await response.json();
const raw: string = data.choices?.[0]?.message?.content || "";
// Parse JSON from response (strip any markdown fences)
const jsonMatch = raw.match(/\[[\s\S]*\]/);
if (!jsonMatch) return;
const facts: Array<{ key: string; value: string; confidence: number }> =
JSON.parse(jsonMatch[0]);
if (!Array.isArray(facts)) return;
const ts = Math.floor(Date.now() / 1000);
for (const fact of facts) {
if (!fact.key || !fact.value) continue;
const key = String(fact.key).toLowerCase().replace(/[^a-z0-9_]/g, "_").slice(0, 64);
const value = String(fact.value).slice(0, 240);
const confidence = Math.max(0, Math.min(1, Number(fact.confidence) || 0.8));
// Upsert: update if key exists and new confidence is higher, insert otherwise
const existing = db.get<UserMemory>(
"SELECT * FROM user_memories WHERE key = ?",
[key],
);
if (existing) {
if (confidence >= existing.confidence - 0.05) {
db.run(
"UPDATE user_memories SET value = ?, confidence = ?, updated_at = ? WHERE key = ?",
[value, confidence, ts, key],
);
}
} else {
db.run(
"INSERT INTO user_memories (key, value, confidence, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
[key, value, confidence, ts, ts],
);
}
}
// Mark all unprocessed user messages in this chat as done
db.run(
"UPDATE messages SET memory_processed = 1 WHERE chat_id = ? AND memory_processed = 0",
[chatId],
);
} catch {
// Silently fail — memory extraction is non-critical
} finally {
runningRef.current = false;
}
},
[db, server, apiKey, memoryModel, enabled],
);
/**
* Reads all stored memories and formats them as a system prompt string.
* Returns empty string if no memories exist.
*/
const buildMemorySystemPrompt = useCallback((): string => {
const memories = db.query<UserMemory>(
"SELECT key, value, confidence FROM user_memories ORDER BY updated_at DESC LIMIT 40",
);
if (memories.length === 0) return "";
const lines = memories
.filter((m) => m.confidence >= 0.6)
.map((m) => `- ${m.key.replace(/_/g, " ")}: ${m.value}`);
if (lines.length === 0) return "";
return [
"You are a helpful assistant with memory of this user.",
"Known facts about the user:",
...lines,
"",
"Use this context naturally — don't repeat it verbatim, just let it inform your responses.",
].join("\n");
}, [db]);
return { extractMemories, buildMemorySystemPrompt };
}
+33 -3
View File
@@ -1,11 +1,24 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import type { ModelInfo, ServerStatus } from "../lib/types";
import { getNextSelectedModel } from "./model-selection";
import { resolveAlias } from "../lib/modelAlias";
// Tempo de proteção após seleção manual (5 segundos)
const MANUAL_SELECTION_LOCK_MS = 5000;
export function useModels(server: string, apiKey: string, enabled = true) {
const [models, setModels] = useState<ModelInfo[]>([]);
const [selectedModel, setSelectedModel] = useState<string>("");
const [selectedModel, setSelectedModelState] = useState<string>("");
const [status, setStatus] = useState<ServerStatus>("loading");
// Ref para rastrear quando o usuário selecionou manualmente um modelo
const lastManualSelectionRef = useRef<number>(0);
// Wrapper para setSelectedModel que registra o timestamp
const setSelectedModel = useCallback((value: string | ((prev: string) => string)) => {
lastManualSelectionRef.current = Date.now();
setSelectedModelState(value);
}, []);
const refresh = useCallback(async () => {
if (!enabled || !server.trim()) {
@@ -25,7 +38,24 @@ export function useModels(server: string, apiKey: string, enabled = true) {
const data = await res.json();
const list: ModelInfo[] = data.data || [];
setModels(list);
setSelectedModel((current) => getNextSelectedModel(current, list));
// Protege contra sobrescrita de seleção manual recente
const timeSinceManualSelection = Date.now() - lastManualSelectionRef.current;
const isLockActive = timeSinceManualSelection < MANUAL_SELECTION_LOCK_MS;
setSelectedModelState((current) => {
// Se há um lock ativo e o modelo atual existe na nova lista, mantém ele
if (isLockActive && current && list.some((m) => m.id === current)) {
return current;
}
// Prefer Iron (2B) first, then Copper (0.8B), then fallback to normal selection
const iron = list.find((m) => resolveAlias(m.id) === 'Iron');
if (iron) return iron.id;
const copper = list.find((m) => resolveAlias(m.id) === 'Copper');
if (copper) return copper.id;
return getNextSelectedModel(current, list);
});
setStatus("ok");
} catch {
setModels([]);
@@ -0,0 +1,85 @@
import { useCallback, useRef, useState } from "react";
import type { RuntimeModelFile } from "../features/runtime/types";
import { resolveAlias } from "../lib/modelAlias";
import { useToast } from "../components/Toast";
interface Options {
preferredModel: string;
onSwitchToCooper: (cooperPath: string) => void;
localModels: RuntimeModelFile[];
}
interface SessionModelController {
effectiveModel: string;
overrideSource: 'auto' | 'user';
setUserOverride: (modelPath: string) => void;
clearOverride: () => void;
onDegradationDetected: () => void;
degradationPromptShown: boolean;
}
export function useSessionModelController({
preferredModel,
onSwitchToCooper,
localModels,
}: Options): SessionModelController {
const { showToast } = useToast();
const [effectiveModel, setEffectiveModel] = useState<string>(preferredModel);
const [overrideSource, setOverrideSource] = useState<'auto' | 'user'>('auto');
const [degradationPromptShown, setDegradationPromptShown] = useState(false);
// Keep effectiveModel in sync when preferredModel changes externally (e.g. startup auto-select)
// but only if the user hasn't manually overridden
const prevPreferredRef = useRef(preferredModel);
if (preferredModel !== prevPreferredRef.current) {
prevPreferredRef.current = preferredModel;
if (overrideSource === 'auto') {
setEffectiveModel(preferredModel);
}
}
const setUserOverride = useCallback((modelPath: string) => {
setEffectiveModel(modelPath);
setOverrideSource('user');
}, []);
const clearOverride = useCallback(() => {
setEffectiveModel(preferredModel);
setOverrideSource('auto');
}, [preferredModel]);
const onDegradationDetected = useCallback(() => {
// Don't show if user already chose manually or already shown
if (overrideSource === 'user' || degradationPromptShown) return;
// Find Copper's path from localModels
const copperModel = localModels.find((m) => resolveAlias(m.path) === 'Copper');
if (!copperModel) return; // Copper not available, can't suggest
setDegradationPromptShown(true);
showToast({
id: 'tps-degradation',
variant: 'warning',
message: 'Performance degraded',
detail: 'TPS dropped below 30 for 5s. Switch to Copper (0.8B) for faster responses?',
action: {
label: 'Switch to Copper',
onClick: () => {
setUserOverride(copperModel.path);
onSwitchToCooper(copperModel.path);
},
},
duration: 0, // persistent until dismissed
});
}, [overrideSource, degradationPromptShown, localModels, showToast, setUserOverride, onSwitchToCooper]);
return {
effectiveModel,
overrideSource,
setUserOverride,
clearOverride,
onDegradationDetected,
degradationPromptShown,
};
}
+3 -2
View File
@@ -28,8 +28,9 @@
--radius-md: 10px;
--radius: 10px;
--radius-lg: 14px;
--font-sans: "Inter", "Segoe UI Variable", "SF Pro Display", system-ui, sans-serif;
--font-serif: "Merriweather", Georgia, serif;
--font-sans: "Aptos", "Segoe UI Variable", "SF Pro Display", system-ui, sans-serif;
--font-editorial: "Aptos", "Segoe UI Variable", "SF Pro Text", system-ui, sans-serif;
--font-serif: "Iowan Old Style", "Palatino Linotype", Georgia, serif;
--shadow: 0 2px 12px rgba(0,0,0,0.4);
--shadow-lg: 0 8px 32px rgba(0,0,0,0.6);
font-size: 15px;
+116
View File
@@ -0,0 +1,116 @@
/**
* Agentic Mode Detection
*
* Detects when a user message should trigger agentic/planning mode
*/
// Keywords that trigger agentic mode
export const AGENTIC_KEYWORDS = [
'como fazer',
'como criar',
'como configurar',
'como instalar',
'como usar',
'como implementar',
'guia',
'tutorial',
'passo a passo',
'exemplo completo',
'projeto',
'aprenda',
'ensine',
'explique detalhadamente',
'me mostre',
'crie um',
'faça um',
'monte um',
'desenvolva',
'construa',
'setup',
'configurar',
'instalação',
'getting started',
'quickstart',
'boilerplate',
'template',
'scaffold',
];
// Regex patterns for more complex detection
export const AGENTIC_PATTERNS = [
/\b(como|guia|tutorial|passo a passo|exemplo completo|projeto|aprenda|ensine|mostre)\b/i,
/\b(pode me (mostrar|ensinar|ajudar|explicar))\b/i,
/\b(quero (aprender|criar|fazer|construir|configurar|montar))\b/i,
/\b(me (explique|mostre|ensine) como)\b/i,
/\b(crie|faça|monte|desenvolva|construa) (um|uma)\b/i,
];
/**
* Check if a message should trigger agentic mode
*/
export function shouldUseAgenticMode(message: string): boolean {
const lowerMessage = message.toLowerCase().trim();
// Check keywords
const hasKeyword = AGENTIC_KEYWORDS.some(keyword =>
lowerMessage.includes(keyword.toLowerCase())
);
// Check patterns
const hasPattern = AGENTIC_PATTERNS.some(pattern =>
pattern.test(message)
);
return hasKeyword || hasPattern;
}
/**
* Extract the core intent from a message
* Used for generating plan titles
*/
export function extractIntent(message: string): string {
// Remove common prefixes
let intent = message
.replace(/^(como|me mostre|me ensine|me explique|quero|gostaria de|pode|consegue)\s+/i, '')
.replace(/\?$/g, '')
.trim();
// Capitalize first letter
return intent.charAt(0).toUpperCase() + intent.slice(1);
}
/**
* Count how many agentic indicators are in the message
* Higher score = more likely to need agentic mode
*/
export function getAgenticScore(message: string): number {
const lowerMessage = message.toLowerCase();
let score = 0;
// Keyword matches
AGENTIC_KEYWORDS.forEach(keyword => {
if (lowerMessage.includes(keyword.toLowerCase())) {
score += 1;
}
});
// Pattern matches
AGENTIC_PATTERNS.forEach(pattern => {
if (pattern.test(message)) {
score += 2;
}
});
// Bonus for longer messages (more detailed requests)
if (message.length > 100) score += 1;
if (message.length > 200) score += 1;
return score;
}
/**
* Determine if we should use agentic mode based on score threshold
*/
export function isAgenticRecommended(message: string, threshold: number = 2): boolean {
return getAgenticScore(message) >= threshold;
}
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { parseAssistantResponse } from "./chat";
describe("parseAssistantResponse", () => {
it("removes leaked role transcript after the assistant answer", () => {
const parsed = parseAssistantResponse(
"Resposta final para o usuário.\n\nAssistant: continuação indevida\nUser: trecho antigo\nSystem: instrução antiga",
);
expect(parsed.content).toBe("Resposta final para o usuário.");
expect(parsed.reasoning).toBe("");
});
it("keeps visible content while separating think blocks", () => {
const parsed = parseAssistantResponse(
"<think>análise interna</think>\n\nExplicação útil.\n\nAssistant: lixo vazado",
);
expect(parsed.reasoning).toBe("análise interna");
expect(parsed.content).toBe("Explicação útil.");
});
});
+22 -1
View File
@@ -7,6 +7,16 @@ export interface ParsedAssistantResponse {
const THINK_OPEN = "<think>";
const THINK_CLOSE = "</think>";
const TRANSCRIPT_LEAKAGE_PATTERN = /(?:^|\n)\s*(?:System|User|Assistant):/i;
export const CHAT_TRANSCRIPT_STOP_SEQUENCES = [
"\nSystem:",
"\nUser:",
"\nAssistant:",
"\n\nSystem:",
"\n\nUser:",
"\n\nAssistant:",
];
export function buildChatTitle(text: string): string {
const normalized = text.replace(/\s+/g, " ").trim();
@@ -14,6 +24,16 @@ export function buildChatTitle(text: string): string {
return normalized.slice(0, 50) + (normalized.length > 50 ? "..." : "");
}
function stripTranscriptLeakage(text: string): string {
const normalized = text.replace(/\r\n/g, "\n");
const match = normalized.match(TRANSCRIPT_LEAKAGE_PATTERN);
if (!match || match.index == null) return text;
const prefix = normalized.slice(0, match.index).trimEnd();
if (!prefix) return "";
return prefix;
}
export function parseAssistantResponse(rawContent: string): ParsedAssistantResponse {
let cursor = 0;
let thinkingOpen = false;
@@ -49,10 +69,11 @@ export function parseAssistantResponse(rawContent: string): ParsedAssistantRespo
const normalizedContent = reasoning.trim().length > 0
? content.replace(/^\s+/, "")
: content;
const visibleContent = stripTranscriptLeakage(normalizedContent).trimEnd();
return {
rawContent,
content: normalizedContent.trimEnd(),
content: visibleContent,
reasoning: reasoning.trim(),
thinkingOpen,
};
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { formatChatDebugTranscript } from "./chatDebug";
describe("formatChatDebugTranscript", () => {
it("includes user and assistant details for a full chat export", () => {
const output = formatChatDebugTranscript({
chatId: 7,
messages: [
{ id: 10, chat_id: 7, role: "user", content: "Pergunta", created_at: 1710000000 },
{
id: 11,
chat_id: 7,
role: "assistant",
content: "Resposta",
reasoning_content: "Rascunho interno",
prompt_tokens: 12,
completion_tokens: 34,
total_tokens: 46,
tokens_per_second: 19.4,
created_at: 1710000060,
},
],
});
expect(output).toContain("Chat ID: 7");
expect(output).toContain("[USER] #10");
expect(output).toContain("Pergunta");
expect(output).toContain("[ASSISTANT] #11");
expect(output).toContain("Resposta");
expect(output).toContain("Reasoning:\nRascunho interno");
expect(output).toContain("Usage: prompt=12 completion=34 total=46 tps=19.4");
});
});
+45
View File
@@ -0,0 +1,45 @@
import type { Message } from "./types";
interface FormatChatDebugTranscriptOptions {
chatId: number | null;
messages: Message[];
}
function formatTimestamp(timestamp: number | null | undefined): string {
if (!timestamp) return "unknown";
return new Date(timestamp * 1000).toISOString();
}
export function formatChatDebugTranscript({ chatId, messages }: FormatChatDebugTranscriptOptions): string {
const lines = [
"# Chat Debug Export",
`Chat ID: ${chatId ?? "new-chat"}`,
`Message count: ${messages.length}`,
"",
];
for (const message of messages) {
lines.push(`[${message.role.toUpperCase()}] #${message.id}`);
lines.push(`Created: ${formatTimestamp(message.created_at)}`);
lines.push(`Content:\n${message.content || "(empty)"}`);
if (message.reasoning_content?.trim()) {
lines.push(`Reasoning:\n${message.reasoning_content}`);
}
if (
message.prompt_tokens != null ||
message.completion_tokens != null ||
message.total_tokens != null ||
message.tokens_per_second != null
) {
lines.push(
`Usage: prompt=${message.prompt_tokens ?? 0} completion=${message.completion_tokens ?? 0} total=${message.total_tokens ?? 0} tps=${message.tokens_per_second ?? "n/a"}`,
);
}
lines.push("");
}
return lines.join("\n").trimEnd();
}
+19
View File
@@ -188,11 +188,30 @@ export async function initDB(): Promise<DB> {
db.run(`CREATE INDEX IF NOT EXISTS idx_msg_chat ON messages(chat_id, created_at)`);
ensureColumn(db, "messages", "reasoning_content", "reasoning_content TEXT");
ensureColumn(db, "messages", "display_mode", "display_mode TEXT");
ensureColumn(db, "messages", "forgeagent_state", "forgeagent_state TEXT");
ensureColumn(db, "messages", "prompt_tokens", "prompt_tokens INTEGER NOT NULL DEFAULT 0");
ensureColumn(db, "messages", "completion_tokens", "completion_tokens INTEGER NOT NULL DEFAULT 0");
ensureColumn(db, "messages", "total_tokens", "total_tokens INTEGER NOT NULL DEFAULT 0");
ensureColumn(db, "messages", "tokens_per_second", "tokens_per_second REAL");
// User memories table for semantic memory system
db.run(`
CREATE TABLE IF NOT EXISTS user_memories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
value TEXT NOT NULL,
confidence REAL NOT NULL DEFAULT 1.0,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
)
`);
db.run(`CREATE INDEX IF NOT EXISTS idx_memories_key ON user_memories(key)`);
db.run(`CREATE INDEX IF NOT EXISTS idx_memories_updated ON user_memories(updated_at DESC)`);
// Track which messages have already been processed for memory extraction
ensureColumn(db, "messages", "memory_processed", "memory_processed INTEGER NOT NULL DEFAULT 0");
const chatCount = db.get<{ c: number }>("SELECT COUNT(*) as c FROM chats");
const msgCount = db.get<{ c: number }>("SELECT COUNT(*) as c FROM messages");
console.log(`[DB] initDB: ready — ${chatCount?.c ?? 0} chats, ${msgCount?.c ?? 0} messages`);
+242
View File
@@ -0,0 +1,242 @@
import type {
AgentPlan,
AgentPlanState,
ArtifactReadyEvent,
PlanCompletedEvent,
SpecialistResponseCompletedEvent,
SpecialistState,
SpecialistThinkingStartedEvent,
SpecialistThinkingUpdateEvent,
StepCompletedEvent,
StepErrorEvent,
StepStartedEvent,
} from "../types/forgeagent";
type ForgeAgentEvent =
| { type: "step-started"; payload: StepStartedEvent }
| { type: "step-completed"; payload: StepCompletedEvent }
| { type: "step-error"; payload: StepErrorEvent }
| { type: "artifact-ready"; payload: ArtifactReadyEvent }
| { type: "plan-completed"; payload: PlanCompletedEvent }
| { type: "specialist-thinking-started"; payload: SpecialistThinkingStartedEvent }
| { type: "specialist-thinking-update"; payload: SpecialistThinkingUpdateEvent }
| { type: "specialist-response-completed"; payload: SpecialistResponseCompletedEvent };
type ForgeAgentState = AgentPlanState & {
specialists: SpecialistState[];
};
export function serializeForgeAgentState(state: AgentPlanState | null | undefined): string | null {
if (!state) return null;
return JSON.stringify(state);
}
export function withPersistedForgeAgentResponse(
state: AgentPlanState | null | undefined,
finalResponse: string | null | undefined,
): AgentPlanState | null {
if (!state) return null;
const normalizedResponse = resolveForgeAgentFinalResponse(state, finalResponse);
if (!normalizedResponse) {
return state;
}
return {
...state,
final_response: normalizedResponse,
};
}
function isGenericForgeAgentResponse(value: string | null | undefined): boolean {
const normalized = value?.trim().toLowerCase();
if (!normalized) return true;
return [
"aqui está a resposta final organizada em formato manus-style, pronta para revisão e entrega.",
"aqui está a resposta final organizada.",
].includes(normalized);
}
function extractPromptFromArtifact(state: AgentPlanState | null | undefined): string | null {
const content = state?.artifact?.content;
if (!content) return null;
const match = content.match(/prompt original:\s*(.+)/i);
return match?.[1]?.trim() ?? null;
}
export function resolveForgeAgentFinalResponse(
state: AgentPlanState | null | undefined,
fallbackContent?: string | null,
): string {
const resolvedFallback = fallbackContent?.trim() ?? "";
const stateResponse = state?.final_response?.trim() ?? "";
if (stateResponse && !isGenericForgeAgentResponse(stateResponse)) {
return stateResponse;
}
if (resolvedFallback && !isGenericForgeAgentResponse(resolvedFallback)) {
return resolvedFallback;
}
if (!state) {
return resolvedFallback;
}
const prompt = extractPromptFromArtifact(state);
const completedUpdates = (state.system_messages ?? [])
.map((message) => message.content.trim())
.filter(Boolean)
.slice(-3);
const sections = [
"## Resposta final",
prompt
? `Concluí a execução do pedido **${prompt}**.`
: `Concluí a execução de **${state.plan.title}**.`,
completedUpdates.length > 0
? `O que foi entregue:\n${completedUpdates.map((item) => `- ${item}`).join("\n")}`
: null,
state.artifact
? `Artefato disponível: \`${state.artifact.filename}\`${state.artifact.cta_label ? `, pronto para ${state.artifact.cta_label.toLowerCase()}` : "."}`
: null,
].filter((section): section is string => Boolean(section));
return sections.join("\n\n");
}
export function parseForgeAgentState(value: unknown): AgentPlanState | null {
if (!value) return null;
const parsed = typeof value === "string" ? safeParse(value) : value;
if (!parsed || typeof parsed !== "object") return null;
const candidate = parsed as Partial<AgentPlanState>;
if (typeof candidate.plan_id !== "string") return null;
if (!candidate.plan || typeof candidate.plan !== "object") return null;
if (!Array.isArray(candidate.plan.steps)) return null;
return candidate as AgentPlanState;
}
function safeParse(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return null;
}
}
export function createForgeAgentState(planId: string, plan: AgentPlan): AgentPlanState {
return {
plan_id: planId,
plan,
current_step: 0,
status: "planning",
progress: 0,
system_messages: [],
specialists: [],
} as ForgeAgentState;
}
export function applyForgeAgentEvent(state: AgentPlanState, event: ForgeAgentEvent): AgentPlanState {
if (state.plan_id !== event.payload.plan_id) return state;
const currentState = state as ForgeAgentState;
switch (event.type) {
case "step-started": {
const steps = currentState.plan.steps.map((step) =>
step.id === event.payload.step_id
? { ...step, status: "running" as const }
: step,
);
return {
...currentState,
status: "executing",
current_step: event.payload.step_id,
progress: Math.max(currentState.progress, Math.round(((event.payload.step_id - 1) / currentState.plan.steps.length) * 100)),
plan: { ...currentState.plan, steps },
} as ForgeAgentState;
}
case "step-completed": {
const steps = currentState.plan.steps.map((step) =>
step.id === event.payload.step_id
? { ...step, status: "completed" as const }
: step,
);
const systemMessages = [
...(currentState.system_messages ?? []),
{
id: `${currentState.plan_id}-${event.payload.step_id}-${(currentState.system_messages ?? []).length + 1}`,
step_id: event.payload.step_id,
tone: "info" as const,
content: event.payload.message,
},
];
return {
...currentState,
status: "executing",
current_step: event.payload.step_id,
progress: Math.round((event.payload.step_id / currentState.plan.steps.length) * 100),
system_messages: systemMessages,
plan: { ...currentState.plan, steps },
} as ForgeAgentState;
}
case "step-error": {
const steps = currentState.plan.steps.map((step) =>
step.id === event.payload.step_id
? { ...step, status: "error" as const }
: step,
);
return {
...currentState,
status: "error",
error: event.payload.error,
plan: { ...currentState.plan, steps },
} as ForgeAgentState;
}
case "artifact-ready":
return { ...currentState, artifact: event.payload.artifact } as ForgeAgentState;
case "specialist-thinking-started": {
const specialist: SpecialistState = {
id: event.payload.specialist_id,
name: event.payload.specialist_name,
title: event.payload.specialist_title,
thinking: "",
response: "",
status: "thinking",
};
const existing = currentState.specialists.findIndex((s) => s.id === event.payload.specialist_id);
const specialists = existing >= 0
? currentState.specialists.map((s, i) => (i === existing ? specialist : s))
: [...currentState.specialists, specialist];
return { ...currentState, specialists } as ForgeAgentState;
}
case "specialist-thinking-update": {
const specialists = currentState.specialists.map((s) =>
s.id === event.payload.specialist_id
? { ...s, thinking: event.payload.thinking }
: s,
);
return { ...currentState, specialists } as ForgeAgentState;
}
case "specialist-response-completed": {
const specialists = currentState.specialists.map((s) =>
s.id === event.payload.specialist_id
? { ...s, thinking: event.payload.thinking, response: event.payload.response, status: "responded" as const }
: s,
);
return { ...currentState, specialists } as ForgeAgentState;
}
case "plan-completed":
return {
...currentState,
status: "completed",
progress: 100,
final_response: event.payload.summary,
} as ForgeAgentState;
}
}
+76
View File
@@ -0,0 +1,76 @@
export type ModelAlias = 'Copper' | 'Iron' | 'Steel' | 'Unknown';
/**
* Resolves model alias from filename or path
* Matches: Copper (0.8B), Iron (2B), Steel (4B)
*/
export function resolveAlias(modelIdOrPath: string): ModelAlias {
const lower = modelIdOrPath.toLowerCase();
// Match by filename pattern first
if (lower.includes('copper')) return 'Copper';
if (lower.includes('iron')) return 'Iron';
if (lower.includes('steel')) return 'Steel';
// Fallback: match by parameter size indicators
if (lower.includes('0.8b')) return 'Copper';
if (lower.includes('4b')) return 'Steel';
if (lower.includes('2b')) return 'Iron';
return 'Unknown';
}
/**
* Gets clean display name for model selector
* Returns: Copper, Iron, Steel (never full filename)
*/
export function getDisplayName(modelIdOrPath: string): string {
const alias = resolveAlias(modelIdOrPath);
if (alias !== 'Unknown') return alias;
// For unknown models, extract a clean name from filename
const parts = modelIdOrPath.replace(/\\/g, '/').split('/');
const base = parts[parts.length - 1];
// Remove .gguf extension and common suffixes
let clean = base.replace(/\.gguf$/i, '');
clean = clean.replace(/-Q\d+_[KML]+$/i, ''); // Remove quantization suffix
clean = clean.replace(/-GGUF$/i, '');
return clean.slice(0, 20);
}
/**
* Returns true if this model should appear in the primary chat selector
* All valid models (Copper, Iron, Steel) are shown
*/
export function isInChatSelector(modelIdOrPath: string): boolean {
const alias = resolveAlias(modelIdOrPath);
return alias === 'Copper' || alias === 'Iron' || alias === 'Steel';
}
/**
* Gets the ForgeMetal enum value for a model
* Used internally for model management
*/
export function getForgeMetal(modelIdOrPath: string): 'Copper' | 'Iron' | 'Steel' | null {
const alias = resolveAlias(modelIdOrPath);
if (alias === 'Unknown') return null;
return alias;
}
/**
* Gets model description/tooltip text
*/
export function getModelDescription(alias: ModelAlias): string {
switch (alias) {
case 'Copper':
return 'Copper (0.8B) - Fast and lightweight';
case 'Iron':
return 'Iron (2B) - Balanced performance';
case 'Steel':
return 'Steel (4B) - Most capable';
default:
return 'Unknown model';
}
}
+13
View File
@@ -1,3 +1,5 @@
import type { AgentPlanState } from "../types/forgeagent";
export interface Chat {
id: number;
title: string;
@@ -10,6 +12,8 @@ export interface Message {
chat_id: number;
role: "user" | "assistant" | "system";
content: string;
display_mode?: "default" | "agentic_final" | null;
forgeagent_state?: AgentPlanState | null;
reasoning_content?: string | null;
prompt_tokens?: number | null;
completion_tokens?: number | null;
@@ -39,3 +43,12 @@ export interface Usage {
total_tokens: number;
tokens_per_second?: number;
}
export interface UserMemory {
id: number;
key: string;
value: string;
confidence: number;
created_at: number;
updated_at: number;
}
+119
View File
@@ -0,0 +1,119 @@
/**
* ForgeAgent System Prompt
*
* Este prompt deve ser usado quando o modo agentic é ativado.
* Ele instrui o modelo a retornar apenas JSON estruturado no formato AgentPlan.
*/
export const FORGEAGENT_SYSTEM_PROMPT = `Você é o ForgeAgent, um assistente especializado em criar planos estruturados e executáveis.
SUA TAREFA:
1. Analisar a solicitação do usuário
2. Criar um plano passo a passo detalhado
3. Retornar APENAS um objeto JSON válido no formato especificado abaixo
REGRAS IMPORTANTES:
- NUNCA adicione texto fora do JSON
- NUNCA use markdown code blocks (\`\`\`json)
- Sempre divida tarefas complexas em passos claros
- Cada passo deve ter 2-5 sub-passos quando aplicável
- Use linguagem clara e direta
- O overview deve ter no máximo 2 frases
- O título deve ter no máximo 50 caracteres
- Máximo de 10 passos principais
- Responda em português do Brasil
ESTRUTURA JSON OBRIGATÓRIA:
{
"title": "string (max 50 chars)",
"overview": "string (max 2 frases)",
"steps": [
{
"id": number,
"title": "string",
"description": "string",
"status": "pending",
"sub_steps": [
{
"id": "string (formato: '{step_id}.{sub_id}')",
"description": "string",
"status": "pending"
}
]
}
]
}
EXEMPLO DE RESPOSTA VÁLIDA:
{"title":"Criar API Node.js","overview":"Vamos criar uma API REST completa usando Express.js.","steps":[{"id":1,"title":"Configurar ambiente","description":"Preparar ambiente de desenvolvimento","status":"pending","sub_steps":[{"id":"1.1","description":"Verificar instalação do Node.js","status":"pending"},{"id":"1.2","description":"Instalar npm","status":"pending"}]}]}
LEMBRE-SE: Retorne APENAS o JSON, sem explicações adicionais.`;
/**
* Prompt para geração do artefato final
* Usado após todos os steps serem completados
*/
export const FORGEAGENT_ARTIFACT_PROMPT = `Você é o ForgeAgent. Com base no plano executado, crie um artefato final completo.
O artefato deve ser um documento Markdown bem estruturado contendo:
1. Título do guia
2. Introdução breve
3. Conteúdo completo (código, explicações, comandos)
4. Conclusão
REGRAS:
- Use Markdown válido
- Inclua blocos de código quando necessário
- Seja completo mas conciso
- Use emojis apropriados para melhorar legibilidade
- Responda em português do Brasil
Retorne APENAS o conteúdo do markdown, sem blocos de código (\`\`\`).`;
/**
* Trigger keywords for automatic agentic mode detection
*/
export const AGENTIC_TRIGGER_KEYWORDS = [
'como fazer',
'como criar',
'como configurar',
'como instalar',
'como usar',
'como implementar',
'guia',
'tutorial',
'passo a passo',
'exemplo completo',
'projeto',
'aprenda',
'ensine',
'explique detalhadamente',
];
/**
* Regex patterns for trigger detection
*/
export const AGENTIC_TRIGGER_PATTERNS = [
/\b(como|guia|tutorial|passo a passo|exemplo completo|projeto|aprenda|ensine)\b/i,
/\b(pode me (mostrar|ensinar|ajudar|explicar))\b/i,
/\b(quero (aprender|criar|fazer|construir|configurar))\b/i,
];
/**
* Check if a message should trigger agentic mode
*/
export function shouldTriggerAgentic(message: string): boolean {
const lowerMessage = message.toLowerCase();
// Check keywords
const hasKeyword = AGENTIC_TRIGGER_KEYWORDS.some(keyword =>
lowerMessage.includes(keyword.toLowerCase())
);
// Check patterns
const hasPattern = AGENTIC_TRIGGER_PATTERNS.some(pattern =>
pattern.test(message)
);
return hasKeyword || hasPattern;
}
+242
View File
@@ -0,0 +1,242 @@
/**
* ForgeAgent Types
*
* TypeScript definitions for the AgentPlan structured output
*/
export type StepStatus = 'pending' | 'running' | 'completed' | 'error';
export interface SubStep {
/** Format: "{step_id}.{sub_id}" e.g., "1.1", "2.3" */
id: string;
/** Description of the sub-task */
description: string;
/** Current status */
status: StepStatus;
}
export interface Step {
/** Sequential ID starting from 1 */
id: number;
/** Short title (max ~50 chars) */
title: string;
/** Detailed description */
description: string;
/** Current execution status */
status: StepStatus;
/** Sub-tasks breakdown */
sub_steps: SubStep[];
}
export interface AgentPlan {
/** Plan title (max 50 chars) */
title: string;
/** Brief overview (max 2 sentences) */
overview: string;
/** List of execution steps */
steps: Step[];
}
export interface Artifact {
/** Filename with extension */
filename: string;
/** Full content */
content: string;
/** Language for syntax highlighting */
language?: 'markdown' | 'javascript' | 'typescript' | 'python' | 'rust' | 'json' | string;
/** CTA label shown in final response */
cta_label?: string;
}
export interface AgentSystemMessage {
id: string;
step_id: number;
tone: 'info' | 'success' | 'warning' | 'error';
content: string;
}
/**
* Runtime state for plan execution
*/
export interface AgentPlanState {
/** Unique plan ID */
plan_id: string;
/** The plan definition */
plan: AgentPlan;
/** Current step index (0-based) */
current_step: number;
/** Overall status */
status: 'planning' | 'executing' | 'completed' | 'error' | 'cancelled';
/** Progress percentage (0-100) */
progress: number;
/** Error message if status is 'error' */
error?: string;
/** Generated artifact */
artifact?: Artifact;
/** Dense transcript updates shown between steps */
system_messages?: AgentSystemMessage[];
/** Final prose response shown after execution */
final_response?: string;
/** Active specialist agents */
specialists?: SpecialistState[];
}
/**
* Props for ForgeAgent components
*/
export interface ForgeAgentBubbleProps {
/** The plan to display */
plan: AgentPlan;
/** Current execution state */
state?: AgentPlanState;
/** Whether the plan is currently executing */
isExecuting: boolean;
/** Optional markdown renderer for the final response */
renderMarkdown?: (text: string) => string;
}
export interface PlanCardProps {
plan: AgentPlan;
state: AgentPlanState;
}
export interface StepItemProps {
step: Step;
isActive: boolean;
isLast: boolean;
}
export interface ArtifactViewerProps {
artifact: Artifact;
onDownload?: () => void;
}
/**
* Tauri event payloads
*/
export interface PlanStartedEvent {
plan_id: string;
title: string;
total_steps: number;
}
export interface StepStartedEvent {
plan_id: string;
step_id: number;
title: string;
}
export interface StepCompletedEvent {
plan_id: string;
step_id: number;
message: string;
sub_steps_completed?: string[];
}
export interface StepErrorEvent {
plan_id: string;
step_id: number;
error: string;
}
export interface ArtifactReadyEvent {
plan_id: string;
artifact: Artifact;
}
export interface PlanCompletedEvent {
plan_id: string;
summary: string;
}
export interface SpecialistThinkingStartedEvent {
plan_id: string;
specialist_id: string;
specialist_name: string;
specialist_title: string;
}
export interface SpecialistThinkingUpdateEvent {
plan_id: string;
specialist_id: string;
thinking: string;
}
export interface SpecialistResponseCompletedEvent {
plan_id: string;
specialist_id: string;
specialist_name: string;
thinking: string;
response: string;
}
export interface SpecialistState {
id: string;
name: string;
title: string;
thinking: string;
response: string;
status: 'thinking' | 'responded';
}
/**
* JSON Schema for validation (can be used with zod or ajv)
*/
export const AgentPlanJSONSchema = {
type: 'object',
required: ['title', 'overview', 'steps'],
properties: {
title: {
type: 'string',
maxLength: 50,
},
overview: {
type: 'string',
maxLength: 200,
},
steps: {
type: 'array',
maxItems: 10,
items: {
type: 'object',
required: ['id', 'title', 'description', 'status', 'sub_steps'],
properties: {
id: {
type: 'number',
minimum: 1,
},
title: {
type: 'string',
},
description: {
type: 'string',
},
status: {
type: 'string',
enum: ['pending', 'running', 'completed', 'error'],
},
sub_steps: {
type: 'array',
items: {
type: 'object',
required: ['id', 'description', 'status'],
properties: {
id: {
type: 'string',
pattern: '^\\d+\\.\\d+$',
},
description: {
type: 'string',
},
status: {
type: 'string',
enum: ['pending', 'running', 'completed', 'error'],
},
},
},
},
},
},
},
},
};