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) => ( +
  1. + {renderInline(item, `${key}.i${j}.`)} +
  2. + ))} +
+ ); + 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')} - -
-
-
-
+ patch('firstName', e.currentTarget.value)} + disabled={submitting} + required + /> + patch('lastName', e.currentTarget.value)} + disabled={submitting} + required + /> +
+ patch('email', e.currentTarget.value)} + disabled={submitting} + required /> -
-
- ); -} - -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 ( -
-
patch('firstName', e.currentTarget.value)} + label={t('auth.password')} + type="password" + placeholder={t('auth.passwordCreatePlaceholder')} + leadingIcon="lock" + hint={t('auth.passwordHint')} + autoComplete="new-password" + value={form.password} + onChange={(e) => patch('password', e.currentTarget.value)} + disabled={submitting} required /> patch('lastName', e.currentTarget.value)} + label={t('auth.confirmPassword')} + type="password" + placeholder={t('auth.confirmPasswordPlaceholder')} + leadingIcon="lock" + autoComplete="new-password" + value={form.confirmPassword} + onChange={(e) => patch('confirmPassword', e.currentTarget.value)} + disabled={submitting} required /> -
- patch('email', e.currentTarget.value)} - required - /> - patch('password', e.currentTarget.value)} - required - /> - patch('confirmPassword', e.currentTarget.value)} - required - /> - -
- ); -} - -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} - /> -