| import useAppStore from '../store/appStore' |
| import { getTree, readFile } from '../api/projects' |
|
|
| |
| const RETRYING_MARKER = '__TESSERACT_RETRYING__' |
|
|
| export function useStream() { |
| async function sendMessage(text, { pinnedFiles = [] } = {}) { |
| const state = useAppStore.getState() |
| const { activeSessionId, project } = state |
| if (!activeSessionId || !text.trim()) return |
|
|
| state.appendMessage({ role: 'user', content: text }) |
| state.appendMessage({ role: 'assistant', content: '', toolCalls: [] }) |
| state.setIsStreaming(true) |
| |
| |
| state.resetInferenceStats() |
| state.setInferenceStats({ visible: true }) |
|
|
| const startTime = performance.now() |
|
|
| try { |
| await _stream({ |
| sessionId: activeSessionId, |
| message: text, |
| activeFile: project.activeFile, |
| pinnedFiles, |
| startTime, |
| }) |
|
|
| |
| |
| |
| if (project.id) { |
| await _resyncProject(project.id) |
| } |
| |
| } catch (err) { |
| if (err?.name !== 'AbortError') { |
| console.error('Stream error:', err) |
| useAppStore.getState().setLastAssistantError( |
| 'Something went wrong while talking to the model. Please try again.', |
| ) |
| } |
| } finally { |
| useAppStore.getState().setIsStreaming(false) |
| useAppStore.getState().setAbortController(null) |
| useAppStore.getState().clearPacing() |
| } |
| } |
|
|
| return { sendMessage } |
| } |
|
|
| |
| |
| |
| |
| async function _resyncProject(projectId) { |
| const store = useAppStore.getState() |
| const beforeFiles = store.project.files |
|
|
| try { |
| const tree = await getTree(projectId) |
| const treePaths = new Set(tree.filter((e) => !e.is_dir).map((e) => e.path)) |
|
|
| |
| const nextFiles = {} |
| for (const [p, content] of Object.entries(beforeFiles)) { |
| if (treePaths.has(p)) nextFiles[p] = content |
| } |
|
|
| |
| |
| const toReread = new Set(Object.keys(nextFiles)) |
| if (store.project.activeFile) toReread.add(store.project.activeFile) |
|
|
| const fetched = await Promise.all( |
| Array.from(toReread).map((p) => |
| readFile(projectId, p).then((c) => [p, c]).catch(() => null), |
| ), |
| ) |
| for (const entry of fetched) { |
| if (entry) nextFiles[entry[0]] = entry[1] |
| } |
|
|
| store.setProject({ tree, files: nextFiles }) |
| } catch (e) { |
| console.warn('project resync failed', e) |
| } |
| } |
|
|
| |
| async function _stream({ sessionId, message, activeFile, pinnedFiles, startTime }) { |
| const controller = new AbortController() |
| useAppStore.getState().setAbortController(controller) |
|
|
| let firstTokenTime = 0 |
| let tokenCount = 0 |
| let lastStatsUpdate = performance.now() |
| let serverError = null |
| let turnUsage = null |
| let liveSessionAdded = 0 |
| |
|
|
| const res = await fetch('/api/chat', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ |
| session_id: sessionId, |
| message, |
| active_file: activeFile, |
| pinned_files: pinnedFiles?.length ? pinnedFiles : undefined, |
| }), |
| signal: controller.signal, |
| }) |
|
|
| const reader = res.body.getReader() |
| const decoder = new TextDecoder() |
| let buffer = '' |
| let done = false |
|
|
| while (!done) { |
| let value, streamDone |
| try { |
| ;({ value, done: streamDone } = await reader.read()) |
| } catch (err) { |
| if (err?.name === 'AbortError') return { aborted: true, error: null } |
| throw err |
| } |
| if (streamDone) break |
|
|
| buffer += decoder.decode(value, { stream: true }) |
|
|
| |
| |
| let nl |
| while ((nl = buffer.indexOf('\n\n')) !== -1) { |
| const block = buffer.slice(0, nl) |
| buffer = buffer.slice(nl + 2) |
|
|
| for (const line of block.split('\n')) { |
| if (!line.startsWith('data: ')) continue |
| const payload = line.slice(6).trim() |
| if (payload === '[DONE]') { done = true; break } |
|
|
| let event |
| try { event = JSON.parse(payload) } catch { continue } |
|
|
| if (event.error) { |
| serverError = event.error |
| useAppStore.getState().setLastAssistantError(event.error) |
| continue |
| } |
|
|
| if (event.tool) { |
| useAppStore.getState().clearPacing() |
| useAppStore.getState().upsertLastAssistantTool(event.tool) |
| continue |
| } |
|
|
| if (event.usage) { |
| |
| |
| turnUsage = event.usage |
| continue |
| } |
|
|
| if (event.session_renamed) { |
| const { id, name } = event.session_renamed |
| const s = useAppStore.getState() |
| s.setSessions( |
| s.sessions.map((x) => (x.id === id ? { ...x, name } : x)) |
| ) |
| continue |
| } |
|
|
| if (event.pacing) { |
| |
| |
| const { wait_seconds, reason, snapshot } = event.pacing |
| useAppStore.getState().setPacing({ |
| waitSeconds: wait_seconds, |
| reason, |
| since: performance.now(), |
| }) |
| if (snapshot) useAppStore.getState().setRateLimit(snapshot) |
| continue |
| } |
|
|
| if (event.token) { |
| if (firstTokenTime === 0) { |
| firstTokenTime = performance.now() |
| useAppStore.getState().setInferenceStats({ |
| firstTokenMs: Math.round(firstTokenTime - startTime), |
| }) |
| } |
| useAppStore.getState().clearPacing() |
| useAppStore.getState().updateLastAssistantMessage(event.token) |
| tokenCount++ |
|
|
| |
| |
| |
| useAppStore.getState().addSessionTokens(1) |
| liveSessionAdded += 1 |
|
|
| const now = performance.now() |
| if (now - lastStatsUpdate > 150) { |
| const generationMs = now - firstTokenTime |
| const tps = generationMs > 0 ? tokenCount / (generationMs / 1000) : 0 |
| useAppStore.getState().setInferenceStats({ |
| tokens: tokenCount, |
| tokensPerSec: Math.round(tps * 10) / 10, |
| elapsedMs: Math.round(now - startTime), |
| }) |
| lastStatsUpdate = now |
| } |
| } |
| } |
| } |
| } |
|
|
| const endTime = performance.now() |
| const elapsedMs = endTime - startTime |
| const generationMs = firstTokenTime > 0 ? endTime - firstTokenTime : elapsedMs |
| const tps = generationMs > 0 ? tokenCount / (generationMs / 1000) : 0 |
|
|
| |
| |
| const realTotal = turnUsage?.total_tokens ?? 0 |
| const realPrompt = turnUsage?.prompt_tokens ?? 0 |
| const realOutput = turnUsage?.completion_tokens ?? 0 |
|
|
| useAppStore.getState().setInferenceStats({ |
| tokens: realOutput || tokenCount, |
| tokensPerSec: Math.round(tps * 10) / 10, |
| elapsedMs: Math.round(elapsedMs), |
| promptTokens: realPrompt, |
| totalTokens: realTotal, |
| }) |
|
|
| |
| |
| |
| |
| if (realTotal > 0) { |
| const delta = realTotal - liveSessionAdded |
| if (delta !== 0) useAppStore.getState().addSessionTokens(delta) |
| } |
|
|
| return { aborted: false, error: serverError } |
| } |
|
|
| export { RETRYING_MARKER } |
|
|