Initial commit: Velum Raycast extension
Pseudonymize-and-AI workflow for handling PII-sensitive text via the Velum API and Raycast AI. Commands cover end-to-end email summary and reply, briefing/action-items/structured-data extraction, manual pseudonymize/depseudonymize on selection or clipboard, and session management. Includes Raycast 2.0 Beta workarounds for selection capture and rich-text clipboard. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
783
src/reply-email.tsx
Normal file
783
src/reply-email.tsx
Normal file
@@ -0,0 +1,783 @@
|
||||
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 { 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;
|
||||
};
|
||||
|
||||
const CREATIVITY_OPTIONS: Creativity[] = ["none", "low", "medium", "high"];
|
||||
|
||||
const MODEL_OPTIONS: Array<{ value: string; title: string }> = [
|
||||
{ value: "anthropic-claude-sonnet-4-6", title: "Claude 4.6 Sonnet" },
|
||||
{ value: "anthropic-claude-opus-4-7", title: "Claude 4.7 Opus" },
|
||||
{ value: "anthropic-claude-4-5-haiku", title: "Claude 4.5 Haiku" },
|
||||
{ value: "openai-gpt-5.3-instant", title: "OpenAI GPT-5.3 Instant" },
|
||||
{ value: "openai-gpt-4.1", title: "OpenAI GPT-4.1" },
|
||||
{ value: "openai-gpt-4o-mini", title: "OpenAI GPT-4o mini" },
|
||||
];
|
||||
|
||||
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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user