diff --git a/app/globals.css b/app/globals.css index c247055..26fddc6 100644 --- a/app/globals.css +++ b/app/globals.css @@ -247,6 +247,9 @@ header select option { flex-wrap: wrap; justify-content: flex-end; } + .followup-button { + width: 100%; + } header button, header select { font-size: 11px; padding: 2px 5px; @@ -610,6 +613,48 @@ header select option { .message.user .content { color: var(--fg); } .message.assistant .content { color: var(--assistant-fg); } +.followup-list { + margin-top: 12px; + padding-top: 10px; + border-top: 1px solid var(--divider-soft); +} + +.followup-heading { + color: var(--dim); + font-size: 11px; + letter-spacing: 0.08em; + margin-bottom: 8px; +} + +.followup-buttons { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.followup-button { + background: var(--surface-muted); + color: var(--assistant-fg); + border: 1px solid var(--border); + font-family: inherit; + font-size: 12px; + line-height: 1.4; + padding: 7px 10px; + cursor: pointer; + text-align: left; + box-shadow: var(--panel-shadow); +} + +.followup-button:hover { + color: var(--green); + border-color: var(--green); +} + +.followup-button:focus { + outline: none; + border-color: var(--green); +} + .cursor { animation: blink 1s step-end infinite; } diff --git a/app/page.tsx b/app/page.tsx index 2eddef1..df180ba 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -6,7 +6,7 @@ import RenderedMarkdown from './rendered-markdown'; import { renderMarkdown } from '@/lib/markdown'; import { getOrCreateKey, encrypt, decrypt } from '@/lib/crypto'; import { MODELS } from '@/lib/models'; -import type { Role, Image, Pdf, Message } from '@/lib/chat'; +import { splitMessageFollowups, type Role, type Image, type Pdf, type Message } from '@/lib/chat'; import { LIMITS } from '@/lib/validation'; type PendingFile = { name: string; content: string }; // text/code files @@ -29,7 +29,11 @@ type SettingsPatch = { systemPrompt?: string; saveHistory?: boolean; girlMode?: type PendingSettings = Omit; function withRenderedHtml(message: Message): Message { - return message.content ? { ...message, html: renderMarkdown(message.content) } : message; + if (!message.content) return message; + const displayContent = message.role === 'assistant' + ? splitMessageFollowups(message.content).content + : message.content; + return { ...message, html: renderMarkdown(displayContent) }; } function withRenderedMessages(messages: Message[]): Message[] { @@ -40,6 +44,14 @@ function stripMessageHtml(messages: Message[]): Array> { return messages.map(({ html: _html, ...message }) => message); } +function toConversationMessages(messages: Message[]): Array> { + return stripMessageHtml(messages).map(message => ( + message.role === 'assistant' + ? { ...message, content: splitMessageFollowups(message.content).content } + : message + )); +} + function setGirlModeDom(enabled: boolean) { if (enabled) document.documentElement.setAttribute(GIRL_MODE_ATTR, 'true'); else document.documentElement.removeAttribute(GIRL_MODE_ATTR); @@ -200,7 +212,8 @@ export default function Home() { const renderStreamingMarkdown = (text: string) => { lastRenderedStreamingTextRef.current = text; startTransition(() => { - setStreamingHtml(text ? renderMarkdown(text) : ''); + const displayText = splitMessageFollowups(text).content; + setStreamingHtml(displayText ? renderMarkdown(displayText) : ''); }); }; @@ -582,7 +595,7 @@ export default function Home() { const SMOOTH_RATE = 3; const requestModel = model; const requestSystemPrompt = systemPrompt; - const requestMessages = stripMessageHtml(msgs); + const requestMessages = toConversationMessages(msgs); setMessages(msgs); setStreaming(true); @@ -738,13 +751,11 @@ export default function Home() { } }; - const handleSubmit = async (e?: React.FormEvent) => { - e?.preventDefault(); - if ((!input.trim() && pendingImages.length === 0 && pendingFiles.length === 0 && pendingPdfs.length === 0) || streaming) return; - + const submitTurn = async (nextInput?: string) => { + if ((!((nextInput ?? input).trim()) && pendingImages.length === 0 && pendingFiles.length === 0 && pendingPdfs.length === 0) || streaming) return; textareaRef.current?.focus(); - const trimmed = input.trim(); + const trimmed = (nextInput ?? input).trim(); if (trimmed) { inputHistoryRef.current = [trimmed, ...inputHistoryRef.current].slice(0, 50); historyIndexRef.current = -1; @@ -773,6 +784,15 @@ export default function Home() { await doStream([...messages, userMessage], currentWebSearch); }; + const handleSubmit = async (e?: React.FormEvent) => { + e?.preventDefault(); + await submitTurn(); + }; + + const handleFollowupClick = async (followup: string) => { + await submitTurn(followup); + }; + const handleRetry = () => doStream(withRenderedMessages(messages.slice(0, -1))); const handleKeyDown = (e: React.KeyboardEvent) => { @@ -826,7 +846,8 @@ export default function Home() { const date = new Date().toISOString().slice(0, 10); const lines: string[] = [`# Chat — ${date}`, ``, `**Model:** ${modelLabel}`, ``]; for (const msg of messages) { - lines.push(`---`, ``, `**${msg.role === 'user' ? 'User' : 'Assistant'}:**`, ``, msg.content, ``); + const displayContent = msg.role === 'assistant' ? splitMessageFollowups(msg.content).content : msg.content; + lines.push(`---`, ``, `**${msg.role === 'user' ? 'User' : 'Assistant'}:**`, ``, displayContent, ``); } const blob = new Blob([lines.join('\n')], { type: 'text/markdown' }); const url = URL.createObjectURL(blob); @@ -951,7 +972,12 @@ export default function Home() { {editingIndex === null && (
{msg.role === 'assistant' && ( - )} @@ -989,7 +1015,12 @@ export default function Home() {
) : ( msg.role === 'assistant' - ? + ? : msg.content && )} @@ -1006,7 +1037,12 @@ export default function Home() {
# {streamingContent - ? + ? : {!connected ? ▋ diff --git a/app/rendered-markdown.tsx b/app/rendered-markdown.tsx index 0cc9bbd..f2ab260 100644 --- a/app/rendered-markdown.tsx +++ b/app/rendered-markdown.tsx @@ -2,17 +2,26 @@ import { memo, useMemo } from 'react'; import { renderMarkdown } from '@/lib/markdown'; +import { splitMessageFollowups } from '@/lib/chat'; function RenderedMarkdown({ text, html, className, + followupsEnabled = false, + onFollowup, }: { text?: string; html?: string; className?: string; + followupsEnabled?: boolean; + onFollowup?: (followup: string) => void; }) { - const rendered = useMemo(() => html ?? renderMarkdown(text ?? ''), [html, text]); + const { content, followups } = useMemo( + () => followupsEnabled ? splitMessageFollowups(text ?? '') : { content: text ?? '', followups: [] }, + [followupsEnabled, text], + ); + const rendered = useMemo(() => html ?? renderMarkdown(content), [html, content]); const handleClick = async (event: React.MouseEvent) => { const button = (event.target as HTMLElement).closest('button[data-copy-code]'); @@ -26,7 +35,29 @@ function RenderedMarkdown({ }, 2000); }; - return
; + return ( +
+
+ {onFollowup && followups.length > 0 && ( +
+
[FOLLOW-UPS]
+
+ {followups.map((followup, index) => ( + + ))} +
+
+ )} +
+ ); } export default memo(RenderedMarkdown); diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx index 7f2c2c6..45250d8 100644 --- a/app/share/[id]/page.tsx +++ b/app/share/[id]/page.tsx @@ -92,7 +92,7 @@ export default async function SharePage({ params }: { params: Promise<{ id: stri
)} {msg.role === 'assistant' - ? + ? : msg.content && }
diff --git a/lib/chat.ts b/lib/chat.ts index 8adbfc2..880e949 100644 --- a/lib/chat.ts +++ b/lib/chat.ts @@ -4,12 +4,30 @@ export type Pdf = { name: string; data: string }; // base64, appl export type Message = { role: Role; content: string; html?: string; images?: Image[]; pdfs?: Pdf[] }; export type Provider = 'openai' | 'anthropic' | 'google'; +const FOLLOWUPS_BLOCK_RE = /(?:\r?\n|\s)*([\s\S]*?)<\/followups>\s*$/i; +const FOLLOWUP_RE = /([\s\S]*?)<\/followup>/gi; + export function getProvider(model: string): Provider { if (model.startsWith('claude')) return 'anthropic'; if (model.startsWith('gemini')) return 'google'; return 'openai'; } +export function splitMessageFollowups(content: string): { content: string; followups: string[] } { + const match = content.match(FOLLOWUPS_BLOCK_RE); + if (!match) return { content, followups: [] }; + + const followups = Array.from(match[1].matchAll(FOLLOWUP_RE)) + .map(([, followup]) => followup.replace(/\s+/g, ' ').trim()) + .filter(Boolean); + if (followups.length === 0) return { content, followups: [] }; + + return { + content: content.slice(0, match.index).trimEnd(), + followups, + }; +} + export function toOpenAIMessages(messages: Message[], systemPrompt?: string) { const result: Array<{ role: Role | 'system'; diff --git a/tests/unit.test.ts b/tests/unit.test.ts index 93b9e3a..9980e80 100644 --- a/tests/unit.test.ts +++ b/tests/unit.test.ts @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { getProvider, toOpenAIMessages, toAnthropicMessages, toGeminiContents, parseOpenAIChunk, parseAnthropicChunk, parseGeminiChunk, parseOpenAIResponsesChunk } from '../lib/chat.ts'; +import { getProvider, toOpenAIMessages, toAnthropicMessages, toGeminiContents, parseOpenAIChunk, parseAnthropicChunk, parseGeminiChunk, parseOpenAIResponsesChunk, splitMessageFollowups } from '../lib/chat.ts'; import { renderMarkdown } from '../lib/markdown.ts'; import { getOrCreateKey, encrypt, decrypt } from '../lib/crypto.ts'; @@ -204,6 +204,21 @@ test('toGeminiContents: PDF + image together — PDF first, then image, then tex assert.deepEqual(out[0].parts[2], { text: 'read both' }); }); +test('splitMessageFollowups: strips the trailing followups block and returns followup buttons', () => { + const parsed = splitMessageFollowups( + 'Main answer.\n\nFirst follow-up.Second follow-up.', + ); + assert.equal(parsed.content, 'Main answer.'); + assert.deepEqual(parsed.followups, ['First follow-up.', 'Second follow-up.']); +}); + +test('splitMessageFollowups: ignores followups tags that are not at the end of the message', () => { + const content = 'Example\nStill part of the visible message.'; + const parsed = splitMessageFollowups(content); + assert.equal(parsed.content, content); + assert.deepEqual(parsed.followups, []); +}); + // ── renderMarkdown ─────────────────────────────────────────────────────────── test('renderMarkdown: bold', () => { @@ -318,6 +333,37 @@ test('page source uses friendly client error formatting instead of raw String(er ); }); +test('followup UI wiring hides XML in assistant messages and submits the selected followup', () => { + const pageSource = readFileSync(join(import.meta.dirname, '../app/page.tsx'), 'utf8'); + const renderedMarkdownSource = readFileSync(join(import.meta.dirname, '../app/rendered-markdown.tsx'), 'utf8'); + const css = readFileSync(join(import.meta.dirname, '../app/globals.css'), 'utf8'); + + assert.ok( + pageSource.includes('const requestMessages = toConversationMessages(msgs);'), + 'assistant followup XML should be stripped before sending prior assistant messages back to the model', + ); + assert.ok( + pageSource.includes('const handleFollowupClick = async (followup: string) => {'), + 'the page should expose a click handler that submits a selected followup', + ); + assert.ok( + pageSource.includes('await submitTurn(followup);'), + 'clicking a followup should immediately submit that followup as the next turn', + ); + assert.ok( + renderedMarkdownSource.includes('className="followup-button"'), + 'RenderedMarkdown should render followups as dedicated themed buttons', + ); + assert.ok( + renderedMarkdownSource.includes('followupsEnabled'), + 'RenderedMarkdown should explicitly opt into followup parsing for assistant messages', + ); + assert.ok( + css.includes('.followup-button'), + 'globals.css should include styling for the followup buttons', + ); +}); + // ── settings validation ──────────────────────────────────────────────────────── test('validateSettingsRequest: validates and defaults girlMode', () => {