feat(conversation): ConversationTimeline with stream structure and styling
This commit is contained in:
parent
ebeeb17170
commit
df8293494f
3 changed files with 285 additions and 54 deletions
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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!} />
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue