Files
velum-raycast/src/reply-email.tsx
muena c0078ce339 feat: derive AI model list from Raycast SDK enum
Generate MODEL_OPTIONS at runtime from AI.Model (dedupe by value, drop
@deprecated aliases), so the AI-view dropdowns show every model the
installed @raycast/api ships — no more hand-maintained six-entry list.

Switch the summaryModel preference from a static dropdown to a textfield
holding a model ID. The discoverable picker lives in the AI views (now
fully dynamic), and the preference just stores the chosen default.

Drop duplicate MODEL_OPTIONS / CREATIVITY_OPTIONS constants from
summarize-email.tsx and reply-email.tsx; both pull from ./ai now like
the briefing and extract views already do.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 07:08:58 +02:00

774 lines
21 KiB
TypeScript

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<VelumSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string>();
const [entityTypes, setEntityTypes] = useState<EntityType[]>([]);
const [selectedEntityTypes, setSelectedEntityTypes] = useState<string[]>([]);
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<Creativity>("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(
<ConfirmReply
session={updatedSession}
pseudonymizedText={pseudoResult.pseudonymized_text}
model={model}
creativity={creativity}
greeting={greeting}
signOff={signOff}
userFullName={userFullName}
instructions={values.instructions}
disclosure={disclosure}
/>,
);
} catch (error) {
toast.style = Toast.Style.Failure;
toast.title = "Pseudonymisierung fehlgeschlagen";
toast.message = error instanceof Error ? error.message : String(error);
}
}
return (
<Form
isLoading={isLoading}
actions={
<ActionPanel>
<Action.SubmitForm
title="Antwort generieren"
icon={Icon.Reply}
onSubmit={handleSubmit}
/>
<Action
title="Markierten Text übernehmen"
icon={Icon.TextSelection}
onAction={loadSelectedText}
/>
<Action
title="Zwischenablage übernehmen"
icon={Icon.Clipboard}
onAction={loadClipboardText}
/>
</ActionPanel>
}
>
<Form.TextArea
id="text"
title="Email / Mailverlauf"
value={text}
onChange={setText}
placeholder="Email oder Mailverlauf einfügen oder laden"
/>
<Form.Dropdown
id="greeting"
title="Anrede"
value={greeting}
onChange={(v) => {
setGreeting(v);
persist({ greeting: v });
}}
>
{GREETING_OPTIONS.map((option) => (
<Form.Dropdown.Item
key={option.value}
value={option.value}
title={option.title}
/>
))}
</Form.Dropdown>
<Form.Dropdown
id="signOff"
title="Grußformel"
value={signOff}
onChange={(v) => {
setSignOff(v);
persist({ signOff: v });
}}
>
{SIGN_OFF_OPTIONS.map((option) => (
<Form.Dropdown.Item
key={option.value}
value={option.value}
title={option.title}
/>
))}
</Form.Dropdown>
<Form.TextField
id="userFullName"
title="Eigener Name"
value={userFullName}
onChange={setUserFullName}
info="Wird aus den Extension-Einstellungen vorbefüllt und nur für diesen Aufruf überschrieben."
placeholder="Wird unter die Grußformel gesetzt"
/>
<Form.TextArea
id="instructions"
title="Zusätzliche Anweisungen"
placeholder="Optional: Tonfall, Inhalte, Termine, …"
/>
<Form.Checkbox
id="disclosure"
label="Maskierungs-Hinweis am Ende anfügen"
value={disclosure}
onChange={(v) => {
setDisclosure(v);
persist({ disclosure: v });
}}
info="Listet am Ende der Email auf, welche Originalbegriffe vor dem KI-Aufruf lokal durch Platzhalter ersetzt wurden."
/>
<Form.Separator />
<Form.Dropdown
id="sessionId"
title="Sitzung"
defaultValue={defaultSessionId}
>
<Form.Dropdown.Item
value={NEW_SESSION_ID}
title="Neue Sitzung"
icon={Icon.Plus}
/>
{sessions.map((session) => (
<Form.Dropdown.Item
key={session.id}
value={session.id}
title={session.name}
icon={
session.id === activeSessionId ? Icon.CheckCircle : Icon.Circle
}
/>
))}
</Form.Dropdown>
{entityTypes.length > 0 ? (
<Form.TagPicker
id="entityTypes"
title="Entitätstypen"
value={selectedEntityTypes}
onChange={setSelectedEntityTypes}
>
{entityTypes.map((entityType) => (
<Form.TagPicker.Item
key={entityType}
value={entityType}
title={entityType}
/>
))}
</Form.TagPicker>
) : null}
<Form.Dropdown
id="model"
title="KI-Modell"
value={model}
onChange={(v) => {
setModel(v);
persist({ model: v });
}}
>
{MODEL_OPTIONS.map((option) => (
<Form.Dropdown.Item
key={option.value}
value={option.value}
title={option.title}
/>
))}
</Form.Dropdown>
<Form.Dropdown
id="creativity"
title="Kreativität"
value={creativity}
onChange={(v) => {
const next = v as Creativity;
setCreativity(next);
persist({ creativity: next });
}}
>
{CREATIVITY_OPTIONS.map((value) => (
<Form.Dropdown.Item key={value} value={value} title={value} />
))}
</Form.Dropdown>
</Form>
);
}
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(
<ReplyResult
session={props.session}
pseudonymizedText={editedText}
model={props.model}
creativity={props.creativity}
greeting={greeting}
signOff={signOff}
userFullName={userFullName}
instructions={editedInstructions}
disclosure={disclosure}
/>,
);
}
return (
<Form
actions={
<ActionPanel>
<Action.SubmitForm
title="An KI senden"
icon={Icon.Wand}
onSubmit={handleSubmit}
/>
<Action.CopyToClipboard
title="Pseudonymisierten Text kopieren"
content={editedText}
/>
</ActionPanel>
}
>
<Form.Description
title="Bestätigen"
text={`Prüfe den pseudonymisierten Text vor dem KI-Aufruf. Die KI verfasst die Anrede mit dem passenden Platzhalter selbst — nur die Signatur wird lokal angefügt. Sitzung: ${props.session.name} · ${Object.keys(props.session.mapping).length} Zuordnungen.`}
/>
<Form.TextArea
id="pseudonymizedText"
title="Pseudonymisierter Text"
value={editedText}
onChange={setEditedText}
/>
<Form.Dropdown
id="greeting"
title="Anrede"
value={greeting}
onChange={(v) => {
setGreeting(v);
saveReplyDefaults({
greeting: v,
signOff,
model: props.model,
creativity: props.creativity,
disclosure,
}).catch(() => {});
}}
>
{GREETING_OPTIONS.map((option) => (
<Form.Dropdown.Item
key={option.value}
value={option.value}
title={option.title}
/>
))}
</Form.Dropdown>
<Form.Dropdown
id="signOff"
title="Grußformel"
value={signOff}
onChange={(v) => {
setSignOff(v);
saveReplyDefaults({
greeting,
signOff: v,
model: props.model,
creativity: props.creativity,
disclosure,
}).catch(() => {});
}}
>
{SIGN_OFF_OPTIONS.map((option) => (
<Form.Dropdown.Item
key={option.value}
value={option.value}
title={option.title}
/>
))}
</Form.Dropdown>
<Form.TextField
id="userFullName"
title="Eigener Name"
value={userFullName}
onChange={setUserFullName}
/>
<Form.TextArea
id="instructions"
title="Zusätzliche Anweisungen"
value={editedInstructions}
onChange={setEditedInstructions}
placeholder="Optional"
/>
<Form.Checkbox
id="disclosure"
label="Maskierungs-Hinweis am Ende anfügen"
value={disclosure}
onChange={(v) => {
setDisclosure(v);
saveReplyDefaults({
greeting,
signOff,
model: props.model,
creativity: props.creativity,
disclosure: v,
}).catch(() => {});
}}
/>
<Form.Separator />
<Form.Description
title="Zuordnung"
text={mappingDescriptionText(props.session)}
/>
</Form>
);
}
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<string | null>(null);
const [disclosureContent, setDisclosureContent] =
useState<DisclosureContent | null>(null);
const [phase, setPhase] = useState<Phase>("drafting");
const [error, setError] = useState<string | null>(null);
const controllerRef = useRef<AbortController | null>(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 (
<Detail
isLoading={isLoading}
markdown={markdown}
actions={
<ActionPanel>
{finalEmailText && finalEmailHtml ? (
<>
<Action.CopyToClipboard
title="Antwort kopieren (HTML)"
content={{
html: finalEmailHtml,
text: finalEmailText,
}}
/>
<Action
title="Antwort einfügen (HTML)"
icon={Icon.TextCursor}
onAction={() =>
Clipboard.paste({
html: finalEmailHtml,
text: finalEmailText,
})
}
/>
<Action.CopyToClipboard
title="Antwort kopieren (Klartext)"
content={finalEmailText}
/>
</>
) : null}
{aiBody ? (
<Action.CopyToClipboard
title="Pseudonymisierten KI-Body kopieren"
content={aiBody}
/>
) : null}
<Action.CopyToClipboard
title="Pseudonymisierte Eingabe kopieren"
content={props.pseudonymizedText}
/>
</ActionPanel>
}
/>
);
}