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
104 changes: 84 additions & 20 deletions apps/mobile/src/lib/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,28 +14,92 @@ export interface ChatMessage {
text: string;
}

/** Endpoint override (e.g. from Expo config). Empty = offline echo mode. */
export const AI_ENDPOINT = process.env.EXPO_PUBLIC_AI_ENDPOINT ?? '';

export async function sendChat(history: ChatMessage[]): Promise<ChatMessage> {
const last = history[history.length - 1]?.text ?? '';
export const CHAT_TIMEOUT_MS = 30000;
export type ChatFailure =
| 'timeout'
| 'cancelled'
| 'network'
| 'http'
| 'invalid';
export class ChatError extends Error {
constructor(readonly kind: ChatFailure) {
super(kind);
this.name = 'ChatError';
}
}

if (!AI_ENDPOINT) {
// Offline placeholder until the provider is wired.
await new Promise((r) => setTimeout(r, 350));
return {
id: `a-${Date.now()}`,
role: 'assistant',
text: `You said: “${last}”. (Offline stub — set EXPO_PUBLIC_AI_ENDPOINT to reach a model.)`,
function offlineDelay(signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const abort = () => {
clearTimeout(timer);
reject(new ChatError('cancelled'));
};
}
const timer = setTimeout(() => {
signal.removeEventListener('abort', abort);
resolve();
}, 350);
signal.addEventListener('abort', abort, { once: true });
if (signal.aborted) abort();
});
}

const res = await fetch(`${AI_ENDPOINT}/chat`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ messages: history.map(({ role, text }) => ({ role, text })) }),
/** One attempt. Cancellation cannot guarantee that the remote service stopped. */
export async function sendChat(history: readonly ChatMessage[], options: {signal?: AbortSignal} = {}): Promise<string> {
if (options.signal?.aborted) throw new ChatError('cancelled');
const controller = new AbortController();
let timedOut = false;
const timer = setTimeout(() => { timedOut = true; controller.abort(); }, CHAT_TIMEOUT_MS);
const forwardAbort = () => controller.abort();
options.signal?.addEventListener('abort', forwardAbort, {once:true});
let rejectAbort: () => void = () => {};
const aborted = new Promise<never>((_, reject) => {
rejectAbort = () => reject(new ChatError(timedOut ? 'timeout' : 'cancelled'));
controller.signal.addEventListener('abort', rejectAbort, {once:true});
});
if (!res.ok) throw new Error(`AI request failed (${res.status})`);
const data = (await res.json()) as { text?: string };
return { id: `a-${Date.now()}`, role: 'assistant', text: data.text ?? '(empty response)' };
const work = async () => {
const endpoint = process.env.EXPO_PUBLIC_AI_ENDPOINT ?? '';
if (!endpoint) {
await offlineDelay(controller.signal);
const last = history[history.length - 1]?.text ?? '';
return `You said: “${last}”. (Offline stub — set EXPO_PUBLIC_AI_ENDPOINT to reach a model.)`;
}
const response = await fetch(`${endpoint.replace(/\/+$/, '')}/chat`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
signal: controller.signal,
body: JSON.stringify({
messages: history.map(({ role, text }) => ({ role, text })),
}),
});
if (!response.ok) {
void response.body?.cancel().catch(() => {});
throw new ChatError('http');
}
let data: unknown;
try {
data = await response.json();
} catch {
throw new ChatError('invalid');
}
if (
!data ||
typeof data !== 'object' ||
!('text' in data) ||
typeof data.text !== 'string' ||
!data.text.trim()
)
throw new ChatError('invalid');
return data.text;
};
try {
return await Promise.race([work(), aborted]);
} catch (error) {
if (controller.signal.aborted)
throw new ChatError(timedOut ? 'timeout' : 'cancelled');
throw error instanceof ChatError ? error : new ChatError('network');
} finally {
clearTimeout(timer);
options.signal?.removeEventListener('abort', forwardAbort);
controller.signal.removeEventListener('abort', rejectAbort);
}
}
205 changes: 180 additions & 25 deletions apps/mobile/src/screens/ChatScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
FlatList,
KeyboardAvoidingView,
Expand All @@ -10,50 +10,164 @@ import {
View,
} from 'react-native';
import { theme } from '../theme';
import { sendChat, type ChatMessage } from '../lib/ai';
import {
CHAT_TIMEOUT_MS,
ChatError,
sendChat,
type ChatFailure,
type ChatMessage,
} from '../lib/ai';

const INITIAL: ChatMessage[] = [
{
id: 'sys-0',
role: 'assistant',
text: 'Hi — I’m your TronBrowser agent. Ask me anything.',
},
];
type Turn =
| { message: ChatMessage; state: 'pending' }
| { message: ChatMessage; state: 'failed'; reason: ChatFailure };
const FAILURE_COPY: Record<ChatFailure, string> = {
timeout: `No reply within ${CHAT_TIMEOUT_MS / 1000} seconds. The request may still have reached the service.`,
cancelled: 'Stopped. The request may still have reached the service.',
network: "Couldn't reach the assistant.",
http: 'The assistant service returned an error.',
invalid: "The assistant sent a reply this app couldn't read.",
};

/**
* AI chat tab (PRD §Mobile). Wired to `../lib/ai`, which is the single seam
* where the real model provider / `@tronbrowser/ai-core` gets plugged in.
*/
export function ChatScreen() {
const [messages, setMessages] = useState<ChatMessage[]>([
{ id: 'sys-0', role: 'assistant', text: 'Hi — I’m your TronBrowser agent. Ask me anything.' },
]);
const [messages, setMessages] = useState<ChatMessage[]>(INITIAL);
const [draft, setDraft] = useState('');
const [busy, setBusy] = useState(false);
const [turn, setTurn] = useState<Turn | null>(null);
const [confirmClear, setConfirmClear] = useState(false);
const history = useRef(INITIAL);
const generation = useRef(0);
const nextId = useRef(0);
const pending = useRef<{
controller: AbortController;
message: ChatMessage;
} | null>(null);
const busy = turn?.state === 'pending';
const listRef = useRef<FlatList<ChatMessage>>(null);

const submit = useCallback(async () => {
const text = draft.trim();
if (!text || busy) return;
const userMsg: ChatMessage = { id: `u-${Date.now()}`, role: 'user', text };
setMessages((m) => [...m, userMsg]);
setDraft('');
setBusy(true);
const run = useCallback(async (message: ChatMessage) => {
if (pending.current) return;
const controller = new AbortController();
const attempt = ++generation.current;
pending.current = { controller, message };
setTurn({ message, state: 'pending' });
try {
const reply = await sendChat([...messages, userMsg]);
setMessages((m) => [...m, reply]);
} catch (err) {
setMessages((m) => [
...m,
{ id: `e-${Date.now()}`, role: 'assistant', text: `Error: ${(err as Error).message}` },
]);
const text = await sendChat([...history.current, message], {
signal: controller.signal,
});
if (attempt !== generation.current) return;
history.current = [
...history.current,
message,
{ id: `a-${++nextId.current}`, role: 'assistant', text },
];
setMessages(history.current);
setTurn(null);
} catch (error) {
if (attempt !== generation.current) return;
setTurn({
message,
state: 'failed',
reason: error instanceof ChatError ? error.kind : 'network',
});
} finally {
setBusy(false);
requestAnimationFrame(() => listRef.current?.scrollToEnd({ animated: true }));
if (pending.current?.controller === controller) pending.current = null;
}
}, [draft, busy, messages]);
}, []);

const submit = () => {
const text = draft.trim();
if (!text || pending.current) return;
setDraft('');
void run({ id: `u-${++nextId.current}`, role: 'user', text });
};
const stop = () => {
const request = pending.current;
if (!request) {
setTurn((current) =>
current?.state === 'pending'
? { message: current.message, state: 'failed', reason: 'cancelled' }
: current,
);
return;
}
generation.current++;
pending.current = null;
request.controller.abort();
setTurn({ message: request.message, state: 'failed', reason: 'cancelled' });
};
const clear = () => {
generation.current++;
pending.current?.controller.abort();
pending.current = null;
history.current = INITIAL;
setMessages(INITIAL);
setTurn(null);
setConfirmClear(false);
};
useEffect(
() => () => {
generation.current++;
pending.current?.controller.abort();
pending.current = null;
},
[],
);

return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={90}
>
<View style={styles.actions}>
{confirmClear ? (
<>
<TouchableOpacity
accessibilityRole="button"
accessibilityLabel="Confirm clear conversation"
onPress={clear}
style={styles.action}
>
<Text style={styles.actionText}>Confirm clear</Text>
</TouchableOpacity>
<TouchableOpacity
accessibilityRole="button"
accessibilityLabel="Cancel clear conversation"
onPress={() => setConfirmClear(false)}
style={styles.action}
>
<Text style={styles.actionText}>Cancel</Text>
</TouchableOpacity>
</>
) : (
<TouchableOpacity
accessibilityRole="button"
accessibilityLabel="Clear conversation"
disabled={messages.length === 1 && !turn}
onPress={() => setConfirmClear(true)}
style={styles.action}
>
<Text style={styles.actionText}>Clear conversation</Text>
</TouchableOpacity>
)}
</View>
<FlatList
ref={listRef}
data={messages}
data={turn ? [...messages, turn.message] : messages}
onContentSizeChange={() =>
listRef.current?.scrollToEnd({ animated: true })
}
keyExtractor={(m) => m.id}
contentContainerStyle={styles.list}
renderItem={({ item }) => (
Expand All @@ -67,21 +181,47 @@ export function ChatScreen() {
</View>
)}
/>
{turn && (
<View style={styles.status}>
<Text accessibilityRole="alert" style={styles.statusText}>
{turn.state === 'pending'
? 'Waiting for reply...'
: FAILURE_COPY[turn.reason]}
</Text>
<TouchableOpacity
accessibilityRole="button"
accessibilityLabel={busy ? 'Stop response' : 'Retry message'}
accessibilityHint={busy ? undefined : 'Sends the message again.'}
style={styles.action}
onPress={
busy
? stop
: () => {
void run(turn.message);
}
}
>
<Text style={styles.actionText}>{busy ? 'Stop' : 'Retry'}</Text>
</TouchableOpacity>
</View>
)}
<View style={styles.composer}>
<TextInput
accessibilityLabel="Chat message"
style={styles.input}
value={draft}
onChangeText={setDraft}
onSubmitEditing={submit}
placeholder={busy ? 'Thinking…' : 'Message'}
placeholderTextColor={theme.textDim}
editable={!busy}
multiline
/>
<TouchableOpacity
style={[styles.send, (!draft.trim() || busy) && styles.sendDisabled]}
onPress={submit}
disabled={!draft.trim() || busy}
accessibilityRole="button"
accessibilityLabel="Send message"
>
<Text style={styles.sendText}>Send</Text>
</TouchableOpacity>
Expand All @@ -93,6 +233,21 @@ export function ChatScreen() {
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: theme.bg },
list: { padding: 12, gap: 8 },
actions: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'flex-end',
paddingHorizontal: 8,
},
action: { minHeight: 44, justifyContent: 'center', paddingHorizontal: 12 },
actionText: { color: theme.accent, fontWeight: '600' },
status: {
paddingHorizontal: 12,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
statusText: { color: theme.textDim, flex: 1, flexShrink: 1 },
bubble: { maxWidth: '85%', padding: 10, borderRadius: 12 },
userBubble: { alignSelf: 'flex-end', backgroundColor: theme.accentDim },
aiBubble: { alignSelf: 'flex-start', backgroundColor: theme.surfaceAlt },
Expand Down
Loading
Loading