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:
@@ -42,36 +42,3 @@ export function CodeBlock({ code, lang }: Props) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SegmentText {
|
||||
kind: 'text';
|
||||
value: string;
|
||||
}
|
||||
interface SegmentCode {
|
||||
kind: 'code';
|
||||
lang?: string;
|
||||
value: string;
|
||||
}
|
||||
export type Segment = SegmentText | SegmentCode;
|
||||
|
||||
export function splitCodeBlocks(input: string): Segment[] {
|
||||
const segments: Segment[] = [];
|
||||
const fence = /```([a-zA-Z0-9_-]*)\n([\s\S]*?)```/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = fence.exec(input)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
segments.push({ kind: 'text', value: input.slice(lastIndex, match.index) });
|
||||
}
|
||||
segments.push({
|
||||
kind: 'code',
|
||||
lang: match[1] || undefined,
|
||||
value: (match[2] ?? '').replace(/\n$/, ''),
|
||||
});
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
if (lastIndex < input.length) {
|
||||
segments.push({ kind: 'text', value: input.slice(lastIndex) });
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
@@ -1,49 +1,209 @@
|
||||
import { useState } from 'react';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Copy, RefreshCw, Check } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import type { Message } from '@/api/types';
|
||||
import { api } from '@/api/client';
|
||||
import { ToolCallCard } from './ToolCallCard';
|
||||
import { CodeBlock, splitCodeBlocks } from './CodeBlock';
|
||||
import { CodeBlock } from './CodeBlock';
|
||||
|
||||
interface Props {
|
||||
message: Message;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export function MessageBubble({ message }: Props) {
|
||||
function MarkdownBody({ content }: { content: string }) {
|
||||
return (
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
pre: ({ children }) => <>{children}</>,
|
||||
code: (props) => {
|
||||
const { children, className, ...rest } = props as {
|
||||
children?: unknown;
|
||||
className?: string;
|
||||
};
|
||||
const text = String(children ?? '').replace(/\n$/, '');
|
||||
const langMatch = /language-([\w-]+)/.exec(className ?? '');
|
||||
const isBlock = !!langMatch || text.includes('\n');
|
||||
if (isBlock) {
|
||||
return <CodeBlock code={text} lang={langMatch?.[1]} />;
|
||||
}
|
||||
return (
|
||||
<code
|
||||
{...rest}
|
||||
className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]"
|
||||
>
|
||||
{children as React.ReactNode}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
a: ({ children, href }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline decoration-muted-foreground/40 underline-offset-2 hover:decoration-foreground"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="list-disc pl-5 space-y-1">{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="list-decimal pl-5 space-y-1">{children}</ol>
|
||||
),
|
||||
p: ({ children }) => <p className="leading-relaxed">{children}</p>,
|
||||
h1: ({ children }) => <h1 className="text-base font-semibold mt-2">{children}</h1>,
|
||||
h2: ({ children }) => <h2 className="text-sm font-semibold mt-2">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="text-sm font-semibold mt-1">{children}</h3>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-2 border-border pl-3 text-muted-foreground">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="border-collapse text-xs">{children}</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="border border-border px-2 py-1 text-left font-medium">{children}</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="border border-border px-2 py-1">{children}</td>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Markdown>
|
||||
);
|
||||
}
|
||||
|
||||
function StatsLine({ message }: { message: Message }) {
|
||||
const tokens = message.tokens_used;
|
||||
if (typeof tokens !== 'number' || tokens <= 0) return null;
|
||||
const started = message.started_at ? Date.parse(message.started_at) : NaN;
|
||||
const finished = message.finished_at ? Date.parse(message.finished_at) : NaN;
|
||||
let tps: number | null = null;
|
||||
if (!Number.isNaN(started) && !Number.isNaN(finished) && finished > started) {
|
||||
const seconds = (finished - started) / 1000;
|
||||
if (seconds > 0) tps = Math.round((tokens / seconds) * 10) / 10;
|
||||
}
|
||||
const ctxUsed = message.ctx_used;
|
||||
const ctxMax = message.ctx_max;
|
||||
const ctxPart =
|
||||
typeof ctxUsed === 'number'
|
||||
? typeof ctxMax === 'number' && ctxMax > 0
|
||||
? `${ctxUsed} / ${ctxMax} ctx`
|
||||
: `${ctxUsed} ctx`
|
||||
: null;
|
||||
|
||||
const parts: string[] = [`${tokens} tokens`];
|
||||
if (tps !== null) parts.push(`${tps.toFixed(1)} tok/s`);
|
||||
if (ctxPart) parts.push(ctxPart);
|
||||
|
||||
return (
|
||||
<div className="text-[10px] font-mono text-muted-foreground">
|
||||
{parts.join(' · ')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionRow({
|
||||
message,
|
||||
sessionId,
|
||||
}: {
|
||||
message: Message;
|
||||
sessionId: string;
|
||||
}) {
|
||||
const [justCopied, setJustCopied] = useState(false);
|
||||
const [regenerating, setRegenerating] = useState(false);
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(message.content);
|
||||
setJustCopied(true);
|
||||
setTimeout(() => setJustCopied(false), 1200);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'copy failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function regenerate() {
|
||||
if (regenerating || message.status === 'streaming') return;
|
||||
setRegenerating(true);
|
||||
try {
|
||||
await api.messages.regenerate(sessionId, message.id);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'regenerate failed');
|
||||
} finally {
|
||||
setRegenerating(false);
|
||||
}
|
||||
}
|
||||
|
||||
const isAssistant = message.role === 'assistant';
|
||||
const canRegen = isAssistant && message.status !== 'streaming';
|
||||
|
||||
return (
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copy()}
|
||||
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
aria-label="Copy message"
|
||||
title="Copy"
|
||||
>
|
||||
{justCopied ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
</button>
|
||||
{isAssistant && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void regenerate()}
|
||||
disabled={!canRegen || regenerating}
|
||||
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
aria-label="Regenerate message"
|
||||
title="Regenerate"
|
||||
>
|
||||
<RefreshCw className={`size-3 ${regenerating ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageBubble({ message, sessionId }: Props) {
|
||||
if (message.role === 'tool') {
|
||||
return <ToolCallCard message={message} />;
|
||||
}
|
||||
|
||||
if (message.role === 'user') {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<div className="group flex flex-col items-end gap-1">
|
||||
<div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap">
|
||||
{message.content}
|
||||
</div>
|
||||
<ActionRow message={message} sessionId={sessionId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isStreaming = message.status === 'streaming';
|
||||
const failed = message.status === 'failed';
|
||||
const hasContent = message.content.length > 0;
|
||||
const hasToolCalls = (message.tool_calls?.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="group flex flex-col gap-2">
|
||||
{message.tool_calls?.map((tc) => (
|
||||
<ToolCallCard key={tc.id} toolCall={tc} />
|
||||
))}
|
||||
{(message.content.length > 0 || (!message.tool_calls?.length && isStreaming)) && (
|
||||
{(hasContent || (!hasToolCalls && isStreaming)) && (
|
||||
<div className="max-w-[90%] text-sm leading-relaxed space-y-2">
|
||||
{splitCodeBlocks(message.content).map((seg, i) =>
|
||||
seg.kind === 'code' ? (
|
||||
<CodeBlock key={i} code={seg.value} lang={seg.lang} />
|
||||
) : (
|
||||
<div key={i} className="whitespace-pre-wrap">
|
||||
{seg.value}
|
||||
{isStreaming && i === splitCodeBlocks(message.content).length - 1 && (
|
||||
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse ml-0.5" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{message.content.length === 0 && isStreaming && (
|
||||
{hasContent ? <MarkdownBody content={message.content} /> : null}
|
||||
{isStreaming && (
|
||||
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
@@ -51,6 +211,10 @@ export function MessageBubble({ message }: Props) {
|
||||
{failed && (
|
||||
<div className="text-xs text-destructive">message failed</div>
|
||||
)}
|
||||
{!isStreaming && <StatsLine message={message} />}
|
||||
{!isStreaming && (hasContent || hasToolCalls) && (
|
||||
<ActionRow message={message} sessionId={sessionId} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ import { MessageBubble } from './MessageBubble';
|
||||
|
||||
interface Props {
|
||||
messages: Message[];
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export function MessageList({ messages }: Props) {
|
||||
export function MessageList({ messages, sessionId }: Props) {
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -24,7 +25,7 @@ export function MessageList({ messages }: Props) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||
{messages.map((m) => (
|
||||
<MessageBubble key={m.id} message={m} />
|
||||
<MessageBubble key={m.id} message={m} sessionId={sessionId} />
|
||||
))}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user