132 lines
4.1 KiB
TypeScript
132 lines
4.1 KiB
TypeScript
/**
|
|
* Prompt runner — executes a PromptTemplate against note content via @bytelyst/llm.
|
|
*/
|
|
|
|
import { llm } from '../../lib/llm.js';
|
|
import { config } from '../../lib/config.js';
|
|
import { trackEvent } from '../../lib/telemetry.js';
|
|
import {
|
|
buildVisionMessage,
|
|
hasVisionContent,
|
|
type ChatMessage,
|
|
} from '@bytelyst/llm';
|
|
import type { PromptTemplateDoc, RunPromptInput, RunPromptOutput } from './types.js';
|
|
|
|
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
return Promise.race([
|
|
promise,
|
|
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('LLM request timed out')), ms)),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Interpolate {{variable}} placeholders in a template string.
|
|
*/
|
|
function interpolate(template: string, vars: Record<string, string>): string {
|
|
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? `{{${key}}}`);
|
|
}
|
|
|
|
/**
|
|
* Run a prompt template against provided input.
|
|
* Handles text-only, image-only, and text+image inputs.
|
|
*/
|
|
export async function executePrompt(
|
|
template: PromptTemplateDoc,
|
|
input: RunPromptInput,
|
|
noteBody: string,
|
|
): Promise<RunPromptOutput> {
|
|
const provider = llm();
|
|
|
|
if (!provider.isConfigured()) {
|
|
throw new Error('LLM provider is not configured. Set LLM_PROVIDER and the required API key.');
|
|
}
|
|
|
|
// Build variables map
|
|
const vars: Record<string, string> = {
|
|
...input.variables,
|
|
noteBody,
|
|
noteId: input.noteId,
|
|
workspaceId: input.workspaceId,
|
|
};
|
|
if (input.inputText) vars.inputText = input.inputText;
|
|
|
|
const userPrompt = interpolate(template.userPromptTemplate, vars);
|
|
|
|
// Build messages
|
|
const messages: ChatMessage[] = [
|
|
{ role: 'system', content: template.systemPrompt },
|
|
];
|
|
|
|
if (input.imageUrl && (template.inputType === 'image' || template.inputType === 'text+image')) {
|
|
messages.push(buildVisionMessage(userPrompt, input.imageUrl));
|
|
} else {
|
|
messages.push({ role: 'user', content: userPrompt });
|
|
}
|
|
|
|
// Select model: vision model for image content, custom model, or default
|
|
const req = { messages };
|
|
let model = template.model || config.LLM_DEFAULT_MODEL;
|
|
if (hasVisionContent(req)) {
|
|
model = config.LLM_VISION_MODEL;
|
|
}
|
|
|
|
const maxRetries = 3;
|
|
const baseDelayMs = 1000;
|
|
let lastError: unknown;
|
|
|
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
try {
|
|
const result = await withTimeout(provider.chatCompletion({
|
|
messages,
|
|
model,
|
|
temperature: template.temperature ?? 0.7,
|
|
maxTokens: template.maxTokens ?? 4096,
|
|
}), 60_000);
|
|
|
|
if (!result.content || result.content.trim().length === 0) {
|
|
throw new Error('LLM returned empty response');
|
|
}
|
|
|
|
const output: RunPromptOutput = {
|
|
content: result.content,
|
|
model: result.model,
|
|
usage: result.usage,
|
|
templateSlug: template.slug,
|
|
outputType: template.outputType,
|
|
};
|
|
|
|
// F27: Approval-gated actions — produce proposed state instead of applied
|
|
if (template.requiresApproval) {
|
|
output.approvalState = 'proposed';
|
|
} else {
|
|
output.approvalState = 'applied';
|
|
}
|
|
|
|
trackEvent('smart_action_run', 'system', {
|
|
templateSlug: template.slug,
|
|
inputType: template.inputType,
|
|
model: result.model,
|
|
totalTokens: String(result.usage.totalTokens),
|
|
});
|
|
trackEvent('smart_action_result_saved', 'system', {
|
|
outputAction: template.outputType,
|
|
resultType: output.approvalState ?? 'applied',
|
|
});
|
|
|
|
return output;
|
|
} catch (err: unknown) {
|
|
lastError = err;
|
|
const errorType = err instanceof Error ? (err.message.includes('429') ? 'rate_limit' : err.message.includes('timed out') ? 'timeout' : 'llm_error') : 'unknown';
|
|
trackEvent('smart_action_error', 'system', { errorType, templateSlug: template.slug });
|
|
const isRateLimit = errorType === 'rate_limit';
|
|
if (isRateLimit && attempt < maxRetries - 1) {
|
|
await new Promise((r) => setTimeout(r, baseDelayMs * Math.pow(2, attempt)));
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
throw lastError instanceof Error ? lastError : new Error('LLM call failed after retries');
|
|
}
|