pi-system/extensions/session-header.ts
Raimund Bauer 1f2c3ecae8 fix/extensions: setWidget erwartet Factory-Funktion, nicht Widget-Objekt
Pi's setWidget-API ruft den zweiten Parameter als content(ui, theme) auf.
session-header.ts und session-index.ts übergaben direkt das Widget-Objekt
— jetzt als (_ui, theme) => makeWidget(...) gewrappt.
2026-06-02 15:47:39 +02:00

169 lines
5.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 (512 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 510 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",
(_ui: any, theme: any) => makeWidget(sessionTitle, 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", (_ui: any, theme: any) => makeWidget(sessionTitle, 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", (_ui: any, theme: any) => makeWidget(sessionTitle, theme));
// Async: LLM-Zusammenfassung nachladen
generateIntentionSummary(event.text, ctx).then((summary) => {
if (summary) {
sessionTitle = summary;
pi.setSessionName(sessionTitle);
ctx.ui.setWidget("session-header", (_ui: any, theme: any) => makeWidget(sessionTitle, theme));
}
}).catch(() => {
// Silent fail — truncate reicht als Fallback
});
return { action: "continue" };
});
pi.on("session_before_switch", async () => {
sessionTitle = null;
});
}