Files
velum-raycast/src/reply.ts
muena 9bc621307c feat: persist last-used model in LocalStorage instead of preference
Drop the summaryModel preference (and the awkward textfield holding a
raw model ID nobody could discover without poking the SDK types). Each
AI view now controls its model dropdown, loads the shared
velum.ai.last-model on mount, and writes it back on submit — so picking
Claude 4.7 Opus in the summarize command becomes the prefilled default
in briefing, action-items, structured-data, and reply next time around.

Also drops 'model' from ReplyDefaults; the shared key supersedes it.

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

304 lines
11 KiB
TypeScript

import { LocalStorage } from "@raycast/api";
import type { PlaceholderMapping } from "./types";
const REPLY_DEFAULTS_KEY = "velum.reply.defaults.v1";
export type ReplyDefaults = {
greeting: string;
signOff: string;
creativity: string;
disclosure: boolean;
};
export async function loadReplyDefaults(): Promise<Partial<ReplyDefaults>> {
const raw = await LocalStorage.getItem<string>(REPLY_DEFAULTS_KEY);
if (!raw) return {};
try {
return JSON.parse(raw) as Partial<ReplyDefaults>;
} catch {
return {};
}
}
export async function saveReplyDefaults(values: ReplyDefaults): Promise<void> {
await LocalStorage.setItem(REPLY_DEFAULTS_KEY, JSON.stringify(values));
}
export const GREETING_OPTIONS: Array<{ value: string; title: string }> = [
{ value: "Lieber", title: "Lieber" },
{ value: "Liebe", title: "Liebe" },
{ value: "Hallo", title: "Hallo" },
{ value: "Hi", title: "Hi" },
{ value: "Sehr geehrter", title: "Sehr geehrter" },
{ value: "Sehr geehrte", title: "Sehr geehrte" },
];
export const SIGN_OFF_OPTIONS: Array<{ value: string; title: string }> = [
{ value: "Alles Liebe,", title: "Alles Liebe," },
{ value: "Liebe Grüße,", title: "Liebe Grüße," },
{ value: "Viele Grüße,", title: "Viele Grüße," },
{ value: "Beste Grüße,", title: "Beste Grüße," },
{ value: "Mit freundlichen Grüßen,", title: "Mit freundlichen Grüßen," },
];
export function listPersonPlaceholdersInText(
pseudonymizedText: string,
mapping: PlaceholderMapping,
): string[] {
return listPersonPlaceholders(mapping).filter((placeholder) =>
pseudonymizedText.includes(placeholder),
);
}
export function guessRecipientPlaceholder(
pseudonymizedText: string,
mapping: PlaceholderMapping,
): string | null {
const persons = listPersonPlaceholdersInText(pseudonymizedText, mapping);
if (persons.length === 0) return null;
const fromMatch = pseudonymizedText.match(
/(?:^|\n)\s*(?:Von|From|Absender)\s*:[^\n]*?(<PERSON_\d+>)/i,
);
if (fromMatch) return fromMatch[1];
const signOffMatch = pseudonymizedText.match(
/(?:Liebe Grüße|Liebe Gruesse|Beste Grüße|Beste Gruesse|Mit freundlichen Grüßen|Mit freundlichen Gruessen|Viele Grüße|Viele Gruesse|Alles Liebe|Herzliche Grüße|Herzliche Gruesse|MfG|LG|VG|Cheers|Best regards|Kind regards)[\s,!.\n]+(<PERSON_\d+>)/i,
);
if (signOffMatch) return signOffMatch[1];
const greetingMatch = pseudonymizedText.match(
/(?:Lieber|Liebe|Hallo|Hi|Hey|Sehr geehrter|Sehr geehrte|Guten Tag)\s+(?:Herr |Frau )?(<PERSON_\d+>)/i,
);
const greetingPlaceholder = greetingMatch ? greetingMatch[1] : null;
for (const placeholder of persons) {
if (placeholder === greetingPlaceholder) continue;
return placeholder;
}
return persons[0];
}
export function listPersonPlaceholders(mapping: PlaceholderMapping): string[] {
return Object.entries(mapping)
.filter(([, entry]) => entry.type.toUpperCase() === "PERSON")
.map(([placeholder]) => placeholder)
.sort((a, b) => {
const numA = Number.parseInt(a.replace(/\D/g, ""), 10);
const numB = Number.parseInt(b.replace(/\D/g, ""), 10);
if (Number.isFinite(numA) && Number.isFinite(numB)) {
return numA - numB;
}
return a.localeCompare(b);
});
}
export function extractPersonPlaceholdersFromText(
pseudonymizedText: string,
): string[] {
const matches = pseudonymizedText.match(/<PERSON_\d+>/g) ?? [];
const unique = Array.from(new Set(matches));
return unique.sort((a, b) => {
const numA = Number.parseInt(a.replace(/\D/g, ""), 10);
const numB = Number.parseInt(b.replace(/\D/g, ""), 10);
return numA - numB;
});
}
export function buildReplyPrompt(options: {
pseudonymizedText: string;
greeting: string;
instructions?: string;
}): string {
const extra = options.instructions?.trim()
? `\n\nZusätzliche Anweisungen des Nutzers:\n${options.instructions.trim()}`
: "";
const availablePersons = extractPersonPlaceholdersFromText(
options.pseudonymizedText,
);
const allowlistText =
availablePersons.length > 0
? `Erlaubte PERSON-Platzhalter (du darfst NUR genau einen aus dieser Liste verwenden, zeichengetreu inkl. spitzer Klammern): ${availablePersons.join(", ")}.`
: "In der Konversation kommt kein PERSON-Platzhalter vor. Verzichte in diesem Fall ausnahmsweise auf den Namen in der Anrede und schreibe nur die Anrede ohne Namen, gefolgt von einem Komma.";
return [
"Du erhältst eine pseudonymisierte Email-Konversation (oder eine einzelne Email) und sollst eine Antwort verfassen.",
"",
"Personenbezogene Daten wurden bereits durch Platzhalter wie <PERSON_1>, <EMAIL_2>, <ORG_3>, <PHONE_4>, <ADDRESS_5> ersetzt.",
"STRENGE REGEL: Gib jeden Platzhalter zeichengetreu (inklusive spitzer Klammern, Großbuchstaben und Unterstrich + Nummer) zurück. Erfinde keine neuen Platzhalter.",
"Du darfst Platzhalter NIEMALS auflösen, raten, übersetzen oder mit erfundenen Namen ersetzen.",
"",
"WICHTIG — wer ist der Empfänger der Antwort?",
"Die zu beantwortende Email ist die OBERSTE/NEUESTE im Text (Email-Clients zeigen neueste oben).",
"In dieser obersten Email gilt:",
"- Direkt am Anfang steht meist eine Anrede wie „Lieber <PERSON_X>,“ oder „Hallo <PERSON_X>,“. Diese Person ist der EMPFÄNGER der EINGEGANGENEN Email — also der NUTZER selbst, für den du die Antwort verfasst. Verwende DIESEN Platzhalter NICHT als Adressaten deiner Antwort.",
"- Am Ende der obersten Email (vor möglichen älteren, eingerückten/zitierten Mails) steht meist eine Grußformel („LG <PERSON_Y>“, „Liebe Grüße <PERSON_Y>“, „Mit freundlichen Grüßen <PERSON_Y>“, o. ä.). Diese Person <PERSON_Y> ist der SENDER der eingegangenen Email — und damit der EMPFÄNGER deiner Antwort.",
"Beispiel: Eingang lautet „Lieber <PERSON_3>, … LG <PERSON_7>“. Dann ist <PERSON_3> der Nutzer (NICHT verwenden), und du adressierst die Antwort an <PERSON_7>.",
"",
"Aufgabe: Verfasse die Antwort auf Deutsch.",
"",
"Format der Antwort — exakt einhalten:",
`1. Erste Zeile: Begrüßung im Format „${options.greeting} <PERSON_NUMMER>,“ — wähle den Platzhalter des SENDERS der obersten Email (siehe oben). Wähle NICHT den Platzhalter aus der Anrede der eingegangenen Email.`,
` ${allowlistText}`,
" ABSOLUT VERBOTEN: Schreibe niemals `<PERSON_N>`, `<PERSON_X>`, `<PERSON_XX>`, `<PERSON_x>` oder ähnliche Schema-Platzhalter mit Buchstaben statt Ziffern. Nur konkrete Werte aus der Allowlist sind erlaubt.",
"2. Eine Leerzeile.",
"3. Den eigentlichen Antworttext.",
"WICHTIG: Schreibe KEINE Signatur, KEINE Grußformel und KEINEN Absendernamen am Ende — die Grußformel und der Absender werden außerhalb der KI angefügt.",
extra,
"",
"--- Email-Konversation (pseudonymisiert) ---",
options.pseudonymizedText,
"--- Ende der Konversation ---",
].join("\n");
}
export function composeReplyEmail(options: {
body: string;
signOff: string;
userFullName: string;
disclosureBlock?: string;
}): string {
const trimmedBody = options.body.trim();
const signature = `${options.signOff}\n${options.userFullName.trim()}`;
const tail = options.disclosureBlock?.trim()
? `\n\n${options.disclosureBlock.trim()}`
: "";
return `${trimmedBody}\n\n${signature}${tail}`;
}
export function extractAllPlaceholdersFromText(
pseudonymizedText: string,
): string[] {
const matches = pseudonymizedText.match(/<[A-Z]+_\d+>/g) ?? [];
return Array.from(new Set(matches));
}
export type DisclosureContent = {
text: string;
html: string;
};
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
const TYPE_LABELS: Record<string, string> = {
PERSON: "Personen",
ORT: "Orte",
LOCATION: "Orte",
GPE: "Orte",
EMAIL: "E-Mail-Adressen",
EMAIL_ADDRESS: "E-Mail-Adressen",
PHONE: "Telefonnummern",
PHONE_NUMBER: "Telefonnummern",
ADDRESS: "Adressen",
ORG: "Organisationen",
ORGANIZATION: "Organisationen",
DATE: "Datumsangaben",
TIME: "Zeitangaben",
IBAN: "IBANs",
IBAN_CODE: "IBANs",
URL: "URLs",
CREDIT_CARD: "Kreditkartennummern",
ID: "IDs",
MISC: "Sonstiges",
};
function labelForType(type: string): string {
const upper = type.toUpperCase();
return TYPE_LABELS[upper] ?? upper;
}
export function buildDisclosureContent(
pseudonymizedText: string,
mapping: PlaceholderMapping,
): DisclosureContent | null {
const entries = Object.entries(mapping)
.filter(([key]) => {
const stripped = key.replace(/[<>]/g, "");
return (
pseudonymizedText.includes(key) ||
pseudonymizedText.includes(`<${stripped}>`) ||
pseudonymizedText.includes(stripped)
);
})
.map(([key, entry]) => ({
original: entry.original,
placeholder: key.replace(/[<>]/g, ""),
type: entry.type,
}))
.sort((a, b) => a.placeholder.localeCompare(b.placeholder));
if (entries.length === 0) return null;
const groups = new Map<string, typeof entries>();
for (const entry of entries) {
const label = labelForType(entry.type);
const list = groups.get(label) ?? [];
list.push(entry);
groups.set(label, list);
}
const groupedEntries = Array.from(groups.entries()).sort(([a], [b]) =>
a.localeCompare(b),
);
const heading = "Dieses Email wurde KI generiert.";
const subheading =
"Folgende Texte wurden vor der Übermittlung lokal maskiert:";
const text = [
"---",
heading,
subheading,
...groupedEntries.map(
([label, list]) =>
`${label}: ${list.map((e) => `${e.original} (${e.placeholder})`).join(", ")}`,
),
].join("\n");
const html = [
"<hr>",
`<p>${escapeHtml(heading)}<br>${escapeHtml(subheading)}</p>`,
"<p>",
groupedEntries
.map(
([label, list]) =>
`<strong>${escapeHtml(label)}:</strong> ${list
.map(
(e) =>
`${escapeHtml(e.original)} (<span style="font-family: monospace;">${escapeHtml(e.placeholder)}</span>)`,
)
.join(", ")}`,
)
.join("<br>"),
"</p>",
].join("\n");
return { text, html };
}
export function plainTextToHtmlEmail(plainText: string): string {
const escaped = plainText
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const paragraphs = escaped.split(/\n\n+/);
return paragraphs
.map((paragraph) => {
if (/^\s*---\s*$/.test(paragraph)) {
return "<hr>";
}
if (/^---\n/.test(paragraph)) {
const rest = paragraph.replace(/^---\n/, "");
return `<hr>\n<p>${rest.replace(/\n/g, "<br>")}</p>`;
}
return `<p>${paragraph.replace(/\n/g, "<br>")}</p>`;
})
.join("\n");
}