Verbesserung für Tests von Prompts #80
|
|
@ -278,7 +278,11 @@ async def execute_base_prompt(
|
|||
|
||||
if enable_debug:
|
||||
debug_info['template'] = template
|
||||
debug_info['final_prompt'] = prompt_text[:500] + ('...' if len(prompt_text) > 500 else '')
|
||||
# Volltext für Test-UI (Admin); sehr große Prompts nur weich begrenzen
|
||||
_max = 512 * 1024
|
||||
debug_info['final_prompt'] = (
|
||||
prompt_text if len(prompt_text) <= _max else prompt_text[:_max] + "\n… [gekürzt, >512KB]"
|
||||
)
|
||||
debug_info['available_variables'] = list(variables.keys())
|
||||
|
||||
# Call AI
|
||||
|
|
@ -397,7 +401,10 @@ async def execute_pipeline_prompt(
|
|||
if enable_debug:
|
||||
prompt_debug['source'] = 'inline'
|
||||
prompt_debug['template'] = template
|
||||
prompt_debug['final_prompt'] = prompt_text[:500] + ('...' if len(prompt_text) > 500 else '')
|
||||
_max = 512 * 1024
|
||||
prompt_debug['final_prompt'] = (
|
||||
prompt_text if len(prompt_text) <= _max else prompt_text[:_max] + "\n… [gekürzt, >512KB]"
|
||||
)
|
||||
prompt_debug.update(placeholder_debug)
|
||||
|
||||
response = await openrouter_call_func(prompt_text)
|
||||
|
|
|
|||
|
|
@ -937,27 +937,60 @@ def export_placeholder_catalog_zip(
|
|||
|
||||
# ── KI-Assisted Prompt Engineering ───────────────────────────────────────────
|
||||
|
||||
async def call_openrouter(prompt: str, max_tokens: int = 1500) -> str:
|
||||
async def call_openrouter(prompt: str, max_tokens: int = 4096) -> str:
|
||||
"""Call OpenRouter API to get AI response."""
|
||||
if not OPENROUTER_KEY:
|
||||
raise HTTPException(status_code=500, detail="OpenRouter API key not configured")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
headers={"Authorization": f"Bearer {OPENROUTER_KEY}"},
|
||||
json={
|
||||
"model": OPENROUTER_MODEL,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": max_tokens
|
||||
},
|
||||
timeout=60.0
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
headers={"Authorization": f"Bearer {OPENROUTER_KEY}"},
|
||||
json={
|
||||
"model": OPENROUTER_MODEL,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": max_tokens
|
||||
},
|
||||
timeout=120.0
|
||||
)
|
||||
except httpx.TimeoutException as e:
|
||||
raise HTTPException(status_code=504, detail=f"OpenRouter-Zeitüberschreitung: {e}") from e
|
||||
except httpx.RequestError as e:
|
||||
raise HTTPException(status_code=502, detail=f"OpenRouter-Verbindungsfehler: {e}") from e
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(
|
||||
status_code=resp.status_code,
|
||||
detail=f"OpenRouter API error ({resp.status_code}): {resp.text[:2000]}",
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(status_code=resp.status_code, detail=f"OpenRouter API error: {resp.text}")
|
||||
try:
|
||||
data = resp.json()
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"OpenRouter lieferte kein JSON: {resp.text[:800]}",
|
||||
)
|
||||
|
||||
return resp.json()['choices'][0]['message']['content'].strip()
|
||||
choices = data.get("choices") or []
|
||||
if not choices:
|
||||
err_snip = json.dumps(data.get("error") or data, ensure_ascii=False)[:1200]
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"OpenRouter ohne choices in der Antwort: {err_snip}",
|
||||
)
|
||||
|
||||
message = choices[0].get("message") or {}
|
||||
content = message.get("content")
|
||||
if content is None:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="OpenRouter-Antwort ohne message.content (z. B. Tool-Calls oder abweichendes Schema).",
|
||||
)
|
||||
if not isinstance(content, str):
|
||||
content = str(content)
|
||||
return content.strip()
|
||||
|
||||
|
||||
def collect_example_data(profile_id: str, data_categories: list[str]) -> dict:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,264 @@
|
|||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useState, useEffect, useRef, useLayoutEffect } from 'react'
|
||||
import { api } from '../utils/api'
|
||||
import { X, Plus, Trash2, MoveUp, MoveDown, Code } from 'lucide-react'
|
||||
import PlaceholderPicker from './PlaceholderPicker'
|
||||
import Markdown from '../utils/Markdown'
|
||||
|
||||
/** Debug aus erfolgreicher Ausführung oder aus FastAPI-Fehlerdetail */
|
||||
function extractExecutionDebug(testResult) {
|
||||
if (!testResult) return null
|
||||
const raw = testResult.debug ?? testResult
|
||||
if (raw && typeof raw === 'object' && raw.detail && raw.detail.debug) {
|
||||
return { ...raw.detail.debug, ...raw.detail }
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
/** Vollständiger Prompt-Text für die Anzeige (Basis oder Pipeline-Stufen) */
|
||||
function buildPromptTextForDisplay(testResult) {
|
||||
if (!testResult || testResult.error) return ''
|
||||
const dbg = extractExecutionDebug(testResult)
|
||||
const t = testResult.type
|
||||
if (t === 'base' && dbg?.final_prompt) return dbg.final_prompt
|
||||
if (t === 'pipeline' && dbg?.stages?.length) {
|
||||
const parts = []
|
||||
for (const st of dbg.stages) {
|
||||
for (const p of st.prompts || []) {
|
||||
const label =
|
||||
p.source === 'reference'
|
||||
? `Stage ${st.stage} · Ref: ${p.ref_slug || '?'}`
|
||||
: `Stage ${st.stage} · Inline`
|
||||
const text =
|
||||
p.final_prompt ||
|
||||
(p.ref_debug && typeof p.ref_debug === 'object' ? p.ref_debug.final_prompt : '') ||
|
||||
''
|
||||
if (text) parts.push(`── ${label} ──\n${text}`)
|
||||
}
|
||||
}
|
||||
return parts.join('\n\n')
|
||||
}
|
||||
return dbg?.final_prompt || ''
|
||||
}
|
||||
|
||||
function sanitizeDebugForPanel(dbg, resultType) {
|
||||
if (!dbg) return {}
|
||||
const o = { ...dbg }
|
||||
delete o.final_prompt
|
||||
delete o.template
|
||||
delete o.ai_response_preview
|
||||
if (o.stages && resultType === 'pipeline') {
|
||||
o.stages = o.stages.map((s) => ({
|
||||
stage: s.stage,
|
||||
available_variables: s.available_variables,
|
||||
output: s.output,
|
||||
prompts: (s.prompts || []).map((p) => ({
|
||||
source: p.source,
|
||||
ref_slug: p.ref_slug,
|
||||
output_key: p.output_key,
|
||||
context_var_key: p.context_var_key,
|
||||
resolved_placeholders: p.resolved_placeholders || p.ref_debug?.resolved_placeholders,
|
||||
unresolved_placeholders: p.unresolved_placeholders || p.ref_debug?.unresolved_placeholders,
|
||||
warnings: p.warnings || p.ref_debug?.warnings,
|
||||
ai_response_length: p.ai_response_length
|
||||
}))
|
||||
}))
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
/** Langer Inhalt: zunächst auf maxRem eingeklappt, „Mehr/Weniger anzeigen“ */
|
||||
function ExpandableCollapsible({ children, contentKey, maxRem = 28 }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const wrapRef = useRef(null)
|
||||
const [showToggle, setShowToggle] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setExpanded(false)
|
||||
}, [contentKey])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = wrapRef.current
|
||||
if (!el) return
|
||||
if (expanded) {
|
||||
setShowToggle(true)
|
||||
return
|
||||
}
|
||||
setShowToggle(el.scrollHeight > el.clientHeight + 2)
|
||||
}, [contentKey, expanded])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
ref={wrapRef}
|
||||
style={{
|
||||
maxHeight: expanded ? 'none' : `${maxRem}rem`,
|
||||
overflow: expanded ? 'visible' : 'hidden'
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{showToggle && (
|
||||
<div style={{ marginTop: 10, textAlign: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: 12, padding: '6px 14px' }}
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
>
|
||||
{expanded ? 'Weniger anzeigen' : 'Mehr anzeigen'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderTestOutput(testResult) {
|
||||
if (!testResult) return null
|
||||
if (testResult.error) {
|
||||
const d = testResult.debug?.detail
|
||||
const msg =
|
||||
typeof d === 'string'
|
||||
? d
|
||||
: d?.error || d?.message || testResult.error_message || 'Unbekannter Fehler'
|
||||
return (
|
||||
<div style={{ color: 'var(--danger)', fontSize: 14, lineHeight: 1.5 }}>
|
||||
{typeof msg === 'string' ? msg : JSON.stringify(msg, null, 2)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const out = testResult.output
|
||||
const fmt = testResult.output_format
|
||||
const typ = testResult.type
|
||||
|
||||
if (out == null) {
|
||||
return <span style={{ color: 'var(--text3)' }}>Keine Ausgabe</span>
|
||||
}
|
||||
|
||||
const proseBox = (content, contentKey) => (
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
background: 'var(--bg)',
|
||||
borderRadius: 8,
|
||||
border: '1px solid var(--border)',
|
||||
fontSize: 14,
|
||||
lineHeight: 1.55,
|
||||
color: 'var(--text1)',
|
||||
textAlign: 'left'
|
||||
}}
|
||||
>
|
||||
{contentKey != null ? (
|
||||
<ExpandableCollapsible contentKey={contentKey}>{content}</ExpandableCollapsible>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (typ === 'pipeline' && out && typeof out === 'object' && !Array.isArray(out)) {
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 16 }}>
|
||||
{Object.entries(out).map(([key, val]) => (
|
||||
<div key={key}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text2)',
|
||||
marginBottom: 8,
|
||||
textAlign: 'left'
|
||||
}}
|
||||
>
|
||||
{key}
|
||||
</div>
|
||||
{typeof val === 'string' ? (
|
||||
proseBox(
|
||||
<Markdown text={val} />,
|
||||
`pipe-${key}-${val.length}-${val.slice(0, 120)}`
|
||||
)
|
||||
) : (
|
||||
<ExpandableCollapsible contentKey={`pipe-json-${key}-${JSON.stringify(val).slice(0, 200)}`} maxRem={22}>
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: 12,
|
||||
background: 'var(--bg)',
|
||||
borderRadius: 8,
|
||||
border: '1px solid var(--border)',
|
||||
fontSize: 12,
|
||||
overflow: 'auto',
|
||||
textAlign: 'left'
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(val, null, 2)}
|
||||
</pre>
|
||||
</ExpandableCollapsible>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof out === 'string') {
|
||||
if (fmt === 'json') {
|
||||
try {
|
||||
const parsed = JSON.parse(out)
|
||||
const jsonStr = JSON.stringify(parsed, null, 2)
|
||||
return (
|
||||
<ExpandableCollapsible contentKey={`json-out-${jsonStr.length}-${jsonStr.slice(0, 80)}`} maxRem={26}>
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: 12,
|
||||
background: 'var(--bg)',
|
||||
borderRadius: 8,
|
||||
border: '1px solid var(--border)',
|
||||
fontSize: 12,
|
||||
overflow: 'auto',
|
||||
textAlign: 'left'
|
||||
}}
|
||||
>
|
||||
{jsonStr}
|
||||
</pre>
|
||||
</ExpandableCollapsible>
|
||||
)
|
||||
} catch {
|
||||
return proseBox(
|
||||
<pre style={{ margin: 0, whiteSpace: 'pre-wrap' }}>{out}</pre>,
|
||||
`json-fail-${out.length}-${out.slice(0, 80)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
return proseBox(<Markdown text={out} />, `text-out-${out.length}-${out.slice(0, 120)}`)
|
||||
}
|
||||
|
||||
if (typeof out === 'object') {
|
||||
const jsonStr = JSON.stringify(out, null, 2)
|
||||
return (
|
||||
<ExpandableCollapsible contentKey={`obj-out-${jsonStr.length}-${jsonStr.slice(0, 120)}`} maxRem={26}>
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: 12,
|
||||
background: 'var(--bg)',
|
||||
borderRadius: 8,
|
||||
border: '1px solid var(--border)',
|
||||
fontSize: 12,
|
||||
overflow: 'auto',
|
||||
textAlign: 'left'
|
||||
}}
|
||||
>
|
||||
{jsonStr}
|
||||
</pre>
|
||||
</ExpandableCollapsible>
|
||||
)
|
||||
}
|
||||
|
||||
return <span>{String(out)}</span>
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified Prompt Editor Modal (Issue #28 Phase 3)
|
||||
|
|
@ -47,6 +304,8 @@ export default function UnifiedPromptModal({ prompt, onSave, onClose }) {
|
|||
const [testing, setTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState(null)
|
||||
const [showDebug, setShowDebug] = useState(false)
|
||||
/** 'response' | 'prompt' | 'debug' */
|
||||
const [testResultTab, setTestResultTab] = useState('response')
|
||||
|
||||
useEffect(() => {
|
||||
loadAvailablePrompts()
|
||||
|
|
@ -280,6 +539,7 @@ export default function UnifiedPromptModal({ prompt, onSave, onClose }) {
|
|||
try {
|
||||
const result = await api.executeUnifiedPrompt(prompt.slug, null, null, true)
|
||||
setTestResult(result)
|
||||
setTestResultTab('response')
|
||||
setShowDebug(true)
|
||||
} catch (e) {
|
||||
// Show error AND try to extract debug info from error
|
||||
|
|
@ -290,7 +550,12 @@ export default function UnifiedPromptModal({ prompt, onSave, onClose }) {
|
|||
try {
|
||||
const parsed = JSON.parse(errorMsg)
|
||||
if (parsed.detail) {
|
||||
setError('Test-Fehler: ' + parsed.detail)
|
||||
const d = parsed.detail
|
||||
const msg =
|
||||
typeof d === 'string'
|
||||
? d
|
||||
: d.error || d.message || d.msg || JSON.stringify(d).slice(0, 500)
|
||||
setError('Test-Fehler: ' + msg)
|
||||
debugData = parsed
|
||||
} else {
|
||||
setError('Test-Fehler: ' + errorMsg)
|
||||
|
|
@ -305,6 +570,7 @@ export default function UnifiedPromptModal({ prompt, onSave, onClose }) {
|
|||
error_message: errorMsg,
|
||||
debug: debugData || { error: errorMsg }
|
||||
})
|
||||
setTestResultTab('response')
|
||||
setShowDebug(true) // ALWAYS show debug on test, even on error
|
||||
} finally {
|
||||
setTesting(false)
|
||||
|
|
@ -315,7 +581,12 @@ export default function UnifiedPromptModal({ prompt, onSave, onClose }) {
|
|||
if (!testResult) return
|
||||
|
||||
// Extract all placeholder data from test result
|
||||
const debug = testResult.debug || testResult
|
||||
const raw = testResult.debug || testResult
|
||||
// API-Fehler: FastAPI liefert { detail: { error, debug: { resolved_placeholders, ... } } }
|
||||
const debug =
|
||||
raw && typeof raw === 'object' && raw.detail && raw.detail.debug
|
||||
? { ...raw.detail.debug, ...raw.detail }
|
||||
: raw
|
||||
const exportData = {
|
||||
export_date: new Date().toISOString(),
|
||||
prompt_slug: prompt?.slug || 'unknown',
|
||||
|
|
@ -711,7 +982,7 @@ export default function UnifiedPromptModal({ prompt, onSave, onClose }) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Debug Output */}
|
||||
{/* Test-Ergebnis: Antwort · Prompt · Debug */}
|
||||
{showDebug && testResult && (
|
||||
<div style={{
|
||||
marginTop: 16,
|
||||
|
|
@ -724,12 +995,14 @@ export default function UnifiedPromptModal({ prompt, onSave, onClose }) {
|
|||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12
|
||||
marginBottom: 12,
|
||||
flexWrap: 'wrap',
|
||||
gap: 8
|
||||
}}>
|
||||
<h3 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>
|
||||
🔬 Debug-Info
|
||||
Test-Ergebnis
|
||||
</h3>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={handleExportPlaceholders}
|
||||
|
|
@ -739,31 +1012,120 @@ export default function UnifiedPromptModal({ prompt, onSave, onClose }) {
|
|||
background: 'var(--accent)',
|
||||
color: 'white'
|
||||
}}
|
||||
title="Exportiere alle Platzhalter mit Werten als JSON"
|
||||
title="Exportiere Platzhalter / Kontext als JSON"
|
||||
>
|
||||
📋 Platzhalter exportieren
|
||||
📋 Kontext exportieren
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDebug(false)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4 }}
|
||||
aria-label="Schließen"
|
||||
>
|
||||
<X size={16} color="var(--text3)" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre style={{
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
background: 'var(--bg)',
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
overflow: 'auto',
|
||||
maxHeight: 400,
|
||||
lineHeight: 1.5,
|
||||
color: 'var(--text2)'
|
||||
}}>
|
||||
{JSON.stringify(testResult.debug || testResult, null, 2)}
|
||||
</pre>
|
||||
|
||||
<div
|
||||
role="tablist"
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 6,
|
||||
marginBottom: 12,
|
||||
flexWrap: 'wrap',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
paddingBottom: 8
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ id: 'response', label: 'Antwort' },
|
||||
{ id: 'prompt', label: 'Prompt (an LLM)' },
|
||||
{ id: 'debug', label: 'Debug' }
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={testResultTab === tab.id}
|
||||
onClick={() => setTestResultTab(tab.id)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
fontSize: 12,
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--border)',
|
||||
background: testResultTab === tab.id ? 'var(--accent)' : 'var(--surface)',
|
||||
color: testResultTab === tab.id ? '#fff' : 'var(--text2)',
|
||||
cursor: 'pointer',
|
||||
fontWeight: testResultTab === tab.id ? 600 : 400
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="tabpanel"
|
||||
style={{
|
||||
minHeight: 120,
|
||||
maxHeight: testResultTab === 'prompt' ? 480 : 420,
|
||||
overflow: 'auto',
|
||||
textAlign: 'left'
|
||||
}}
|
||||
>
|
||||
{testResultTab === 'response' && (
|
||||
<div style={{ paddingRight: 4 }}>
|
||||
{renderTestOutput(testResult)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{testResultTab === 'prompt' && (
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 12,
|
||||
fontFamily: 'ui-monospace, monospace',
|
||||
lineHeight: 1.45,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
color: 'var(--text1)',
|
||||
padding: 12,
|
||||
background: 'var(--bg)',
|
||||
borderRadius: 8,
|
||||
border: '1px solid var(--border)'
|
||||
}}
|
||||
>
|
||||
{buildPromptTextForDisplay(testResult) || '(Kein Prompt-Text in der Antwort — evtl. Fehler vor Ausführung.)'}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{testResultTab === 'debug' && (
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 11,
|
||||
fontFamily: 'ui-monospace, monospace',
|
||||
lineHeight: 1.45,
|
||||
padding: 12,
|
||||
background: 'var(--bg)',
|
||||
borderRadius: 8,
|
||||
border: '1px solid var(--border)',
|
||||
color: 'var(--text2)',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word'
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(
|
||||
sanitizeDebugForPanel(
|
||||
extractExecutionDebug(testResult),
|
||||
testResult.error ? null : testResult.type
|
||||
),
|
||||
null,
|
||||
2
|
||||
)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// Lightweight Markdown renderer – handles the subset used by the AI:
|
||||
// ## Headings, **bold**, bullet lists, numbered lists, line breaks
|
||||
// ## Headings, **bold**, bullet lists, numbered lists, fenced ``` code ```, line breaks
|
||||
|
||||
export default function Markdown({ text }) {
|
||||
if (!text) return null
|
||||
|
|
@ -7,6 +7,19 @@ export default function Markdown({ text }) {
|
|||
const lines = text.split('\n')
|
||||
const elements = []
|
||||
let i = 0
|
||||
let blockId = 0
|
||||
|
||||
const codeBlockStyle = {
|
||||
margin: '10px 0',
|
||||
padding: 12,
|
||||
background: 'var(--surface2)',
|
||||
borderRadius: 8,
|
||||
border: '1px solid var(--border)',
|
||||
overflow: 'auto',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
fontFamily: 'ui-monospace, Consolas, monospace'
|
||||
}
|
||||
|
||||
const parseLine = (line) => {
|
||||
// Parse inline **bold** and *italic*
|
||||
|
|
@ -39,6 +52,44 @@ export default function Markdown({ text }) {
|
|||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
|
||||
// Fenced code block: ``` or ```lang
|
||||
const trimmedStart = line.trimStart()
|
||||
if (trimmedStart.startsWith('```')) {
|
||||
const lang = trimmedStart.slice(3).trim() || null
|
||||
i++
|
||||
const codeLines = []
|
||||
while (i < lines.length) {
|
||||
if (lines[i].trim().startsWith('```')) {
|
||||
i++
|
||||
break
|
||||
}
|
||||
codeLines.push(lines[i])
|
||||
i++
|
||||
}
|
||||
const code = codeLines.join('\n')
|
||||
elements.push(
|
||||
<div key={`code-${blockId++}`} style={{ margin: '10px 0' }}>
|
||||
{lang && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: 'var(--text3)',
|
||||
marginBottom: 6,
|
||||
fontFamily: 'ui-monospace, monospace',
|
||||
letterSpacing: 0.02
|
||||
}}
|
||||
>
|
||||
{lang}
|
||||
</div>
|
||||
)}
|
||||
<pre style={codeBlockStyle}>
|
||||
<code style={{ color: 'var(--text1)', whiteSpace: 'pre', display: 'block' }}>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip empty lines (add spacing)
|
||||
if (line.trim() === '') {
|
||||
elements.push(<div key={i} style={{ height: 8 }} />)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user