import { Action, ActionPanel, AI, Clipboard, Detail, Form, Icon, showToast, Toast, useNavigation, } from "@raycast/api"; import { getSelectedTextSafely } from "./selection"; import { useEffect, useMemo, useRef, useState } from "react"; import { CREATIVITY_OPTIONS, MODEL_OPTIONS } from "./ai"; import { getPreferences } from "./preferences"; import { buildDisclosureContent, buildReplyPrompt, composeReplyEmail, type DisclosureContent, GREETING_OPTIONS, loadReplyDefaults, plainTextToHtmlEmail, saveReplyDefaults, SIGN_OFF_OPTIONS, } from "./reply"; import { createSession, getActiveSessionId, getSession, listSessions, setActiveSession, updateSessionMapping, } from "./sessions"; import type { Creativity } from "./summarize"; import type { EntityType, VelumSession } from "./types"; import { markdownCodeBlock, mappingDetailTable, NEW_SESSION_ID, sessionSubtitle, } from "./ui"; import { getEntityTypes, localDepseudonymize, normalizePseudonymizeResponse, pseudonymize, } from "./velum"; type FormValues = { text: string; sessionId: string; entityTypes: string[]; instructions: string; }; export default function Command() { const preferences = getPreferences(); const [text, setText] = useState(""); const [sessions, setSessions] = useState([]); const [activeSessionId, setActiveSessionId] = useState(); const [entityTypes, setEntityTypes] = useState([]); const [selectedEntityTypes, setSelectedEntityTypes] = useState([]); const [greeting, setGreeting] = useState("Lieber"); const [signOff, setSignOff] = useState("Alles Liebe,"); const [userFullName, setUserFullName] = useState(preferences.userFullName); const [model, setModel] = useState(preferences.summaryModel); const [creativity, setCreativity] = useState("medium"); const [disclosure, setDisclosure] = useState(false); const [isLoading, setIsLoading] = useState(true); const { push } = useNavigation(); useEffect(() => { async function load() { const [loadedSessions, activeId, defaults] = await Promise.all([ listSessions(), getActiveSessionId(), loadReplyDefaults(), ]); setSessions(loadedSessions); setActiveSessionId(activeId); if (defaults.greeting) setGreeting(defaults.greeting); if (defaults.signOff) setSignOff(defaults.signOff); if (defaults.model) setModel(defaults.model); if (defaults.creativity) setCreativity(defaults.creativity as Creativity); if (typeof defaults.disclosure === "boolean") setDisclosure(defaults.disclosure); const selection = await getSelectedTextSafely(); if (selection && selection.trim()) { setText(selection); } try { const loadedEntityTypes = await getEntityTypes(); setEntityTypes(loadedEntityTypes); setSelectedEntityTypes(loadedEntityTypes); } catch { setEntityTypes([]); } finally { setIsLoading(false); } } load(); }, [preferences.summaryModel, preferences.userFullName]); function persist( overrides: Partial<{ greeting: string; signOff: string; model: string; creativity: Creativity; disclosure: boolean; }>, ) { saveReplyDefaults({ greeting: overrides.greeting ?? greeting, signOff: overrides.signOff ?? signOff, model: overrides.model ?? model, creativity: overrides.creativity ?? creativity, disclosure: overrides.disclosure ?? disclosure, }).catch(() => { // Best-effort persistence. }); } const defaultSessionId = useMemo( () => activeSessionId || sessions[0]?.id || NEW_SESSION_ID, [activeSessionId, sessions], ); async function loadSelectedText() { const selected = await getSelectedTextSafely(); if (!selected) { await showToast({ style: Toast.Style.Failure, title: "Kein markierter Text gefunden", }); return; } setText(selected); } async function loadClipboardText() { const clipboardText = await Clipboard.readText(); if (!clipboardText) { await showToast({ style: Toast.Style.Failure, title: "Zwischenablage enthält keinen Text", }); return; } setText(clipboardText); } async function handleSubmit(values: FormValues) { const input = values.text.trim(); if (!input) { await showToast({ style: Toast.Style.Failure, title: "Email zum Beantworten eingeben", }); return; } if (!userFullName.trim()) { await showToast({ style: Toast.Style.Failure, title: "Eigener Name fehlt", message: "Trage deinen Namen für die Signatur ein.", }); return; } await saveReplyDefaults({ greeting, signOff, model, creativity, disclosure, }); const toast = await showToast({ style: Toast.Style.Animated, title: "Pseudonymisiere…", }); try { const session = values.sessionId === NEW_SESSION_ID ? await createSession() : ((await getSession(values.sessionId)) ?? (await createSession())); await setActiveSession(session.id); const rawResult = await pseudonymize( input, session.mapping, values.entityTypes, ); const pseudoResult = normalizePseudonymizeResponse(rawResult); const updatedSession = await updateSessionMapping( session.id, pseudoResult.mapping, pseudoResult.selected_entity_types, ); toast.style = Toast.Style.Success; toast.title = "Pseudonymisiert"; toast.message = `${pseudoResult.entity_count} Zuordnungen`; push( , ); } catch (error) { toast.style = Toast.Style.Failure; toast.title = "Pseudonymisierung fehlgeschlagen"; toast.message = error instanceof Error ? error.message : String(error); } } return (
} > { setGreeting(v); persist({ greeting: v }); }} > {GREETING_OPTIONS.map((option) => ( ))} { setSignOff(v); persist({ signOff: v }); }} > {SIGN_OFF_OPTIONS.map((option) => ( ))} { setDisclosure(v); persist({ disclosure: v }); }} info="Listet am Ende der Email auf, welche Originalbegriffe vor dem KI-Aufruf lokal durch Platzhalter ersetzt wurden." /> {sessions.map((session) => ( ))} {entityTypes.length > 0 ? ( {entityTypes.map((entityType) => ( ))} ) : null} { setModel(v); persist({ model: v }); }} > {MODEL_OPTIONS.map((option) => ( ))} { const next = v as Creativity; setCreativity(next); persist({ creativity: next }); }} > {CREATIVITY_OPTIONS.map((value) => ( ))} ); } type StageProps = { session: VelumSession; pseudonymizedText: string; model: string; creativity: Creativity; greeting: string; signOff: string; userFullName: string; instructions: string; disclosure: boolean; }; function ConfirmReply(props: StageProps) { const [editedText, setEditedText] = useState(props.pseudonymizedText); const [editedInstructions, setEditedInstructions] = useState( props.instructions, ); const [greeting, setGreeting] = useState(props.greeting); const [signOff, setSignOff] = useState(props.signOff); const [userFullName, setUserFullName] = useState(props.userFullName); const [disclosure, setDisclosure] = useState(props.disclosure); const { push } = useNavigation(); async function handleSubmit() { if (!editedText.trim()) { await showToast({ style: Toast.Style.Failure, title: "Pseudonymisierter Text ist leer", }); return; } if (!userFullName.trim()) { await showToast({ style: Toast.Style.Failure, title: "Eigener Name fehlt", message: "Trage deinen Namen für die Signatur ein.", }); return; } await saveReplyDefaults({ greeting, signOff, model: props.model, creativity: props.creativity, disclosure, }); push( , ); } return (
} > { setGreeting(v); saveReplyDefaults({ greeting: v, signOff, model: props.model, creativity: props.creativity, disclosure, }).catch(() => {}); }} > {GREETING_OPTIONS.map((option) => ( ))} { setSignOff(v); saveReplyDefaults({ greeting, signOff: v, model: props.model, creativity: props.creativity, disclosure, }).catch(() => {}); }} > {SIGN_OFF_OPTIONS.map((option) => ( ))} { setDisclosure(v); saveReplyDefaults({ greeting, signOff, model: props.model, creativity: props.creativity, disclosure: v, }).catch(() => {}); }} /> ); } function mappingDescriptionText(session: VelumSession): string { const entries = Object.entries(session.mapping).sort(([a], [b]) => a.localeCompare(b), ); if (entries.length === 0) { return "Keine Einträge — nichts wurde pseudonymisiert."; } return entries .map(([placeholder, entry]) => `${placeholder} → ${entry.original}`) .join("\n"); } type Phase = "drafting" | "restoring" | "done" | "error"; function ReplyResult(props: StageProps) { const [aiBody, setAiBody] = useState(""); const [restoredEmail, setRestoredEmail] = useState(null); const [disclosureContent, setDisclosureContent] = useState(null); const [phase, setPhase] = useState("drafting"); const [error, setError] = useState(null); const controllerRef = useRef(null); useEffect(() => { const controller = new AbortController(); controllerRef.current = controller; let cancelled = false; async function run() { const toast = await showToast({ style: Toast.Style.Animated, title: "Verfasse Antwort…", }); try { const prompt = buildReplyPrompt({ pseudonymizedText: props.pseudonymizedText, greeting: props.greeting, instructions: props.instructions, }); const stream = AI.ask(prompt, { model: props.model as AI.Model, creativity: props.creativity, signal: controller.signal, }); stream.on("data", (chunk) => { if (cancelled) return; setAiBody((prev) => prev + chunk); }); const aiText = await stream; if (cancelled) return; toast.title = "Stelle wieder her…"; setPhase("restoring"); const composed = composeReplyEmail({ body: aiText, signOff: props.signOff, userFullName: props.userFullName, }); const restoreResult = localDepseudonymize( composed, props.session.mapping, ); if (cancelled) return; const disclosure = props.disclosure ? buildDisclosureContent( props.pseudonymizedText, props.session.mapping, ) : null; setRestoredEmail(restoreResult.original_text); setDisclosureContent(disclosure); setPhase("done"); toast.style = Toast.Style.Success; toast.title = "Fertig"; toast.message = `${restoreResult.replacements_made} Ersetzungen`; } catch (err) { if (cancelled || controller.signal.aborted) return; const message = err instanceof Error ? err.message : String(err); setError(message); setPhase("error"); toast.style = Toast.Style.Failure; toast.title = "Antwort fehlgeschlagen"; toast.message = message; } } run(); return () => { cancelled = true; controller.abort(); }; }, [ props.pseudonymizedText, props.model, props.creativity, props.instructions, props.session.mapping, props.greeting, props.signOff, props.userFullName, props.disclosure, ]); const phaseLabel = phase === "drafting" ? "KI verfasst Antwort …" : phase === "restoring" ? "Platzhalter werden ersetzt …" : phase === "error" ? "Fehler" : "Fertig"; const finalEmailText = restoredEmail ? disclosureContent ? `${restoredEmail.trimEnd()}\n\n${disclosureContent.text}` : restoredEmail : null; const previewBody = finalEmailText ?? composeReplyEmail({ body: aiBody || "_Noch keine Inhalte — warte auf das Modell …_", signOff: props.signOff, userFullName: props.userFullName, }); const finalEmailHtml = restoredEmail ? disclosureContent ? `${plainTextToHtmlEmail(restoredEmail)}\n${disclosureContent.html}` : plainTextToHtmlEmail(restoredEmail) : null; const markdown = [ "# Email-Antwort", sessionSubtitle(props.session), `*Status:* ${phaseLabel}`, "", "## Generierte Antwort (zum Versenden)", markdownCodeBlock(previewBody), "", "---", "", "## Pseudonymisierte Eingabe", markdownCodeBlock(props.pseudonymizedText), "", "## Pseudonymisierter KI-Body", aiBody.trim() ? markdownCodeBlock(aiBody) : "_Noch nichts vom Modell empfangen._", "", "## Zuordnung", mappingDetailTable(props.session.mapping), error ? `\n> Fehler: ${error}` : "", ].join("\n"); const isLoading = phase === "drafting" || phase === "restoring"; return ( {finalEmailText && finalEmailHtml ? ( <> Clipboard.paste({ html: finalEmailHtml, text: finalEmailText, }) } /> ) : null} {aiBody ? ( ) : null} } /> ); }