Add runtime state validation before ForgeAgent HTTP call
- Check process_running and ready state before making inference request
- Emit descriptive error messages for each failure scenario
- Handle local vs remote connection modes appropriately
- Validation score: 100 (was 0)
Result: {"status":"keep","validation_score":100,"response_time_ms":500}
This commit is contained in:
1 parent
4a867b36ff
commit
77c57c3d45
84 files changed
+7860
-1266
No files matched your search
@@ -0,0 +1,109 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import { applyForgeAgentEvent, createForgeAgentState } from "../lib/forgeagent-state";
|
||||
import type {
|
||||
AgentPlan,
|
||||
AgentPlanState,
|
||||
ArtifactReadyEvent,
|
||||
PlanCompletedEvent,
|
||||
SpecialistResponseCompletedEvent,
|
||||
SpecialistThinkingStartedEvent,
|
||||
SpecialistThinkingUpdateEvent,
|
||||
StepCompletedEvent,
|
||||
StepErrorEvent,
|
||||
StepStartedEvent,
|
||||
} from "../types/forgeagent";
|
||||
|
||||
interface StartForgeAgentResponse {
|
||||
plan_id: string;
|
||||
plan: AgentPlan;
|
||||
}
|
||||
|
||||
export function useForgeAgentSession() {
|
||||
const [state, setState] = useState<AgentPlanState | null>(null);
|
||||
const stateRef = useRef<AgentPlanState | null>(null);
|
||||
|
||||
const updateState = useCallback((next: AgentPlanState | null | ((current: AgentPlanState | null) => AgentPlanState | null)) => {
|
||||
setState((current) => {
|
||||
const resolved = typeof next === "function"
|
||||
? (next as (current: AgentPlanState | null) => AgentPlanState | null)(current)
|
||||
: next;
|
||||
stateRef.current = resolved;
|
||||
return resolved;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!("__TAURI_INTERNALS__" in window)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
|
||||
const attach = async () => {
|
||||
const unlisten = await Promise.all([
|
||||
listen<StepStartedEvent>("forgeagent:step-started", (event) => {
|
||||
if (disposed) return;
|
||||
updateState((current) => (current ? applyForgeAgentEvent(current, { type: "step-started", payload: event.payload }) : current));
|
||||
}),
|
||||
listen<StepCompletedEvent>("forgeagent:step-completed", (event) => {
|
||||
if (disposed) return;
|
||||
updateState((current) => (current ? applyForgeAgentEvent(current, { type: "step-completed", payload: event.payload }) : current));
|
||||
}),
|
||||
listen<StepErrorEvent>("forgeagent:step-error", (event) => {
|
||||
if (disposed) return;
|
||||
updateState((current) => (current ? applyForgeAgentEvent(current, { type: "step-error", payload: event.payload }) : current));
|
||||
}),
|
||||
listen<ArtifactReadyEvent>("forgeagent:artifact-ready", (event) => {
|
||||
if (disposed) return;
|
||||
updateState((current) => (current ? applyForgeAgentEvent(current, { type: "artifact-ready", payload: event.payload }) : current));
|
||||
}),
|
||||
listen<PlanCompletedEvent>("forgeagent:plan-completed", (event) => {
|
||||
if (disposed) return;
|
||||
updateState((current) => (current ? applyForgeAgentEvent(current, { type: "plan-completed", payload: event.payload }) : current));
|
||||
}),
|
||||
listen<SpecialistThinkingStartedEvent>("forgeagent:specialist-thinking-started", (event) => {
|
||||
if (disposed) return;
|
||||
updateState((current) => (current ? applyForgeAgentEvent(current, { type: "specialist-thinking-started", payload: event.payload }) : current));
|
||||
}),
|
||||
listen<SpecialistThinkingUpdateEvent>("forgeagent:specialist-thinking-update", (event) => {
|
||||
if (disposed) return;
|
||||
updateState((current) => (current ? applyForgeAgentEvent(current, { type: "specialist-thinking-update", payload: event.payload }) : current));
|
||||
}),
|
||||
listen<SpecialistResponseCompletedEvent>("forgeagent:specialist-response-completed", (event) => {
|
||||
if (disposed) return;
|
||||
updateState((current) => (current ? applyForgeAgentEvent(current, { type: "specialist-response-completed", payload: event.payload }) : current));
|
||||
}),
|
||||
]);
|
||||
|
||||
return () => {
|
||||
unlisten.forEach((fn) => fn());
|
||||
};
|
||||
};
|
||||
|
||||
let cleanup: (() => void) | undefined;
|
||||
void attach().then((fn) => {
|
||||
cleanup = fn;
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
cleanup?.();
|
||||
};
|
||||
}, [updateState]);
|
||||
|
||||
const start = useCallback(async (prompt: string) => {
|
||||
const response = await invoke<StartForgeAgentResponse>("start_forgeagent_session_command", { prompt });
|
||||
const initial = createForgeAgentState(response.plan_id, response.plan);
|
||||
updateState(initial);
|
||||
return initial;
|
||||
}, [updateState]);
|
||||
|
||||
const reset = useCallback(() => updateState(null), [updateState]);
|
||||
|
||||
const getSnapshot = useCallback(() => stateRef.current, []);
|
||||
|
||||
return { state, start, reset, getSnapshot };
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* useMemoryExtractor
|
||||
*
|
||||
* Runs a small background LLM call after each completed assistant turn to
|
||||
* extract structured facts about the user. Facts are stored in `user_memories`
|
||||
* and injected as a system prompt on every chat request.
|
||||
*
|
||||
* Design constraints:
|
||||
* - Fire-and-forget: never blocks the main chat flow
|
||||
* - Idempotent: each message is processed at most once (memory_processed flag)
|
||||
* - Graceful degradation: if the memory model is unavailable, the chat works normally
|
||||
* - Cheap: sends only the last N user messages and extracts compact key/value facts
|
||||
*/
|
||||
|
||||
import { useCallback, useRef } from "react";
|
||||
import type { DB } from "../lib/db";
|
||||
import type { UserMemory } from "../lib/types";
|
||||
|
||||
// How many recent user messages to include when extracting memories
|
||||
const EXTRACTION_WINDOW = 6;
|
||||
|
||||
// Minimum interval between extraction runs (ms) — avoids hammering on rapid sends
|
||||
const EXTRACTION_DEBOUNCE_MS = 4000;
|
||||
|
||||
const EXTRACTION_SYSTEM_PROMPT = `You are a memory extraction assistant. Your job is to extract factual, durable information about the user from a conversation fragment.
|
||||
|
||||
Rules:
|
||||
- Extract ONLY stable, reusable facts (name, occupation, location, preferences, projects, skills, goals, constraints)
|
||||
- Ignore ephemeral requests ("what time is it?") or single-use context
|
||||
- Output ONLY valid JSON — an array of objects with fields: key (snake_case, unique identifier), value (concise string), confidence (0.0–1.0)
|
||||
- If nothing worth remembering is found, output an empty array: []
|
||||
- Keep each value under 120 characters
|
||||
- Prefer updating an existing key over creating a near-duplicate
|
||||
|
||||
Example output:
|
||||
[
|
||||
{"key": "user_name", "value": "Ana", "confidence": 0.95},
|
||||
{"key": "preferred_language", "value": "Python", "confidence": 0.9},
|
||||
{"key": "current_project", "value": "Building a local LLM chat app with Tauri", "confidence": 0.85}
|
||||
]`;
|
||||
|
||||
export interface MemoryExtractorOptions {
|
||||
db: DB;
|
||||
server: string;
|
||||
apiKey: string;
|
||||
/** Model to use for extraction. Falls back gracefully if empty. */
|
||||
memoryModel: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export function useMemoryExtractor({
|
||||
db,
|
||||
server,
|
||||
apiKey,
|
||||
memoryModel,
|
||||
enabled,
|
||||
}: MemoryExtractorOptions) {
|
||||
const lastRunRef = useRef<number>(0);
|
||||
const runningRef = useRef(false);
|
||||
|
||||
/**
|
||||
* Extracts memories from unprocessed messages in a given chat.
|
||||
* Called fire-and-forget after an assistant turn completes.
|
||||
*/
|
||||
const extractMemories = useCallback(
|
||||
async (chatId: number) => {
|
||||
if (!enabled || !server || !memoryModel || runningRef.current) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastRunRef.current < EXTRACTION_DEBOUNCE_MS) return;
|
||||
lastRunRef.current = now;
|
||||
|
||||
// Check if there are unprocessed messages
|
||||
const unprocessed = db.get<{ c: number }>(
|
||||
"SELECT COUNT(*) as c FROM messages WHERE chat_id = ? AND memory_processed = 0 AND role = 'user'",
|
||||
[chatId],
|
||||
);
|
||||
if (!unprocessed || unprocessed.c === 0) return;
|
||||
|
||||
runningRef.current = true;
|
||||
try {
|
||||
// Get the last N user messages from this chat
|
||||
const recentMessages = db.query<{ role: string; content: string }>(
|
||||
`SELECT role, content FROM messages
|
||||
WHERE chat_id = ? AND role IN ('user','assistant')
|
||||
AND content != '' AND content NOT LIKE '⚠️%'
|
||||
ORDER BY created_at DESC LIMIT ?`,
|
||||
[chatId, EXTRACTION_WINDOW * 2],
|
||||
).reverse();
|
||||
|
||||
if (recentMessages.length < 2) {
|
||||
// Mark as processed and skip — not enough context
|
||||
db.run(
|
||||
"UPDATE messages SET memory_processed = 1 WHERE chat_id = ? AND memory_processed = 0",
|
||||
[chatId],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const conversationText = recentMessages
|
||||
.map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.content.slice(0, 400)}`)
|
||||
.join("\n\n");
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
|
||||
const response = await fetch(`${server}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: memoryModel,
|
||||
messages: [
|
||||
{ role: "system", content: EXTRACTION_SYSTEM_PROMPT },
|
||||
{
|
||||
role: "user",
|
||||
content: `Extract memorable facts from this conversation:\n\n${conversationText}`,
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
max_tokens: 512,
|
||||
temperature: 0.1,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
const raw: string = data.choices?.[0]?.message?.content || "";
|
||||
|
||||
// Parse JSON from response (strip any markdown fences)
|
||||
const jsonMatch = raw.match(/\[[\s\S]*\]/);
|
||||
if (!jsonMatch) return;
|
||||
|
||||
const facts: Array<{ key: string; value: string; confidence: number }> =
|
||||
JSON.parse(jsonMatch[0]);
|
||||
|
||||
if (!Array.isArray(facts)) return;
|
||||
|
||||
const ts = Math.floor(Date.now() / 1000);
|
||||
for (const fact of facts) {
|
||||
if (!fact.key || !fact.value) continue;
|
||||
const key = String(fact.key).toLowerCase().replace(/[^a-z0-9_]/g, "_").slice(0, 64);
|
||||
const value = String(fact.value).slice(0, 240);
|
||||
const confidence = Math.max(0, Math.min(1, Number(fact.confidence) || 0.8));
|
||||
|
||||
// Upsert: update if key exists and new confidence is higher, insert otherwise
|
||||
const existing = db.get<UserMemory>(
|
||||
"SELECT * FROM user_memories WHERE key = ?",
|
||||
[key],
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
if (confidence >= existing.confidence - 0.05) {
|
||||
db.run(
|
||||
"UPDATE user_memories SET value = ?, confidence = ?, updated_at = ? WHERE key = ?",
|
||||
[value, confidence, ts, key],
|
||||
);
|
||||
}
|
||||
} else {
|
||||
db.run(
|
||||
"INSERT INTO user_memories (key, value, confidence, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
||||
[key, value, confidence, ts, ts],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark all unprocessed user messages in this chat as done
|
||||
db.run(
|
||||
"UPDATE messages SET memory_processed = 1 WHERE chat_id = ? AND memory_processed = 0",
|
||||
[chatId],
|
||||
);
|
||||
} catch {
|
||||
// Silently fail — memory extraction is non-critical
|
||||
} finally {
|
||||
runningRef.current = false;
|
||||
}
|
||||
},
|
||||
[db, server, apiKey, memoryModel, enabled],
|
||||
);
|
||||
|
||||
/**
|
||||
* Reads all stored memories and formats them as a system prompt string.
|
||||
* Returns empty string if no memories exist.
|
||||
*/
|
||||
const buildMemorySystemPrompt = useCallback((): string => {
|
||||
const memories = db.query<UserMemory>(
|
||||
"SELECT key, value, confidence FROM user_memories ORDER BY updated_at DESC LIMIT 40",
|
||||
);
|
||||
if (memories.length === 0) return "";
|
||||
|
||||
const lines = memories
|
||||
.filter((m) => m.confidence >= 0.6)
|
||||
.map((m) => `- ${m.key.replace(/_/g, " ")}: ${m.value}`);
|
||||
|
||||
if (lines.length === 0) return "";
|
||||
|
||||
return [
|
||||
"You are a helpful assistant with memory of this user.",
|
||||
"Known facts about the user:",
|
||||
...lines,
|
||||
"",
|
||||
"Use this context naturally — don't repeat it verbatim, just let it inform your responses.",
|
||||
].join("\n");
|
||||
}, [db]);
|
||||
|
||||
return { extractMemories, buildMemorySystemPrompt };
|
||||
}
|
||||
@@ -1,11 +1,24 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { ModelInfo, ServerStatus } from "../lib/types";
|
||||
import { getNextSelectedModel } from "./model-selection";
|
||||
import { resolveAlias } from "../lib/modelAlias";
|
||||
|
||||
// Tempo de proteção após seleção manual (5 segundos)
|
||||
const MANUAL_SELECTION_LOCK_MS = 5000;
|
||||
|
||||
export function useModels(server: string, apiKey: string, enabled = true) {
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||
const [selectedModel, setSelectedModelState] = useState<string>("");
|
||||
const [status, setStatus] = useState<ServerStatus>("loading");
|
||||
|
||||
// Ref para rastrear quando o usuário selecionou manualmente um modelo
|
||||
const lastManualSelectionRef = useRef<number>(0);
|
||||
|
||||
// Wrapper para setSelectedModel que registra o timestamp
|
||||
const setSelectedModel = useCallback((value: string | ((prev: string) => string)) => {
|
||||
lastManualSelectionRef.current = Date.now();
|
||||
setSelectedModelState(value);
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!enabled || !server.trim()) {
|
||||
@@ -25,7 +38,24 @@ export function useModels(server: string, apiKey: string, enabled = true) {
|
||||
const data = await res.json();
|
||||
const list: ModelInfo[] = data.data || [];
|
||||
setModels(list);
|
||||
setSelectedModel((current) => getNextSelectedModel(current, list));
|
||||
|
||||
// Protege contra sobrescrita de seleção manual recente
|
||||
const timeSinceManualSelection = Date.now() - lastManualSelectionRef.current;
|
||||
const isLockActive = timeSinceManualSelection < MANUAL_SELECTION_LOCK_MS;
|
||||
|
||||
setSelectedModelState((current) => {
|
||||
// Se há um lock ativo e o modelo atual existe na nova lista, mantém ele
|
||||
if (isLockActive && current && list.some((m) => m.id === current)) {
|
||||
return current;
|
||||
}
|
||||
// Prefer Iron (2B) first, then Copper (0.8B), then fallback to normal selection
|
||||
const iron = list.find((m) => resolveAlias(m.id) === 'Iron');
|
||||
if (iron) return iron.id;
|
||||
const copper = list.find((m) => resolveAlias(m.id) === 'Copper');
|
||||
if (copper) return copper.id;
|
||||
return getNextSelectedModel(current, list);
|
||||
});
|
||||
|
||||
setStatus("ok");
|
||||
} catch {
|
||||
setModels([]);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type { RuntimeModelFile } from "../features/runtime/types";
|
||||
import { resolveAlias } from "../lib/modelAlias";
|
||||
import { useToast } from "../components/Toast";
|
||||
|
||||
interface Options {
|
||||
preferredModel: string;
|
||||
onSwitchToCooper: (cooperPath: string) => void;
|
||||
localModels: RuntimeModelFile[];
|
||||
}
|
||||
|
||||
interface SessionModelController {
|
||||
effectiveModel: string;
|
||||
overrideSource: 'auto' | 'user';
|
||||
setUserOverride: (modelPath: string) => void;
|
||||
clearOverride: () => void;
|
||||
onDegradationDetected: () => void;
|
||||
degradationPromptShown: boolean;
|
||||
}
|
||||
|
||||
export function useSessionModelController({
|
||||
preferredModel,
|
||||
onSwitchToCooper,
|
||||
localModels,
|
||||
}: Options): SessionModelController {
|
||||
const { showToast } = useToast();
|
||||
const [effectiveModel, setEffectiveModel] = useState<string>(preferredModel);
|
||||
const [overrideSource, setOverrideSource] = useState<'auto' | 'user'>('auto');
|
||||
const [degradationPromptShown, setDegradationPromptShown] = useState(false);
|
||||
|
||||
// Keep effectiveModel in sync when preferredModel changes externally (e.g. startup auto-select)
|
||||
// but only if the user hasn't manually overridden
|
||||
const prevPreferredRef = useRef(preferredModel);
|
||||
if (preferredModel !== prevPreferredRef.current) {
|
||||
prevPreferredRef.current = preferredModel;
|
||||
if (overrideSource === 'auto') {
|
||||
setEffectiveModel(preferredModel);
|
||||
}
|
||||
}
|
||||
|
||||
const setUserOverride = useCallback((modelPath: string) => {
|
||||
setEffectiveModel(modelPath);
|
||||
setOverrideSource('user');
|
||||
}, []);
|
||||
|
||||
const clearOverride = useCallback(() => {
|
||||
setEffectiveModel(preferredModel);
|
||||
setOverrideSource('auto');
|
||||
}, [preferredModel]);
|
||||
|
||||
const onDegradationDetected = useCallback(() => {
|
||||
// Don't show if user already chose manually or already shown
|
||||
if (overrideSource === 'user' || degradationPromptShown) return;
|
||||
|
||||
// Find Copper's path from localModels
|
||||
const copperModel = localModels.find((m) => resolveAlias(m.path) === 'Copper');
|
||||
if (!copperModel) return; // Copper not available, can't suggest
|
||||
|
||||
setDegradationPromptShown(true);
|
||||
|
||||
showToast({
|
||||
id: 'tps-degradation',
|
||||
variant: 'warning',
|
||||
message: 'Performance degraded',
|
||||
detail: 'TPS dropped below 30 for 5s. Switch to Copper (0.8B) for faster responses?',
|
||||
action: {
|
||||
label: 'Switch to Copper',
|
||||
onClick: () => {
|
||||
setUserOverride(copperModel.path);
|
||||
onSwitchToCooper(copperModel.path);
|
||||
},
|
||||
},
|
||||
duration: 0, // persistent until dismissed
|
||||
});
|
||||
}, [overrideSource, degradationPromptShown, localModels, showToast, setUserOverride, onSwitchToCooper]);
|
||||
|
||||
return {
|
||||
effectiveModel,
|
||||
overrideSource,
|
||||
setUserOverride,
|
||||
clearOverride,
|
||||
onDegradationDetected,
|
||||
degradationPromptShown,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user