mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
feat(writing): M1+M2 — new Ghostwriter module with manual draft CRUD
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 (<button> for a11y), BriefingForm (topic/kind/audience/tone/
length/language/extra), VersionEditor (500ms debounce + onBlur flush),
VersionHistory (restore button per version).
- Routes: /writing list + /writing/draft/[id] with {#key id} remounting.
User flow: create draft from briefing → land in detail view → type →
autosave → "Als Checkpoint speichern" for a new version → restore any
older version from the history panel. No AI yet; M3 wires mana-llm for
short-form generation and M7 switches to mana-ai missions for long-form.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
259f6fb316
commit
3c3b2ebbc7
27 changed files with 3484 additions and 0 deletions
|
|
@ -91,6 +91,12 @@ import type {
|
|||
import type { LocalArticle, LocalHighlight } from '../../modules/articles/types';
|
||||
import type { LocalMeImage } from '../../modules/profile/types';
|
||||
import type { LocalWardrobeGarment, LocalWardrobeOutfit } from '../../modules/wardrobe/types';
|
||||
import type {
|
||||
LocalDraft,
|
||||
LocalDraftVersion,
|
||||
LocalGeneration,
|
||||
LocalWritingStyle,
|
||||
} from '../../modules/writing/types';
|
||||
|
||||
export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
||||
// ─── Chat ────────────────────────────────────────────────
|
||||
|
|
@ -718,6 +724,36 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
|||
fields: ['title', 'originalTitle', 'creators', 'review', 'tags'],
|
||||
},
|
||||
|
||||
// ─── Writing ─────────────────────────────────────────────
|
||||
// Ghostwriter module — drafts, version snapshots, generation records,
|
||||
// and user-defined styles. The full prose is the most sensitive
|
||||
// surface: a draft's content, briefing (topic / audience / extra
|
||||
// instructions) and any user-supplied reference notes can disclose
|
||||
// anything from unannounced launches to personal letters.
|
||||
//
|
||||
// Plaintext (intentional):
|
||||
// - kind, status, publish targets, isFavorite, visibility fields
|
||||
// drive tabs / chips / sort — query-critical.
|
||||
// - versionNumber, wordCount, generationId, isAiGenerated — used
|
||||
// for ordering and the history panel.
|
||||
// - generation.status, provider, model, params, tokenUsage,
|
||||
// durationMs, missionId — purely operational, no user content.
|
||||
// - style.source, presetId, isSpaceDefault, isFavorite — query.
|
||||
//
|
||||
// `references` on a draft is an array of { kind, targetId/url, note }.
|
||||
// targetId + url + kind are plaintext (FKs and public URLs); the
|
||||
// per-reference `note` travels encrypted with the whole array via
|
||||
// array-path encryption (same pattern as food.foods / quiz.options).
|
||||
writingDrafts: entry<LocalDraft>(['title', 'briefing', 'styleOverrides', 'references']),
|
||||
writingDraftVersions: entry<LocalDraftVersion>(['content', 'summary']),
|
||||
writingGenerations: entry<LocalGeneration>(['prompt', 'output']),
|
||||
writingStyles: entry<LocalWritingStyle>([
|
||||
'name',
|
||||
'description',
|
||||
'samples',
|
||||
'extractedPrinciples',
|
||||
]),
|
||||
|
||||
// ─── Invoices ────────────────────────────────────────────
|
||||
// Outbound finance. Sensitive surface is non-trivial: client name and
|
||||
// address, the free-text subject/notes/terms, and the line items
|
||||
|
|
|
|||
|
|
@ -1011,6 +1011,27 @@ db.version(42).stores({
|
|||
'id, isFavorite, isPublic, isArchived, prompt, updatedAt, wardrobeOutfitId, wardrobeGarmentId',
|
||||
});
|
||||
|
||||
// v43 — Writing module (docs/plans/writing-module.md M1).
|
||||
// Four space-scoped tables:
|
||||
// - writingDrafts: briefing + currentVersionId pointer. Indices: kind
|
||||
// (tab filter), status (chip filter), updatedAt (default sort),
|
||||
// isFavorite (favourites toggle).
|
||||
// - writingDraftVersions: immutable snapshots of the draft body. Indexed
|
||||
// on draftId for version-history fetch, versionNumber for ordering.
|
||||
// - writingGenerations: provider-level call records (prompt, status,
|
||||
// tokens, model). Indexed on draftId for per-draft runs and status
|
||||
// so the UI can find in-flight work on reload.
|
||||
// - writingStyles: reusable style definitions (preset + custom). Indexed
|
||||
// on source (preset vs custom), isSpaceDefault, isFavorite.
|
||||
// All four get standard spaceId/authorId/visibility stamping via the Dexie
|
||||
// hook (NOT in USER_LEVEL_TABLES).
|
||||
db.version(43).stores({
|
||||
writingDrafts: 'id, kind, status, updatedAt, isFavorite',
|
||||
writingDraftVersions: 'id, draftId, versionNumber, createdAt',
|
||||
writingGenerations: 'id, draftId, status, createdAt',
|
||||
writingStyles: 'id, source, isSpaceDefault, isFavorite, updatedAt',
|
||||
});
|
||||
|
||||
// ─── Sync Routing ──────────────────────────────────────────
|
||||
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
|
||||
// toSyncName() and fromSyncName() are now derived from per-module
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ import { broadcastModuleConfig } from '$lib/modules/broadcast/module.config';
|
|||
import { wetterModuleConfig } from '$lib/modules/wetter/module.config';
|
||||
import { websiteModuleConfig } from '$lib/modules/website/module.config';
|
||||
import { wardrobeModuleConfig } from '$lib/modules/wardrobe/module.config';
|
||||
import { writingModuleConfig } from '$lib/modules/writing/module.config';
|
||||
import { aiModuleConfig } from '$lib/data/ai/module.config';
|
||||
|
||||
export const MODULE_CONFIGS: readonly ModuleConfig[] = [
|
||||
|
|
@ -166,6 +167,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
|
|||
wetterModuleConfig,
|
||||
websiteModuleConfig,
|
||||
wardrobeModuleConfig,
|
||||
writingModuleConfig,
|
||||
aiModuleConfig,
|
||||
];
|
||||
|
||||
|
|
|
|||
11
apps/mana/apps/web/src/lib/modules/writing/ListView.svelte
Normal file
11
apps/mana/apps/web/src/lib/modules/writing/ListView.svelte
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<!--
|
||||
Writing — Module-root ListView.
|
||||
Mirrors the library/ convention: a thin wrapper that delegates to the
|
||||
real view in views/ so the route file doesn't need to know about the
|
||||
internal structure.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import ListView from './views/ListView.svelte';
|
||||
</script>
|
||||
|
||||
<ListView />
|
||||
65
apps/mana/apps/web/src/lib/modules/writing/collections.ts
Normal file
65
apps/mana/apps/web/src/lib/modules/writing/collections.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
/**
|
||||
* Writing module — Dexie accessors and guest seed.
|
||||
*
|
||||
* Four tables: `writingDrafts` (the briefing + pointer to current version),
|
||||
* `writingDraftVersions` (immutable snapshots of the text body), `writingGenerations`
|
||||
* (provider-level call records — prompt, status, duration, tokens), and
|
||||
* `writingStyles` (reusable named style definitions, preset or custom).
|
||||
*/
|
||||
|
||||
import { db } from '$lib/data/database';
|
||||
import type { LocalDraft, LocalDraftVersion, LocalGeneration, LocalWritingStyle } from './types';
|
||||
|
||||
export const draftTable = db.table<LocalDraft>('writingDrafts');
|
||||
export const draftVersionTable = db.table<LocalDraftVersion>('writingDraftVersions');
|
||||
export const generationTable = db.table<LocalGeneration>('writingGenerations');
|
||||
export const writingStyleTable = db.table<LocalWritingStyle>('writingStyles');
|
||||
|
||||
// ─── Guest Seed ────────────────────────────────────────────
|
||||
//
|
||||
// One example draft + its initial version + a custom style, so first-run
|
||||
// users can open the module and immediately see the briefing / version /
|
||||
// style shape. Intentionally small — the interesting seeds are the
|
||||
// preset styles (see `presets/styles.ts`), not sample drafts.
|
||||
|
||||
export const WRITING_GUEST_SEED = {
|
||||
writingDrafts: [
|
||||
{
|
||||
id: 'demo-draft-welcome',
|
||||
kind: 'blog' as const,
|
||||
status: 'draft' as const,
|
||||
title: 'Willkommen bei Writing',
|
||||
briefing: {
|
||||
topic: 'Was dieses Modul für dich tut',
|
||||
audience: 'Neue Mana-Nutzer',
|
||||
tone: 'freundlich, konkret',
|
||||
language: 'de',
|
||||
targetLength: { type: 'words' as const, value: 220 },
|
||||
extraInstructions: null,
|
||||
useResearch: false,
|
||||
},
|
||||
styleId: null,
|
||||
styleOverrides: null,
|
||||
references: [],
|
||||
currentVersionId: 'demo-draft-welcome-v1',
|
||||
isFavorite: false,
|
||||
publishedTo: [],
|
||||
},
|
||||
],
|
||||
writingDraftVersions: [
|
||||
{
|
||||
id: 'demo-draft-welcome-v1',
|
||||
draftId: 'demo-draft-welcome',
|
||||
versionNumber: 1,
|
||||
content:
|
||||
'Writing ist dein Ghostwriter in Mana. Beschreibe kurz, was du brauchst — Thema, Zielgruppe, Länge, Ton — und wähle einen Stil. Das Modul erzeugt einen ersten Entwurf, den du Absatz für Absatz verfeinern kannst. Bereit für deinen ersten Text?',
|
||||
wordCount: 41,
|
||||
generationId: null,
|
||||
isAiGenerated: false,
|
||||
parentVersionId: null,
|
||||
summary: null,
|
||||
},
|
||||
],
|
||||
writingGenerations: [],
|
||||
writingStyles: [],
|
||||
};
|
||||
|
|
@ -0,0 +1,304 @@
|
|||
<!--
|
||||
BriefingForm — create a new draft or edit an existing draft's briefing.
|
||||
In M2 this is the only way a draft comes into existence. M3+ will add a
|
||||
one-field "what do you want to write?" shortcut that proposes a briefing,
|
||||
but the full form remains the canonical source-of-truth view.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { KIND_LABELS, TONE_PRESETS, LENGTH_PRESETS, DEFAULT_LANGUAGE } from '../constants';
|
||||
import { draftsStore } from '../stores/drafts.svelte';
|
||||
import type { Draft, DraftKind, DraftBriefing } from '../types';
|
||||
|
||||
let {
|
||||
mode,
|
||||
draft,
|
||||
initialKind,
|
||||
onclose,
|
||||
oncreated,
|
||||
}: {
|
||||
mode: 'create' | 'edit';
|
||||
draft?: Draft;
|
||||
initialKind?: DraftKind;
|
||||
onclose: () => void;
|
||||
oncreated?: (draft: Draft) => void;
|
||||
} = $props();
|
||||
|
||||
const KIND_ORDER: DraftKind[] = [
|
||||
'blog',
|
||||
'essay',
|
||||
'email',
|
||||
'social',
|
||||
'story',
|
||||
'letter',
|
||||
'speech',
|
||||
'cover-letter',
|
||||
'product-description',
|
||||
'press-release',
|
||||
'bio',
|
||||
'other',
|
||||
];
|
||||
|
||||
// Form state is seeded once from `draft` / `initialKind`; it never
|
||||
// re-syncs because the form gets a fresh mount every time it opens.
|
||||
// The svelte-ignore comments silence the (correct-in-general) warning.
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let title = $state(draft?.title ?? '');
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let kind = $state<DraftKind>(draft?.kind ?? initialKind ?? 'blog');
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let topic = $state(draft?.briefing.topic ?? '');
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let audience = $state(draft?.briefing.audience ?? '');
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let tone = $state(draft?.briefing.tone ?? '');
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let language = $state(draft?.briefing.language ?? DEFAULT_LANGUAGE);
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let targetLengthValue = $state<number>(
|
||||
draft?.briefing.targetLength?.value ?? LENGTH_PRESETS[draft?.kind ?? 'blog'].value
|
||||
);
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let extraInstructions = $state(draft?.briefing.extraInstructions ?? '');
|
||||
|
||||
let saving = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// When the user switches kind while creating a new draft, nudge the
|
||||
// target length to that kind's preset so they don't have to touch it.
|
||||
// On edit we leave existing custom values alone.
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let lastKindSeen = $state(kind);
|
||||
$effect(() => {
|
||||
if (mode !== 'create') return;
|
||||
if (kind === lastKindSeen) return;
|
||||
targetLengthValue = LENGTH_PRESETS[kind].value;
|
||||
lastKindSeen = kind;
|
||||
});
|
||||
|
||||
const isValid = $derived(title.trim().length > 0 && topic.trim().length > 0);
|
||||
|
||||
async function submit(ev: Event) {
|
||||
ev.preventDefault();
|
||||
if (!isValid || saving) return;
|
||||
saving = true;
|
||||
error = null;
|
||||
try {
|
||||
const briefing: Partial<DraftBriefing> & { topic: string } = {
|
||||
topic: topic.trim(),
|
||||
audience: audience.trim() || null,
|
||||
tone: tone.trim() || null,
|
||||
language,
|
||||
targetLength: { type: 'words', value: targetLengthValue },
|
||||
extraInstructions: extraInstructions.trim() || null,
|
||||
};
|
||||
if (mode === 'create') {
|
||||
const { draft: created } = await draftsStore.createDraft({
|
||||
kind,
|
||||
title: title.trim(),
|
||||
briefing,
|
||||
});
|
||||
oncreated?.(created);
|
||||
} else if (draft) {
|
||||
await draftsStore.updateDraft(draft.id, {
|
||||
title: title.trim(),
|
||||
kind,
|
||||
});
|
||||
await draftsStore.updateBriefing(draft.id, briefing);
|
||||
}
|
||||
onclose();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form class="briefing" onsubmit={submit}>
|
||||
<div class="row">
|
||||
<label>
|
||||
<span>Titel</span>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input type="text" bind:value={title} placeholder="Mein Blogpost über …" required autofocus />
|
||||
</label>
|
||||
<label class="kind-select">
|
||||
<span>Textart</span>
|
||||
<select bind:value={kind}>
|
||||
{#each KIND_ORDER as k (k)}
|
||||
<option value={k}>
|
||||
{KIND_LABELS[k].emoji}
|
||||
{KIND_LABELS[k].de}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
<span>Worum geht's? <small>(wird als Kern-Briefing an die KI übergeben)</small></span>
|
||||
<textarea
|
||||
bind:value={topic}
|
||||
rows="3"
|
||||
placeholder="z.B. 'Was Mana von klassischen Produktivitätstools unterscheidet, aus Nutzersicht'"
|
||||
required
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<div class="row">
|
||||
<label>
|
||||
<span>Zielgruppe</span>
|
||||
<input type="text" bind:value={audience} placeholder="z.B. Gründer, Eltern, …" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Ton</span>
|
||||
<select bind:value={tone}>
|
||||
<option value="">— kein fester Ton —</option>
|
||||
{#each TONE_PRESETS as preset (preset.id)}
|
||||
<option value={preset.id}>{preset.de}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>
|
||||
<span>Länge (Wörter)</span>
|
||||
<input type="number" min="20" max="20000" step="20" bind:value={targetLengthValue} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Sprache</span>
|
||||
<select bind:value={language}>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
<option value="fr">Français</option>
|
||||
<option value="es">Español</option>
|
||||
<option value="it">Italiano</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
<span>Zusatzhinweise <small>(optional)</small></span>
|
||||
<textarea
|
||||
bind:value={extraInstructions}
|
||||
rows="2"
|
||||
placeholder="z.B. 'keine Buzzwords', 'mit einem Zitat beginnen', …"
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
{#if error}
|
||||
<p class="error">{error}</p>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="secondary" onclick={onclose} disabled={saving}> Abbrechen </button>
|
||||
<button type="submit" class="primary" disabled={!isValid || saving}>
|
||||
{#if saving}
|
||||
Speichert…
|
||||
{:else if mode === 'create'}
|
||||
Draft anlegen
|
||||
{:else}
|
||||
Speichern
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style>
|
||||
.briefing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
label > span {
|
||||
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
|
||||
}
|
||||
small {
|
||||
font-weight: normal;
|
||||
opacity: 0.7;
|
||||
}
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
|
||||
background: var(--color-surface, transparent);
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 3rem;
|
||||
}
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: 2px solid #0ea5e9;
|
||||
outline-offset: 1px;
|
||||
border-color: transparent;
|
||||
}
|
||||
.kind-select {
|
||||
max-width: 220px;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
button {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.55rem;
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.primary {
|
||||
background: #0ea5e9;
|
||||
color: white;
|
||||
border: 1px solid #0ea5e9;
|
||||
}
|
||||
.primary:hover:not(:disabled) {
|
||||
background: #0284c7;
|
||||
border-color: #0284c7;
|
||||
}
|
||||
.secondary {
|
||||
background: transparent;
|
||||
color: var(--color-text, inherit);
|
||||
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
.secondary:hover:not(:disabled) {
|
||||
background: var(--color-surface, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
.error {
|
||||
color: #ef4444;
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.kind-select {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
<script lang="ts">
|
||||
import StatusBadge from './StatusBadge.svelte';
|
||||
import { KIND_LABELS } from '../constants';
|
||||
import type { Draft, DraftVersion } from '../types';
|
||||
|
||||
let {
|
||||
draft,
|
||||
currentVersion,
|
||||
onopen,
|
||||
}: {
|
||||
draft: Draft;
|
||||
currentVersion: DraftVersion | null;
|
||||
onopen: (draft: Draft) => void;
|
||||
} = $props();
|
||||
|
||||
const kind = $derived(KIND_LABELS[draft.kind]);
|
||||
const preview = $derived.by(() => {
|
||||
const text = (currentVersion?.content ?? '').trim();
|
||||
if (!text) return draft.briefing.topic;
|
||||
return text.length > 160 ? text.slice(0, 160) + '…' : text;
|
||||
});
|
||||
const updatedLabel = $derived(formatRelative(draft.updatedAt));
|
||||
|
||||
function formatRelative(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
const diff = Date.now() - d.getTime();
|
||||
const mins = Math.round(diff / 60000);
|
||||
if (mins < 1) return 'gerade eben';
|
||||
if (mins < 60) return `vor ${mins} min`;
|
||||
const hrs = Math.round(mins / 60);
|
||||
if (hrs < 24) return `vor ${hrs} h`;
|
||||
const days = Math.round(hrs / 24);
|
||||
if (days < 30) return `vor ${days} d`;
|
||||
return d.toLocaleDateString('de-DE');
|
||||
}
|
||||
|
||||
function open() {
|
||||
onopen(draft);
|
||||
}
|
||||
</script>
|
||||
|
||||
<button type="button" class="card" onclick={open}>
|
||||
<header>
|
||||
<span class="kind" title={kind.de}>
|
||||
<span aria-hidden="true">{kind.emoji}</span>
|
||||
{kind.de}
|
||||
</span>
|
||||
{#if draft.isFavorite}
|
||||
<span class="fav" aria-label="Favorit">★</span>
|
||||
{/if}
|
||||
</header>
|
||||
<h3 class="title">{draft.title || draft.briefing.topic || 'Unbenannt'}</h3>
|
||||
<p class="preview">{preview}</p>
|
||||
<footer>
|
||||
<StatusBadge status={draft.status} />
|
||||
<span class="words">
|
||||
{currentVersion?.wordCount ?? 0} Wörter
|
||||
</span>
|
||||
<span class="updated">{updatedLabel}</span>
|
||||
</footer>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
|
||||
background: var(--color-surface, rgba(255, 255, 255, 0.04));
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
.card:hover,
|
||||
.card:focus-visible {
|
||||
background: var(--color-surface-hover, rgba(0, 0, 0, 0.04));
|
||||
border-color: color-mix(in srgb, #0ea5e9 40%, transparent);
|
||||
}
|
||||
.card:focus-visible {
|
||||
outline: 2px solid #0ea5e9;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.kind {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.fav {
|
||||
color: #f59e0b;
|
||||
}
|
||||
.title {
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.preview {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
justify-content: space-between;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.words {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.updated {
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
<script lang="ts">
|
||||
import { KIND_LABELS } from '../constants';
|
||||
import type { DraftKind } from '../types';
|
||||
|
||||
let {
|
||||
active,
|
||||
counts,
|
||||
onselect,
|
||||
}: {
|
||||
active: DraftKind | 'all';
|
||||
counts: Record<DraftKind, number>;
|
||||
onselect: (kind: DraftKind | 'all') => void;
|
||||
} = $props();
|
||||
|
||||
const ORDER: DraftKind[] = [
|
||||
'blog',
|
||||
'essay',
|
||||
'email',
|
||||
'social',
|
||||
'story',
|
||||
'letter',
|
||||
'speech',
|
||||
'cover-letter',
|
||||
'product-description',
|
||||
'press-release',
|
||||
'bio',
|
||||
'other',
|
||||
];
|
||||
const total = $derived(ORDER.reduce((s, k) => s + counts[k], 0));
|
||||
</script>
|
||||
|
||||
<div class="tabs" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
class="tab"
|
||||
class:active={active === 'all'}
|
||||
onclick={() => onselect('all')}
|
||||
role="tab"
|
||||
aria-selected={active === 'all'}
|
||||
>
|
||||
Alle <span class="count">{total}</span>
|
||||
</button>
|
||||
{#each ORDER as kind (kind)}
|
||||
{#if counts[kind] > 0 || active === kind}
|
||||
<button
|
||||
type="button"
|
||||
class="tab"
|
||||
class:active={active === kind}
|
||||
onclick={() => onselect(kind)}
|
||||
role="tab"
|
||||
aria-selected={active === kind}
|
||||
>
|
||||
<span class="emoji" aria-hidden="true">{KIND_LABELS[kind].emoji}</span>
|
||||
{KIND_LABELS[kind].de}
|
||||
<span class="count">{counts[kind]}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
.tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.45rem 0.85rem;
|
||||
border-radius: 0.6rem;
|
||||
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text, inherit);
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
.tab:hover {
|
||||
background: var(--color-surface, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
.tab.active {
|
||||
background: color-mix(in srgb, #0ea5e9 12%, transparent);
|
||||
border-color: #0ea5e9;
|
||||
color: #0ea5e9;
|
||||
font-weight: 500;
|
||||
}
|
||||
.emoji {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.count {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
background: var(--color-surface-muted, rgba(0, 0, 0, 0.05));
|
||||
padding: 0.05rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.tab.active .count {
|
||||
background: color-mix(in srgb, #0ea5e9 20%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<script lang="ts">
|
||||
import { STATUS_LABELS, STATUS_COLORS } from '../constants';
|
||||
import type { DraftStatus } from '../types';
|
||||
|
||||
let { status }: { status: DraftStatus } = $props();
|
||||
const color = $derived(STATUS_COLORS[status]);
|
||||
const label = $derived(STATUS_LABELS[status].de);
|
||||
</script>
|
||||
|
||||
<span class="badge" style:--badge={color}>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--badge);
|
||||
background: color-mix(in srgb, var(--badge) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--badge) 25%, transparent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dot {
|
||||
width: 0.4rem;
|
||||
height: 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: var(--badge);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
<script lang="ts">
|
||||
import { STATUS_LABELS } from '../constants';
|
||||
import type { DraftStatus } from '../types';
|
||||
|
||||
let {
|
||||
active,
|
||||
onselect,
|
||||
}: {
|
||||
active: DraftStatus | null;
|
||||
onselect: (status: DraftStatus | null) => void;
|
||||
} = $props();
|
||||
|
||||
const ORDER: DraftStatus[] = ['draft', 'refining', 'complete', 'published'];
|
||||
</script>
|
||||
|
||||
<div class="chips" role="toolbar" aria-label="Status-Filter">
|
||||
<button type="button" class="chip" class:active={active === null} onclick={() => onselect(null)}>
|
||||
Alle
|
||||
</button>
|
||||
{#each ORDER as status (status)}
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
class:active={active === status}
|
||||
onclick={() => onselect(active === status ? null : status)}
|
||||
>
|
||||
{STATUS_LABELS[status].de}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.chips {
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.chip {
|
||||
padding: 0.3rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
|
||||
}
|
||||
.chip:hover {
|
||||
background: var(--color-surface, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
.chip.active {
|
||||
background: color-mix(in srgb, #0ea5e9 10%, transparent);
|
||||
border-color: #0ea5e9;
|
||||
color: #0ea5e9;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
<!--
|
||||
Editable textarea bound to the draft's current version.
|
||||
Saves with a short debounce so every keystroke doesn't hit Dexie; on
|
||||
blur it force-flushes any pending edit. The word-count is computed
|
||||
locally for live feedback and re-derived on save.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { draftsStore } from '../stores/drafts.svelte';
|
||||
import type { DraftVersion } from '../types';
|
||||
|
||||
let {
|
||||
version,
|
||||
targetWords = null,
|
||||
onchange,
|
||||
}: {
|
||||
version: DraftVersion;
|
||||
targetWords?: number | null;
|
||||
onchange?: (content: string) => void;
|
||||
} = $props();
|
||||
|
||||
// Snapshot id so we reset local state when the active version flips
|
||||
// (e.g. after "Restore this version" on the history panel).
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let lastVersionId = $state<string>(version.id);
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let text = $state<string>(version.content);
|
||||
|
||||
$effect(() => {
|
||||
if (version.id !== lastVersionId) {
|
||||
lastVersionId = version.id;
|
||||
text = version.content;
|
||||
}
|
||||
});
|
||||
|
||||
const wordCount = $derived.by(() => {
|
||||
const trimmed = text.trim();
|
||||
return trimmed ? trimmed.split(/\s+/).length : 0;
|
||||
});
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pending = $state(false);
|
||||
|
||||
function queueSave(next: string) {
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
pending = true;
|
||||
saveTimer = setTimeout(async () => {
|
||||
saveTimer = null;
|
||||
await draftsStore.updateVersionContent(version.id, next);
|
||||
pending = false;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
onchange?.(text);
|
||||
queueSave(text);
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = null;
|
||||
await draftsStore.updateVersionContent(version.id, text);
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="editor">
|
||||
<textarea
|
||||
bind:value={text}
|
||||
oninput={handleInput}
|
||||
onblur={flush}
|
||||
placeholder="Hier schreibst du (oder die KI). Leer lassen für Generate."
|
||||
spellcheck="true"
|
||||
></textarea>
|
||||
<footer>
|
||||
<span>
|
||||
{wordCount} Wörter{#if targetWords}
|
||||
<span class="target"> / Ziel ~{targetWords}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="status" aria-live="polite">
|
||||
{#if pending}
|
||||
Speichert…
|
||||
{:else}
|
||||
Gespeichert
|
||||
{/if}
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 50vh;
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
|
||||
background: var(--color-surface, rgba(255, 255, 255, 0.04));
|
||||
font-family: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.6;
|
||||
color: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
textarea:focus {
|
||||
outline: 2px solid #0ea5e9;
|
||||
outline-offset: 1px;
|
||||
border-color: transparent;
|
||||
}
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
|
||||
}
|
||||
.target {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.status {
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
<!--
|
||||
List of all LocalDraftVersions for a draft. Highlights the current one;
|
||||
"Wiederherstellen" flips the draft's `currentVersionId` back via the
|
||||
store. Versions are immutable snapshots so Restore is a pointer change,
|
||||
not a destructive revert.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { draftsStore } from '../stores/drafts.svelte';
|
||||
import type { DraftVersion } from '../types';
|
||||
|
||||
let {
|
||||
versions,
|
||||
currentVersionId,
|
||||
draftId,
|
||||
}: {
|
||||
versions: DraftVersion[];
|
||||
currentVersionId: string | null;
|
||||
draftId: string;
|
||||
} = $props();
|
||||
|
||||
// Newest on top.
|
||||
const sorted = $derived([...versions].sort((a, b) => b.versionNumber - a.versionNumber));
|
||||
|
||||
async function restore(versionId: string) {
|
||||
await draftsStore.restoreVersion(draftId, versionId);
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<ul class="history">
|
||||
{#each sorted as version (version.id)}
|
||||
{@const isCurrent = version.id === currentVersionId}
|
||||
<li class="version" class:current={isCurrent}>
|
||||
<div class="meta">
|
||||
<strong>v{version.versionNumber}</strong>
|
||||
{#if version.isAiGenerated}
|
||||
<span class="tag ai" title="KI-generiert">KI</span>
|
||||
{/if}
|
||||
{#if isCurrent}
|
||||
<span class="tag current">Aktiv</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="stats">
|
||||
<span>{version.wordCount} Wörter</span>
|
||||
<span class="date">{formatDate(version.createdAt)}</span>
|
||||
</div>
|
||||
{#if version.summary}
|
||||
<p class="summary">{version.summary}</p>
|
||||
{/if}
|
||||
{#if !isCurrent}
|
||||
<button type="button" class="restore" onclick={() => restore(version.id)}>
|
||||
Wiederherstellen
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<style>
|
||||
.history {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.version {
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: 0.55rem;
|
||||
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
|
||||
background: var(--color-surface, rgba(255, 255, 255, 0.04));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.version.current {
|
||||
border-color: #0ea5e9;
|
||||
background: color-mix(in srgb, #0ea5e9 6%, transparent);
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.tag {
|
||||
font-size: 0.65rem;
|
||||
padding: 0.05rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.tag.ai {
|
||||
background: color-mix(in srgb, #a855f7 15%, transparent);
|
||||
color: #a855f7;
|
||||
}
|
||||
.tag.current {
|
||||
background: color-mix(in srgb, #0ea5e9 15%, transparent);
|
||||
color: #0ea5e9;
|
||||
}
|
||||
.stats {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
|
||||
}
|
||||
.summary {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
|
||||
}
|
||||
.restore {
|
||||
align-self: flex-start;
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 0.4rem;
|
||||
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
.restore:hover {
|
||||
background: var(--color-surface-hover, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
</style>
|
||||
85
apps/mana/apps/web/src/lib/modules/writing/constants.ts
Normal file
85
apps/mana/apps/web/src/lib/modules/writing/constants.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import type { DraftKind, DraftStatus, GenerationStatus, StyleSource } from './types';
|
||||
|
||||
export const KIND_LABELS: Record<DraftKind, { de: string; en: string; emoji: string }> = {
|
||||
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<DraftStatus, { de: string; en: string }> = {
|
||||
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<DraftStatus, string> = {
|
||||
draft: '#64748b',
|
||||
refining: '#3b82f6',
|
||||
complete: '#22c55e',
|
||||
published: '#a855f7',
|
||||
};
|
||||
|
||||
export const GENERATION_STATUS_LABELS: Record<GenerationStatus, { de: string; en: string }> = {
|
||||
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<StyleSource, { de: string; en: string }> = {
|
||||
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<DraftKind, { type: 'words'; value: number }> = {
|
||||
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<DraftKind> = [
|
||||
'blog',
|
||||
'essay',
|
||||
'speech',
|
||||
'cover-letter',
|
||||
'story',
|
||||
];
|
||||
86
apps/mana/apps/web/src/lib/modules/writing/index.ts
Normal file
86
apps/mana/apps/web/src/lib/modules/writing/index.ts
Normal file
|
|
@ -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';
|
||||
11
apps/mana/apps/web/src/lib/modules/writing/module.config.ts
Normal file
11
apps/mana/apps/web/src/lib/modules/writing/module.config.ts
Normal file
|
|
@ -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' },
|
||||
],
|
||||
};
|
||||
175
apps/mana/apps/web/src/lib/modules/writing/presets/styles.ts
Normal file
175
apps/mana/apps/web/src/lib/modules/writing/presets/styles.ts
Normal file
|
|
@ -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=<id>) 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<StylePreset> = [
|
||||
{
|
||||
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);
|
||||
}
|
||||
267
apps/mana/apps/web/src/lib/modules/writing/queries.ts
Normal file
267
apps/mana/apps/web/src/lib/modules/writing/queries.ts
Normal file
|
|
@ -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<LocalDraft, string>('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<LocalDraft>('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<LocalDraftVersion>('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<LocalDraftVersion>('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<LocalDraft>('writingDrafts').get(draftId);
|
||||
if (!draftRow || draftRow.deletedAt || !draftRow.currentVersionId) return null;
|
||||
const versionRow = await db
|
||||
.table<LocalDraftVersion>('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<LocalGeneration>('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<LocalWritingStyle, string>(
|
||||
'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<DraftKind, Draft[]> {
|
||||
const out: Record<DraftKind, Draft[]> = {
|
||||
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<DraftStatus, number>;
|
||||
totalWords: number;
|
||||
currentlyActive: number;
|
||||
}
|
||||
|
||||
export function computeStats(drafts: Draft[], versions: DraftVersion[]): WritingStats {
|
||||
const byStatus: Record<DraftStatus, number> = {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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<DraftBriefing> & { 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<string, unknown>;
|
||||
await encryptRecord('writingDrafts', wrapped);
|
||||
await draftTable.update(id, {
|
||||
...wrapped,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async updateBriefing(id: string, briefingPatch: Partial<DraftBriefing>) {
|
||||
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<string, unknown> = {
|
||||
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<LocalDraft> = {
|
||||
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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -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<string, unknown>;
|
||||
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 });
|
||||
},
|
||||
};
|
||||
265
apps/mana/apps/web/src/lib/modules/writing/types.ts
Normal file
265
apps/mana/apps/web/src/lib/modules/writing/types.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,355 @@
|
|||
<!--
|
||||
Writing — Detail view. Three panels on desktop (collapsing to tabs on
|
||||
mobile later): briefing summary, text editor, version history. In M2
|
||||
the editor is a plain textarea; M6 adds selection-based refinement
|
||||
tools. "Als Checkpoint speichern" freezes the current draft content
|
||||
as a numbered version (otherwise typing just edits version v1 in
|
||||
place).
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import BriefingForm from '../components/BriefingForm.svelte';
|
||||
import StatusBadge from '../components/StatusBadge.svelte';
|
||||
import VersionEditor from '../components/VersionEditor.svelte';
|
||||
import VersionHistory from '../components/VersionHistory.svelte';
|
||||
import { draftsStore } from '../stores/drafts.svelte';
|
||||
import { useDraft, useVersionsForDraft, useCurrentVersionForDraft } from '../queries';
|
||||
import { KIND_LABELS, STATUS_LABELS } from '../constants';
|
||||
import type { DraftStatus } from '../types';
|
||||
|
||||
let { id }: { id: string } = $props();
|
||||
|
||||
// The parent route wraps this component in `{#key id}` so each draft
|
||||
// gets a fresh mount — the live queries are safe to seed from the
|
||||
// initial `id` without reacting to prop changes.
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
const draft$ = useDraft(id);
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
const versions$ = useVersionsForDraft(id);
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
const currentVersion$ = useCurrentVersionForDraft(id);
|
||||
const draft = $derived(draft$.value);
|
||||
const versions = $derived(versions$.value);
|
||||
const currentVersion = $derived(currentVersion$.value);
|
||||
|
||||
let briefingOpen = $state(false);
|
||||
let saving = $state(false);
|
||||
|
||||
async function setStatus(next: DraftStatus) {
|
||||
if (!draft) return;
|
||||
await draftsStore.setStatus(draft.id, next);
|
||||
}
|
||||
|
||||
async function toggleFavorite() {
|
||||
if (!draft) return;
|
||||
await draftsStore.toggleFavorite(draft.id);
|
||||
}
|
||||
|
||||
async function saveCheckpoint() {
|
||||
if (!draft || !currentVersion || saving) return;
|
||||
saving = true;
|
||||
try {
|
||||
await draftsStore.createCheckpointVersion(draft.id, currentVersion.id);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!draft) return;
|
||||
if (!confirm(`"${draft.title}" wirklich löschen?`)) return;
|
||||
await draftsStore.deleteDraft(draft.id);
|
||||
goto('/writing');
|
||||
}
|
||||
|
||||
const kind = $derived(draft ? KIND_LABELS[draft.kind] : null);
|
||||
const targetWords = $derived(draft?.briefing.targetLength?.value ?? null);
|
||||
const STATUS_ORDER: DraftStatus[] = ['draft', 'refining', 'complete', 'published'];
|
||||
</script>
|
||||
|
||||
{#if draft$.loading}
|
||||
<p class="muted center">Lädt…</p>
|
||||
{:else if !draft}
|
||||
<div class="empty">
|
||||
<p>Dieser Draft existiert nicht (mehr).</p>
|
||||
<a href="/writing">Zurück zur Übersicht</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="shell">
|
||||
<header class="head">
|
||||
<div class="title-row">
|
||||
<a href="/writing" class="back">← Alle Drafts</a>
|
||||
<div class="title-block">
|
||||
<div class="kind" title={kind?.de}>
|
||||
<span aria-hidden="true">{kind?.emoji}</span>
|
||||
{kind?.de}
|
||||
</div>
|
||||
<h1>{draft.title || draft.briefing.topic || 'Unbenannt'}</h1>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button
|
||||
type="button"
|
||||
class="ghost"
|
||||
onclick={toggleFavorite}
|
||||
aria-pressed={draft.isFavorite}
|
||||
title="Favorit"
|
||||
>
|
||||
{draft.isFavorite ? '★' : '☆'}
|
||||
</button>
|
||||
<button type="button" class="ghost danger" onclick={remove}>Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="meta-row">
|
||||
<StatusBadge status={draft.status} />
|
||||
<div class="status-picker">
|
||||
{#each STATUS_ORDER as s (s)}
|
||||
{#if s !== draft.status}
|
||||
<button type="button" class="tiny" onclick={() => setStatus(s)}>
|
||||
→ {STATUS_LABELS[s].de}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="briefing-section">
|
||||
<button type="button" class="briefing-toggle" onclick={() => (briefingOpen = !briefingOpen)}>
|
||||
{briefingOpen ? '▾' : '▸'} Briefing
|
||||
{#if !briefingOpen}
|
||||
<span class="preview">{draft.briefing.topic}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if briefingOpen}
|
||||
<BriefingForm mode="edit" {draft} onclose={() => (briefingOpen = false)} />
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<div class="columns">
|
||||
<section class="editor-column">
|
||||
{#if currentVersion}
|
||||
<div class="editor-head">
|
||||
<div>
|
||||
<strong>Version {currentVersion.versionNumber}</strong>
|
||||
{#if currentVersion.isAiGenerated}
|
||||
<span class="ai-tag">KI</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="checkpoint"
|
||||
onclick={saveCheckpoint}
|
||||
disabled={saving}
|
||||
title="Aktuellen Text als neue Version einfrieren"
|
||||
>
|
||||
{saving ? 'Speichert…' : '+ Als Checkpoint speichern'}
|
||||
</button>
|
||||
</div>
|
||||
<VersionEditor version={currentVersion} {targetWords} />
|
||||
{:else}
|
||||
<p class="muted">Diese Version existiert nicht mehr.</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<aside class="history-column">
|
||||
<h2>Versionen</h2>
|
||||
<VersionHistory
|
||||
versions={versions ?? []}
|
||||
currentVersionId={draft.currentVersionId}
|
||||
draftId={draft.id}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.shell {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 1.25rem 1.5rem 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.muted {
|
||||
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
|
||||
}
|
||||
.muted.center {
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
.empty {
|
||||
max-width: 600px;
|
||||
margin: 4rem auto;
|
||||
text-align: center;
|
||||
}
|
||||
.empty a {
|
||||
color: #0ea5e9;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.title-block {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.back {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
|
||||
text-decoration: none;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
.back:hover {
|
||||
color: #0ea5e9;
|
||||
}
|
||||
.kind {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.actions {
|
||||
display: inline-flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.ghost {
|
||||
padding: 0.4rem 0.7rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
.ghost:hover {
|
||||
background: var(--color-surface, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
.ghost.danger:hover {
|
||||
border-color: #ef4444;
|
||||
color: #ef4444;
|
||||
}
|
||||
.meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.status-picker {
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tiny {
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 0.35rem;
|
||||
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
|
||||
background: transparent;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
|
||||
}
|
||||
.tiny:hover {
|
||||
border-color: #0ea5e9;
|
||||
color: #0ea5e9;
|
||||
}
|
||||
.briefing-section {
|
||||
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
|
||||
border-radius: 0.75rem;
|
||||
background: var(--color-surface, rgba(255, 255, 255, 0.02));
|
||||
}
|
||||
.briefing-toggle {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.75rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: inherit;
|
||||
}
|
||||
.briefing-toggle .preview {
|
||||
font-weight: normal;
|
||||
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
|
||||
font-size: 0.85rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.columns {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 280px;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.editor-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.editor-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.ai-tag {
|
||||
font-size: 0.65rem;
|
||||
padding: 0.05rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, #a855f7 15%, transparent);
|
||||
color: #a855f7;
|
||||
margin-left: 0.4rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.checkpoint {
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #0ea5e9;
|
||||
background: transparent;
|
||||
color: #0ea5e9;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.checkpoint:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, #0ea5e9 10%, transparent);
|
||||
}
|
||||
.checkpoint:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.history-column h2 {
|
||||
font-size: 0.8rem;
|
||||
margin: 0 0 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.columns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
259
apps/mana/apps/web/src/lib/modules/writing/views/ListView.svelte
Normal file
259
apps/mana/apps/web/src/lib/modules/writing/views/ListView.svelte
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
<!--
|
||||
Writing — ListView.
|
||||
Grid of drafts with KindTabs + status chips + search + "+ Neu" inline-create.
|
||||
Clicking a card routes to /writing/draft/[id]. The draft preview shows the
|
||||
first ~160 chars of the current version so the card isn't empty for an
|
||||
unstarted draft.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import KindTabs from '../components/KindTabs.svelte';
|
||||
import StatusFilter from '../components/StatusFilter.svelte';
|
||||
import DraftCard from '../components/DraftCard.svelte';
|
||||
import BriefingForm from '../components/BriefingForm.svelte';
|
||||
import {
|
||||
useAllDrafts,
|
||||
filterByKind,
|
||||
filterByStatus,
|
||||
searchDrafts,
|
||||
sortByUpdated,
|
||||
} from '../queries';
|
||||
import { draftVersionTable } from '../collections';
|
||||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { toDraftVersion } from '../queries';
|
||||
import type { Draft, DraftVersion, DraftKind, DraftStatus, LocalDraftVersion } from '../types';
|
||||
|
||||
const drafts$ = useAllDrafts();
|
||||
const drafts = $derived(drafts$.value);
|
||||
|
||||
// Pull every version for the drafts we show so we can look up each
|
||||
// card's current version by id without a per-card live query. On the
|
||||
// list page we only need the wordCount + first 160 chars, so the whole
|
||||
// content is fine to read (decryption is the same either way).
|
||||
const currentVersions$ = useLiveQueryWithDefault(async () => {
|
||||
const ids = drafts.map((d) => d.currentVersionId).filter((id): id is string => !!id);
|
||||
if (ids.length === 0) return new Map<string, DraftVersion>();
|
||||
const rows = (await draftVersionTable.bulkGet(ids)).filter(
|
||||
(r): r is LocalDraftVersion => !!r && !r.deletedAt
|
||||
);
|
||||
const decrypted = await decryptRecords('writingDraftVersions', rows);
|
||||
const map = new Map<string, DraftVersion>();
|
||||
for (const v of decrypted.map(toDraftVersion)) map.set(v.id, v);
|
||||
return map;
|
||||
}, new Map<string, DraftVersion>());
|
||||
const currentVersionsById = $derived(currentVersions$.value);
|
||||
|
||||
let activeKind = $state<DraftKind | 'all'>('all');
|
||||
let activeStatus = $state<DraftStatus | null>(null);
|
||||
let searchQuery = $state('');
|
||||
let showFavoritesOnly = $state(false);
|
||||
let showCreate = $state(false);
|
||||
|
||||
const counts = $derived<Record<DraftKind, number>>({
|
||||
blog: drafts.filter((d) => d.kind === 'blog').length,
|
||||
essay: drafts.filter((d) => d.kind === 'essay').length,
|
||||
email: drafts.filter((d) => d.kind === 'email').length,
|
||||
social: drafts.filter((d) => d.kind === 'social').length,
|
||||
story: drafts.filter((d) => d.kind === 'story').length,
|
||||
letter: drafts.filter((d) => d.kind === 'letter').length,
|
||||
speech: drafts.filter((d) => d.kind === 'speech').length,
|
||||
'cover-letter': drafts.filter((d) => d.kind === 'cover-letter').length,
|
||||
'product-description': drafts.filter((d) => d.kind === 'product-description').length,
|
||||
'press-release': drafts.filter((d) => d.kind === 'press-release').length,
|
||||
bio: drafts.filter((d) => d.kind === 'bio').length,
|
||||
other: drafts.filter((d) => d.kind === 'other').length,
|
||||
});
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
let result = drafts;
|
||||
if (activeKind !== 'all') result = filterByKind(result, activeKind);
|
||||
if (activeStatus) result = filterByStatus(result, activeStatus);
|
||||
if (showFavoritesOnly) result = result.filter((d) => d.isFavorite);
|
||||
if (searchQuery.trim()) result = searchDrafts(result, searchQuery.trim());
|
||||
return sortByUpdated(result);
|
||||
});
|
||||
|
||||
function openDraft(d: Draft) {
|
||||
goto(`/writing/draft/${d.id}`);
|
||||
}
|
||||
|
||||
function presetKind(): DraftKind | undefined {
|
||||
return activeKind === 'all' ? undefined : activeKind;
|
||||
}
|
||||
|
||||
function onCreated(d: Draft) {
|
||||
showCreate = false;
|
||||
openDraft(d);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="writing-shell">
|
||||
<div class="controls">
|
||||
<div class="search-row">
|
||||
<input
|
||||
type="search"
|
||||
class="search"
|
||||
bind:value={searchQuery}
|
||||
placeholder="Nach Titel oder Thema suchen…"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="create-btn"
|
||||
class:active={showCreate}
|
||||
onclick={() => (showCreate = !showCreate)}
|
||||
aria-expanded={showCreate}
|
||||
>
|
||||
{showCreate ? '× Schließen' : '+ Neuer Draft'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<KindTabs active={activeKind} {counts} onselect={(k) => (activeKind = k)} />
|
||||
|
||||
<div class="filter-row">
|
||||
<StatusFilter active={activeStatus} onselect={(s) => (activeStatus = s)} />
|
||||
<label class="fav-toggle">
|
||||
<input type="checkbox" bind:checked={showFavoritesOnly} />
|
||||
<span>Nur Favoriten</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showCreate}
|
||||
<div class="inline-create">
|
||||
<BriefingForm
|
||||
mode="create"
|
||||
initialKind={presetKind()}
|
||||
onclose={() => (showCreate = false)}
|
||||
oncreated={onCreated}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if drafts$.loading}
|
||||
<p class="muted center">Lädt…</p>
|
||||
{:else if filtered.length === 0}
|
||||
<div class="empty">
|
||||
{#if drafts.length === 0}
|
||||
<h2>Noch keine Drafts</h2>
|
||||
<p>
|
||||
Klick auf <strong>+ Neuer Draft</strong>, brief dem Ghostwriter Thema, Stil und Länge — M3
|
||||
ergänzt die Generate-Funktion. Bis dahin kannst du Drafts manuell erstellen und editieren.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="muted">Keine Drafts passen zum aktuellen Filter.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each filtered as draft (draft.id)}
|
||||
<DraftCard
|
||||
{draft}
|
||||
currentVersion={currentVersionsById.get(draft.currentVersionId ?? '') ?? null}
|
||||
onopen={openDraft}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.writing-shell {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.muted {
|
||||
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.muted.center {
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.create-btn {
|
||||
padding: 0.45rem 0.9rem;
|
||||
border-radius: 0.55rem;
|
||||
border: 1px solid #0ea5e9;
|
||||
background: #0ea5e9;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.create-btn:hover {
|
||||
background: #0284c7;
|
||||
border-color: #0284c7;
|
||||
}
|
||||
.create-btn.active {
|
||||
background: transparent;
|
||||
color: #0ea5e9;
|
||||
}
|
||||
.filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.fav-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
|
||||
cursor: pointer;
|
||||
}
|
||||
.search {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.55rem 0.85rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
|
||||
background: var(--color-surface, transparent);
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
.search:focus {
|
||||
outline: 2px solid #0ea5e9;
|
||||
outline-offset: 1px;
|
||||
border-color: transparent;
|
||||
}
|
||||
.inline-create {
|
||||
margin-bottom: 1.25rem;
|
||||
border: 1px solid color-mix(in srgb, #0ea5e9 30%, transparent);
|
||||
border-radius: 0.75rem;
|
||||
background: color-mix(in srgb, #0ea5e9 4%, transparent);
|
||||
}
|
||||
.empty {
|
||||
max-width: 540px;
|
||||
margin: 3rem auto;
|
||||
text-align: center;
|
||||
}
|
||||
.empty h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.empty p {
|
||||
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
|
||||
line-height: 1.5;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
12
apps/mana/apps/web/src/routes/(app)/writing/+page.svelte
Normal file
12
apps/mana/apps/web/src/routes/(app)/writing/+page.svelte
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<script lang="ts">
|
||||
import ListView from '$lib/modules/writing/ListView.svelte';
|
||||
import { RoutePage } from '$lib/components/shell';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Writing - Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<RoutePage appId="writing">
|
||||
<ListView />
|
||||
</RoutePage>
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<script lang="ts">
|
||||
import DetailView from '$lib/modules/writing/views/DetailView.svelte';
|
||||
import { RoutePage } from '$lib/components/shell';
|
||||
import { page } from '$app/state';
|
||||
|
||||
const id = $derived(page.params.id ?? '');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Draft - Writing - Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<RoutePage appId="writing">
|
||||
{#key id}
|
||||
<DetailView {id} />
|
||||
{/key}
|
||||
</RoutePage>
|
||||
458
docs/plans/writing-module.md
Normal file
458
docs/plans/writing-module.md
Normal file
|
|
@ -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 (`<VisibilityPicker>` 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 volltext 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**: `<VisibilityPicker>` 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".
|
||||
|
|
@ -244,6 +244,13 @@ export const APP_ICONS = {
|
|||
// and news-research (cyan) in the Wissen & Recherche row.
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="ar" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#f97316"/><stop offset="100%" style="stop-color:#f59e0b"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#ar)"/><path d="M28 22h30l18 18v38a4 4 0 0 1-4 4H28a4 4 0 0 1-4-4V26a4 4 0 0 1 4-4z" fill="white" fill-opacity="0.95"/><path d="M58 22v14a4 4 0 0 0 4 4h14" fill="none" stroke="#f97316" stroke-width="2" stroke-opacity="0.35"/><rect x="32" y="48" width="26" height="3" rx="1.5" fill="#f97316" fill-opacity="0.6"/><rect x="32" y="56" width="22" height="3" rx="1.5" fill="#f97316" fill-opacity="0.45"/><rect x="32" y="64" width="24" height="3" rx="1.5" fill="#f97316" fill-opacity="0.6"/><path d="M62 54v22l8-6 8 6V54a4 4 0 0 0-4-4h-8a4 4 0 0 0-4 4z" fill="#f97316"/></svg>`
|
||||
),
|
||||
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.
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="wr" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#0ea5e9"/><stop offset="100%" style="stop-color:#06b6d4"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#wr)"/><rect x="20" y="22" width="44" height="56" rx="4" fill="white" fill-opacity="0.95"/><line x1="26" y1="34" x2="56" y2="34" stroke="#0ea5e9" stroke-width="2" stroke-opacity="0.5" stroke-linecap="round"/><line x1="26" y1="42" x2="52" y2="42" stroke="#0ea5e9" stroke-width="2" stroke-opacity="0.35" stroke-linecap="round"/><line x1="26" y1="50" x2="56" y2="50" stroke="#0ea5e9" stroke-width="2" stroke-opacity="0.35" stroke-linecap="round"/><line x1="26" y1="58" x2="48" y2="58" stroke="#0ea5e9" stroke-width="2" stroke-opacity="0.35" stroke-linecap="round"/><path d="M62 72l14-14 8 8-14 14-10 2 2-10z" fill="white"/><path d="M74 60l8 8" stroke="#0ea5e9" stroke-width="2" stroke-linecap="round" stroke-opacity="0.6"/><path d="M60 74l4 4" stroke="#0ea5e9" stroke-width="2" stroke-linecap="round" stroke-opacity="0.6"/></svg>`
|
||||
),
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue