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:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
raycast-env.d.ts
|
||||
35
README.md
Normal file
35
README.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Velum Raycast Extension
|
||||
|
||||
Raycast-Extension zum Pseudonymisieren und Wiederherstellen von Text mit Velum.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
Öffne die Raycast-Einstellungen für die Extension und konfiguriere:
|
||||
|
||||
- Velum Basis-URL, z. B. `https://velum.example.com`
|
||||
- Authentik Token-URL, üblicherweise `https://auth.example.com/application/o/token/`
|
||||
- OAuth Client-ID
|
||||
- Dienstkonto-Benutzername
|
||||
- Dienstkonto App-Passwort
|
||||
- Optionaler OAuth-Scope, Standard `profile`
|
||||
|
||||
Das Dienstkonto-Passwort wird als Raycast-Passwort-Preference gespeichert. Access-Tokens und Velum-Sitzungen liegen im Raycast-LocalStorage.
|
||||
|
||||
## Befehle
|
||||
|
||||
- `Email-Konversation zusammenfassen`: markierten Email-Verlauf pseudonymisieren, per Raycast-KI zusammenfassen und wiederherstellen.
|
||||
- `Text pseudonymisieren`: Text manuell eingeben oder markierten/Zwischenablage-Text laden, Sitzung wählen, Ergebnis kopieren oder einfügen.
|
||||
- `Markierten Text pseudonymisieren`: Schnellbefehl für markierten Text.
|
||||
- `Zwischenablage pseudonymisieren`: Schnellbefehl für die Zwischenablage.
|
||||
- `Text wiederherstellen`: Platzhalter mit einer gespeicherten Sitzungs-Zuordnung wiederherstellen.
|
||||
- `Sitzungen verwalten`: Zuordnungs-Sitzungen anlegen, aktivieren, ansehen, leeren oder löschen.
|
||||
|
||||
## Sitzungsverhalten
|
||||
|
||||
Velum-Zuordnungen enthalten die Originalwerte. Sitzungen steuern, welche Anfragen sich eine Zuordnung teilen:
|
||||
|
||||
- `Aktive Sitzung wiederverwenden`: setzt die aktuelle Zuordnung fort.
|
||||
- `Neue Sitzung pro Anfrage`: isoliert jede Schnellbefehl-Anfrage.
|
||||
- `Tagessitzung`: nutzt eine Zuordnung pro Tag.
|
||||
|
||||
Der interaktive Befehl bietet immer eine explizite Sitzungs-Auswahl plus eine `Neue Sitzung`-Option.
|
||||
BIN
assets/extension-icon.png
Normal file
BIN
assets/extension-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
10
eslint.config.js
Normal file
10
eslint.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
const raycastConfig = require("@raycast/eslint-config");
|
||||
|
||||
module.exports = [
|
||||
...raycastConfig.flat(),
|
||||
{
|
||||
rules: {
|
||||
"@raycast/prefer-title-case": "off",
|
||||
},
|
||||
},
|
||||
];
|
||||
3019
package-lock.json
generated
Normal file
3019
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
253
package.json
Normal file
253
package.json
Normal file
@@ -0,0 +1,253 @@
|
||||
{
|
||||
"$schema": "https://www.raycast.com/schemas/extension.json",
|
||||
"name": "velum",
|
||||
"title": "Velum",
|
||||
"description": "Text per Velum-API pseudonymisieren und wiederherstellen.",
|
||||
"icon": "extension-icon.png",
|
||||
"author": "raphael",
|
||||
"license": "MIT",
|
||||
"categories": [
|
||||
"Productivity",
|
||||
"Developer Tools"
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"name": "summarize-email",
|
||||
"title": "Email-Konversation zusammenfassen",
|
||||
"description": "Markierte Email pseudonymisieren, per Raycast-KI zusammenfassen und wiederherstellen.",
|
||||
"mode": "view"
|
||||
},
|
||||
{
|
||||
"name": "reply-email",
|
||||
"title": "Email-Antwort generieren",
|
||||
"description": "Antwort auf eine markierte Email/einen Mailverlauf per Raycast-KI verfassen — pseudonymisiert.",
|
||||
"mode": "view"
|
||||
},
|
||||
{
|
||||
"name": "briefing-from-notes",
|
||||
"title": "Briefing aus Notizen",
|
||||
"description": "Aus Notizen, Stichpunkten oder einem Transkript ein strukturiertes Briefing per Raycast-KI erstellen — pseudonymisiert.",
|
||||
"mode": "view"
|
||||
},
|
||||
{
|
||||
"name": "extract-action-items",
|
||||
"title": "Action Items extrahieren",
|
||||
"description": "Action Items aus einem Transkript, Thread oder Notizen als Markdown-Tabelle extrahieren — pseudonymisiert.",
|
||||
"mode": "view"
|
||||
},
|
||||
{
|
||||
"name": "extract-structured-data",
|
||||
"title": "Strukturierte Daten extrahieren",
|
||||
"description": "Strukturierte Daten (JSON oder Tabelle) aus Freitext gemäß einem Schema extrahieren — pseudonymisiert.",
|
||||
"mode": "view"
|
||||
},
|
||||
{
|
||||
"name": "pseudonymize-text",
|
||||
"title": "Text pseudonymisieren",
|
||||
"description": "Eingegebenen, markierten oder Zwischenablage-Text pseudonymisieren.",
|
||||
"mode": "view"
|
||||
},
|
||||
{
|
||||
"name": "pseudonymize-selected-text",
|
||||
"title": "Markierten Text pseudonymisieren",
|
||||
"description": "Aktuell markierten Text pseudonymisieren und das Ergebnis kopieren oder einfügen.",
|
||||
"mode": "no-view"
|
||||
},
|
||||
{
|
||||
"name": "pseudonymize-clipboard",
|
||||
"title": "Zwischenablage pseudonymisieren",
|
||||
"description": "Aktuellen Zwischenablage-Text pseudonymisieren und das Ergebnis kopieren oder einfügen.",
|
||||
"mode": "no-view"
|
||||
},
|
||||
{
|
||||
"name": "depseudonymize-text",
|
||||
"title": "Text wiederherstellen",
|
||||
"description": "Platzhalter mit einer gespeicherten Velum-Zuordnung wiederherstellen.",
|
||||
"mode": "view"
|
||||
},
|
||||
{
|
||||
"name": "depseudonymize-selected-text",
|
||||
"title": "Markierten Text wiederherstellen",
|
||||
"description": "Aktuell markierten Text mit der aktiven Sitzung wiederherstellen und das Ergebnis kopieren oder einfügen.",
|
||||
"mode": "no-view"
|
||||
},
|
||||
{
|
||||
"name": "depseudonymize-clipboard",
|
||||
"title": "Zwischenablage wiederherstellen",
|
||||
"description": "Aktuellen Zwischenablage-Text mit der aktiven Sitzung wiederherstellen und das Ergebnis kopieren oder einfügen.",
|
||||
"mode": "no-view"
|
||||
},
|
||||
{
|
||||
"name": "manage-sessions",
|
||||
"title": "Sitzungen verwalten",
|
||||
"description": "Velum-Sitzungen mit Zuordnungen anlegen, aktivieren, ansehen und löschen.",
|
||||
"mode": "view"
|
||||
}
|
||||
],
|
||||
"preferences": [
|
||||
{
|
||||
"name": "velumBaseUrl",
|
||||
"title": "Velum Basis-URL",
|
||||
"description": "Basis-URL der Velum-Installation, z. B. https://velum.example.com.",
|
||||
"type": "textfield",
|
||||
"required": true,
|
||||
"placeholder": "https://velum.example.com"
|
||||
},
|
||||
{
|
||||
"name": "authentikTokenUrl",
|
||||
"title": "Authentik Token-URL",
|
||||
"description": "OAuth2-Token-Endpunkt, üblicherweise https://auth.example.com/application/o/token/.",
|
||||
"type": "textfield",
|
||||
"required": true,
|
||||
"placeholder": "https://auth.example.com/application/o/token/"
|
||||
},
|
||||
{
|
||||
"name": "clientId",
|
||||
"title": "OAuth Client-ID",
|
||||
"description": "Client-ID des Authentik-Providers, der Tokens für Velum ausstellt.",
|
||||
"type": "textfield",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "serviceAccountUsername",
|
||||
"title": "Dienstkonto-Benutzername",
|
||||
"description": "Benutzername des Authentik-Dienstkontos, z. B. svc-velum-raycast.",
|
||||
"type": "textfield",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "serviceAccountPassword",
|
||||
"title": "Dienstkonto App-Passwort",
|
||||
"description": "App-Passwort/Token des Authentik-Dienstkontos.",
|
||||
"type": "password",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "scope",
|
||||
"title": "OAuth Scope",
|
||||
"description": "Scopes, die von Authentik angefordert werden.",
|
||||
"type": "textfield",
|
||||
"required": false,
|
||||
"default": "profile"
|
||||
},
|
||||
{
|
||||
"name": "sessionMode",
|
||||
"title": "Standard-Sitzungsmodus",
|
||||
"description": "Bestimmt, wie Schnellbefehle eine Zuordnungs-Sitzung wählen.",
|
||||
"type": "dropdown",
|
||||
"required": true,
|
||||
"default": "reuse-active",
|
||||
"data": [
|
||||
{
|
||||
"title": "Aktive Sitzung wiederverwenden",
|
||||
"value": "reuse-active"
|
||||
},
|
||||
{
|
||||
"title": "Neue Sitzung pro Anfrage",
|
||||
"value": "new-each-request"
|
||||
},
|
||||
{
|
||||
"title": "Tagessitzung",
|
||||
"value": "daily"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "quickOutput",
|
||||
"title": "Ausgabe der Schnellbefehle",
|
||||
"description": "Was die Befehle für markierten Text und Zwischenablage mit dem Ergebnis machen.",
|
||||
"type": "dropdown",
|
||||
"required": true,
|
||||
"default": "copy",
|
||||
"data": [
|
||||
{
|
||||
"title": "In die Zwischenablage kopieren",
|
||||
"value": "copy"
|
||||
},
|
||||
{
|
||||
"title": "Am Cursor einfügen",
|
||||
"value": "paste"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "userFullName",
|
||||
"title": "Eigener Name",
|
||||
"description": "Dein Name in der Signatur generierter Email-Antworten (z. B. „Raphael“). Wird im Antwort-Befehl vorbefüllt und kann pro Aufruf überschrieben werden.",
|
||||
"type": "textfield",
|
||||
"required": true,
|
||||
"placeholder": "Raphael"
|
||||
},
|
||||
{
|
||||
"name": "summaryModel",
|
||||
"title": "Standard-Modell für Zusammenfassungen",
|
||||
"description": "Raycast-KI-Modell für „Email-Konversation zusammenfassen“. Benötigt Raycast Pro.",
|
||||
"type": "dropdown",
|
||||
"required": true,
|
||||
"default": "anthropic-claude-sonnet-4-6",
|
||||
"data": [
|
||||
{
|
||||
"title": "Claude 4.6 Sonnet",
|
||||
"value": "anthropic-claude-sonnet-4-6"
|
||||
},
|
||||
{
|
||||
"title": "Claude 4.7 Opus",
|
||||
"value": "anthropic-claude-opus-4-7"
|
||||
},
|
||||
{
|
||||
"title": "Claude 4.5 Haiku",
|
||||
"value": "anthropic-claude-4-5-haiku"
|
||||
},
|
||||
{
|
||||
"title": "OpenAI GPT-5.3 Instant",
|
||||
"value": "openai-gpt-5.3-instant"
|
||||
},
|
||||
{
|
||||
"title": "OpenAI GPT-4.1",
|
||||
"value": "openai-gpt-4.1"
|
||||
},
|
||||
{
|
||||
"title": "OpenAI GPT-4o mini",
|
||||
"value": "openai-gpt-4o-mini"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "maxSessions",
|
||||
"title": "Maximale Anzahl gespeicherter Sitzungen",
|
||||
"description": "Älteste Sitzungen werden entfernt, wenn dieses Limit überschritten wird.",
|
||||
"type": "textfield",
|
||||
"required": true,
|
||||
"default": "20"
|
||||
},
|
||||
{
|
||||
"name": "closeAfterAction",
|
||||
"title": "Verhalten nach Abschluss",
|
||||
"label": "Raycast nach Kopieren/Einfügen schließen",
|
||||
"description": "Schließt das Raycast-Fenster und kehrt zum Root-Search zurück, sobald in einem AI-Workflow Kopieren oder Einfügen ausgelöst wurde.",
|
||||
"type": "checkbox",
|
||||
"required": false,
|
||||
"default": true
|
||||
}
|
||||
],
|
||||
"scripts": {
|
||||
"build": "ray build -e dist",
|
||||
"dev": "ray develop",
|
||||
"fix-lint": "ray lint --fix",
|
||||
"lint": "ray lint",
|
||||
"publish": "ray publish"
|
||||
},
|
||||
"dependencies": {
|
||||
"@raycast/api": "^1.104.17",
|
||||
"@raycast/utils": "^2.2.5",
|
||||
"marked": "^18.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@raycast/eslint-config": "^2.0.4",
|
||||
"@types/node": "^22.15.3",
|
||||
"@types/react": "^19.1.2",
|
||||
"eslint": "^9.26.0",
|
||||
"prettier": "^3.5.3",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
341
src/ai-views.tsx
Normal file
341
src/ai-views.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
import {
|
||||
Action,
|
||||
ActionPanel,
|
||||
AI,
|
||||
Clipboard,
|
||||
closeMainWindow,
|
||||
Detail,
|
||||
Form,
|
||||
Icon,
|
||||
PopToRootType,
|
||||
showHUD,
|
||||
showToast,
|
||||
Toast,
|
||||
useNavigation,
|
||||
} from "@raycast/api";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { marked } from "marked";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import type { Creativity } from "./ai";
|
||||
import { getPreferences } from "./preferences";
|
||||
import type { VelumSession } from "./types";
|
||||
import { markdownCodeBlock, mappingDetailTable, sessionSubtitle } from "./ui";
|
||||
import { localDepseudonymize } from "./velum";
|
||||
|
||||
export async function maybeCloseRaycast(): Promise<void> {
|
||||
if (getPreferences().closeAfterAction) {
|
||||
await closeMainWindow({ popToRootType: PopToRootType.Immediate });
|
||||
}
|
||||
}
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// Raycast 2.0 Beta's Clipboard.copy with { html, text } only writes plain text
|
||||
// to the system pasteboard. We bypass it via osascript / JXA, which sets both
|
||||
// the HTML and plain-text pasteboard types directly on NSPasteboard. Content
|
||||
// is passed via environment variables to avoid shell-quoting hazards.
|
||||
const RICH_COPY_SCRIPT = [
|
||||
"ObjC.import('AppKit');",
|
||||
"const env = $.NSProcessInfo.processInfo.environment;",
|
||||
"const html = env.objectForKey('VELUM_HTML');",
|
||||
"const text = env.objectForKey('VELUM_TEXT');",
|
||||
"const pb = $.NSPasteboard.generalPasteboard;",
|
||||
"pb.clearContents;",
|
||||
"pb.setStringForType(html, 'public.html');",
|
||||
"pb.setStringForType(text, 'public.utf8-plain-text');",
|
||||
].join("\n");
|
||||
|
||||
export async function copyRichText(html: string, text: string): Promise<void> {
|
||||
await execFileAsync(
|
||||
"osascript",
|
||||
["-l", "JavaScript", "-e", RICH_COPY_SCRIPT],
|
||||
{
|
||||
env: { ...process.env, VELUM_HTML: html, VELUM_TEXT: text },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export type AiCommandLabels = {
|
||||
pageHeading: string;
|
||||
runningLabel: string;
|
||||
doneLabel?: string;
|
||||
failureTitle: string;
|
||||
copyTitle: string;
|
||||
pasteTitle: string;
|
||||
};
|
||||
|
||||
export type PseudoStageProps = {
|
||||
session: VelumSession;
|
||||
pseudonymizedText: string;
|
||||
model: string;
|
||||
creativity: Creativity;
|
||||
instructions: string;
|
||||
buildPrompt: (pseudonymizedText: string, instructions: string) => string;
|
||||
labels: AiCommandLabels;
|
||||
extraResultActions?: (restored: string) => ReactNode;
|
||||
};
|
||||
|
||||
export function PseudonymizationConfirm(props: PseudoStageProps) {
|
||||
const [editedText, setEditedText] = useState(props.pseudonymizedText);
|
||||
const [editedInstructions, setEditedInstructions] = useState(
|
||||
props.instructions,
|
||||
);
|
||||
const { push } = useNavigation();
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!editedText.trim()) {
|
||||
await showToast({
|
||||
style: Toast.Style.Failure,
|
||||
title: "Pseudonymisierter Text ist leer",
|
||||
});
|
||||
return;
|
||||
}
|
||||
push(
|
||||
<AiStreamResult
|
||||
{...props}
|
||||
pseudonymizedText={editedText}
|
||||
instructions={editedInstructions}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
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, bevor er an die KI gesendet wird. Sitzung: ${props.session.name} · ${Object.keys(props.session.mapping).length} Zuordnungen.`}
|
||||
/>
|
||||
<Form.TextArea
|
||||
id="pseudonymizedText"
|
||||
title="Pseudonymisierter Text"
|
||||
value={editedText}
|
||||
onChange={setEditedText}
|
||||
/>
|
||||
<Form.TextArea
|
||||
id="instructions"
|
||||
title="Zusätzliche Anweisungen"
|
||||
value={editedInstructions}
|
||||
onChange={setEditedInstructions}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
type Phase = "running" | "restoring" | "done" | "error";
|
||||
|
||||
export function AiStreamResult(props: PseudoStageProps) {
|
||||
const {
|
||||
session,
|
||||
pseudonymizedText,
|
||||
model,
|
||||
creativity,
|
||||
instructions,
|
||||
buildPrompt,
|
||||
labels,
|
||||
extraResultActions,
|
||||
} = props;
|
||||
const [aiBuffer, setAiBuffer] = useState("");
|
||||
const [restored, setRestored] = useState<string | null>(null);
|
||||
const [replacementsMade, setReplacementsMade] = useState<number>(0);
|
||||
const [phase, setPhase] = useState<Phase>("running");
|
||||
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: labels.runningLabel,
|
||||
});
|
||||
|
||||
try {
|
||||
const prompt = buildPrompt(pseudonymizedText, instructions);
|
||||
const stream = AI.ask(prompt, {
|
||||
model: model as AI.Model,
|
||||
creativity,
|
||||
signal: controller.signal,
|
||||
});
|
||||
stream.on("data", (chunk) => {
|
||||
if (cancelled) return;
|
||||
setAiBuffer((prev) => prev + chunk);
|
||||
});
|
||||
const aiText = await stream;
|
||||
if (cancelled) return;
|
||||
|
||||
toast.title = "Stelle wieder her…";
|
||||
setPhase("restoring");
|
||||
|
||||
const restoreResult = localDepseudonymize(aiText, session.mapping);
|
||||
if (cancelled) return;
|
||||
|
||||
setRestored(restoreResult.original_text);
|
||||
setReplacementsMade(restoreResult.replacements_made);
|
||||
setPhase("done");
|
||||
|
||||
toast.style = Toast.Style.Success;
|
||||
toast.title = labels.doneLabel ?? "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 = labels.failureTitle;
|
||||
toast.message = message;
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [
|
||||
pseudonymizedText,
|
||||
model,
|
||||
creativity,
|
||||
instructions,
|
||||
session.mapping,
|
||||
buildPrompt,
|
||||
labels.runningLabel,
|
||||
labels.doneLabel,
|
||||
labels.failureTitle,
|
||||
]);
|
||||
|
||||
const phaseLabel =
|
||||
phase === "running"
|
||||
? labels.runningLabel
|
||||
: phase === "restoring"
|
||||
? "Platzhalter werden ersetzt …"
|
||||
: phase === "error"
|
||||
? "Fehler"
|
||||
: (labels.doneLabel ?? "Fertig");
|
||||
|
||||
const body = restored ?? aiBuffer;
|
||||
|
||||
const markdown = [
|
||||
`# ${labels.pageHeading} — ${session.name}`,
|
||||
sessionSubtitle(session),
|
||||
`*Status:* ${phaseLabel}`,
|
||||
"",
|
||||
body.trim() ? body : "_Noch keine Inhalte — warte auf das Modell …_",
|
||||
"",
|
||||
restored ? `*${replacementsMade} Platzhalter ersetzt.*` : "",
|
||||
"",
|
||||
"## Pseudonymisierte Eingabe",
|
||||
markdownCodeBlock(pseudonymizedText),
|
||||
"",
|
||||
"## Pseudonymisierte KI-Ausgabe",
|
||||
aiBuffer.trim()
|
||||
? markdownCodeBlock(aiBuffer)
|
||||
: "_Noch nichts vom Modell empfangen._",
|
||||
"",
|
||||
"## Zuordnung",
|
||||
mappingDetailTable(session.mapping),
|
||||
error ? `\n> Fehler: ${error}` : "",
|
||||
].join("\n");
|
||||
|
||||
const isLoading = phase === "running" || phase === "restoring";
|
||||
|
||||
return (
|
||||
<Detail
|
||||
isLoading={isLoading}
|
||||
markdown={markdown}
|
||||
actions={
|
||||
<ActionPanel>
|
||||
{restored ? (
|
||||
<>
|
||||
{extraResultActions ? extraResultActions(restored) : null}
|
||||
<Action
|
||||
title={labels.copyTitle}
|
||||
icon={Icon.Clipboard}
|
||||
onAction={async () => {
|
||||
try {
|
||||
await copyRichText(markdownToHtml(restored), restored);
|
||||
await showHUD("Als Rich Text kopiert");
|
||||
} catch {
|
||||
await Clipboard.copy(restored);
|
||||
await showHUD("Kopiert (Plain Text Fallback)");
|
||||
}
|
||||
await closeMainWindow({
|
||||
popToRootType: PopToRootType.Immediate,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Action
|
||||
title={`${labels.copyTitle} (Markdown)`}
|
||||
icon={Icon.CodeBlock}
|
||||
onAction={async () => {
|
||||
await Clipboard.copy(restored);
|
||||
await showHUD("Als Markdown kopiert");
|
||||
await closeMainWindow({
|
||||
popToRootType: PopToRootType.Immediate,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Action
|
||||
title={labels.pasteTitle}
|
||||
icon={Icon.TextCursor}
|
||||
onAction={async () => {
|
||||
await Clipboard.paste({
|
||||
html: markdownToHtml(restored),
|
||||
text: restored,
|
||||
});
|
||||
await closeMainWindow({
|
||||
popToRootType: PopToRootType.Immediate,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{aiBuffer ? (
|
||||
<Action.CopyToClipboard
|
||||
title="Pseudonymisierte KI-Ausgabe kopieren"
|
||||
content={aiBuffer}
|
||||
/>
|
||||
) : null}
|
||||
<Action.CopyToClipboard
|
||||
title="Pseudonymisierte Eingabe kopieren"
|
||||
content={pseudonymizedText}
|
||||
/>
|
||||
</ActionPanel>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function stripOuterFence(text: string): string {
|
||||
const trimmed = text.trim();
|
||||
const match = trimmed.match(/^```[a-zA-Z0-9-]*\s*\n([\s\S]*?)\n```\s*$/);
|
||||
return match ? match[1].trim() : trimmed;
|
||||
}
|
||||
|
||||
export function markdownToHtml(markdownText: string): string {
|
||||
return marked.parse(stripOuterFence(markdownText), {
|
||||
async: false,
|
||||
gfm: true,
|
||||
breaks: false,
|
||||
}) as string;
|
||||
}
|
||||
22
src/ai.ts
Normal file
22
src/ai.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export type Creativity = "none" | "low" | "medium" | "high";
|
||||
|
||||
export const CREATIVITY_OPTIONS: Creativity[] = [
|
||||
"none",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
];
|
||||
|
||||
export 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 const STRICT_PLACEHOLDER_RULE = [
|
||||
"STRENGE REGEL: Gib jeden Platzhalter zeichengetreu (inklusive spitzer Klammern, Großbuchstaben und Unterstrich + Nummer) zurück.",
|
||||
"Du darfst Platzhalter NIEMALS auflösen, raten, übersetzen oder mit erfundenen Namen ersetzen. Schreibe sie exakt so, wie sie in der Eingabe stehen.",
|
||||
].join("\n");
|
||||
133
src/auth.ts
Normal file
133
src/auth.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { LocalStorage } from "@raycast/api";
|
||||
import { getPreferences } from "./preferences";
|
||||
|
||||
const TOKEN_STORAGE_KEY = "velum.auth.token.v1";
|
||||
const TOKEN_EXPIRY_SKEW_MS = 30_000;
|
||||
|
||||
type StoredToken = {
|
||||
accessToken: string;
|
||||
tokenType: string;
|
||||
expiresAt: number;
|
||||
authKey: string;
|
||||
};
|
||||
|
||||
type TokenResponse = {
|
||||
access_token: string;
|
||||
token_type?: string;
|
||||
expires_in?: number;
|
||||
};
|
||||
|
||||
function currentAuthKey(): string {
|
||||
const preferences = getPreferences();
|
||||
return [
|
||||
preferences.authentikTokenUrl,
|
||||
preferences.clientId,
|
||||
preferences.serviceAccountUsername,
|
||||
].join("|");
|
||||
}
|
||||
|
||||
async function getStoredToken(): Promise<StoredToken | undefined> {
|
||||
const raw = await LocalStorage.getItem<string>(TOKEN_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = JSON.parse(raw) as StoredToken;
|
||||
if (token.authKey !== currentAuthKey()) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
!token.accessToken ||
|
||||
Date.now() + TOKEN_EXPIRY_SKEW_MS >= token.expiresAt
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return token;
|
||||
} catch {
|
||||
await clearAccessToken();
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearAccessToken(): Promise<void> {
|
||||
await LocalStorage.removeItem(TOKEN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export async function getAccessToken(): Promise<string> {
|
||||
const stored = await getStoredToken();
|
||||
if (stored) {
|
||||
return stored.accessToken;
|
||||
}
|
||||
|
||||
return refreshAccessToken();
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(): Promise<string> {
|
||||
const preferences = getPreferences();
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "client_credentials",
|
||||
client_id: preferences.clientId,
|
||||
username: preferences.serviceAccountUsername,
|
||||
password: preferences.serviceAccountPassword,
|
||||
scope: preferences.scope,
|
||||
});
|
||||
|
||||
const response = await fetch(preferences.authentikTokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const details = await response.text().catch(() => response.statusText);
|
||||
throw new Error(
|
||||
`Authentik token request failed: ${response.status} ${response.statusText}: ${details}`,
|
||||
);
|
||||
}
|
||||
|
||||
const token = (await response.json()) as TokenResponse;
|
||||
if (!token.access_token) {
|
||||
throw new Error("Authentik token response did not include access_token.");
|
||||
}
|
||||
|
||||
const stored: StoredToken = {
|
||||
accessToken: token.access_token,
|
||||
tokenType: token.token_type || "Bearer",
|
||||
expiresAt: Date.now() + Math.max(30, token.expires_in ?? 300) * 1000,
|
||||
authKey: currentAuthKey(),
|
||||
};
|
||||
|
||||
await LocalStorage.setItem(TOKEN_STORAGE_KEY, JSON.stringify(stored));
|
||||
return stored.accessToken;
|
||||
}
|
||||
|
||||
export async function fetchWithAuth(
|
||||
input: string,
|
||||
init: RequestInit = {},
|
||||
retry = true,
|
||||
): Promise<Response> {
|
||||
const token = await getAccessToken();
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
|
||||
const response = await fetch(input, {
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
|
||||
if ((response.status === 401 || response.status === 403) && retry) {
|
||||
await clearAccessToken();
|
||||
const freshToken = await refreshAccessToken();
|
||||
const retryHeaders = new Headers(init.headers);
|
||||
retryHeaders.set("Authorization", `Bearer ${freshToken}`);
|
||||
return fetch(input, {
|
||||
...init,
|
||||
headers: retryHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
297
src/briefing-from-notes.tsx
Normal file
297
src/briefing-from-notes.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import {
|
||||
Action,
|
||||
ActionPanel,
|
||||
Clipboard,
|
||||
Form,
|
||||
Icon,
|
||||
showToast,
|
||||
Toast,
|
||||
useNavigation,
|
||||
} from "@raycast/api";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
type Creativity,
|
||||
CREATIVITY_OPTIONS,
|
||||
MODEL_OPTIONS,
|
||||
STRICT_PLACEHOLDER_RULE,
|
||||
} from "./ai";
|
||||
import { PseudonymizationConfirm } from "./ai-views";
|
||||
import { getPreferences } from "./preferences";
|
||||
import { getSelectedTextSafely } from "./selection";
|
||||
import {
|
||||
createSession,
|
||||
getActiveSessionId,
|
||||
getSession,
|
||||
listSessions,
|
||||
setActiveSession,
|
||||
updateSessionMapping,
|
||||
} from "./sessions";
|
||||
import type { EntityType, VelumSession } from "./types";
|
||||
import { NEW_SESSION_ID } from "./ui";
|
||||
import {
|
||||
getEntityTypes,
|
||||
normalizePseudonymizeResponse,
|
||||
pseudonymize,
|
||||
} from "./velum";
|
||||
|
||||
function buildBriefingPrompt(
|
||||
pseudonymizedText: string,
|
||||
instructions: string,
|
||||
): string {
|
||||
const extra = instructions.trim()
|
||||
? `\n\nZusätzliche Anweisungen des Nutzers:\n${instructions.trim()}`
|
||||
: "";
|
||||
|
||||
return [
|
||||
"Du erhältst pseudonymisierten Text (Stichpunkte, Notizen, Transkript, Whiteboard-Mitschrift).",
|
||||
"Personenbezogene Daten wurden durch Platzhalter wie <PERSON_1>, <ORG_2>, <DATE_3> ersetzt.",
|
||||
"",
|
||||
STRICT_PLACEHOLDER_RULE,
|
||||
"",
|
||||
"Aufgabe: Erstelle ein strukturiertes deutschsprachiges Briefing in Markdown mit folgenden Abschnitten:",
|
||||
"- **Kontext**: Worum geht es? Kurzer Absatz.",
|
||||
"- **Teilnehmer**: Beteiligte Platzhalter mit Rolle (falls aus dem Text ableitbar).",
|
||||
"- **Entscheidungen**: Welche Entscheidungen wurden getroffen? Stichpunktliste, jeweils 1 Zeile.",
|
||||
"- **Action Items**: Was ist zu tun? Stichpunktliste mit Verantwortlichem (Platzhalter) und Deadline (falls genannt).",
|
||||
"- **Offene Punkte**: Was bleibt zu klären? Stichpunktliste.",
|
||||
"",
|
||||
'Wenn ein Abschnitt nicht im Text vorkommt, schreibe „—" als einzigen Inhalt darunter.',
|
||||
"Antworte ausschließlich in Markdown, ohne einleitende Floskeln und ohne Code-Fences (keine ``` Zeilen am Anfang oder Ende).",
|
||||
extra,
|
||||
"",
|
||||
"--- Notizen (pseudonymisiert) ---",
|
||||
pseudonymizedText,
|
||||
"--- Ende ---",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
type FormValues = {
|
||||
text: string;
|
||||
sessionId: string;
|
||||
entityTypes: string[];
|
||||
model: string;
|
||||
creativity: Creativity;
|
||||
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 [isLoading, setIsLoading] = useState(true);
|
||||
const { push } = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const [loadedSessions, activeId] = await Promise.all([
|
||||
listSessions(),
|
||||
getActiveSessionId(),
|
||||
]);
|
||||
setSessions(loadedSessions);
|
||||
setActiveSessionId(activeId);
|
||||
|
||||
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();
|
||||
}, []);
|
||||
|
||||
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: "Notizen eingeben",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
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(
|
||||
<PseudonymizationConfirm
|
||||
session={updatedSession}
|
||||
pseudonymizedText={pseudoResult.pseudonymized_text}
|
||||
model={values.model}
|
||||
creativity={values.creativity}
|
||||
instructions={values.instructions}
|
||||
buildPrompt={buildBriefingPrompt}
|
||||
labels={{
|
||||
pageHeading: "Briefing",
|
||||
runningLabel: "KI erstellt Briefing …",
|
||||
failureTitle: "Briefing fehlgeschlagen",
|
||||
copyTitle: "Briefing kopieren",
|
||||
pasteTitle: "Briefing einfügen",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
} 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="Briefing erstellen"
|
||||
icon={Icon.Document}
|
||||
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="Notizen"
|
||||
value={text}
|
||||
onChange={setText}
|
||||
placeholder="Meeting-Notizen, Stichpunkte, Transkript …"
|
||||
/>
|
||||
<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"
|
||||
defaultValue={preferences.summaryModel}
|
||||
>
|
||||
{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" defaultValue="low">
|
||||
{CREATIVITY_OPTIONS.map((value) => (
|
||||
<Form.Dropdown.Item key={value} value={value} title={value} />
|
||||
))}
|
||||
</Form.Dropdown>
|
||||
<Form.TextArea
|
||||
id="instructions"
|
||||
title="Zusätzliche Anweisungen"
|
||||
placeholder="Optional: Fokus auf bestimmte Themen, Ton, Detailtiefe, …"
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
7
src/depseudonymize-clipboard.ts
Normal file
7
src/depseudonymize-clipboard.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Clipboard } from "@raycast/api";
|
||||
import { depseudonymizeQuickText } from "./quick";
|
||||
|
||||
export default async function Command() {
|
||||
const clipboardText = await Clipboard.readText();
|
||||
await depseudonymizeQuickText(clipboardText ?? "", "Zwischenablage");
|
||||
}
|
||||
15
src/depseudonymize-selected-text.ts
Normal file
15
src/depseudonymize-selected-text.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { showToast, Toast } from "@raycast/api";
|
||||
import { depseudonymizeQuickText } from "./quick";
|
||||
import { getSelectedTextSafely } from "./selection";
|
||||
|
||||
export default async function Command() {
|
||||
const selectedText = await getSelectedTextSafely();
|
||||
if (!selectedText) {
|
||||
await showToast({
|
||||
style: Toast.Style.Failure,
|
||||
title: "Kein markierter Text gefunden",
|
||||
});
|
||||
return;
|
||||
}
|
||||
await depseudonymizeQuickText(selectedText, "Selektion");
|
||||
}
|
||||
176
src/depseudonymize-text.tsx
Normal file
176
src/depseudonymize-text.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
Action,
|
||||
ActionPanel,
|
||||
Clipboard,
|
||||
Detail,
|
||||
Form,
|
||||
Icon,
|
||||
showToast,
|
||||
Toast,
|
||||
useNavigation,
|
||||
} from "@raycast/api";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { depseudonymize } from "./velum";
|
||||
import { getActiveSessionId, getSession, listSessions } from "./sessions";
|
||||
import type { DepseudonymizeResponse, VelumSession } from "./types";
|
||||
import { markdownCodeBlock, sessionSubtitle } from "./ui";
|
||||
|
||||
type FormValues = {
|
||||
text: string;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
export default function Command() {
|
||||
const [text, setText] = useState("");
|
||||
const [sessions, setSessions] = useState<VelumSession[]>([]);
|
||||
const [activeSessionId, setActiveSessionId] = useState<string>();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { push } = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const [loadedSessions, activeId] = await Promise.all([
|
||||
listSessions(),
|
||||
getActiveSessionId(),
|
||||
]);
|
||||
setSessions(loadedSessions);
|
||||
setActiveSessionId(activeId);
|
||||
setIsLoading(false);
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const defaultSessionId = useMemo(
|
||||
() => activeSessionId || sessions[0]?.id,
|
||||
[activeSessionId, sessions],
|
||||
);
|
||||
|
||||
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 || !values.sessionId) {
|
||||
await showToast({
|
||||
style: Toast.Style.Failure,
|
||||
title: "Sitzung wählen und Text eingeben",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await getSession(values.sessionId);
|
||||
if (!session) {
|
||||
await showToast({
|
||||
style: Toast.Style.Failure,
|
||||
title: "Sitzung nicht gefunden",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const toast = await showToast({
|
||||
style: Toast.Style.Animated,
|
||||
title: "Stelle wieder her…",
|
||||
});
|
||||
try {
|
||||
const result = await depseudonymize(input, session.mapping);
|
||||
toast.style = Toast.Style.Success;
|
||||
toast.title = "Wiederhergestellt";
|
||||
toast.message = `${result.replacements_made} Ersetzungen`;
|
||||
push(<DepseudonymizeResult result={result} session={session} />);
|
||||
} catch (error) {
|
||||
toast.style = Toast.Style.Failure;
|
||||
toast.title = "Wiederherstellung fehlgeschlagen";
|
||||
toast.message = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form
|
||||
isLoading={isLoading}
|
||||
actions={
|
||||
<ActionPanel>
|
||||
<Action.SubmitForm
|
||||
title="Wiederherstellen"
|
||||
icon={Icon.Eye}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
<Action
|
||||
title="Zwischenablage übernehmen"
|
||||
icon={Icon.Clipboard}
|
||||
onAction={loadClipboardText}
|
||||
/>
|
||||
</ActionPanel>
|
||||
}
|
||||
>
|
||||
<Form.TextArea
|
||||
id="text"
|
||||
title="Text"
|
||||
value={text}
|
||||
onChange={setText}
|
||||
placeholder="Text mit Velum-Platzhaltern"
|
||||
/>
|
||||
<Form.Dropdown
|
||||
id="sessionId"
|
||||
title="Sitzung"
|
||||
defaultValue={defaultSessionId}
|
||||
>
|
||||
{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>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function DepseudonymizeResult({
|
||||
result,
|
||||
session,
|
||||
}: {
|
||||
result: DepseudonymizeResponse;
|
||||
session: VelumSession;
|
||||
}) {
|
||||
const markdown = [
|
||||
`# ${session.name}`,
|
||||
sessionSubtitle(session),
|
||||
"",
|
||||
"## Wiederhergestellter Text",
|
||||
markdownCodeBlock(result.original_text),
|
||||
"",
|
||||
`Ersetzungen: ${result.replacements_made}`,
|
||||
].join("\n");
|
||||
|
||||
return (
|
||||
<Detail
|
||||
markdown={markdown}
|
||||
actions={
|
||||
<ActionPanel>
|
||||
<Action.CopyToClipboard
|
||||
title="Wiederhergestellten Text kopieren"
|
||||
content={result.original_text}
|
||||
/>
|
||||
<Action
|
||||
title="Wiederhergestellten Text einfügen"
|
||||
icon={Icon.TextCursor}
|
||||
onAction={() => Clipboard.paste(result.original_text)}
|
||||
/>
|
||||
</ActionPanel>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
302
src/extract-action-items.tsx
Normal file
302
src/extract-action-items.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import {
|
||||
Action,
|
||||
ActionPanel,
|
||||
Clipboard,
|
||||
Form,
|
||||
Icon,
|
||||
showToast,
|
||||
Toast,
|
||||
useNavigation,
|
||||
} from "@raycast/api";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
type Creativity,
|
||||
CREATIVITY_OPTIONS,
|
||||
MODEL_OPTIONS,
|
||||
STRICT_PLACEHOLDER_RULE,
|
||||
} from "./ai";
|
||||
import { PseudonymizationConfirm } from "./ai-views";
|
||||
import { getPreferences } from "./preferences";
|
||||
import { getSelectedTextSafely } from "./selection";
|
||||
import {
|
||||
createSession,
|
||||
getActiveSessionId,
|
||||
getSession,
|
||||
listSessions,
|
||||
setActiveSession,
|
||||
updateSessionMapping,
|
||||
} from "./sessions";
|
||||
import type { EntityType, VelumSession } from "./types";
|
||||
import { NEW_SESSION_ID } from "./ui";
|
||||
import {
|
||||
getEntityTypes,
|
||||
normalizePseudonymizeResponse,
|
||||
pseudonymize,
|
||||
} from "./velum";
|
||||
|
||||
function buildActionItemsPrompt(
|
||||
pseudonymizedText: string,
|
||||
instructions: string,
|
||||
): string {
|
||||
const extra = instructions.trim()
|
||||
? `\n\nZusätzliche Anweisungen des Nutzers:\n${instructions.trim()}`
|
||||
: "";
|
||||
|
||||
return [
|
||||
"Du erhältst pseudonymisierten Text (Meeting-Notizen, Email-Thread, Transkript).",
|
||||
"Personenbezogene Daten wurden durch Platzhalter wie <PERSON_1>, <ORG_2>, <DATE_3> ersetzt.",
|
||||
"",
|
||||
STRICT_PLACEHOLDER_RULE,
|
||||
"",
|
||||
"Aufgabe: Extrahiere alle Action Items aus dem Text als deutschsprachige Markdown-Tabelle.",
|
||||
"",
|
||||
"Format:",
|
||||
"| Aufgabe | Verantwortlich | Deadline | Status |",
|
||||
"| --- | --- | --- | --- |",
|
||||
"",
|
||||
"Regeln:",
|
||||
"- Liste nur konkret formulierte Aufgaben/Zusagen, keine vagen Absichten.",
|
||||
'- Verantwortliche werden als Platzhalter referenziert (z. B. <PERSON_1>); wenn unklar, schreibe „—".',
|
||||
'- Deadline: Datum oder Zeitraum aus dem Text. Wenn nichts genannt, „—".',
|
||||
'- Status: „Offen", „In Arbeit" oder „Erledigt" — wenn nicht erkennbar, „Offen".',
|
||||
'- Wenn der Text keine Action Items enthält, antworte mit Kopfzeile, Trennzeile und einer Zeile „| Keine Action Items | — | — | — |".',
|
||||
"",
|
||||
"Antworte ausschließlich mit der Tabelle, ohne einleitende Floskeln und ohne Code-Fences (keine ``` Zeilen).",
|
||||
extra,
|
||||
"",
|
||||
"--- Text (pseudonymisiert) ---",
|
||||
pseudonymizedText,
|
||||
"--- Ende ---",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
type FormValues = {
|
||||
text: string;
|
||||
sessionId: string;
|
||||
entityTypes: string[];
|
||||
model: string;
|
||||
creativity: Creativity;
|
||||
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 [isLoading, setIsLoading] = useState(true);
|
||||
const { push } = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const [loadedSessions, activeId] = await Promise.all([
|
||||
listSessions(),
|
||||
getActiveSessionId(),
|
||||
]);
|
||||
setSessions(loadedSessions);
|
||||
setActiveSessionId(activeId);
|
||||
|
||||
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();
|
||||
}, []);
|
||||
|
||||
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: "Text zum Extrahieren eingeben",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
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(
|
||||
<PseudonymizationConfirm
|
||||
session={updatedSession}
|
||||
pseudonymizedText={pseudoResult.pseudonymized_text}
|
||||
model={values.model}
|
||||
creativity={values.creativity}
|
||||
instructions={values.instructions}
|
||||
buildPrompt={buildActionItemsPrompt}
|
||||
labels={{
|
||||
pageHeading: "Action Items",
|
||||
runningLabel: "KI extrahiert Action Items …",
|
||||
failureTitle: "Extraktion fehlgeschlagen",
|
||||
copyTitle: "Action Items kopieren",
|
||||
pasteTitle: "Action Items einfügen",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
} 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="Action Items extrahieren"
|
||||
icon={Icon.List}
|
||||
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="Text"
|
||||
value={text}
|
||||
onChange={setText}
|
||||
placeholder="Meeting-Notizen, Email-Thread, Transkript …"
|
||||
/>
|
||||
<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"
|
||||
defaultValue={preferences.summaryModel}
|
||||
>
|
||||
{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" defaultValue="low">
|
||||
{CREATIVITY_OPTIONS.map((value) => (
|
||||
<Form.Dropdown.Item key={value} value={value} title={value} />
|
||||
))}
|
||||
</Form.Dropdown>
|
||||
<Form.TextArea
|
||||
id="instructions"
|
||||
title="Zusätzliche Anweisungen"
|
||||
placeholder="Optional: Fokus auf bestimmte Personen, Zeitraum, …"
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
362
src/extract-structured-data.tsx
Normal file
362
src/extract-structured-data.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
import {
|
||||
Action,
|
||||
ActionPanel,
|
||||
Clipboard,
|
||||
Form,
|
||||
Icon,
|
||||
showToast,
|
||||
Toast,
|
||||
useNavigation,
|
||||
} from "@raycast/api";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
type Creativity,
|
||||
CREATIVITY_OPTIONS,
|
||||
MODEL_OPTIONS,
|
||||
STRICT_PLACEHOLDER_RULE,
|
||||
} from "./ai";
|
||||
import { PseudonymizationConfirm } from "./ai-views";
|
||||
import { getPreferences } from "./preferences";
|
||||
import { getSelectedTextSafely } from "./selection";
|
||||
import {
|
||||
createSession,
|
||||
getActiveSessionId,
|
||||
getSession,
|
||||
listSessions,
|
||||
setActiveSession,
|
||||
updateSessionMapping,
|
||||
} from "./sessions";
|
||||
import type { EntityType, VelumSession } from "./types";
|
||||
import { NEW_SESSION_ID } from "./ui";
|
||||
import {
|
||||
getEntityTypes,
|
||||
normalizePseudonymizeResponse,
|
||||
pseudonymize,
|
||||
} from "./velum";
|
||||
|
||||
type OutputFormat = "json" | "markdown-table";
|
||||
|
||||
const FORMAT_OPTIONS: Array<{ value: OutputFormat; title: string }> = [
|
||||
{ value: "json", title: "JSON" },
|
||||
{ value: "markdown-table", title: "Markdown-Tabelle" },
|
||||
];
|
||||
|
||||
function buildStructuredDataPrompt(args: {
|
||||
pseudonymizedText: string;
|
||||
instructions: string;
|
||||
schema: string;
|
||||
format: OutputFormat;
|
||||
}): string {
|
||||
const extra = args.instructions.trim()
|
||||
? `\n\nZusätzliche Anweisungen des Nutzers:\n${args.instructions.trim()}`
|
||||
: "";
|
||||
|
||||
const formatInstructions =
|
||||
args.format === "json"
|
||||
? [
|
||||
"Format: gib genau einen Code-Block zurück, der valides JSON enthält.",
|
||||
"Verwende für nicht gefundene Felder den Wert null. Bei Listen: leeres Array `[]`.",
|
||||
"Antworte mit dem Code-Block und nichts anderem.",
|
||||
].join("\n")
|
||||
: [
|
||||
"Format: gib genau eine Markdown-Tabelle zurück. Eine Zeile pro Datensatz.",
|
||||
'Verwende für nicht gefundene Felder „—".',
|
||||
"Antworte mit der Tabelle und nichts anderem.",
|
||||
].join("\n");
|
||||
|
||||
return [
|
||||
"Du erhältst pseudonymisierten Text und ein Schema. Personenbezogene Daten wurden durch Platzhalter wie <PERSON_1>, <ORG_2>, <EMAIL_3> ersetzt.",
|
||||
"",
|
||||
STRICT_PLACEHOLDER_RULE,
|
||||
"",
|
||||
"Aufgabe: Extrahiere die im Schema beschriebenen Daten aus dem Text.",
|
||||
"",
|
||||
"Regeln:",
|
||||
"- Verwende ausschließlich Informationen, die im Text vorkommen. Erfinde nichts.",
|
||||
"- Personen, Organisationen etc. erscheinen als Platzhalter (z. B. <PERSON_1>) — übernimm sie zeichengetreu.",
|
||||
"- Keine einleitenden Floskeln, keine Erklärungen — nur das angeforderte Ausgabeformat.",
|
||||
"",
|
||||
formatInstructions,
|
||||
extra,
|
||||
"",
|
||||
"--- Schema ---",
|
||||
args.schema.trim(),
|
||||
"--- Ende Schema ---",
|
||||
"",
|
||||
"--- Text (pseudonymisiert) ---",
|
||||
args.pseudonymizedText,
|
||||
"--- Ende Text ---",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
type FormValues = {
|
||||
text: string;
|
||||
schema: string;
|
||||
format: OutputFormat;
|
||||
sessionId: string;
|
||||
entityTypes: string[];
|
||||
model: string;
|
||||
creativity: Creativity;
|
||||
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 [isLoading, setIsLoading] = useState(true);
|
||||
const { push } = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const [loadedSessions, activeId] = await Promise.all([
|
||||
listSessions(),
|
||||
getActiveSessionId(),
|
||||
]);
|
||||
setSessions(loadedSessions);
|
||||
setActiveSessionId(activeId);
|
||||
|
||||
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();
|
||||
}, []);
|
||||
|
||||
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: "Text zum Extrahieren eingeben",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!values.schema.trim()) {
|
||||
await showToast({
|
||||
style: Toast.Style.Failure,
|
||||
title: "Schema beschreiben",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
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`;
|
||||
|
||||
const schema = values.schema;
|
||||
const format = values.format;
|
||||
const buildPrompt = (pseudonymizedText: string, instructions: string) =>
|
||||
buildStructuredDataPrompt({
|
||||
pseudonymizedText,
|
||||
instructions,
|
||||
schema,
|
||||
format,
|
||||
});
|
||||
|
||||
push(
|
||||
<PseudonymizationConfirm
|
||||
session={updatedSession}
|
||||
pseudonymizedText={pseudoResult.pseudonymized_text}
|
||||
model={values.model}
|
||||
creativity={values.creativity}
|
||||
instructions={values.instructions}
|
||||
buildPrompt={buildPrompt}
|
||||
labels={{
|
||||
pageHeading:
|
||||
format === "json" ? "Strukturierte Daten (JSON)" : "Tabelle",
|
||||
runningLabel:
|
||||
format === "json"
|
||||
? "KI extrahiert Daten (JSON) …"
|
||||
: "KI extrahiert Tabelle …",
|
||||
failureTitle: "Extraktion fehlgeschlagen",
|
||||
copyTitle:
|
||||
format === "json" ? "JSON kopieren" : "Markdown-Tabelle kopieren",
|
||||
pasteTitle:
|
||||
format === "json" ? "JSON einfügen" : "Tabelle einfügen",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
} 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="Daten extrahieren"
|
||||
icon={Icon.AppWindowGrid3x3}
|
||||
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="Text"
|
||||
value={text}
|
||||
onChange={setText}
|
||||
placeholder="Freitext mit den zu extrahierenden Informationen"
|
||||
/>
|
||||
<Form.TextArea
|
||||
id="schema"
|
||||
title="Schema"
|
||||
placeholder={
|
||||
'z. B. „Liste aller Teilnehmer mit Rolle und E-Mail" oder „Felder: name, rolle, email, telefon"'
|
||||
}
|
||||
/>
|
||||
<Form.Dropdown id="format" title="Ausgabeformat" defaultValue="json">
|
||||
{FORMAT_OPTIONS.map((option) => (
|
||||
<Form.Dropdown.Item
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
title={option.title}
|
||||
/>
|
||||
))}
|
||||
</Form.Dropdown>
|
||||
<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"
|
||||
defaultValue={preferences.summaryModel}
|
||||
>
|
||||
{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" defaultValue="none">
|
||||
{CREATIVITY_OPTIONS.map((value) => (
|
||||
<Form.Dropdown.Item key={value} value={value} title={value} />
|
||||
))}
|
||||
</Form.Dropdown>
|
||||
<Form.TextArea
|
||||
id="instructions"
|
||||
title="Zusätzliche Anweisungen"
|
||||
placeholder="Optional: Sortierung, Filterung, Sonderfälle, …"
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
260
src/manage-sessions.tsx
Normal file
260
src/manage-sessions.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import {
|
||||
Action,
|
||||
ActionPanel,
|
||||
Alert,
|
||||
confirmAlert,
|
||||
Detail,
|
||||
Form,
|
||||
Icon,
|
||||
List,
|
||||
showToast,
|
||||
Toast,
|
||||
useNavigation,
|
||||
} from "@raycast/api";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
clearSessionMapping,
|
||||
countMappingEntries,
|
||||
createSession,
|
||||
deleteAllSessions,
|
||||
deleteSession,
|
||||
getActiveSessionId,
|
||||
listSessions,
|
||||
renameSession,
|
||||
setActiveSession,
|
||||
} from "./sessions";
|
||||
import type { VelumSession } from "./types";
|
||||
import { mappingSummary, sessionSubtitle } from "./ui";
|
||||
|
||||
export default function Command() {
|
||||
const [sessions, setSessions] = useState<VelumSession[]>([]);
|
||||
const [activeSessionId, setActiveSessionId] = useState<string>();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
async function reload() {
|
||||
const [loadedSessions, activeId] = await Promise.all([
|
||||
listSessions(),
|
||||
getActiveSessionId(),
|
||||
]);
|
||||
setSessions(loadedSessions);
|
||||
setActiveSessionId(activeId);
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, []);
|
||||
|
||||
async function activate(session: VelumSession) {
|
||||
await setActiveSession(session.id);
|
||||
await reload();
|
||||
await showToast({
|
||||
style: Toast.Style.Success,
|
||||
title: "Aktive Sitzung geändert",
|
||||
message: session.name,
|
||||
});
|
||||
}
|
||||
|
||||
async function clearMapping(session: VelumSession) {
|
||||
const confirmed = await confirmAlert({
|
||||
title: "Zuordnung leeren?",
|
||||
message: `${session.name} behält den Namen, verliert aber alle Platzhalter-Zuordnungen.`,
|
||||
primaryAction: {
|
||||
title: "Zuordnung leeren",
|
||||
style: Alert.ActionStyle.Destructive,
|
||||
},
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await clearSessionMapping(session.id);
|
||||
await reload();
|
||||
await showToast({
|
||||
style: Toast.Style.Success,
|
||||
title: "Zuordnung geleert",
|
||||
message: session.name,
|
||||
});
|
||||
}
|
||||
|
||||
async function remove(session: VelumSession) {
|
||||
const confirmed = await confirmAlert({
|
||||
title: "Sitzung löschen?",
|
||||
message: `${session.name} und ihre Zuordnung werden gelöscht.`,
|
||||
primaryAction: {
|
||||
title: "Sitzung löschen",
|
||||
style: Alert.ActionStyle.Destructive,
|
||||
},
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteSession(session.id);
|
||||
await reload();
|
||||
await showToast({
|
||||
style: Toast.Style.Success,
|
||||
title: "Sitzung gelöscht",
|
||||
message: session.name,
|
||||
});
|
||||
}
|
||||
|
||||
async function removeAll() {
|
||||
const confirmed = await confirmAlert({
|
||||
title: "Alle Sitzungen löschen?",
|
||||
message:
|
||||
"Alle gespeicherten Velum-Zuordnungen dieser Raycast-Extension werden gelöscht.",
|
||||
primaryAction: {
|
||||
title: "Alle löschen",
|
||||
style: Alert.ActionStyle.Destructive,
|
||||
},
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteAllSessions();
|
||||
await reload();
|
||||
await showToast({
|
||||
style: Toast.Style.Success,
|
||||
title: "Alle Sitzungen gelöscht",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<List isLoading={isLoading} searchBarPlaceholder="Velum-Sitzungen suchen">
|
||||
<List.EmptyView
|
||||
title="Keine Sitzungen"
|
||||
description="Lege eine Sitzung an oder pseudonymisiere Text, um Zuordnungen zu speichern."
|
||||
actions={
|
||||
<ActionPanel>
|
||||
<Action.Push
|
||||
title="Neue Sitzung"
|
||||
icon={Icon.Plus}
|
||||
target={<SessionNameForm onSaved={reload} />}
|
||||
/>
|
||||
</ActionPanel>
|
||||
}
|
||||
/>
|
||||
{sessions.map((session) => (
|
||||
<List.Item
|
||||
key={session.id}
|
||||
title={session.name}
|
||||
subtitle={sessionSubtitle(session)}
|
||||
icon={session.id === activeSessionId ? Icon.CheckCircle : Icon.Circle}
|
||||
accessories={[{ text: `${countMappingEntries(session.mapping)}` }]}
|
||||
actions={
|
||||
<ActionPanel>
|
||||
<Action
|
||||
title="Als Aktive Sitzung Setzen"
|
||||
icon={Icon.CheckCircle}
|
||||
onAction={() => activate(session)}
|
||||
/>
|
||||
<Action.Push
|
||||
title="Details anzeigen"
|
||||
icon={Icon.List}
|
||||
target={<SessionDetail session={session} />}
|
||||
/>
|
||||
<Action.Push
|
||||
title="Sitzung umbenennen"
|
||||
icon={Icon.Pencil}
|
||||
target={<SessionNameForm session={session} onSaved={reload} />}
|
||||
/>
|
||||
<Action.Push
|
||||
title="Neue Sitzung"
|
||||
icon={Icon.Plus}
|
||||
target={<SessionNameForm onSaved={reload} />}
|
||||
/>
|
||||
<Action
|
||||
title="Zuordnung leeren"
|
||||
icon={Icon.XMarkCircle}
|
||||
style={Action.Style.Destructive}
|
||||
onAction={() => clearMapping(session)}
|
||||
/>
|
||||
<Action
|
||||
title="Sitzung löschen"
|
||||
icon={Icon.Trash}
|
||||
style={Action.Style.Destructive}
|
||||
onAction={() => remove(session)}
|
||||
/>
|
||||
<Action
|
||||
title="Alle Sitzungen löschen"
|
||||
icon={Icon.Trash}
|
||||
style={Action.Style.Destructive}
|
||||
onAction={removeAll}
|
||||
/>
|
||||
</ActionPanel>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionNameForm({
|
||||
session,
|
||||
onSaved,
|
||||
}: {
|
||||
session?: VelumSession;
|
||||
onSaved: () => Promise<void>;
|
||||
}) {
|
||||
const { pop } = useNavigation();
|
||||
|
||||
async function handleSubmit(values: { name: string }) {
|
||||
if (session) {
|
||||
await renameSession(session.id, values.name);
|
||||
await showToast({
|
||||
style: Toast.Style.Success,
|
||||
title: "Sitzung umbenannt",
|
||||
});
|
||||
} else {
|
||||
await createSession(values.name.trim() || undefined);
|
||||
await showToast({
|
||||
style: Toast.Style.Success,
|
||||
title: "Sitzung erstellt",
|
||||
});
|
||||
}
|
||||
|
||||
await onSaved();
|
||||
pop();
|
||||
}
|
||||
|
||||
return (
|
||||
<Form
|
||||
actions={
|
||||
<ActionPanel>
|
||||
<Action.SubmitForm
|
||||
title={session ? "Sitzung Umbenennen" : "Sitzung Erstellen"}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</ActionPanel>
|
||||
}
|
||||
>
|
||||
<Form.TextField
|
||||
id="name"
|
||||
title="Name"
|
||||
defaultValue={session?.name}
|
||||
placeholder="Sitzungsname"
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionDetail({ session }: { session: VelumSession }) {
|
||||
const markdown = [
|
||||
`# ${session.name}`,
|
||||
sessionSubtitle(session),
|
||||
"",
|
||||
`Erstellt: ${new Date(session.createdAt).toLocaleString()}`,
|
||||
"",
|
||||
"## Zuordnung",
|
||||
mappingSummary(session.mapping),
|
||||
"",
|
||||
"## Platzhalter",
|
||||
...Object.entries(session.mapping)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([placeholder, entry]) => `- \`${placeholder}\` · ${entry.type}`),
|
||||
].join("\n");
|
||||
|
||||
return <Detail markdown={markdown} />;
|
||||
}
|
||||
46
src/preferences.ts
Normal file
46
src/preferences.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { getPreferenceValues } from "@raycast/api";
|
||||
import type { ExtensionPreferences, QuickOutput, SessionMode } from "./types";
|
||||
|
||||
export type NormalizedPreferences = {
|
||||
velumBaseUrl: string;
|
||||
authentikTokenUrl: string;
|
||||
clientId: string;
|
||||
serviceAccountUsername: string;
|
||||
serviceAccountPassword: string;
|
||||
scope: string;
|
||||
sessionMode: SessionMode;
|
||||
quickOutput: QuickOutput;
|
||||
summaryModel: string;
|
||||
userFullName: string;
|
||||
maxSessions: number;
|
||||
closeAfterAction: boolean;
|
||||
};
|
||||
|
||||
function trimTrailingSlash(value: string): string {
|
||||
return value.trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string, fallback: number): number {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
export function getPreferences(): NormalizedPreferences {
|
||||
const preferences = getPreferenceValues<ExtensionPreferences>();
|
||||
|
||||
return {
|
||||
velumBaseUrl: trimTrailingSlash(preferences.velumBaseUrl),
|
||||
authentikTokenUrl: preferences.authentikTokenUrl.trim(),
|
||||
clientId: preferences.clientId.trim(),
|
||||
serviceAccountUsername: preferences.serviceAccountUsername.trim(),
|
||||
serviceAccountPassword: preferences.serviceAccountPassword,
|
||||
scope: preferences.scope?.trim() || "profile",
|
||||
sessionMode: preferences.sessionMode,
|
||||
quickOutput: preferences.quickOutput,
|
||||
summaryModel:
|
||||
preferences.summaryModel?.trim() || "anthropic-claude-sonnet-4-6",
|
||||
userFullName: preferences.userFullName?.trim() ?? "",
|
||||
maxSessions: parsePositiveInteger(preferences.maxSessions, 20),
|
||||
closeAfterAction: preferences.closeAfterAction ?? true,
|
||||
};
|
||||
}
|
||||
7
src/pseudonymize-clipboard.ts
Normal file
7
src/pseudonymize-clipboard.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Clipboard } from "@raycast/api";
|
||||
import { pseudonymizeQuickText } from "./quick";
|
||||
|
||||
export default async function Command() {
|
||||
const clipboardText = await Clipboard.readText();
|
||||
await pseudonymizeQuickText(clipboardText ?? "", "Zwischenablage");
|
||||
}
|
||||
15
src/pseudonymize-selected-text.ts
Normal file
15
src/pseudonymize-selected-text.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { showToast, Toast } from "@raycast/api";
|
||||
import { pseudonymizeQuickText } from "./quick";
|
||||
import { getSelectedTextSafely } from "./selection";
|
||||
|
||||
export default async function Command() {
|
||||
const selectedText = await getSelectedTextSafely();
|
||||
if (!selectedText) {
|
||||
await showToast({
|
||||
style: Toast.Style.Failure,
|
||||
title: "Kein markierter Text gefunden",
|
||||
});
|
||||
return;
|
||||
}
|
||||
await pseudonymizeQuickText(selectedText, "Selektion");
|
||||
}
|
||||
248
src/pseudonymize-text.tsx
Normal file
248
src/pseudonymize-text.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import {
|
||||
Action,
|
||||
ActionPanel,
|
||||
Clipboard,
|
||||
Detail,
|
||||
Form,
|
||||
Icon,
|
||||
showToast,
|
||||
Toast,
|
||||
useNavigation,
|
||||
} from "@raycast/api";
|
||||
import { getSelectedTextSafely } from "./selection";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
createSession,
|
||||
getActiveSessionId,
|
||||
getSession,
|
||||
listSessions,
|
||||
setActiveSession,
|
||||
updateSessionMapping,
|
||||
} from "./sessions";
|
||||
import type { EntityType, PseudonymizeResponse, VelumSession } from "./types";
|
||||
import {
|
||||
markdownCodeBlock,
|
||||
mappingSummary,
|
||||
NEW_SESSION_ID,
|
||||
sessionSubtitle,
|
||||
} from "./ui";
|
||||
import { getEntityTypes, pseudonymize } from "./velum";
|
||||
|
||||
type FormValues = {
|
||||
text: string;
|
||||
sessionId: string;
|
||||
entityTypes: string[];
|
||||
};
|
||||
|
||||
export default function Command() {
|
||||
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 [isLoading, setIsLoading] = useState(true);
|
||||
const { push } = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const [loadedSessions, activeId] = await Promise.all([
|
||||
listSessions(),
|
||||
getActiveSessionId(),
|
||||
]);
|
||||
setSessions(loadedSessions);
|
||||
setActiveSessionId(activeId);
|
||||
|
||||
try {
|
||||
const loadedEntityTypes = await getEntityTypes();
|
||||
setEntityTypes(loadedEntityTypes);
|
||||
setSelectedEntityTypes(loadedEntityTypes);
|
||||
} catch {
|
||||
setEntityTypes([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
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: "Text zum Pseudonymisieren eingeben",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
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 result = await pseudonymize(
|
||||
input,
|
||||
session.mapping,
|
||||
values.entityTypes,
|
||||
);
|
||||
const updatedSession = await updateSessionMapping(
|
||||
session.id,
|
||||
result.mapping,
|
||||
result.selected_entity_types,
|
||||
);
|
||||
|
||||
toast.style = Toast.Style.Success;
|
||||
toast.title = "Pseudonymisiert";
|
||||
toast.message = `${result.entity_count} Zuordnungen in ${updatedSession.name}`;
|
||||
|
||||
push(<PseudonymizeResult result={result} session={updatedSession} />);
|
||||
} 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="Pseudonymisieren"
|
||||
icon={Icon.EyeDisabled}
|
||||
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="Text"
|
||||
value={text}
|
||||
onChange={setText}
|
||||
placeholder="Zu pseudonymisierender Text"
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function PseudonymizeResult({
|
||||
result,
|
||||
session,
|
||||
}: {
|
||||
result: PseudonymizeResponse;
|
||||
session: VelumSession;
|
||||
}) {
|
||||
const markdown = [
|
||||
`# ${session.name}`,
|
||||
sessionSubtitle(session),
|
||||
"",
|
||||
"## Pseudonymisierter Text",
|
||||
markdownCodeBlock(result.pseudonymized_text),
|
||||
"",
|
||||
"## Zuordnung",
|
||||
mappingSummary(result.mapping),
|
||||
].join("\n");
|
||||
|
||||
return (
|
||||
<Detail
|
||||
markdown={markdown}
|
||||
actions={
|
||||
<ActionPanel>
|
||||
<Action.CopyToClipboard
|
||||
title="Pseudonymisierten Text kopieren"
|
||||
content={result.pseudonymized_text}
|
||||
/>
|
||||
<Action
|
||||
title="Pseudonymisierten Text einfügen"
|
||||
icon={Icon.TextCursor}
|
||||
onAction={() => Clipboard.paste(result.pseudonymized_text)}
|
||||
/>
|
||||
</ActionPanel>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
121
src/quick.ts
Normal file
121
src/quick.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { Clipboard, showHUD, showToast, Toast } from "@raycast/api";
|
||||
import { getPreferences } from "./preferences";
|
||||
import {
|
||||
getActiveSession,
|
||||
resolveDefaultSession,
|
||||
updateSessionMapping,
|
||||
} from "./sessions";
|
||||
import type { VelumSession } from "./types";
|
||||
import { localDepseudonymize, pseudonymize } from "./velum";
|
||||
|
||||
export async function pseudonymizeQuickText(
|
||||
text: string,
|
||||
source: string,
|
||||
): Promise<void> {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
await showToast({
|
||||
style: Toast.Style.Failure,
|
||||
title: `Kein Text in ${source} gefunden`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const toast = await showToast({
|
||||
style: Toast.Style.Animated,
|
||||
title: "Pseudonymisiere…",
|
||||
});
|
||||
|
||||
try {
|
||||
const session = await resolveDefaultSession();
|
||||
const result = await pseudonymize(
|
||||
trimmed,
|
||||
session.mapping,
|
||||
session.entityTypes,
|
||||
);
|
||||
const updatedSession = await updateSessionMapping(
|
||||
session.id,
|
||||
result.mapping,
|
||||
result.selected_entity_types,
|
||||
);
|
||||
await writeQuickOutput(
|
||||
result.pseudonymized_text,
|
||||
updatedSession,
|
||||
"Pseudonymisierten Text",
|
||||
);
|
||||
|
||||
toast.style = Toast.Style.Success;
|
||||
toast.title = "Pseudonymisiert";
|
||||
toast.message = `${result.entity_count} Zuordnungen in ${updatedSession.name}`;
|
||||
} catch (error) {
|
||||
toast.style = Toast.Style.Failure;
|
||||
toast.title = "Pseudonymisierung fehlgeschlagen";
|
||||
toast.message = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function depseudonymizeQuickText(
|
||||
text: string,
|
||||
source: string,
|
||||
): Promise<void> {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
await showToast({
|
||||
style: Toast.Style.Failure,
|
||||
title: `Kein Text in ${source} gefunden`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const toast = await showToast({
|
||||
style: Toast.Style.Animated,
|
||||
title: "Stelle wieder her…",
|
||||
});
|
||||
|
||||
try {
|
||||
const session = await getActiveSession();
|
||||
if (Object.keys(session.mapping).length === 0) {
|
||||
toast.style = Toast.Style.Failure;
|
||||
toast.title = "Aktive Sitzung hat keine Zuordnungen";
|
||||
toast.message = session.name;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = localDepseudonymize(trimmed, session.mapping);
|
||||
if (result.replacements_made === 0) {
|
||||
toast.style = Toast.Style.Failure;
|
||||
toast.title = "Keine Platzhalter ersetzt";
|
||||
toast.message = `Sitzung: ${session.name}`;
|
||||
return;
|
||||
}
|
||||
|
||||
await writeQuickOutput(
|
||||
result.original_text,
|
||||
session,
|
||||
"Wiederhergestellten Text",
|
||||
);
|
||||
|
||||
toast.style = Toast.Style.Success;
|
||||
toast.title = "Wiederhergestellt";
|
||||
toast.message = `${result.replacements_made} Ersetzungen in ${session.name}`;
|
||||
} catch (error) {
|
||||
toast.style = Toast.Style.Failure;
|
||||
toast.title = "Wiederherstellung fehlgeschlagen";
|
||||
toast.message = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeQuickOutput(
|
||||
text: string,
|
||||
session: VelumSession,
|
||||
textKind: string,
|
||||
): Promise<void> {
|
||||
if (getPreferences().quickOutput === "paste") {
|
||||
await Clipboard.paste(text);
|
||||
await showHUD(`${textKind} eingefügt (${session.name})`);
|
||||
return;
|
||||
}
|
||||
|
||||
await Clipboard.copy(text);
|
||||
await showHUD(`${textKind} kopiert (${session.name})`);
|
||||
}
|
||||
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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
304
src/reply.ts
Normal file
304
src/reply.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
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;
|
||||
model: 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");
|
||||
}
|
||||
35
src/selection.ts
Normal file
35
src/selection.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Clipboard, getSelectedText } from "@raycast/api";
|
||||
|
||||
// Raycast 2.0 Beta: getSelectedText() alone does not yield the frontmost app's
|
||||
// selection — a Clipboard.clear() beforehand appears to be required to push
|
||||
// focus back to the source app. We deliberately do NOT snapshot/restore the
|
||||
// previous clipboard, because Clipboard.copy() afterwards dismisses the main
|
||||
// window in 2.0. Side effect: the user's previous clipboard content is lost
|
||||
// whenever this helper is invoked.
|
||||
//
|
||||
// Outlook (and other Electron/slow-responding apps) sometimes write to the
|
||||
// clipboard after getSelectedText() has already read it — so we also fall
|
||||
// back to a delayed Clipboard.readText() before declaring "no selection".
|
||||
export async function getSelectedTextSafely(): Promise<string | null> {
|
||||
async function attempt(): Promise<string | null> {
|
||||
await Clipboard.clear().catch(() => undefined);
|
||||
|
||||
try {
|
||||
const selected = await getSelectedText();
|
||||
if (selected && selected.length > 0) return selected;
|
||||
} catch {
|
||||
// fall through to clipboard fallback
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
const clipboardText = await Clipboard.readText().catch(() => undefined);
|
||||
return clipboardText && clipboardText.length > 0 ? clipboardText : null;
|
||||
}
|
||||
|
||||
let result = await attempt();
|
||||
if (result === null) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
result = await attempt();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
192
src/sessions.ts
Normal file
192
src/sessions.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { LocalStorage } from "@raycast/api";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getPreferences } from "./preferences";
|
||||
import type { PlaceholderMapping, SessionMode, VelumSession } from "./types";
|
||||
|
||||
const SESSIONS_STORAGE_KEY = "velum.sessions.v1";
|
||||
const ACTIVE_SESSION_STORAGE_KEY = "velum.sessions.active.v1";
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function dayKey(date = new Date()): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function sessionSort(a: VelumSession, b: VelumSession): number {
|
||||
return Date.parse(b.updatedAt) - Date.parse(a.updatedAt);
|
||||
}
|
||||
|
||||
async function saveSessions(sessions: VelumSession[]): Promise<void> {
|
||||
const maxSessions = getPreferences().maxSessions;
|
||||
const pruned = [...sessions].sort(sessionSort).slice(0, maxSessions);
|
||||
await LocalStorage.setItem(SESSIONS_STORAGE_KEY, JSON.stringify(pruned));
|
||||
|
||||
const activeId = await getActiveSessionId();
|
||||
if (activeId && !pruned.some((session) => session.id === activeId)) {
|
||||
await LocalStorage.setItem(ACTIVE_SESSION_STORAGE_KEY, pruned[0]?.id ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
export async function listSessions(): Promise<VelumSession[]> {
|
||||
const raw = await LocalStorage.getItem<string>(SESSIONS_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return (JSON.parse(raw) as VelumSession[]).sort(sessionSort);
|
||||
} catch {
|
||||
await LocalStorage.removeItem(SESSIONS_STORAGE_KEY);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getActiveSessionId(): Promise<string | undefined> {
|
||||
const id = await LocalStorage.getItem<string>(ACTIVE_SESSION_STORAGE_KEY);
|
||||
return id || undefined;
|
||||
}
|
||||
|
||||
export async function setActiveSession(id: string): Promise<void> {
|
||||
await LocalStorage.setItem(ACTIVE_SESSION_STORAGE_KEY, id);
|
||||
}
|
||||
|
||||
export async function createSession(name?: string): Promise<VelumSession> {
|
||||
const timestamp = nowIso();
|
||||
const session: VelumSession = {
|
||||
id: randomUUID(),
|
||||
name: name || `Sitzung ${new Date().toLocaleString()}`,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
mapping: {},
|
||||
};
|
||||
|
||||
const sessions = await listSessions();
|
||||
await saveSessions([session, ...sessions]);
|
||||
await setActiveSession(session.id);
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function getSession(
|
||||
id: string,
|
||||
): Promise<VelumSession | undefined> {
|
||||
const sessions = await listSessions();
|
||||
return sessions.find((session) => session.id === id);
|
||||
}
|
||||
|
||||
export async function getActiveSession(): Promise<VelumSession> {
|
||||
const sessions = await listSessions();
|
||||
const activeId = await getActiveSessionId();
|
||||
const active = sessions.find((session) => session.id === activeId);
|
||||
if (active) {
|
||||
return active;
|
||||
}
|
||||
return createSession();
|
||||
}
|
||||
|
||||
export async function resolveSessionForMode(
|
||||
mode: SessionMode,
|
||||
): Promise<VelumSession> {
|
||||
if (mode === "new-each-request") {
|
||||
return createSession(`Anfrage ${new Date().toLocaleString()}`);
|
||||
}
|
||||
|
||||
if (mode === "daily") {
|
||||
const name = `Tag ${dayKey()}`;
|
||||
const existing = (await listSessions()).find(
|
||||
(session) => session.name === name,
|
||||
);
|
||||
if (existing) {
|
||||
await setActiveSession(existing.id);
|
||||
return existing;
|
||||
}
|
||||
return createSession(name);
|
||||
}
|
||||
|
||||
return getActiveSession();
|
||||
}
|
||||
|
||||
export async function resolveDefaultSession(): Promise<VelumSession> {
|
||||
return resolveSessionForMode(getPreferences().sessionMode);
|
||||
}
|
||||
|
||||
export async function updateSessionMapping(
|
||||
id: string,
|
||||
mapping: PlaceholderMapping,
|
||||
entityTypes?: string[],
|
||||
): Promise<VelumSession> {
|
||||
const sessions = await listSessions();
|
||||
const timestamp = nowIso();
|
||||
const index = sessions.findIndex((session) => session.id === id);
|
||||
|
||||
if (index === -1) {
|
||||
const created: VelumSession = {
|
||||
id,
|
||||
name: `Sitzung ${new Date().toLocaleString()}`,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
mapping,
|
||||
entityTypes,
|
||||
};
|
||||
await saveSessions([created, ...sessions]);
|
||||
return created;
|
||||
}
|
||||
|
||||
const updated: VelumSession = {
|
||||
...sessions[index],
|
||||
mapping,
|
||||
entityTypes,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
const next = [...sessions];
|
||||
next[index] = updated;
|
||||
await saveSessions(next);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function renameSession(id: string, name: string): Promise<void> {
|
||||
const sessions = await listSessions();
|
||||
await saveSessions(
|
||||
sessions.map((session) =>
|
||||
session.id === id
|
||||
? { ...session, name: name.trim() || session.name, updatedAt: nowIso() }
|
||||
: session,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function clearSessionMapping(id: string): Promise<void> {
|
||||
const sessions = await listSessions();
|
||||
await saveSessions(
|
||||
sessions.map((session) =>
|
||||
session.id === id
|
||||
? { ...session, mapping: {}, updatedAt: nowIso() }
|
||||
: session,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteSession(id: string): Promise<void> {
|
||||
const sessions = await listSessions();
|
||||
const next = sessions.filter((session) => session.id !== id);
|
||||
await saveSessions(next);
|
||||
|
||||
const activeId = await getActiveSessionId();
|
||||
if (activeId === id) {
|
||||
if (next[0]) {
|
||||
await setActiveSession(next[0].id);
|
||||
} else {
|
||||
await LocalStorage.removeItem(ACTIVE_SESSION_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAllSessions(): Promise<void> {
|
||||
await LocalStorage.removeItem(SESSIONS_STORAGE_KEY);
|
||||
await LocalStorage.removeItem(ACTIVE_SESSION_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function countMappingEntries(mapping: PlaceholderMapping): number {
|
||||
return Object.keys(mapping).length;
|
||||
}
|
||||
538
src/summarize-email.tsx
Normal file
538
src/summarize-email.tsx
Normal file
@@ -0,0 +1,538 @@
|
||||
import {
|
||||
Action,
|
||||
ActionPanel,
|
||||
AI,
|
||||
Clipboard,
|
||||
Detail,
|
||||
Form,
|
||||
Icon,
|
||||
showHUD,
|
||||
showToast,
|
||||
Toast,
|
||||
useNavigation,
|
||||
} from "@raycast/api";
|
||||
import { copyRichText, markdownToHtml, maybeCloseRaycast } from "./ai-views";
|
||||
import { getSelectedTextSafely } from "./selection";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { getPreferences } from "./preferences";
|
||||
import {
|
||||
createSession,
|
||||
getActiveSessionId,
|
||||
getSession,
|
||||
listSessions,
|
||||
setActiveSession,
|
||||
updateSessionMapping,
|
||||
} from "./sessions";
|
||||
import { buildSummaryPrompt, 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[];
|
||||
model: string;
|
||||
creativity: Creativity;
|
||||
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 [isLoading, setIsLoading] = useState(true);
|
||||
const { push } = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const [loadedSessions, activeId] = await Promise.all([
|
||||
listSessions(),
|
||||
getActiveSessionId(),
|
||||
]);
|
||||
setSessions(loadedSessions);
|
||||
setActiveSessionId(activeId);
|
||||
|
||||
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();
|
||||
}, []);
|
||||
|
||||
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: "Text zum Zusammenfassen eingeben",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
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(
|
||||
<ConfirmPseudonymized
|
||||
session={updatedSession}
|
||||
pseudonymizedText={pseudoResult.pseudonymized_text}
|
||||
model={values.model}
|
||||
creativity={values.creativity}
|
||||
instructions={values.instructions}
|
||||
/>,
|
||||
);
|
||||
} 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="Zusammenfassen"
|
||||
icon={Icon.Wand}
|
||||
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-Konversation"
|
||||
value={text}
|
||||
onChange={setText}
|
||||
placeholder="Email-Konversation einfügen oder laden"
|
||||
/>
|
||||
<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"
|
||||
defaultValue={preferences.summaryModel}
|
||||
>
|
||||
{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" defaultValue="low">
|
||||
{CREATIVITY_OPTIONS.map((value) => (
|
||||
<Form.Dropdown.Item key={value} value={value} title={value} />
|
||||
))}
|
||||
</Form.Dropdown>
|
||||
<Form.TextArea
|
||||
id="instructions"
|
||||
title="Zusätzliche Anweisungen"
|
||||
placeholder="Optional: Fokus auf Entscheidungen, Tonalität, Action Items, …"
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
type StageProps = {
|
||||
session: VelumSession;
|
||||
pseudonymizedText: string;
|
||||
model: string;
|
||||
creativity: Creativity;
|
||||
instructions: string;
|
||||
};
|
||||
|
||||
function ConfirmPseudonymized({
|
||||
session,
|
||||
pseudonymizedText,
|
||||
model,
|
||||
creativity,
|
||||
instructions,
|
||||
}: StageProps) {
|
||||
const [editedText, setEditedText] = useState(pseudonymizedText);
|
||||
const [editedInstructions, setEditedInstructions] = useState(instructions);
|
||||
const { push } = useNavigation();
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!editedText.trim()) {
|
||||
await showToast({
|
||||
style: Toast.Style.Failure,
|
||||
title: "Pseudonymisierter Text ist leer",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
push(
|
||||
<SummarizeEmailResult
|
||||
session={session}
|
||||
pseudonymizedText={editedText}
|
||||
model={model}
|
||||
creativity={creativity}
|
||||
instructions={editedInstructions}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
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, bevor er an die KI gesendet wird. Platzhalter wie <PERSON_1> werden nach der Zusammenfassung wiederhergestellt. Sitzung: ${session.name} · ${Object.keys(session.mapping).length} Zuordnungen.`}
|
||||
/>
|
||||
<Form.TextArea
|
||||
id="pseudonymizedText"
|
||||
title="Pseudonymisierter Text"
|
||||
value={editedText}
|
||||
onChange={setEditedText}
|
||||
/>
|
||||
<Form.TextArea
|
||||
id="instructions"
|
||||
title="Zusätzliche Anweisungen"
|
||||
value={editedInstructions}
|
||||
onChange={setEditedInstructions}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
<Form.Separator />
|
||||
<Form.Description
|
||||
title="Zuordnung"
|
||||
text={mappingDescriptionText(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 = "summarizing" | "restoring" | "done" | "error";
|
||||
|
||||
function SummarizeEmailResult({
|
||||
session,
|
||||
pseudonymizedText,
|
||||
model,
|
||||
creativity,
|
||||
instructions,
|
||||
}: StageProps) {
|
||||
const [aiBuffer, setAiBuffer] = useState("");
|
||||
const [restored, setRestored] = useState<string | null>(null);
|
||||
const [replacementsMade, setReplacementsMade] = useState<number>(0);
|
||||
const [phase, setPhase] = useState<Phase>("summarizing");
|
||||
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: "Fasse zusammen…",
|
||||
});
|
||||
|
||||
try {
|
||||
const prompt = buildSummaryPrompt(pseudonymizedText, instructions);
|
||||
const stream = AI.ask(prompt, {
|
||||
model: model as AI.Model,
|
||||
creativity,
|
||||
signal: controller.signal,
|
||||
});
|
||||
stream.on("data", (chunk) => {
|
||||
if (cancelled) return;
|
||||
setAiBuffer((prev) => prev + chunk);
|
||||
});
|
||||
const aiText = await stream;
|
||||
if (cancelled) return;
|
||||
|
||||
toast.title = "Stelle wieder her…";
|
||||
setPhase("restoring");
|
||||
|
||||
const restoreResult = localDepseudonymize(aiText, session.mapping);
|
||||
if (cancelled) return;
|
||||
|
||||
setRestored(restoreResult.original_text);
|
||||
setReplacementsMade(restoreResult.replacements_made);
|
||||
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 = "Zusammenfassung fehlgeschlagen";
|
||||
toast.message = message;
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [pseudonymizedText, model, creativity, instructions, session.mapping]);
|
||||
|
||||
const phaseLabel =
|
||||
phase === "summarizing"
|
||||
? "KI generiert Zusammenfassung …"
|
||||
: phase === "restoring"
|
||||
? "Platzhalter werden ersetzt …"
|
||||
: phase === "error"
|
||||
? "Fehler"
|
||||
: "Fertig";
|
||||
|
||||
const summaryBody = restored ?? aiBuffer;
|
||||
|
||||
const markdown = [
|
||||
`# ${session.name}`,
|
||||
sessionSubtitle(session),
|
||||
`*Status:* ${phaseLabel}`,
|
||||
"",
|
||||
summaryBody.trim()
|
||||
? summaryBody
|
||||
: "_Noch keine Inhalte — warte auf das Modell …_",
|
||||
"",
|
||||
restored
|
||||
? `*${replacementsMade} Platzhalter ersetzt.*`
|
||||
: "_Originalnamen werden eingesetzt, sobald das Modell fertig ist._",
|
||||
"",
|
||||
"## Pseudonymisierte Eingabe",
|
||||
markdownCodeBlock(pseudonymizedText),
|
||||
"",
|
||||
"## Pseudonymisierte KI-Ausgabe",
|
||||
aiBuffer.trim()
|
||||
? markdownCodeBlock(aiBuffer)
|
||||
: "_Noch nichts vom Modell empfangen._",
|
||||
"",
|
||||
"## Zuordnung",
|
||||
mappingDetailTable(session.mapping),
|
||||
error ? `\n> Fehler: ${error}` : "",
|
||||
].join("\n");
|
||||
|
||||
const isLoading = phase === "summarizing" || phase === "restoring";
|
||||
|
||||
return (
|
||||
<Detail
|
||||
isLoading={isLoading}
|
||||
markdown={markdown}
|
||||
actions={
|
||||
<ActionPanel>
|
||||
{restored ? (
|
||||
<>
|
||||
<Action
|
||||
title="Zusammenfassung kopieren"
|
||||
icon={Icon.Clipboard}
|
||||
onAction={async () => {
|
||||
try {
|
||||
await copyRichText(markdownToHtml(restored), restored);
|
||||
await showHUD("Als Rich Text kopiert");
|
||||
} catch {
|
||||
await Clipboard.copy(restored);
|
||||
await showHUD("Kopiert (Plain Text Fallback)");
|
||||
}
|
||||
await maybeCloseRaycast();
|
||||
}}
|
||||
/>
|
||||
<Action
|
||||
title="Zusammenfassung kopieren (Markdown)"
|
||||
icon={Icon.CodeBlock}
|
||||
onAction={async () => {
|
||||
await Clipboard.copy(restored);
|
||||
await showHUD("Als Markdown kopiert");
|
||||
await maybeCloseRaycast();
|
||||
}}
|
||||
/>
|
||||
<Action
|
||||
title="Zusammenfassung einfügen"
|
||||
icon={Icon.TextCursor}
|
||||
onAction={async () => {
|
||||
await Clipboard.paste({
|
||||
html: markdownToHtml(restored),
|
||||
text: restored,
|
||||
});
|
||||
await maybeCloseRaycast();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{aiBuffer ? (
|
||||
<Action.CopyToClipboard
|
||||
title="Pseudonymisierte KI-Ausgabe kopieren"
|
||||
content={aiBuffer}
|
||||
/>
|
||||
) : null}
|
||||
<Action.CopyToClipboard
|
||||
title="Pseudonymisierte Eingabe kopieren"
|
||||
content={pseudonymizedText}
|
||||
/>
|
||||
</ActionPanel>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
58
src/summarize.ts
Normal file
58
src/summarize.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { AI } from "@raycast/api";
|
||||
|
||||
export type Creativity = "none" | "low" | "medium" | "high";
|
||||
|
||||
export function buildSummaryPrompt(
|
||||
pseudonymizedText: string,
|
||||
instructions?: string,
|
||||
): string {
|
||||
const extra = instructions?.trim()
|
||||
? `\n\nZusätzliche Anweisungen des Nutzers:\n${instructions.trim()}`
|
||||
: "";
|
||||
|
||||
return [
|
||||
"Du erhältst eine pseudonymisierte Email-Konversation.",
|
||||
"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.",
|
||||
"Du darfst Platzhalter NIEMALS auflösen, raten, übersetzen oder mit erfundenen Namen ersetzen. Schreibe sie exakt so, wie sie in der Eingabe stehen.",
|
||||
"",
|
||||
"Aufgabe: Erstelle eine prägnante deutschsprachige Zusammenfassung der Konversation mit folgenden Abschnitten als Markdown:",
|
||||
"- **Teilnehmer**: Liste der beteiligten Platzhalter, ggf. mit Rolle (Absender/Empfänger).",
|
||||
"- **Anliegen**: Worum geht es im Kern?",
|
||||
"- **Verlauf**: Chronologische Kurzfassung der wichtigsten Punkte.",
|
||||
"- **Offene Punkte / Action Items**: Was ist noch zu tun, von wem, bis wann.",
|
||||
"",
|
||||
"Antworte ausschließlich in Markdown, ohne einleitende Floskeln.",
|
||||
extra,
|
||||
"",
|
||||
"--- Email-Konversation (pseudonymisiert) ---",
|
||||
pseudonymizedText,
|
||||
"--- Ende der Konversation ---",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export type SummaryStreamOptions = {
|
||||
prompt: string;
|
||||
model: string;
|
||||
creativity: Creativity;
|
||||
signal?: AbortSignal;
|
||||
onChunk: (chunk: string) => void;
|
||||
};
|
||||
|
||||
export async function runSummaryStream(
|
||||
options: SummaryStreamOptions,
|
||||
): Promise<string> {
|
||||
let buffer = "";
|
||||
const stream = AI.ask(options.prompt, {
|
||||
model: options.model as AI.Model,
|
||||
creativity: options.creativity,
|
||||
signal: options.signal,
|
||||
});
|
||||
stream.on("data", (chunk) => {
|
||||
buffer += chunk;
|
||||
options.onChunk(chunk);
|
||||
});
|
||||
await stream;
|
||||
return buffer;
|
||||
}
|
||||
55
src/types.ts
Normal file
55
src/types.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
export type EntityType = string;
|
||||
|
||||
export type MappingEntry = {
|
||||
original: string;
|
||||
type: EntityType;
|
||||
};
|
||||
|
||||
export type PlaceholderMapping = Record<string, MappingEntry>;
|
||||
|
||||
export type PseudonymizeResponse = {
|
||||
pseudonymized_text: string;
|
||||
selected_entity_types: EntityType[];
|
||||
mapping: PlaceholderMapping;
|
||||
spans: Array<{
|
||||
start: number;
|
||||
end: number;
|
||||
type: EntityType;
|
||||
score: number;
|
||||
placeholder: string;
|
||||
original: string;
|
||||
}>;
|
||||
entity_count: number;
|
||||
};
|
||||
|
||||
export type DepseudonymizeResponse = {
|
||||
original_text: string;
|
||||
replacements_made: number;
|
||||
};
|
||||
|
||||
export type VelumSession = {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
mapping: PlaceholderMapping;
|
||||
entityTypes?: EntityType[];
|
||||
};
|
||||
|
||||
export type SessionMode = "reuse-active" | "new-each-request" | "daily";
|
||||
export type QuickOutput = "copy" | "paste";
|
||||
|
||||
export type ExtensionPreferences = {
|
||||
velumBaseUrl: string;
|
||||
authentikTokenUrl: string;
|
||||
clientId: string;
|
||||
serviceAccountUsername: string;
|
||||
serviceAccountPassword: string;
|
||||
scope?: string;
|
||||
sessionMode: SessionMode;
|
||||
quickOutput: QuickOutput;
|
||||
summaryModel: string;
|
||||
userFullName?: string;
|
||||
maxSessions: string;
|
||||
closeAfterAction: boolean;
|
||||
};
|
||||
57
src/ui.ts
Normal file
57
src/ui.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { PlaceholderMapping, VelumSession } from "./types";
|
||||
|
||||
export const NEW_SESSION_ID = "__new_session__";
|
||||
|
||||
export function markdownCodeBlock(value: string): string {
|
||||
return `\`\`\`text\n${value.replace(/```/g, "`\u200b``")}\n\`\`\``;
|
||||
}
|
||||
|
||||
function escapeMarkdownTableCell(value: string): string {
|
||||
return value.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
|
||||
}
|
||||
|
||||
export function mappingDetailTable(mapping: PlaceholderMapping): string {
|
||||
const rows = Object.entries(mapping)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(
|
||||
([placeholder, entry]) =>
|
||||
`| \`${placeholder}\` | ${escapeMarkdownTableCell(entry.original)} | ${entry.type} |`,
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return "Keine Einträge.";
|
||||
}
|
||||
|
||||
return [
|
||||
"| Platzhalter | Original | Typ |",
|
||||
"| --- | --- | --- |",
|
||||
...rows,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function mappingSummary(mapping: PlaceholderMapping): string {
|
||||
const counts = Object.values(mapping).reduce<Record<string, number>>(
|
||||
(acc, entry) => {
|
||||
acc[entry.type] = (acc[entry.type] ?? 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const rows = Object.entries(counts)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([type, count]) => `| ${type} | ${count} |`);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return "Keine Einträge.";
|
||||
}
|
||||
|
||||
return ["| Typ | Anzahl |", "| --- | ---: |", ...rows].join("\n");
|
||||
}
|
||||
|
||||
export function sessionSubtitle(session: VelumSession): string {
|
||||
const entries = Object.keys(session.mapping).length;
|
||||
return `${entries} ${entries === 1 ? "Zuordnung" : "Zuordnungen"} · aktualisiert ${new Date(
|
||||
session.updatedAt,
|
||||
).toLocaleString()}`;
|
||||
}
|
||||
144
src/velum.ts
Normal file
144
src/velum.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { fetchWithAuth } from "./auth";
|
||||
import { getPreferences } from "./preferences";
|
||||
import type {
|
||||
DepseudonymizeResponse,
|
||||
EntityType,
|
||||
PlaceholderMapping,
|
||||
PseudonymizeResponse,
|
||||
} from "./types";
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
export function normalizePseudonymizeResponse(
|
||||
response: PseudonymizeResponse,
|
||||
): PseudonymizeResponse {
|
||||
const normalizedMapping: PlaceholderMapping = {};
|
||||
const strippedKeys = new Set<string>();
|
||||
|
||||
for (const [key, entry] of Object.entries(response.mapping)) {
|
||||
const stripped = key.replace(/[<>]/g, "");
|
||||
normalizedMapping[`<${stripped}>`] = entry;
|
||||
strippedKeys.add(stripped);
|
||||
}
|
||||
|
||||
let text = response.pseudonymized_text;
|
||||
const ordered = Array.from(strippedKeys).sort((a, b) => b.length - a.length);
|
||||
for (const stripped of ordered) {
|
||||
const bracketed = `<${stripped}>`;
|
||||
const bareRegex = new RegExp(
|
||||
`(?<![<\\w])${escapeRegExp(stripped)}(?![\\w>])`,
|
||||
"g",
|
||||
);
|
||||
text = text.replace(bareRegex, bracketed);
|
||||
}
|
||||
|
||||
return {
|
||||
...response,
|
||||
pseudonymized_text: text,
|
||||
mapping: normalizedMapping,
|
||||
};
|
||||
}
|
||||
|
||||
export function localDepseudonymize(
|
||||
text: string,
|
||||
mapping: PlaceholderMapping,
|
||||
): DepseudonymizeResponse {
|
||||
let result = text;
|
||||
let replacementsMade = 0;
|
||||
|
||||
const entries = Object.entries(mapping)
|
||||
.map(([key, entry]) => ({
|
||||
stripped: key.replace(/[<>]/g, ""),
|
||||
entry,
|
||||
}))
|
||||
.sort((a, b) => b.stripped.length - a.stripped.length);
|
||||
|
||||
for (const { stripped, entry } of entries) {
|
||||
const bracketed = `<${stripped}>`;
|
||||
const bracketedRegex = new RegExp(escapeRegExp(bracketed), "g");
|
||||
const bracketedMatches = result.match(bracketedRegex);
|
||||
if (bracketedMatches) {
|
||||
replacementsMade += bracketedMatches.length;
|
||||
result = result.replace(bracketedRegex, entry.original);
|
||||
}
|
||||
|
||||
const bareRegex = new RegExp(`\\b${escapeRegExp(stripped)}\\b`, "g");
|
||||
const bareMatches = result.match(bareRegex);
|
||||
if (bareMatches) {
|
||||
replacementsMade += bareMatches.length;
|
||||
result = result.replace(bareRegex, entry.original);
|
||||
}
|
||||
}
|
||||
|
||||
return { original_text: result, replacements_made: replacementsMade };
|
||||
}
|
||||
|
||||
function apiUrl(path: string): string {
|
||||
return `${getPreferences().velumBaseUrl}${path}`;
|
||||
}
|
||||
|
||||
async function parseJsonResponse<T>(response: Response): Promise<T> {
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (!response.ok) {
|
||||
const details = await response.text().catch(() => response.statusText);
|
||||
throw new Error(
|
||||
`Velum API request failed: ${response.status} ${response.statusText}: ${details}`,
|
||||
);
|
||||
}
|
||||
if (!contentType.includes("application/json")) {
|
||||
const details = await response.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Velum API returned non-JSON response: ${details.slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export async function getEntityTypes(): Promise<EntityType[]> {
|
||||
const response = await fetchWithAuth(apiUrl("/api/entity-types"));
|
||||
const payload = await parseJsonResponse<{ entity_types: EntityType[] }>(
|
||||
response,
|
||||
);
|
||||
return payload.entity_types;
|
||||
}
|
||||
|
||||
export async function pseudonymize(
|
||||
text: string,
|
||||
mapping: PlaceholderMapping,
|
||||
entityTypes?: EntityType[],
|
||||
): Promise<PseudonymizeResponse> {
|
||||
const response = await fetchWithAuth(apiUrl("/api/pseudonymize"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text,
|
||||
mapping,
|
||||
entity_types:
|
||||
entityTypes && entityTypes.length > 0 ? entityTypes : undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
return parseJsonResponse<PseudonymizeResponse>(response);
|
||||
}
|
||||
|
||||
export async function depseudonymize(
|
||||
text: string,
|
||||
mapping: PlaceholderMapping,
|
||||
): Promise<DepseudonymizeResponse> {
|
||||
const response = await fetchWithAuth(apiUrl("/api/depseudonymize"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text,
|
||||
mapping,
|
||||
}),
|
||||
});
|
||||
|
||||
return parseJsonResponse<DepseudonymizeResponse>(response);
|
||||
}
|
||||
16
tsconfig.json
Normal file
16
tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"compilerOptions": {
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["ES2023"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"target": "ES2023"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|
||||
Reference in New Issue
Block a user