diff --git a/src/components/chat/AssistantMessage.tsx b/src/components/chat/AssistantMessage.tsx
index 6a57e61..9d79f32 100644
--- a/src/components/chat/AssistantMessage.tsx
+++ b/src/components/chat/AssistantMessage.tsx
@@ -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 {
@@ -58,7 +59,7 @@ export function AssistantMessage({
)}
- {children}
+ {typeof children === 'string' ?
: children}
{streaming && (
diff --git a/src/components/chat/MarkdownContent.tsx b/src/components/chat/MarkdownContent.tsx
new file mode 100644
index 0000000..8d48fdb
--- /dev/null
+++ b/src/components/chat/MarkdownContent.tsx
@@ -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(
+
+ {match.slice(1, -1)}
+ ,
+ );
+ } else if (b1 || b2) {
+ const inner = match.slice(2, -2);
+ out.push(
+
+ {renderInline(inner, `${keyPrefix}b${k}.`)}
+ ,
+ );
+ } else if (i1 || i2) {
+ const inner = match.slice(1, -1);
+ out.push(
+
+ {renderInline(inner, `${keyPrefix}i${k}.`)}
+ ,
+ );
+ } else if (link) {
+ const closeBracket = match.indexOf(']');
+ const linkText = match.slice(1, closeBracket);
+ const url = match.slice(closeBracket + 2, -1);
+ out.push(
+
+ {renderInline(linkText, `${keyPrefix}l${k}.`)}
+ ,
+ );
+ }
+ 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(
);
+ if (part) out.push({part});
+ });
+ 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 (
+
+ {renderInline(block.text, `${key}.`)}
+
+ );
+ }
+ case 'p':
+ return (
+
+ {renderInline(block.text, `${key}.`)}
+
+ );
+ case 'ul':
+ return (
+
+ {block.items.map((item, j) => (
+ -
+ {renderInline(item, `${key}.i${j}.`)}
+
+ ))}
+
+ );
+ case 'ol':
+ return (
+
+ {block.items.map((item, j) => (
+ -
+ {renderInline(item, `${key}.i${j}.`)}
+
+ ))}
+
+ );
+ case 'quote':
+ return (
+
+ {renderInline(block.text, `${key}.`)}
+
+ );
+ case 'code':
+ return (
+
+ {block.text}
+
+ );
+ case 'hr':
+ return (
+
+ );
+ }
+ })}
+ >
+ );
+}
diff --git a/src/components/chat/MessageActions.tsx b/src/components/chat/MessageActions.tsx
index 5c11ef6..ef7ff79 100644
--- a/src/components/chat/MessageActions.tsx
+++ b/src/components/chat/MessageActions.tsx
@@ -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';
@@ -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('idle');
const audioRef = useRef(null);
@@ -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);
diff --git a/src/lib/agents.ts b/src/lib/agents.ts
index 6d1a516..126c198 100644
--- a/src/lib/agents.ts
+++ b/src/lib/agents.ts
@@ -86,19 +86,4 @@ export const AGENT_BY_ID: Record = Object.fromEntries(
AGENTS.map((a) => [a.id, a]),
) as Record;
-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);
diff --git a/src/lib/api/audio.ts b/src/lib/api/audio.ts
index 1f5a662..f56898e 100644
--- a/src/lib/api/audio.ts
+++ b/src/lib/api/audio.ts
@@ -1,6 +1,21 @@
import { api } from './client';
import type { SpeakResponse, TranscribeResponse } from './types';
+const UI_LOCALE_TO_TTS: Record = {
+ en: 'en-US',
+ 'pt-BR': 'pt-BR',
+ es: 'es-ES',
+ fr: 'fr-FR',
+};
+
+/** Resolve a UI i18n locale (e.g. "en", "pt-BR") to a Google TTS language code
+ * the backend's VOICE_MAP understands. Returns undefined if unknown so the
+ * backend can fall back to its own langdetect. */
+export function ttsLanguageCode(uiLocale: string | null | undefined): string | undefined {
+ if (!uiLocale) return undefined;
+ return UI_LOCALE_TO_TTS[uiLocale] ?? UI_LOCALE_TO_TTS[uiLocale.split('-')[0]];
+}
+
export async function transcribeAudio(
blob: Blob,
filename = 'recording.webm',
@@ -16,10 +31,10 @@ export async function transcribeAudio(
return data;
}
-export async function speak(text: string, voiceName?: string): Promise {
+export async function speak(text: string, languageCode?: string): Promise {
const { data } = await api.post('/api/translation-helper/audio/speak', {
text,
- voice_name: voiceName,
+ language_code: languageCode,
});
return data;
}
diff --git a/src/lib/api/auth.ts b/src/lib/api/auth.ts
index 642bc70..e698021 100644
--- a/src/lib/api/auth.ts
+++ b/src/lib/api/auth.ts
@@ -1,3 +1,4 @@
+import { TH_APP_KEY } from '../constants';
import { api } from './client';
import type { CurrentUser } from './types';
@@ -40,7 +41,7 @@ export async function updateProfile(payload: {
return data;
}
-export async function forgotPassword(email: string, appKey = 'translation-helper'): Promise {
+export async function forgotPassword(email: string, appKey: string = TH_APP_KEY): Promise {
await api.post('/api/auth/forgot-password', { email, app_key: appKey });
}
@@ -49,7 +50,7 @@ export async function resetPassword(token: string, password: string): Promise {
await api.post('/api/access-requests', { app_key: appKey, note });
diff --git a/src/lib/api/chats.ts b/src/lib/api/chats.ts
index c8e005d..122048b 100644
--- a/src/lib/api/chats.ts
+++ b/src/lib/api/chats.ts
@@ -101,13 +101,14 @@ export async function streamChatMessage(
// ignore malformed
}
} else if (eventName === 'error') {
+ let message = 'Streaming error';
try {
const parsed = JSON.parse(dataLine) as { message?: string };
- throw new Error(parsed.message || 'Streaming error');
- } catch (e) {
- if (e instanceof Error) throw e;
- throw new Error('Streaming error');
+ if (parsed.message) message = parsed.message;
+ } catch {
+ // Malformed error payload — fall back to default message.
}
+ throw new Error(message);
} else if (eventName === 'done') {
return;
}
diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts
index 1ce07ac..77c8dd1 100644
--- a/src/lib/api/types.ts
+++ b/src/lib/api/types.ts
@@ -47,9 +47,6 @@ export interface AgentInfo {
id: AgentId;
name: string;
description: string;
- short: string;
- icon: string;
- starters: string[];
prompt_version: number | null;
}
@@ -91,7 +88,6 @@ export interface AgentPrompt {
export interface TranscribeResponse {
text: string;
- duration_sec: number | null;
}
export interface SpeakResponse {
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
new file mode 100644
index 0000000..20df14e
--- /dev/null
+++ b/src/lib/constants.ts
@@ -0,0 +1 @@
+export const TH_APP_KEY = 'translation-helper';
diff --git a/src/lib/hooks/useChat.tsx b/src/lib/hooks/useChat.tsx
index f424d17..fd45538 100644
--- a/src/lib/hooks/useChat.tsx
+++ b/src/lib/hooks/useChat.tsx
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'wouter';
-import { AGENT_BY_ID, type Agent, type AgentId } from '../agents';
+import { AGENT_BY_ID, AGENT_IDS, type Agent, type AgentId } from '../agents';
import { chatsApi } from '../api';
import type { ChatMessageDto } from '../api/types';
import type { ChatMessageSeed } from '../fixtures';
@@ -223,8 +223,7 @@ export function useChat(chatId?: string, opts: UseChatOptions = {}) {
}, []);
const rotateAgent = useCallback(() => {
- const ids: AgentId[] = ['storyteller', 'conversation', 'oral', 'health', 'backtrans'];
- setAgentId((cur) => ids[(ids.indexOf(cur) + 1) % ids.length]);
+ setAgentId((cur) => AGENT_IDS[(AGENT_IDS.indexOf(cur) + 1) % AGENT_IDS.length]);
}, []);
const agent: Agent = useMemo(() => AGENT_BY_ID[agentId], [agentId]);
diff --git a/src/lib/stores/authStore.ts b/src/lib/stores/authStore.ts
index 3e461b4..8f6e75b 100644
--- a/src/lib/stores/authStore.ts
+++ b/src/lib/stores/authStore.ts
@@ -2,6 +2,7 @@ import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import i18n, { LOCALE_STORAGE_KEY, isSupportedLocale } from '../../i18n';
import { authApi, configureApiAuth, type CurrentUser } from '../api';
+import { TH_APP_KEY } from '../constants';
import { useChatHistoryStore } from './chatHistoryStore';
function syncLocaleFromUser(user: CurrentUser | null): void {
@@ -85,10 +86,10 @@ export const useAuthStore = create()(
});
let accessRequested = true;
try {
- await authApi.requestAccess('translation-helper');
+ await authApi.requestAccess(TH_APP_KEY);
} catch (err) {
accessRequested = false;
- console.warn('translation-helper access request failed:', err);
+ console.warn(`${TH_APP_KEY} access request failed:`, err);
}
return { accessRequested };
} catch (e) {
@@ -120,7 +121,7 @@ export const useAuthStore = create()(
refreshMyRoles: async () => {
try {
- const roles = await authApi.myRoles('translation-helper');
+ const roles = await authApi.myRoles(TH_APP_KEY);
set({ appRoles: roles.map((r) => r.role_key) });
} catch {
set({ appRoles: [] });
diff --git a/src/pages/Signup.tsx b/src/pages/Signup.tsx
index 99b28b2..c144e91 100644
--- a/src/pages/Signup.tsx
+++ b/src/pages/Signup.tsx
@@ -1,10 +1,8 @@
import { useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useLocation } from 'wouter';
-import { Icon } from '../components/Icon';
-import { Alert, Button, Input, Select } from '../components/primitives';
+import { Alert, Button, Input } from '../components/primitives';
import { AuthShell } from '../components/shells';
-import { ROLE_OPTION_KEYS, type RoleOptionKey } from '../lib/agents';
import { useToast } from '../lib/hooks/useToast';
import { useAuthStore } from '../lib/stores/authStore';
@@ -14,8 +12,6 @@ interface SignupForm {
email: string;
password: string;
confirmPassword: string;
- organization: string;
- role: RoleOptionKey;
}
const initialForm = (): SignupForm => ({
@@ -24,21 +20,22 @@ const initialForm = (): SignupForm => ({
email: '',
password: '',
confirmPassword: '',
- organization: '',
- role: ROLE_OPTION_KEYS[0],
});
export default function Signup() {
const { t } = useTranslation();
const [, navigate] = useLocation();
- const [step, setStep] = useState<1 | 2>(1);
const [form, setForm] = useState(initialForm);
const [error, setError] = useState(null);
const [submitting, setSubmitting] = useState(false);
const toast = useToast();
const signup = useAuthStore((s) => s.signup);
- const onCreate = async () => {
+ const patch = (key: keyof SignupForm, value: string) =>
+ setForm((prev) => ({ ...prev, [key]: value }));
+
+ const onSubmit = async (e: FormEvent) => {
+ e.preventDefault();
setError(null);
if (form.password !== form.confirmPassword) {
setError(t('auth.passwordsDontMatch'));
@@ -85,264 +82,91 @@ export default function Signup() {
return (
-
-
- {step === 1 ? t('auth.createYourAccount') : t('auth.tellAboutYourWork')}
+ {t('auth.createYourAccount')}
- {step === 1 ? t('auth.fewDetailsToSetUp') : t('auth.helpsUsTailor')}
+ {t('auth.fewDetailsToSetUp')}
- {step === 1 ? (
- setStep(2)} />
- ) : (
- setStep(1)}
- onCreate={onCreate}
- submitting={submitting}
- />
- )}
-
-
-
- {t('auth.alreadyHaveAccount')}{' '}
-
- {t('auth.signIn')}
-
-
-
-
- );
-}
-
-function ProgressBar({ step }: { step: 1 | 2 }) {
- const { t } = useTranslation();
- return (
-
-
- {t('auth.stepOf', { current: step, total: 2 })}
-
- {step === 1 ? t('auth.stepAccount') : t('auth.stepProfile')}
-
-
-
-
- );
-}
-
-function AccountStep({
- form,
- setForm,
- onNext,
-}: {
- form: SignupForm;
- setForm: (updater: (prev: SignupForm) => SignupForm) => void;
- onNext: () => void;
-}) {
- const { t } = useTranslation();
- const patch = (key: keyof SignupForm, value: string) =>
- setForm((prev) => ({ ...prev, [key]: value }));
-
- const onSubmit = (e: FormEvent) => {
- e.preventDefault();
- onNext();
- };
-
- return (
-
- );
-}
-
-function ProfileStep({
- form,
- setForm,
- onBack,
- onCreate,
- submitting,
-}: {
- form: SignupForm;
- setForm: (updater: (prev: SignupForm) => SignupForm) => void;
- onBack: () => void;
- onCreate: () => void;
- submitting: boolean;
-}) {
- const { t } = useTranslation();
- const patch = (key: K, value: SignupForm[K]) =>
- setForm((prev) => ({ ...prev, [key]: value }));
-
- return (
-
-
-
patch('organization', e.currentTarget.value)}
- disabled={submitting}
- />
-
- );
-}
+
-function AvatarUploader() {
- const { t } = useTranslation();
- const toast = useToast();
- return (
-
-
-
-
+
+
+ {t('auth.alreadyHaveAccount')}{' '}
+
+ {t('auth.signIn')}
+
-
-
- {t('auth.addPhotoOptional')}
-
-
+
);
}