blob: c97ef742513dd3727eb93fcec71fd9c8715c97b1 [file]
import { generateText as aiGenerateText, type LanguageModel } from 'ai';
import { normalizeUserSessionName } from '@maka/core';
const MAX_SOURCE_BYTES = 8 * 1024;
const MAX_FALLBACK_CODE_POINTS = 42;
export const SESSION_TITLE_GENERATION_TIMEOUT_MS = 15_000;
export function sessionTitleSource(input: { text: string; displayText?: string }): string {
const raw = input.displayText ?? input.text;
const cleaned = (raw.match(/<user-message>([\s\S]*?)<\/user-message>/i)?.[1] ?? raw)
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, '')
.trim();
const bytes = new TextEncoder().encode(cleaned);
if (bytes.length <= MAX_SOURCE_BYTES) return cleaned;
let end = MAX_SOURCE_BYTES;
const decoder = new TextDecoder('utf-8', { fatal: true });
while (end > 0) {
try {
return decoder.decode(bytes.subarray(0, end));
} catch {
end -= 1;
}
}
return '';
}
export function fallbackSessionTitle(sourceText: string): string | undefined {
const firstLine = sourceText
.split(/\r?\n/)
.find((line) => line.trim())
?.trim();
return firstLine ? Array.from(firstLine).slice(0, MAX_FALLBACK_CODE_POINTS).join('') : undefined;
}
type GenerateText = (options: Record<string, unknown>) => Promise<{
text: string;
finishReason?: string;
}>;
export async function generateSessionTitle(input: {
model: LanguageModel;
sourceText: string;
providerOptions?: unknown;
generateText?: GenerateText;
timeoutMs?: number;
}): Promise<string | undefined> {
if (!input.sourceText.trim()) return undefined;
try {
const generateText: GenerateText =
input.generateText ??
(async (options) => aiGenerateText(options as Parameters<typeof aiGenerateText>[0]));
const abortSignal = AbortSignal.timeout(input.timeoutMs ?? SESSION_TITLE_GENERATION_TIMEOUT_MS);
let onAbort!: () => void;
const timeout = new Promise<never>((_resolve, reject) => {
onAbort = () => reject(abortSignal.reason);
abortSignal.addEventListener('abort', onAbort, { once: true });
});
const result = await Promise.race([
generateText({
model: input.model,
prompt: buildSessionTitlePrompt(input.sourceText),
...(input.providerOptions === undefined ? {} : { providerOptions: input.providerOptions }),
maxOutputTokens: 1024,
abortSignal,
}),
timeout,
]).finally(() => abortSignal.removeEventListener('abort', onAbort));
if (result.finishReason === 'length') return undefined;
return cleanGeneratedSessionTitle(result.text);
} catch {
return undefined;
}
}
export function buildSessionTitlePrompt(sourceText: string): string {
return `Create a descriptive 5–10 word title for the user message below. Use the user's language; for Chinese and similar languages, use an equivalently brief natural title. Output only the title.\n\n${sourceText}`;
}
export function cleanGeneratedSessionTitle(text: string): string | undefined {
const firstLine = text
.replace(/<think>[\s\S]*?(?:<\/think>|$)/gi, '')
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean)
?.replace(/^(?:title|标题)\s*[::]\s*/i, '')
.trim();
if (!firstLine) return undefined;
const pairs: ReadonlyArray<readonly [string, string]> = [
['"', '"'],
["'", "'"],
['`', '`'],
['“', '”'],
['「', '」'],
['『', '』'],
];
const unquoted = pairs.find(
([left, right]) => firstLine.startsWith(left) && firstLine.endsWith(right),
)
? firstLine.slice(1, -1).trim()
: firstLine;
const normalized = normalizeUserSessionName(unquoted);
return normalized.ok ? normalized.value : undefined;
}