feat(writing): M3 — one-shot prose generation via mana-llm

Server:
- New llmText() helper in apps/api/src/lib/llm.ts for plain-text
  (non-streaming) completions with token-usage reporting.
- POST /api/v1/writing/generations (Hono + requireTier('beta'))
  accepts system+user prompts, forwards to mana-llm (default model
  ollama/gemma3:4b), returns raw output + model + tokenUsage. The
  endpoint is stateless — draft/version bookkeeping is entirely
  client-side so the same route serves refinement calls later.

Client:
- writing/api.ts — Bearer-authed fetch client (follows the food/
  news-research pattern).
- writing/utils/prompt-builder.ts — pure builder turning a briefing
  (+ optional style preset / extracted principles) into a system+user
  pair. Forbids preamble / sign-off / meta commentary so the output is
  ready to paste into a version.
- writing/stores/generations.svelte.ts — orchestrates the full flow:
  queued → running → call → new LocalDraftVersion → pointer flip →
  succeeded. On failure leaves the current version untouched with the
  error on the generation record. Emits WritingDraftGenerationStarted /
  WritingDraftVersionCreated / WritingDraftGenerationFailed events.

UI:
- Generate button in DetailView.svelte (label flips "Generate" / "Neu
  generieren" based on whether the draft already has content).
- GenerationStatus.svelte strip surfaces queued / running / failed with
  model + duration badges; succeeded generations auto-disappear because
  the new version is already live via the currentVersionId pointer.

M3 is synchronous and non-streaming by design. M7 adds mission-based
long-form with streaming + outline stage + reference injection. M6 will
reuse the same /generations endpoint for selection-refinement prompts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-24 15:11:48 +02:00
parent 3c3b2ebbc7
commit d725a8df8b
9 changed files with 814 additions and 11 deletions

View file

@ -31,6 +31,15 @@ export interface LlmJsonOptions {
maxTokens?: number;
}
export interface LlmTextOptions {
model: string;
system?: string;
user: string;
temperature?: number;
maxTokens?: number;
signal?: AbortSignal;
}
export interface LlmStreamOptions {
model: string;
system?: string;
@ -101,6 +110,56 @@ export async function llmJson<T = unknown>(opts: LlmJsonOptions): Promise<T> {
}
}
/**
* Call the LLM and return the raw text content no JSON parsing, no
* streaming. Used when you want a finished prose artifact (a generated
* draft, a summary, a translation) as one string. Includes token usage
* when the provider reports it so generation records can store it.
*/
export interface LlmTextResult {
text: string;
tokenUsage?: { input: number; output: number };
model: string;
}
export async function llmText(opts: LlmTextOptions): Promise<LlmTextResult> {
const res = await fetch(`${LLM_URL}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: opts.model,
messages: buildMessages(opts.system, opts.user),
temperature: opts.temperature ?? 0.7,
max_tokens: opts.maxTokens ?? 2000,
}),
signal: opts.signal,
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new LlmError(`mana-llm returned ${res.status}`, res.status, body);
}
const data = (await res.json()) as {
choices?: Array<{ message?: { content?: string } }>;
usage?: { prompt_tokens?: number; completion_tokens?: number };
model?: string;
};
const text = data.choices?.[0]?.message?.content;
if (!text) throw new LlmError('mana-llm response missing content');
return {
text: text.trim(),
tokenUsage:
data.usage && typeof data.usage.prompt_tokens === 'number'
? {
input: data.usage.prompt_tokens ?? 0,
output: data.usage.completion_tokens ?? 0,
}
: undefined,
model: data.model ?? opts.model,
};
}
/**
* Call the LLM in streaming mode. Invokes onToken() for each delta and
* returns the full concatenated text once the stream completes.