Drop the summaryModel preference (and the awkward textfield holding a raw model ID nobody could discover without poking the SDK types). Each AI view now controls its model dropdown, loads the shared velum.ai.last-model on mount, and writes it back on submit — so picking Claude 4.7 Opus in the summarize command becomes the prefilled default in briefing, action-items, structured-data, and reply next time around. Also drops 'model' from ReplyDefaults; the shared key supersedes it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
539 lines
14 KiB
TypeScript
539 lines
14 KiB
TypeScript
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 {
|
|
createSession,
|
|
getActiveSessionId,
|
|
getSession,
|
|
listSessions,
|
|
setActiveSession,
|
|
updateSessionMapping,
|
|
} from "./sessions";
|
|
import {
|
|
CREATIVITY_OPTIONS,
|
|
DEFAULT_MODEL,
|
|
getLastUsedModel,
|
|
MODEL_OPTIONS,
|
|
setLastUsedModel,
|
|
} from "./ai";
|
|
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;
|
|
};
|
|
|
|
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 [model, setModel] = useState<string>(DEFAULT_MODEL);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const { push } = useNavigation();
|
|
|
|
useEffect(() => {
|
|
async function load() {
|
|
const [loadedSessions, activeId, lastModel] = await Promise.all([
|
|
listSessions(),
|
|
getActiveSessionId(),
|
|
getLastUsedModel(),
|
|
]);
|
|
setSessions(loadedSessions);
|
|
setActiveSessionId(activeId);
|
|
setModel(lastModel);
|
|
|
|
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;
|
|
}
|
|
|
|
void setLastUsedModel(values.model);
|
|
|
|
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"
|
|
value={model}
|
|
onChange={setModel}
|
|
>
|
|
{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>
|
|
}
|
|
/>
|
|
);
|
|
}
|