wip: snapshot das alterações não commitadas (2026-10-20)

Origem: D:/Documents/Github/ForgeLM
Arquivos omitidos (segredos, bancos de dados, logs, >50 MB): 1
This commit is contained in:
evandro committed 2026-10-20 00:49:35 -03:00
1 parent f2481cf7e6
commit 4f293dcb3b
34 files changed
+7131 -255

No files matched your search

+3
View File
@@ -1 +1,4 @@
{"timestamp":"2026-04-11T05:27:00Z","iteration":0,"metric":100,"status":"baseline","summary":"Initial baseline - 5/5 tests passing","commit":"baseline"}
{"timestamp":"2026-04-11T05:28:07Z","iteration":1,"metric":100,"status":"kept","summary":"Add conciseness guidance: Be concise but thorough","commit":"3ea47c2"}
{"timestamp":"2026-04-11T05:28:45Z","iteration":2,"metric":100,"status":"kept","summary":"Improve language detection: Detect from user message","commit":"1b66a5c"}
{"timestamp":"2026-04-11T05:29:19Z","iteration":3,"metric":100,"status":"kept","summary":"Add markdown formatting guidance","commit":"exp3"}
-1
View File
@@ -1 +0,0 @@
{"started_at": "2026-04-11T05:27:00Z", "iteration": 0}
+4
View File
@@ -0,0 +1,4 @@
# Taste (Continuously Learned by [CommandCode][cmd])
[cmd]: https://commandcode.ai/
+1
View File
@@ -12,3 +12,4 @@ vendor/llama-bin/
*.dll
*.so
*.dylib
.opencode
+21
View File
@@ -0,0 +1,21 @@
node_modules/
.next/
dist/
build/
out/
coverage/
.git/
.pnpm-store/
.cache/
*.log
.env
.env.*
__pycache__/
*.pyc
.venv/
venv/
target/
*.exe
*.dll
*.so
*.dylib
+10 -1
View File
@@ -70,7 +70,16 @@ ForgeLM (formerly AI Backend Studio) is a local-first desktop workspace for runn
<!-- GSD:conventions-start source:CONVENTIONS.md -->
## Conventions
Conventions not yet established. Will populate as patterns emerge during development.
### Automatic Context7 Usage
Whenever the agent has doubts about library APIs, coding patterns, best practices, or how to implement a feature using any dependency in the project, it MUST automatically invoke the Context7 MCP server to fetch up-to-date documentation without waiting for the user to ask.
This applies to:
- Questions about React, Tauri, Axum, Tokio, Serde, or any other dependency
- Uncertainty about correct API usage, function signatures, or configuration options
- Need for code examples or patterns from official docs
- Any situation where outdated or hallucinated API knowledge could lead to incorrect code
The agent should use `resolve-library-id` followed by `get-library-docs` as needed, without prompting the user.
<!-- GSD:conventions-end -->
<!-- GSD:architecture-start source:ARCHITECTURE.md -->
+3
View File
@@ -15,6 +15,7 @@
"marked": "^12.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"zustand": "^5.0.12",
},
"devDependencies": {
"@tauri-apps/cli": "^2",
@@ -631,6 +632,8 @@
"yallist": ["[email protected]", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"zustand": ["[email protected]", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g=="],
"@babel/helper-compilation-targets/lru-cache": ["[email protected]", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"anymatch/picomatch": ["[email protected]", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
+446
View File
@@ -0,0 +1,446 @@
# Arquitetura: Multi-Agentes Especialistas para ForgeLM
## Visão Geral
Sistema de especialistas que analisam e refinam iterativamente as respostas do assistente, utilizando a infraestrutura ForgeAgent existente.
## 1. Fluxo Conceitual
```
Usuário → ChatView → API Local → Resposta Inicial
↓
┌───────────────┼───────────────┐
↓ ↓ ↓
[Especialista 1] [Especialista 2] [Especialista 3]
CodeReviewer StyleEditor FactChecker
↓ ↓ ↓
└───────────────┴───────────────┘
↓
Resposta Refinada
↓
ChatView UI
```
## 2. Estrutura de Dados
### SpecialistDefinition (Configuração)
```typescript
interface SpecialistDefinition {
id: string; // "code-reviewer", "style-editor"
name: string; // "Code Reviewer"
title: string; // "Revisor de Código"
systemPrompt: string; // Prompt específico do especialista
activationRules: ActivationRule[]; // Quando ativar
modelTier: "fast" | "quality"; // Qual modelo usar
maxTokens: number;
temperature: number;
}
type ActivationRule =
| { type: "always" }
| { type: "contains-code"; languages?: string[] }
| { type: "length-threshold"; minChars: number }
| { type: "content-pattern"; regex: string }
| { type: "manual-toggle"; toggleId: string };
```
### ReviewCycle (Execução)
```typescript
interface ReviewCycle {
id: string; // UUID da revisão
originalMessageId: number; // ID da mensagem original
originalContent: string; // Resposta inicial do assistente
specialists: SpecialistReview[];
finalContent: string; // Resposta após todas as revisões
status: "running" | "completed" | "error";
startedAt: number;
completedAt?: number;
}
interface SpecialistReview {
specialistId: string;
status: "pending" | "thinking" | "completed" | "error";
thinking?: string; // Chain of thought
suggestions: Suggestion[];
applied: boolean; // Se as sugestões foram aplicadas
}
interface Suggestion {
type: "improvement" | "correction" | "addition" | "deletion";
original?: string; // Texto original
replacement?: string; // Texto sugerido
explanation: string; // Por que mudar
confidence: number; // 0.0 - 1.0
}
```
## 3. Componentes Principais
### A. SpecialistRegistry (src/lib/specialist-registry.ts)
Registro central de especialistas disponíveis.
```typescript
class SpecialistRegistry {
private specialists: Map<string, SpecialistDefinition> = new Map();
register(def: SpecialistDefinition): void;
unregister(id: string): void;
getActiveForContent(content: string, context: Context): SpecialistDefinition[];
getAll(): SpecialistDefinition[];
}
```
### B. ReviewOrchestrator (src/lib/review-orchestrator.ts)
Orquestra o ciclo de revisão.
```typescript
class ReviewOrchestrator {
async startReview(
messageId: number,
content: string,
context: MessageContext
): Promise<ReviewCycle>;
private async runSpecialist(
cycle: ReviewCycle,
specialist: SpecialistDefinition,
currentContent: string
): Promise<SpecialistReview>;
private applySuggestions(content: string, review: SpecialistReview): string;
onProgress(callback: (cycle: ReviewCycle) => void): void;
onComplete(callback: (cycle: ReviewCycle) => void): void;
}
```
### C. SpecialistService (Backend/Tauri)
Comandos Rust para executar revisões sem bloquear a UI.
```rust
#[tauri::command]
async fn run_specialist_review(
specialist_id: String,
content: String,
context: MessageContext,
server: String,
model: String,
) -> Result<SpecialistReview, String>;
```
## 4. Integração ao ChatView
### Modo Síncrono vs Assíncrono
**Opção A: Assíncrono (Recomendado)**
- Resposta inicial aparece imediatamente
- Especialistas processam em background
- Badge/indicator mostra "Melhorando..."
- Quando completo, mensagem é atualizada com versão refinada
- Toggle "Agentic" controla se ativa especialistas
**Opção B: Síncrono**
- Aguarda todos os especialistas antes de mostrar
- Mais lento mas garante qualidade máxima
- Mostra progresso em tempo real (SpecialistCards)
### Proposta: Híbrido com Streaming
```typescript
// No ChatView
const [reviewCycle, setReviewCycle] = useState<ReviewCycle | null>(null);
// Após receber resposta completa
if (agenticMode) {
const cycle = await reviewOrchestrator.startReview(
messageId,
responseContent,
{ chatId, previousMessages }
);
setReviewCycle(cycle);
// Mostra mensagem inicial imediatamente
// Atualiza quando especialistas completam
cycle.onProgress((updated) => {
setReviewCycle({ ...updated });
});
}
```
### UI Components
1. **ReviewProgressIndicator** - Badge mostrando "3/5 especialistas completos"
2. **ReviewDiffViewer** - Mostra diff entre original e refinado
3. **SpecialistSidebar** - Panel mostrando thinking de cada especialista
4. **SuggestionAcceptReject** - Controles para aceitar/rejeitar sugestões
## 5. Especialistas Padrão
### 1. CodeReviewer
```typescript
{
id: "code-reviewer",
name: "Code Reviewer",
title: "Revisor de Código",
activationRules: [{ type: "contains-code" }],
systemPrompt: `Você é um revisor de código sênior. Analise o código fornecido e sugira melhorias em:
- Eficiência e performance
- Legibilidade e clareza
- Boas práticas e padrões
- Possíveis bugs ou edge cases
Formato de resposta (JSON):
{
"thinking": "sua análise passo a passo",
"suggestions": [
{
"type": "improvement",
"original": "código original",
"replacement": "código melhorado",
"explanation": "por que melhorar",
"confidence": 0.95
}
]
}`
}
```
### 2. StyleEditor
```typescript
{
id: "style-editor",
name: "Style Editor",
title: "Editor de Estilo",
activationRules: [{ type: "length-threshold", minChars: 200 }],
systemPrompt: `Você é um editor de texto profissional. Melhore a clareza e fluidez do texto mantendo o significado original.
Foque em:
- Clareza e concisão
- Fluxo e coesão entre frases
- Tom apropriado ao contexto
- Eliminação de redundâncias
Formato de resposta (JSON):
{
"thinking": "análise do estilo",
"suggestions": [...]
}`
}
```
### 3. FactChecker
```typescript
{
id: "fact-checker",
name: "Fact Checker",
title: "Verificador de Fatos",
activationRules: [{ type: "content-pattern", regex: "\b(sabe|sabia|fato|verdade|estudo|pesquisa|dado|estatística)\b" }],
systemPrompt: `Você é um verificador de fatos. Identifique afirmações factuais no texto e avalie:
- Se são factualmente corretas (conhecimento até 2024)
- Se precisam de citação ou fonte
- Se há nuances ou contexto faltante
NÃO invente informações. Marque como "não verificável" se incerto.
Formato de resposta (JSON):
{
"thinking": "análise factual",
"suggestions": [
{
"type": "correction",
"original": "afirmação incorreta",
"replacement": "versão corrigida ou com ressalvas",
"explanation": "por que precisa correção",
"confidence": 0.85
}
]
}`
}
```
### 4. ToneAdapter
```typescript
{
id: "tone-adapter",
name: "Tone Adapter",
title: "Adaptador de Tom",
activationRules: [{ type: "always" }],
systemPrompt: `Você adapta o tom da resposta ao contexto da conversa.
Analise o histórico recente e ajuste:
- Formalidade (técnico vs casual)
- Entusiasmo (neutro vs animado)
- Detalhamento (resumo vs aprofundado)
- Personalização (menção a fatos do usuário)
Formato de resposta (JSON):
{
"thinking": "análise do contexto e tom adequado",
"suggestions": [...]
}`
}
```
## 6. Fluxo de Execução
```typescript
// 1. Usuário envia mensagem
// 2. Recebe resposta do assistente
// 3. Se agenticMode ativo:
async function processWithSpecialists(messageId: number, content: string) {
// Determina quais especialistas ativar
const activeSpecialists = specialistRegistry.getActiveForContent(content, context);
// Cria ciclo de revisão
const cycle: ReviewCycle = {
id: generateUUID(),
originalMessageId: messageId,
originalContent: content,
specialists: activeSpecialists.map(s => ({
specialistId: s.id,
status: "pending",
suggestions: [],
applied: false
})),
finalContent: content, // Começa com original
status: "running",
startedAt: Date.now()
};
// Executa sequencialmente (para builds contextuais)
let currentContent = content;
for (const specialist of activeSpecialists) {
updateSpecialistStatus(cycle, specialist.id, "thinking");
const review = await runSpecialist(specialist, currentContent, context);
updateSpecialistStatus(cycle, specialist.id, "completed", review);
// Aplica sugestões automaticamente (pode ser manual no futuro)
currentContent = applySuggestions(currentContent, review);
cycle.finalContent = currentContent;
notifyProgress(cycle);
}
cycle.status = "completed";
cycle.completedAt = Date.now();
// Atualiza mensagem no DB com versão refinada
await updateMessage(messageId, cycle.finalContent, cycle);
return cycle;
}
```
## 7. Persistência
### Tabela: specialist_reviews
```sql
CREATE TABLE specialist_reviews (
id TEXT PRIMARY KEY,
message_id INTEGER NOT NULL,
original_content TEXT NOT NULL,
final_content TEXT NOT NULL,
specialists_json TEXT NOT NULL, -- SpecialistReview[]
status TEXT NOT NULL,
created_at INTEGER NOT NULL,
completed_at INTEGER
);
```
### Tabela: specialist_definitions (configuração do usuário)
```sql
CREATE TABLE specialist_definitions (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
title TEXT NOT NULL,
system_prompt TEXT NOT NULL,
activation_rules_json TEXT NOT NULL,
is_enabled BOOLEAN DEFAULT 1,
sort_order INTEGER DEFAULT 0
);
```
## 8. Configuração do Usuário
### Settings UI
- Lista de especialistas (drag-drop para reordenar)
- Toggle individual para cada especialista
- Editor de system prompts customizados
- Regras de ativação configuráveis
### Padrões do Sistema
```typescript
const DEFAULT_SPECIALISTS: SpecialistDefinition[] = [
toneAdapter, // Sempre ativo
codeReviewer, // Só com código
styleEditor, // Textos longos
factChecker, // Quando detecta factualidade
];
```
## 9. Otimizações de Performance
1. **Caching**: Especialistas memorizam contexto entre mensagens
2. **Lazy Loading**: Só carrega especialistas quando necessário
3. **Parallel where safe**: Especialistas independentes rodam em paralelo
4. **Early Exit**: Se confiança < threshold, pula para o próximo
5. **Model Tier**: Especialistas usam modelos menores/fasters quando possível
## 10. Considerações de UX
### Quando Mostrar
- ✅ Sempre mostrar resultado original primeiro
- ✅ Mostrar progresso discreto (badge, não modal)
- ✅ Permitir ver thinking de cada especialista (expansível)
- ✅ Permitir toggle on/off global (já existe AgenticToggle)
- ✅ Permitir ver diff original vs refinado
### Quando NÃO Usar
- ❌ Não bloquear a UI esperando especialistas
- ❌ Não mostrar erros de especialista ao usuário (fail silently)
- ❌ Não processar mensagens muito curtas (< 50 chars)
- ❌ Não processar se modelo indisponível
## 11. MVP - Fase 1
Implementação mínima viável:
1. **2 especialistas hardcoded**: ToneAdapter (sempre) + CodeReviewer (se código)
2. **Execução sequencial síncrona** (simplifica UI)
3. **Aplicação automática** de sugestões
4. **UI mínima**: Badge "Melhorando..." → "Melhorado ✓" + SpecialistCard expansível
5. **Sem persistência** de review cycles (só resultado final)
### Arquivos a Criar (Fase 1)
```
src/
lib/
specialist-definitions.ts # Especialistas padrão
review-orchestrator.ts # Lógica de orquestração
components/
forgeagent/
ReviewProgress.tsx # Badge de progresso
ReviewDiffViewer.tsx # Diff original vs refinado
hooks/
useSpecialistReview.ts # Hook para gerenciar ciclo
```
### Arquivos a Modificar
```
src/components/ChatView.tsx # Integrar ao fluxo de resposta
src/components/MessageBubble.tsx # Mostrar badge de revisão
src/prompts/specialists/ # Prompts de cada especialista
```
## 12. Próximos Passos
1. ✅ Validar arquitetura com o usuário
2. Implementar Fase 1 (MVP)
3. Testar com diferentes modelos (Qwen3 0.8B, 2B, 4B)
4. Ajustar prompts baseado em resultados
5. Adicionar especialistas adicionais
6. Implementar modo paralelo assíncrono
7. Adicionar configuração via UI Settings
@@ -0,0 +1,498 @@
# Arquitetura: Modo Paralelo Multi-Agente com Síntese
## Visão Geral
Sistema onde múltiplos agentes (2-16) processam o prompt simultaneamente, e um sintetizador consolida todas as respostas em uma resposta final otimizada.
## Estrutura de Dados
### Estado do Sistema
```typescript
// Substitui agenticMode: boolean → agentCount: number (0 = desativado)
type AgentCount = 0 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16;
// Agente individual no modo paralelo
interface ParallelAgent {
id: string; // "agent-1", "agent-2", etc.
name: string; // Nome exibido no card
role: string; // "Crítico", "Criativo", "Técnico", etc.
systemPrompt: string; // Prompt específico deste agente
status: "idle" | "streaming" | "completed" | "error";
content: string; // Resposta acumulada (streaming)
streamChunks: string[]; // Chunks para renderização em tempo real
tokensPerSecond: number | null;
error?: string;
}
// Estado completo da revisão paralela
interface ParallelReviewState {
messageId: number; // ID da mensagem sendo processada
agents: ParallelAgent[]; // Lista de agentes (2-16)
// Barreira de sincronia
allAgentsCompleted: boolean; // true quando TODOS terminaram
completedAt: number | null; // Timestamp de conclusão do último agente
// Sintetizador
synthesizerStatus: "waiting" | "streaming" | "completed" | "error" | "skipped";
synthesizerContent: string; // Resposta final sendo gerada
// Estado geral
status: "running" | "completed" | "error";
startedAt: number;
completedAt: number | null;
}
```
## Fluxo de Execução
### 1. Inicialização (ChatView)
```typescript
// Antes de enviar mensagem
const agentCount = useAgentStore((s) => s.agentCount); // 2-16 ou 0
// Se agentCount > 0, não envia para o LLM principal diretamente
// Em vez disso, inicia o modo paralelo
if (agentCount > 0) {
await parallelOrchestrator.startParallelReview(
messageId,
userMessage,
agentCount,
context
);
} else {
// Fluxo normal
await sendToLLM(userMessage);
}
```
### 2. Orquestração Paralela
```typescript
class ParallelOrchestrator {
async startParallelReview(
messageId: number,
userPrompt: string,
agentCount: number,
context: Context
): Promise<void> {
// 1. Gerar N agentes dinamicamente
const agents = this.generateAgents(agentCount, userPrompt);
// 2. Inicializar estado
const state: ParallelReviewState = {
messageId,
agents: agents.map((a) => ({ ...a, status: "idle", content: "" })),
allAgentsCompleted: false,
completedAt: null,
synthesizerStatus: "waiting",
synthesizerContent: "",
status: "running",
startedAt: Date.now(),
completedAt: null,
};
// 3. Iniciar TODOS os agentes em paralelo
const agentPromises = agents.map((agent, index) =>
this.runAgentStreaming(agent, index, userPrompt, context, state)
);
// 4. BARREIRA: Aguardar TODOS terminarem
await Promise.all(agentPromises);
state.allAgentsCompleted = true;
state.agentsCompletedAt = Date.now();
// 5. Chamar sintetizador (só depois da barreira)
await this.runSynthesizer(userPrompt, state, context);
state.status = "completed";
state.completedAt = Date.now();
}
private async runAgentStreaming(
agent: AgentDefinition,
index: number,
userPrompt: string,
context: Context,
state: ParallelReviewState
): Promise<void> {
state.agents[index].status = "streaming";
const response = await fetch(`${context.server}/v1/chat/completions`, {
method: "POST",
headers: { /* ... */ },
body: JSON.stringify({
model: context.model,
messages: [
{ role: "system", content: agent.systemPrompt },
{ role: "user", content: userPrompt }
],
stream: true, // Stream para atualização em tempo real
}),
});
// Processar stream e atualizar state.agents[index].content em tempo real
for await (const chunk of response.body) {
const content = extractContent(chunk);
state.agents[index].content += content;
state.agents[index].streamChunks.push(content);
this.notifyUpdate(state); // Callback para UI
}
state.agents[index].status = "completed";
}
private async runSynthesizer(
originalPrompt: string,
state: ParallelReviewState,
context: Context
): Promise<void> {
state.synthesizerStatus = "streaming";
// Construir contexto com TODAS as respostas dos agentes
const synthesizerPrompt = this.buildSynthesizerPrompt(
originalPrompt,
state.agents
);
const response = await fetch(`${context.server}/v1/chat/completions`, {
method: "POST",
headers: { /* ... */ },
body: JSON.stringify({
model: context.model,
messages: [
{ role: "system", content: SYNTHESIZER_SYSTEM_PROMPT },
{ role: "user", content: synthesizerPrompt }
],
stream: true,
}),
});
// Stream da resposta final
for await (const chunk of response.body) {
const content = extractContent(chunk);
state.synthesizerContent += content;
this.notifyUpdate(state);
}
state.synthesizerStatus = "completed";
// Salvar no DB
await this.saveFinalResponse(state);
}
}
```
## Geração Dinâmica de Agentes
```typescript
function generateAgents(count: number, userPrompt: string): AgentDefinition[] {
// Especialistas base que variam conforme o contexto
const baseRoles = [
{ name: "Analista", focus: "Analisar profundamente o problema" },
{ name: "Criativo", focus: "Gerar soluções inovadoras e fora da caixa" },
{ name: "Crítico", focus: "Identificar falhas, riscos e edge cases" },
{ name: "Prático", focus: "Focar em implementação e viabilidade" },
{ name: "Especialista", focus: "Aprofundar em detalhes técnicos" },
{ name: "Simplificador", focus: "Explicar de forma clara e acessível" },
{ name: "Histórico", focus: "Considerar contexto e precedentes" },
{ name: "Futurista", focus: "Projetar implicações de longo prazo" },
];
// Detectar tipo de pergunta para especialização
const isCode = /```|function|class|const|let|var|import|export/.test(userPrompt);
const isCreative = /ideia|criar|inventar|imagine|story|história/i.test(userPrompt);
const isTechnical = /como funciona|explica|por que/i.test(userPrompt);
// Selecionar roles apropriados
const selectedRoles = selectRoles(baseRoles, count, { isCode, isCreative, isTechnical });
return selectedRoles.map((role, i) => ({
id: `agent-${i + 1}`,
name: role.name,
role: role.focus,
systemPrompt: buildAgentPrompt(role, i + 1, count),
}));
}
function buildAgentPrompt(role: Role, index: number, total: number): string {
return `Você é o ${role.name} (${index}/${total}) em um painel de especialistas.
SUA FUNÇÃO ESPECÍFICA: ${role.focus}
REGRAS:
1. Responda à pergunta do usuário APENAS da perspectiva do seu papel
2. Não se preocupe em ser completo - outros especialistas cobrirão outros ângulos
3. Seja específico e aprofunde no que seu papel exige
4. Mantenha resposta concisa (máx 500 tokens) para facilitar síntese posterior
5. Use markdown quando apropriado
Você é o especialista #${index} de ${total}. Foque em contribuir sua perspectiva única.`;
}
```
## Prompt do Sintetizador
```typescript
const SYNTHESIZER_SYSTEM_PROMPT = `Você é um sintetizador master. Sua função é consolidar múltiplas perspectivas de especialistas em uma resposta única, otimizada e completa.
ENTRADAS QUE VOCÊ RECEBERÁ:
1. Pergunta original do usuário
2. Respostas de N especialistas (cada um com perspectiva diferente)
SUA TAREFA:
1. Analisar todas as contribuições dos especialistas
2. Identificar pontos de concordância e complementaridade
3. Resolver contradições de forma equilibrada
4. Criar uma resposta final que:
- Seja mais completa que qualquer resposta individual
- Mantenha o melhor de cada perspectiva
- Tenha fluxo natural e coeso
- Seja precisa e bem fundamentada
ESTRUTURA DA RESPOSTA FINAL:
- Comece com uma resposta direta à pergunta
- Desenvolva com detalhes relevantes das perspectivas combinadas
- Use exemplos práticos quando apropriado
- Mantenha o tom apropriado ao contexto
NÃO mencione que esta é uma síntese de múltiplos agentes. A resposta deve parecer uma única resposta coesa e natural.`;
function buildSynthesizerPrompt(originalPrompt: string, agents: ParallelAgent[]): string {
const agentResponses = agents
.map((a, i) => `=== ESPECIALISTA ${i + 1}: ${a.name} ===\n${a.content}\n`)
.join("\n");
return `PERGUNTA ORIGINAL:\n${originalPrompt}\n\n${agentResponses}`;
}
```
## UI Components
### 1. AgentCountSelector (substitui AgenticToggle)
```typescript
interface AgentCountSelectorProps {
value: AgentCount; // 0-16, onde 0 = desativado
onChange: (count: AgentCount) => void;
}
// Slider de 0 a 16, com:
// - 0 = "Modo Normal" (label)
// - 2-16 = "Modo Multi-Agente: N agentes"
// - Marcadores visuais em 2, 4, 8, 16
```
### 2. ParallelAgentsPanel (dentro de MessageBubble)
```typescript
interface ParallelAgentsPanelProps {
state: ParallelReviewState;
onAgentClick?: (agentId: string) => void;
}
// Layout: Grid de cards (2 colunas em mobile, 3-4 em desktop)
// Cada card mostra:
// - Nome do agente + ícone/avatar
// - Status (streaming dots, check, error)
// - Preview do conteúdo (scrollable)
// - Progresso visual (se streaming)
// Seção separada para sintetizador:
// - Mostra só depois de allAgentsCompleted = true
// - Indicador "Sintetizando..." com animação
// - Streaming da resposta final
```
### 3. Estado no ChatView
```typescript
const [agentCount, setAgentCount] = useState<AgentCount>(0);
const [parallelStates, setParallelStates] = useState<Map<number, ParallelReviewState>>(new Map());
// Quando uma mensagem é criada no modo paralelo:
const handleParallelStart = (messageId: number, state: ParallelReviewState) => {
setParallelStates((prev) => new Map(prev).set(messageId, state));
};
// Atualizações em tempo real via callback:
const handleParallelUpdate = (messageId: number, state: ParallelReviewState) => {
setParallelStates((prev) => new Map(prev).set(messageId, state));
};
```
## Hook useParallelReview
```typescript
function useParallelReview() {
const [states, setStates] = useState<Map<number, ParallelReviewState>>(new Map());
const orchestratorRef = useRef<ParallelOrchestrator | null>(null);
const startParallelReview = useCallback(async (
messageId: number,
userPrompt: string,
agentCount: number,
context: Context
) => {
orchestratorRef.current = new ParallelOrchestrator({
onUpdate: (state) => {
setStates((prev) => new Map(prev).set(messageId, state));
}
});
await orchestratorRef.current.start(messageId, userPrompt, agentCount, context);
}, []);
const getStateForMessage = useCallback((messageId: number) => {
return states.get(messageId) || null;
}, [states]);
return { startParallelReview, getStateForMessage };
}
```
## Modificações Necessárias
### Arquivos a Modificar
1. **`src/stores/agentStore.ts`** (novo)
- Estado global: `agentCount: AgentCount`
- Persistir no localStorage
2. **`src/components/AgentCountSelector.tsx`** (novo)
- Slider 0-16 com labels
- Tooltip explicando modo multi-agente
3. **`src/lib/parallel-orchestrator.ts`** (novo)
- Lógica completa descrita acima
- Promise.all para paralelismo
- Barreira de sincronia
- Sintetizador
4. **`src/components/ParallelAgentsPanel.tsx`** (novo)
- Grid de cards de agentes
- Visualização de streaming
- Seção do sintetizador
5. **`src/components/ChatView.tsx`**
- Substituir toggle por AgentCountSelector
- Condicional: se agentCount > 0, usa parallel-orchestrator
- Passar parallelState para MessageBubble
6. **`src/components/MessageBubble.tsx`**
- Prop opcional: `parallelState?: ParallelReviewState`
- Se presente, renderiza ParallelAgentsPanel
- Senão, comportamento normal
7. **`src/hooks/useParallelReview.ts`** (novo)
- Hook para gerenciar estado
- Interface com orchestrator
### Arquivos a Deprecar/Manter
- **`src/lib/review-orchestrator.ts`**: Manter (modo sequencial ainda útil)
- **`src/hooks/useSpecialistReview.ts`**: Manter para compatibilidade
- **`src/components/forgeagent/ReviewProgress.tsx`**: Manter para modo sequencial
## Fluxo Visual Esperado
```
[Input Area]
Slider: [0====|====4====|====8====|====12====|====16]
Label: "Modo Multi-Agente: 5 agentes"
[Usuário envia mensagem]
[MessageBubble aparece]
┌─────────────────────────────────────────┐
│ 🤖 Processando com 5 especialistas... │
├─────────────────────────────────────────┤
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │Analista │ │ Criativo│ │ Crítico │ │
│ │ ●●● │ │ ●●● │ │ ●●● │ │
│ │Streaming│ │Streaming│ │ Aguarde │ │
│ └─────────┘ └─────────┘ └─────────┘ │
│ ┌─────────┐ ┌─────────┐ │
│ │ Prático │ │Especiali│ │
│ │ ●●● │ │ ✓ │ │
│ │Streaming│ │Completo │ │
│ └─────────┘ └─────────┘ │
├─────────────────────────────────────────┤
│ ⏳ Aguardando todos os agentes... │
└─────────────────────────────────────────┘
[Todos completam]
[MessageBubble atualiza]
┌─────────────────────────────────────────┐
│ ✓ Análise de 5 especialistas completa │
├─────────────────────────────────────────┤
│ [Cards colapsados ou em abas] │
│ ├─ Analista [Ver ▼] │
│ ├─ Criativo [Ver ▼] │
│ ├─ Crítico [Ver ▼] │
│ ├─ Prático [Ver ▼] │
│ └─ Especialista[Ver ▼] │
├─────────────────────────────────────────┤
│ 🤖 Sintetizando resposta final... │
│ [Streaming da resposta consolidada] │
└─────────────────────────────────────────┘
[Síntese completa]
[MessageBubble final]
┌─────────────────────────────────────────┐
│ [Resposta final sintetizada] │
│ │
│ [Conteúdo completo e otimizado] │
│ │
├─────────────────────────────────────────┤
│ 💡 Baseado em análise de 5 especialistas│
│ [Ver detalhes ▼] │
└─────────────────────────────────────────┘
```
## Considerações de Performance
1. **N requisições simultâneas**: Pode sobrecarregar o servidor local
- Mitigação: Limitar a 8 agentes por padrão
- Ou implementar batching/semaforo
2. **Memória**: N streams simultâneas
- Mitigação: Manter só últimos N chunks visíveis
- Descartar chunks antigos da UI
3. **Tempo total**: max(tempo_agentes) + tempo_sintetizador
- Mitigação: Usar modelo mais rápido para agentes
- Modelo melhor só para sintetizador
## Próximos Passos
### Fase 1: UI e Estado
1. Criar agentStore com persistência
2. Criar AgentCountSelector component
3. Integrar no ChatView (substituir toggle)
### Fase 2: Orquestrador Paralelo
1. Criar parallel-orchestrator.ts
2. Implementar geração dinâmica de agentes
3. Promise.all com streaming simultâneo
4. Barreira de sincronia
### Fase 3: UI de Agentes
1. Criar ParallelAgentsPanel
2. Grid de cards responsivo
3. Streaming em tempo real
4. Collapse/expand de cards
### Fase 4: Sintetizador
1. Prompt do sintetizador
2. Integração no orchestrator
3. Streaming da resposta final
4. Salvamento no DB
Quer que eu comece implementando a Fase 1 (UI do seletor)?
+4337
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -20,7 +20,8 @@
"lucide-react": "^1.7.0",
"marked": "^12.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"react-dom": "^18.3.1",
"zustand": "^5.0.12"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
+32 -108
View File
@@ -1,56 +1,32 @@
//! 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,
Bonsai,
Unknown,
}
impl ModelAlias {
pub fn as_str(&self) -> &'static str {
match self {
ModelAlias::Cooper => "Cooper",
ModelAlias::Iron => "Iron",
ModelAlias::Steel => "Steel",
ModelAlias::Bonsai => "Bonsai",
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
if filename.contains("bonsai") {
ModelAlias::Bonsai
} else {
ModelAlias::Unknown
}
}
/// Result of startup model resolution.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct StartupModelResolution {
pub model_path: String,
@@ -62,18 +38,10 @@ pub struct StartupModelResolution {
#[serde(rename_all = "snake_case")]
pub enum StartupReason {
SavedPreference,
DefaultIron,
FallbackCooper,
DefaultBonsai,
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],
@@ -82,7 +50,6 @@ pub fn resolve_startup_model(
return None;
}
// Classify all discovered models
let classified: Vec<(&ModelFile, ModelAlias)> = discovered
.iter()
.map(|m| (m, resolve_alias(&m.path)))
@@ -91,9 +58,7 @@ pub fn resolve_startup_model(
// 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,
"bonsai" => ModelAlias::Bonsai,
_ => ModelAlias::Unknown,
};
if target_alias != ModelAlias::Unknown {
@@ -104,30 +69,24 @@ pub fn resolve_startup_model(
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) {
// 2. Default: Bonsai (8B)
if let Some((model, _)) = classified.iter().find(|(_, a)| *a == ModelAlias::Bonsai) {
return Some(StartupModelResolution {
model_path: model.path.clone(),
alias: ModelAlias::Iron,
reason: StartupReason::DefaultIron,
alias: ModelAlias::Bonsai,
reason: StartupReason::DefaultBonsai,
});
}
// 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
// 3. Fallback: pick the first discovered model regardless of alias
discovered.first().map(|model| StartupModelResolution {
model_path: model.path.clone(),
alias: resolve_alias(&model.path),
reason: StartupReason::DefaultBonsai,
})
}
#[cfg(test)]
@@ -143,26 +102,10 @@ mod tests {
}
#[test]
fn resolve_alias_detects_cooper_from_filename() {
fn resolve_alias_detects_bonsai_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
resolve_alias("D:/models/Bonsai-8B.gguf"),
ModelAlias::Bonsai
);
}
@@ -175,40 +118,30 @@ mod tests {
}
#[test]
fn startup_resolver_picks_iron_when_both_available() {
fn startup_resolver_picks_bonsai_when_available() {
let models = vec![
make_model("rahul7star_Qwen3.5-0.8B-Coder.gguf"),
make_model("Qwen3.5-2B.Q6_K.gguf"),
make_model("Bonsai-8B.gguf"),
make_model("some-other-model.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);
assert_eq!(result.alias, ModelAlias::Bonsai);
assert_eq!(result.reason, StartupReason::DefaultBonsai);
}
#[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);
let models = vec![make_model("Bonsai-8B.gguf")];
let result = resolve_startup_model(Some("bonsai"), &models).unwrap();
assert_eq!(result.alias, ModelAlias::Bonsai);
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");
fn startup_resolver_falls_back_to_first_model_when_no_bonsai() {
let models = vec![make_model("some-other-model.gguf")];
let result = resolve_startup_model(None, &models).unwrap();
assert_eq!(result.model_path, "D:/models/some-other-model.gguf");
assert_eq!(result.reason, StartupReason::DefaultBonsai);
}
#[test]
@@ -216,13 +149,4 @@ mod tests {
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);
}
}
+5 -3
View File
@@ -2,7 +2,8 @@ use serde::{Deserialize, Serialize};
use crate::model_library::types::RemotePreset;
pub const DEFAULT_CTX_SIZE: u32 = 4096;
pub const DEFAULT_CTX_SIZE: u32 = 131072;
pub const DEFAULT_GPU_LAYERS: i32 = -1;
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Debug)]
#[serde(rename_all = "snake_case")]
@@ -105,7 +106,7 @@ impl Default for RuntimeConfig {
model_path: None,
models_dir: None,
addr: Self::default_addr(),
gpu_layers: 0,
gpu_layers: DEFAULT_GPU_LAYERS,
ctx_size: DEFAULT_CTX_SIZE,
extra_model_paths: Vec::new(),
selected_model_id: None,
@@ -118,13 +119,14 @@ impl Default for RuntimeConfig {
#[cfg(test)]
mod tests {
use super::{ConnectionMode, RuntimeConfig, DEFAULT_CTX_SIZE};
use super::{ConnectionMode, RuntimeConfig, DEFAULT_CTX_SIZE, DEFAULT_GPU_LAYERS};
#[test]
fn runtime_config_defaults_to_safe_context_window() {
let config = RuntimeConfig::default();
assert_eq!(config.ctx_size, DEFAULT_CTX_SIZE);
assert_eq!(config.gpu_layers, DEFAULT_GPU_LAYERS);
}
#[test]
-7
View File
@@ -151,13 +151,6 @@ export default function App() {
onToggleSidebar={() => setSidebarOpen((v) => !v)}
onOpenSettings={() => setView("settings")}
onBackToChat={view === "settings" ? () => setView("chat") : undefined}
models={models}
localModels={localModels}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
onModelSwap={handleModelSwap}
isSwapping={isSwapping}
status={status}
runtimeStatus={runtime.snapshot.status}
serverConnected={serverConnected}
generating={generating}
+9 -18
View File
@@ -1,6 +1,6 @@
import type { ModelInfo, ServerStatus, ChatTokenTotals } from "../lib/types";
import type { RuntimeModelFile, RuntimeStatus } from "../features/runtime/types";
import ModelSelectorPill from "./ModelSelectorPill";
import type { ServerStatus, ChatTokenTotals } from "../lib/types";
import type { RuntimeStatus } from "../features/runtime/types";
import { Cpu } from "@phosphor-icons/react";
async function tauriMinimize() {
try { const { getCurrentWindow } = await import("@tauri-apps/api/window"); await getCurrentWindow().minimize(); } catch { /**/ }
@@ -21,13 +21,6 @@ interface Props {
onToggleSidebar: () => void;
onOpenSettings: () => void;
onBackToChat?: () => void;
models: ModelInfo[];
localModels: RuntimeModelFile[];
selectedModel: string;
onModelChange: (id: string) => void;
onModelSwap?: (path: string) => void;
isSwapping?: boolean;
status: ServerStatus;
runtimeStatus: RuntimeStatus;
serverConnected: boolean;
generating: boolean;
@@ -39,8 +32,7 @@ interface Props {
export default function AppHeader({
sidebarOpen, onToggleSidebar, onOpenSettings, onBackToChat,
models, localModels, selectedModel, onModelChange, onModelSwap, isSwapping,
status, runtimeStatus, serverConnected,
runtimeStatus, serverConnected,
generating, streamTokenCount, tps, chatTitle, chatTokenTotals,
}: Props) {
const isOnline = serverConnected && runtimeStatus === "ready";
@@ -94,16 +86,15 @@ export default function AppHeader({
</div>
</div>
{/* ── Center: model selector ── */}
{/* ── Center: model badge ── */}
<div
className="flex items-center justify-center flex-shrink-0"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<ModelSelectorPill
models={models} localModels={localModels}
selectedModel={selectedModel} onModelChange={onModelChange}
onModelSwap={onModelSwap} isSwapping={isSwapping} status={status}
/>
<div className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-white/[0.04] border border-white/[0.06] text-[11px] text-white/70">
<Cpu size={12} weight="bold" className="text-blue-400" />
<span className="font-medium">Bonsai 8B</span>
</div>
</div>
{/* ── Right ── */}
+35
View File
@@ -1608,3 +1608,38 @@
background: rgba(22, 101, 52, 0.95);
border-color: rgba(134, 239, 172, 0.28);
}
.agent-slider {
-webkit-appearance: none;
appearance: none;
height: 6px;
border-radius: 9999px;
outline: none;
cursor: pointer;
}
.agent-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #3b82f6;
border: 2px solid rgba(255, 255, 255, 0.2);
cursor: pointer;
transition: background 0.15s, transform 0.15s;
}
.agent-slider::-webkit-slider-thumb:hover {
background: #60a5fa;
transform: scale(1.15);
}
.agent-slider::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #3b82f6;
border: 2px solid rgba(255, 255, 255, 0.2);
cursor: pointer;
}
+39 -10
View File
@@ -13,8 +13,9 @@ import { devLog } from "../lib/devLogger";
import { getComposerNotice } from "./chat-view-state";
import MessageBubble from "./MessageBubble";
import StreamingBubble from "./StreamingBubble";
import { AgenticToggle } from "./forgeagent";
// agentic-detection: shouldUseAgenticMode removed from auto-trigger — agentic mode is manual-only now
import { AgentPanelInline } from "./forgeagent/AgentPanelInline";
import { useAgentStore } from "../stores/agentStore";
import { ParallelOrchestrator } from "../lib/parallel-orchestrator";
import { Image, Microphone, Paperclip, ChatCircleText, PaperPlaneRight, Database, Lightning, Plus, Monitor, Globe, Palette, Code } from "@phosphor-icons/react";
import { useMemoryExtractor } from "../hooks/useMemoryExtractor";
import { MemoryModal } from "./memory/MemoryModal";
@@ -99,7 +100,9 @@ interface Props {
const [showMemoryModal, setShowMemoryModal] = useState(false);
const [isQueueOpen, setIsQueueOpen] = useState(false);
const [debugCopied, setDebugCopied] = useState(false);
const [agenticMode, setAgenticMode] = useState(false);
const { agentCount, startReview: startAgentReview, reviewState: agentReviewState } = useAgentStore();
const isParallelMode = agentCount > 0;
const { extractMemories, buildMemorySystemPrompt } = useMemoryExtractor({
db,
@@ -336,7 +339,7 @@ interface Props {
// Auto-detection was triggering on benign messages like "como fazer um carro?"
// and injecting the ForgeAgent system prompt, causing confusing refusals on
// small Qwen3 models (0.8B, 2B, 4B).
const isAgenticRequest = agenticMode;
const isAgenticRequest = isParallelMode;
setGenerating(true); onGeneratingChange?.(true);
setStreamContent("");
@@ -348,7 +351,7 @@ interface Props {
// ── DevLogger: start interaction tracking ──
devLog.startInteraction(chatId, text, {
memoryEnabled: useMemory,
agenticMode,
agenticMode: isParallelMode,
});
const cid = fromChatId ?? getOrCreateChat(text);
@@ -590,6 +593,31 @@ interface Props {
if (persistedAssistant) {
appendPersistedAssistantMessage({ ...persistedAssistant });
if (isParallelMode && persistedAssistant.id && agentCount > 0) {
const orchestrator = new ParallelOrchestrator();
const lastUserMessage = messages
.slice()
.reverse()
.find((m) => m.role === "user");
orchestrator.execute(agentCount, {
chatId: cid,
server,
apiKey,
model,
userMessage: lastUserMessage?.content || "",
originalResponse: finalContent,
}).then((result) => {
if (result && result.synthesizedContent !== finalContent) {
db.run(
"UPDATE messages SET content = ? WHERE id = ?",
[result.synthesizedContent, persistedAssistant.id]
);
onChatUpdated();
}
}).catch(() => {});
}
} else {
appendPersistedAssistantMessage({
id: Date.now(),
@@ -700,7 +728,7 @@ interface Props {
setTimeout(() => sendRef.current(nextInQueue), 100);
}
}
}, [generating, model, server, apiKey, useStream, db, getOrCreateChat, ensureChatTitle, buildApiMessages, onChatUpdated, agenticMode, appendPersistedAssistantMessage]);
}, [generating, model, server, apiKey, useStream, db, getOrCreateChat, ensureChatTitle, buildApiMessages, onChatUpdated, appendPersistedAssistantMessage]);
// Keep sendRef always pointing to latest send closure
sendRef.current = send;
@@ -750,7 +778,6 @@ interface Props {
apiKeyPresent: Boolean(apiKey),
memoryEnabled: useMemory,
streamEnabled: useStream,
agenticMode,
savedMemories,
latestInteraction,
runtimeMode,
@@ -760,7 +787,7 @@ interface Props {
await navigator.clipboard.writeText(transcript);
setDebugCopied(true);
window.setTimeout(() => setDebugCopied(false), 2000);
}, [apiKey, chatId, db, messages, model, server, useMemory, useStream, agenticMode]);
}, [apiKey, chatId, db, messages, model, server, useMemory, useStream, isParallelMode]);
const handleEditAndResend = (msg: Message, newContent: string) => {
db.run("UPDATE messages SET content = ? WHERE id = ?", [newContent, msg.id]);
@@ -896,7 +923,7 @@ interface Props {
</div>
<div className="flex items-center gap-4 self-end sm:self-auto">
<AgenticToggle isActive={agenticMode} onToggle={() => setAgenticMode((value) => !value)} disabled={Boolean(composerNotice)} />
<AgentPanelInline />
{generating ? (
<button className="inline-flex h-10 items-center rounded-2xl border border-white/12 bg-white/[0.05] px-4 text-[13px] font-medium text-white/80 transition hover:bg-white/[0.08]" onClick={handleStop} type="button">
■ Stop
@@ -985,6 +1012,8 @@ interface Props {
</div>
</div>
<AgentPanelInline />
{/* ── Fixed Input (Manus-style) ── */}
{!isInitialScreen && (
<div className="absolute bottom-0 left-0 w-full flex flex-col items-center z-50 pointer-events-none"
@@ -1080,7 +1109,7 @@ interface Props {
{debugCopied ? 'Copied debug' : 'Copy debug'}
</button>
)}
<AgenticToggle isActive={agenticMode} onToggle={() => setAgenticMode((value) => !value)} disabled={Boolean(composerNotice)} />
<AgentPanelInline />
<button type="button" disabled title="Voice (coming soon)" className="w-8 h-8 flex items-center justify-center rounded-full text-white/25 cursor-not-allowed"><Microphone size={16} weight="duotone" /></button>
{generating ? (
<button type="button" onClick={handleStop} aria-label="Stop"
+16 -2
View File
@@ -1,6 +1,8 @@
import { useState, useRef, useEffect } from "react";
import type { Message } from "../lib/types";
import type { ReviewCycle } from "../lib/specialist-definitions";
import { sanitizeMessageText } from "../lib/messageSanitizer";
import { ReviewProgress } from "./forgeagent/ReviewProgress";
import "./MessageBubble.css";
interface Props {
@@ -10,9 +12,10 @@ interface Props {
onEditAssistant?: (id: number, newContent: string) => void;
onResend?: (msg: Message) => void;
renderMarkdown: (text: string) => string;
reviewCycle?: ReviewCycle | null;
}
export default function MessageBubble({ message, onDelete, onEditAndResend, onEditAssistant, onResend, renderMarkdown }: Props) {
export default function MessageBubble({ message, onDelete, onEditAndResend, onEditAssistant, onResend, renderMarkdown, reviewCycle }: Props) {
const sanitizedMessageContent = sanitizeMessageText(message.content).text;
const sanitizedReasoningContent = sanitizeMessageText(message.reasoning_content || "").text;
const [editing, setEditing] = useState(false);
@@ -186,9 +189,20 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
<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}
<ReviewProgress
cycle={reviewCycle ?? null}
isReviewing={reviewCycle?.status === "running"}
/>
</div>
)}
{!hasUsage && (
<div>
<ReviewProgress
cycle={reviewCycle ?? null}
isReviewing={reviewCycle?.status === "running"}
/>
</div>
)}
{!hasUsage && <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">
@@ -0,0 +1,65 @@
import { Users } from "@phosphor-icons/react";
import { useAgentStore } from "../../stores/agentStore";
export function AgentCountSelector() {
const { agentCount, setAgentCount } = useAgentStore();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value, 10);
setAgentCount(value);
};
const getLabel = (count: number): string => {
if (count === 0) return "Single Agent";
if (count === 1) return "1 Agent";
return `${count} Agents`;
};
const getDescription = (count: number): string => {
if (count === 0) return "Standard mode - single response";
if (count <= 4) return "Small swarm - focused collaboration";
if (count <= 8) return "Medium swarm - balanced analysis";
return "Large swarm - comprehensive review";
};
return (
<div className="flex flex-col gap-2 p-3 rounded-xl border border-white/10 bg-white/[0.03]">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Users size={16} className="text-blue-400" />
<span className="text-sm font-medium text-white/90">
{getLabel(agentCount)}
</span>
</div>
<span className="text-xs text-white/50">
{getDescription(agentCount)}
</span>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-white/40 w-8">Off</span>
<input
type="range"
min="0"
max="16"
step="1"
value={agentCount}
onChange={handleChange}
className="flex-1 h-2 bg-white/10 rounded-lg appearance-none cursor-pointer slider-thumb"
style={{
background: `linear-gradient(to right, rgb(59, 130, 246) 0%, rgb(59, 130, 246) ${(agentCount / 16) * 100}%, rgba(255,255,255,0.1) ${(agentCount / 16) * 100}%, rgba(255,255,255,0.1) 100%)`,
}}
/>
<span className="text-xs text-white/40 w-8">16</span>
</div>
<div className="flex justify-between text-[10px] text-white/30">
<span>0</span>
<span>4</span>
<span>8</span>
<span>12</span>
<span>16</span>
</div>
</div>
);
}
@@ -0,0 +1,319 @@
import { useState } from "react";
import {
Users,
CheckCircle,
XCircle,
CircleNotch,
Sparkle,
CaretDown,
DotsThree,
} from "@phosphor-icons/react";
import { useAgentStore, type ParallelAgent } from "../../stores/agentStore";
function CollapsedPill({
agentCount,
hasActiveReview,
isExpanded,
onToggle,
}: {
agentCount: number;
hasActiveReview: boolean;
isExpanded: boolean;
onToggle: () => void;
}) {
const label =
agentCount === 0 ? "Agents off" : agentCount === 1 ? "1 agent" : `${agentCount} agents`;
const isActive = agentCount > 0;
return (
<button
type="button"
onClick={onToggle}
className={[
"inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-[12px] font-medium transition-all duration-150",
isActive
? "border-blue-500/40 bg-blue-500/10 text-blue-300 hover:border-blue-500/60 hover:bg-blue-500/15 hover:text-blue-200"
: "border-white/[0.08] bg-white/[0.04] text-white/50 hover:border-white/[0.14] hover:bg-white/[0.07] hover:text-white/75",
].join(" ")}
>
<Users size={13} className={isActive ? "text-blue-400" : "text-white/35"} />
<span className="tracking-tight">{label}</span>
{hasActiveReview && (
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-60" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-blue-400" />
</span>
)}
<CaretDown
size={11}
className={[
"opacity-60 transition-transform duration-150",
isExpanded ? "rotate-180" : "",
].join(" ")}
/>
</button>
);
}
function AgentSlider() {
const { agentCount, setAgentCount } = useAgentStore();
const getDescription = (count: number): string => {
if (count === 0) return "Single response";
if (count <= 4) return "Small swarm";
if (count <= 8) return "Medium swarm";
return "Large swarm";
};
const getMode = (count: number): string => {
if (count === 0) return "Off";
if (count === 1) return "1 Agent";
return `${count} Agents`;
};
const fillPct = (agentCount / 16) * 100;
return (
<div className="flex flex-col gap-3">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div
className={[
"flex h-6 w-6 items-center justify-center rounded-md",
agentCount > 0 ? "bg-blue-500/15" : "bg-white/[0.05]",
].join(" ")}
>
<Users size={13} className={agentCount > 0 ? "text-blue-400" : "text-white/35"} />
</div>
<span className="text-[13px] font-semibold tracking-tight text-white/90">
{getMode(agentCount)}
</span>
</div>
<span
className={[
"rounded-full px-2 py-0.5 text-[10px] font-medium tracking-wide",
agentCount > 0
? "bg-blue-500/10 text-blue-400/80"
: "bg-white/[0.05] text-white/35",
].join(" ")}
>
{getDescription(agentCount)}
</span>
</div>
{/* Slider track */}
<div className="px-0.5">
<input
type="range"
min="0"
max="16"
step="1"
value={agentCount}
onChange={(e) => setAgentCount(parseInt(e.target.value, 10))}
className="agent-slider w-full"
style={{
background: `linear-gradient(to right, rgb(59,130,246) 0%, rgb(96,165,250) ${fillPct}%, rgba(255,255,255,0.07) ${fillPct}%, rgba(255,255,255,0.07) 100%)`,
}}
/>
</div>
{/* Scale labels */}
<div className="flex justify-between px-0.5">
{[0, 4, 8, 12, 16].map((n) => (
<span
key={n}
className={[
"text-[10px] tabular-nums transition-colors",
n === 0
? "text-white/25"
: agentCount >= n
? "text-blue-400/60"
: "text-white/25",
].join(" ")}
>
{n === 0 ? "off" : n}
</span>
))}
</div>
</div>
);
}
function MiniAgentCard({ agent, index }: { agent: ParallelAgent; index: number }) {
const accents = [
{ bar: "bg-blue-500/50", border: "border-blue-500/20", bg: "bg-blue-500/[0.06]", dot: "bg-blue-400" },
{ bar: "bg-purple-500/50", border: "border-purple-500/20", bg: "bg-purple-500/[0.06]", dot: "bg-purple-400" },
{ bar: "bg-emerald-500/50", border: "border-emerald-500/20", bg: "bg-emerald-500/[0.06]", dot: "bg-emerald-400" },
{ bar: "bg-amber-500/50", border: "border-amber-500/20", bg: "bg-amber-500/[0.06]", dot: "bg-amber-400" },
{ bar: "bg-rose-500/50", border: "border-rose-500/20", bg: "bg-rose-500/[0.06]", dot: "bg-rose-400" },
{ bar: "bg-indigo-500/50", border: "border-indigo-500/20", bg: "bg-indigo-500/[0.06]", dot: "bg-indigo-400" },
{ bar: "bg-cyan-500/50", border: "border-cyan-500/20", bg: "bg-cyan-500/[0.06]", dot: "bg-cyan-400" },
{ bar: "bg-lime-500/50", border: "border-lime-500/20", bg: "bg-lime-500/[0.06]", dot: "bg-lime-400" },
];
const accent = accents[index % accents.length];
const statusIcon = (() => {
switch (agent.status) {
case "streaming":
return <CircleNotch size={12} className="animate-spin text-blue-400" />;
case "completed":
return <CheckCircle size={12} weight="fill" className="text-emerald-400" />;
case "error":
return <XCircle size={12} weight="fill" className="text-rose-400" />;
default:
return <span className={`h-1.5 w-1.5 rounded-full opacity-40 ${accent.dot}`} />;
}
})();
const [expanded, setExpanded] = useState(false);
return (
<div
className={[
"overflow-hidden rounded-lg border transition-all duration-150",
accent.border,
accent.bg,
agent.content ? "cursor-pointer hover:brightness-110" : "",
].join(" ")}
onClick={() => agent.content && setExpanded(!expanded)}
>
<div className="flex items-center gap-2 px-2.5 py-2">
<div className={`h-3.5 w-0.5 shrink-0 rounded-full ${accent.bar}`} />
<span className="flex-1 truncate text-[12px] font-medium tracking-tight text-white/75">
{agent.name}
</span>
{agent.tokensPerSecond && agent.status === "streaming" && (
<span className="shrink-0 rounded bg-white/[0.05] px-1.5 py-0.5 font-mono text-[9px] tabular-nums text-white/35">
{agent.tokensPerSecond.toFixed(1)} t/s
</span>
)}
<span className="shrink-0">{statusIcon}</span>
</div>
{expanded && agent.content && (
<div className="border-t border-white/[0.05] px-2.5 py-2">
<p className="max-h-28 overflow-y-auto whitespace-pre-wrap text-[11px] leading-relaxed text-white/50">
{agent.content.slice(0, 400)}
{agent.content.length > 400 && <span className="text-white/25">…</span>}
</p>
</div>
)}
</div>
);
}
function SynthesizerRow({ status }: { status: string }) {
const isSynthesizing = status === "thinking" || status === "streaming";
const isComplete = status === "completed";
return (
<div
className={[
"flex items-center gap-2.5 rounded-xl border px-3 py-2.5",
isComplete
? "border-emerald-500/25 bg-emerald-500/[0.08]"
: "border-amber-500/25 bg-gradient-to-r from-amber-500/[0.1] to-amber-600/[0.05]",
].join(" ")}
>
<div
className={[
"flex h-5 w-5 shrink-0 items-center justify-center rounded-md",
isComplete ? "bg-emerald-500/15" : "bg-amber-500/15",
].join(" ")}
>
{isSynthesizing ? (
<CircleNotch size={12} className="animate-spin text-amber-400" />
) : isComplete ? (
<CheckCircle size={12} weight="fill" className="text-emerald-400" />
) : (
<Sparkle size={12} weight="fill" className="text-amber-400" />
)}
</div>
<div className="flex min-w-0 flex-col gap-0.5">
<span
className={[
"text-[11px] font-semibold tracking-tight",
isComplete ? "text-emerald-400/90" : "text-amber-400/90",
].join(" ")}
>
Synthesizer
</span>
<span className="truncate text-[10px] text-white/40">
{isSynthesizing
? "Combining agent responses…"
: isComplete
? "Synthesis complete"
: "Waiting for agents"}
</span>
</div>
</div>
);
}
export function AgentPanelInline() {
const { agentCount, reviewState } = useAgentStore();
const [isExpanded, setIsExpanded] = useState(false);
const hasActiveReview = reviewState !== null;
const agents = reviewState?.agents ?? [];
const synthesizerStatus = reviewState?.synthesizerStatus ?? "idle";
const completedCount = agents.filter(
(a) => a.status === "completed" || a.status === "error",
).length;
return (
<div className="flex flex-col items-start gap-1.5">
<CollapsedPill
agentCount={agentCount}
hasActiveReview={hasActiveReview}
isExpanded={isExpanded}
onToggle={() => setIsExpanded((v) => !v)}
/>
{isExpanded && (
<div className="w-72 rounded-2xl border border-white/[0.08] bg-neutral-950/90 p-4 shadow-[0_8px_32px_rgba(0,0,0,0.5),0_0_0_1px_rgba(255,255,255,0.03)] backdrop-blur-xl">
<AgentSlider />
{agents.length > 0 && (
<div className="mt-4 space-y-1.5 border-t border-white/[0.06] pt-4">
<div className="mb-2 flex items-center justify-between">
<span className="text-[10px] font-semibold uppercase tracking-widest text-white/30">
Live agents
</span>
{hasActiveReview && (
<span className="rounded-full bg-white/[0.05] px-2 py-0.5 text-[10px] tabular-nums text-white/40">
{completedCount} / {agents.length}
</span>
)}
</div>
<div className="max-h-48 space-y-1.5 overflow-y-auto pr-0.5">
{agents.map((agent, i) => (
<MiniAgentCard key={agent.id} agent={agent} index={i} />
))}
</div>
{(synthesizerStatus === "thinking" ||
synthesizerStatus === "streaming" ||
synthesizerStatus === "completed") && (
<div className="mt-2">
<SynthesizerRow status={synthesizerStatus} />
</div>
)}
</div>
)}
{agentCount > 0 && agents.length === 0 && (
<div className="mt-4 flex items-center gap-2.5 rounded-xl border border-white/[0.06] bg-white/[0.02] px-3 py-2.5">
<DotsThree size={15} className="shrink-0 text-white/25" />
<span className="text-[11px] leading-snug text-white/35">
Agents will activate on your next message
</span>
</div>
)}
</div>
)}
</div>
);
}
export default AgentPanelInline;
@@ -0,0 +1,176 @@
import { useState } from "react";
import { useAgentStore, type ParallelAgent } from "../../stores/agentStore";
import {
Brain,
CheckCircle,
XCircle,
CircleNotch,
Sparkle,
Robot,
} from "@phosphor-icons/react";
interface AgentCardProps {
agent: ParallelAgent;
index: number;
}
function AgentCard({ agent, index }: AgentCardProps) {
const colors = [
"from-blue-500/20 to-cyan-500/20 border-blue-500/30",
"from-purple-500/20 to-pink-500/20 border-purple-500/30",
"from-emerald-500/20 to-teal-500/20 border-emerald-500/30",
"from-amber-500/20 to-orange-500/20 border-amber-500/30",
"from-rose-500/20 to-red-500/20 border-rose-500/30",
"from-indigo-500/20 to-violet-500/20 border-indigo-500/30",
"from-cyan-500/20 to-sky-500/20 border-cyan-500/30",
"from-lime-500/20 to-green-500/20 border-lime-500/30",
];
const colorClass = colors[index % colors.length];
const getStatusIcon = () => {
switch (agent.status) {
case "streaming":
return <CircleNotch size={16} className="animate-spin text-blue-400" />;
case "completed":
return <CheckCircle size={16} className="text-emerald-400" />;
case "error":
return <XCircle size={16} className="text-rose-400" />;
default:
return <Sparkle size={16} className="text-white/40" />;
}
};
const [isExpanded, setIsExpanded] = useState(false);
return (
<div
className={`relative overflow-hidden rounded-xl border bg-gradient-to-br ${colorClass} backdrop-blur-sm transition-all duration-300 hover:scale-[1.02] cursor-pointer ${
agent.status === "streaming" ? "ring-2 ring-blue-500/50" : ""
}`}
onClick={() => agent.content && setIsExpanded(!isExpanded)}
>
<div className="p-4">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-white/10">
<Robot size={18} className="text-white/80" />
</div>
<div>
<h4 className="text-sm font-medium text-white/90">
{agent.name}
</h4>
<p className="text-xs text-white/50">{agent.role}</p>
</div>
</div>
<div className="flex items-center gap-1.5">
{agent.tokensPerSecond && agent.status === "streaming" && (
<span className="text-[10px] tabular-nums text-white/40">
{agent.tokensPerSecond.toFixed(1)} t/s
</span>
)}
{getStatusIcon()}
</div>
</div>
{agent.status === "streaming" && (
<div className="mt-3">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-white/10">
<div className="h-full animate-pulse rounded-full bg-gradient-to-r from-blue-400 to-cyan-400" />
</div>
</div>
)}
{isExpanded && agent.content && (
<div className="mt-3 border-t border-white/10 pt-3">
<div className="max-h-48 overflow-y-auto">
<p className="whitespace-pre-wrap text-xs leading-relaxed text-white/70">
{agent.content.slice(0, 500)}
{agent.content.length > 500 && (
<span className="text-white/40">...</span>
)}
</p>
</div>
</div>
)}
{!isExpanded && agent.content && agent.status !== "streaming" && (
<div className="mt-2">
<p className="line-clamp-2 text-xs text-white/50">
{agent.content.slice(0, 100)}...
</p>
</div>
)}
</div>
</div>
);
}
interface ParallelAgentsPanelProps {
isVisible: boolean;
}
export function ParallelAgentsPanel({ isVisible }: ParallelAgentsPanelProps) {
const { agentCount, reviewState } = useAgentStore();
if (!isVisible || agentCount === 0 || !reviewState) return null;
const { agents, synthesizerStatus, allAgentsCompleted } = reviewState;
const completedCount = agents.filter(
(a: ParallelAgent) => a.status === "completed" || a.status === "error"
).length;
const isSynthesizing = synthesizerStatus === "thinking" || synthesizerStatus === "streaming";
const isSynthesisComplete = synthesizerStatus === "completed";
return (
<div className="fixed right-4 top-20 z-40 w-80 max-h-[calc(100vh-120px)] overflow-hidden rounded-2xl border border-white/10 bg-[#0f0f11]/95 shadow-2xl backdrop-blur-xl">
<div className="border-b border-white/10 bg-white/5 px-4 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Brain size={18} className="text-blue-400" />
<h3 className="text-sm font-semibold text-white/90">
Parallel Agents
</h3>
</div>
<span className="text-xs text-white/50">
{completedCount}/{agents.length} done
</span>
</div>
</div>
<div className="max-h-[calc(100vh-200px)] overflow-y-auto p-3">
<div className="grid gap-2">
{agents.map((agent: ParallelAgent, index: number) => (
<AgentCard key={agent.id} agent={agent} index={index} />
))}
</div>
{(isSynthesizing || isSynthesisComplete) && (
<div className="mt-3 border-t border-white/10 pt-3">
<div
className={`rounded-xl border bg-gradient-to-r from-amber-500/20 to-orange-500/20 border-amber-500/30 p-4 ${
isSynthesizing ? "ring-2 ring-amber-500/50" : ""
}`}
>
<div className="flex items-center gap-2">
<Sparkle
size={18}
className={
isSynthesizing ? "animate-pulse text-amber-400" : "text-emerald-400"
}
/>
<span className="text-sm font-medium text-white/90">
{isSynthesizing ? "Synthesizing..." : "Synthesis Complete"}
</span>
</div>
</div>
</div>
)}
</div>
</div>
);
}
export default ParallelAgentsPanel;
@@ -0,0 +1,174 @@
import { Sparkle, Check, X } from "@phosphor-icons/react";
import type { ReviewCycle } from "../../lib/specialist-definitions";
import { DEFAULT_SPECIALISTS } from "../../lib/specialist-definitions";
interface ReviewProgressProps {
cycle: ReviewCycle | null;
isReviewing: boolean;
}
export function ReviewProgress({ cycle, isReviewing }: ReviewProgressProps) {
if (!isReviewing && (!cycle || cycle.status === "completed")) {
return null;
}
const completedCount = cycle?.specialists.filter(
(s) => s.status === "completed"
).length ?? 0;
const totalCount = cycle?.specialists.length ?? 0;
if (cycle?.status === "completed") {
return (
<div className="inline-flex items-center gap-1.5 text-[11px] text-emerald-400/80">
<Check size={12} weight="bold" />
<span>Improved</span>
</div>
);
}
return (
<div className="inline-flex items-center gap-2">
<div className="inline-flex items-center gap-1.5 text-[11px] text-blue-300/80">
<Sparkle size={12} className="animate-pulse" />
<span>
Improving {completedCount}/{totalCount}
</span>
</div>
{cycle && (
<div className="flex gap-0.5">
{cycle.specialists.map((specialist) => {
const def = DEFAULT_SPECIALISTS.find(
(s) => s.id === specialist.specialistId
);
return (
<div
key={specialist.specialistId}
className={`w-1.5 h-1.5 rounded-full ${
specialist.status === "completed"
? "bg-emerald-400"
: specialist.status === "thinking"
? "bg-blue-400 animate-pulse"
: specialist.status === "error"
? "bg-red-400"
: "bg-white/20"
}`}
title={def?.title || specialist.specialistId}
/>
);
})}
</div>
)}
</div>
);
}
interface ReviewDetailsProps {
cycle: ReviewCycle | null;
isOpen: boolean;
onClose: () => void;
}
export function ReviewDetails({ cycle, isOpen, onClose }: ReviewDetailsProps) {
if (!isOpen || !cycle) return null;
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm px-4"
onClick={onClose}
>
<div
className="w-full max-w-xl max-h-[80vh] overflow-hidden rounded-xl border border-white/10 bg-[#1b1b1f] shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10">
<h3 className="text-sm font-medium text-white/90">
Review Details
</h3>
<button
onClick={onClose}
className="w-8 h-8 flex items-center justify-center rounded-lg text-white/40 hover:text-white/80 hover:bg-white/5 transition-colors"
>
<X size={16} />
</button>
</div>
<div className="overflow-y-auto max-h-[calc(80vh-60px)] p-4 space-y-4">
{cycle.specialists.map((specialist) => {
const def = DEFAULT_SPECIALISTS.find(
(s) => s.id === specialist.specialistId
);
return (
<div
key={specialist.specialistId}
className="rounded-lg border border-white/10 bg-white/[0.03] p-3"
>
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-medium text-white/80">
{def?.title || specialist.specialistId}
</span>
<span
className={`text-[10px] px-1.5 py-0.5 rounded ${
specialist.status === "completed"
? "bg-emerald-500/20 text-emerald-300"
: specialist.status === "error"
? "bg-red-500/20 text-red-300"
: "bg-white/10 text-white/50"
}`}
>
{specialist.status}
</span>
</div>
{specialist.thinking && (
<p className="text-[11px] text-white/50 mb-2 leading-relaxed">
{specialist.thinking}
</p>
)}
{specialist.suggestions.length > 0 && (
<div className="space-y-1.5">
{specialist.suggestions.map((suggestion, idx) => (
<div
key={idx}
className="text-[10px] p-2 rounded bg-white/[0.04]"
>
<div className="flex items-center gap-1.5 mb-1">
<span
className={`px-1 rounded ${
suggestion.type === "improvement"
? "bg-blue-500/20 text-blue-300"
: suggestion.type === "correction"
? "bg-amber-500/20 text-amber-300"
: "bg-white/10 text-white/50"
}`}
>
{suggestion.type}
</span>
<span className="text-white/40">
{Math.round(suggestion.confidence * 100)}%
</span>
</div>
<p className="text-white/60">{suggestion.explanation}</p>
</div>
))}
</div>
)}
</div>
);
})}
<div className="pt-2 border-t border-white/10">
<div className="text-[10px] text-white/40">
Original: {cycle.originalContent.length} chars
<br />
Final: {cycle.finalContent.length} chars
</div>
</div>
</div>
</div>
</div>
);
}
+8 -3
View File
@@ -8,8 +8,13 @@
* Philosophy: less configuration → fewer loops, fewer artifacts.
*/
// Maximum context window sent to the model (tokens)
export const AI_CONTEXT_LENGTH = 32000;
// Bonsai-8B.gguf — 1-bit TurboQuant model
// The sole model for ForgeLM Studio.
// High context for main chat (128k tokens — 1-bit quant handles it efficiently)
export const AI_CONTEXT_LENGTH = 131072;
// Smaller context for parallel agents (focused prompts, no need for full window)
export const AI_AGENT_CONTEXT_LENGTH = 4096;
// How many messages from history to include in each request
export const AI_MAX_HISTORY_MESSAGES = 20;
@@ -19,4 +24,4 @@ export const AI_STREAM_DEFAULT = true;
// ── DO NOT ADD SYSTEM PROMPTS, STOP SEQUENCES, OR RESPONSE FORMAT INSTRUCTIONS HERE ──
// ── Any modification to how the AI responds should be done in prompts/system.ts ──
// ── and explicitly opted-in — NOT applied globally. ──
// ── and explicitly opted-in — NOT applied globally. ──
@@ -1,3 +1,4 @@
export const MIN_CTX_SIZE = 512;
export const DEFAULT_CTX_SIZE = 4096;
export const DEFAULT_CTX_SIZE = 131072;
export const DEFAULT_GPU_LAYERS = -1;
export const CTX_PRESETS = [2048, 4096, 8192, 16384, 32768, 65536, 131072];
@@ -13,7 +13,7 @@ import type {
RuntimeWorkspaceSummary,
} from "./types";
import { scanRuntimeModels } from "./api";
import { DEFAULT_CTX_SIZE } from "./runtime-defaults";
import { DEFAULT_CTX_SIZE, DEFAULT_GPU_LAYERS } from "./runtime-defaults";
const DEFAULT_LOCAL_URL = "http://127.0.0.1:8080";
@@ -304,13 +304,13 @@ export function createDefaultRuntimeSnapshot(): RuntimeSnapshot {
last_command_result: createRuntimeLifecycleResult(),
config: {
connection_mode: "local",
start_policy: "manual",
start_policy: "on_app_launch",
server_url: "",
api_key: "",
model_path: null,
models_dir: null,
addr: "127.0.0.1:8080",
gpu_layers: 0,
gpu_layers: DEFAULT_GPU_LAYERS,
ctx_size: DEFAULT_CTX_SIZE,
extra_model_paths: [],
selected_model_id: null,
+9 -25
View File
@@ -1,22 +1,14 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useCallback } 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;
import { BONSAI_MODEL_ID } from "../lib/modelAlias";
export function useModels(server: string, apiKey: string, enabled = true) {
const [models, setModels] = useState<ModelInfo[]>([]);
const [selectedModel, setSelectedModelState] = useState<string>("");
const [selectedModel, setSelectedModelState] = useState<string>(BONSAI_MODEL_ID);
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);
}, []);
@@ -38,24 +30,16 @@ export function useModels(server: string, apiKey: string, enabled = true) {
const data = await res.json();
const list: ModelInfo[] = data.data || [];
setModels(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)) {
if (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;
const bonsai = list.find((m) => m.id.toLowerCase().includes('bonsai'));
if (bonsai) return bonsai.id;
return getNextSelectedModel(current, list);
});
setStatus("ok");
} catch {
setModels([]);
@@ -70,4 +54,4 @@ export function useModels(server: string, apiKey: string, enabled = true) {
}, [refresh]);
return { models, selectedModel, setSelectedModel, status, refresh };
}
}
@@ -1,6 +1,5 @@
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 {
@@ -20,16 +19,13 @@ interface SessionModelController {
export function useSessionModelController({
preferredModel,
onSwitchToCooper,
localModels,
localModels: _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;
@@ -49,30 +45,17 @@ export function useSessionModelController({
}, [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
detail: 'TPS dropped below 30 for 5s. The model may be struggling with the current workload.',
duration: 8000,
});
}, [overrideSource, degradationPromptShown, localModels, showToast, setUserOverride, onSwitchToCooper]);
}, [overrideSource, degradationPromptShown, showToast]);
return {
effectiveModel,
@@ -82,4 +65,4 @@ export function useSessionModelController({
onDegradationDetected,
degradationPromptShown,
};
}
}
@@ -0,0 +1,68 @@
import { useCallback, useRef, useState } from "react";
import type { ReviewCycle } from "../lib/specialist-definitions";
import { ReviewOrchestrator, type ReviewContext } from "../lib/review-orchestrator";
export function useSpecialistReview() {
const orchestratorRef = useRef<ReviewOrchestrator | null>(null);
const [reviewCycle, setReviewCycle] = useState<ReviewCycle | null>(null);
const [isReviewing, setIsReviewing] = useState(false);
const startReview = useCallback(
async (
messageId: number,
content: string,
context: ReviewContext
): Promise<ReviewCycle | null> => {
if (orchestratorRef.current) {
orchestratorRef.current.abort();
}
orchestratorRef.current = new ReviewOrchestrator();
setIsReviewing(true);
try {
const cycle = await orchestratorRef.current.startReview(
messageId,
content,
context,
(progress) => {
setReviewCycle({ ...progress });
}
);
setReviewCycle(cycle);
return cycle;
} catch (error) {
console.error("Review failed:", error);
return null;
} finally {
setIsReviewing(false);
}
},
[]
);
const abortReview = useCallback(() => {
orchestratorRef.current?.abort();
orchestratorRef.current = null;
setIsReviewing(false);
}, []);
const completedCount = reviewCycle?.specialists.filter(
(s) => s.status === "completed" || s.status === "error"
).length ?? 0;
const totalCount = reviewCycle?.specialists.length ?? 0;
return {
reviewCycle,
isReviewing,
startReview,
abortReview,
progress: {
completed: completedCount,
total: totalCount,
percentage: totalCount > 0 ? (completedCount / totalCount) * 100 : 0,
},
};
}
+37 -50
View File
@@ -1,76 +1,63 @@
export type ModelAlias = 'Copper' | 'Iron' | 'Steel' | 'Unknown';
/**
* Model Alias System — Bonsai-8B.gguf Edition
*
* ForgeLM Studio uses a single model: Bonsai-8B.gguf (1-bit TurboQuant).
* The alias system is simplified to reflect this.
*/
export type ModelAlias = 'Bonsai';
/** The canonical filename for the ForgeLM model */
export const BONSAI_MODEL_ID = 'Bonsai-8B.gguf';
/**
* Resolves model alias from filename or path
* Matches: Copper (0.8B), Iron (2B), Steel (4B)
* Resolves model alias from filename or path.
* Always returns 'Bonsai' since it's our only model.
*/
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';
export function resolveAlias(_modelIdOrPath: string): ModelAlias {
return 'Bonsai';
}
/**
* Gets clean display name for model selector
* Returns: Copper, Iron, Steel (never full filename)
* Gets clean display name for model.
* Returns: Bonsai (always)
*/
export function getDisplayName(modelIdOrPath: string): string {
const alias = resolveAlias(modelIdOrPath);
if (alias !== 'Unknown') return alias;
// For unknown models, extract a clean name from filename
if (modelIdOrPath === 'Bonsai' || modelIdOrPath === BONSAI_MODEL_ID) {
return 'Bonsai';
}
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(/-Q\d+_[KML]+$/i, '');
clean = clean.replace(/-GGUF$/i, '');
if (clean.toLowerCase().includes('bonsai')) {
return 'Bonsai';
}
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
* Returns true — all models are valid selectors since we only have Bonsai.
*/
export function isInChatSelector(modelIdOrPath: string): boolean {
const alias = resolveAlias(modelIdOrPath);
return alias === 'Copper' || alias === 'Iron' || alias === 'Steel';
export function isInChatSelector(_modelIdOrPath: string): boolean {
return true;
}
/**
* Gets the ForgeMetal enum value for a model
* Used internally for model management
* Gets the ForgeMetal value — always 'Bonsai'.
* Kept for backward compatibility.
*/
export function getForgeMetal(modelIdOrPath: string): 'Copper' | 'Iron' | 'Steel' | null {
const alias = resolveAlias(modelIdOrPath);
if (alias === 'Unknown') return null;
return alias;
export function getForgeMetal(_modelIdOrPath: string): 'Bonsai' | null {
return 'Bonsai';
}
/**
* 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';
}
}
export function getModelDescription(_alias?: ModelAlias): string {
return 'Bonsai (8B) — 1-bit TurboQuant, high-context local model';
}
+276
View File
@@ -0,0 +1,276 @@
import type { ParallelAgent } from "../stores/agentStore";
import { generateAgentRoles } from "../stores/agentStore";
import { AI_AGENT_CONTEXT_LENGTH } from "../config/ai";
export interface ParallelContext {
chatId: number;
server: string;
apiKey: string;
model: string;
userMessage: string;
originalResponse: string;
}
export interface AgentResult {
agentId: string;
content: string;
tokensPerSecond: number | null;
error?: string;
}
const AGENT_SYSTEM_PROMPT = `You are one of multiple AI agents collaborating to improve a response. Your specific role is described below.
ROLE: {ROLE}
INSTRUCTIONS:
1. Review the original user question and the assistant's response
2. From YOUR SPECIFIC ROLE's perspective, identify improvements
3. Provide detailed feedback and a refined version of the response
4. Focus on your area of expertise - don't try to cover everything
5. Be constructive and specific in your recommendations
OUTPUT FORMAT:
Provide your analysis and improved response. Focus on actionable improvements from your role's perspective.`;
const SYNTHESIZER_PROMPT = `You are a synthesis expert. Your job is to combine multiple agent perspectives into a single, optimized response.
INSTRUCTIONS:
1. Review all agent responses below
2. Extract the best insights from each perspective
3. Consolidate them into ONE cohesive, improved response
4. Maintain the original response's helpfulness while incorporating improvements
5. Ensure the final response is clear, accurate, and well-structured
AGENT RESPONSES:
{AGENT_RESPONSES}
SYNTHESIZE into a single optimal response:`;
export class ParallelOrchestrator {
private abortController: AbortController | null = null;
abort(): void {
this.abortController?.abort();
}
async runParallelAgents(
agentCount: number,
context: ParallelContext,
onAgentUpdate: (agentId: string, content: string, tps?: number) => void,
onAgentComplete: (agentId: string) => void,
onAgentError: (agentId: string, error: string) => void
): Promise<AgentResult[]> {
this.abortController = new AbortController();
const signal = this.abortController.signal;
const roles = generateAgentRoles(agentCount);
const agentPromises = roles.map((role, index) =>
this.runSingleAgent(
`agent-${index}`,
role.name,
role.role,
context,
signal,
onAgentUpdate,
onAgentComplete,
onAgentError
)
);
const results = await Promise.all(agentPromises);
return results;
}
private async runSingleAgent(
agentId: string,
name: string,
role: string,
context: ParallelContext,
signal: AbortSignal,
onUpdate: (agentId: string, content: string, tps?: number) => void,
onComplete: (agentId: string) => void,
onError: (agentId: string, error: string) => void
): Promise<AgentResult> {
const systemPrompt = AGENT_SYSTEM_PROMPT.replace("{ROLE}", `${name}: ${role}`);
const userPrompt = `Original user question: ${context.userMessage}
Original assistant response: ${context.originalResponse}
Provide your analysis and improved response from your perspective as ${name}:`;
try {
const response = await fetch(`${context.server}/v1/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${context.apiKey}`,
},
body: JSON.stringify({
model: context.model,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
temperature: 0.4,
max_tokens: 2000,
num_ctx: AI_AGENT_CONTEXT_LENGTH,
stream: true,
}),
signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error("No response body");
}
let content = "";
let tokensPerSecond: number | null = null;
const decoder = new TextDecoder();
const startTime = Date.now();
let tokenCount = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split("\n").filter((line) => line.trim());
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") continue;
try {
const parsed = JSON.parse(data);
const delta = parsed.choices?.[0]?.delta?.content;
if (delta) {
content += delta;
tokenCount++;
const elapsed = (Date.now() - startTime) / 1000;
if (elapsed > 0) {
tokensPerSecond = tokenCount / elapsed;
}
onUpdate(agentId, content, tokensPerSecond ?? undefined);
}
} catch {
// Ignore malformed JSON in stream
}
}
}
}
onComplete(agentId);
return { agentId, content, tokensPerSecond };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
onError(agentId, errorMessage);
return { agentId, content: "", tokensPerSecond: null, error: errorMessage };
}
}
async execute(
agentCount: number,
context: ParallelContext
): Promise<{ synthesizedContent: string; agentResults: AgentResult[] }> {
const agentResults = await this.runParallelAgents(
agentCount,
context,
() => {},
() => {},
() => {}
);
const synthesizedContent = await this.runSynthesizer(
agentResults,
context,
() => {}
);
return { synthesizedContent, agentResults };
}
async runSynthesizer(
agentResults: AgentResult[],
context: ParallelContext,
onUpdate: (content: string) => void
): Promise<string> {
const agentResponsesText = agentResults
.filter((r) => !r.error && r.content)
.map((r) => `[Agent ${r.agentId}]: ${r.content}`)
.join("\n\n---\n\n");
const systemPrompt = SYNTHESIZER_PROMPT.replace(
"{AGENT_RESPONSES}",
agentResponsesText
);
const response = await fetch(`${context.server}/v1/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${context.apiKey}`,
},
body: JSON.stringify({
model: context.model,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: "Synthesize the optimal response:" },
],
temperature: 0.3,
max_tokens: 2500,
num_ctx: AI_AGENT_CONTEXT_LENGTH,
stream: true,
}),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error("No response body");
}
let finalContent = "";
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split("\n").filter((line) => line.trim());
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") continue;
try {
const parsed = JSON.parse(data);
const delta = parsed.choices?.[0]?.delta?.content;
if (delta) {
finalContent += delta;
onUpdate(finalContent);
}
} catch {
// Ignore malformed JSON in stream
}
}
}
}
return finalContent;
}
}
+181
View File
@@ -0,0 +1,181 @@
import type {
ReviewCycle,
SpecialistDefinition,
SpecialistReview,
Suggestion,
} from "./specialist-definitions";
import { getActiveSpecialists } from "./specialist-definitions";
import { SYSTEM_PROMPT } from "../prompts/system";
export interface ReviewContext {
chatId: number;
server: string;
apiKey: string;
model: string;
}
export class ReviewOrchestrator {
private abortController: AbortController | null = null;
abort(): void {
this.abortController?.abort();
}
async startReview(
messageId: number,
content: string,
context: ReviewContext,
onProgress?: (cycle: ReviewCycle) => void
): Promise<ReviewCycle> {
this.abortController = new AbortController();
const signal = this.abortController.signal;
const activeSpecialists = getActiveSpecialists(content);
const cycle: ReviewCycle = {
id: generateUUID(),
originalMessageId: messageId,
originalContent: content,
specialists: activeSpecialists.map((s) => ({
specialistId: s.id,
status: "pending",
suggestions: [],
applied: false,
})),
finalContent: content,
status: "running",
startedAt: Date.now(),
};
let currentContent = content;
for (let i = 0; i < activeSpecialists.length; i++) {
if (signal.aborted) break;
const specialist = activeSpecialists[i];
const specialistReview = cycle.specialists[i];
specialistReview.status = "thinking";
onProgress?.({ ...cycle });
try {
const review = await this.runSpecialist(
specialist,
currentContent,
context,
signal
);
specialistReview.status = "completed";
specialistReview.thinking = review.thinking;
specialistReview.suggestions = review.suggestions;
specialistReview.applied = true;
currentContent = this.applySuggestions(currentContent, review.suggestions);
cycle.finalContent = currentContent;
onProgress?.({ ...cycle });
} catch (error) {
specialistReview.status = "error";
console.error(`Specialist ${specialist.id} failed:`, error);
onProgress?.({ ...cycle });
}
}
cycle.status = signal.aborted ? "error" : "completed";
cycle.completedAt = Date.now();
return cycle;
}
private async runSpecialist(
specialist: SpecialistDefinition,
content: string,
context: ReviewContext,
signal: AbortSignal
): Promise<{ thinking: string; suggestions: Suggestion[] }> {
const messages = [
{ role: "system", content: specialist.systemPrompt },
{
role: "user",
content: `Review and improve this assistant response:\n\n${content}`,
},
];
const response = await fetch(`${context.server}/v1/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${context.apiKey}`,
},
body: JSON.stringify({
model: context.model,
messages,
temperature: specialist.temperature,
max_tokens: specialist.maxTokens,
stream: false,
}),
signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const rawContent = data.choices?.[0]?.message?.content || "{}";
try {
const jsonMatch = rawContent.match(/\{[\s\S]*\}/);
const jsonStr = jsonMatch ? jsonMatch[0] : rawContent;
const parsed = JSON.parse(jsonStr);
return {
thinking: parsed.thinking || "",
suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions : [],
};
} catch {
return {
thinking: "Failed to parse specialist response",
suggestions: [],
};
}
}
private applySuggestions(content: string, suggestions: Suggestion[]): string {
let result = content;
for (const suggestion of suggestions) {
if (suggestion.confidence < 0.8) continue;
switch (suggestion.type) {
case "improvement":
case "correction":
if (suggestion.original && suggestion.replacement) {
result = result.replace(suggestion.original, suggestion.replacement);
}
break;
case "addition":
if (suggestion.replacement) {
result += "\n\n" + suggestion.replacement;
}
break;
case "deletion":
if (suggestion.original) {
result = result.replace(suggestion.original, "");
}
break;
}
}
return result;
}
}
function generateUUID(): string {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
+139
View File
@@ -0,0 +1,139 @@
export interface SpecialistDefinition {
id: string;
name: string;
title: string;
systemPrompt: string;
activationRules: ActivationRule[];
temperature: number;
maxTokens: number;
}
export type ActivationRule =
| { type: "always" }
| { type: "contains-code"; languages?: string[] }
| { type: "length-threshold"; minChars: number }
| { type: "content-pattern"; regex: string };
export interface Suggestion {
type: "improvement" | "correction" | "addition" | "deletion";
original?: string;
replacement?: string;
explanation: string;
confidence: number;
}
export interface SpecialistReview {
specialistId: string;
status: "pending" | "thinking" | "completed" | "error";
thinking?: string;
suggestions: Suggestion[];
applied: boolean;
}
export interface ReviewCycle {
id: string;
originalMessageId: number;
originalContent: string;
specialists: SpecialistReview[];
finalContent: string;
status: "running" | "completed" | "error";
startedAt: number;
completedAt?: number;
}
export const TONE_ADAPTER: SpecialistDefinition = {
id: "tone-adapter",
name: "Tone Adapter",
title: "Adaptador de Tom",
systemPrompt: `You are a tone adaptation specialist. Analyze the conversation context and refine the assistant's response to match the appropriate tone.
Guidelines:
- Match the user's formality level (technical vs casual)
- Maintain consistency with previous messages
- Adjust enthusiasm based on context (neutral vs helpful)
- Ensure the response feels natural and conversational
- Keep all factual information intact
Output format (JSON only):
{
"thinking": "brief analysis of context and tone needed",
"suggestions": [
{
"type": "improvement",
"original": "text to change",
"replacement": "improved text",
"explanation": "why this improves the tone",
"confidence": 0.9
}
]
}
If no changes are needed, return empty suggestions array.`,
activationRules: [{ type: "always" }],
temperature: 0.3,
maxTokens: 2000,
};
export const CODE_REVIEWER: SpecialistDefinition = {
id: "code-reviewer",
name: "Code Reviewer",
title: "Revisor de Código",
systemPrompt: `You are a senior code reviewer. Analyze any code in the response and suggest improvements.
Focus on:
- Code efficiency and performance
- Readability and clarity
- Best practices and patterns
- Potential bugs or edge cases
- Security considerations
Only suggest changes with high confidence (>0.8). Minor style preferences should be skipped.
Output format (JSON only):
{
"thinking": "step-by-step code analysis",
"suggestions": [
{
"type": "improvement",
"original": "original code",
"replacement": "improved code",
"explanation": "specific reason for improvement",
"confidence": 0.95
}
]
}
If code is already good, return empty suggestions array.`,
activationRules: [{ type: "contains-code" }],
temperature: 0.2,
maxTokens: 3000,
};
export const DEFAULT_SPECIALISTS: SpecialistDefinition[] = [
TONE_ADAPTER,
CODE_REVIEWER,
];
export function shouldActivateSpecialist(
specialist: SpecialistDefinition,
content: string
): boolean {
return specialist.activationRules.some((rule) => {
switch (rule.type) {
case "always":
return true;
case "contains-code":
return /```[\w]*\n[\s\S]*?```/.test(content);
case "length-threshold":
return content.length >= rule.minChars;
case "content-pattern":
return new RegExp(rule.regex, "i").test(content);
default:
return false;
}
});
}
export function getActiveSpecialists(content: string): SpecialistDefinition[] {
return DEFAULT_SPECIALISTS.filter((s) => shouldActivateSpecialist(s, content));
}
+190
View File
@@ -0,0 +1,190 @@
import { create } from "zustand";
export interface ParallelAgent {
id: string;
name: string;
role: string;
status: "idle" | "streaming" | "completed" | "error";
content: string;
tokensPerSecond: number | null;
error?: string;
}
export interface ParallelReviewState {
agents: ParallelAgent[];
allAgentsCompleted: boolean;
synthesizerStatus: "idle" | "thinking" | "streaming" | "completed" | "error";
synthesizerContent: string;
finalContent: string;
isActive: boolean;
}
interface AgentStore {
agentCount: number;
reviewState: ParallelReviewState | null;
setAgentCount: (count: number) => void;
startReview: (agentCount: number) => void;
updateAgentContent: (agentId: string, content: string, tps?: number) => void;
completeAgent: (agentId: string) => void;
errorAgent: (agentId: string, error: string) => void;
startSynthesizer: () => void;
updateSynthesizerContent: (content: string) => void;
completeSynthesizer: (finalContent: string) => void;
reset: () => void;
}
export const generateAgentRoles = (count: number): { name: string; role: string }[] => {
const baseRoles = [
{ name: "Architect", role: "Focus on system design, structure, and scalability" },
{ name: "Code Reviewer", role: "Review code quality, best practices, and potential bugs" },
{ name: "Optimizer", role: "Identify performance improvements and optimizations" },
{ name: "Security Analyst", role: "Check for security vulnerabilities and safe practices" },
{ name: "Documentation", role: "Ensure clarity, completeness, and proper documentation" },
{ name: "Tester", role: "Consider edge cases, test coverage, and validation" },
{ name: "DevOps", role: "Consider deployment, infrastructure, and operational concerns" },
{ name: "UX Specialist", role: "Focus on user experience and interface design" },
{ name: "Database Expert", role: "Optimize data models, queries, and storage" },
{ name: "API Designer", role: "Review API design, consistency, and standards" },
{ name: "Accessibility", role: "Ensure accessibility and inclusive design" },
{ name: "Internationalization", role: "Consider localization and global usage" },
{ name: "Compliance", role: "Check regulatory and compliance requirements" },
{ name: "Performance", role: "Deep dive on performance bottlenecks" },
{ name: "Maintainability", role: "Focus on long-term maintenance and readability" },
{ name: "Innovation", role: "Suggest creative improvements and new approaches" },
];
return baseRoles.slice(0, count);
};
export const useAgentStore = create<AgentStore>((set) => ({
agentCount: 0,
reviewState: null,
setAgentCount: (count) => set({ agentCount: count }),
startReview: (agentCount) => {
const roles = generateAgentRoles(agentCount);
const agents: ParallelAgent[] = roles.map((role, index) => ({
id: `agent-${index}`,
name: role.name,
role: role.role,
status: "idle",
content: "",
tokensPerSecond: null,
}));
set({
reviewState: {
agents,
allAgentsCompleted: false,
synthesizerStatus: "idle",
synthesizerContent: "",
finalContent: "",
isActive: true,
},
});
},
updateAgentContent: (agentId, content, tps) =>
set((state) => {
if (!state.reviewState) return state;
const updatedAgents = state.reviewState.agents.map((agent) =>
agent.id === agentId
? { ...agent, status: "streaming" as const, content, tokensPerSecond: tps ?? agent.tokensPerSecond }
: agent
);
return {
reviewState: {
...state.reviewState,
agents: updatedAgents,
},
};
}),
completeAgent: (agentId) =>
set((state) => {
if (!state.reviewState) return state;
const updatedAgents = state.reviewState.agents.map((agent) =>
agent.id === agentId ? { ...agent, status: "completed" as const } : agent
);
const allCompleted = updatedAgents.every(
(a) => a.status === "completed" || a.status === "error"
);
return {
reviewState: {
...state.reviewState,
agents: updatedAgents,
allAgentsCompleted: allCompleted,
},
};
}),
errorAgent: (agentId, error) =>
set((state) => {
if (!state.reviewState) return state;
const updatedAgents = state.reviewState.agents.map((agent) =>
agent.id === agentId ? { ...agent, status: "error" as const, error } : agent
);
const allCompleted = updatedAgents.every(
(a) => a.status === "completed" || a.status === "error"
);
return {
reviewState: {
...state.reviewState,
agents: updatedAgents,
allAgentsCompleted: allCompleted,
},
};
}),
startSynthesizer: () =>
set((state) => {
if (!state.reviewState) return state;
return {
reviewState: {
...state.reviewState,
synthesizerStatus: "thinking",
},
};
}),
updateSynthesizerContent: (content) =>
set((state) => {
if (!state.reviewState) return state;
return {
reviewState: {
...state.reviewState,
synthesizerStatus: "streaming",
synthesizerContent: content,
},
};
}),
completeSynthesizer: (finalContent) =>
set((state) => {
if (!state.reviewState) return state;
return {
reviewState: {
...state.reviewState,
synthesizerStatus: "completed",
finalContent,
isActive: false,
},
};
}),
reset: () =>
set({
agentCount: 0,
reviewState: null,
}),
}));
@@ -0,0 +1,18 @@
// vite.config.ts
import { defineConfig } from "file:///D:/Documents/Github/forgelm/tauri-app/node_modules/vite/dist/node/index.js";
import react from "file:///D:/Documents/Github/forgelm/tauri-app/node_modules/@vitejs/plugin-react/dist/index.js";
var vite_config_default = defineConfig(async () => ({
plugins: [react()],
clearScreen: false,
server: {
port: 1420,
strictPort: true,
watch: {
ignored: ["**/src-tauri/**"]
}
}
}));
export {
vite_config_default as default
};
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCJEOlxcXFxEb2N1bWVudHNcXFxcR2l0aHViXFxcXGZvcmdlbG1cXFxcdGF1cmktYXBwXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ZpbGVuYW1lID0gXCJEOlxcXFxEb2N1bWVudHNcXFxcR2l0aHViXFxcXGZvcmdlbG1cXFxcdGF1cmktYXBwXFxcXHZpdGUuY29uZmlnLnRzXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ltcG9ydF9tZXRhX3VybCA9IFwiZmlsZTovLy9EOi9Eb2N1bWVudHMvR2l0aHViL2ZvcmdlbG0vdGF1cmktYXBwL3ZpdGUuY29uZmlnLnRzXCI7aW1wb3J0IHsgZGVmaW5lQ29uZmlnIH0gZnJvbSBcInZpdGVcIjtcbmltcG9ydCByZWFjdCBmcm9tIFwiQHZpdGVqcy9wbHVnaW4tcmVhY3RcIjtcblxuZXhwb3J0IGRlZmF1bHQgZGVmaW5lQ29uZmlnKGFzeW5jICgpID0+ICh7XG4gIHBsdWdpbnM6IFtyZWFjdCgpXSxcbiAgY2xlYXJTY3JlZW46IGZhbHNlLFxuICBzZXJ2ZXI6IHtcbiAgICBwb3J0OiAxNDIwLFxuICAgIHN0cmljdFBvcnQ6IHRydWUsXG4gICAgd2F0Y2g6IHtcbiAgICAgIGlnbm9yZWQ6IFtcIioqL3NyYy10YXVyaS8qKlwiXSxcbiAgICB9LFxuICB9LFxufSkpO1xuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUEyUyxTQUFTLG9CQUFvQjtBQUN4VSxPQUFPLFdBQVc7QUFFbEIsSUFBTyxzQkFBUSxhQUFhLGFBQWE7QUFBQSxFQUN2QyxTQUFTLENBQUMsTUFBTSxDQUFDO0FBQUEsRUFDakIsYUFBYTtBQUFBLEVBQ2IsUUFBUTtBQUFBLElBQ04sTUFBTTtBQUFBLElBQ04sWUFBWTtBQUFBLElBQ1osT0FBTztBQUFBLE1BQ0wsU0FBUyxDQUFDLGlCQUFpQjtBQUFBLElBQzdCO0FBQUEsRUFDRjtBQUNGLEVBQUU7IiwKICAibmFtZXMiOiBbXQp9Cg==