mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 00:01:10 +02:00
feat(comic): M4 — AI-Storyboard aus Cross-Modul-Text
User wählt einen bestehenden Text (Tagebuch-Eintrag, Notiz oder
Bibliotheks-Review), das Modell schlägt eine geordnete
Panel-Sequenz vor (prompt + optional caption + dialogue pro Panel),
der User prüft/editiert und feuert Batch-Gen mit sourceInput-
Tagging — damit wird `useStoriesByInput` später cross-referenzieren
können ("Welche Comics sind aus diesem Journal-Eintrag entstanden?").
Backend:
- POST /api/v1/comic/storyboard (Hono route) nimmt style +
sourceText + panelCount (+ optional storyContext / sourceModule)
und ruft llmJson() mit einem response_format=json_object-Prompt
an mana-llm. System-Prompt instruiert das Modell auf eine exakte
{panels: [{prompt, caption?, dialogue?}]}-Shape, Rules wie
"keine Style-Instruktionen" (kommen aus dem Story-Prefix
downstream) und "kein Panel-Nummerieren".
- Defense-in-depth Coerce auf der Response: Panel ohne prompt
wird gefiltert, Strings werden gecappt (caption/dialogue 200,
prompt 800), Zahl der Panels auf panelCount geclampt.
- Model via COMIC_STORYBOARD_MODEL env var überschreibbar;
Default ollama/gemma3:4b wie writing (lokal + billig).
- Beide Erfolgs- und Fehler-Pfade mit logger.info /
logger.error + userId + sourceModule für Observability.
- Route registriert in apps/api/src/index.ts als /api/v1/comic.
Client:
- api/storyboard.ts: suggestPanels({style, sourceText, panelCount,
storyContext?, sourceModule?}) — thin fetch-Wrapper + Error-Messaging
für 402 / 502 / no-panels-Responses.
- ReferenceInputPicker: Tabs über Journal / Notizen / Bibliothek
(die drei inhalts-dichtesten Quellen), pro Tab Live-Query +
Suche + Entry-Liste. Click emittiert {module, entryId, label,
sourceText} — label ist der Display-Name für die
"Gequellt aus…"-Chip, sourceText ist bereits decrypted (Queries
liefern plaintext zurück). Bibliotheks-Einträge ohne Review
sind disabled (kein Text = nichts zu rendern).
- StoryboardSuggester: 4-Schritt-Flow (pick-source →
generating-plan → review-plan → rendering). Schritt 3 ist der
eigentliche Editor: jede Claude-Zeile ist editierbar (Prompt,
Caption, Dialog) mit Trash-Button; Quality + Format-Toggle
teilen sich M3-Batch-Style. "Generieren" ruft parallel
runPanelGenerate() via Promise.allSettled mit
sourceInput={module, entryId} im panelMeta, alle Panels gehen
durch den identischen M2-HTTP-Pfad.
- DetailView bekommt einen dritten Editor-Modus "ai" neben
"single" und "batch" — eine Sparkle-Button-CTA öffnet den
Suggester.
Kein Writing-Draft / Calendar-Event-Input in dieser Runde —
Drafts brauchen Version-Chain-Resolve, Events sind meist zu dünn
an Prosa. Follow-up wenn gewünscht (rein additiv: Tab + Hook).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8a882a3760
commit
6432ef7e6b
6 changed files with 1015 additions and 1 deletions
|
|
@ -43,6 +43,7 @@ import { newsResearchRoutes } from './modules/news-research/routes';
|
|||
import { articlesRoutes } from './modules/articles/routes';
|
||||
import { tracesRoutes } from './modules/traces/routes';
|
||||
import { writingRoutes } from './modules/writing/routes';
|
||||
import { comicRoutes } from './modules/comic/routes';
|
||||
import { presiRoutes } from './modules/presi/routes';
|
||||
import { researchRoutes } from './modules/research/routes';
|
||||
import { whoRoutes } from './modules/who/routes';
|
||||
|
|
@ -134,6 +135,7 @@ app.route('/api/v1/research', researchRoutes);
|
|||
app.route('/api/v1/website', websiteRoutes);
|
||||
app.route('/api/v1/who', whoRoutes);
|
||||
app.route('/api/v1/writing', writingRoutes);
|
||||
app.route('/api/v1/comic', comicRoutes);
|
||||
|
||||
// ─── Server Info ────────────────────────────────────────────
|
||||
console.log(`mana-api starting on port ${PORT}...`);
|
||||
|
|
|
|||
216
apps/api/src/modules/comic/routes.ts
Normal file
216
apps/api/src/modules/comic/routes.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
/**
|
||||
* Comic module — server endpoints.
|
||||
*
|
||||
* Current scope (M4):
|
||||
* - POST /storyboard — one-shot panel-sequence suggestion from a text
|
||||
* input (journal entry, note, library review, writing draft,
|
||||
* calendar event description). The client decrypts the source
|
||||
* locally, sends the plaintext + style, and we round-trip to
|
||||
* mana-llm with a JSON-schema system prompt, returning
|
||||
* `{ panels: Array<{ prompt, caption?, dialogue? }> }`. Panel
|
||||
* rendering itself still happens through /picture/generate-with-
|
||||
* reference — this endpoint is pure text → plan.
|
||||
*
|
||||
* Future (M5+):
|
||||
* - Upload endpoint for comic-specific anchor / backdrop images if
|
||||
* M6 character-cast scope happens; the 'comic' upload slot is
|
||||
* already allowed by verifyMediaOwnership (set in M1).
|
||||
*
|
||||
* Why not reuse /api/v1/writing/generations?
|
||||
* That endpoint is a free-text prose endpoint (no JSON parsing) and
|
||||
* is wired for one-shot writing drafts. Comic storyboarding wants a
|
||||
* structured Panel[] envelope the client can iterate over cheaply —
|
||||
* different prompt shape, different parser, different observability
|
||||
* tag. Keeping them apart avoids prompt-contamination between the
|
||||
* two use-cases and keeps each module's logs grep-able.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { llmJson, LlmError } from '../../lib/llm';
|
||||
import { logger, type AuthVariables } from '@mana/shared-hono';
|
||||
|
||||
const STORYBOARD_MODEL = process.env.COMIC_STORYBOARD_MODEL || 'ollama/gemma3:4b';
|
||||
|
||||
type ComicStyle = 'comic' | 'manga' | 'cartoon' | 'graphic-novel' | 'webtoon';
|
||||
|
||||
const STYLE_HINTS: Record<ComicStyle, string> = {
|
||||
comic: 'US comic book, bold linework, cell-shading, dramatic framing',
|
||||
manga: 'Japanese manga, black-and-white with screen tones, dynamic perspective',
|
||||
cartoon: 'soft pastel cartoon, rounded shapes, Saturday-morning animation',
|
||||
'graphic-novel': 'graphic novel, painterly watercolor, muted atmospheric palette',
|
||||
webtoon: 'webtoon, vertical framing, bright saturated colors, soft cel-shading',
|
||||
};
|
||||
|
||||
const VALID_STYLES = Object.keys(STYLE_HINTS) as readonly ComicStyle[];
|
||||
const MAX_SOURCE_TEXT_CHARS = 8_000;
|
||||
const MIN_PANEL_COUNT = 2;
|
||||
const MAX_PANEL_COUNT = 8;
|
||||
|
||||
interface StoryboardRequest {
|
||||
style: ComicStyle;
|
||||
sourceText: string;
|
||||
/** Optional — if omitted we ask for 4 panels (plan default). */
|
||||
panelCount?: number;
|
||||
/** Optional story-level briefing the author wrote at create-time.
|
||||
* Gets prepended to the source-text so Claude knows the tonal
|
||||
* register ("make it funny" / "stay serious"). */
|
||||
storyContext?: string | null;
|
||||
/** Where this text came from — logged only, not sent to the LLM.
|
||||
* Useful for observability ("which module drives most storyboards"). */
|
||||
sourceModule?: string;
|
||||
}
|
||||
|
||||
interface StoryboardPanel {
|
||||
prompt: string;
|
||||
caption?: string;
|
||||
dialogue?: string;
|
||||
}
|
||||
|
||||
interface StoryboardResponse {
|
||||
panels: StoryboardPanel[];
|
||||
model: string;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
function isValidStyle(v: unknown): v is ComicStyle {
|
||||
return typeof v === 'string' && (VALID_STYLES as readonly string[]).includes(v);
|
||||
}
|
||||
|
||||
function buildSystemPrompt(style: ComicStyle): string {
|
||||
const hint = STYLE_HINTS[style];
|
||||
return [
|
||||
`You are a comic-story editor. Given a short piece of text (journal entry, note, review, or event description), break it into a sequence of visual comic panels.`,
|
||||
`Style: ${hint}.`,
|
||||
`Return ONLY a JSON object with this exact shape:`,
|
||||
`{"panels": [{"prompt": string, "caption"?: string, "dialogue"?: string}, ...]}`,
|
||||
`Rules:`,
|
||||
`- "prompt" is the visual scene description (what the artist draws). One or two short English sentences. Focus on composition, action, mood, setting. Do NOT describe style — the style prefix is added downstream.`,
|
||||
`- "caption" (optional) is a short narration line rendered at the top or bottom of the panel, max 80 chars. Use sparingly — only when scene-setting or transitions need it.`,
|
||||
`- "dialogue" (optional) is what the protagonist says inside a speech bubble, max 80 chars. Use when the scene has a spoken moment.`,
|
||||
`- Do not number panels. Do not add meta commentary. Do not explain your choices.`,
|
||||
`- The protagonist of every panel is the same person (the story's author).`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function buildUserPrompt(
|
||||
sourceText: string,
|
||||
panelCount: number,
|
||||
storyContext: string | null | undefined
|
||||
): string {
|
||||
const trimmed = sourceText.trim().slice(0, MAX_SOURCE_TEXT_CHARS);
|
||||
const contextBlock = storyContext?.trim()
|
||||
? `Story briefing from the author:\n${storyContext.trim()}\n\n---\n\n`
|
||||
: '';
|
||||
return [
|
||||
contextBlock,
|
||||
`Source text:\n${trimmed}\n\n---\n\n`,
|
||||
`Generate exactly ${panelCount} panels that tell this as a comic. Output the JSON object described in the system message.`,
|
||||
].join('');
|
||||
}
|
||||
|
||||
const routes = new Hono<{ Variables: AuthVariables }>();
|
||||
|
||||
routes.post('/storyboard', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const body = (await c.req.json()) as Partial<StoryboardRequest>;
|
||||
|
||||
if (!isValidStyle(body.style)) {
|
||||
return c.json({ error: `Invalid style, expected one of: ${VALID_STYLES.join(', ')}` }, 400);
|
||||
}
|
||||
if (!body.sourceText || typeof body.sourceText !== 'string') {
|
||||
return c.json({ error: 'sourceText required' }, 400);
|
||||
}
|
||||
if (body.sourceText.trim().length === 0) {
|
||||
return c.json({ error: 'sourceText must not be blank' }, 400);
|
||||
}
|
||||
|
||||
const panelCount = Math.max(
|
||||
MIN_PANEL_COUNT,
|
||||
Math.min(MAX_PANEL_COUNT, Number(body.panelCount) || 4)
|
||||
);
|
||||
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const parsed = await llmJson<{ panels?: unknown }>({
|
||||
model: STORYBOARD_MODEL,
|
||||
system: buildSystemPrompt(body.style),
|
||||
user: buildUserPrompt(body.sourceText, panelCount, body.storyContext),
|
||||
temperature: 0.7,
|
||||
maxTokens: 2000,
|
||||
});
|
||||
|
||||
const rawPanels = Array.isArray(parsed?.panels) ? parsed.panels : [];
|
||||
// Defense-in-depth: coerce + strip unknown shapes, clamp to
|
||||
// requested count. If the model returns more panels than asked
|
||||
// for we keep the first N; less is fine (fewer credits later).
|
||||
const panels: StoryboardPanel[] = rawPanels
|
||||
.map((raw): StoryboardPanel | null => {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const entry = raw as Record<string, unknown>;
|
||||
const prompt = typeof entry.prompt === 'string' ? entry.prompt.trim() : '';
|
||||
if (!prompt) return null;
|
||||
const caption =
|
||||
typeof entry.caption === 'string' && entry.caption.trim().length > 0
|
||||
? entry.caption.trim().slice(0, 200)
|
||||
: undefined;
|
||||
const dialogue =
|
||||
typeof entry.dialogue === 'string' && entry.dialogue.trim().length > 0
|
||||
? entry.dialogue.trim().slice(0, 200)
|
||||
: undefined;
|
||||
return { prompt: prompt.slice(0, 800), caption, dialogue };
|
||||
})
|
||||
.filter((p): p is StoryboardPanel => p !== null)
|
||||
.slice(0, panelCount);
|
||||
|
||||
const durationMs = Date.now() - startedAt;
|
||||
|
||||
if (panels.length === 0) {
|
||||
logger.warn('comic.storyboard_empty', {
|
||||
userId,
|
||||
style: body.style,
|
||||
sourceModule: body.sourceModule,
|
||||
model: STORYBOARD_MODEL,
|
||||
durationMs,
|
||||
});
|
||||
return c.json(
|
||||
{
|
||||
error: 'Model returned no usable panels',
|
||||
detail: 'Try again, shorten the input, or pick a different style',
|
||||
durationMs,
|
||||
},
|
||||
502
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('comic.storyboard_ok', {
|
||||
userId,
|
||||
style: body.style,
|
||||
sourceModule: body.sourceModule,
|
||||
panelCount: panels.length,
|
||||
model: STORYBOARD_MODEL,
|
||||
durationMs,
|
||||
});
|
||||
|
||||
const response: StoryboardResponse = {
|
||||
panels,
|
||||
model: STORYBOARD_MODEL,
|
||||
durationMs,
|
||||
};
|
||||
return c.json(response);
|
||||
} catch (err) {
|
||||
const durationMs = Date.now() - startedAt;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error('comic.storyboard_failed', {
|
||||
userId,
|
||||
style: body.style,
|
||||
sourceModule: body.sourceModule,
|
||||
model: STORYBOARD_MODEL,
|
||||
error: message,
|
||||
status: err instanceof LlmError ? err.status : undefined,
|
||||
durationMs,
|
||||
});
|
||||
return c.json({ error: 'Storyboard generation failed', detail: message, durationMs }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export { routes as comicRoutes };
|
||||
73
apps/mana/apps/web/src/lib/modules/comic/api/storyboard.ts
Normal file
73
apps/mana/apps/web/src/lib/modules/comic/api/storyboard.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/**
|
||||
* Storyboard client. Calls `/api/v1/comic/storyboard` with the
|
||||
* decrypted source text (journal entry, note, library review,
|
||||
* writing draft, calendar event description) and the chosen style,
|
||||
* receives an ordered `Panel[]` suggestion that the user reviews +
|
||||
* edits before firing the batch-gen flow (M3).
|
||||
*
|
||||
* Cross-module decrypt stays client-side — the browser loads the
|
||||
* source module's row, passes it through its own decryptor, and
|
||||
* hands us plaintext. No Key-Grants / server-side decrypts involved
|
||||
* (matches the plan §6 decision: M4 is interactive client-side).
|
||||
*
|
||||
* Plan: docs/plans/comic-module.md M4.
|
||||
*/
|
||||
|
||||
import { getManaApiUrl } from '$lib/api/config';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import type { ComicStyle } from '../types';
|
||||
|
||||
export type StoryboardSourceModule = 'journal' | 'notes' | 'library' | 'writing' | 'calendar';
|
||||
|
||||
export interface StoryboardPanel {
|
||||
prompt: string;
|
||||
caption?: string;
|
||||
dialogue?: string;
|
||||
}
|
||||
|
||||
export interface SuggestPanelsParams {
|
||||
style: ComicStyle;
|
||||
sourceText: string;
|
||||
panelCount: number;
|
||||
/** Story-level briefing the author typed when creating the story.
|
||||
* Gets prepended server-side so Claude knows the tonal register. */
|
||||
storyContext?: string | null;
|
||||
/** Logged for observability only — not sent to the LLM. */
|
||||
sourceModule?: StoryboardSourceModule;
|
||||
}
|
||||
|
||||
export interface SuggestPanelsResult {
|
||||
panels: StoryboardPanel[];
|
||||
model: string;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export async function suggestPanels(params: SuggestPanelsParams): Promise<SuggestPanelsResult> {
|
||||
const token = await authStore.getValidToken();
|
||||
const res = await fetch(`${getManaApiUrl()}/api/v1/comic/storyboard`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
style: params.style,
|
||||
sourceText: params.sourceText,
|
||||
panelCount: params.panelCount,
|
||||
storyContext: params.storyContext,
|
||||
sourceModule: params.sourceModule,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: string; detail?: string };
|
||||
const label = body.error ?? `Storyboard fehlgeschlagen (${res.status})`;
|
||||
throw new Error(body.detail ? `${label}: ${body.detail}` : label);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as SuggestPanelsResult;
|
||||
if (!Array.isArray(data.panels) || data.panels.length === 0) {
|
||||
throw new Error('Keine Panels vom Modell zurück — versuche es mit anderem Text oder Stil.');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
<!--
|
||||
ReferenceInputPicker — tabs over the three content-richest modules
|
||||
(Journal / Notes / Library) and picks one entry as the seed for the
|
||||
AI-Storyboard flow. On select, resolves the decrypted plaintext
|
||||
(title + content / review) and emits it to the parent so
|
||||
`suggestPanels` can post it to the server.
|
||||
|
||||
Writing-Drafts and Calendar-Events are intentionally left out of the
|
||||
first cut — writing drafts need version-chain resolution and
|
||||
calendar events rarely carry enough prose to drive a panel sequence.
|
||||
Adding them is a follow-up (tabs + hook + resolver wiring).
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { useAllJournalEntries } from '$lib/modules/journal/queries';
|
||||
import { useAllNotes } from '$lib/modules/notes/queries';
|
||||
import { useAllEntries as useAllLibraryEntries } from '$lib/modules/library/queries';
|
||||
import { MagnifyingGlass, Book, NotePencil, BookOpen } from '@mana/shared-icons';
|
||||
import type { StoryboardSourceModule } from '../api/storyboard';
|
||||
|
||||
export interface ReferenceSelection {
|
||||
module: StoryboardSourceModule;
|
||||
entryId: string;
|
||||
/** Human-readable label — shown in the "seeded from…"-chip on the
|
||||
* story detail once panels are generated. */
|
||||
label: string;
|
||||
/** Decrypted plaintext that gets posted to /comic/storyboard. */
|
||||
sourceText: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onSelect: (sel: ReferenceSelection) => void;
|
||||
}
|
||||
|
||||
let { onSelect }: Props = $props();
|
||||
|
||||
type Tab = 'journal' | 'notes' | 'library';
|
||||
let activeTab = $state<Tab>('journal');
|
||||
let search = $state('');
|
||||
|
||||
const journal$ = useAllJournalEntries();
|
||||
const notes$ = useAllNotes();
|
||||
const library$ = useAllLibraryEntries();
|
||||
|
||||
const journal = $derived(journal$.value ?? []);
|
||||
const notes = $derived(notes$.value ?? []);
|
||||
const library = $derived(library$.value ?? []);
|
||||
|
||||
const q = $derived(search.trim().toLowerCase());
|
||||
|
||||
const journalFiltered = $derived(
|
||||
q.length === 0
|
||||
? journal.slice(0, 30)
|
||||
: journal
|
||||
.filter((e) => {
|
||||
const hay = `${e.title ?? ''} ${e.content}`.toLowerCase();
|
||||
return hay.includes(q);
|
||||
})
|
||||
.slice(0, 30)
|
||||
);
|
||||
const notesFiltered = $derived(
|
||||
q.length === 0
|
||||
? notes.slice(0, 30)
|
||||
: notes.filter((n) => `${n.title} ${n.content}`.toLowerCase().includes(q)).slice(0, 30)
|
||||
);
|
||||
const libraryFiltered = $derived(
|
||||
q.length === 0
|
||||
? library.slice(0, 30)
|
||||
: library
|
||||
.filter((e) => {
|
||||
const review = e.review ?? '';
|
||||
return `${e.title} ${review}`.toLowerCase().includes(q);
|
||||
})
|
||||
.slice(0, 30)
|
||||
);
|
||||
|
||||
function shortPreview(text: string, maxLen = 100): string {
|
||||
const clean = text.replace(/\s+/g, ' ').trim();
|
||||
return clean.length > maxLen ? clean.slice(0, maxLen) + '…' : clean;
|
||||
}
|
||||
|
||||
const TABS: { key: Tab; label: string; count: number }[] = $derived([
|
||||
{ key: 'journal', label: 'Tagebuch', count: journal.length },
|
||||
{ key: 'notes', label: 'Notizen', count: notes.length },
|
||||
{ key: 'library', label: 'Bibliothek', count: library.length },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div class="space-y-3 rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||
<header class="space-y-1">
|
||||
<h3 class="text-sm font-semibold text-foreground">Quelle wählen</h3>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Aus welchem Text soll die KI eine Panel-Folge bauen? Alles bleibt lokal — erst der
|
||||
verschlüsselte Klartext wird an das Modell gesendet, nur für diesen einen Call.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<nav class="flex gap-1 border-b border-border" aria-label="Quelle">
|
||||
{#each TABS as tab (tab.key)}
|
||||
<button
|
||||
type="button"
|
||||
class="-mb-px border-b-2 px-2.5 py-1.5 text-xs font-medium transition-colors
|
||||
{activeTab === tab.key
|
||||
? 'border-primary text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'}"
|
||||
aria-pressed={activeTab === tab.key}
|
||||
onclick={() => (activeTab = tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
<span class="ml-1 text-[10px] text-muted-foreground">{tab.count}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="relative">
|
||||
<MagnifyingGlass
|
||||
size={14}
|
||||
class="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
bind:value={search}
|
||||
placeholder="Suchen…"
|
||||
class="block w-full rounded-md border border-border bg-background py-1.5 pl-7 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="max-h-80 space-y-1.5 overflow-y-auto">
|
||||
{#if activeTab === 'journal'}
|
||||
{#if journalFiltered.length === 0}
|
||||
<p class="py-4 text-center text-xs text-muted-foreground">
|
||||
{journal.length === 0
|
||||
? 'Noch keine Tagebuch-Einträge in diesem Space.'
|
||||
: 'Keine Einträge passen zur Suche.'}
|
||||
</p>
|
||||
{:else}
|
||||
{#each journalFiltered as entry (entry.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() =>
|
||||
onSelect({
|
||||
module: 'journal',
|
||||
entryId: entry.id,
|
||||
label: entry.title?.trim() || entry.entryDate || 'Tagebuch-Eintrag',
|
||||
sourceText: entry.title ? `${entry.title}\n\n${entry.content}` : entry.content,
|
||||
})}
|
||||
class="flex w-full items-start gap-2 rounded-md border border-border bg-background p-2.5 text-left transition-colors hover:border-primary/40 hover:bg-muted"
|
||||
>
|
||||
<Book size={14} class="mt-0.5 flex-shrink-0 text-muted-foreground" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="flex items-center gap-1.5 text-xs font-medium text-foreground">
|
||||
<span class="truncate">{entry.title?.trim() || entry.entryDate || 'Eintrag'}</span>
|
||||
</p>
|
||||
<p class="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
|
||||
{shortPreview(entry.content)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
{:else if activeTab === 'notes'}
|
||||
{#if notesFiltered.length === 0}
|
||||
<p class="py-4 text-center text-xs text-muted-foreground">
|
||||
{notes.length === 0
|
||||
? 'Noch keine Notizen in diesem Space.'
|
||||
: 'Keine Notizen passen zur Suche.'}
|
||||
</p>
|
||||
{:else}
|
||||
{#each notesFiltered as note (note.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() =>
|
||||
onSelect({
|
||||
module: 'notes',
|
||||
entryId: note.id,
|
||||
label: note.title.trim() || 'Notiz',
|
||||
sourceText: note.title ? `${note.title}\n\n${note.content}` : note.content,
|
||||
})}
|
||||
class="flex w-full items-start gap-2 rounded-md border border-border bg-background p-2.5 text-left transition-colors hover:border-primary/40 hover:bg-muted"
|
||||
>
|
||||
<NotePencil size={14} class="mt-0.5 flex-shrink-0 text-muted-foreground" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-xs font-medium text-foreground">
|
||||
{note.title.trim() || 'Ohne Titel'}
|
||||
</p>
|
||||
<p class="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
|
||||
{shortPreview(note.content)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
{:else if activeTab === 'library'}
|
||||
{#if libraryFiltered.length === 0}
|
||||
<p class="py-4 text-center text-xs text-muted-foreground">
|
||||
{library.length === 0
|
||||
? 'Noch keine Bibliotheks-Einträge in diesem Space.'
|
||||
: 'Keine Einträge passen zur Suche.'}
|
||||
</p>
|
||||
{:else}
|
||||
{#each libraryFiltered as entry (entry.id)}
|
||||
{@const hasReview = entry.review && entry.review.trim().length > 0}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasReview}
|
||||
onclick={() => {
|
||||
if (!hasReview || !entry.review) return;
|
||||
onSelect({
|
||||
module: 'library',
|
||||
entryId: entry.id,
|
||||
label: entry.title,
|
||||
sourceText: `${entry.title}\n\n${entry.review}`,
|
||||
});
|
||||
}}
|
||||
class="flex w-full items-start gap-2 rounded-md border border-border bg-background p-2.5 text-left transition-colors hover:border-primary/40 hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-border disabled:hover:bg-background"
|
||||
title={hasReview ? '' : 'Kein Review hinterlegt — kein Text zum Rendern'}
|
||||
>
|
||||
<BookOpen size={14} class="mt-0.5 flex-shrink-0 text-muted-foreground" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="flex items-center gap-1.5 text-xs font-medium text-foreground">
|
||||
<span class="truncate">{entry.title}</span>
|
||||
<span
|
||||
class="flex-shrink-0 rounded-sm bg-muted px-1 py-0 text-[9px] uppercase text-muted-foreground"
|
||||
>
|
||||
{entry.kind}
|
||||
</span>
|
||||
</p>
|
||||
<p class="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
|
||||
{hasReview ? shortPreview(entry.review ?? '') : 'Kein Review'}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,475 @@
|
|||
<!--
|
||||
StoryboardSuggester — the M4 multi-step flow:
|
||||
|
||||
1. Pick source (ReferenceInputPicker)
|
||||
2. Choose panel count + submit to /comic/storyboard
|
||||
3. Review / edit the suggested Panel[] list
|
||||
4. Fire parallel generation — same batch executor as M3, plus
|
||||
`panelMeta[imageId].sourceInput = {module, entryId}` on each
|
||||
generated panel so the "Comics zu diesem Eintrag"-back-query
|
||||
(useStoriesByInput) resolves later.
|
||||
|
||||
Unlike BatchPanelEditor this editor doesn't let the user author
|
||||
panels from scratch — they start with a Claude suggestion and tune.
|
||||
That's the value-add of M4: text in → panels out, author as editor
|
||||
not author-from-scratch.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle,
|
||||
Plus,
|
||||
Sparkle,
|
||||
SpinnerGap,
|
||||
Trash,
|
||||
WarningCircle,
|
||||
X,
|
||||
} from '@mana/shared-icons';
|
||||
import { runPanelGenerate, type PanelSize } from '../api/generate-panel';
|
||||
import {
|
||||
suggestPanels,
|
||||
type StoryboardSourceModule,
|
||||
type StoryboardPanel,
|
||||
} from '../api/storyboard';
|
||||
import {
|
||||
DEFAULT_STORYBOARD_PANEL_COUNT,
|
||||
MAX_PANELS_PER_STORY,
|
||||
MAX_STORYBOARD_PANEL_COUNT,
|
||||
MIN_STORYBOARD_PANEL_COUNT,
|
||||
PANEL_COUNT_WARN_THRESHOLD,
|
||||
} from '../constants';
|
||||
import type { ComicStory } from '../types';
|
||||
import ReferenceInputPicker, { type ReferenceSelection } from './ReferenceInputPicker.svelte';
|
||||
|
||||
interface Props {
|
||||
story: ComicStory;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { story, onClose }: Props = $props();
|
||||
|
||||
type Step = 'pick-source' | 'generating-plan' | 'review-plan' | 'rendering';
|
||||
|
||||
let step = $state<Step>('pick-source');
|
||||
let selection = $state<ReferenceSelection | null>(null);
|
||||
let requestedCount = $state(DEFAULT_STORYBOARD_PANEL_COUNT);
|
||||
let planError = $state<string | null>(null);
|
||||
|
||||
interface PlanRow extends StoryboardPanel {
|
||||
id: string;
|
||||
}
|
||||
|
||||
let rows = $state<PlanRow[]>([]);
|
||||
|
||||
type Quality = 'low' | 'medium' | 'high';
|
||||
const QUALITIES: readonly Quality[] = ['low', 'medium', 'high'] as const;
|
||||
const CREDIT_COST: Record<Quality, number> = { low: 3, medium: 10, high: 25 };
|
||||
let quality = $state<Quality>('medium');
|
||||
// svelte-ignore state_referenced_locally
|
||||
let size = $state<PanelSize>(story.style === 'webtoon' ? '1024x1536' : '1024x1024');
|
||||
|
||||
type RowStatus = 'idle' | 'pending' | 'ok' | 'error';
|
||||
let rowStatus = $state<Record<string, { status: RowStatus; error?: string }>>({});
|
||||
let renderBusy = $state(false);
|
||||
|
||||
const panelCount = $derived(story.panelImageIds.length);
|
||||
const roomLeft = $derived(Math.max(0, MAX_PANELS_PER_STORY - panelCount));
|
||||
const filledRows = $derived(rows.filter((r) => r.prompt.trim().length > 0));
|
||||
const effectiveCount = $derived(Math.min(filledRows.length, roomLeft));
|
||||
const warn = $derived(
|
||||
panelCount + effectiveCount >= PANEL_COUNT_WARN_THRESHOLD &&
|
||||
panelCount + effectiveCount <= MAX_PANELS_PER_STORY
|
||||
);
|
||||
|
||||
const totalCost = $derived(CREDIT_COST[quality] * effectiveCount);
|
||||
|
||||
async function handleSelect(sel: ReferenceSelection) {
|
||||
selection = sel;
|
||||
step = 'generating-plan';
|
||||
planError = null;
|
||||
try {
|
||||
const result = await suggestPanels({
|
||||
style: story.style,
|
||||
sourceText: sel.sourceText,
|
||||
panelCount: requestedCount,
|
||||
storyContext: story.storyContext,
|
||||
sourceModule: sel.module as StoryboardSourceModule,
|
||||
});
|
||||
rows = result.panels.map((p) => ({ ...p, id: crypto.randomUUID() }));
|
||||
step = 'review-plan';
|
||||
} catch (err) {
|
||||
planError = err instanceof Error ? err.message : 'Unbekannter Fehler';
|
||||
step = 'pick-source';
|
||||
}
|
||||
}
|
||||
|
||||
function removeRow(id: string) {
|
||||
if (rows.length <= 1) return;
|
||||
rows = rows.filter((r) => r.id !== id);
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
rows.push({ id: crypto.randomUUID(), prompt: '', caption: undefined, dialogue: undefined });
|
||||
}
|
||||
|
||||
async function submitRow(row: PlanRow): Promise<string | null> {
|
||||
if (!selection) return null;
|
||||
rowStatus[row.id] = { status: 'pending' };
|
||||
try {
|
||||
const result = await runPanelGenerate({
|
||||
story,
|
||||
panelPrompt: row.prompt,
|
||||
caption: row.caption?.trim() || undefined,
|
||||
dialogue: row.dialogue?.trim() || undefined,
|
||||
quality,
|
||||
size,
|
||||
sourceInput: {
|
||||
module: selection.module,
|
||||
entryId: selection.entryId,
|
||||
},
|
||||
});
|
||||
rowStatus[row.id] = { status: 'ok' };
|
||||
return result.imageId;
|
||||
} catch (err) {
|
||||
rowStatus[row.id] = {
|
||||
status: 'error',
|
||||
error: err instanceof Error ? err.message : 'Unbekannter Fehler',
|
||||
};
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRender() {
|
||||
if (renderBusy || filledRows.length === 0) return;
|
||||
renderBusy = true;
|
||||
step = 'rendering';
|
||||
rowStatus = {};
|
||||
const effectiveRows = filledRows.slice(0, roomLeft);
|
||||
await Promise.allSettled(effectiveRows.map((r) => submitRow(r)));
|
||||
renderBusy = false;
|
||||
// Close the flow once everything succeeded. If any row failed the
|
||||
// user stays in the review step with per-row retry chips.
|
||||
const anyError = Object.values(rowStatus).some((s) => s.status === 'error');
|
||||
if (!anyError) {
|
||||
onClose();
|
||||
} else {
|
||||
step = 'review-plan';
|
||||
}
|
||||
}
|
||||
|
||||
async function retryRow(row: PlanRow) {
|
||||
if (renderBusy) return;
|
||||
renderBusy = true;
|
||||
await submitRow(row);
|
||||
renderBusy = false;
|
||||
}
|
||||
|
||||
function resetToSource() {
|
||||
step = 'pick-source';
|
||||
rows = [];
|
||||
rowStatus = {};
|
||||
selection = null;
|
||||
planError = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||
<header class="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="flex items-center gap-1.5 text-sm font-semibold text-foreground">
|
||||
<Sparkle size={14} class="text-primary" />
|
||||
Mit KI aus Text generieren
|
||||
</h3>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{#if step === 'pick-source'}
|
||||
Schritt 1 · Quelle auswählen
|
||||
{:else if step === 'generating-plan'}
|
||||
Schritt 2 · Panels werden vorgeschlagen…
|
||||
{:else if step === 'review-plan'}
|
||||
Schritt 3 · Vorschläge prüfen und generieren
|
||||
{:else}
|
||||
Schritt 4 · Panels werden gerendert…
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="KI-Flow schließen"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if step === 'pick-source'}
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
for="panel-count"
|
||||
class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
Panel-Anzahl:
|
||||
</label>
|
||||
<input
|
||||
id="panel-count"
|
||||
type="number"
|
||||
min={MIN_STORYBOARD_PANEL_COUNT}
|
||||
max={MAX_STORYBOARD_PANEL_COUNT}
|
||||
bind:value={requestedCount}
|
||||
class="w-16 rounded-md border border-border bg-background px-2 py-1 text-sm text-foreground focus:border-primary focus:outline-none"
|
||||
/>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
({MIN_STORYBOARD_PANEL_COUNT}–{MAX_STORYBOARD_PANEL_COUNT})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if planError}
|
||||
<div
|
||||
class="rounded-md border border-error/30 bg-error/10 px-3 py-2 text-sm text-error"
|
||||
role="alert"
|
||||
>
|
||||
{planError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ReferenceInputPicker onSelect={handleSelect} />
|
||||
</div>
|
||||
{:else if step === 'generating-plan'}
|
||||
<div class="flex items-center justify-center gap-3 py-8" role="status" aria-live="polite">
|
||||
<SpinnerGap size={20} class="spinner text-primary" weight="bold" />
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Das Modell denkt über deine {requestedCount} Panels nach…
|
||||
</p>
|
||||
</div>
|
||||
{:else if step === 'review-plan' || step === 'rendering'}
|
||||
<div class="space-y-3">
|
||||
{#if selection}
|
||||
<div
|
||||
class="flex items-start justify-between gap-2 rounded-md bg-muted/30 px-3 py-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="font-medium text-foreground">
|
||||
Quelle: {selection.label}
|
||||
</p>
|
||||
<p class="text-[11px] capitalize text-muted-foreground">{selection.module}</p>
|
||||
</div>
|
||||
{#if !renderBusy}
|
||||
<button
|
||||
type="button"
|
||||
onclick={resetToSource}
|
||||
class="inline-flex items-center gap-1 text-[11px] font-medium text-primary hover:underline"
|
||||
>
|
||||
<ArrowLeft size={10} />
|
||||
Andere Quelle
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if warn && !renderBusy}
|
||||
<p class="rounded-md bg-muted px-3 py-2 text-xs text-muted-foreground">
|
||||
Hinweis: Ab ~{PANEL_COUNT_WARN_THRESHOLD} Panels wird Character-Konsistenz spürbar schwerer.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if roomLeft < rows.length}
|
||||
<div
|
||||
class="rounded-md border border-error/30 bg-error/10 px-3 py-2 text-xs text-error"
|
||||
role="alert"
|
||||
>
|
||||
Nur {roomLeft} Slot{roomLeft === 1 ? '' : 's'} frei in dieser Story — die letzten {rows.length -
|
||||
roomLeft} Vorschläge werden nicht gerendert.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-2">
|
||||
{#each rows as row, index (row.id)}
|
||||
{@const status = rowStatus[row.id]}
|
||||
{@const overRoom = index >= roomLeft}
|
||||
<div
|
||||
class="rounded-lg border border-border bg-background p-3"
|
||||
class:opacity-50={overRoom}
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||
<span
|
||||
class="inline-flex h-5 w-5 items-center justify-center rounded-full bg-muted text-[10px] text-foreground"
|
||||
>
|
||||
{panelCount + index + 1}
|
||||
</span>
|
||||
<span>Panel {index + 1}</span>
|
||||
{#if status?.status === 'pending'}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 text-primary"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<SpinnerGap size={12} class="spinner" weight="bold" />
|
||||
Wird generiert…
|
||||
</span>
|
||||
{:else if status?.status === 'ok'}
|
||||
<span class="inline-flex items-center gap-1 text-primary">
|
||||
<CheckCircle size={12} weight="fill" />
|
||||
Fertig
|
||||
</span>
|
||||
{:else if status?.status === 'error'}
|
||||
<span class="inline-flex items-center gap-1 text-error">
|
||||
<WarningCircle size={12} weight="fill" />
|
||||
Fehlgeschlagen
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
{#if status?.status === 'error'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => retryRow(row)}
|
||||
disabled={renderBusy}
|
||||
class="text-[11px] font-medium text-primary hover:underline disabled:opacity-50"
|
||||
>
|
||||
Neu versuchen
|
||||
</button>
|
||||
{/if}
|
||||
{#if rows.length > 1 && !renderBusy}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeRow(row.id)}
|
||||
class="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-error"
|
||||
aria-label="Vorschlag entfernen"
|
||||
>
|
||||
<Trash size={12} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
bind:value={row.prompt}
|
||||
rows={2}
|
||||
placeholder="Prompt — was passiert im Panel?"
|
||||
maxlength={600}
|
||||
class="block w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm text-foreground focus:border-primary focus:outline-none"
|
||||
disabled={renderBusy}
|
||||
></textarea>
|
||||
|
||||
<div class="mt-2 grid gap-2 sm:grid-cols-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={row.caption}
|
||||
placeholder="Caption (optional)"
|
||||
maxlength={120}
|
||||
class="block w-full rounded-md border border-border bg-background px-2 py-1 text-xs text-foreground focus:border-primary focus:outline-none"
|
||||
disabled={renderBusy}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={row.dialogue}
|
||||
placeholder="Dialog (optional)"
|
||||
maxlength={120}
|
||||
class="block w-full rounded-md border border-border bg-background px-2 py-1 text-xs text-foreground focus:border-primary focus:outline-none"
|
||||
disabled={renderBusy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if status?.status === 'error' && status.error}
|
||||
<p class="mt-2 text-[11px] text-error" role="alert">{status.error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if !renderBusy}
|
||||
<button
|
||||
type="button"
|
||||
onclick={addRow}
|
||||
disabled={rows.length >= MAX_PANELS_PER_STORY}
|
||||
class="inline-flex items-center gap-1 rounded-md border border-border bg-background px-2.5 py-1.5 text-xs text-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Plus size={12} />
|
||||
Weiteres Panel manuell
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3 border-t border-border pt-3">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-[11px] font-medium text-muted-foreground">Qualität:</span>
|
||||
{#each QUALITIES as q (q)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (quality = q)}
|
||||
class="rounded-md border px-2 py-0.5 text-[11px] transition-colors
|
||||
{quality === q
|
||||
? 'border-primary bg-primary/10 text-foreground'
|
||||
: 'border-border bg-background text-muted-foreground hover:bg-muted'}"
|
||||
disabled={renderBusy}
|
||||
aria-pressed={quality === q}
|
||||
>
|
||||
{q} ({CREDIT_COST[q]}c)
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-[11px] font-medium text-muted-foreground">Format:</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (size = '1024x1024')}
|
||||
class="rounded-md border px-2 py-0.5 text-[11px] transition-colors
|
||||
{size === '1024x1024'
|
||||
? 'border-primary bg-primary/10 text-foreground'
|
||||
: 'border-border bg-background text-muted-foreground hover:bg-muted'}"
|
||||
disabled={renderBusy}
|
||||
aria-pressed={size === '1024x1024'}
|
||||
>
|
||||
Quadrat
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (size = '1024x1536')}
|
||||
class="rounded-md border px-2 py-0.5 text-[11px] transition-colors
|
||||
{size === '1024x1536'
|
||||
? 'border-primary bg-primary/10 text-foreground'
|
||||
: 'border-border bg-background text-muted-foreground hover:bg-muted'}"
|
||||
disabled={renderBusy}
|
||||
aria-pressed={size === '1024x1536'}
|
||||
>
|
||||
Hoch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleRender}
|
||||
disabled={renderBusy || effectiveCount === 0}
|
||||
class="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if renderBusy}
|
||||
<SpinnerGap size={14} class="spinner" weight="bold" />
|
||||
{effectiveCount}
|
||||
{effectiveCount === 1 ? 'Panel' : 'Panels'} werden generiert…
|
||||
{:else}
|
||||
<Sparkle size={14} />
|
||||
{effectiveCount}
|
||||
{effectiveCount === 1 ? 'Panel' : 'Panels'} generieren ({totalCost}c)
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(.spinner) {
|
||||
animation: panel-spin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes panel-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -19,6 +19,7 @@
|
|||
import PanelStrip from '../components/PanelStrip.svelte';
|
||||
import PanelEditor from '../components/PanelEditor.svelte';
|
||||
import BatchPanelEditor from '../components/BatchPanelEditor.svelte';
|
||||
import StoryboardSuggester from '../components/StoryboardSuggester.svelte';
|
||||
import { encryptRecord } from '$lib/data/crypto';
|
||||
import type { ComicPanelMeta, LocalComicStory } from '../types';
|
||||
|
||||
|
|
@ -32,7 +33,7 @@
|
|||
const story$ = useStory(id);
|
||||
const story = $derived(story$.value);
|
||||
|
||||
type EditorMode = 'off' | 'single' | 'batch';
|
||||
type EditorMode = 'off' | 'single' | 'batch' | 'ai';
|
||||
let editorMode = $state<EditorMode>('off');
|
||||
|
||||
async function handleToggleFavorite() {
|
||||
|
|
@ -189,6 +190,15 @@
|
|||
<Plus size={12} />
|
||||
Batch
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (editorMode = 'ai')}
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-primary/40 bg-primary/5 px-3 py-1.5 text-xs font-medium text-primary transition-colors hover:bg-primary/10"
|
||||
title="KI schlägt Panels aus einem Tagebuch-Eintrag, Notiz oder Review vor"
|
||||
>
|
||||
<Sparkle size={12} weight="fill" />
|
||||
Mit KI
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -211,6 +221,8 @@
|
|||
/>
|
||||
{:else if editorMode === 'batch' && !story.isArchived}
|
||||
<BatchPanelEditor {story} onClose={() => (editorMode = 'off')} />
|
||||
{:else if editorMode === 'ai' && !story.isArchived}
|
||||
<StoryboardSuggester {story} onClose={() => (editorMode = 'off')} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue