From 3c3b2ebbc7af32f576d4b019e73469142471e546 Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 24 Apr 2026 14:59:56 +0200 Subject: [PATCH] =?UTF-8?q?feat(writing):=20M1+M2=20=E2=80=94=20new=20Ghos?= =?UTF-8?q?twriter=20module=20with=20manual=20draft=20CRUD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M1 (skeleton): - Module `writing` registered: 4 Dexie tables (writingDrafts, writingDraftVersions, writingGenerations, writingStyles) in v43, encrypted via typed registry entries, space-scoped via the Dexie hook. - App entry in mana-apps.ts (sky-cyan #0ea5e9, LOCAL TIER PATCH guest), fountain-pen icon in app-icons.ts. - Plan: docs/plans/writing-module.md — 12 milestones, Ghostwriter-first with Canvas deferred to M9, Picture-pattern analogue (Draft + Version + Generation), 9 preset styles, Space-Kontext-as-default. M2 (manual CRUD): - drafts store: createDraft (atomic draft + initial v1), updateBriefing, setStatus, toggleFavorite, deleteDraft (cascade soft-delete versions), updateVersionContent (live edit), createCheckpointVersion, restoreVersion (pointer flip, non-destructive), setVisibility. - styles store: createStyle, updateStyle, upsertExtractedPrinciples, setSpaceDefault (exclusive flip), deleteStyle. - queries: useAllDrafts, useDraft, useVersionsForDraft, useCurrentVersionForDraft (follows the pointer so restoreVersion shows up in the editor), useGenerationsForDraft, useAllStyles + helpers. - UI: KindTabs (shows only kinds with drafts), StatusBadge, StatusFilter, DraftCard ( + + + + + diff --git a/apps/mana/apps/web/src/lib/modules/writing/components/DraftCard.svelte b/apps/mana/apps/web/src/lib/modules/writing/components/DraftCard.svelte new file mode 100644 index 000000000..95419b1f7 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/writing/components/DraftCard.svelte @@ -0,0 +1,140 @@ + + + + + diff --git a/apps/mana/apps/web/src/lib/modules/writing/components/KindTabs.svelte b/apps/mana/apps/web/src/lib/modules/writing/components/KindTabs.svelte new file mode 100644 index 000000000..badf9707b --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/writing/components/KindTabs.svelte @@ -0,0 +1,106 @@ + + +
+ + {#each ORDER as kind (kind)} + {#if counts[kind] > 0 || active === kind} + + {/if} + {/each} +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/writing/components/StatusBadge.svelte b/apps/mana/apps/web/src/lib/modules/writing/components/StatusBadge.svelte new file mode 100644 index 000000000..389ce4255 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/writing/components/StatusBadge.svelte @@ -0,0 +1,34 @@ + + + + + {label} + + + diff --git a/apps/mana/apps/web/src/lib/modules/writing/components/StatusFilter.svelte b/apps/mana/apps/web/src/lib/modules/writing/components/StatusFilter.svelte new file mode 100644 index 000000000..fc9adf9fd --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/writing/components/StatusFilter.svelte @@ -0,0 +1,56 @@ + + + + + diff --git a/apps/mana/apps/web/src/lib/modules/writing/components/VersionEditor.svelte b/apps/mana/apps/web/src/lib/modules/writing/components/VersionEditor.svelte new file mode 100644 index 000000000..e333c5625 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/writing/components/VersionEditor.svelte @@ -0,0 +1,130 @@ + + + +
+ +
+ + {wordCount} Wörter{#if targetWords} + / Ziel ~{targetWords} + {/if} + + + {#if pending} + Speichert… + {:else} + Gespeichert + {/if} + +
+
+ + diff --git a/apps/mana/apps/web/src/lib/modules/writing/components/VersionHistory.svelte b/apps/mana/apps/web/src/lib/modules/writing/components/VersionHistory.svelte new file mode 100644 index 000000000..f55e47a22 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/writing/components/VersionHistory.svelte @@ -0,0 +1,136 @@ + + + + + + diff --git a/apps/mana/apps/web/src/lib/modules/writing/constants.ts b/apps/mana/apps/web/src/lib/modules/writing/constants.ts new file mode 100644 index 000000000..849b3406f --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/writing/constants.ts @@ -0,0 +1,85 @@ +import type { DraftKind, DraftStatus, GenerationStatus, StyleSource } from './types'; + +export const KIND_LABELS: Record = { + blog: { de: 'Blog', en: 'Blog', emoji: '📝' }, + essay: { de: 'Essay', en: 'Essay', emoji: '📄' }, + email: { de: 'E-Mail', en: 'Email', emoji: '✉️' }, + social: { de: 'Social', en: 'Social', emoji: '💬' }, + story: { de: 'Story', en: 'Story', emoji: '📖' }, + letter: { de: 'Brief', en: 'Letter', emoji: '💌' }, + speech: { de: 'Rede', en: 'Speech', emoji: '🎤' }, + 'cover-letter': { de: 'Bewerbung', en: 'Cover letter', emoji: '💼' }, + 'product-description': { de: 'Produkttext', en: 'Product', emoji: '🛍️' }, + 'press-release': { de: 'Pressetext', en: 'Press', emoji: '📰' }, + bio: { de: 'Bio', en: 'Bio', emoji: '👤' }, + other: { de: 'Sonstiges', en: 'Other', emoji: '✏️' }, +}; + +export const STATUS_LABELS: Record = { + draft: { de: 'Entwurf', en: 'Draft' }, + refining: { de: 'In Überarbeitung', en: 'Refining' }, + complete: { de: 'Fertig', en: 'Complete' }, + published: { de: 'Veröffentlicht', en: 'Published' }, +}; + +export const STATUS_COLORS: Record = { + draft: '#64748b', + refining: '#3b82f6', + complete: '#22c55e', + published: '#a855f7', +}; + +export const GENERATION_STATUS_LABELS: Record = { + queued: { de: 'In Warteschlange', en: 'Queued' }, + running: { de: 'Läuft', en: 'Running' }, + succeeded: { de: 'Fertig', en: 'Succeeded' }, + failed: { de: 'Fehlgeschlagen', en: 'Failed' }, + cancelled: { de: 'Abgebrochen', en: 'Cancelled' }, +}; + +export const STYLE_SOURCE_LABELS: Record = { + preset: { de: 'Vorlage', en: 'Preset' }, + 'custom-description': { de: 'Eigene Beschreibung', en: 'Custom description' }, + 'sample-trained': { de: 'Aus Textproben trainiert', en: 'Trained from samples' }, + 'self-trained': { de: 'Schreibe wie ich', en: 'Write like me' }, +}; + +/** Default word-count targets per kind — used in briefing defaults. */ +export const LENGTH_PRESETS: Record = { + blog: { type: 'words', value: 800 }, + essay: { type: 'words', value: 1500 }, + email: { type: 'words', value: 180 }, + social: { type: 'words', value: 80 }, + story: { type: 'words', value: 1200 }, + letter: { type: 'words', value: 350 }, + speech: { type: 'words', value: 600 }, + 'cover-letter': { type: 'words', value: 400 }, + 'product-description': { type: 'words', value: 220 }, + 'press-release': { type: 'words', value: 450 }, + bio: { type: 'words', value: 120 }, + other: { type: 'words', value: 500 }, +}; + +export const TONE_PRESETS: ReadonlyArray<{ id: string; de: string; en: string }> = [ + { id: 'neutral', de: 'Neutral', en: 'Neutral' }, + { id: 'warm', de: 'Warm', en: 'Warm' }, + { id: 'formal', de: 'Formell', en: 'Formal' }, + { id: 'casual', de: 'Locker', en: 'Casual' }, + { id: 'professional', de: 'Professionell', en: 'Professional' }, + { id: 'playful', de: 'Verspielt', en: 'Playful' }, + { id: 'urgent', de: 'Dringlich', en: 'Urgent' }, + { id: 'empathetic', de: 'Einfühlsam', en: 'Empathetic' }, + { id: 'assertive', de: 'Selbstbewusst', en: 'Assertive' }, + { id: 'humorous', de: 'Humorvoll', en: 'Humorous' }, +]; + +export const DEFAULT_LANGUAGE = 'de'; + +/** Kinds for which the runner should produce an outline before the full draft. */ +export const AUTO_OUTLINE_KINDS: ReadonlyArray = [ + 'blog', + 'essay', + 'speech', + 'cover-letter', + 'story', +]; diff --git a/apps/mana/apps/web/src/lib/modules/writing/index.ts b/apps/mana/apps/web/src/lib/modules/writing/index.ts new file mode 100644 index 000000000..fd6d37b2b --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/writing/index.ts @@ -0,0 +1,86 @@ +/** + * Writing module — barrel exports. + */ + +export { draftsStore } from './stores/drafts.svelte'; +export type { CreateDraftInput, UpdateDraftPatch } from './stores/drafts.svelte'; + +export { stylesStore } from './stores/styles.svelte'; +export type { CreateStyleInput, UpdateStylePatch } from './stores/styles.svelte'; + +export { + useAllDrafts, + useDraft, + useVersionsForDraft, + useVersion, + useCurrentVersionForDraft, + useGenerationsForDraft, + useAllStyles, + toDraft, + toDraftVersion, + toGeneration, + toWritingStyle, + filterByKind, + filterByStatus, + searchDrafts, + sortByUpdated, + groupByKind, + computeStats, +} from './queries'; +export type { WritingStats } from './queries'; + +export { + draftTable, + draftVersionTable, + generationTable, + writingStyleTable, + WRITING_GUEST_SEED, +} from './collections'; + +export { + KIND_LABELS, + STATUS_LABELS, + STATUS_COLORS, + GENERATION_STATUS_LABELS, + STYLE_SOURCE_LABELS, + LENGTH_PRESETS, + TONE_PRESETS, + DEFAULT_LANGUAGE, + AUTO_OUTLINE_KINDS, +} from './constants'; + +export { STYLE_PRESETS, getStylePreset } from './presets/styles'; +export type { StylePreset } from './presets/styles'; + +export type { + // Enums + DraftKind, + DraftStatus, + DraftLengthUnit, + GenerationStatus, + GenerationKind, + GenerationProvider, + StyleSource, + DraftReferenceKind, + DraftPublishModule, + // Sub-objects + DraftBriefing, + DraftStyleOverrides, + DraftReference, + DraftPublishTarget, + DraftGenerationParams, + DraftSelection, + DraftTokenUsage, + StyleSample, + StyleExtractedPrinciples, + // Dexie records + LocalDraft, + LocalDraftVersion, + LocalGeneration, + LocalWritingStyle, + // Domain types + Draft, + DraftVersion, + Generation, + WritingStyle, +} from './types'; diff --git a/apps/mana/apps/web/src/lib/modules/writing/module.config.ts b/apps/mana/apps/web/src/lib/modules/writing/module.config.ts new file mode 100644 index 000000000..0702ad916 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/writing/module.config.ts @@ -0,0 +1,11 @@ +import type { ModuleConfig } from '$lib/data/module-registry'; + +export const writingModuleConfig: ModuleConfig = { + appId: 'writing', + tables: [ + { name: 'writingDrafts' }, + { name: 'writingDraftVersions' }, + { name: 'writingGenerations' }, + { name: 'writingStyles' }, + ], +}; diff --git a/apps/mana/apps/web/src/lib/modules/writing/presets/styles.ts b/apps/mana/apps/web/src/lib/modules/writing/presets/styles.ts new file mode 100644 index 000000000..07781cdec --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/writing/presets/styles.ts @@ -0,0 +1,175 @@ +/** + * Preset style definitions — the "ready-made" styles a user can pick from + * in the briefing without having to train anything. Each preset ships with + * a German + English description plus extracted principles so the prompt + * builder can treat presets and custom-trained styles identically. + * + * Keeping presets in code (not in Dexie) means they're versioned with the + * app, never need syncing, and can be edited in a PR. A user's "custom" + * style is a row in `writingStyles`; a preset is referenced by its `id` + * via `LocalDraft.styleId`. When a user "favourites" a preset we still + * write a row (source='preset', presetId=) so the picker can show it. + */ + +import type { StyleExtractedPrinciples } from '../types'; + +export interface StylePreset { + id: string; + name: { de: string; en: string }; + description: { de: string; en: string }; + principles: StyleExtractedPrinciples; +} + +const now = '2026-04-24T00:00:00.000Z'; + +export const STYLE_PRESETS: ReadonlyArray = [ + { + id: 'academic', + name: { de: 'Akademisch', en: 'Academic' }, + description: { + de: 'Dicht, passive Voice erlaubt, Zitate, Konjunktiv — für wissenschaftliche Texte.', + en: 'Dense, passive voice allowed, citations, subjunctive — for scholarly prose.', + }, + principles: { + toneTraits: ['formal', 'precise', 'hedged'], + sentenceLengthAvg: 28, + vocabulary: ['furthermore', 'notwithstanding', 'consequently'], + examples: [], + rawAnalysis: + 'Passive constructions allowed. Qualifier-heavy ("it may be argued that…"). References by author + year. No contractions. No rhetorical questions.', + extractedAt: now, + }, + }, + { + id: 'casual-blog', + name: { de: 'Casual Blog', en: 'Casual blog' }, + description: { + de: 'Du-Ansprache, kurze Absätze, rhetorische Fragen — persönlicher Blog-Ton.', + en: 'Second-person, short paragraphs, rhetorical questions — personal blog voice.', + }, + principles: { + toneTraits: ['conversational', 'direct', 'warm'], + sentenceLengthAvg: 16, + examples: [], + rawAnalysis: + 'Address the reader as "du" / "you". Contractions fine. 2–3-sentence paragraphs. Occasional rhetorical question to punctuate sections.', + extractedAt: now, + }, + }, + { + id: 'linkedin', + name: { de: 'LinkedIn-Post', en: 'LinkedIn post' }, + description: { + de: 'Hook in Zeile 1, 1-Satz-Absätze, sparsamer Emoji-Einsatz, Call-to-Action am Ende.', + en: 'Hook on line 1, one-sentence paragraphs, sparing emoji, CTA at the end.', + }, + principles: { + toneTraits: ['hook-first', 'confident', 'accessible'], + sentenceLengthAvg: 12, + examples: [], + rawAnalysis: + 'Line 1 must hook. Short paragraphs (often one sentence each). Bullet-style mid-post lists are OK. End with a question or explicit CTA. Emoji only to structure, not decorate.', + extractedAt: now, + }, + }, + { + id: 'twitter-thread', + name: { de: 'Twitter/X-Thread', en: 'Twitter/X thread' }, + description: { + de: 'Nummerierte Tweets ≤280 Zeichen, Cliffhanger zwischen den Posts.', + en: 'Numbered tweets ≤280 chars, cliffhanger between posts.', + }, + principles: { + toneTraits: ['punchy', 'cliffhanger-driven'], + sentenceLengthAvg: 10, + examples: [], + rawAnalysis: + 'Split into numbered tweets (1/, 2/, …). Every tweet ≤280 chars. Each tweet should reward a stop, and incentivise a continue. Open with a strong claim.', + extractedAt: now, + }, + }, + { + id: 'hemingway', + name: { de: 'Hemingway', en: 'Hemingway' }, + description: { + de: 'Deklarativ, kurze Sätze, minimale Adjektive — nüchtern und klar.', + en: 'Declarative, short sentences, minimal adjectives — lean and clear.', + }, + principles: { + toneTraits: ['declarative', 'lean', 'concrete'], + sentenceLengthAvg: 9, + examples: [], + rawAnalysis: + 'Mostly simple declarative sentences. Adverbs used sparingly. Concrete nouns over abstract. No meta-commentary. Show, do not tell.', + extractedAt: now, + }, + }, + { + id: 'news', + name: { de: 'Nachrichtlich', en: 'Newswire' }, + description: { + de: 'Inverted Pyramid, nüchtern, keine Meinung — wie eine Nachrichtenagentur.', + en: 'Inverted pyramid, neutral, opinion-free — newswire style.', + }, + principles: { + toneTraits: ['neutral', 'factual', 'inverted-pyramid'], + sentenceLengthAvg: 18, + examples: [], + rawAnalysis: + 'Lead with the 5 Ws. Most important fact first, background last. Attribute every claim. No first-person. No opinion.', + extractedAt: now, + }, + }, + { + id: 'listicle', + name: { de: 'Listicle', en: 'Listicle' }, + description: { + de: 'Nummerierte Liste mit überspitzten Einleitungen — Buzzfeed-Format.', + en: 'Numbered list with punchy intros — Buzzfeed-style.', + }, + principles: { + toneTraits: ['punchy', 'listicle-structured', 'irreverent'], + sentenceLengthAvg: 14, + examples: [], + rawAnalysis: + 'Numbered headings (e.g. "1. The thing that changed everything"). 2–4 sentences per item. Short opener, strong closing sentence per item. OK to use surprise and hyperbole.', + extractedAt: now, + }, + }, + { + id: 'pitch', + name: { de: 'Pitch / Sales', en: 'Pitch / sales' }, + description: { + de: 'Problem → Agitation → Solution — Verkaufstext mit klarer Struktur.', + en: 'Problem → Agitation → Solution — sales writing with a clear arc.', + }, + principles: { + toneTraits: ['persuasive', 'outcome-focused'], + sentenceLengthAvg: 15, + examples: [], + rawAnalysis: + "Open with the reader's problem. Agitate (cost of inaction). Introduce solution. Close with specific next step. Short paragraphs, concrete benefits, social proof when available.", + extractedAt: now, + }, + }, + { + id: 'memoir', + name: { de: 'Memoir', en: 'Memoir' }, + description: { + de: '1. Person, sensorisch, Szenen statt Zusammenfassungen — persönlicher Erinnerungsstil.', + en: 'First-person, sensory, scenes over summary — personal memoir voice.', + }, + principles: { + toneTraits: ['introspective', 'sensory', 'scene-driven'], + sentenceLengthAvg: 17, + examples: [], + rawAnalysis: + 'First-person throughout. Anchor in time + place + body. Prefer scenes ("It was the Tuesday after…") to summaries. Interior thought marked by rhythm change rather than italics.', + extractedAt: now, + }, + }, +]; + +export function getStylePreset(id: string): StylePreset | undefined { + return STYLE_PRESETS.find((p) => p.id === id); +} diff --git a/apps/mana/apps/web/src/lib/modules/writing/queries.ts b/apps/mana/apps/web/src/lib/modules/writing/queries.ts new file mode 100644 index 000000000..5d65bd468 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/writing/queries.ts @@ -0,0 +1,267 @@ +/** + * Reactive queries + pure helpers for the Writing module. + */ + +import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; +import { decryptRecords } from '$lib/data/crypto'; +import { db } from '$lib/data/database'; +import { scopedForModule } from '$lib/data/scope'; +import type { + LocalDraft, + LocalDraftVersion, + LocalGeneration, + LocalWritingStyle, + Draft, + DraftVersion, + Generation, + WritingStyle, + DraftKind, + DraftStatus, +} from './types'; + +// ─── Type Converters ───────────────────────────────────── + +export function toDraft(local: LocalDraft): Draft { + const now = new Date().toISOString(); + return { + id: local.id, + kind: local.kind, + status: local.status, + title: local.title, + briefing: local.briefing, + styleId: local.styleId ?? null, + styleOverrides: local.styleOverrides ?? null, + references: local.references ?? [], + currentVersionId: local.currentVersionId ?? null, + publishedTo: local.publishedTo ?? [], + isFavorite: local.isFavorite ?? false, + visibility: local.visibility ?? 'space', + createdAt: local.createdAt ?? now, + updatedAt: local.updatedAt ?? now, + }; +} + +export function toDraftVersion(local: LocalDraftVersion): DraftVersion { + const now = new Date().toISOString(); + return { + id: local.id, + draftId: local.draftId, + versionNumber: local.versionNumber, + content: local.content, + wordCount: local.wordCount ?? 0, + generationId: local.generationId ?? null, + isAiGenerated: local.isAiGenerated ?? false, + parentVersionId: local.parentVersionId ?? null, + summary: local.summary ?? null, + createdAt: local.createdAt ?? now, + }; +} + +export function toGeneration(local: LocalGeneration): Generation { + const now = new Date().toISOString(); + return { + id: local.id, + draftId: local.draftId, + kind: local.kind, + status: local.status, + prompt: local.prompt, + provider: local.provider, + model: local.model ?? null, + params: local.params ?? null, + inputSelection: local.inputSelection ?? null, + output: local.output ?? null, + outputVersionId: local.outputVersionId ?? null, + startedAt: local.startedAt ?? null, + completedAt: local.completedAt ?? null, + durationMs: local.durationMs ?? null, + tokenUsage: local.tokenUsage ?? null, + error: local.error ?? null, + missionId: local.missionId ?? null, + createdAt: local.createdAt ?? now, + }; +} + +export function toWritingStyle(local: LocalWritingStyle): WritingStyle { + const now = new Date().toISOString(); + return { + id: local.id, + name: local.name, + description: local.description, + source: local.source, + presetId: local.presetId ?? null, + samples: local.samples ?? [], + extractedPrinciples: local.extractedPrinciples ?? null, + isSpaceDefault: local.isSpaceDefault ?? false, + isFavorite: local.isFavorite ?? false, + createdAt: local.createdAt ?? now, + updatedAt: local.updatedAt ?? now, + }; +} + +// ─── Live Queries ───────────────────────────────────────── + +export function useAllDrafts() { + return useLiveQueryWithDefault(async () => { + const locals = await scopedForModule('writing', 'writingDrafts').toArray(); + const visible = locals.filter((d) => !d.deletedAt); + const decrypted = await decryptRecords('writingDrafts', visible); + return decrypted.map(toDraft); + }, [] as Draft[]); +} + +export function useDraft(id: string) { + return useLiveQueryWithDefault( + async () => { + if (!id) return null; + const row = await db.table('writingDrafts').get(id); + if (!row || row.deletedAt) return null; + const [decrypted] = await decryptRecords('writingDrafts', [row]); + return decrypted ? toDraft(decrypted) : null; + }, + null as Draft | null + ); +} + +export function useVersionsForDraft(draftId: string) { + return useLiveQueryWithDefault(async () => { + if (!draftId) return [] as DraftVersion[]; + const rows = await db + .table('writingDraftVersions') + .where('draftId') + .equals(draftId) + .toArray(); + const visible = rows.filter((v) => !v.deletedAt); + const decrypted = await decryptRecords('writingDraftVersions', visible); + return decrypted.map(toDraftVersion).sort((a, b) => a.versionNumber - b.versionNumber); + }, [] as DraftVersion[]); +} + +export function useVersion(versionId: string) { + return useLiveQueryWithDefault( + async () => { + if (!versionId) return null; + const row = await db.table('writingDraftVersions').get(versionId); + if (!row || row.deletedAt) return null; + const [decrypted] = await decryptRecords('writingDraftVersions', [row]); + return decrypted ? toDraftVersion(decrypted) : null; + }, + null as DraftVersion | null + ); +} + +/** + * Live-track a draft's *current* version by following the pointer on the + * draft row. Re-runs whenever either the draft or the version table + * changes — so flipping `currentVersionId` via `restoreVersion` shows up + * automatically in the editor. + */ +export function useCurrentVersionForDraft(draftId: string) { + return useLiveQueryWithDefault( + async () => { + if (!draftId) return null; + const draftRow = await db.table('writingDrafts').get(draftId); + if (!draftRow || draftRow.deletedAt || !draftRow.currentVersionId) return null; + const versionRow = await db + .table('writingDraftVersions') + .get(draftRow.currentVersionId); + if (!versionRow || versionRow.deletedAt) return null; + const [decrypted] = await decryptRecords('writingDraftVersions', [versionRow]); + return decrypted ? toDraftVersion(decrypted) : null; + }, + null as DraftVersion | null + ); +} + +export function useGenerationsForDraft(draftId: string) { + return useLiveQueryWithDefault(async () => { + if (!draftId) return [] as Generation[]; + const rows = await db + .table('writingGenerations') + .where('draftId') + .equals(draftId) + .toArray(); + const visible = rows.filter((g) => !g.deletedAt); + const decrypted = await decryptRecords('writingGenerations', visible); + return decrypted.map(toGeneration).sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + }, [] as Generation[]); +} + +export function useAllStyles() { + return useLiveQueryWithDefault(async () => { + const rows = await scopedForModule( + 'writing', + 'writingStyles' + ).toArray(); + const visible = rows.filter((s) => !s.deletedAt); + const decrypted = await decryptRecords('writingStyles', visible); + return decrypted.map(toWritingStyle); + }, [] as WritingStyle[]); +} + +// ─── Pure Helpers ───────────────────────────────────────── + +export function filterByKind(drafts: Draft[], kind: DraftKind): Draft[] { + return drafts.filter((d) => d.kind === kind); +} + +export function filterByStatus(drafts: Draft[], status: DraftStatus): Draft[] { + return drafts.filter((d) => d.status === status); +} + +export function searchDrafts(drafts: Draft[], query: string): Draft[] { + const lower = query.toLowerCase(); + return drafts.filter( + (d) => d.title.toLowerCase().includes(lower) || d.briefing.topic.toLowerCase().includes(lower) + ); +} + +export function sortByUpdated(drafts: Draft[]): Draft[] { + return [...drafts].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); +} + +export function groupByKind(drafts: Draft[]): Record { + const out: Record = { + blog: [], + essay: [], + email: [], + social: [], + story: [], + letter: [], + speech: [], + 'cover-letter': [], + 'product-description': [], + 'press-release': [], + bio: [], + other: [], + }; + for (const d of drafts) out[d.kind].push(d); + return out; +} + +export interface WritingStats { + totalDrafts: number; + byStatus: Record; + totalWords: number; + currentlyActive: number; +} + +export function computeStats(drafts: Draft[], versions: DraftVersion[]): WritingStats { + const byStatus: Record = { + draft: 0, + refining: 0, + complete: 0, + published: 0, + }; + let currentlyActive = 0; + for (const d of drafts) { + byStatus[d.status]++; + if (d.status === 'draft' || d.status === 'refining') currentlyActive++; + } + const totalWords = versions.reduce((acc, v) => acc + v.wordCount, 0); + return { + totalDrafts: drafts.length, + byStatus, + totalWords, + currentlyActive, + }; +} diff --git a/apps/mana/apps/web/src/lib/modules/writing/stores/drafts.svelte.ts b/apps/mana/apps/web/src/lib/modules/writing/stores/drafts.svelte.ts new file mode 100644 index 000000000..71d9a09b8 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/writing/stores/drafts.svelte.ts @@ -0,0 +1,312 @@ +/** + * Writing drafts store — mutation-only service for drafts + draft versions. + * + * Creating a draft always creates an initial empty version (v1) in the same + * transaction so the UI can always render a "current version" without + * having to handle a null/missing body. Live typing mutates the current + * version in-place; `createCheckpointVersion` snapshots the current content + * as a new numbered version. `restoreVersion` sets `currentVersionId` to an + * older version without destroying history. + * + * Full-regeneration flows (M3+) will write new versions via the generations + * store and then call `pointToVersion` — never append to an existing version. + */ + +import { encryptRecord } from '$lib/data/crypto'; +import { emitDomainEvent } from '$lib/data/events'; +import { getActiveSpace } from '$lib/data/scope'; +import { getEffectiveUserId } from '$lib/data/current-user'; +import { + defaultVisibilityFor, + generateUnlistedToken, + type VisibilityLevel, +} from '@mana/shared-privacy'; +import { draftTable, draftVersionTable } from '../collections'; +import { toDraft, toDraftVersion } from '../queries'; +import { LENGTH_PRESETS, DEFAULT_LANGUAGE } from '../constants'; +import type { + LocalDraft, + LocalDraftVersion, + DraftKind, + DraftStatus, + DraftBriefing, + DraftReference, + DraftStyleOverrides, +} from '../types'; + +function wordCountOf(text: string): number { + const trimmed = text.trim(); + if (!trimmed) return 0; + return trimmed.split(/\s+/).length; +} + +function defaultBriefing(kind: DraftKind, topic: string): DraftBriefing { + return { + topic, + audience: null, + tone: null, + language: DEFAULT_LANGUAGE, + targetLength: LENGTH_PRESETS[kind], + extraInstructions: null, + useResearch: false, + }; +} + +export interface CreateDraftInput { + kind: DraftKind; + title: string; + briefing?: Partial & { topic: string }; + styleId?: string | null; + references?: DraftReference[]; + initialContent?: string; + status?: DraftStatus; + isFavorite?: boolean; +} + +export type UpdateDraftPatch = Partial< + Pick< + LocalDraft, + | 'title' + | 'kind' + | 'status' + | 'briefing' + | 'styleId' + | 'styleOverrides' + | 'references' + | 'isFavorite' + > +>; + +export const draftsStore = { + /** + * Create a draft + its first (empty or pre-filled) version atomically. + * Returns the plaintext Draft snapshot + initial version id. + */ + async createDraft(input: CreateDraftInput) { + const draftId = crypto.randomUUID(); + const versionId = crypto.randomUUID(); + const briefingInput = input.briefing ?? { topic: input.title }; + const briefing: DraftBriefing = { + ...defaultBriefing(input.kind, briefingInput.topic), + ...briefingInput, + }; + const initialContent = input.initialContent ?? ''; + + const newDraft: LocalDraft = { + id: draftId, + kind: input.kind, + status: input.status ?? 'draft', + title: input.title, + briefing, + styleId: input.styleId ?? null, + styleOverrides: null, + references: input.references ?? [], + currentVersionId: versionId, + publishedTo: [], + isFavorite: input.isFavorite ?? false, + visibility: defaultVisibilityFor(getActiveSpace()?.type), + }; + + const newVersion: LocalDraftVersion = { + id: versionId, + draftId, + versionNumber: 1, + content: initialContent, + wordCount: wordCountOf(initialContent), + generationId: null, + isAiGenerated: false, + parentVersionId: null, + summary: null, + }; + + const draftSnapshot = toDraft({ ...newDraft }); + const versionSnapshot = toDraftVersion({ ...newVersion }); + + await encryptRecord('writingDrafts', newDraft); + await encryptRecord('writingDraftVersions', newVersion); + await draftTable.add(newDraft); + await draftVersionTable.add(newVersion); + + emitDomainEvent('WritingDraftCreated', 'writing', 'writingDrafts', draftId, { + draftId, + kind: input.kind, + title: input.title, + }); + + return { draft: draftSnapshot, version: versionSnapshot }; + }, + + async updateDraft(id: string, patch: UpdateDraftPatch) { + const wrapped = { ...patch } as Record; + await encryptRecord('writingDrafts', wrapped); + await draftTable.update(id, { + ...wrapped, + updatedAt: new Date().toISOString(), + }); + }, + + async updateBriefing(id: string, briefingPatch: Partial) { + const existing = await draftTable.get(id); + if (!existing) return; + const merged: DraftBriefing = { ...existing.briefing, ...briefingPatch }; + await draftsStore.updateDraft(id, { briefing: merged }); + }, + + async updateStyleOverrides(id: string, overrides: DraftStyleOverrides | null) { + await draftsStore.updateDraft(id, { styleOverrides: overrides }); + }, + + async setStatus(id: string, status: DraftStatus) { + const existing = await draftTable.get(id); + if (!existing || existing.status === status) return; + await draftTable.update(id, { + status, + updatedAt: new Date().toISOString(), + }); + emitDomainEvent('WritingDraftStatusChanged', 'writing', 'writingDrafts', id, { + draftId: id, + before: existing.status, + after: status, + }); + }, + + async toggleFavorite(id: string) { + const existing = await draftTable.get(id); + if (!existing) return; + await draftTable.update(id, { + isFavorite: !existing.isFavorite, + updatedAt: new Date().toISOString(), + }); + }, + + async deleteDraft(id: string) { + const now = new Date().toISOString(); + await draftTable.update(id, { deletedAt: now, updatedAt: now }); + // Soft-delete every version belonging to the draft so they stop + // showing up in version-history queries. Generations we leave — + // they're audit records and shouldn't disappear silently. + const versions = await draftVersionTable.where('draftId').equals(id).toArray(); + await Promise.all( + versions.map((v) => draftVersionTable.update(v.id, { deletedAt: now, updatedAt: now })) + ); + emitDomainEvent('WritingDraftDeleted', 'writing', 'writingDrafts', id, { draftId: id }); + }, + + /** + * In-place edit of the draft's current version. This is the path for + * live typing in the editor and for selection-refinement application; + * it does NOT create a new version record. Use `createCheckpointVersion` + * when the user wants to freeze the current state. + */ + async updateVersionContent(versionId: string, content: string) { + const existing = await draftVersionTable.get(versionId); + if (!existing) return; + const wrapped: Record = { + content, + wordCount: wordCountOf(content), + }; + await encryptRecord('writingDraftVersions', wrapped); + const now = new Date().toISOString(); + await draftVersionTable.update(versionId, { ...wrapped, updatedAt: now }); + // Bump the owning draft's updatedAt so ListView sorting reflects + // the edit even though nothing on the draft itself changed. + await draftTable.update(existing.draftId, { updatedAt: now }); + }, + + /** + * Take the current content of `sourceVersionId` and copy it into a + * new version with the next versionNumber; point the draft at it. + * Used by the "Als Checkpoint speichern" button. + */ + async createCheckpointVersion( + draftId: string, + sourceVersionId: string, + opts: { isAiGenerated?: boolean; generationId?: string | null; summary?: string | null } = {} + ) { + const source = await draftVersionTable.get(sourceVersionId); + if (!source) throw new Error(`Version ${sourceVersionId} not found`); + const existing = await draftVersionTable.where('draftId').equals(draftId).toArray(); + const nextNumber = Math.max(0, ...existing.map((v) => v.versionNumber)) + 1; + + const newVersion: LocalDraftVersion = { + id: crypto.randomUUID(), + draftId, + versionNumber: nextNumber, + content: source.content, + wordCount: source.wordCount, + generationId: opts.generationId ?? null, + isAiGenerated: opts.isAiGenerated ?? false, + parentVersionId: sourceVersionId, + summary: opts.summary ?? null, + }; + const snapshot = toDraftVersion({ ...newVersion }); + await encryptRecord('writingDraftVersions', newVersion); + await draftVersionTable.add(newVersion); + await draftTable.update(draftId, { + currentVersionId: newVersion.id, + updatedAt: new Date().toISOString(), + }); + emitDomainEvent( + 'WritingDraftVersionCreated', + 'writing', + 'writingDraftVersions', + newVersion.id, + { + draftId, + versionId: newVersion.id, + versionNumber: nextNumber, + isAiGenerated: newVersion.isAiGenerated, + } + ); + return snapshot; + }, + + /** + * Restore an older version as the draft's current version. Does NOT + * destroy newer versions — the version history still shows them so + * the user can re-restore. Implemented as a pointer flip, not a copy, + * because the user wants the old text back verbatim. + */ + async restoreVersion(draftId: string, versionId: string) { + const version = await draftVersionTable.get(versionId); + if (!version || version.draftId !== draftId) { + throw new Error(`Version ${versionId} does not belong to draft ${draftId}`); + } + await draftTable.update(draftId, { + currentVersionId: versionId, + updatedAt: new Date().toISOString(), + }); + emitDomainEvent('WritingDraftVersionReverted', 'writing', 'writingDrafts', draftId, { + draftId, + restoredVersionId: versionId, + versionNumber: version.versionNumber, + }); + }, + + async setVisibility(id: string, next: VisibilityLevel) { + const existing = await draftTable.get(id); + if (!existing) throw new Error(`Draft ${id} not found`); + const before: VisibilityLevel = existing.visibility ?? 'space'; + if (before === next) return; + + const now = new Date().toISOString(); + const patch: Partial = { + visibility: next, + visibilityChangedAt: now, + visibilityChangedBy: getEffectiveUserId(), + updatedAt: now, + }; + if (next === 'unlisted' && !existing.unlistedToken) { + patch.unlistedToken = generateUnlistedToken(); + } else if (next !== 'unlisted' && existing.unlistedToken) { + patch.unlistedToken = undefined; + } + await draftTable.update(id, patch); + emitDomainEvent('VisibilityChanged', 'writing', 'writingDrafts', id, { + recordId: id, + collection: 'writingDrafts', + before, + after: next, + }); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/writing/stores/styles.svelte.ts b/apps/mana/apps/web/src/lib/modules/writing/stores/styles.svelte.ts new file mode 100644 index 000000000..4a9b49ff7 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/writing/stores/styles.svelte.ts @@ -0,0 +1,117 @@ +/** + * Writing styles store — mutation service for user-defined style records. + * + * Preset styles are not stored in Dexie (they live in `presets/styles.ts`) + * unless a user explicitly "favourites" a preset — that writes a row with + * `source='preset'` + `presetId`. Custom styles (typed description, sample- + * trained, self-trained) are always rows. + * + * Sample-extraction (training) lives in M4.1 and calls into this store via + * `upsertExtractedPrinciples`. + */ + +import { encryptRecord } from '$lib/data/crypto'; +import { emitDomainEvent } from '$lib/data/events'; +import { writingStyleTable } from '../collections'; +import { toWritingStyle } from '../queries'; +import type { + LocalWritingStyle, + StyleSource, + StyleSample, + StyleExtractedPrinciples, +} from '../types'; + +export interface CreateStyleInput { + name: string; + description: string; + source: StyleSource; + presetId?: string | null; + samples?: StyleSample[]; + extractedPrinciples?: StyleExtractedPrinciples | null; + isSpaceDefault?: boolean; + isFavorite?: boolean; +} + +export type UpdateStylePatch = Partial< + Pick< + LocalWritingStyle, + 'name' | 'description' | 'samples' | 'extractedPrinciples' | 'isSpaceDefault' | 'isFavorite' + > +>; + +export const stylesStore = { + async createStyle(input: CreateStyleInput) { + const newLocal: LocalWritingStyle = { + id: crypto.randomUUID(), + name: input.name, + description: input.description, + source: input.source, + presetId: input.presetId ?? null, + samples: input.samples ?? [], + extractedPrinciples: input.extractedPrinciples ?? null, + isSpaceDefault: input.isSpaceDefault ?? false, + isFavorite: input.isFavorite ?? false, + }; + const snapshot = toWritingStyle({ ...newLocal }); + await encryptRecord('writingStyles', newLocal); + await writingStyleTable.add(newLocal); + emitDomainEvent('WritingStyleCreated', 'writing', 'writingStyles', newLocal.id, { + styleId: newLocal.id, + source: input.source, + name: input.name, + }); + return snapshot; + }, + + async updateStyle(id: string, patch: UpdateStylePatch) { + const wrapped = { ...patch } as Record; + await encryptRecord('writingStyles', wrapped); + await writingStyleTable.update(id, { + ...wrapped, + updatedAt: new Date().toISOString(), + }); + }, + + async upsertExtractedPrinciples(id: string, principles: StyleExtractedPrinciples) { + await stylesStore.updateStyle(id, { extractedPrinciples: principles }); + emitDomainEvent('WritingStyleTrainedFromSamples', 'writing', 'writingStyles', id, { + styleId: id, + toneTraitsCount: principles.toneTraits.length, + }); + }, + + async toggleFavorite(id: string) { + const existing = await writingStyleTable.get(id); + if (!existing) return; + await writingStyleTable.update(id, { + isFavorite: !existing.isFavorite, + updatedAt: new Date().toISOString(), + }); + }, + + async setSpaceDefault(id: string, isDefault: boolean) { + // Only one style per space can be the default; flip the others off first. + if (isDefault) { + const existing = await writingStyleTable + .filter((s) => s.isSpaceDefault && s.id !== id) + .toArray(); + await Promise.all( + existing.map((s) => + writingStyleTable.update(s.id, { + isSpaceDefault: false, + updatedAt: new Date().toISOString(), + }) + ) + ); + } + await writingStyleTable.update(id, { + isSpaceDefault: isDefault, + updatedAt: new Date().toISOString(), + }); + }, + + async deleteStyle(id: string) { + const now = new Date().toISOString(); + await writingStyleTable.update(id, { deletedAt: now, updatedAt: now }); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/writing/types.ts b/apps/mana/apps/web/src/lib/modules/writing/types.ts new file mode 100644 index 000000000..83b55aa5d --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/writing/types.ts @@ -0,0 +1,265 @@ +/** + * Writing module types — an AI-first Ghostwriter surface for intentionally + * produced prose. A `Draft` carries briefing + references + a pointer to its + * current version; every full (re)generation writes a new immutable + * `DraftVersion`; `Generation` records capture the provider-level call so + * we can show live progress and audit which model/prompt produced what. + * `WritingStyle` lives as its own table so a style can be reused across + * drafts, trained from samples, and (later) linked from agent personas. + * + * Plan: `docs/plans/writing-module.md`. + */ + +import type { BaseRecord } from '@mana/local-store'; +import type { VisibilityLevel } from '@mana/shared-privacy'; + +// ─── Discriminators & Enums ────────────────────────────── + +export type DraftKind = + | 'blog' + | 'essay' + | 'email' + | 'social' + | 'story' + | 'letter' + | 'speech' + | 'cover-letter' + | 'product-description' + | 'press-release' + | 'bio' + | 'other'; + +export type DraftStatus = 'draft' | 'refining' | 'complete' | 'published'; + +export type DraftLengthUnit = 'words' | 'chars' | 'minutes'; + +export type GenerationStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'; + +export type GenerationKind = + | 'outline' + | 'draft-from-brief' + | 'draft-from-outline' + | 'selection-rewrite' + | 'selection-shorten' + | 'selection-expand' + | 'selection-tone' + | 'selection-translate' + | 'full-regenerate'; + +export type GenerationProvider = 'mana-ai' | 'mana-llm' | 'local-llm'; + +export type StyleSource = 'preset' | 'custom-description' | 'sample-trained' | 'self-trained'; + +export type DraftReferenceKind = + | 'article' + | 'note' + | 'library' + | 'kontext' + | 'goal' + | 'url' + | 'me-image'; + +export type DraftPublishModule = 'website' | 'articles' | 'social-relay' | 'mail' | 'presi'; + +// ─── Sub-objects ───────────────────────────────────────── + +export interface DraftBriefing { + topic: string; + audience?: string | null; + tone?: string | null; + /** ISO language code; default 'de'. */ + language: string; + targetLength?: { + type: DraftLengthUnit; + value: number; + } | null; + extraInstructions?: string | null; + /** When true, the runner injects mana-research results as standing context. */ + useResearch?: boolean; +} + +export interface DraftStyleOverrides { + tone?: string | null; + styleNotes?: string | null; +} + +export interface DraftReference { + kind: DraftReferenceKind; + /** Module-local id; present for every kind except 'url'. */ + targetId?: string | null; + /** External URL; present for kind='url', optional otherwise. */ + url?: string | null; + /** Free-form note about why this reference matters for the draft. */ + note?: string | null; +} + +export interface DraftPublishTarget { + module: DraftPublishModule; + targetId: string; + publishedAt: string; +} + +export interface DraftGenerationParams { + temperature?: number | null; + maxTokens?: number | null; +} + +export interface DraftSelection { + start: number; + end: number; +} + +export interface DraftTokenUsage { + input: number; + output: number; +} + +export interface StyleSample { + label: string; + text: string; + /** Optional pointer back to the source (e.g. 'journal:abc', 'articles:xyz'). */ + sourceRef?: string | null; +} + +export interface StyleExtractedPrinciples { + toneTraits: string[]; + sentenceLengthAvg?: number | null; + vocabulary?: string[]; + examples?: string[]; + rawAnalysis?: string | null; + extractedAt: string; +} + +// ─── Local Records (Dexie) ─────────────────────────────── + +export interface LocalDraft extends BaseRecord { + kind: DraftKind; + status: DraftStatus; + title: string; + briefing: DraftBriefing; + /** FK to writingStyles; null = ad-hoc (no saved style). */ + styleId?: string | null; + styleOverrides?: DraftStyleOverrides | null; + references: DraftReference[]; + /** Points at the current LocalDraftVersion.id; null until first generation. */ + currentVersionId?: string | null; + visibility?: VisibilityLevel; + visibilityChangedAt?: string; + visibilityChangedBy?: string; + unlistedToken?: string; + publishedTo?: DraftPublishTarget[]; + isFavorite: boolean; +} + +export interface LocalDraftVersion extends BaseRecord { + draftId: string; + versionNumber: number; + /** Markdown body of this version. */ + content: string; + wordCount: number; + generationId?: string | null; + isAiGenerated: boolean; + parentVersionId?: string | null; + /** Short auto-summary for the version-history panel. */ + summary?: string | null; +} + +export interface LocalGeneration extends BaseRecord { + draftId: string; + kind: GenerationKind; + status: GenerationStatus; + prompt: string; + provider: GenerationProvider; + model?: string | null; + params?: DraftGenerationParams | null; + /** Only set for selection-* kinds. */ + inputSelection?: DraftSelection | null; + output?: string | null; + outputVersionId?: string | null; + startedAt?: string | null; + completedAt?: string | null; + durationMs?: number | null; + tokenUsage?: DraftTokenUsage | null; + error?: string | null; + /** FK into a mana-ai mission when the generation ran server-side. */ + missionId?: string | null; +} + +export interface LocalWritingStyle extends BaseRecord { + name: string; + description: string; + source: StyleSource; + presetId?: string | null; + samples?: StyleSample[]; + extractedPrinciples?: StyleExtractedPrinciples | null; + /** True when this style is the Space-wide default for team spaces. */ + isSpaceDefault: boolean; + isFavorite: boolean; +} + +// ─── Domain Types (plaintext, for UI) ──────────────────── + +export interface Draft { + id: string; + kind: DraftKind; + status: DraftStatus; + title: string; + briefing: DraftBriefing; + styleId: string | null; + styleOverrides: DraftStyleOverrides | null; + references: DraftReference[]; + currentVersionId: string | null; + visibility: VisibilityLevel; + publishedTo: DraftPublishTarget[]; + isFavorite: boolean; + createdAt: string; + updatedAt: string; +} + +export interface DraftVersion { + id: string; + draftId: string; + versionNumber: number; + content: string; + wordCount: number; + generationId: string | null; + isAiGenerated: boolean; + parentVersionId: string | null; + summary: string | null; + createdAt: string; +} + +export interface Generation { + id: string; + draftId: string; + kind: GenerationKind; + status: GenerationStatus; + prompt: string; + provider: GenerationProvider; + model: string | null; + params: DraftGenerationParams | null; + inputSelection: DraftSelection | null; + output: string | null; + outputVersionId: string | null; + startedAt: string | null; + completedAt: string | null; + durationMs: number | null; + tokenUsage: DraftTokenUsage | null; + error: string | null; + missionId: string | null; + createdAt: string; +} + +export interface WritingStyle { + id: string; + name: string; + description: string; + source: StyleSource; + presetId: string | null; + samples: StyleSample[]; + extractedPrinciples: StyleExtractedPrinciples | null; + isSpaceDefault: boolean; + isFavorite: boolean; + createdAt: string; + updatedAt: string; +} diff --git a/apps/mana/apps/web/src/lib/modules/writing/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/writing/views/DetailView.svelte new file mode 100644 index 000000000..0ac74e284 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/writing/views/DetailView.svelte @@ -0,0 +1,355 @@ + + + +{#if draft$.loading} +

Lädt…

+{:else if !draft} +
+

Dieser Draft existiert nicht (mehr).

+ Zurück zur Übersicht +
+{:else} +
+
+
+ ← Alle Drafts +
+
+ + {kind?.de} +
+

{draft.title || draft.briefing.topic || 'Unbenannt'}

+
+
+ + +
+
+ +
+ +
+ {#each STATUS_ORDER as s (s)} + {#if s !== draft.status} + + {/if} + {/each} +
+
+
+ +
+ + {#if briefingOpen} + (briefingOpen = false)} /> + {/if} +
+ +
+
+ {#if currentVersion} +
+
+ Version {currentVersion.versionNumber} + {#if currentVersion.isAiGenerated} + KI + {/if} +
+ +
+ + {:else} +

Diese Version existiert nicht mehr.

+ {/if} +
+ + +
+
+{/if} + + diff --git a/apps/mana/apps/web/src/lib/modules/writing/views/ListView.svelte b/apps/mana/apps/web/src/lib/modules/writing/views/ListView.svelte new file mode 100644 index 000000000..e0777a69b --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/writing/views/ListView.svelte @@ -0,0 +1,259 @@ + + + +
+
+
+ + +
+ + (activeKind = k)} /> + +
+ (activeStatus = s)} /> + +
+
+ + {#if showCreate} +
+ (showCreate = false)} + oncreated={onCreated} + /> +
+ {/if} + + {#if drafts$.loading} +

Lädt…

+ {:else if filtered.length === 0} +
+ {#if drafts.length === 0} +

Noch keine Drafts

+

+ Klick auf + Neuer Draft, brief dem Ghostwriter Thema, Stil und Länge — M3 + ergänzt die Generate-Funktion. Bis dahin kannst du Drafts manuell erstellen und editieren. +

+ {:else} +

Keine Drafts passen zum aktuellen Filter.

+ {/if} +
+ {:else} +
+ {#each filtered as draft (draft.id)} + + {/each} +
+ {/if} +
+ + diff --git a/apps/mana/apps/web/src/routes/(app)/writing/+page.svelte b/apps/mana/apps/web/src/routes/(app)/writing/+page.svelte new file mode 100644 index 000000000..32176f4b9 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/writing/+page.svelte @@ -0,0 +1,12 @@ + + + + Writing - Mana + + + + + diff --git a/apps/mana/apps/web/src/routes/(app)/writing/draft/[id]/+page.svelte b/apps/mana/apps/web/src/routes/(app)/writing/draft/[id]/+page.svelte new file mode 100644 index 000000000..0567b5200 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/writing/draft/[id]/+page.svelte @@ -0,0 +1,17 @@ + + + + Draft - Writing - Mana + + + + {#key id} + + {/key} + diff --git a/docs/plans/writing-module.md b/docs/plans/writing-module.md new file mode 100644 index 000000000..4ffc6101f --- /dev/null +++ b/docs/plans/writing-module.md @@ -0,0 +1,458 @@ +# Writing — Module Plan + +## Status (2026-04-24) + +**Planung.** Noch nichts geshipped. Nächster Schritt: M1 (Skelett). + +## Ziel + +Ein Modul, mit dem der Nutzer dem AI-Agenten Brief + Stil + Referenzen gibt und **fertige Texte** produziert: Blogposts, Essays, Mails, Bewerbungen, Social Posts, Reden, Storys, Produkttexte. Kernfrage: *"Ich brauche einen Text zu X im Stil Y — schreib ihn."* + +**Start-Modus: Ghostwriter.** Input → fertiger Entwurf. Nutzer bewertet ganze Versionen, verfeinert Stellen gezielt mit Selection-Tools. Ein späterer **Canvas-Modus** (freies Tippen, Inline-Autocomplete, `/`-Kommandos) ist als M9 eingeplant, aber nicht Teil des Kern-Scopes. + +Nicht im Scope Phase 1: +- Freies Notizen/Journalen (→ `notes` / `journal`) +- Speichern externer Artikel (→ `articles`) +- Kollaboratives Echtzeit-Editing +- Automatische Veröffentlichung (Hand-Off zu `website` / `articles` schon, aber User löst aus) + +## Abgrenzung + +| Modul | Unterschied | +|---|---| +| `notes` | unstrukturierte Snippets, persönlich, ohne Zweck | +| `journal` | datierte Reflexionen, persönlich | +| `articles` | **konsumierte** Artikel (Readability-Extrakt), Highlights — hier wird gelesen, nicht produziert | +| `chat` | Gespräch, nicht produzierter Text als Artefakt | +| `presi` / `website` | Konsumenten von Text — können aus Writing-Drafts gespeist werden | +| `news-research` / `mana-research` | Recherche-Provider; Writing **konsumiert** diese Quellen als Referenz | + +Writing = **intentional produzierter Prosa-Text mit Zweck und Adressat**. Existiert heute nicht. + +## Getroffene Entscheidungen (vorab, 2026-04-24) + +1. **Ghostwriter-Modus zuerst**, Canvas später. +2. **Styles ≠ Personas**, aber verknüpfbar. Personas (`mana-persona-runner`) bleiben für Agent-Loops; Writing hat eigene `WritingStyle`-Entität. Eine Persona kann einen `defaultWritingStyleId` referenzieren — so nutzt z.B. ein "Marketing-Agent" automatisch den "Corporate Tone"-Style. +3. **Versionierung**: Jede *volle* Generierung/Regeneration → neue `LocalDraftVersion`. Selection-basierte Refinements (Shorten/Expand/Tone) modifizieren die aktuelle Version in-place mit lokalem Undo-Stack, ohne Versions-Explosion. Erst wenn der User "Diese Änderungen übernehmen als neue Version" klickt, wird eine Version geschrieben. +4. **Kind-Liste breit von Anfang an**: `blog`, `essay`, `email`, `social`, `story`, `letter`, `speech`, `cover-letter`, `product-description`, `press-release`, `bio`, `other`. Start mit vollem Set — Templates pro Kind kommen später. Das Discriminator-Feld ist billig; nachträglich einen Kind umzubenennen ist teurer. +5. **Space-Kontext als Default-Stil**: In einem Firmen-/Team-Space wird ein `spaceDefaultStyleId` unterstützt. Der Space kann "Corporate Tone" + standardmäßig verknüpfte kontextDocs als Default-Referenzen setzen. Personal-Space → kein Default, User wählt Style pro Draft. + +## Modul-Struktur + +``` +apps/mana/apps/web/src/lib/modules/writing/ +├── types.ts # LocalDraft, Draft, Kind, Status, LocalGeneration, LocalDraftVersion, LocalWritingStyle +├── collections.ts # drafts + draftVersions + generations + writingStyles Tables + Guest-Seed +├── queries.ts # useAllDrafts, useDraftsByKind, useDraft(id), useVersions(draftId), useStyles, useStats +├── stores/ +│ ├── drafts.svelte.ts # createDraft, updateBriefing, deleteDraft, setVisibility, publishVersion, restoreVersion +│ ├── generations.svelte.ts # startGeneration, cancelGeneration, applyGenerationAsVersion +│ └── styles.svelte.ts # createStyle, updateStyle, trainStyleFromSamples, deleteStyle +├── components/ +│ ├── BriefingForm.svelte # topic, kind, length, tone, audience, language, style-picker, reference-picker +│ ├── DraftCard.svelte # kompakter Listeneintrag (Titel + kind-Badge + Preview + Last-Updated + Visibility-Icon) +│ ├── KindTabs.svelte # Alle | Blog | Essay | E-Mail | Social | Story | Brief | Rede | ... +│ ├── StatusBadge.svelte # entwurf | in-überarbeitung | fertig | veröffentlicht +│ ├── StylePicker.svelte # Preset-Liste + Custom-Styles + "Schreibe wie ich"-Option +│ ├── ReferencePicker.svelte # cross-modul Picker (articles, notes, library, kontext, goals, URLs) +│ ├── VersionHistory.svelte # vertikale Timeline aller Versions, Diff auf Click, Revert-Button +│ ├── DiffView.svelte # seitlicher oder Inline-Diff zwischen zwei Versionen +│ ├── SelectionToolbar.svelte # erscheint bei Text-Markierung: Kürzen / Erweitern / Ton / Umschreiben / Übersetzen +│ ├── GenerationStatus.svelte # Fortschritts-UI während Generation läuft (Streaming-Preview) +│ └── ProposalInbox.svelte # Refine-Vorschläge, die auf User-Approval warten +├── views/ +│ ├── ListView.svelte # Modul-Root: KindTabs + Grid of DraftCards + "+ Neuer Draft"-FAB +│ └── DetailView.svelte # Drei-Spalten-Layout (Briefing | Text | Tools) +├── tools.ts # AI-Tools (siehe AI-Integration) +├── constants.ts # KIND_LABELS, TONE_PRESETS, LENGTH_PRESETS, STYLE_PRESETS +├── presets/ +│ └── styles.ts # Preset-Styles: Akademisch, LinkedIn, Hemingway, Casual-Blog, Buzzfeed-Listicle, Nachrichten, ... +├── module.config.ts # { appId: 'writing', tables: [{ name: 'drafts' }, { name: 'draftVersions' }, { name: 'generations' }, { name: 'writingStyles' }] } +└── index.ts # Re-Exports +``` + +## Daten-Schema + +### `LocalDraft` (Dexie) + +```typescript +export type DraftKind = + | 'blog' | 'essay' | 'email' | 'social' | 'story' + | 'letter' | 'speech' | 'cover-letter' + | 'product-description' | 'press-release' | 'bio' | 'other'; + +export type DraftStatus = 'draft' | 'refining' | 'complete' | 'published'; + +export interface LocalDraft extends BaseRecord { + kind: DraftKind; // plaintext — Diskriminator + status: DraftStatus; // plaintext — filterbar + title: string; // encrypted + briefing: { // encrypted — Kern-Eingabe + topic: string; + audience?: string; + tone?: string; // z.B. "sachlich", "humorvoll", "motivierend" + language: string; // ISO-Code, default 'de' + targetLength?: { // optional — default abgeleitet von kind + type: 'words' | 'chars' | 'minutes'; + value: number; + }; + extraInstructions?: string; + }; + styleId?: string | null; // plaintext — FK auf LocalWritingStyle, null = Ad-hoc + styleOverrides?: { // encrypted — Style-Felder, die diesen Draft übersteuern + tone?: string; + styleNotes?: string; + } | null; + references: DraftReference[]; // plaintext IDs + URLs; encrypted Notes + currentVersionId?: string | null; // plaintext — zeigt auf aktive Version + visibility: VisibilityLevel; // plaintext + visibilityChangedAt?: string | null; // plaintext + visibilityChangedBy?: string | null; // plaintext (userId) + unlistedToken?: string | null; // plaintext — minted beim Flip auf 'unlisted' + publishedTo?: DraftPublishTarget[]; // plaintext — ['website:block/abc', 'articles:xyz'] + isFavorite: boolean; // plaintext +} + +export interface DraftReference { + kind: 'article' | 'note' | 'library' | 'kontext' | 'goal' | 'url' | 'me-image'; + targetId?: string; // plaintext, module-lokal + url?: string; // plaintext + note?: string; // encrypted — was der User an dieser Quelle relevant findet +} + +export type DraftPublishTarget = { + module: 'website' | 'articles' | 'social-relay' | 'mail' | 'presi'; + targetId: string; + publishedAt: string; // ISO +}; +``` + +### `LocalDraftVersion` + +```typescript +export interface LocalDraftVersion extends BaseRecord { + draftId: string; // plaintext — FK + versionNumber: number; // plaintext — 1, 2, 3... + content: string; // encrypted — der Text selbst (Markdown) + wordCount: number; // plaintext + generationId?: string | null; // plaintext — falls AI-generiert + isAiGenerated: boolean; // plaintext + parentVersionId?: string | null; // plaintext — für Branching später + summary?: string | null; // encrypted — optional Auto-Summary fürs History-Panel +} +``` + +Selection-basierte Refinements erzeugen **keine** neue Version; sie mutieren den `content` der aktuellen Version. Ein Undo-Stack bleibt im lokalen State (nicht synced). "Als neue Version speichern" ist ein expliziter Button. + +### `LocalGeneration` + +```typescript +export type GenerationStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'; +export type GenerationKind = + | 'outline' // Outline aus Briefing + | 'draft-from-brief' // Volltext aus Briefing (direkt) + | 'draft-from-outline' // Volltext aus Outline + | 'selection-rewrite' // Mark. Passage umschreiben + | 'selection-shorten' | 'selection-expand' + | 'selection-tone' | 'selection-translate' + | 'full-regenerate'; + +export interface LocalGeneration extends BaseRecord { + draftId: string; // plaintext + kind: GenerationKind; // plaintext + status: GenerationStatus; // plaintext + prompt: string; // encrypted — finaler zusammengebauter Prompt + provider: 'mana-ai' | 'mana-llm' | 'local-llm'; // plaintext + model?: string | null; // plaintext — z.B. "claude-opus-4-7" + params?: { // plaintext + temperature?: number; + maxTokens?: number; + } | null; + inputSelection?: { start: number; end: number } | null; // plaintext — nur bei selection-* + output?: string | null; // encrypted — was generiert wurde + outputVersionId?: string | null; // plaintext — FK falls als Version gespeichert + startedAt?: string | null; // plaintext + completedAt?: string | null; // plaintext + durationMs?: number | null; // plaintext + tokenUsage?: { input: number; output: number } | null; // plaintext + error?: string | null; // plaintext — User-lesbarer Fehler + missionId?: string | null; // plaintext — FK zu mana-ai mission, falls async +} +``` + +### `LocalWritingStyle` + +```typescript +export type StyleSource = 'preset' | 'custom-description' | 'sample-trained' | 'self-trained'; + +export interface LocalWritingStyle extends BaseRecord { + name: string; // encrypted + description: string; // encrypted — Style-Beschreibung + source: StyleSource; // plaintext + presetId?: string | null; // plaintext — falls source='preset' + samples?: Array<{ // encrypted + label: string; + text: string; + sourceRef?: string; // z.B. 'journal:id', 'articles:id' + }>; + extractedPrinciples?: { // encrypted — cached Style-Extraktion + toneTraits: string[]; + sentenceLengthAvg?: number; + vocabulary?: string[]; + examples?: string[]; + rawAnalysis?: string; // Freitext-Analyse + extractedAt: string; + } | null; + isSpaceDefault: boolean; // plaintext — für Space-Kontext-Default + isFavorite: boolean; // plaintext +} +``` + +**Self-Training** (source='self-trained'): Tool sammelt 10–20 Snippets aus `journal` + `notes` + `articles` (Highlights) des Users, extrahiert Prinzipien einmalig, cached als `extractedPrinciples`. Explizite User-Aktion — keine Hintergrund-Analyse. + +### Encryption-Registry + +```typescript +// apps/mana/apps/web/src/lib/data/crypto/registry.ts +drafts: { + fields: ['title', 'briefing', 'styleOverrides', 'references'], // references: wegen .note + version: 1, +}, +draftVersions: { + fields: ['content', 'summary'], + version: 1, +}, +generations: { + fields: ['prompt', 'output'], + version: 1, +}, +writingStyles: { + fields: ['name', 'description', 'samples', 'extractedPrinciples'], + version: 1, +}, +``` + +Alles Nutzer-getippte: encrypted. IDs, Status, Counts, Timestamps, FK-Pointer: plaintext. + +## Routing + +``` +apps/mana/apps/web/src/routes/(app)/writing/ +├── +page.svelte # ListView: KindTabs + Grid +├── [kind]/+page.svelte # Deep-Link: /writing/blog, /writing/email ... +├── draft/[id]/+page.svelte # DetailView (drei-spaltig) +├── new/+page.svelte # Kurz-Briefing-Flow (1-Feld → Kind-Vorschlag → Briefing) +└── styles/+page.svelte # Styles-Verwaltung (Preset durchstöbern, eigene anlegen/trainieren) +``` + +## UI-Konzept + +### ListView (`/writing`) + +- **Top**: `KindTabs` (Alle | Blog | Essay | E-Mail | Social | Story | ...) +- **Sekundärleiste**: Status-Chips (Entwurf | In-Überarbeitung | Fertig | Veröffentlicht), Sort (Zuletzt bearbeitet | Titel | Wortzahl), Favoriten-Toggle +- **Grid**: `DraftCard` mit Titel + kind-Badge + 2-Zeilen-Preview (erste Zeilen der aktuellen Version) + Last-Updated + Visibility-Icon + Status-Badge +- **FAB "+"**: öffnet `/writing/new` + +### `/writing/new` — Kurz-Briefing-Flow + +Drei-Schritt-Wizard in einer Card: +1. "Was möchtest du schreiben?" — ein Textfeld. User tippt z.B. "LinkedIn Post zu meinem neuen Modul". +2. AI schlägt basierend auf Freitext vor: `kind='social'`, Länge=200 Wörter, Ton=professional-excited. User kann adjusten. +3. "Generate" → erstellt Draft, leitet zu DetailView weiter, startet erste Generation. + +Alternativ "Ohne Vorschlag anlegen" → leeres Briefing-Form. + +### DetailView (`/writing/draft/[id]`) + +**Drei Spalten** (responsiv: auf Mobil als Tabs): + +**Links — Briefing-Panel** (collapsible): +- `BriefingForm` mit Topic, Kind, Audience, Tone, Language, TargetLength, ExtraInstructions +- `StylePicker` — Preset, Custom, oder "Schreibe wie ich" +- `ReferencePicker` — Cross-Modul-Picker: articles, notes, library, kontext, goals, URLs +- "Generate" / "Regenerate" Button — triggert volle Generation → neue Version +- Visibility-Picker (`` aus shared-privacy) + +**Mitte — Text**: +- Editierbarer Textbereich (Markdown, WYSIWYG-Toggle) +- Bei Selektion: `SelectionToolbar` erscheint → Kürzen / Erweitern / Ton / Umschreiben / Übersetzen +- Top-Bar: aktuelle Version, Wortzahl, Sprache, "Als neue Version speichern"-Button +- Live-Streaming während aktiver Generation (Overlay mit Streaming-Preview) + +**Rechts — Tools & Context**: +- `VersionHistory` — Timeline aller Versions, Click → Diff, Revert +- Referenzen-Liste (aus Briefing) mit "Öffnen"-Link +- `ProposalInbox` — wartende Refine-Vorschläge (falls `propose`-Policy) +- "Veröffentlichen als..." → Dropdown: Website, Artikel, E-Mail, PDF-Export, Zwischenablage + +### Styles-Verwaltung (`/writing/styles`) + +- Grid: Preset-Styles + Custom-Styles +- Button "Eigenen Style trainieren" — öffnet Dialog: + - Option A: Style-Beschreibung eintippen ("akademisch, prägnant, aktiv formuliert") + - Option B: Textproben hochladen/aus bestehenden Drafts/Notes importieren → One-Shot-Extraction + - Option C: "Schreibe wie ich" — zieht Samples aus journal/notes/articles, extrahiert Prinzipien +- Pro Style: Preview-Box "So klingt's: [Beispiel-Absatz über Dummy-Topic]" — lazy generiert auf Klick + +## Style-System — Details + +### Preset-Library (`presets/styles.ts`) + +Erste Tranche: +- **Akademisch** — dicht, passive Voice erlaubt, Zitate, Konjunktiv +- **Casual Blog** — du-Ansprache, kurze Absätze, rhetorische Fragen +- **LinkedIn-Post** — Hook in Zeile 1, 1-Satz-Absätze, Emoji sparsam, Call-to-action am Ende +- **Twitter/X-Thread** — nummerierte Tweets, je ≤280 Chars, Cliffhanger +- **Hemingway** — deklarativ, kurze Sätze, minimal Adjektive +- **Nachrichtlich** — inverted pyramid, nüchtern, keine Meinung +- **Buzzfeed-Listicle** — Listenformat, überspitzte Einleitungen +- **Pitch / Sales** — Problem → Agitation → Solution-Struktur +- **Memoir** — 1. Person, sensorisch, Szenen statt Zusammenfassungen + +### Space-Default-Style + +- Personal-Spaces: kein Default; User wählt pro Draft (oder "Schreibe wie ich" ist Default nach erstem Self-Training). +- Team/Firmen-Spaces: `spaceDefaultStyleId` im `Space`-Record (Erweiterung in `spaces-foundation`). Ein Space-Admin kann einen Style als `isSpaceDefault=true` markieren. +- Vererbung: Briefing.styleId → Space-Default-Style → Kein Style (AI wählt generisch). + +### Persona-Linkage + +`mana-persona-runner` Personas bekommen ein optionales `defaultWritingStyleId`. Wenn eine Persona einen Writing-Draft erzeugt (via MCP `create_draft`-Tool), wird ihr Default-Style vorausgewählt. Personas und Styles bleiben getrennte Entitäten — die Linkage ist lose. + +## AI-Integration + +### Tools (`tools.ts` + `@mana/shared-ai`) + +| Tool | Policy | Beschreibung | +|---|---|---| +| `list_drafts` | auto | Liefert Drafts gefiltert nach `kind`/`status`, read-only | +| `get_draft` | auto | Voller Draft inkl. aktueller Version | +| `create_draft` | propose | Legt neuen Draft mit Briefing an (ohne Generation) | +| `generate_draft_content` | propose | Startet Generation auf existierendem Draft → schreibt neue Version | +| `generate_outline` | propose | Generiert Outline aus Briefing, als "Outline"-Section vor Volltext | +| `refine_selection` | propose | Mark. Passage umschreiben mit Instruction | +| `shorten_draft` | propose | Verkürzen auf Ziel-Wortzahl | +| `expand_draft` | propose | Ausweiten auf Ziel-Wortzahl | +| `change_tone` | propose | Ton wechseln | +| `translate_draft` | propose | In andere Sprache übersetzen — erstellt neuen Draft mit `language` und Link auf Original | +| `publish_draft` | propose | Nach website/articles/... veröffentlichen | +| `list_writing_styles` | auto | Alle verfügbaren Styles (Preset + Custom) | +| `train_style_from_samples` | propose | Neuen Custom-Style aus Sample-Set extrahieren | + +Alle `propose`-Tools landen in `ProposalInbox` mit Preview (Diff gegen aktuellen Content bei Refine-Tools). + +### Provider-Wahl (Runtime) + +| Fall | Provider | +|---|---| +| Kurztext (≤300 Wörter), synchron gewünscht | `mana-llm` direkt (oder `local-llm` als Fallback) | +| Langtext (>300 Wörter) | Mission über `mana-ai` — streamt zurück, versions-fähig | +| Offline / Privacy-max | `local-llm` (Gemma 4 E2B via WebGPU) — Qualität eingeschränkt | +| Mit Recherche-Flag | Mission über `mana-ai` mit pre-planning web-research-Injection (analog zu `news-research`-Keywords) | + +Die Entscheidung passiert im `generations.svelte.ts`-Store, nicht im Tool. Tools sind Provider-agnostisch. + +### Mission-Flow für Langtext + +1. `generate_draft_content` erstellt `LocalGeneration` mit `status='queued'`, provider=`mana-ai` +2. Store startet Mission über `mana-ai` mit Context: Briefing + Style (inkl. `extractedPrinciples`) + Referenzen (aufgelöst zu voll­text wo möglich) + Space-Kontext-Docs falls vorhanden +3. Mission-Runner kettet intern bis zu 5 Planner-Calls: + - Research (optional, falls Referenzen URLs enthalten ohne Inhalt) + - Outline (falls `generate_outline` separat gecalled oder automatisch bei langen Texten) + - Volltext-Generation + - Selbst-Review (optional — Qualitätscheck) + - Final Polish +4. Streaming-Output landet via Sync-Channel im Client-Store → UI zeigt live +5. Bei `status='succeeded'`: `applyGenerationAsVersion(generationId)` schreibt neue `LocalDraftVersion`, setzt `currentVersionId` + +### Recherche-Integration + +- Flag `briefing.useResearch: boolean` (im UI "Mit Web-Recherche schreiben") +- Wenn gesetzt, injectet mana-ai bei Mission-Start `mana-research` pre-planning (existing Code aus `news-research`) +- Gefundene Quellen werden automatisch als `DraftReference[]` mit `kind='url'` an den Draft gehängt +- Inline-Zitate optional als M7-Feature (Markdown-Footnotes) + +## Cross-Modul Integration + +### Als Konsument + +| Modul | Integration | +|---|---| +| `articles` | Als Referenz pickbar; Content fließt in Prompt | +| `notes` | Als Referenz pickbar | +| `library` | Entries als Referenz ("schreibe über Film X") | +| `kontext` | Kontext-Docs als Standing-Context, Space-Default-Referenzen | +| `goals` | Als Motivation-Anker ("Ziel-Update-Post") | +| `me-images` | Für Ghost-Writer mit Foto: picture-Generation eines Headers vor-/nach-geschaltet | +| `mana-research` | Bei `useResearch=true` automatisch | + +### Als Produzent + +| Ziel-Modul | Publish-Hook | +|---|---| +| `website` | Draft → neuer Text-Block in ausgewählter Page | +| `articles` | Als "Eigen-Artikel" speichern (mit Autor=Self) | +| `social-relay` | Zu Social-Plattformen senden (falls Modul aktiv) | +| `mail` | Als E-Mail-Entwurf übergeben | +| `presi` | Als Präsi-Outline-Import | +| Export | Markdown-Download, PDF, Zwischenablage | + +Publish-Targets werden in `draft.publishedTo[]` gespeichert → User sieht "Wurde veröffentlicht als: ..." im DetailView. + +## Events (Domain-Events) + +Für Workbench-Timeline + Audit: + +- `WritingDraftCreated` +- `WritingDraftBriefingUpdated` +- `WritingDraftGenerationStarted` (für live-Tracking) +- `WritingDraftVersionCreated` +- `WritingDraftVersionReverted` +- `WritingDraftPublished` +- `WritingDraftVisibilityChanged` +- `WritingStyleCreated` +- `WritingStyleTrainedFromSamples` + +## Registrierung (Checklist) + +1. `module.config.ts` anlegen mit `tables: [drafts, draftVersions, generations, writingStyles]` +2. Config in `apps/mana/apps/web/src/lib/data/module-registry.ts` aufnehmen +3. Dexie-Schema-Migration: neue Version mit vier Tables +4. Encryption-Registry: vier Einträge +5. Routes unter `(app)/writing/` anlegen +6. App-Eintrag in `packages/shared-branding/src/mana-apps.ts`: + ```typescript + { id: 'writing', name: 'Writing', description: {...}, icon: APP_ICONS.writing, color: '#0ea5e9', status: 'development', requiredTier: 'beta' } + ``` +7. Icon in `packages/shared-branding/src/app-icons.ts` +8. `docs/MODULE_REGISTRY.md` ergänzen +9. Guest-Seed in `collections.ts` (1 Draft + 1 leerer Custom-Style) +10. Vitest für Mutationen + Encryption-Roundtrip + Version-Logik + +## Offene Fragen + +- **Outline-Mandatory?** Für Blog/Essay ist eine Outline fast immer sinnvoll; für Social/Bio/E-Mail-Kurz nicht. **Vorschlag:** `AUTO_OUTLINE_KINDS = ['blog', 'essay', 'speech', 'cover-letter', 'story']` — bei denen startet die Mission mit Outline-Schritt automatisch. User kann im Briefing überschreiben. +- **Image-Integration mit `picture`:** Soll ein Draft optional einen Header/Cover-Image haben, generiert via picture? **Vorschlag:** erst M9+. Zunächst nur `coverImageId` als optionales Feld reservieren (plaintext FK) — UI kommt später. +- **Kollaboratives Editing:** Mehrere User im gleichen Space editieren denselben Draft. Sync-Layer ist LWW → letzte Änderung gewinnt. Das reicht für den Anfang. Realtime-CRDT ist kein Phase-1-Thema. +- **Auto-Title:** Soll der Title aus dem Topic automatisch gesetzt werden oder beim ersten Generate aus dem generierten Text extrahiert? **Vorschlag:** Topic = initialer Titel; beim ersten Draft-Version-Create bietet die UI "Titel vom AI vorschlagen lassen" an. +- **Re-Generate-Semantik:** Ersetzt eine volle Re-Generation die vorherige Version oder fügt neue hinzu? Wir haben entschieden "neue Version immer" — das kann aber bei 10 Iterationen unübersichtlich werden. **Vorschlag:** History-Panel zeigt nur `isAiGenerated=true`-Versions mit Label "Generation N"; "Zwischenstände" (Selection-Apply) bleiben im lokalen Undo-Stack ohne Version-Record. +- **Token-Limits bei großen Referenzen:** Lange Artikel als Referenz → Prompt-Explosion. **Vorschlag:** Im Mission-Runner automatischen Reference-Summarizer davorschalten (schon für `articles` da? prüfen). Falls nicht, als Sub-Task in M7. +- **Veröffentlichte Drafts readonly?** Nach `publish_draft` sollte der Draft vor versehentlichem Editieren geschützt sein. **Vorschlag:** Status `published` → UI rendert text readonly mit "Editieren erlauben"-Toggle; Publish-Targets zeigen Sync-Status. + +## Reihenfolge (Milestones) + +1. **M1 — Skelett**: types, collections, module.config, Registrierung, Dexie-Migration (v N+1), leere Routes, leeres ListView, kein UI. *Ziel: App zeigt "Writing"-Modul-Kachel an, Route lädt leer, nichts crasht.* +2. **M2 — Draft-CRUD manuell**: `createDraft`, `BriefingForm`, `DraftCard`, `KindTabs`, `DetailView` mit manuell editierbarem Text (ohne AI). Alle 12 Kinds als Chips. *Ziel: User kann Drafts anlegen und tippen — wie ein eingebauter Texteditor.* +3. **M3 — Generation v1 (Sync-LLM)**: `generate_draft_content` über `mana-llm` direkt, ohne Mission-Runner. Schreibt neue `LocalDraftVersion`. Versions-History-Panel. *Ziel: "Generate"-Button produziert ersten Draft-Text aus Briefing für Kurztexte.* +4. **M4 — Stil-System (Presets + Custom)**: `LocalWritingStyle`-Table, 9 Presets, `/writing/styles` View, `StylePicker` in Briefing, Style fließt in Prompt ein. *Ziel: User wählt "LinkedIn-Post"-Preset und Output ändert sich sichtbar.* +5. **M4.1 — "Schreibe wie ich" (Self-Training)**: `train_style_from_samples` mit Auto-Pull aus `journal` + `notes` + `articles`. Extrahierte Prinzipien gecached. *Ziel: Ein "Self"-Style, der User's Schreibstil imitiert.* +6. **M5 — Cross-Modul-Referenzen**: `ReferencePicker`, Auflösung in Prompt-Context mit Summarizer bei Langtext. *Ziel: "Schreibe Blog über Buch X (aus library) und Artikel Y (aus articles)".* +7. **M6 — Selection-Refinement-Tools**: `SelectionToolbar`, `refine_selection` / `shorten` / `expand` / `change_tone` als Selection-Operations mit Diff-Preview. Undo-Stack lokal. *Ziel: User markiert Absatz, klickt "Kürzer" → 3 Optionen als Proposal, User picked.* +8. **M7 — Mission-Runner für Langtext + Recherche**: Flip auf `mana-ai`-Missions für lange Drafts, `useResearch`-Flag, Outline-Stage, Streaming-Preview. *Ziel: Essay >1500 Wörter mit Outline→Draft→Review in einer Mission.* +9. **M8 — AI-Tool-Katalog + MCP-Exposure**: Alle Tools in `@mana/shared-ai/src/tools/schemas.ts`, in `mana-mcp` exposed, `AiProposalInbox` im DetailView. Persona-Linkage (`defaultWritingStyleId`). *Ziel: Personas können Drafts erzeugen, Claude Desktop hat Writing-Tools.* +10. **M9 — Canvas-Modus** (optional, Phase 2): Inline-Autocomplete am Cursor, `/`-Command-Palette wie Notion AI. Gleiche Draft-Datenstruktur, alternative UX. *Ziel: User tippt im leeren Canvas, AI ergänzt kontinuierlich.* +11. **M10 — Publish-Hooks**: Integration mit `website`, `articles`, `presi`, `social-relay`. Markdown/PDF-Export. *Ziel: Ein Draft kann als Block auf Website gepublisht werden mit einem Klick.* +12. **M11 — Visibility-System adoptieren**: `` in DetailView, Unlisted-Share-Link, Embed-Support auf Website. *Ziel: Writing konform mit Visibility-M1+-Standard.* + +M1–M3 sind "Grundfunktion steht". Ab M4 wird's differenzierend. M7 macht es gegenüber ChatGPT einzigartig (Space-Kontext + Cross-Modul-Refs + Mission-Chaining). M9 ist "nice-to-have, wenn Ghostwriter-Flow sich als zu starr erweist". diff --git a/packages/shared-branding/src/app-icons.ts b/packages/shared-branding/src/app-icons.ts index 73fa08e3f..cea88f765 100644 --- a/packages/shared-branding/src/app-icons.ts +++ b/packages/shared-branding/src/app-icons.ts @@ -244,6 +244,13 @@ export const APP_ICONS = { // and news-research (cyan) in the Wissen & Recherche row. `` ), + writing: svgToDataUrl( + // Fountain-pen nib writing on a lined sheet — the "Ghostwriter" + // theme. Sky→cyan gradient sits next to chat (sky) and storage + // (blue) without clashing, while standing apart from articles + // (orange) and library (purple) in the text/media family. + `` + ), invoices: svgToDataUrl( // Document with a QR-code corner (CH QR-Bill) + a diagonal amount line. // Emerald→teal sits next to finance green in the Arbeit & Finanzen row. diff --git a/packages/shared-branding/src/mana-apps.ts b/packages/shared-branding/src/mana-apps.ts index 54e8f5faf..f582d2f19 100644 --- a/packages/shared-branding/src/mana-apps.ts +++ b/packages/shared-branding/src/mana-apps.ts @@ -1054,6 +1054,23 @@ export const MANA_APPS: ManaApp[] = [ status: 'development', requiredTier: 'guest', }, + { + id: 'writing', + name: 'Writing', + description: { + de: 'KI-Ghostwriter für Texte', + en: 'AI ghostwriter for prose', + }, + longDescription: { + de: 'Brief dem KI-Agenten Thema, Stil und Referenzen — er schreibt den Text. Blog, Essay, E-Mail, Bewerbung, Social Post, Rede, Story und mehr. Mit versionierten Entwürfen und Selection-Refinements.', + en: 'Brief the AI agent with topic, style and references — it writes the text. Blog posts, essays, emails, cover letters, social posts, speeches, stories and more. Versioned drafts with selection-based refinements.', + }, + icon: APP_ICONS.writing, + color: '#0ea5e9', + comingSoon: false, + status: 'development', + requiredTier: 'guest', // LOCAL TIER PATCH — revert to 'beta' before release + }, { id: 'broadcast', name: 'Broadcasts',