mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-24 21:41:08 +02:00
Merge pull request #786 from rowboatlabs/feat/chat-auto-titles
Auto-name chats from the first user message
This commit is contained in:
commit
f0e8e51a0e
5 changed files with 158 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` | (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` | `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` | `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` | `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` | `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 |
|
| `live_note_agent` | `cron` | yes | Pass 2 agent run — cron expression matched | same call site |
|
||||||
|
|
|
||||||
70
apps/x/packages/core/src/knowledge/generate_title.ts
Normal file
70
apps/x/packages/core/src/knowledge/generate_title.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { generateText } from 'ai';
|
||||||
|
import { createLanguageModel } from '../models/models.js';
|
||||||
|
import { getChatTitleModel, resolveProviderConfig } from '../models/defaults.js';
|
||||||
|
import { mapReasoningEffort } from '../models/reasoning.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:
|
||||||
|
- At least 2 words, at most 5 — never a single word
|
||||||
|
- Same language as the message
|
||||||
|
- No quotation marks, no ending punctuation, no emoji
|
||||||
|
- Output the title and nothing else
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- "who are the investors in supabase" -> Supabase investor list
|
||||||
|
- "help me write a python script that renames all files in a folder" -> Python file renaming script
|
||||||
|
- "why is my docker build so slow" -> Slow Docker build debugging
|
||||||
|
- "draft an email to my landlord about the broken heater" -> Broken heater landlord email`;
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// Reasoning models (e.g. gateway gemini-3.5-flash) think by default and
|
||||||
|
// can starve a tight output cap — dial thinking to low and leave the cap
|
||||||
|
// roomy; the title itself is a handful of tokens either way.
|
||||||
|
const reasoning = mapReasoningEffort(providerConfig.flavor, modelId, 'low', undefined);
|
||||||
|
const result = await withUseCase({ useCase: 'copilot_chat', subUseCase: 'chat_title' }, () => generateText({
|
||||||
|
model,
|
||||||
|
system: SYSTEM_PROMPT,
|
||||||
|
prompt: text.slice(0, MAX_INPUT_CHARS),
|
||||||
|
maxOutputTokens: 1000,
|
||||||
|
...(reasoning?.providerOptions ? { providerOptions: reasoning.providerOptions } : {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
captureLlmUsage({
|
||||||
|
useCase: 'copilot_chat',
|
||||||
|
subUseCase: 'chat_title',
|
||||||
|
model: modelId,
|
||||||
|
provider: providerName,
|
||||||
|
usage: result.usage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const title = (result.text.trim().split(/\r?\n/)[0] ?? '')
|
||||||
|
.trim()
|
||||||
|
.replace(/^["'“”‘’]+|["'“”‘’]+$/g, '')
|
||||||
|
.replace(/[.!。]+$/, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
if (!title || title.length > MAX_TITLE_CHARS) return null;
|
||||||
|
if (!title.includes(' ')) return null;
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,9 @@ const SIGNED_IN_DEFAULT_PROVIDER = "rowboat";
|
||||||
const SIGNED_IN_KG_MODEL = "google/gemini-3.1-flash-lite";
|
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_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_AUTO_PERMISSION_DECISION_MODEL = "google/gemini-3.1-flash-lite";
|
||||||
|
// Must be on the gateway's server-side allowlist or title calls 403
|
||||||
|
// "Model not allowed" (and silently keep the placeholder title).
|
||||||
|
const SIGNED_IN_CHAT_TITLE_MODEL = "google/gemini-3.5-flash-lite";
|
||||||
|
|
||||||
export type ModelSelection = z.infer<typeof ModelRef>;
|
export type ModelSelection = z.infer<typeof ModelRef>;
|
||||||
|
|
||||||
|
|
@ -158,6 +161,36 @@ export async function getMeetingNotesModel(): Promise<ModelSelection> {
|
||||||
return getCategoryModel("meetingNotesModel", SIGNED_IN_DEFAULT_MODEL);
|
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
|
* Model used by the background-task agent + routing classifier. Currently
|
||||||
* mirrors `getLiveNoteAgentModel()` — both surfaces want a fast, reliable
|
* mirrors `getLiveNoteAgentModel()` — both surfaces want a fast, reliable
|
||||||
|
|
|
||||||
|
|
@ -232,6 +232,13 @@ export class SessionsImpl implements ISessions {
|
||||||
this.publishEntry(
|
this.publishEntry(
|
||||||
sessionIndexEntry(reduceSession([...events, ...batch]), "idle"),
|
sessionIndexEntry(reduceSession([...events, ...batch]), "idle"),
|
||||||
);
|
);
|
||||||
|
if (!state.title) {
|
||||||
|
this.generateTitleInBackground(
|
||||||
|
sessionId,
|
||||||
|
defaultTitle(input),
|
||||||
|
messageText(input),
|
||||||
|
);
|
||||||
|
}
|
||||||
this.startTrackedAdvance(sessionId, turnId);
|
this.startTrackedAdvance(sessionId, turnId);
|
||||||
return { turnId };
|
return { turnId };
|
||||||
});
|
});
|
||||||
|
|
@ -319,6 +326,46 @@ export class SessionsImpl implements ISessions {
|
||||||
this.startTrackedAdvance(sessionId, state.latestTurnId);
|
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> {
|
async setTitle(sessionId: string, title: string): Promise<void> {
|
||||||
await this.sessionRepo.withLock(sessionId, async () => {
|
await this.sessionRepo.withLock(sessionId, async () => {
|
||||||
const events = await this.sessionRepo.read(sessionId);
|
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 =
|
const text =
|
||||||
typeof input.content === "string"
|
typeof input.content === "string"
|
||||||
? input.content
|
? input.content
|
||||||
: input.content
|
: input.content
|
||||||
.map((part) => (part.type === "text" ? part.text : ""))
|
.map((part) => (part.type === "text" ? part.text : ""))
|
||||||
.join(" ");
|
.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) {
|
if (collapsed.length === 0) {
|
||||||
return "New session";
|
return "New session";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,4 +74,5 @@ export const LlmModelConfig = z.object({
|
||||||
meetingNotesModel: ModelOverride.optional(),
|
meetingNotesModel: ModelOverride.optional(),
|
||||||
liveNoteAgentModel: ModelOverride.optional(),
|
liveNoteAgentModel: ModelOverride.optional(),
|
||||||
autoPermissionDecisionModel: ModelOverride.optional(),
|
autoPermissionDecisionModel: ModelOverride.optional(),
|
||||||
|
chatTitleModel: ModelOverride.optional(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue