170 lines
5.2 KiB
TypeScript
170 lines
5.2 KiB
TypeScript
|
|
/**
|
|||
|
|
* Session Header Extension
|
|||
|
|
*
|
|||
|
|
* Zeigt den erkannten Sitzungszweck (Intention) als gelbe Überschrift
|
|||
|
|
* oberhalb des Editors an. Nutzt das LLM zur Zusammenfassung der ersten
|
|||
|
|
* Benutzereingabe, statt sie 1:1 zu transkribieren.
|
|||
|
|
*
|
|||
|
|
* Farbe: Gelb (warning), damit es sich von anderen UI-Elementen abhebt.
|
|||
|
|
* Fallback: Gekürzter Originaltext, wenn die LLM-Zusammenfassung fehlschlägt.
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|||
|
|
import { complete, getModel } from "@earendil-works/pi-ai";
|
|||
|
|
|
|||
|
|
const MAX_TITLE_LENGTH = 160;
|
|||
|
|
const MAX_SUMMARY_TOKENS = 60;
|
|||
|
|
|
|||
|
|
function truncate(text: string): string {
|
|||
|
|
const trimmed = text.trim();
|
|||
|
|
if (trimmed.length <= MAX_TITLE_LENGTH) return trimmed;
|
|||
|
|
return trimmed.slice(0, MAX_TITLE_LENGTH - 3) + "\u2026";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Ruft das aktuelle LLM auf, um aus der Benutzereingabe eine kurze
|
|||
|
|
* Intention-Zusammenfassung (5–12 Wörter) zu extrahieren.
|
|||
|
|
*/
|
|||
|
|
async function generateIntentionSummary(
|
|||
|
|
input: string,
|
|||
|
|
ctx: any,
|
|||
|
|
): Promise<string | null> {
|
|||
|
|
try {
|
|||
|
|
const provider = ctx.model?.provider;
|
|||
|
|
const modelId = ctx.model?.id;
|
|||
|
|
if (!provider || !modelId) return null;
|
|||
|
|
|
|||
|
|
const model = getModel(provider, modelId);
|
|||
|
|
if (!model) return null;
|
|||
|
|
|
|||
|
|
const registry = ctx.modelRegistry;
|
|||
|
|
const auth = registry
|
|||
|
|
? await registry.getApiKeyAndHeaders(model)
|
|||
|
|
: null;
|
|||
|
|
if (!auth?.ok || !auth.apiKey) return null;
|
|||
|
|
|
|||
|
|
const prompt = [
|
|||
|
|
"Du extrahierst aus der folgenden Benutzereingabe maximal 5–10 Wörter",
|
|||
|
|
"als präzisen Session-Titel. Fasse die KERN-Intention zusammen,",
|
|||
|
|
"nicht die wörtliche Formulierung. Antworte NUR mit dem Titel,",
|
|||
|
|
"keinem weiteren Text.",
|
|||
|
|
"",
|
|||
|
|
"Eingabe:",
|
|||
|
|
input,
|
|||
|
|
].join("\n");
|
|||
|
|
|
|||
|
|
const response = await complete(
|
|||
|
|
model,
|
|||
|
|
{
|
|||
|
|
messages: [
|
|||
|
|
{
|
|||
|
|
role: "user",
|
|||
|
|
content: [{ type: "text" as const, text: prompt }],
|
|||
|
|
timestamp: Date.now(),
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
apiKey: auth.apiKey,
|
|||
|
|
headers: auth.headers,
|
|||
|
|
maxTokens: MAX_SUMMARY_TOKENS,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const textParts = response.content
|
|||
|
|
.filter((c: any): c is { type: "text"; text: string } => c.type === "text")
|
|||
|
|
.map((c: any) => c.text)
|
|||
|
|
.join(" ")
|
|||
|
|
.trim();
|
|||
|
|
|
|||
|
|
if (!textParts) return null;
|
|||
|
|
// Entferne mögliche Anführungszeichen, die das LLM setzt
|
|||
|
|
return textParts.replace(/^["']|["']$/g, "").substring(0, MAX_TITLE_LENGTH);
|
|||
|
|
} catch (err) {
|
|||
|
|
// Bei Fehler (z.B. API nicht erreichbar, Modell nicht gefunden): Fallback auf truncate
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function makeWidget(title: string | null, theme: any) {
|
|||
|
|
return {
|
|||
|
|
render(_width: number): string[] {
|
|||
|
|
if (!title) {
|
|||
|
|
return [theme.fg("warning", "\u25C6 Sitzung: (warte auf erste Eingabe\u2026)")];
|
|||
|
|
}
|
|||
|
|
const label = theme.fg("warning", theme.bold("\u25C6"));
|
|||
|
|
const text = theme.fg("warning", ` ${title}`);
|
|||
|
|
return [`${label}${text}`];
|
|||
|
|
},
|
|||
|
|
invalidate() {},
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export default function (pi: ExtensionAPI) {
|
|||
|
|
let sessionTitle: string | null = null;
|
|||
|
|
|
|||
|
|
pi.on("session_start", async (_event, ctx) => {
|
|||
|
|
if (!ctx.hasUI) return;
|
|||
|
|
|
|||
|
|
// Bestehende Session: ersten User-Eintrag finden und zusammenfassen
|
|||
|
|
const entries = ctx.sessionManager.getEntries();
|
|||
|
|
for (const entry of entries) {
|
|||
|
|
if (entry.type === "message" && (entry as any).message?.role === "user") {
|
|||
|
|
const text = (entry as any).message.content
|
|||
|
|
.filter((c: any) => c.type === "text")
|
|||
|
|
.map((c: any) => c.text)
|
|||
|
|
.join(" ");
|
|||
|
|
if (text.trim()) {
|
|||
|
|
// Bei Wiederaufnahme: versuche Zusammenfassung asynchron
|
|||
|
|
generateIntentionSummary(text, ctx).then((summary) => {
|
|||
|
|
if (summary) {
|
|||
|
|
sessionTitle = summary;
|
|||
|
|
pi.setSessionName(sessionTitle);
|
|||
|
|
ctx.ui.setWidget(
|
|||
|
|
"session-header",
|
|||
|
|
makeWidget(sessionTitle, ctx.ui.theme),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}).catch(() => {
|
|||
|
|
// Silent fail — truncate reicht als Fallback
|
|||
|
|
});
|
|||
|
|
// Interim: gekürzten Originaltext anzeigen, bis Summary da ist
|
|||
|
|
sessionTitle = truncate(text);
|
|||
|
|
pi.setSessionName(sessionTitle);
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ctx.ui.setWidget("session-header", makeWidget(sessionTitle, ctx.ui.theme));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Erste Benutzereingabe: Intention erkennen statt 1:1 übernehmen
|
|||
|
|
pi.on("input", async (event, ctx) => {
|
|||
|
|
if (!ctx.hasUI) return;
|
|||
|
|
if (sessionTitle) return { action: "continue" };
|
|||
|
|
|
|||
|
|
// Interim: sofort gekürzten Originaltext anzeigen
|
|||
|
|
sessionTitle = truncate(event.text);
|
|||
|
|
pi.setSessionName(sessionTitle);
|
|||
|
|
ctx.ui.setWidget("session-header", makeWidget(sessionTitle, ctx.ui.theme));
|
|||
|
|
|
|||
|
|
// Async: LLM-Zusammenfassung nachladen
|
|||
|
|
generateIntentionSummary(event.text, ctx).then((summary) => {
|
|||
|
|
if (summary) {
|
|||
|
|
sessionTitle = summary;
|
|||
|
|
pi.setSessionName(sessionTitle);
|
|||
|
|
ctx.ui.setWidget("session-header", makeWidget(sessionTitle, ctx.ui.theme));
|
|||
|
|
}
|
|||
|
|
}).catch(() => {
|
|||
|
|
// Silent fail — truncate reicht als Fallback
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return { action: "continue" };
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
pi.on("session_before_switch", async () => {
|
|||
|
|
sessionTitle = null;
|
|||
|
|
});
|
|||
|
|
}
|