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

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

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

310 lines
8.7 KiB
TypeScript

import {
Action,
ActionPanel,
Clipboard,
Form,
Icon,
showToast,
Toast,
useNavigation,
} from "@raycast/api";
import { useEffect, useMemo, useState } from "react";
import {
type Creativity,
CREATIVITY_OPTIONS,
DEFAULT_MODEL,
getLastUsedModel,
MODEL_OPTIONS,
setLastUsedModel,
STRICT_PLACEHOLDER_RULE,
} from "./ai";
import { PseudonymizationConfirm } from "./ai-views";
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 [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 Extrahieren 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(
<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"
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 bestimmte Personen, Zeitraum, …"
/>
</Form>
);
}