mirror of
https://github.com/earendil-works/pi.git
synced 2026-06-18 15:54:04 +08:00
8a0903ebf2
The root barrel is now core-only and side-effect free: types, createModels/createProvider, auth substrate, lazyStream/lazyApi, faux, utils. Generated catalogs, api-registry, env-api-keys, images, global stream functions, and per-API lazy wrappers leave the root. New @earendil-works/pi-ai/compat preserves the old surface verbatim as a strict superset of the root: api-dispatch stream/complete with env key injection, the builtin registration side effect (skip-if-present so it cannot clobber earlier overrides), deprecated getModel/getModels/ getProviders aliases of the new getBuiltin* reads in providers/all, lazy api wrappers + setBedrockProviderModule, and image generation. Compat dies with the coding-agent ModelManager migration. Packaging: exports map gains ./compat, ./providers/*, ./api/*; sideEffects array lists only the effectful modules. Old-global imports across agent/coding-agent/examples and pi-ai tests switch to /compat (path-only; compat is a superset). The coding-agent extension loader resolves the pi-ai ROOT specifier to compat, so existing user extensions using the old global API keep working at runtime until compat is removed. vitest configs alias /compat to src; browser smoke imports old globals from /compat.
123 lines
3.7 KiB
TypeScript
123 lines
3.7 KiB
TypeScript
/**
|
|
* Q&A extraction extension - extracts questions from assistant responses
|
|
*
|
|
* Demonstrates the "prompt generator" pattern:
|
|
* 1. /qna command gets the last assistant message
|
|
* 2. Shows a spinner while extracting (hides editor)
|
|
* 3. Loads the result into the editor for user to fill in answers
|
|
*/
|
|
|
|
import { complete, type UserMessage } from "@earendil-works/pi-ai/compat";
|
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
import { BorderedLoader } from "@earendil-works/pi-coding-agent";
|
|
|
|
const SYSTEM_PROMPT = `You are a question extractor. Given text from a conversation, extract any questions that need answering and format them for the user to fill in.
|
|
|
|
Output format:
|
|
- List each question on its own line, prefixed with "Q: "
|
|
- After each question, add a blank line for the answer prefixed with "A: "
|
|
- If no questions are found, output "No questions found in the last message."
|
|
|
|
Example output:
|
|
Q: What is your preferred database?
|
|
A:
|
|
|
|
Q: Should we use TypeScript or JavaScript?
|
|
A:
|
|
|
|
Keep questions in the order they appeared. Be concise.`;
|
|
|
|
export default function (pi: ExtensionAPI) {
|
|
pi.registerCommand("qna", {
|
|
description: "Extract questions from last assistant message into editor",
|
|
handler: async (_args, ctx) => {
|
|
if (ctx.mode !== "tui") {
|
|
ctx.ui.notify("qna requires interactive mode", "error");
|
|
return;
|
|
}
|
|
|
|
if (!ctx.model) {
|
|
ctx.ui.notify("No model selected", "error");
|
|
return;
|
|
}
|
|
|
|
// Find the last assistant message on the current branch
|
|
const branch = ctx.sessionManager.getBranch();
|
|
let lastAssistantText: string | undefined;
|
|
|
|
for (let i = branch.length - 1; i >= 0; i--) {
|
|
const entry = branch[i];
|
|
if (entry.type === "message") {
|
|
const msg = entry.message;
|
|
if ("role" in msg && msg.role === "assistant") {
|
|
if (msg.stopReason !== "stop") {
|
|
ctx.ui.notify(`Last assistant message incomplete (${msg.stopReason})`, "error");
|
|
return;
|
|
}
|
|
const textParts = msg.content
|
|
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
.map((c) => c.text);
|
|
if (textParts.length > 0) {
|
|
lastAssistantText = textParts.join("\n");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!lastAssistantText) {
|
|
ctx.ui.notify("No assistant messages found", "error");
|
|
return;
|
|
}
|
|
|
|
// Run extraction with loader UI
|
|
const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
|
const loader = new BorderedLoader(tui, theme, `Extracting questions using ${ctx.model!.id}...`);
|
|
loader.onAbort = () => done(null);
|
|
|
|
// Do the work
|
|
const doExtract = async () => {
|
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model!);
|
|
if (!auth.ok || !auth.apiKey) {
|
|
throw new Error(auth.ok ? `No API key for ${ctx.model!.provider}` : auth.error);
|
|
}
|
|
const userMessage: UserMessage = {
|
|
role: "user",
|
|
content: [{ type: "text", text: lastAssistantText! }],
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
const response = await complete(
|
|
ctx.model!,
|
|
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
|
|
{ apiKey: auth.apiKey, headers: auth.headers, signal: loader.signal },
|
|
);
|
|
|
|
if (response.stopReason === "aborted") {
|
|
return null;
|
|
}
|
|
|
|
return response.content
|
|
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
.map((c) => c.text)
|
|
.join("\n");
|
|
};
|
|
|
|
doExtract()
|
|
.then(done)
|
|
.catch(() => done(null));
|
|
|
|
return loader;
|
|
});
|
|
|
|
if (result === null) {
|
|
ctx.ui.notify("Cancelled", "info");
|
|
return;
|
|
}
|
|
|
|
ctx.ui.setEditorText(result);
|
|
ctx.ui.notify("Questions loaded. Edit and submit when ready.", "info");
|
|
},
|
|
});
|
|
}
|