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>
304 lines
11 KiB
TypeScript
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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">");
|
|
}
|
|
|
|
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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">");
|
|
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");
|
|
}
|