From ae183715a5853c5f9c77be678136c5c6fcd53110 Mon Sep 17 00:00:00 2001 From: shancw Date: Sat, 17 Oct 2026 23:16:58 +0800 Subject: [PATCH] feat(chat): choose the model that names a session Generate title can now name a session with a model of its own: a right-click on the button, or a press and hold on a phone, opens a menu of the enabled models with the levels each supports. The choice is one for the whole pi-web instance (pi-web-title-model.json beside pi's own files, GET/PUT /api/title-model), with the session's own model at its cheapest level when nothing is chosen, and a thinking level stored on its own still applies to the session's model. A model row names the session right away; a level row keeps the menu open, so both are set in one visit. --- app/api/sessions/[id]/auto-name/route.ts | 36 ++++- app/api/title-model/route.test.mjs | 80 ++++++++++ app/api/title-model/route.ts | 54 +++++++ components/AppShell.tsx | 180 +++++++++++++++++++++++ docs/agents/sessions.md | 1 + lib/i18n/messages/en.ts | 7 + lib/i18n/messages/zh-CN.ts | 7 + lib/i18n/messages/zh-TW.ts | 7 + lib/session-title.test.mjs | 40 +++++ lib/session-title.ts | 35 +++-- lib/title-menu.test.mjs | 93 ++++++++++++ lib/title-menu.ts | 111 ++++++++++++++ lib/title-model-preference.test.mjs | 67 +++++++++ lib/title-model-preference.ts | 80 ++++++++++ 14 files changed, 786 insertions(+), 12 deletions(-) create mode 100644 app/api/title-model/route.test.mjs create mode 100644 app/api/title-model/route.ts create mode 100644 lib/title-menu.test.mjs create mode 100644 lib/title-menu.ts create mode 100644 lib/title-model-preference.test.mjs create mode 100644 lib/title-model-preference.ts diff --git a/app/api/sessions/[id]/auto-name/route.ts b/app/api/sessions/[id]/auto-name/route.ts index 84b14adaf0..6b9ccf9a1b 100644 --- a/app/api/sessions/[id]/auto-name/route.ts +++ b/app/api/sessions/[id]/auto-name/route.ts @@ -1,6 +1,8 @@ import { NextResponse } from "next/server"; import type { AgentSession } from "@earendil-works/pi-coding-agent"; -import { generateSessionTitle } from "@/lib/session-title"; +import type { Api, Model } from "@earendil-works/pi-ai"; +import { generateSessionTitle, type TitleModelOverride } from "@/lib/session-title"; +import { readTitleModelPreference } from "@/lib/title-model-preference"; import { getRpcSession, startRpcSession } from "@/lib/rpc-manager"; import { invalidateSessionListCache, resolveSessionPath } from "@/lib/session-reader"; @@ -24,7 +26,37 @@ export async function POST( // globalThis keeps wrappers alive across dev hot reloads; older instances // may predate waitUntilReady(), but those have already completed startup. await session.waitUntilReady?.(); - const result = await generateSessionTitle(session.inner as unknown as AgentSession); + + // The instance's own choice, made from the Generate title button's menu (see + // lib/title-model-preference.ts); the session's own model and its cheapest + // thinking level when there is none. + const preference = readTitleModelPreference(); + let override: TitleModelOverride | undefined; + if (preference?.provider && preference.modelId) { + const runtime = session.inner.modelRuntime; + let model = runtime.getModel(preference.provider, preference.modelId); + if (!model) { + // A provider an extension registers at session_start, or a models.json + // edited since the choice was made: reload once before giving up on it. + await runtime.refresh({ allowNetwork: false }); + model = runtime.getModel(preference.provider, preference.modelId); + } + if (model) { + override = { + model: model as unknown as Model, + ...(preference.thinkingLevel ? { thinkingLevel: preference.thinkingLevel } : {}), + }; + } else { + // A stale choice, not a broken title: name the session anyway, keeping a + // level the user asked for. + console.warn(`[pi-web] the title model ${preference.provider}/${preference.modelId} is not available; naming with the session's own model`); + if (preference.thinkingLevel) override = { thinkingLevel: preference.thinkingLevel }; + } + } else if (preference?.thinkingLevel) { + override = { thinkingLevel: preference.thinkingLevel }; + } + + const result = await generateSessionTitle(session.inner as unknown as AgentSession, override); if (!session.isAlive()) { return NextResponse.json( diff --git a/app/api/title-model/route.test.mjs b/app/api/title-model/route.test.mjs new file mode 100644 index 0000000000..700e5b002b --- /dev/null +++ b/app/api/title-model/route.test.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { createJiti } from "jiti"; + +const agentDir = await mkdtemp(path.join(os.tmpdir(), "pi-web-title-model-route-")); +const previousAgentDir = process.env.PI_CODING_AGENT_DIR; +process.env.PI_CODING_AGENT_DIR = agentDir; +test.after(async () => { + if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = previousAgentDir; + await rm(agentDir, { recursive: true, force: true }); +}); + +const jiti = createJiti(import.meta.url, { + alias: { "@": process.cwd() }, + interopDefault: true, + moduleCache: false, +}); +const { GET, PUT } = await jiti.import("./route.ts"); + +const preferencePath = path.join(agentDir, "pi-web-title-model.json"); + +function put(body, headers = { host: "localhost", "Content-Type": "application/json" }) { + return PUT(new Request("http://localhost/api/title-model", { + method: "PUT", + headers, + body: typeof body === "string" ? body : JSON.stringify(body), + })); +} + +test("keeps the instance's title model until a choice replaces it whole", async () => { + assert.deepEqual(await (await GET()).json(), { titleModel: null }); + + const model = await put({ titleModel: { provider: "cheap", modelId: "small", thinkingLevel: "high" } }); + assert.equal(model.status, 200); + assert.deepEqual(await model.json(), { titleModel: { provider: "cheap", modelId: "small", thinkingLevel: "high" } }); + assert.deepEqual(JSON.parse(await readFile(preferencePath, "utf8")), { + provider: "cheap", + modelId: "small", + thinkingLevel: "high", + }); + assert.deepEqual(await (await GET()).json(), { + titleModel: { provider: "cheap", modelId: "small", thinkingLevel: "high" }, + }); + + // A level without a model names the session's own: the old model choice goes. + const level = await put({ titleModel: { thinkingLevel: "medium" } }); + assert.deepEqual(await level.json(), { titleModel: { thinkingLevel: "medium" } }); + + // Nothing to prefer is no file at all: naming follows the session's own model. + const cleared = await put({ titleModel: null }); + assert.deepEqual(await cleared.json(), { titleModel: null }); + assert.equal(existsSync(preferencePath), false); + assert.deepEqual(await (await GET()).json(), { titleModel: null }); +}); + +test("drops a field that is not one, and refuses a body that is no choice at all", async () => { + await writeFile(preferencePath, JSON.stringify({ provider: "cheap", modelId: "small" })); + + // A field that is not a usable string is dropped; the rest of the choice stands. + assert.deepEqual(await (await put({ titleModel: { provider: 5, thinkingLevel: "max" } })).json(), { + titleModel: { thinkingLevel: "max" }, + }); + + assert.equal((await put({ titleModel: "cheap/small" })).status, 400); + assert.equal((await put({ titleModel: ["cheap"] })).status, 400); + // No `titleModel` key at all must not clear a stored choice. + assert.equal((await put({})).status, 400); + assert.equal((await put("{")).status, 400); + assert.equal((await put({ titleModel: { provider: "cheap" } }, { host: "localhost", "Content-Type": "text/plain" })).status, 415); + assert.equal((await put( + { titleModel: { provider: "cheap" } }, + { host: "localhost", origin: "https://evil.example", "Content-Type": "application/json" }, + )).status, 403); + assert.deepEqual(await (await GET()).json(), { titleModel: { thinkingLevel: "max" } }); +}); diff --git a/app/api/title-model/route.ts b/app/api/title-model/route.ts new file mode 100644 index 0000000000..bb4af832fb --- /dev/null +++ b/app/api/title-model/route.ts @@ -0,0 +1,54 @@ +import { NextResponse } from "next/server"; +import { hasJsonContentType, isApiRequestAllowed } from "@/lib/request-security"; +import { + parseTitleModelPreference, + readTitleModelPreference, + writeTitleModelPreference, +} from "@/lib/title-model-preference"; + +export const dynamic = "force-dynamic"; + +// Which model the Generate title button names a session with: one choice for +// this pi-web instance, so every browser and device names sessions alike, and +// empty until the button's menu sets one. The rules of that value, its file and +// what an empty one means live in lib/title-model-preference.ts. + +function errorResponse(error: unknown) { + return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 }); +} + +export async function GET() { + try { + return NextResponse.json({ titleModel: readTitleModelPreference() }); + } catch (error) { + return errorResponse(error); + } +} + +export async function PUT(req: Request) { + if (!isApiRequestAllowed(req)) { + return NextResponse.json({ error: "Untrusted API request" }, { status: 403 }); + } + if (!hasJsonContentType(req)) { + return NextResponse.json({ error: "Content-Type must be application/json" }, { status: 415 }); + } + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + // null clears the choice; a body that is neither null nor an object is a + // client mistake, and clearing the choice over it would lose it silently. + const requested = (body as { titleModel?: unknown } | null)?.titleModel; + if (requested !== null && (typeof requested !== "object" || Array.isArray(requested))) { + return NextResponse.json({ error: "titleModel must be an object or null" }, { status: 400 }); + } + try { + const titleModel = parseTitleModelPreference(requested); + writeTitleModelPreference(titleModel); + return NextResponse.json({ titleModel }); + } catch (error) { + return errorResponse(error); + } +} diff --git a/components/AppShell.tsx b/components/AppShell.tsx index e02bdd8240..d60029ad36 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -4,6 +4,7 @@ import { useState, useCallback, useRef, useEffect, useLayoutEffect, useMemo } fr import { useRouter, useSearchParams } from "next/navigation"; import { useGlobalKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts"; import { SessionSidebar, type SelectSessionOptions, type SessionSidebarControl } from "./SessionSidebar"; +import { SidebarMenu, type SidebarMenuAnchor, type SidebarMenuSelectEvent, type SidebarMenuItem } from "./SidebarMenu"; import { ChatWindow } from "./ChatWindow"; import { NewSessionContextBar, type NewSessionContextControl } from "./NewSessionContextBar"; import type { ChatScrollPosition } from "@/lib/chat-scroll-position"; @@ -28,6 +29,8 @@ import { useResizablePanel } from "@/hooks/useResizablePanel"; import { useAudio } from "@/hooks/useAudio"; import { copyText } from "@/lib/clipboard"; import { sendAgentCommand } from "@/lib/agent-client"; +import { preferenceWithLevel, preferenceWithModel, titleMenuEntries, type TitleMenuModel } from "@/lib/title-menu"; +import type { TitleModelPreference } from "@/lib/title-model-preference"; import { getFileName } from "@/lib/file-paths"; import { buildAtMentionText, buildFileAtMentionsText, buildFileLineMentionText } from "@/lib/file-fuzzy"; import { @@ -88,6 +91,19 @@ const AGENT_PANEL_WIDTH = 420; // pi's built-in tools that never change a file; any other tool may (#1144). const READ_ONLY_TOOL_NAMES = new Set(PRESET_READ_ONLY); const TOOL_END_REFRESH_MS = 1000; +// How long a press on the Generate title button (a phone's right-click) waits +// before its model menu opens. +const TITLE_MENU_LONG_PRESS_MS = 500; +// A level's name, as the composer's own model control shows it. +const TITLE_THINKING_LEVEL_KEYS: Record = { + off: "chat.thinkingOff", + minimal: "chat.thinkingMinimal", + low: "chat.thinkingLow", + medium: "chat.thinkingMedium", + high: "chat.thinkingHigh", + xhigh: "chat.thinkingXhigh", + max: "chat.thinkingMax", +}; function parkedNewSessionDraftKey(cwd: string): string { return `parked-new:${cwd}`; @@ -355,6 +371,15 @@ export function AppShell() { const [sessionStats, setSessionStats] = useState(null); const [autoNameStatus, setAutoNameStatus] = useState({ kind: "idle" }); const autoNameTimerRef = useRef | null>(null); + // The Generate title button's model menu: the instance's title model, the list + // to pick one from, and where the menu hangs (see lib/title-model-preference.ts). + const [titleMenu, setTitleMenu] = useState<{ anchor: SidebarMenuAnchor; opener: HTMLElement | null } | null>(null); + const [titleModel, setTitleModel] = useState(null); + const [titleMenuModels, setTitleMenuModels] = useState(null); + const [titleMenuError, setTitleMenuError] = useState(null); + // The press that opens that menu on a phone: `fired` tells the click that + // follows the release that the menu, not the button, was asked for. + const titlePressRef = useRef<{ timer: ReturnType; fired: boolean } | null>(null); const activeSessionIdRef = useRef(selectedSession?.id ?? null); activeSessionIdRef.current = selectedSession?.id ?? null; const handleSessionStatsChange = useCallback((stats: SessionStatsInfo | null) => { @@ -1097,6 +1122,116 @@ export function AppShell() { setAutoNameStatus({ kind: "idle" }); }, [selectedSession?.id]); + /** Stores the instance's title model; false when the write failed, already reported. */ + const saveTitleModel = useCallback(async (next: TitleModelPreference | null): Promise => { + setTitleModel(next); + try { + const response = await fetch("/api/title-model", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ titleModel: next }), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return true; + } catch (error) { + // The button's own status line is where this feature reports failure. + if (autoNameTimerRef.current) clearTimeout(autoNameTimerRef.current); + setAutoNameStatus({ kind: "error", message: error instanceof Error ? error.message : String(error) }); + autoNameTimerRef.current = setTimeout(() => setAutoNameStatus({ kind: "idle" }), 5000); + return false; + } + }, []); + + /** A model row: store the choice, then name the session with it. */ + const applyTitleModel = useCallback(async (next: TitleModelPreference | null) => { + if (await saveTitleModel(next)) void handleAutoName(); + }, [handleAutoName, saveTitleModel]); + + const openTitleMenu = useCallback((anchor: SidebarMenuAnchor, opener: HTMLElement | null) => { + setTitleMenu({ anchor, opener }); + setTitleMenuModels(null); + setTitleMenuError(null); + // Read both on every open: another browser may have changed the choice since + // the last menu, and a model enabled since then belongs in the list. + const cwd = selectedSession?.cwd; + if (!cwd) return; + void (async () => { + try { + const [preferenceResponse, modelsResponse] = await Promise.all([ + fetch("/api/title-model"), + fetch(`/api/models?cwd=${encodeURIComponent(cwd)}`), + ]); + if (!preferenceResponse.ok) throw new Error(`HTTP ${preferenceResponse.status}`); + if (!modelsResponse.ok) throw new Error(`HTTP ${modelsResponse.status}`); + const preference = await preferenceResponse.json() as { titleModel?: TitleModelPreference | null }; + const models = await modelsResponse.json() as { + modelList?: { id: string; name: string; provider: string }[]; + thinkingLevels?: Record; + }; + setTitleModel(preference.titleModel ?? null); + setTitleMenuModels((models.modelList ?? []).map((model) => ({ + provider: model.provider, + modelId: model.id, + name: model.name, + levels: models.thinkingLevels?.[`${model.provider}:${model.id}`] ?? [], + }))); + } catch (error) { + // The list stays unread (null): the menu says so rather than showing none. + setTitleMenuError(error instanceof Error ? error.message : String(error)); + } + })(); + }, [selectedSession?.cwd]); + + const endTitlePress = useCallback(() => { + if (titlePressRef.current) clearTimeout(titlePressRef.current.timer); + }, []); + + const titleMenuItems = useMemo(() => { + const naming = autoNameStatus.kind === "naming"; + return titleMenuEntries({ preference: titleModel, models: titleMenuModels, failed: titleMenuError !== null }) + .map((entry): SidebarMenuItem => { + if (entry.kind === "separator") return { type: "separator", id: "title-menu-separator" }; + if (entry.kind === "header") { + const labels = { model: "title.modelMenu", level: "title.thinkingLevel", unavailable: "title.modelsUnavailable" }; + return { type: "header", id: `title-menu-${entry.id}`, label: translate(labels[entry.id]) }; + } + if (entry.kind === "session-model") { + return { + type: "item", + id: "title-model-session", + label: translate("title.followSessionModel"), + checked: entry.checked, + disabled: naming, + onSelect: () => void applyTitleModel(preferenceWithModel(titleModel, null)), + }; + } + if (entry.kind === "model") { + return { + type: "item", + id: `title-model:${entry.provider}:${entry.modelId}`, + label: entry.name, + note: entry.provider, + title: `${entry.provider}/${entry.modelId}`, + checked: entry.checked, + disabled: naming, + onSelect: () => void applyTitleModel(preferenceWithModel(titleModel, entry)), + }; + } + // A level is stored without naming the session: the menu stays open, so a + // model and its level are picked in one visit and the model row generates. + return { + type: "item", + id: `title-thinking:${entry.level ?? "auto"}`, + label: entry.level ? translate(TITLE_THINKING_LEVEL_KEYS[entry.level] ?? entry.level) : translate("title.thinkingAuto"), + checked: entry.checked, + onSelect: (event: SidebarMenuSelectEvent) => { + event.keepOpen(); + void saveTitleModel(preferenceWithLevel(titleModel, entry.level)); + }, + }; + }); + }, [applyTitleModel, autoNameStatus.kind, saveTitleModel, titleMenuError, titleMenuModels, titleModel, translate]); + const handleExplorerRefresh = useCallback(() => { setExplorerRefreshKey((k) => k + 1); }, []); @@ -1648,9 +1783,40 @@ export function AppShell() {