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.
169 lines
5.3 KiB
TypeScript
169 lines
5.3 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",
|
||
(_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;
|
||
});
|
||
}
|