Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac553cb6b4 |
@@ -53,6 +53,13 @@ const STUB_RESULT =
|
||||
"Stub response: Timmy is running in stub mode (no Anthropic API key). " +
|
||||
"Configure AI_INTEGRATIONS_ANTHROPIC_API_KEY to enable real AI responses.";
|
||||
|
||||
const STUB_DECOMPOSITION_STEPS = [
|
||||
"Understand the request",
|
||||
"Research and gather information",
|
||||
"Draft a comprehensive response",
|
||||
"Review and finalise output",
|
||||
];
|
||||
|
||||
const STUB_CHAT_REPLIES = [
|
||||
"Ah, a visitor! *adjusts hat* The crystal ball sensed your presence. What do you seek?",
|
||||
"By the ancient runes! In stub mode I cannot reach the stars, but my wisdom remains. Ask away!",
|
||||
@@ -248,6 +255,40 @@ If the user asks how to run their own Timmy or self-host this service, enthusias
|
||||
return { result: fullText, inputTokens, outputTokens };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompose a request into 2-4 named sub-steps (#5).
|
||||
* Uses the cheap eval model (Haiku) so this adds minimal latency at job start.
|
||||
* In stub mode, returns canned steps so the full checklist UI is exercised.
|
||||
*/
|
||||
async decomposeRequest(requestText: string): Promise<string[]> {
|
||||
if (STUB_MODE) {
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
return [...STUB_DECOMPOSITION_STEPS];
|
||||
}
|
||||
|
||||
const client = await getClient();
|
||||
const message = await client.messages.create({
|
||||
model: this.evalModel,
|
||||
max_tokens: 256,
|
||||
system: `You are a task decomposition assistant. Break the user's request into 2-4 short, specific sub-step labels that describe how you will fulfil it. Each label should be 3-8 words. Respond ONLY with a valid JSON array of strings, e.g. ["Step one label", "Step two label"].`,
|
||||
messages: [{ role: "user", content: `Decompose this request into sub-steps: ${requestText}` }],
|
||||
});
|
||||
|
||||
const block = message.content[0];
|
||||
if (block.type !== "text") return [...STUB_DECOMPOSITION_STEPS];
|
||||
|
||||
try {
|
||||
const raw = block.text!.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "").trim();
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (Array.isArray(parsed) && parsed.length >= 2 && parsed.length <= 4) {
|
||||
return (parsed as unknown[]).map(String).slice(0, 4);
|
||||
}
|
||||
} catch {
|
||||
logger.warn("decomposeRequest parse failed, using stubs", { text: block.text });
|
||||
}
|
||||
return [...STUB_DECOMPOSITION_STEPS];
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick free chat reply — called for visitor messages in the Workshop.
|
||||
* Uses the cheaper eval model with a wizard persona and a 150-token limit
|
||||
|
||||
@@ -18,7 +18,11 @@ export type DebateEvent =
|
||||
export type CostEvent =
|
||||
| { type: "cost:update"; jobId: string; sats: number; phase: "eval" | "work" | "session"; isFinal: boolean };
|
||||
|
||||
export type BusEvent = JobEvent | SessionEvent | DebateEvent | CostEvent;
|
||||
export type DecompositionEvent =
|
||||
| { type: "job:steps"; jobId: string; steps: string[] }
|
||||
| { type: "job:step_update"; jobId: string; activeStep: number; completedSteps: number[] };
|
||||
|
||||
export type BusEvent = JobEvent | SessionEvent | DebateEvent | CostEvent | DecompositionEvent;
|
||||
|
||||
class EventBus extends EventEmitter {
|
||||
emit(event: "bus", data: BusEvent): boolean;
|
||||
|
||||
@@ -257,6 +257,21 @@ function translateEvent(ev: BusEvent): object | null {
|
||||
isFinal: ev.isFinal,
|
||||
};
|
||||
|
||||
// ── Task decomposition (#5) ───────────────────────────────────────────────
|
||||
case "job:steps":
|
||||
return {
|
||||
type: "job_steps",
|
||||
jobId: ev.jobId,
|
||||
steps: ev.steps,
|
||||
};
|
||||
case "job:step_update":
|
||||
return {
|
||||
type: "job_step_update",
|
||||
jobId: ev.jobId,
|
||||
activeStep: ev.activeStep,
|
||||
completedSteps: ev.completedSteps,
|
||||
};
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -254,10 +254,70 @@ async function runWorkInBackground(
|
||||
try {
|
||||
eventBus.publish({ type: "job:state", jobId, state: "executing" });
|
||||
|
||||
// ── Task decomposition (#5) ─────────────────────────────────────────────
|
||||
// Decompose the request into 2-4 named sub-steps via Haiku, then broadcast
|
||||
// the step list so the Workshop checklist panel can appear immediately.
|
||||
let decompositionSteps: string[] = [];
|
||||
try {
|
||||
decompositionSteps = await agentService.decomposeRequest(request);
|
||||
// Persist steps in job record (non-fatal if it fails)
|
||||
await db
|
||||
.update(jobs)
|
||||
.set({ decompositionSteps: JSON.stringify(decompositionSteps), updatedAt: new Date() })
|
||||
.where(eq(jobs.id, jobId));
|
||||
eventBus.publish({ type: "job:steps", jobId, steps: decompositionSteps });
|
||||
// Start with step 0 active
|
||||
eventBus.publish({ type: "job:step_update", jobId, activeStep: 0, completedSteps: [] });
|
||||
} catch (decompErr) {
|
||||
logger.warn("decomposeRequest failed, continuing without steps", { jobId, err: String(decompErr) });
|
||||
}
|
||||
|
||||
// ── Step advancement heuristic ──────────────────────────────────────────
|
||||
// Divide the expected output into equal chunks; advance the active step
|
||||
// each time that fraction of streamed characters has been received.
|
||||
let charCount = 0;
|
||||
let currentStep = 0;
|
||||
const numSteps = decompositionSteps.length;
|
||||
const completedStepsSoFar: number[] = [];
|
||||
|
||||
// Rough expected output length — anything over ~200 chars triggers first step.
|
||||
// Steps are advanced at equal intervals: 25% / 50% / 75% of 600 estimated chars.
|
||||
const ESTIMATED_TOTAL_CHARS = 600;
|
||||
const stepThreshold = numSteps > 0 ? ESTIMATED_TOTAL_CHARS / numSteps : Infinity;
|
||||
|
||||
const workResult = await agentService.executeWorkStreaming(request, (delta) => {
|
||||
streamRegistry.write(jobId, delta);
|
||||
|
||||
if (numSteps > 0) {
|
||||
charCount += delta.length;
|
||||
const expectedStep = Math.min(
|
||||
Math.floor(charCount / stepThreshold),
|
||||
numSteps - 1,
|
||||
);
|
||||
if (expectedStep > currentStep) {
|
||||
completedStepsSoFar.push(currentStep);
|
||||
currentStep = expectedStep;
|
||||
eventBus.publish({
|
||||
type: "job:step_update",
|
||||
jobId,
|
||||
activeStep: currentStep,
|
||||
completedSteps: [...completedStepsSoFar],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Mark all steps complete when streaming finishes
|
||||
if (numSteps > 0) {
|
||||
const allCompleted = Array.from({ length: numSteps }, (_, i) => i);
|
||||
eventBus.publish({
|
||||
type: "job:step_update",
|
||||
jobId,
|
||||
activeStep: -1, // -1 = all done, no active step
|
||||
completedSteps: allCompleted,
|
||||
});
|
||||
}
|
||||
|
||||
streamRegistry.end(jobId);
|
||||
latencyHistogram.record("work_phase", Date.now() - workStart);
|
||||
|
||||
|
||||
4
lib/db/migrations/0010_task_decomposition.sql
Normal file
4
lib/db/migrations/0010_task_decomposition.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Task decomposition view (#5)
|
||||
-- Stores the Haiku-generated step labels for a job execution as a JSON array.
|
||||
|
||||
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS decomposition_steps TEXT;
|
||||
@@ -52,6 +52,10 @@ export const jobs = pgTable("jobs", {
|
||||
refundState: text("refund_state").$type<"not_applicable" | "pending" | "paid">(),
|
||||
refundPaymentHash: text("refund_payment_hash"),
|
||||
|
||||
// ── Task decomposition (#5) ──────────────────────────────────────────────
|
||||
// JSON array of 2-4 step labels generated by Haiku at execution start.
|
||||
decompositionSteps: text("decomposition_steps"),
|
||||
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
@@ -305,3 +305,129 @@ export function appendDebateMessage(agent, argument, isVerdict, accepted) {
|
||||
|
||||
export function loadChatHistory() { return []; }
|
||||
export function saveChatHistory() {}
|
||||
|
||||
// ── Task decomposition checklist panel (#5) ───────────────────────────────────
|
||||
// Appears near the active Gamma agent during job execution.
|
||||
// Steps are checked off as streaming progresses, then collapses to "Done".
|
||||
|
||||
let $stepsPanel = null;
|
||||
let _stepElements = [];
|
||||
let _collapseTimer = null;
|
||||
|
||||
function _ensureStepsPanel() {
|
||||
if ($stepsPanel) return $stepsPanel;
|
||||
$stepsPanel = document.getElementById('timmy-steps-panel');
|
||||
if (!$stepsPanel) {
|
||||
$stepsPanel = document.createElement('div');
|
||||
$stepsPanel.id = 'timmy-steps-panel';
|
||||
$stepsPanel.style.cssText = [
|
||||
'position:fixed;bottom:80px;right:16px',
|
||||
'background:rgba(0,20,40,0.88);border:1px solid #224466',
|
||||
'border-radius:6px;padding:8px 12px',
|
||||
'font-size:11px;font-family:"Courier New",monospace',
|
||||
'color:#88bbdd;min-width:200px;max-width:280px',
|
||||
'pointer-events:none;z-index:10',
|
||||
'transition:opacity .4s;opacity:0',
|
||||
].join(';');
|
||||
document.body.appendChild($stepsPanel);
|
||||
}
|
||||
return $stepsPanel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the checklist panel with the given step labels.
|
||||
* Called on `job_steps` WebSocket message.
|
||||
*/
|
||||
export function showJobSteps(steps) {
|
||||
clearTimeout(_collapseTimer);
|
||||
const panel = _ensureStepsPanel();
|
||||
|
||||
// Clear previous content
|
||||
panel.innerHTML = '';
|
||||
_stepElements = [];
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.textContent = '⚙ Working…';
|
||||
header.style.cssText = 'color:#aaccee;font-weight:bold;margin-bottom:5px;font-size:10px;letter-spacing:1px;';
|
||||
panel.appendChild(header);
|
||||
|
||||
steps.forEach((label, i) => {
|
||||
const row = document.createElement('div');
|
||||
row.style.cssText = 'display:flex;align-items:center;gap:5px;margin:2px 0;transition:color .3s;';
|
||||
|
||||
const check = document.createElement('span');
|
||||
check.textContent = '○';
|
||||
check.style.cssText = 'min-width:12px;color:#336655;';
|
||||
|
||||
const text = document.createElement('span');
|
||||
text.textContent = label;
|
||||
|
||||
row.appendChild(check);
|
||||
row.appendChild(text);
|
||||
panel.appendChild(row);
|
||||
_stepElements.push({ row, check, text, index: i });
|
||||
});
|
||||
|
||||
panel.style.opacity = '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the visual state of steps.
|
||||
* activeStep = currently running step index (-1 = all done).
|
||||
* completedSteps = array of step indices that are finished.
|
||||
* Called on `job_step_update` WebSocket message.
|
||||
*/
|
||||
export function updateJobStep(activeStep, completedSteps) {
|
||||
if (!$stepsPanel || _stepElements.length === 0) return;
|
||||
|
||||
const completedSet = new Set(completedSteps);
|
||||
|
||||
_stepElements.forEach(({ row, check, text, index }) => {
|
||||
if (completedSet.has(index)) {
|
||||
// Completed: green checkmark
|
||||
check.textContent = '✓';
|
||||
check.style.color = '#44cc88';
|
||||
text.style.color = '#667788';
|
||||
row.style.opacity = '0.7';
|
||||
} else if (index === activeStep) {
|
||||
// Active: pulsing indicator
|
||||
check.textContent = '▶';
|
||||
check.style.color = '#ffcc44';
|
||||
text.style.color = '#eeddaa';
|
||||
row.style.opacity = '1';
|
||||
// Pulse animation via inline keyframe trick
|
||||
row.style.animation = 'none';
|
||||
void row.offsetHeight; // reflow
|
||||
check.style.transition = 'opacity 0.5s';
|
||||
} else {
|
||||
// Pending
|
||||
check.textContent = '○';
|
||||
check.style.color = '#336655';
|
||||
text.style.color = '#88bbdd';
|
||||
row.style.opacity = '1';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse the checklist panel to a single "Done" line, then fade out.
|
||||
* Called on `job_completed` WebSocket message.
|
||||
*/
|
||||
export function collapseJobSteps() {
|
||||
if (!$stepsPanel) return;
|
||||
clearTimeout(_collapseTimer);
|
||||
|
||||
// Replace content with a Done summary
|
||||
$stepsPanel.innerHTML = '';
|
||||
const done = document.createElement('div');
|
||||
done.textContent = '✓ Done';
|
||||
done.style.cssText = 'color:#44cc88;font-weight:bold;letter-spacing:1px;';
|
||||
$stepsPanel.appendChild(done);
|
||||
$stepsPanel.style.opacity = '1';
|
||||
|
||||
// Fade out after 2.5 s
|
||||
_collapseTimer = setTimeout(() => {
|
||||
if ($stepsPanel) $stepsPanel.style.opacity = '0';
|
||||
_stepElements = [];
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { setAgentState, setSpeechBubble, applyAgentStates, setMood } from './agents.js';
|
||||
import { appendSystemMessage, appendDebateMessage, showCostTicker, updateCostTicker } from './ui.js';
|
||||
import { appendSystemMessage, appendDebateMessage, showCostTicker, updateCostTicker, showJobSteps, updateJobStep, collapseJobSteps } from './ui.js';
|
||||
import { sentiment } from './edge-worker-client.js';
|
||||
import { setLabelState } from './hud-labels.js';
|
||||
|
||||
@@ -105,6 +105,7 @@ function handleMessage(msg) {
|
||||
setLabelState(msg.agentId, 'idle');
|
||||
}
|
||||
appendSystemMessage(`job ${(msg.jobId || '').slice(0, 8)} complete`);
|
||||
collapseJobSteps();
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -140,6 +141,20 @@ function handleMessage(msg) {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'job_steps': {
|
||||
// Task decomposition (#5): render checklist panel with step labels
|
||||
if (Array.isArray(msg.steps) && msg.steps.length > 0) {
|
||||
showJobSteps(msg.steps);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'job_step_update': {
|
||||
// Task decomposition (#5): advance active step and mark completed ones
|
||||
updateJobStep(msg.activeStep, msg.completedSteps || []);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'cost_update': {
|
||||
// Real-time cost ticker (#68): show estimated cost when job starts,
|
||||
// update to final charged amount when job completes.
|
||||
|
||||
Reference in New Issue
Block a user