Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/components/chat/AssistantMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { AGENT_BY_ID, type AgentId } from '../../lib/agents';
import { Icon } from '../Icon';
import { Streaming } from '../primitives';
import { MarkdownContent } from './MarkdownContent';
import { MessageActions } from './MessageActions';

interface AssistantMessageProps {
Expand Down Expand Up @@ -58,7 +59,7 @@ export function AssistantMessage({
</div>
)}
<div style={{ fontSize: 15, lineHeight: 1.7, color: 'var(--text)' }}>
{children}
{typeof children === 'string' ? <MarkdownContent text={children} /> : children}
{streaming && (
<span style={{ color: 'var(--text-3)' }}>
<Streaming />
Expand Down
324 changes: 324 additions & 0 deletions src/components/chat/MarkdownContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,324 @@
import { Fragment, type ReactNode } from 'react';

type Block =
| { kind: 'h'; level: 1 | 2 | 3 | 4 | 5 | 6; text: string }
| { kind: 'p'; text: string }
| { kind: 'ul'; items: string[] }
| { kind: 'ol'; items: string[] }
| { kind: 'quote'; text: string }
| { kind: 'code'; text: string }
| { kind: 'hr' };

const HEADING_RE = /^(#{1,6})\s+(.*)$/;
const HR_RE = /^[\s]*([-*_])\1{2,}[\s]*$/;
const UL_RE = /^[\s]*[-*+]\s+(.*)$/;
const OL_RE = /^[\s]*\d+\.\s+(.*)$/;
const QUOTE_RE = /^>\s?(.*)$/;

function isBlockStart(line: string): boolean {
return (
HEADING_RE.test(line) ||
HR_RE.test(line) ||
UL_RE.test(line) ||
OL_RE.test(line) ||
QUOTE_RE.test(line) ||
line.startsWith('```')
);
}

function parseBlocks(input: string): Block[] {
const lines = input.replace(/\r\n/g, '\n').split('\n');
const blocks: Block[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];

if (line.startsWith('```')) {
i++;
const codeLines: string[] = [];
while (i < lines.length && !lines[i].startsWith('```')) {
codeLines.push(lines[i]);
i++;
}
if (i < lines.length) i++;
blocks.push({ kind: 'code', text: codeLines.join('\n') });
continue;
}

if (HR_RE.test(line)) {
blocks.push({ kind: 'hr' });
i++;
continue;
}

const h = HEADING_RE.exec(line);
if (h) {
blocks.push({
kind: 'h',
level: Math.min(6, h[1].length) as 1 | 2 | 3 | 4 | 5 | 6,
text: h[2],
});
i++;
continue;
}

if (QUOTE_RE.test(line)) {
const quoteLines: string[] = [];
while (i < lines.length) {
const m = QUOTE_RE.exec(lines[i]);
if (!m) break;
quoteLines.push(m[1]);
i++;
}
blocks.push({ kind: 'quote', text: quoteLines.join('\n') });
continue;
}

if (UL_RE.test(line)) {
const items: string[] = [];
while (i < lines.length) {
const m = UL_RE.exec(lines[i]);
if (!m) break;
items.push(m[1]);
i++;
}
blocks.push({ kind: 'ul', items });
continue;
}

if (OL_RE.test(line)) {
const items: string[] = [];
while (i < lines.length) {
const m = OL_RE.exec(lines[i]);
if (!m) break;
items.push(m[1]);
i++;
}
blocks.push({ kind: 'ol', items });
continue;
}

if (line.trim() === '') {
i++;
continue;
}

const paraLines: string[] = [];
while (i < lines.length && lines[i].trim() !== '' && !isBlockStart(lines[i])) {
paraLines.push(lines[i]);
i++;
}
blocks.push({ kind: 'p', text: paraLines.join('\n') });
}
return blocks;
}

const INLINE_RE =
/(`[^`\n]+`)|(\*\*[\s\S]+?\*\*)|(__[\s\S]+?__)|(\*[^*\n]+\*)|(_[^_\n]+_)|(\[[^\]]+\]\([^)\s]+\))/g;

function renderInline(text: string, keyPrefix: string): ReactNode[] {
if (!text) return [];
const out: ReactNode[] = [];
let lastIndex = 0;
let k = 0;
text.replace(INLINE_RE, (match, code, b1, b2, i1, i2, link, offset: number) => {
if (offset > lastIndex) {
out.push(...withSoftBreaks(text.slice(lastIndex, offset), `${keyPrefix}t${k++}`));
}
if (code) {
out.push(
<code
key={`${keyPrefix}c${k++}`}
style={{
background: 'var(--surface-2)',
border: '1px solid var(--border-subtle)',
borderRadius: 6,
padding: '1px 6px',
fontSize: '0.9em',
fontFamily:
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
}}
>
{match.slice(1, -1)}
</code>,
);
} else if (b1 || b2) {
const inner = match.slice(2, -2);
out.push(
<strong key={`${keyPrefix}b${k++}`} style={{ fontWeight: 600, color: 'var(--text)' }}>
{renderInline(inner, `${keyPrefix}b${k}.`)}
</strong>,
);
} else if (i1 || i2) {
const inner = match.slice(1, -1);
out.push(
<em key={`${keyPrefix}i${k++}`}>
{renderInline(inner, `${keyPrefix}i${k}.`)}
</em>,
);
} else if (link) {
const closeBracket = match.indexOf(']');
const linkText = match.slice(1, closeBracket);
const url = match.slice(closeBracket + 2, -1);
out.push(
<a
key={`${keyPrefix}l${k++}`}
href={url}
target="_blank"
rel="noreferrer noopener"
style={{ color: 'var(--accent)', textDecoration: 'underline' }}
>
{renderInline(linkText, `${keyPrefix}l${k}.`)}
</a>,
);
}
lastIndex = offset + match.length;
return match;
});
if (lastIndex < text.length) {
out.push(...withSoftBreaks(text.slice(lastIndex), `${keyPrefix}t${k++}`));
}
return out;
}

function withSoftBreaks(text: string, keyPrefix: string): ReactNode[] {
if (!text.includes('\n')) return [text];
const parts = text.split('\n');
const out: ReactNode[] = [];
parts.forEach((part, idx) => {
if (idx > 0) out.push(<br key={`${keyPrefix}br${idx}`} />);
if (part) out.push(<Fragment key={`${keyPrefix}s${idx}`}>{part}</Fragment>);
});
return out;
}

interface MarkdownContentProps {
text: string;
}

export function MarkdownContent({ text }: MarkdownContentProps) {
const blocks = parseBlocks(text);
return (
<>
{blocks.map((block, idx) => {
const key = `b${idx}`;
switch (block.kind) {
case 'h': {
const Tag = (`h${block.level}` as unknown) as keyof JSX.IntrinsicElements;
const size = [1.5, 1.3, 1.15, 1.05, 1, 0.95][block.level - 1];
return (
<Tag
key={key}
style={{
fontSize: `${size}em`,
fontWeight: 600,
marginTop: idx === 0 ? 0 : '1.2em',
marginBottom: '0.6em',
lineHeight: 1.3,
color: 'var(--text)',
}}
>
{renderInline(block.text, `${key}.`)}
</Tag>
);
}
case 'p':
return (
<p
key={key}
style={{ margin: idx === 0 ? '0 0 0.9em' : '0.9em 0', lineHeight: 1.7 }}
>
{renderInline(block.text, `${key}.`)}
</p>
);
case 'ul':
return (
<ul
key={key}
style={{
margin: '0.9em 0',
paddingLeft: '1.5em',
display: 'flex',
flexDirection: 'column',
gap: '0.35em',
}}
>
{block.items.map((item, j) => (
<li key={`${key}.i${j}`} style={{ lineHeight: 1.6 }}>
{renderInline(item, `${key}.i${j}.`)}
</li>
))}
</ul>
);
case 'ol':
return (
<ol
key={key}
style={{
margin: '0.9em 0',
paddingLeft: '1.6em',
display: 'flex',
flexDirection: 'column',
gap: '0.35em',
}}
>
{block.items.map((item, j) => (
<li key={`${key}.i${j}`} style={{ lineHeight: 1.6 }}>
{renderInline(item, `${key}.i${j}.`)}
</li>
))}
</ol>
);
case 'quote':
return (
<blockquote
key={key}
style={{
margin: '0.9em 0',
padding: '0.4em 0.9em',
borderLeft: '3px solid var(--accent)',
background: 'var(--surface-2)',
borderRadius: '0 8px 8px 0',
color: 'var(--text-2)',
fontStyle: 'italic',
}}
>
{renderInline(block.text, `${key}.`)}
</blockquote>
);
case 'code':
return (
<pre
key={key}
style={{
margin: '0.9em 0',
padding: '0.8em 1em',
background: 'var(--surface-2)',
border: '1px solid var(--border-subtle)',
borderRadius: 8,
overflowX: 'auto',
fontFamily:
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
fontSize: '0.9em',
lineHeight: 1.5,
}}
>
<code>{block.text}</code>
</pre>
);
case 'hr':
return (
<hr
key={key}
style={{
border: 0,
borderTop: '1px solid var(--border-subtle)',
margin: '1.2em 0',
}}
/>
);
}
})}
</>
);
}
8 changes: 6 additions & 2 deletions src/components/chat/MessageActions.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { audioApi } from '../../lib/api';
import { ttsLanguageCode } from '../../lib/api/audio';
import { useToast } from '../../lib/hooks/useToast';
import { stripMarkdownForSpeech } from '../../lib/text/stripMarkdownForSpeech';
import { Icon } from '../Icon';
Expand All @@ -13,7 +14,7 @@ interface MessageActionsProps {
type PlaybackState = 'idle' | 'loading' | 'playing';

export function MessageActions({ copyText }: MessageActionsProps) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const toast = useToast();
const [state, setState] = useState<PlaybackState>('idle');
const audioRef = useRef<HTMLAudioElement | null>(null);
Expand Down Expand Up @@ -69,7 +70,10 @@ export function MessageActions({ copyText }: MessageActionsProps) {

setState('loading');
try {
const { audio_base64, mime_type } = await audioApi.speak(spoken.slice(0, 4500));
const { audio_base64, mime_type } = await audioApi.speak(
spoken.slice(0, 4500),
ttsLanguageCode(i18n.language),
);
const bin = atob(audio_base64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
Expand Down
17 changes: 1 addition & 16 deletions src/lib/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,19 +86,4 @@ export const AGENT_BY_ID: Record<AgentId, Agent> = Object.fromEntries(
AGENTS.map((a) => [a.id, a]),
) as Record<AgentId, Agent>;

export type RoleOptionKey =
| 'mothertongue'
| 'facilitator'
| 'advisor'
| 'consultant'
| 'administrator'
| 'other';

export const ROLE_OPTION_KEYS: RoleOptionKey[] = [
'mothertongue',
'facilitator',
'advisor',
'consultant',
'administrator',
'other',
];
export const AGENT_IDS: AgentId[] = AGENTS.map((a) => a.id);
Loading
Loading