Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f293dcb3b |
No files matched your search
@@ -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 +0,0 @@
|
||||
{"started_at": "2026-04-11T05:27:00Z", "iteration": 0}
|
||||
@@ -0,0 +1,4 @@
|
||||
# Taste (Continuously Learned by [CommandCode][cmd])
|
||||
|
||||
[cmd]: https://commandcode.ai/
|
||||
|
||||
@@ -12,3 +12,4 @@ vendor/llama-bin/
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
.opencode
|
||||
@@ -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
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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)?
|
||||
Generated
+4337
File diff suppressed because it is too large.
Load diff
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ── */}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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==
|
||||
Reference in new issue
Block a user