Server:
- projects.status + projects.gitea_remote (additive) with CHECK ('open','archived')
- GET /api/projects?status=archived; PATCH /api/projects/:id (rename);
POST /api/projects/:id/archive | unarchive; POST /api/projects/create
- POST /api/projects ON CONFLICT (path) DO UPDATE SET status='open': re-add
of archived path restores existing row (preserves id + FKs); already-open
path returns 409. Detected-repos picker now excludes only status='open'.
- New gitea.ts (createGiteaRepo + GiteaRepoExistsError) and
project_bootstrap.ts (sanitize name, mkdir under PROJECT_ROOT_WHITELIST,
git init -b main + first commit with -c user.name/email per-command, optional
Gitea repo create + remote add + push; all via execFile, no shell).
- 3 new user-stream frames: project_archived, project_unarchived, project_updated.
- sidebar.ts now selects path + gitea_remote and filters status='open'.
- Gitea env added to config.ts (GITEA_BASE_URL, GITEA_USER, GITEA_TOKEN,
GITEA_SSH_HOST).
- docker-compose.yml /opt mount flipped to rw so create-project can mkdir.
- auto_name.ts gate relaxed from `!== 1` to `< 1` (fires on every turn while
chat name is empty, not only the first).
Web:
- ProjectSidebar: project rows use proper Radix ContextMenu; items Rename /
Archive / Open in Gitea. Inline rename, archive confirm dialog.
Removed obsolete handleRemove + DropdownMenu hack.
- Home: Add-existing + Create-new buttons; collapsible Archived Projects
section with Restore.
- New CreateProjectModal: name + live folder preview, commit msg, Private/
Public radio, create-Gitea-remote checkbox, toast on success/warnings.
- New projectUrls.ts giteaUrlFor() — uses gitea_remote when present,
falls back to convention URL.
- 3 new event types in sessionEvents.ts with idempotent useSidebar handlers.
- SidebarProject extended with path + gitea_remote so Open-in-Gitea can
resolve without a separate fetch.
148 lines
4.2 KiB
TypeScript
148 lines
4.2 KiB
TypeScript
import type { InferenceContext } from './inference.js';
|
||
|
||
const NAMING_SYSTEM_PROMPT =
|
||
'You name chat sessions. Reply directly with no thinking, reasoning, or explanation. Output ONLY the title, 4 words max, no quotes, no punctuation, no prefix like "Title:".';
|
||
|
||
const MAX_TITLE_CHARS = 60;
|
||
|
||
function cleanTitle(raw: string): string {
|
||
let name = raw.trim();
|
||
const quotes = ['"', "'", '`', '‘', '’', '“', '”'];
|
||
while (name.length >= 2 && quotes.includes(name[0]!) && quotes.includes(name[name.length - 1]!)) {
|
||
name = name.slice(1, -1).trim();
|
||
}
|
||
name = name.replace(/^title\s*:\s*/i, '').trim();
|
||
if (name.length > MAX_TITLE_CHARS) {
|
||
name = name.slice(0, MAX_TITLE_CHARS).trim();
|
||
}
|
||
return name;
|
||
}
|
||
|
||
interface NamingResponse {
|
||
choices?: Array<{
|
||
message?: {
|
||
content?: string;
|
||
reasoning_content?: string;
|
||
};
|
||
}>;
|
||
}
|
||
|
||
function pickTitleSource(data: NamingResponse): string {
|
||
const choice = data.choices?.[0]?.message;
|
||
if (!choice) return '';
|
||
if (choice.content && choice.content.trim().length > 0) return choice.content;
|
||
const reasoning = choice.reasoning_content ?? '';
|
||
if (reasoning.length === 0) return '';
|
||
const lines = reasoning
|
||
.split('\n')
|
||
.map((l) => l.trim())
|
||
.filter((l) => l.length > 0);
|
||
return lines[lines.length - 1] ?? '';
|
||
}
|
||
|
||
export async function maybeAutoNameChat(
|
||
ctx: InferenceContext,
|
||
chatId: string,
|
||
sessionId: string
|
||
): Promise<void> {
|
||
const counts = await ctx.sql<{ n: number }[]>`
|
||
SELECT COUNT(*)::int AS n
|
||
FROM messages
|
||
WHERE chat_id = ${chatId}
|
||
AND role = 'assistant'
|
||
AND status = 'complete'
|
||
AND content <> ''
|
||
`;
|
||
if ((counts[0]?.n ?? 0) < 1) return;
|
||
|
||
const chatRows = await ctx.sql<
|
||
{ id: string; name: string | null; session_id: string }[]
|
||
>`
|
||
SELECT id, name, session_id FROM chats WHERE id = ${chatId}
|
||
`;
|
||
const chat = chatRows[0];
|
||
if (!chat) return;
|
||
if (chat.name !== null && chat.name !== '') return;
|
||
|
||
const sessionRows = await ctx.sql<{ model: string }[]>`
|
||
SELECT model FROM sessions WHERE id = ${sessionId}
|
||
`;
|
||
const model = sessionRows[0]?.model;
|
||
if (!model) return;
|
||
|
||
const userMsg = await ctx.sql<{ content: string }[]>`
|
||
SELECT content FROM messages
|
||
WHERE chat_id = ${chatId} AND role = 'user'
|
||
ORDER BY created_at ASC
|
||
LIMIT 1
|
||
`;
|
||
const assistantMsg = await ctx.sql<{ content: string }[]>`
|
||
SELECT content FROM messages
|
||
WHERE chat_id = ${chatId}
|
||
AND role = 'assistant'
|
||
AND status = 'complete'
|
||
AND content <> ''
|
||
ORDER BY created_at ASC
|
||
LIMIT 1
|
||
`;
|
||
if (!userMsg[0] || !assistantMsg[0]) return;
|
||
|
||
const userText = userMsg[0].content.slice(0, 2000);
|
||
const assistantText = assistantMsg[0].content.slice(0, 2000);
|
||
|
||
const body = {
|
||
model,
|
||
messages: [
|
||
{ role: 'system', content: NAMING_SYSTEM_PROMPT },
|
||
{
|
||
role: 'user',
|
||
content: `First user message: ${userText}\nFirst assistant reply: ${assistantText}`,
|
||
},
|
||
],
|
||
max_tokens: 30,
|
||
temperature: 0.3,
|
||
stream: false,
|
||
chat_template_kwargs: { enable_thinking: false },
|
||
};
|
||
|
||
const res = await fetch(`${ctx.config.LLAMA_SWAP_URL}/v1/chat/completions`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
});
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => '');
|
||
throw new Error(`naming request failed: ${res.status} ${text.slice(0, 200)}`);
|
||
}
|
||
const data = (await res.json()) as NamingResponse;
|
||
const raw = pickTitleSource(data);
|
||
const name = cleanTitle(raw);
|
||
if (!name) {
|
||
ctx.log.warn({ chatId, raw }, 'auto-name: empty title from model');
|
||
return;
|
||
}
|
||
|
||
const updated = await ctx.sql<{ id: string; name: string; session_id: string; updated_at: string }[]>`
|
||
UPDATE chats
|
||
SET name = ${name}, updated_at = clock_timestamp()
|
||
WHERE id = ${chatId}
|
||
AND (name IS NULL OR name = '')
|
||
RETURNING id, name, session_id, updated_at
|
||
`;
|
||
if (updated.length === 0) return;
|
||
|
||
ctx.publish(sessionId, {
|
||
type: 'chat_renamed',
|
||
chat_id: chatId,
|
||
name,
|
||
});
|
||
ctx.publishUser({
|
||
type: 'chat_updated',
|
||
chat_id: chatId,
|
||
session_id: sessionId,
|
||
name,
|
||
updated_at: updated[0]!.updated_at,
|
||
});
|
||
ctx.log.info({ chatId, name }, 'chat auto-named');
|
||
}
|