198 lines
6.2 KiB
JavaScript
198 lines
6.2 KiB
JavaScript
/**
|
|
* GoosedExecutor — runs a skill via a goosed session.
|
|
*
|
|
* Session lifecycle:
|
|
* 1. POST /agent/start { working_dir }
|
|
* 2. POST /sessions/:id/reply { request_id: UUID, user_message: {...} }
|
|
* 3. GET /sessions/:id/events (SSE) → collect until Finish event or timeout
|
|
* 4. DELETE /sessions/:id (best-effort cleanup)
|
|
*
|
|
* Dual-node: pickGoosedUrl() round-robins across configured nodes.
|
|
* Protocol: HTTPS with self-signed cert (rejectUnauthorized: false).
|
|
*/
|
|
|
|
import https from 'node:https';
|
|
import crypto from 'node:crypto';
|
|
import { goosedFetch, pickGoosedUrl } from '../goosed-client.mjs';
|
|
|
|
const SESSION_TIMEOUT_MS = Number(process.env.SKILL_GOOSED_TIMEOUT_MS ?? 120_000);
|
|
const tlsAgent = new https.Agent({ rejectUnauthorized: false });
|
|
|
|
export async function invokeGoosedSkill(skill, input) {
|
|
const node = pickGoosedUrl();
|
|
const prompt = buildPrompt(skill, input);
|
|
|
|
// 1. Start session (no messages yet)
|
|
const startRes = await goosedFetch('/agent/start', {
|
|
method: 'POST',
|
|
node,
|
|
body: {
|
|
working_dir: process.env.GOOSE_SKILL_WORKING_DIR ?? '/tmp',
|
|
},
|
|
});
|
|
|
|
if (!startRes.ok) {
|
|
throw Object.assign(
|
|
new Error(`goosed /agent/start failed (${startRes.status})`),
|
|
{ status: startRes.status, upstream: startRes.body },
|
|
);
|
|
}
|
|
|
|
const sessionId = startRes.body?.id ?? startRes.body?.session_id;
|
|
if (!sessionId) {
|
|
throw new Error(`goosed did not return a session id: ${JSON.stringify(startRes.body)}`);
|
|
}
|
|
|
|
// 1b. Set the LLM provider on the session
|
|
const goosedProvider = process.env.GOOSE_PROVIDER ?? process.env.GOOSED_PROVIDER ?? 'custom_deepseek';
|
|
const goosedModel = process.env.GOOSE_MODEL ?? process.env.GOOSED_MODEL;
|
|
const providerBody = { session_id: sessionId, provider: goosedProvider };
|
|
if (goosedModel) providerBody.model = goosedModel;
|
|
await goosedFetch('/agent/update_provider', { method: 'POST', node, body: providerBody });
|
|
|
|
// 2. Send the prompt via reply
|
|
const requestId = crypto.randomUUID();
|
|
const replyRes = await goosedFetch(`/sessions/${sessionId}/reply`, {
|
|
method: 'POST',
|
|
node,
|
|
body: {
|
|
request_id: requestId,
|
|
user_message: {
|
|
role: 'user',
|
|
created: Math.floor(Date.now() / 1000),
|
|
metadata: { userVisible: true, agentVisible: true },
|
|
content: [{ type: 'text', text: prompt }],
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!replyRes.ok) {
|
|
throw Object.assign(
|
|
new Error(`goosed /sessions/${sessionId}/reply failed (${replyRes.status})`),
|
|
{ status: replyRes.status, upstream: replyRes.body },
|
|
);
|
|
}
|
|
|
|
// 3. Stream SSE events and collect result
|
|
try {
|
|
return await collectSessionResult(node, sessionId);
|
|
} finally {
|
|
goosedFetch(`/sessions/${sessionId}`, { method: 'DELETE', node }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
function buildPrompt(skill, input) {
|
|
if (input.design_md_content) {
|
|
return `You are a UI generation assistant. Your output must be a complete, self-contained HTML page — not a component, not a snippet, not a code block with backticks. Output raw HTML only, starting with <!DOCTYPE html>.
|
|
|
|
Apply the following design system exactly. Match the colors, typography, spacing, border styles, shadows, and interaction patterns described:
|
|
|
|
${input.design_md_content}
|
|
|
|
Task: ${input.task ?? input.prompt ?? ''}
|
|
|
|
Requirements:
|
|
- Output a single complete HTML file with <html>, <head>, <body> and all inline <style> — no external stylesheets or JS frameworks
|
|
- The page should feel like a real product page from this brand, not a generic template
|
|
- Use the exact color values, font sizes, border-radius, shadow formulas from the design system above
|
|
- Include realistic placeholder content relevant to the task
|
|
- The page must be visually complete and ready to render in a browser`;
|
|
}
|
|
return input.task ?? input.prompt ?? JSON.stringify(input);
|
|
}
|
|
|
|
function collectSessionResult(node, sessionId) {
|
|
const secret =
|
|
process.env.GOOSE_SERVER__SECRET_KEY ??
|
|
process.env.TKMIND_SERVER__SECRET_KEY ??
|
|
'local-dev-secret';
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const url = new URL(`${node}/sessions/${sessionId}/events`);
|
|
const options = {
|
|
hostname: url.hostname,
|
|
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
|
path: url.pathname,
|
|
method: 'GET',
|
|
headers: { 'X-Secret-Key': secret, Accept: 'text/event-stream' },
|
|
agent: tlsAgent,
|
|
};
|
|
|
|
const req = https.request(options, (res) => {
|
|
if (res.statusCode !== 200) {
|
|
res.resume();
|
|
return reject(new Error(`SSE stream returned ${res.statusCode}`));
|
|
}
|
|
|
|
let buffer = '';
|
|
let collectedText = '';
|
|
|
|
const timer = setTimeout(() => {
|
|
req.destroy();
|
|
resolve({ content: collectedText, truncated: true });
|
|
}, SESSION_TIMEOUT_MS);
|
|
|
|
res.setEncoding('utf8');
|
|
|
|
res.on('data', (chunk) => {
|
|
buffer += chunk;
|
|
const lines = buffer.split('\n');
|
|
buffer = lines.pop() ?? '';
|
|
|
|
for (const line of lines) {
|
|
if (!line.startsWith('data:')) continue;
|
|
const raw = line.slice(5).trim();
|
|
if (!raw || raw === '[DONE]') continue;
|
|
|
|
let event;
|
|
try { event = JSON.parse(raw); } catch { continue; }
|
|
|
|
const text = extractText(event);
|
|
if (text) collectedText += text;
|
|
|
|
if (isFinishEvent(event)) {
|
|
clearTimeout(timer);
|
|
req.destroy();
|
|
resolve({ content: collectedText });
|
|
return;
|
|
}
|
|
}
|
|
});
|
|
|
|
res.on('end', () => {
|
|
clearTimeout(timer);
|
|
resolve({ content: collectedText });
|
|
});
|
|
|
|
res.on('error', (err) => {
|
|
clearTimeout(timer);
|
|
reject(err);
|
|
});
|
|
});
|
|
|
|
req.on('error', reject);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
function extractText(event) {
|
|
if (event?.type === 'Message' && event?.message?.role === 'assistant') {
|
|
return (event.message.content ?? [])
|
|
.filter((c) => c.type === 'text')
|
|
.map((c) => c.text)
|
|
.join('');
|
|
}
|
|
if (event?.delta?.text) return event.delta.text;
|
|
if (typeof event?.content === 'string') return event.content;
|
|
return '';
|
|
}
|
|
|
|
function isFinishEvent(event) {
|
|
return (
|
|
event?.type === 'Finish' ||
|
|
event?.type === 'finish' ||
|
|
event?.status === 'done' ||
|
|
event?.done === true
|
|
);
|
|
}
|