v1.1 batch 1: markdown, message actions, tok/s+ctx, AI naming
Four features land together on this branch:
1. Markdown rendering — assistant messages go through react-markdown +
remark-gfm. Fenced code blocks render via existing CodeBlock (with copy
button); inline `code` is styled inline. User messages stay plain text.
No raw HTML (no rehype-raw).
2. Per-message Copy + Regenerate. New endpoint
POST /api/sessions/:id/messages/:message_id/regenerate validates the
target (404/400/409), atomically deletes the target plus any later
messages in the session, inserts a fresh streaming assistant row, and
enqueues a normal inference run. The DELETE bound uses a SQL subquery
(`created_at >= (SELECT created_at FROM messages WHERE id = $1)`)
instead of a JS round-trip so postgres TIMESTAMPTZ µs precision is
preserved — otherwise sub-ms clock_timestamp() differences between the
user row and the assistant row collapsed to the same JS Date, pulling
the triggering user message into the >= bound. New `messages_deleted`
WS frame so already-connected clients prune the stale tail without
needing a full snapshot resend.
3. tok/s + ctx counter. Five new nullable message columns: tokens_used,
ctx_used, ctx_max, started_at, finished_at. started_at is set right
before the OpenAI call in services/inference.ts (not in the route, not
in the frame handler); finished_at + tokens_used + ctx_used + ctx_max
are committed in the same UPDATE that flips status to 'complete'. The
inference request now opts into stream_options.include_usage so the
final chunk carries usage; defensive parsing also picks up timings.n_ctx
when llama.cpp emits it (currently absent for our llama-swap models, so
ctx_max stays NULL and the UI just shows `<used> ctx`). message_complete
frame extended with tokens_used / ctx_used / ctx_max / started_at /
finished_at / model. Frontend StatsLine in MessageBubble computes tok/s
client-side from the timestamps and renders muted mono text below the
body of completed assistant messages.
4. AI chat naming after the first turn. Backend services/auto_name.ts
runs via setImmediate after the top-level inference resolves; it
checks that there is exactly one completed assistant message and that
the session has not been user-renamed (`name IS NULL OR name = '' OR
name = 'New session'`), then fires a single non-streaming chat
completion with the spec prompt. Qwen3 chat templates emit chain-of-
thought into reasoning_content and burn the entire max_tokens budget
without producing visible output, so the request includes
`chat_template_kwargs: { enable_thinking: false }` and max_tokens=30.
Title is trimmed, quote-stripped, "Title:" prefix dropped, and
truncated to 60 chars before a guarded UPDATE on sessions.name. New
`session_renamed` WS frame propagates to the open session view
directly and to the project's session list via a tiny module-scope
event bus (apps/web/src/hooks/sessionEvents.ts) — kept dumb: one event
type, two methods, no library.
Cleanups: dropped the now-unused splitCodeBlocks export from CodeBlock.tsx
(react-markdown supersedes it), and added a long-form NOTE in auto_name.ts
documenting the enable_thinking + max_tokens pattern for any future Qwen-
family non-streaming utility calls (planned: fork-message, agent-routing,
web-search summarization).
Schema bootstrap remains idempotent (ADD COLUMN IF NOT EXISTS). Auth,
broker, clock_timestamp() conventions, and zod validation all unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
32
apps/web/src/hooks/sessionEvents.ts
Normal file
32
apps/web/src/hooks/sessionEvents.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// Tiny in-app event bus for session metadata changes that need to propagate
|
||||
// across hooks (e.g. AI rename arriving via WS in the session view needs to
|
||||
// also refresh the sidebar's session list). One event type for now.
|
||||
|
||||
export interface SessionRenamedEvent {
|
||||
type: 'session_renamed';
|
||||
session_id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
type SessionEvent = SessionRenamedEvent;
|
||||
type Listener = (event: SessionEvent) => void;
|
||||
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
export const sessionEvents = {
|
||||
emit(event: SessionEvent) {
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch {
|
||||
// swallow — one bad listener shouldn't break others
|
||||
}
|
||||
}
|
||||
},
|
||||
subscribe(listener: Listener): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { Message, WsFrame } from '@/api/types';
|
||||
import { sessionEvents } from './sessionEvents';
|
||||
|
||||
interface State {
|
||||
messages: Message[];
|
||||
@@ -24,6 +25,11 @@ function applyFrame(state: State, frame: WsFrame): State {
|
||||
tool_results: null,
|
||||
status: 'streaming',
|
||||
last_seq: 0,
|
||||
tokens_used: null,
|
||||
ctx_used: null,
|
||||
ctx_max: null,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
return { ...state, messages: [...state.messages, newMsg] };
|
||||
@@ -76,16 +82,47 @@ function applyFrame(state: State, frame: WsFrame): State {
|
||||
},
|
||||
status: 'complete',
|
||||
last_seq: 0,
|
||||
tokens_used: null,
|
||||
ctx_used: null,
|
||||
ctx_max: null,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
return { ...state, messages: [...state.messages, newMsg] };
|
||||
}
|
||||
case 'message_complete': {
|
||||
const next = state.messages.map((m) =>
|
||||
m.id === frame.message_id ? { ...m, status: 'complete' as const } : m
|
||||
m.id === frame.message_id
|
||||
? {
|
||||
...m,
|
||||
status: 'complete' as const,
|
||||
...(frame.tokens_used !== undefined ? { tokens_used: frame.tokens_used } : {}),
|
||||
...(frame.ctx_used !== undefined ? { ctx_used: frame.ctx_used } : {}),
|
||||
...(frame.ctx_max !== undefined ? { ctx_max: frame.ctx_max } : {}),
|
||||
...(frame.started_at !== undefined ? { started_at: frame.started_at } : {}),
|
||||
...(frame.finished_at !== undefined ? { finished_at: frame.finished_at } : {}),
|
||||
}
|
||||
: m
|
||||
);
|
||||
return { ...state, messages: next };
|
||||
}
|
||||
case 'messages_deleted': {
|
||||
const removeSet = new Set(frame.message_ids);
|
||||
return {
|
||||
...state,
|
||||
messages: state.messages.filter((m) => !removeSet.has(m.id)),
|
||||
};
|
||||
}
|
||||
case 'session_renamed': {
|
||||
// Side-effect, not state — dispatch via event bus to other hooks.
|
||||
sessionEvents.emit({
|
||||
type: 'session_renamed',
|
||||
session_id: frame.session_id,
|
||||
name: frame.name,
|
||||
});
|
||||
return state;
|
||||
}
|
||||
case 'error': {
|
||||
const next = frame.message_id
|
||||
? state.messages.map((m) =>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api } from '@/api/client';
|
||||
import type { Session } from '@/api/types';
|
||||
import { sessionEvents } from './sessionEvents';
|
||||
|
||||
export function useSessions(projectId: string | undefined) {
|
||||
const [sessions, setSessions] = useState<Session[] | null>(null);
|
||||
@@ -24,6 +25,23 @@ export function useSessions(projectId: string | undefined) {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
return sessionEvents.subscribe((event) => {
|
||||
if (event.type !== 'session_renamed') return;
|
||||
setSessions((prev) => {
|
||||
if (!prev) return prev;
|
||||
let changed = false;
|
||||
const next = prev.map((s) => {
|
||||
if (s.id !== event.session_id) return s;
|
||||
if (s.name === event.name) return s;
|
||||
changed = true;
|
||||
return { ...s, name: event.name };
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const create = useCallback(
|
||||
async (body: { name?: string; model?: string; system_prompt?: string }) => {
|
||||
if (!projectId) throw new Error('no project');
|
||||
|
||||
Reference in New Issue
Block a user