wip: snapshot das alterações não commitadas (2026-10-20)
Origem: D:/Documents/Github/ForgeLM Arquivos omitidos (segredos, bancos de dados, logs, >50 MB): 1
This commit is contained in:
1 parent
f2481cf7e6
commit
4f293dcb3b
34 files changed
+7122
-246
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: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
|
*.dll
|
||||||
*.so
|
*.so
|
||||||
*.dylib
|
*.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 -->
|
<!-- GSD:conventions-start source:CONVENTIONS.md -->
|
||||||
## Conventions
|
## 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:conventions-end -->
|
||||||
|
|
||||||
<!-- GSD:architecture-start source:ARCHITECTURE.md -->
|
<!-- GSD:architecture-start source:ARCHITECTURE.md -->
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"marked": "^12.0.0",
|
"marked": "^12.0.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"zustand": "^5.0.12",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
@@ -631,6 +632,8 @@
|
|||||||
|
|
||||||
"yallist": ["[email protected]", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
"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=="],
|
"@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=="],
|
"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",
|
"lucide-react": "^1.7.0",
|
||||||
"marked": "^12.0.0",
|
"marked": "^12.0.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2",
|
"@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;
|
use crate::model_files::ModelFile;
|
||||||
|
|
||||||
/// Canonical display alias for a model family.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ModelAlias {
|
pub enum ModelAlias {
|
||||||
Cooper,
|
Bonsai,
|
||||||
Iron,
|
|
||||||
Steel,
|
|
||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ModelAlias {
|
impl ModelAlias {
|
||||||
pub fn as_str(&self) -> &'static str {
|
pub fn as_str(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
ModelAlias::Cooper => "Cooper",
|
ModelAlias::Bonsai => "Bonsai",
|
||||||
ModelAlias::Iron => "Iron",
|
|
||||||
ModelAlias::Steel => "Steel",
|
|
||||||
ModelAlias::Unknown => "Unknown",
|
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 {
|
pub fn resolve_alias(path: &str) -> ModelAlias {
|
||||||
let lower = path.to_lowercase();
|
let lower = path.to_lowercase();
|
||||||
// Extract just the filename for matching
|
|
||||||
let filename = lower.rsplit(['/', '\\']).next().unwrap_or(&lower);
|
let filename = lower.rsplit(['/', '\\']).next().unwrap_or(&lower);
|
||||||
|
|
||||||
if filename.contains("0.8b") {
|
if filename.contains("bonsai") {
|
||||||
ModelAlias::Cooper
|
ModelAlias::Bonsai
|
||||||
} else if filename.contains("4b") {
|
|
||||||
ModelAlias::Steel
|
|
||||||
} else if filename.contains("2b") {
|
|
||||||
ModelAlias::Iron
|
|
||||||
} else {
|
} else {
|
||||||
ModelAlias::Unknown
|
ModelAlias::Unknown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of startup model resolution.
|
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct StartupModelResolution {
|
pub struct StartupModelResolution {
|
||||||
pub model_path: String,
|
pub model_path: String,
|
||||||
@@ -62,18 +38,10 @@ pub struct StartupModelResolution {
|
|||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum StartupReason {
|
pub enum StartupReason {
|
||||||
SavedPreference,
|
SavedPreference,
|
||||||
DefaultIron,
|
DefaultBonsai,
|
||||||
FallbackCooper,
|
|
||||||
MissingSavedModel,
|
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(
|
pub fn resolve_startup_model(
|
||||||
preferred_alias: Option<&str>,
|
preferred_alias: Option<&str>,
|
||||||
discovered: &[ModelFile],
|
discovered: &[ModelFile],
|
||||||
@@ -82,7 +50,6 @@ pub fn resolve_startup_model(
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Classify all discovered models
|
|
||||||
let classified: Vec<(&ModelFile, ModelAlias)> = discovered
|
let classified: Vec<(&ModelFile, ModelAlias)> = discovered
|
||||||
.iter()
|
.iter()
|
||||||
.map(|m| (m, resolve_alias(&m.path)))
|
.map(|m| (m, resolve_alias(&m.path)))
|
||||||
@@ -91,9 +58,7 @@ pub fn resolve_startup_model(
|
|||||||
// 1. Respect saved preference if available
|
// 1. Respect saved preference if available
|
||||||
if let Some(pref) = preferred_alias {
|
if let Some(pref) = preferred_alias {
|
||||||
let target_alias = match pref.to_lowercase().as_str() {
|
let target_alias = match pref.to_lowercase().as_str() {
|
||||||
"cooper" => ModelAlias::Cooper,
|
"bonsai" => ModelAlias::Bonsai,
|
||||||
"iron" => ModelAlias::Iron,
|
|
||||||
"steel" => ModelAlias::Steel,
|
|
||||||
_ => ModelAlias::Unknown,
|
_ => ModelAlias::Unknown,
|
||||||
};
|
};
|
||||||
if target_alias != ModelAlias::Unknown {
|
if target_alias != ModelAlias::Unknown {
|
||||||
@@ -104,30 +69,24 @@ pub fn resolve_startup_model(
|
|||||||
reason: StartupReason::SavedPreference,
|
reason: StartupReason::SavedPreference,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Saved model not found — fall through to default policy with MissingSavedModel reason
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Prefer Iron
|
// 2. Default: Bonsai (8B)
|
||||||
if let Some((model, _)) = classified.iter().find(|(_, a)| *a == ModelAlias::Iron) {
|
if let Some((model, _)) = classified.iter().find(|(_, a)| *a == ModelAlias::Bonsai) {
|
||||||
return Some(StartupModelResolution {
|
return Some(StartupModelResolution {
|
||||||
model_path: model.path.clone(),
|
model_path: model.path.clone(),
|
||||||
alias: ModelAlias::Iron,
|
alias: ModelAlias::Bonsai,
|
||||||
reason: StartupReason::DefaultIron,
|
reason: StartupReason::DefaultBonsai,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Fallback to Cooper
|
// 3. Fallback: pick the first discovered model regardless of alias
|
||||||
if let Some((model, _)) = classified.iter().find(|(_, a)| *a == ModelAlias::Cooper) {
|
discovered.first().map(|model| StartupModelResolution {
|
||||||
return Some(StartupModelResolution {
|
|
||||||
model_path: model.path.clone(),
|
model_path: model.path.clone(),
|
||||||
alias: ModelAlias::Cooper,
|
alias: resolve_alias(&model.path),
|
||||||
reason: StartupReason::FallbackCooper,
|
reason: StartupReason::DefaultBonsai,
|
||||||
});
|
})
|
||||||
}
|
|
||||||
|
|
||||||
// 4. No suitable model found (Steel only or Unknown only)
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -143,26 +102,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_alias_detects_cooper_from_filename() {
|
fn resolve_alias_detects_bonsai_from_filename() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolve_alias("D:/models/rahul7star_Qwen3.5-0.8B-Coder.gguf"),
|
resolve_alias("D:/models/Bonsai-8B.gguf"),
|
||||||
ModelAlias::Cooper
|
ModelAlias::Bonsai
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,40 +118,30 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn startup_resolver_picks_iron_when_both_available() {
|
fn startup_resolver_picks_bonsai_when_available() {
|
||||||
let models = vec![
|
let models = vec![
|
||||||
make_model("rahul7star_Qwen3.5-0.8B-Coder.gguf"),
|
make_model("Bonsai-8B.gguf"),
|
||||||
make_model("Qwen3.5-2B.Q6_K.gguf"),
|
make_model("some-other-model.gguf"),
|
||||||
];
|
];
|
||||||
let result = resolve_startup_model(None, &models).unwrap();
|
let result = resolve_startup_model(None, &models).unwrap();
|
||||||
assert_eq!(result.alias, ModelAlias::Iron);
|
assert_eq!(result.alias, ModelAlias::Bonsai);
|
||||||
assert_eq!(result.reason, StartupReason::DefaultIron);
|
assert_eq!(result.reason, StartupReason::DefaultBonsai);
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn startup_resolver_falls_back_to_cooper_when_iron_missing() {
|
|
||||||
let models = vec![make_model("rahul7star_Qwen3.5-0.8B-Coder.gguf")];
|
|
||||||
let result = resolve_startup_model(None, &models).unwrap();
|
|
||||||
assert_eq!(result.alias, ModelAlias::Cooper);
|
|
||||||
assert_eq!(result.reason, StartupReason::FallbackCooper);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn startup_resolver_respects_saved_preference() {
|
fn startup_resolver_respects_saved_preference() {
|
||||||
let models = vec![
|
let models = vec![make_model("Bonsai-8B.gguf")];
|
||||||
make_model("rahul7star_Qwen3.5-0.8B-Coder.gguf"),
|
let result = resolve_startup_model(Some("bonsai"), &models).unwrap();
|
||||||
make_model("Qwen3.5-2B.Q6_K.gguf"),
|
assert_eq!(result.alias, ModelAlias::Bonsai);
|
||||||
];
|
|
||||||
let result = resolve_startup_model(Some("cooper"), &models).unwrap();
|
|
||||||
assert_eq!(result.alias, ModelAlias::Cooper);
|
|
||||||
assert_eq!(result.reason, StartupReason::SavedPreference);
|
assert_eq!(result.reason, StartupReason::SavedPreference);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn startup_resolver_ignores_steel_in_auto_policy() {
|
fn startup_resolver_falls_back_to_first_model_when_no_bonsai() {
|
||||||
let models = vec![make_model("Qwen3.5-4B-Abliterated.i1-Q6_K.gguf")];
|
let models = vec![make_model("some-other-model.gguf")];
|
||||||
let result = resolve_startup_model(None, &models);
|
let result = resolve_startup_model(None, &models).unwrap();
|
||||||
assert!(result.is_none(), "Steel should not be auto-selected");
|
assert_eq!(result.model_path, "D:/models/some-other-model.gguf");
|
||||||
|
assert_eq!(result.reason, StartupReason::DefaultBonsai);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -216,13 +149,4 @@ mod tests {
|
|||||||
let result = resolve_startup_model(None, &[]);
|
let result = resolve_startup_model(None, &[]);
|
||||||
assert!(result.is_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;
|
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)]
|
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Debug)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
@@ -105,7 +106,7 @@ impl Default for RuntimeConfig {
|
|||||||
model_path: None,
|
model_path: None,
|
||||||
models_dir: None,
|
models_dir: None,
|
||||||
addr: Self::default_addr(),
|
addr: Self::default_addr(),
|
||||||
gpu_layers: 0,
|
gpu_layers: DEFAULT_GPU_LAYERS,
|
||||||
ctx_size: DEFAULT_CTX_SIZE,
|
ctx_size: DEFAULT_CTX_SIZE,
|
||||||
extra_model_paths: Vec::new(),
|
extra_model_paths: Vec::new(),
|
||||||
selected_model_id: None,
|
selected_model_id: None,
|
||||||
@@ -118,13 +119,14 @@ impl Default for RuntimeConfig {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{ConnectionMode, RuntimeConfig, DEFAULT_CTX_SIZE};
|
use super::{ConnectionMode, RuntimeConfig, DEFAULT_CTX_SIZE, DEFAULT_GPU_LAYERS};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_config_defaults_to_safe_context_window() {
|
fn runtime_config_defaults_to_safe_context_window() {
|
||||||
let config = RuntimeConfig::default();
|
let config = RuntimeConfig::default();
|
||||||
|
|
||||||
assert_eq!(config.ctx_size, DEFAULT_CTX_SIZE);
|
assert_eq!(config.ctx_size, DEFAULT_CTX_SIZE);
|
||||||
|
assert_eq!(config.gpu_layers, DEFAULT_GPU_LAYERS);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -151,13 +151,6 @@ export default function App() {
|
|||||||
onToggleSidebar={() => setSidebarOpen((v) => !v)}
|
onToggleSidebar={() => setSidebarOpen((v) => !v)}
|
||||||
onOpenSettings={() => setView("settings")}
|
onOpenSettings={() => setView("settings")}
|
||||||
onBackToChat={view === "settings" ? () => setView("chat") : undefined}
|
onBackToChat={view === "settings" ? () => setView("chat") : undefined}
|
||||||
models={models}
|
|
||||||
localModels={localModels}
|
|
||||||
selectedModel={selectedModel}
|
|
||||||
onModelChange={setSelectedModel}
|
|
||||||
onModelSwap={handleModelSwap}
|
|
||||||
isSwapping={isSwapping}
|
|
||||||
status={status}
|
|
||||||
runtimeStatus={runtime.snapshot.status}
|
runtimeStatus={runtime.snapshot.status}
|
||||||
serverConnected={serverConnected}
|
serverConnected={serverConnected}
|
||||||
generating={generating}
|
generating={generating}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ModelInfo, ServerStatus, ChatTokenTotals } from "../lib/types";
|
import type { ServerStatus, ChatTokenTotals } from "../lib/types";
|
||||||
import type { RuntimeModelFile, RuntimeStatus } from "../features/runtime/types";
|
import type { RuntimeStatus } from "../features/runtime/types";
|
||||||
import ModelSelectorPill from "./ModelSelectorPill";
|
import { Cpu } from "@phosphor-icons/react";
|
||||||
|
|
||||||
async function tauriMinimize() {
|
async function tauriMinimize() {
|
||||||
try { const { getCurrentWindow } = await import("@tauri-apps/api/window"); await getCurrentWindow().minimize(); } catch { /**/ }
|
try { const { getCurrentWindow } = await import("@tauri-apps/api/window"); await getCurrentWindow().minimize(); } catch { /**/ }
|
||||||
@@ -21,13 +21,6 @@ interface Props {
|
|||||||
onToggleSidebar: () => void;
|
onToggleSidebar: () => void;
|
||||||
onOpenSettings: () => void;
|
onOpenSettings: () => void;
|
||||||
onBackToChat?: () => void;
|
onBackToChat?: () => void;
|
||||||
models: ModelInfo[];
|
|
||||||
localModels: RuntimeModelFile[];
|
|
||||||
selectedModel: string;
|
|
||||||
onModelChange: (id: string) => void;
|
|
||||||
onModelSwap?: (path: string) => void;
|
|
||||||
isSwapping?: boolean;
|
|
||||||
status: ServerStatus;
|
|
||||||
runtimeStatus: RuntimeStatus;
|
runtimeStatus: RuntimeStatus;
|
||||||
serverConnected: boolean;
|
serverConnected: boolean;
|
||||||
generating: boolean;
|
generating: boolean;
|
||||||
@@ -39,8 +32,7 @@ interface Props {
|
|||||||
|
|
||||||
export default function AppHeader({
|
export default function AppHeader({
|
||||||
sidebarOpen, onToggleSidebar, onOpenSettings, onBackToChat,
|
sidebarOpen, onToggleSidebar, onOpenSettings, onBackToChat,
|
||||||
models, localModels, selectedModel, onModelChange, onModelSwap, isSwapping,
|
runtimeStatus, serverConnected,
|
||||||
status, runtimeStatus, serverConnected,
|
|
||||||
generating, streamTokenCount, tps, chatTitle, chatTokenTotals,
|
generating, streamTokenCount, tps, chatTitle, chatTokenTotals,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const isOnline = serverConnected && runtimeStatus === "ready";
|
const isOnline = serverConnected && runtimeStatus === "ready";
|
||||||
@@ -94,16 +86,15 @@ export default function AppHeader({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Center: model selector ── */}
|
{/* ── Center: model badge ── */}
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-center flex-shrink-0"
|
className="flex items-center justify-center flex-shrink-0"
|
||||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||||
>
|
>
|
||||||
<ModelSelectorPill
|
<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">
|
||||||
models={models} localModels={localModels}
|
<Cpu size={12} weight="bold" className="text-blue-400" />
|
||||||
selectedModel={selectedModel} onModelChange={onModelChange}
|
<span className="font-medium">Bonsai 8B</span>
|
||||||
onModelSwap={onModelSwap} isSwapping={isSwapping} status={status}
|
</div>
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Right ── */}
|
{/* ── Right ── */}
|
||||||
|
|||||||
@@ -1608,3 +1608,38 @@
|
|||||||
background: rgba(22, 101, 52, 0.95);
|
background: rgba(22, 101, 52, 0.95);
|
||||||
border-color: rgba(134, 239, 172, 0.28);
|
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 { getComposerNotice } from "./chat-view-state";
|
||||||
import MessageBubble from "./MessageBubble";
|
import MessageBubble from "./MessageBubble";
|
||||||
import StreamingBubble from "./StreamingBubble";
|
import StreamingBubble from "./StreamingBubble";
|
||||||
import { AgenticToggle } from "./forgeagent";
|
import { AgentPanelInline } from "./forgeagent/AgentPanelInline";
|
||||||
// agentic-detection: shouldUseAgenticMode removed from auto-trigger — agentic mode is manual-only now
|
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 { Image, Microphone, Paperclip, ChatCircleText, PaperPlaneRight, Database, Lightning, Plus, Monitor, Globe, Palette, Code } from "@phosphor-icons/react";
|
||||||
import { useMemoryExtractor } from "../hooks/useMemoryExtractor";
|
import { useMemoryExtractor } from "../hooks/useMemoryExtractor";
|
||||||
import { MemoryModal } from "./memory/MemoryModal";
|
import { MemoryModal } from "./memory/MemoryModal";
|
||||||
@@ -99,7 +100,9 @@ interface Props {
|
|||||||
const [showMemoryModal, setShowMemoryModal] = useState(false);
|
const [showMemoryModal, setShowMemoryModal] = useState(false);
|
||||||
const [isQueueOpen, setIsQueueOpen] = useState(false);
|
const [isQueueOpen, setIsQueueOpen] = useState(false);
|
||||||
const [debugCopied, setDebugCopied] = 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({
|
const { extractMemories, buildMemorySystemPrompt } = useMemoryExtractor({
|
||||||
db,
|
db,
|
||||||
@@ -336,7 +339,7 @@ interface Props {
|
|||||||
// Auto-detection was triggering on benign messages like "como fazer um carro?"
|
// Auto-detection was triggering on benign messages like "como fazer um carro?"
|
||||||
// and injecting the ForgeAgent system prompt, causing confusing refusals on
|
// and injecting the ForgeAgent system prompt, causing confusing refusals on
|
||||||
// small Qwen3 models (0.8B, 2B, 4B).
|
// small Qwen3 models (0.8B, 2B, 4B).
|
||||||
const isAgenticRequest = agenticMode;
|
const isAgenticRequest = isParallelMode;
|
||||||
|
|
||||||
setGenerating(true); onGeneratingChange?.(true);
|
setGenerating(true); onGeneratingChange?.(true);
|
||||||
setStreamContent("");
|
setStreamContent("");
|
||||||
@@ -348,7 +351,7 @@ interface Props {
|
|||||||
// ── DevLogger: start interaction tracking ──
|
// ── DevLogger: start interaction tracking ──
|
||||||
devLog.startInteraction(chatId, text, {
|
devLog.startInteraction(chatId, text, {
|
||||||
memoryEnabled: useMemory,
|
memoryEnabled: useMemory,
|
||||||
agenticMode,
|
agenticMode: isParallelMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
const cid = fromChatId ?? getOrCreateChat(text);
|
const cid = fromChatId ?? getOrCreateChat(text);
|
||||||
@@ -590,6 +593,31 @@ interface Props {
|
|||||||
|
|
||||||
if (persistedAssistant) {
|
if (persistedAssistant) {
|
||||||
appendPersistedAssistantMessage({ ...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 {
|
} else {
|
||||||
appendPersistedAssistantMessage({
|
appendPersistedAssistantMessage({
|
||||||
id: Date.now(),
|
id: Date.now(),
|
||||||
@@ -700,7 +728,7 @@ interface Props {
|
|||||||
setTimeout(() => sendRef.current(nextInQueue), 100);
|
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
|
// Keep sendRef always pointing to latest send closure
|
||||||
sendRef.current = send;
|
sendRef.current = send;
|
||||||
@@ -750,7 +778,6 @@ interface Props {
|
|||||||
apiKeyPresent: Boolean(apiKey),
|
apiKeyPresent: Boolean(apiKey),
|
||||||
memoryEnabled: useMemory,
|
memoryEnabled: useMemory,
|
||||||
streamEnabled: useStream,
|
streamEnabled: useStream,
|
||||||
agenticMode,
|
|
||||||
savedMemories,
|
savedMemories,
|
||||||
latestInteraction,
|
latestInteraction,
|
||||||
runtimeMode,
|
runtimeMode,
|
||||||
@@ -760,7 +787,7 @@ interface Props {
|
|||||||
await navigator.clipboard.writeText(transcript);
|
await navigator.clipboard.writeText(transcript);
|
||||||
setDebugCopied(true);
|
setDebugCopied(true);
|
||||||
window.setTimeout(() => setDebugCopied(false), 2000);
|
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) => {
|
const handleEditAndResend = (msg: Message, newContent: string) => {
|
||||||
db.run("UPDATE messages SET content = ? WHERE id = ?", [newContent, msg.id]);
|
db.run("UPDATE messages SET content = ? WHERE id = ?", [newContent, msg.id]);
|
||||||
@@ -896,7 +923,7 @@ interface Props {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 self-end sm:self-auto">
|
<div className="flex items-center gap-4 self-end sm:self-auto">
|
||||||
<AgenticToggle isActive={agenticMode} onToggle={() => setAgenticMode((value) => !value)} disabled={Boolean(composerNotice)} />
|
<AgentPanelInline />
|
||||||
{generating ? (
|
{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">
|
<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
|
■ Stop
|
||||||
@@ -985,6 +1012,8 @@ interface Props {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AgentPanelInline />
|
||||||
|
|
||||||
{/* ── Fixed Input (Manus-style) ── */}
|
{/* ── Fixed Input (Manus-style) ── */}
|
||||||
{!isInitialScreen && (
|
{!isInitialScreen && (
|
||||||
<div className="absolute bottom-0 left-0 w-full flex flex-col items-center z-50 pointer-events-none"
|
<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'}
|
{debugCopied ? 'Copied debug' : 'Copy debug'}
|
||||||
</button>
|
</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>
|
<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 ? (
|
{generating ? (
|
||||||
<button type="button" onClick={handleStop} aria-label="Stop"
|
<button type="button" onClick={handleStop} aria-label="Stop"
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import type { Message } from "../lib/types";
|
import type { Message } from "../lib/types";
|
||||||
|
import type { ReviewCycle } from "../lib/specialist-definitions";
|
||||||
import { sanitizeMessageText } from "../lib/messageSanitizer";
|
import { sanitizeMessageText } from "../lib/messageSanitizer";
|
||||||
|
import { ReviewProgress } from "./forgeagent/ReviewProgress";
|
||||||
import "./MessageBubble.css";
|
import "./MessageBubble.css";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -10,9 +12,10 @@ interface Props {
|
|||||||
onEditAssistant?: (id: number, newContent: string) => void;
|
onEditAssistant?: (id: number, newContent: string) => void;
|
||||||
onResend?: (msg: Message) => void;
|
onResend?: (msg: Message) => void;
|
||||||
renderMarkdown: (text: string) => string;
|
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 sanitizedMessageContent = sanitizeMessageText(message.content).text;
|
||||||
const sanitizedReasoningContent = sanitizeMessageText(message.reasoning_content || "").text;
|
const sanitizedReasoningContent = sanitizeMessageText(message.reasoning_content || "").text;
|
||||||
const [editing, setEditing] = useState(false);
|
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>Generated {message.completion_tokens ?? 0}</span>
|
||||||
<span>Total {message.total_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}
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!hasUsage && <div />}
|
|
||||||
<div className="msg-actions msg-actions--inline" aria-label="Message actions">
|
<div className="msg-actions msg-actions--inline" aria-label="Message actions">
|
||||||
<button type="button" className={`action-btn ${copied ? "copied" : ""}`} onClick={copyMessage} aria-label="Copy message">
|
<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">
|
<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.
|
* Philosophy: less configuration → fewer loops, fewer artifacts.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Maximum context window sent to the model (tokens)
|
// Bonsai-8B.gguf — 1-bit TurboQuant model
|
||||||
export const AI_CONTEXT_LENGTH = 32000;
|
// 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
|
// How many messages from history to include in each request
|
||||||
export const AI_MAX_HISTORY_MESSAGES = 20;
|
export const AI_MAX_HISTORY_MESSAGES = 20;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export const MIN_CTX_SIZE = 512;
|
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];
|
export const CTX_PRESETS = [2048, 4096, 8192, 16384, 32768, 65536, 131072];
|
||||||
@@ -13,7 +13,7 @@ import type {
|
|||||||
RuntimeWorkspaceSummary,
|
RuntimeWorkspaceSummary,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { scanRuntimeModels } from "./api";
|
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";
|
const DEFAULT_LOCAL_URL = "http://127.0.0.1:8080";
|
||||||
|
|
||||||
@@ -304,13 +304,13 @@ export function createDefaultRuntimeSnapshot(): RuntimeSnapshot {
|
|||||||
last_command_result: createRuntimeLifecycleResult(),
|
last_command_result: createRuntimeLifecycleResult(),
|
||||||
config: {
|
config: {
|
||||||
connection_mode: "local",
|
connection_mode: "local",
|
||||||
start_policy: "manual",
|
start_policy: "on_app_launch",
|
||||||
server_url: "",
|
server_url: "",
|
||||||
api_key: "",
|
api_key: "",
|
||||||
model_path: null,
|
model_path: null,
|
||||||
models_dir: null,
|
models_dir: null,
|
||||||
addr: "127.0.0.1:8080",
|
addr: "127.0.0.1:8080",
|
||||||
gpu_layers: 0,
|
gpu_layers: DEFAULT_GPU_LAYERS,
|
||||||
ctx_size: DEFAULT_CTX_SIZE,
|
ctx_size: DEFAULT_CTX_SIZE,
|
||||||
extra_model_paths: [],
|
extra_model_paths: [],
|
||||||
selected_model_id: null,
|
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 type { ModelInfo, ServerStatus } from "../lib/types";
|
||||||
import { getNextSelectedModel } from "./model-selection";
|
import { getNextSelectedModel } from "./model-selection";
|
||||||
import { resolveAlias } from "../lib/modelAlias";
|
import { BONSAI_MODEL_ID } from "../lib/modelAlias";
|
||||||
|
|
||||||
// Tempo de proteção após seleção manual (5 segundos)
|
|
||||||
const MANUAL_SELECTION_LOCK_MS = 5000;
|
|
||||||
|
|
||||||
export function useModels(server: string, apiKey: string, enabled = true) {
|
export function useModels(server: string, apiKey: string, enabled = true) {
|
||||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||||
const [selectedModel, setSelectedModelState] = useState<string>("");
|
const [selectedModel, setSelectedModelState] = useState<string>(BONSAI_MODEL_ID);
|
||||||
const [status, setStatus] = useState<ServerStatus>("loading");
|
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)) => {
|
const setSelectedModel = useCallback((value: string | ((prev: string) => string)) => {
|
||||||
lastManualSelectionRef.current = Date.now();
|
|
||||||
setSelectedModelState(value);
|
setSelectedModelState(value);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -39,20 +31,12 @@ export function useModels(server: string, apiKey: string, enabled = true) {
|
|||||||
const list: ModelInfo[] = data.data || [];
|
const list: ModelInfo[] = data.data || [];
|
||||||
setModels(list);
|
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) => {
|
setSelectedModelState((current) => {
|
||||||
// Se há um lock ativo e o modelo atual existe na nova lista, mantém ele
|
if (current && list.some((m) => m.id === current)) {
|
||||||
if (isLockActive && current && list.some((m) => m.id === current)) {
|
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
// Prefer Iron (2B) first, then Copper (0.8B), then fallback to normal selection
|
const bonsai = list.find((m) => m.id.toLowerCase().includes('bonsai'));
|
||||||
const iron = list.find((m) => resolveAlias(m.id) === 'Iron');
|
if (bonsai) return bonsai.id;
|
||||||
if (iron) return iron.id;
|
|
||||||
const copper = list.find((m) => resolveAlias(m.id) === 'Copper');
|
|
||||||
if (copper) return copper.id;
|
|
||||||
return getNextSelectedModel(current, list);
|
return getNextSelectedModel(current, list);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import type { RuntimeModelFile } from "../features/runtime/types";
|
import type { RuntimeModelFile } from "../features/runtime/types";
|
||||||
import { resolveAlias } from "../lib/modelAlias";
|
|
||||||
import { useToast } from "../components/Toast";
|
import { useToast } from "../components/Toast";
|
||||||
|
|
||||||
interface Options {
|
interface Options {
|
||||||
@@ -20,16 +19,13 @@ interface SessionModelController {
|
|||||||
|
|
||||||
export function useSessionModelController({
|
export function useSessionModelController({
|
||||||
preferredModel,
|
preferredModel,
|
||||||
onSwitchToCooper,
|
localModels: _localModels,
|
||||||
localModels,
|
|
||||||
}: Options): SessionModelController {
|
}: Options): SessionModelController {
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [effectiveModel, setEffectiveModel] = useState<string>(preferredModel);
|
const [effectiveModel, setEffectiveModel] = useState<string>(preferredModel);
|
||||||
const [overrideSource, setOverrideSource] = useState<'auto' | 'user'>('auto');
|
const [overrideSource, setOverrideSource] = useState<'auto' | 'user'>('auto');
|
||||||
const [degradationPromptShown, setDegradationPromptShown] = useState(false);
|
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);
|
const prevPreferredRef = useRef(preferredModel);
|
||||||
if (preferredModel !== prevPreferredRef.current) {
|
if (preferredModel !== prevPreferredRef.current) {
|
||||||
prevPreferredRef.current = preferredModel;
|
prevPreferredRef.current = preferredModel;
|
||||||
@@ -49,30 +45,17 @@ export function useSessionModelController({
|
|||||||
}, [preferredModel]);
|
}, [preferredModel]);
|
||||||
|
|
||||||
const onDegradationDetected = useCallback(() => {
|
const onDegradationDetected = useCallback(() => {
|
||||||
// Don't show if user already chose manually or already shown
|
|
||||||
if (overrideSource === 'user' || degradationPromptShown) return;
|
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);
|
setDegradationPromptShown(true);
|
||||||
|
|
||||||
showToast({
|
showToast({
|
||||||
id: 'tps-degradation',
|
id: 'tps-degradation',
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
message: 'Performance degraded',
|
message: 'Performance degraded',
|
||||||
detail: 'TPS dropped below 30 for 5s. Switch to Copper (0.8B) for faster responses?',
|
detail: 'TPS dropped below 30 for 5s. The model may be struggling with the current workload.',
|
||||||
action: {
|
duration: 8000,
|
||||||
label: 'Switch to Copper',
|
|
||||||
onClick: () => {
|
|
||||||
setUserOverride(copperModel.path);
|
|
||||||
onSwitchToCooper(copperModel.path);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
duration: 0, // persistent until dismissed
|
|
||||||
});
|
});
|
||||||
}, [overrideSource, degradationPromptShown, localModels, showToast, setUserOverride, onSwitchToCooper]);
|
}, [overrideSource, degradationPromptShown, showToast]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
effectiveModel,
|
effectiveModel,
|
||||||
|
|||||||
@@ -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
|
* Resolves model alias from filename or path.
|
||||||
* Matches: Copper (0.8B), Iron (2B), Steel (4B)
|
* Always returns 'Bonsai' since it's our only model.
|
||||||
*/
|
*/
|
||||||
export function resolveAlias(modelIdOrPath: string): ModelAlias {
|
export function resolveAlias(_modelIdOrPath: string): ModelAlias {
|
||||||
const lower = modelIdOrPath.toLowerCase();
|
return 'Bonsai';
|
||||||
|
|
||||||
// Match by filename pattern first
|
|
||||||
if (lower.includes('copper')) return 'Copper';
|
|
||||||
if (lower.includes('iron')) return 'Iron';
|
|
||||||
if (lower.includes('steel')) return 'Steel';
|
|
||||||
|
|
||||||
// Fallback: match by parameter size indicators
|
|
||||||
if (lower.includes('0.8b')) return 'Copper';
|
|
||||||
if (lower.includes('4b')) return 'Steel';
|
|
||||||
if (lower.includes('2b')) return 'Iron';
|
|
||||||
|
|
||||||
return 'Unknown';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets clean display name for model selector
|
* Gets clean display name for model.
|
||||||
* Returns: Copper, Iron, Steel (never full filename)
|
* Returns: Bonsai (always)
|
||||||
*/
|
*/
|
||||||
export function getDisplayName(modelIdOrPath: string): string {
|
export function getDisplayName(modelIdOrPath: string): string {
|
||||||
const alias = resolveAlias(modelIdOrPath);
|
if (modelIdOrPath === 'Bonsai' || modelIdOrPath === BONSAI_MODEL_ID) {
|
||||||
if (alias !== 'Unknown') return alias;
|
return 'Bonsai';
|
||||||
|
}
|
||||||
|
|
||||||
// For unknown models, extract a clean name from filename
|
|
||||||
const parts = modelIdOrPath.replace(/\\/g, '/').split('/');
|
const parts = modelIdOrPath.replace(/\\/g, '/').split('/');
|
||||||
const base = parts[parts.length - 1];
|
const base = parts[parts.length - 1];
|
||||||
|
|
||||||
// Remove .gguf extension and common suffixes
|
|
||||||
let clean = base.replace(/\.gguf$/i, '');
|
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, '');
|
clean = clean.replace(/-GGUF$/i, '');
|
||||||
|
|
||||||
|
if (clean.toLowerCase().includes('bonsai')) {
|
||||||
|
return 'Bonsai';
|
||||||
|
}
|
||||||
|
|
||||||
return clean.slice(0, 20);
|
return clean.slice(0, 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true if this model should appear in the primary chat selector
|
* Returns true — all models are valid selectors since we only have Bonsai.
|
||||||
* All valid models (Copper, Iron, Steel) are shown
|
|
||||||
*/
|
*/
|
||||||
export function isInChatSelector(modelIdOrPath: string): boolean {
|
export function isInChatSelector(_modelIdOrPath: string): boolean {
|
||||||
const alias = resolveAlias(modelIdOrPath);
|
return true;
|
||||||
return alias === 'Copper' || alias === 'Iron' || alias === 'Steel';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the ForgeMetal enum value for a model
|
* Gets the ForgeMetal value — always 'Bonsai'.
|
||||||
* Used internally for model management
|
* Kept for backward compatibility.
|
||||||
*/
|
*/
|
||||||
export function getForgeMetal(modelIdOrPath: string): 'Copper' | 'Iron' | 'Steel' | null {
|
export function getForgeMetal(_modelIdOrPath: string): 'Bonsai' | null {
|
||||||
const alias = resolveAlias(modelIdOrPath);
|
return 'Bonsai';
|
||||||
if (alias === 'Unknown') return null;
|
|
||||||
return alias;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets model description/tooltip text
|
* Gets model description/tooltip text
|
||||||
*/
|
*/
|
||||||
export function getModelDescription(alias: ModelAlias): string {
|
export function getModelDescription(_alias?: ModelAlias): string {
|
||||||
switch (alias) {
|
return 'Bonsai (8B) — 1-bit TurboQuant, high-context local model';
|
||||||
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';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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