mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-24 21:41:08 +02:00
feat(x): auto-name chats from the first user message
A tiny background LLM call names new chats in 3-5 words, replacing the truncated-first-message placeholder once it lands. Manual renames always win; failures silently keep the placeholder. Model resolution (getChatTitleModel): chatTitleModel override in models.json, else the curated gateway flash-lite when the assistant default routes through the gateway, else the assistant's own provider - so signed-in users who switched their default to BYOK get titles on that provider instead of a dead gateway call.
This commit is contained in:
parent
033cc6d351
commit
81c07b3a3f
5 changed files with 143 additions and 2 deletions
|
|
@ -44,6 +44,7 @@ Every `llm_usage` emit point in the codebase:
|
|||
| `copilot_chat` | (none) | yes | User chat in renderer (turn runtime; the ALS default when no caller set a use case) | `packages/core/src/runtime/turns/bridges/real-usage-reporter.ts` (`reportModelUsage`); legacy runs (code-mode carve-out) still emit from `packages/core/src/runtime/legacy/engine.ts` (`streamLlm` finish-step) |
|
||||
| `copilot_chat` | `scheduled` | yes | Background scheduled agent runner | `packages/core/src/agent-schedule/runner.ts:167` |
|
||||
| `copilot_chat` | `file_parse` | inherits | `parseFile` builtin tool inside any chat | `packages/core/src/runtime/tools/domains/parsing.ts:179` |
|
||||
| `copilot_chat` | `chat_title` | no | Auto-naming a chat from its first user message (`generateText`) | `packages/core/src/knowledge/generate_title.ts` |
|
||||
| `live_note_agent` | `routing` | no | Pass 1 routing classifier (`generateObject`) | `packages/core/src/knowledge/live-note/routing.ts:93` |
|
||||
| `live_note_agent` | `manual` | yes | Pass 2 agent run — user clicked Run / called the `run-live-note-agent` tool | `packages/core/src/knowledge/live-note/runner.ts:140` (createRun, `subUseCase: trigger`) |
|
||||
| `live_note_agent` | `cron` | yes | Pass 2 agent run — cron expression matched | same call site |
|
||||
|
|
|
|||
57
apps/x/packages/core/src/knowledge/generate_title.ts
Normal file
57
apps/x/packages/core/src/knowledge/generate_title.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { generateText } from 'ai';
|
||||
import { createLanguageModel } from '../models/models.js';
|
||||
import { getChatTitleModel, resolveProviderConfig } from '../models/defaults.js';
|
||||
import { captureLlmUsage } from '../analytics/usage.js';
|
||||
import { withUseCase } from '../analytics/use_case.js';
|
||||
|
||||
const SYSTEM_PROMPT = `You name chat conversations. Given the user's first message, reply with a concise title for the conversation.
|
||||
|
||||
Rules:
|
||||
- 3 to 5 words
|
||||
- Same language as the message
|
||||
- No quotation marks, no ending punctuation, no emoji
|
||||
- Output the title and nothing else`;
|
||||
|
||||
// Long first messages are usually pasted documents or code; the opening is
|
||||
// enough signal for a title and keeps the call cheap.
|
||||
const MAX_INPUT_CHARS = 500;
|
||||
|
||||
const MAX_TITLE_CHARS = 80;
|
||||
|
||||
/**
|
||||
* Generate a short chat title from the first user message. Returns null when
|
||||
* the message is empty or the model produces something unusable — callers
|
||||
* keep the truncated-message placeholder in that case.
|
||||
*/
|
||||
export async function generateChatTitle(firstMessage: string): Promise<string | null> {
|
||||
const text = firstMessage.trim().replace(/\s+/g, ' ');
|
||||
if (!text) return null;
|
||||
|
||||
const { model: modelId, provider: providerName } = await getChatTitleModel();
|
||||
const providerConfig = await resolveProviderConfig(providerName);
|
||||
const model = createLanguageModel(providerConfig, modelId);
|
||||
|
||||
const result = await withUseCase({ useCase: 'copilot_chat', subUseCase: 'chat_title' }, () => generateText({
|
||||
model,
|
||||
system: SYSTEM_PROMPT,
|
||||
prompt: text.slice(0, MAX_INPUT_CHARS),
|
||||
maxOutputTokens: 50,
|
||||
}));
|
||||
|
||||
captureLlmUsage({
|
||||
useCase: 'copilot_chat',
|
||||
subUseCase: 'chat_title',
|
||||
model: modelId,
|
||||
provider: providerName,
|
||||
usage: result.usage,
|
||||
});
|
||||
|
||||
const title = result.text
|
||||
.trim()
|
||||
.replace(/^["'“”‘’]+|["'“”‘’]+$/g, '')
|
||||
.replace(/[.!。]+$/, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (!title || title.length > MAX_TITLE_CHARS) return null;
|
||||
return title;
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ const SIGNED_IN_DEFAULT_PROVIDER = "rowboat";
|
|||
const SIGNED_IN_KG_MODEL = "google/gemini-3.1-flash-lite";
|
||||
const SIGNED_IN_LIVE_NOTE_AGENT_MODEL = "google/gemini-3.1-flash-lite";
|
||||
const SIGNED_IN_AUTO_PERMISSION_DECISION_MODEL = "google/gemini-3.1-flash-lite";
|
||||
const SIGNED_IN_CHAT_TITLE_MODEL = "google/gemini-3.5-flash-lite";
|
||||
|
||||
export type ModelSelection = z.infer<typeof ModelRef>;
|
||||
|
||||
|
|
@ -158,6 +159,36 @@ export async function getMeetingNotesModel(): Promise<ModelSelection> {
|
|||
return getCategoryModel("meetingNotesModel", SIGNED_IN_DEFAULT_MODEL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Model used to auto-name chat sessions from the first user message.
|
||||
*
|
||||
* Deliberately NOT getCategoryModel: that helper routes every signed-in user
|
||||
* to the gateway, even one whose assistant default is a BYOK provider (the
|
||||
* common "gateway limits exhausted, switched to own key" case) — and a title
|
||||
* call against an exhausted gateway fails silently forever. Instead the
|
||||
* curated model applies only when the assistant default itself routes
|
||||
* through the gateway; otherwise titles follow the assistant provider.
|
||||
*/
|
||||
export async function getChatTitleModel(): Promise<ModelSelection> {
|
||||
const signedIn = await isSignedIn();
|
||||
const cfg = await readConfig();
|
||||
const override = cfg?.chatTitleModel;
|
||||
if (override) {
|
||||
if (typeof override === "string") {
|
||||
if (cfg) {
|
||||
return { model: override, provider: cfg.provider.flavor };
|
||||
}
|
||||
} else if (override.provider !== "rowboat" || signedIn) {
|
||||
return { model: override.model, provider: override.provider };
|
||||
}
|
||||
}
|
||||
const dflt = await getDefaultModelAndProvider();
|
||||
if (dflt.provider === SIGNED_IN_DEFAULT_PROVIDER) {
|
||||
return { model: SIGNED_IN_CHAT_TITLE_MODEL, provider: SIGNED_IN_DEFAULT_PROVIDER };
|
||||
}
|
||||
return dflt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model used by the background-task agent + routing classifier. Currently
|
||||
* mirrors `getLiveNoteAgentModel()` — both surfaces want a fast, reliable
|
||||
|
|
|
|||
|
|
@ -232,6 +232,13 @@ export class SessionsImpl implements ISessions {
|
|||
this.publishEntry(
|
||||
sessionIndexEntry(reduceSession([...events, ...batch]), "idle"),
|
||||
);
|
||||
if (!state.title) {
|
||||
this.generateTitleInBackground(
|
||||
sessionId,
|
||||
defaultTitle(input),
|
||||
messageText(input),
|
||||
);
|
||||
}
|
||||
this.startTrackedAdvance(sessionId, turnId);
|
||||
return { turnId };
|
||||
});
|
||||
|
|
@ -319,6 +326,46 @@ export class SessionsImpl implements ISessions {
|
|||
this.startTrackedAdvance(sessionId, state.latestTurnId);
|
||||
}
|
||||
|
||||
// Fire-and-forget: replace the truncated-first-message placeholder with a
|
||||
// short model-generated title. The placeholder stays if the call fails or
|
||||
// the title changed in the meantime (e.g. a manual rename won the race).
|
||||
private generateTitleInBackground(
|
||||
sessionId: string,
|
||||
placeholder: string,
|
||||
firstMessage: string,
|
||||
): void {
|
||||
void (async () => {
|
||||
try {
|
||||
// Dynamic import: generate_title reaches models/defaults →
|
||||
// di/container, which imports this module (see traits.js note
|
||||
// above) — a static import would close the cycle.
|
||||
const { generateChatTitle } = await import(
|
||||
"../../knowledge/generate_title.js"
|
||||
);
|
||||
const title = await generateChatTitle(firstMessage);
|
||||
if (!title || title === placeholder) return;
|
||||
await this.sessionRepo.withLock(sessionId, async () => {
|
||||
const events = await this.sessionRepo.read(sessionId);
|
||||
const state = reduceSession(events);
|
||||
if (state.title !== placeholder) return;
|
||||
const batch: Array<z.infer<typeof SessionEvent>> = [
|
||||
{ type: "title_changed", sessionId, ts: this.clock.now(), title },
|
||||
];
|
||||
await this.sessionRepo.append(sessionId, batch);
|
||||
const existing = this.index.get(sessionId);
|
||||
this.publishEntry(
|
||||
sessionIndexEntry(
|
||||
reduceSession([...events, ...batch]),
|
||||
existing?.latestTurnStatus ?? "none",
|
||||
),
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
// Placeholder title stays.
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
async setTitle(sessionId: string, title: string): Promise<void> {
|
||||
await this.sessionRepo.withLock(sessionId, async () => {
|
||||
const events = await this.sessionRepo.read(sessionId);
|
||||
|
|
@ -541,14 +588,18 @@ function withActiveSkills(
|
|||
};
|
||||
}
|
||||
|
||||
function defaultTitle(input: z.infer<typeof UserMessage>): string {
|
||||
function messageText(input: z.infer<typeof UserMessage>): string {
|
||||
const text =
|
||||
typeof input.content === "string"
|
||||
? input.content
|
||||
: input.content
|
||||
.map((part) => (part.type === "text" ? part.text : ""))
|
||||
.join(" ");
|
||||
const collapsed = text.trim().replace(/\s+/g, " ");
|
||||
return text.trim().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function defaultTitle(input: z.infer<typeof UserMessage>): string {
|
||||
const collapsed = messageText(input);
|
||||
if (collapsed.length === 0) {
|
||||
return "New session";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,4 +74,5 @@ export const LlmModelConfig = z.object({
|
|||
meetingNotesModel: ModelOverride.optional(),
|
||||
liveNoteAgentModel: ModelOverride.optional(),
|
||||
autoPermissionDecisionModel: ModelOverride.optional(),
|
||||
chatTitleModel: ModelOverride.optional(),
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue