mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-04 11:39:36 +00:00
feat: integrate Pi backend and Pi Web
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
import {
|
||||
Agent,
|
||||
type AgentMessage,
|
||||
type AgentOptions,
|
||||
type AgentTool,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
const TITLE_TIMEOUT_MS = 90_000;
|
||||
const MAX_TITLE_LENGTH = 80;
|
||||
|
||||
const TITLE_PROMPT = `Create a concise title for this session based on the conversation above.
|
||||
|
||||
Requirements:
|
||||
- Match the primary language used by the user.
|
||||
- Describe the user's concrete goal or the outcome, not the act of chatting.
|
||||
- Use 4-12 words for space-separated languages, or 8-24 characters for CJK text when practical.
|
||||
- Do not call any tools.
|
||||
- Return only the title as plain text, with no quotes, label, markdown, or explanation.`;
|
||||
|
||||
export interface GeneratedSessionTitle {
|
||||
title: string;
|
||||
usage?: {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
function createShadowTools(tools: AgentTool[]): AgentTool[] {
|
||||
return tools.map((tool) => ({
|
||||
...tool,
|
||||
execute: async () => {
|
||||
throw new Error("Tools cannot be executed while generating a session title");
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a temporary Agent configuration whose provider-facing prefix matches
|
||||
* the source Agent. Tool implementations are replaced without changing their
|
||||
* names, descriptions, or schemas, so a naming run cannot mutate the project.
|
||||
*/
|
||||
export function buildSessionTitleAgentOptions(source: Agent): AgentOptions {
|
||||
const state = source.state;
|
||||
return {
|
||||
initialState: {
|
||||
systemPrompt: state.systemPrompt,
|
||||
model: state.model,
|
||||
thinkingLevel: state.thinkingLevel,
|
||||
tools: createShadowTools(state.tools),
|
||||
messages: state.messages,
|
||||
},
|
||||
convertToLlm: source.convertToLlm,
|
||||
transformContext: source.transformContext,
|
||||
streamFn: source.streamFunction,
|
||||
getApiKey: source.getApiKey,
|
||||
onPayload: source.onPayload,
|
||||
onResponse: source.onResponse,
|
||||
steeringMode: source.steeringMode,
|
||||
followUpMode: source.followUpMode,
|
||||
sessionId: source.sessionId,
|
||||
thinkingBudgets: source.thinkingBudgets,
|
||||
transport: source.transport,
|
||||
maxRetryDelayMs: source.maxRetryDelayMs,
|
||||
toolExecution: source.toolExecution,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A running source session usually ends in the user message currently being
|
||||
* answered. Fold the title request into a copy of that message so the title
|
||||
* request does not send two consecutive user messages to the provider.
|
||||
*/
|
||||
export function appendTitleRequestToTrailingUser(messages: AgentMessage[]): AgentMessage[] {
|
||||
const lastMessage = messages.at(-1);
|
||||
if (!lastMessage || lastMessage.role !== "user") return messages;
|
||||
|
||||
const content = typeof lastMessage.content === "string"
|
||||
? `${lastMessage.content}\n\n${TITLE_PROMPT}`
|
||||
: [...lastMessage.content, { type: "text" as const, text: TITLE_PROMPT }];
|
||||
|
||||
return [
|
||||
...messages.slice(0, -1),
|
||||
{ ...lastMessage, content },
|
||||
];
|
||||
}
|
||||
|
||||
function stripWrappingQuotes(value: string): string {
|
||||
const pairs: Array<[string, string]> = [
|
||||
['"', '"'],
|
||||
["'", "'"],
|
||||
["`", "`"],
|
||||
["\u201c", "\u201d"],
|
||||
["\u300c", "\u300d"],
|
||||
["\u300e", "\u300f"],
|
||||
];
|
||||
for (const [start, end] of pairs) {
|
||||
if (value.startsWith(start) && value.endsWith(end) && value.length > start.length + end.length) {
|
||||
return value.slice(start.length, -end.length).trim();
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseGeneratedSessionTitle(raw: string): string {
|
||||
let value = raw.trim();
|
||||
const fenced = value.match(/^```(?:json|text)?\s*([\s\S]*?)\s*```$/i);
|
||||
if (fenced) value = fenced[1].trim();
|
||||
|
||||
if (value.startsWith("{")) {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as { title?: unknown };
|
||||
if (typeof parsed.title === "string") value = parsed.title.trim();
|
||||
} catch {
|
||||
// Fall back to plain-text cleanup below.
|
||||
}
|
||||
}
|
||||
|
||||
value = value.split(/\r?\n/, 1)[0] ?? "";
|
||||
value = value.replace(/^(?:session\s+title|title|标题)\s*[::-]\s*/i, "");
|
||||
value = stripWrappingQuotes(value).replace(/\s+/g, " ").trim();
|
||||
value = value.replace(/[。.!]+$/u, "").trim();
|
||||
|
||||
if (!/[\p{L}\p{N}]/u.test(value)) {
|
||||
throw new Error("The model did not return a usable session title");
|
||||
}
|
||||
|
||||
const characters = Array.from(value);
|
||||
if (characters.length > MAX_TITLE_LENGTH) {
|
||||
value = characters.slice(0, MAX_TITLE_LENGTH).join("").trim();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function getAssistantResult(agent: Agent, historyLength: number): GeneratedSessionTitle {
|
||||
const generatedMessages = agent.state.messages.slice(historyLength);
|
||||
for (let i = generatedMessages.length - 1; i >= 0; i--) {
|
||||
const message = generatedMessages[i];
|
||||
if (message.role !== "assistant") continue;
|
||||
if (message.stopReason === "error") {
|
||||
throw new Error(message.errorMessage || "The title model request failed");
|
||||
}
|
||||
const text = message.content
|
||||
.filter((block) => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (!text) continue;
|
||||
return {
|
||||
title: parseGeneratedSessionTitle(text),
|
||||
...(message.usage ? {
|
||||
usage: {
|
||||
input: message.usage.input,
|
||||
output: message.usage.output,
|
||||
cacheRead: message.usage.cacheRead,
|
||||
cacheWrite: message.usage.cacheWrite,
|
||||
total: message.usage.totalTokens,
|
||||
},
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
throw new Error("The model did not return a session title");
|
||||
}
|
||||
|
||||
export function sanitizeTitleMessages(messages: AgentMessage[]): AgentMessage[] {
|
||||
const sanitized: AgentMessage[] = [];
|
||||
let expectedToolResultIds: Set<string> | undefined;
|
||||
|
||||
for (let index = 0; index < messages.length; index++) {
|
||||
const message = messages[index];
|
||||
|
||||
if (message.role === "assistant") {
|
||||
const followingToolResultIds = new Set<string>();
|
||||
for (let resultIndex = index + 1; resultIndex < messages.length; resultIndex++) {
|
||||
const resultMessage = messages[resultIndex];
|
||||
if (resultMessage.role !== "toolResult") break;
|
||||
followingToolResultIds.add(resultMessage.toolCallId);
|
||||
}
|
||||
|
||||
expectedToolResultIds = new Set<string>();
|
||||
const content = message.content.filter((block) => {
|
||||
if (block.type !== "toolCall") return true;
|
||||
if (!followingToolResultIds.has(block.id)) return false;
|
||||
expectedToolResultIds!.add(block.id);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (content.length > 0) {
|
||||
sanitized.push({ ...message, content });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === "toolResult") {
|
||||
if (expectedToolResultIds?.delete(message.toolCallId)) {
|
||||
sanitized.push(message);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
expectedToolResultIds = undefined;
|
||||
sanitized.push(message);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export async function generateSessionTitle(source: AgentSession): Promise<GeneratedSessionTitle> {
|
||||
const sourceAgent = source.agent;
|
||||
await sourceAgent.waitForIdle();
|
||||
|
||||
const sanitizedMessages = sanitizeTitleMessages(sourceAgent.state.messages);
|
||||
const historyLength = sanitizedMessages.length;
|
||||
if (!sanitizedMessages.some((message) => message.role === "user")) {
|
||||
throw new Error("The session has no user messages to name");
|
||||
}
|
||||
|
||||
const options = buildSessionTitleAgentOptions(sourceAgent);
|
||||
options.initialState!.messages = sanitizedMessages;
|
||||
const continuesFromTrailingUser = sanitizedMessages.at(-1)?.role === "user";
|
||||
if (continuesFromTrailingUser) {
|
||||
options.initialState!.messages = appendTitleRequestToTrailingUser(sanitizedMessages);
|
||||
}
|
||||
|
||||
const temporaryAgent = new Agent(options);
|
||||
const runPromise = continuesFromTrailingUser
|
||||
? temporaryAgent.continue()
|
||||
: temporaryAgent.prompt(TITLE_PROMPT);
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
runPromise,
|
||||
new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
temporaryAgent.abort();
|
||||
reject(new Error("Session title generation timed out"));
|
||||
}, TITLE_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
} catch (error) {
|
||||
temporaryAgent.abort();
|
||||
await runPromise.catch(() => {});
|
||||
throw error;
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
|
||||
return getAssistantResult(temporaryAgent, historyLength);
|
||||
}
|
||||
Reference in New Issue
Block a user