feat(ui): parse LLM resultJson and render as markdown in detail drawer
- resultJson was showing as raw {"text":"..."} JSON string
- Now parses the JSON and renders the text via react-markdown
- Pretty rendering: headings, lists, code blocks, blockquotes, links
- Shows status dot + model name header
- Fallback to raw view when text field is empty
This commit is contained in:
@@ -1,12 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { API_URL } from '@/lib/config';
|
||||
import SisterAvatar from '@/components/common/SisterAvatar';
|
||||
|
||||
const SISTER_NAMES = new Set(['harang', 'narang', 'darang', 'erang']);
|
||||
|
||||
function parseResult(
|
||||
raw: string | null,
|
||||
): { text: string; ok: boolean; extra?: Record<string, unknown> } | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
const text = typeof parsed.text === 'string' ? parsed.text : '';
|
||||
const ok = parsed.ok !== false;
|
||||
const { text: _t, ok: _o, ...rest } = parsed as {
|
||||
text?: unknown;
|
||||
ok?: unknown;
|
||||
} & Record<string, unknown>;
|
||||
void _t;
|
||||
void _o;
|
||||
return { text, ok, extra: Object.keys(rest).length > 0 ? rest : undefined };
|
||||
}
|
||||
} catch {
|
||||
return { text: raw, ok: true };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface SubTaskEvent {
|
||||
id: number;
|
||||
eventType: string;
|
||||
@@ -208,6 +232,89 @@ const Description = styled.p`
|
||||
word-break: break-word;
|
||||
`;
|
||||
|
||||
const LlmOutput = styled.div<{ $ok: boolean }>`
|
||||
background: var(--bg-input);
|
||||
border: 1px solid ${({ $ok }) => ($ok ? 'var(--border-color)' : '#ef444460')};
|
||||
border-left: 4px solid ${({ $ok }) => ($ok ? '#5fafff' : '#ef4444')};
|
||||
padding: 18px 22px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.75;
|
||||
color: var(--text-primary);
|
||||
|
||||
& > *:first-child { margin-top: 0; }
|
||||
& > *:last-child { margin-bottom: 0; }
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
margin: 14px 0 6px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
p { margin: 10px 0; }
|
||||
|
||||
ul, ol {
|
||||
margin: 10px 0;
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
li { margin: 4px 0; }
|
||||
|
||||
code {
|
||||
background: var(--bg-surface);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
color: #5fafff;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
margin: 10px 0;
|
||||
|
||||
code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 3px solid var(--border-color);
|
||||
padding-left: 12px;
|
||||
margin: 10px 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
a {
|
||||
color: #5fafff;
|
||||
text-decoration: none;
|
||||
&:hover { text-decoration: underline; }
|
||||
}
|
||||
|
||||
strong { font-weight: 700; }
|
||||
`;
|
||||
|
||||
const OutputHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const OutputStatusDot = styled.span<{ $ok: boolean }>`
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: ${({ $ok }) => ($ok ? '#22c55e' : '#ef4444')};
|
||||
`;
|
||||
|
||||
const ChildList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -348,6 +455,11 @@ export default function SubTaskDetailDrawer({ subTaskId, onClose }: Props) {
|
||||
const [detail, setDetail] = useState<SubTaskDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const llmResult = useMemo(
|
||||
() => (detail ? parseResult(detail.resultJson) : null),
|
||||
[detail],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetch(`${API_URL}/api/rails/sub-tasks/${subTaskId}`, {
|
||||
@@ -453,9 +565,24 @@ export default function SubTaskDetailDrawer({ subTaskId, onClose }: Props) {
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{detail.resultJson && (
|
||||
{llmResult && llmResult.text && (
|
||||
<Section>
|
||||
<SectionLabel>결과 JSON</SectionLabel>
|
||||
<SectionLabel>LLM 응답</SectionLabel>
|
||||
<LlmOutput $ok={llmResult.ok}>
|
||||
<OutputHeader>
|
||||
<OutputStatusDot $ok={llmResult.ok} />
|
||||
<span style={{ fontSize: 11, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
{llmResult.ok ? 'success' : 'failed'} · {detail.model || 'default'}
|
||||
</span>
|
||||
</OutputHeader>
|
||||
<ReactMarkdown>{llmResult.text}</ReactMarkdown>
|
||||
</LlmOutput>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{llmResult && !llmResult.text && detail.resultJson && (
|
||||
<Section>
|
||||
<SectionLabel>원본 결과 (텍스트 없음)</SectionLabel>
|
||||
<Description>{detail.resultJson}</Description>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user