feat(conversation): ConversationTimeline with stream structure and styling

This commit is contained in:
Kyush 2026-04-23 13:13:40 +09:00
commit df8293494f
3 changed files with 285 additions and 54 deletions

View file

@ -3,7 +3,7 @@ import RefreshCcw from 'lucide-solid/icons/refresh-ccw';
import { api } from '../api/client';
import { Layout } from '../components/Layout';
import type { RequestLog } from '../types';
import { Button, CommandBar, CommandBarGroup, ConversationTimeline, DataGrid, EmptyState, MetaCluster, PageHeader, Panel, Select, StatusBadge, SummaryStrip, Tabs, TextField, hasRenderableConversation } from '../ui';
import { Button, CommandBar, CommandBarGroup, ConversationTimeline, DataGrid, EmptyState, MetaCluster, PageHeader, Panel, Select, StatusBadge, SummaryStrip, Tabs, TextField, extractAssistantConversationPreview, hasRenderableConversation } from '../ui';
interface FilterState {
month: string;
@ -25,32 +25,6 @@ const emptyFilters = (): FilterState => ({
endpoint: '',
});
function extractAssistantPreview(responseBody?: string): string {
if (!responseBody) return '-';
try {
const parsed = JSON.parse(responseBody) as {
choices?: Array<{
message?: {
content?: unknown;
};
}>;
};
const content = parsed.choices?.[0]?.message?.content;
if (typeof content !== 'string') return '-';
const normalized = content
.replace(/\r/g, '')
.replace(/\n+/g, ' ')
.trim();
if (!normalized) return '-';
return normalized.length > 50 ? `${normalized.slice(0, 50)}...` : normalized;
} catch {
return '-';
}
}
function prettyPrint(value?: string): string {
if (!value) return '';
@ -140,7 +114,7 @@ export const DetailLogs: Component = () => {
const assistantPreviewById = createMemo(() => {
const previews = new Map<number, string>();
for (const row of requestRows()) {
previews.set(row.id, extractAssistantPreview(row.response_body));
previews.set(row.id, extractAssistantConversationPreview(row.response_body));
}
return previews;
});

View file

@ -7,6 +7,8 @@ type KnownChatRole = 'system' | 'user' | 'assistant';
interface ParsedMessage {
role: string;
content: string;
reasoning?: string;
toolCalls?: string;
metadata?: Array<{ key: string; value: string }>;
}
@ -16,6 +18,56 @@ interface ConversationTimelineProps {
emptyMessage?: string;
}
interface ParsedStreamResponse {
messages: ParsedMessage[];
model?: string;
created?: number;
usage?: Record<string, unknown>;
}
interface StreamChoiceState {
index: number;
role?: string;
content: string[];
reasoning: string[];
toolCalls: Map<number, StreamToolCallState>;
finishReason?: string;
stopReason?: string;
}
interface StreamToolCallState {
index: number;
id?: string;
type?: string;
function?: {
name?: string;
arguments?: string;
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function stringifyValue(value: unknown): string {
if (typeof value === 'string') return value;
if (value === undefined || value === null) return '';
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function prettyJson(value: unknown): string {
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function normalizePayload(value: unknown): Record<string, unknown> | null {
if (!value) return null;
@ -39,7 +91,7 @@ function normalizeMessages(payload: Record<string, unknown> | null): ParsedMessa
.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object')
.map((item) => ({
role: typeof item.role === 'string' ? item.role : 'unknown',
content: typeof item.content === 'string' ? item.content : JSON.stringify(item.content ?? ''),
content: stringifyValue(item.content),
}));
}
@ -52,25 +104,11 @@ function normalizeAssistantMessages(payload: Record<string, unknown> | null): Pa
const message = (choice as Record<string, unknown>).message;
if (!message || typeof message !== 'object') return null;
const content = typeof (message as Record<string, unknown>).content === 'string'
? String((message as Record<string, unknown>).content)
: JSON.stringify((message as Record<string, unknown>).content ?? '');
const messageRecord = message as Record<string, unknown>;
const content = stringifyValue(messageRecord.content);
const reasoning = stringifyValue(messageRecord.reasoning_content ?? messageRecord.reasoning).trim();
const toolCalls = messageRecord.tool_calls !== undefined ? prettyJson(messageRecord.tool_calls) : undefined;
const metadata = [
(message as Record<string, unknown>).reasoning_content !== undefined
? {
key: 'Reasoning',
value:
typeof (message as Record<string, unknown>).reasoning_content === 'string'
? String((message as Record<string, unknown>).reasoning_content)
: JSON.stringify((message as Record<string, unknown>).reasoning_content),
}
: null,
(message as Record<string, unknown>).tool_calls !== undefined
? {
key: 'Tool Calls',
value: JSON.stringify((message as Record<string, unknown>).tool_calls),
}
: null,
(choice as Record<string, unknown>).finish_reason !== undefined
? { key: 'Finish', value: String((choice as Record<string, unknown>).finish_reason) }
: null,
@ -85,6 +123,8 @@ function normalizeAssistantMessages(payload: Record<string, unknown> | null): Pa
return {
role: 'assistant' as const,
content,
reasoning: reasoning || undefined,
toolCalls,
metadata,
};
});
@ -92,9 +132,179 @@ function normalizeAssistantMessages(payload: Record<string, unknown> | null): Pa
return messages.filter((message): message is ParsedMessage => message !== null);
}
function extractSseJsonPayloads(value: string): Record<string, unknown>[] {
const payloads: Record<string, unknown>[] = [];
const lines = value.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
let dataLines: string[] = [];
const flush = () => {
if (dataLines.length === 0) return;
const data = dataLines.join('\n');
dataLines = [];
if (data.trim() === '[DONE]') return;
try {
const parsed = JSON.parse(data);
if (isRecord(parsed)) payloads.push(parsed);
} catch {
// Ignore non-JSON SSE data frames.
}
};
for (const line of lines) {
if (line === '') {
flush();
continue;
}
if (line.startsWith('data:')) {
dataLines.push(line.slice(5).replace(/^ /, ''));
}
}
flush();
return payloads;
}
function mergeToolCall(target: StreamToolCallState, chunk: Record<string, unknown>): void {
if (typeof chunk.id === 'string') target.id = chunk.id;
if (typeof chunk.type === 'string') target.type = chunk.type;
const functionChunk = chunk.function;
if (!isRecord(functionChunk)) return;
target.function = target.function ?? {};
if (typeof functionChunk.name === 'string') {
target.function.name = `${target.function.name ?? ''}${functionChunk.name}`;
}
if (typeof functionChunk.arguments === 'string') {
target.function.arguments = `${target.function.arguments ?? ''}${functionChunk.arguments}`;
}
}
function parseStreamResponse(value: unknown): ParsedStreamResponse | null {
if (typeof value !== 'string' || !value.includes('data:')) return null;
const payloads = extractSseJsonPayloads(value);
if (payloads.length === 0) return null;
const choices = new Map<number, StreamChoiceState>();
let model: string | undefined;
let created: number | undefined;
let usage: Record<string, unknown> | undefined;
const getChoice = (index: number) => {
const existing = choices.get(index);
if (existing) return existing;
const createdChoice: StreamChoiceState = {
index,
content: [],
reasoning: [],
toolCalls: new Map(),
};
choices.set(index, createdChoice);
return createdChoice;
};
for (const payload of payloads) {
if (typeof payload.model === 'string' && !model) model = payload.model;
if (typeof payload.created === 'number' && created === undefined) created = payload.created;
if (isRecord(payload.usage)) usage = payload.usage;
if (!Array.isArray(payload.choices)) continue;
for (const rawChoice of payload.choices) {
if (!isRecord(rawChoice)) continue;
const index = typeof rawChoice.index === 'number' ? rawChoice.index : 0;
const choice = getChoice(index);
const delta = rawChoice.delta;
if (isRecord(delta)) {
if (typeof delta.role === 'string') choice.role = delta.role;
if (typeof delta.content === 'string') choice.content.push(delta.content);
if (typeof delta.reasoning === 'string') choice.reasoning.push(delta.reasoning);
if (typeof delta.reasoning_content === 'string') choice.reasoning.push(delta.reasoning_content);
if (Array.isArray(delta.tool_calls)) {
for (const rawToolCall of delta.tool_calls) {
if (!isRecord(rawToolCall)) continue;
const toolIndex = typeof rawToolCall.index === 'number' ? rawToolCall.index : choice.toolCalls.size;
const existing = choice.toolCalls.get(toolIndex) ?? { index: toolIndex };
mergeToolCall(existing, rawToolCall);
choice.toolCalls.set(toolIndex, existing);
}
}
}
if (rawChoice.finish_reason !== undefined && rawChoice.finish_reason !== null) {
choice.finishReason = String(rawChoice.finish_reason);
}
if (rawChoice.stop_reason !== undefined && rawChoice.stop_reason !== null) {
choice.stopReason = String(rawChoice.stop_reason);
}
}
}
const messages = [...choices.values()]
.sort((left, right) => left.index - right.index)
.map((choice) => {
const toolCalls = [...choice.toolCalls.values()].sort((left, right) => left.index - right.index);
const metadata = [
choice.finishReason ? { key: 'Finish', value: choice.finishReason } : null,
choice.stopReason ? { key: 'Stop Reason', value: choice.stopReason } : null,
].filter((item): item is { key: string; value: string } => Boolean(item));
return {
role: choice.role ?? 'assistant',
content: choice.content.join(''),
reasoning: choice.reasoning.join('') || undefined,
toolCalls: toolCalls.length > 0 ? prettyJson(toolCalls) : undefined,
metadata,
};
})
.filter((message) => message.content || message.reasoning || message.toolCalls || (message.metadata?.length ?? 0) > 0);
return {
messages,
model,
created,
usage,
};
}
function getAssistantMessages(responseBody?: unknown): ParsedMessage[] {
const stream = parseStreamResponse(responseBody);
if (stream) return stream.messages;
return normalizeAssistantMessages(normalizePayload(responseBody));
}
export function extractAssistantConversationPreview(responseBody?: unknown): string {
const assistantMessage = getAssistantMessages(responseBody).find((message) => (
message.content.trim() || message.reasoning?.trim() || message.toolCalls?.trim()
));
if (!assistantMessage) return '-';
const source = assistantMessage.content.trim()
? assistantMessage.content
: assistantMessage.reasoning
? `Thinking: ${assistantMessage.reasoning}`
: `Tool Calls: ${assistantMessage.toolCalls ?? ''}`;
const normalized = source
.replace(/\r/g, '')
.replace(/\n+/g, ' ')
.trim();
if (!normalized) return '-';
return normalized.length > 50 ? `${normalized.slice(0, 50)}...` : normalized;
}
export function hasRenderableConversation(requestBody?: unknown, responseBody?: unknown): boolean {
const requestMessages = normalizeMessages(normalizePayload(requestBody));
const responseMessages = normalizeAssistantMessages(normalizePayload(responseBody));
const responseMessages = getAssistantMessages(responseBody);
return requestMessages.length > 0 || responseMessages.length > 0;
}
@ -121,23 +331,28 @@ function getRoleClass(role: string): string {
export function ConversationTimeline(props: ConversationTimelineProps) {
const parsedRequest = createMemo(() => normalizePayload(props.requestBody));
const parsedResponse = createMemo(() => normalizePayload(props.responseBody));
const parsedStreamResponse = createMemo(() => parseStreamResponse(props.responseBody));
const requestMessages = createMemo(() => normalizeMessages(parsedRequest()));
const responseMessages = createMemo(() => normalizeAssistantMessages(parsedResponse()));
const responseMessages = createMemo(() => parsedStreamResponse()?.messages ?? normalizeAssistantMessages(parsedResponse()));
const messages = createMemo(() => [...requestMessages(), ...responseMessages()]);
const summaryItems = createMemo(() => {
const request = parsedRequest();
const response = parsedResponse();
const stream = parsedStreamResponse();
const usage = response?.usage && typeof response.usage === 'object' ? response.usage as Record<string, unknown> : null;
const responseUsage = usage ?? stream?.usage ?? null;
return [
typeof request?.model === 'string' ? { key: 'Model', value: request.model } : null,
request?.temperature !== undefined ? { key: 'Temp', value: String(request.temperature) } : null,
typeof stream?.model === 'string' && stream.model !== request?.model ? { key: 'Response Model', value: stream.model } : null,
typeof response?.created === 'number' ? { key: 'Created', value: String(response.created) } : null,
usage?.prompt_tokens !== undefined ? { key: 'Prompt', value: String(usage.prompt_tokens) } : null,
usage?.completion_tokens !== undefined ? { key: 'Completion', value: String(usage.completion_tokens) } : null,
usage?.total_tokens !== undefined ? { key: 'Total', value: String(usage.total_tokens) } : null,
typeof stream?.created === 'number' ? { key: 'Created', value: String(stream.created) } : null,
responseUsage?.prompt_tokens !== undefined ? { key: 'Prompt', value: String(responseUsage.prompt_tokens) } : null,
responseUsage?.completion_tokens !== undefined ? { key: 'Completion', value: String(responseUsage.completion_tokens) } : null,
responseUsage?.total_tokens !== undefined ? { key: 'Total', value: String(responseUsage.total_tokens) } : null,
].filter((item): item is { key: string; value: string } => Boolean(item));
});
@ -160,7 +375,26 @@ export function ConversationTimeline(props: ConversationTimelineProps) {
<span class="ui-conversation__turn-index">Turn {index() + 1}</span>
</header>
<div class="ui-conversation__bubble">
<pre class="ui-conversation__content">{message.content}</pre>
<Show when={message.reasoning}>
<section class="ui-conversation__block ui-conversation__block--reasoning">
<div class="ui-conversation__block-label">Thinking</div>
<pre class="ui-conversation__content">{message.reasoning}</pre>
</section>
</Show>
<Show when={message.content.trim().length > 0 || (!message.reasoning && !message.toolCalls)}>
<section class="ui-conversation__block">
<Show when={message.reasoning}>
<div class="ui-conversation__block-label">Response</div>
</Show>
<pre class="ui-conversation__content">{message.content}</pre>
</section>
</Show>
<Show when={message.toolCalls}>
<section class="ui-conversation__block ui-conversation__block--tool-calls">
<div class="ui-conversation__block-label">Tool Calls</div>
<pre class="ui-conversation__content">{message.toolCalls}</pre>
</section>
</Show>
<Show when={message.metadata && message.metadata.length > 0}>
<div class="ui-conversation__meta">
<MetaCluster items={message.metadata!} />

View file

@ -464,6 +464,8 @@
}
.ui-conversation__bubble {
display: grid;
gap: var(--space-4);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-2);
@ -498,8 +500,29 @@
color: var(--color-text);
}
.ui-conversation__block {
display: grid;
gap: var(--space-2);
min-width: 0;
}
.ui-conversation__block--reasoning,
.ui-conversation__block--tool-calls {
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-2);
background: var(--color-bg-inset);
}
.ui-conversation__block-label {
color: var(--color-text-soft);
font-size: var(--text-1);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.ui-conversation__meta {
margin-top: var(--space-4);
padding-top: var(--space-4);
border-top: 1px solid var(--color-border);
}