'use strict'; const byId = (id) => document.getElementById(id); const workspaceValue = String( typeof document !== 'undefined' && document.body ? document.body.dataset.workspace || 'chat' : 'chat' ); const WORKSPACE = workspaceValue === 'research' ? 'research' : 'chat'; const CHAT_STORAGE_PREFIX = WORKSPACE === 'research' ? 'synderesis_research_history_v2' : 'synderesis_chat_history_v2'; const LEGACY_CHAT_STORAGE_PREFIX = WORKSPACE === 'research' ? 'synderesis_research_history_v1' : 'synderesis_chat_history_v1'; const MEMORY_STORAGE_PREFIX = 'synderesis_individual_memory_v1'; const MAX_SAVED_CHATS = 20; const MAX_CHAT_TITLE_CHARS = 54; const MAX_TRANSCRIPT_MESSAGES = 100; const MAX_TRANSCRIPT_CHARS = 100000; const MAX_CHAT_STORAGE_CHARS = 2500000; const MAX_PERSISTED_SOURCES_PER_MESSAGE = 24; const MAX_SOURCE_EXCERPT_CHARS = 2400; const MAX_SOURCE_EXCERPT_TOTAL_CHARS = 6000; const MAX_SOURCE_FIELD_CHARS = 500; const PROBLEM_REPORT_DESCRIPTION_LIMIT = 800; const REASONING_EFFORTS = ['minimal', 'low', 'medium', 'high', 'max']; const PROBLEM_REPORT_CATEGORIES = { error: 'Error or interruption', answer: 'Answer display', task: 'Task behavior', usage: 'Usage or access', interface: 'Interface', other: 'Other' }; const SENSITIVE_URL_QUERY_NAMES = new Set([ 'access_token', 'api_key', 'apikey', 'auth', 'authorization', 'client_secret', 'code', 'credential', 'jwt', 'key', 'password', 'passwd', 'secret', 'session', 'sig', 'signature', 'token' ]); const PUBLIC_REPORT_CODES = new Set([ 'access_denied', 'completed_with_available_evidence', 'decision_limit', 'incomplete_stream', 'invalid_decision', 'network_error', 'quota_exceeded', 'rate_limit', 'request_failed', 'request_too_large', 'service_unavailable', 'stopped', 'stream_error', 'timeout', 'tool_limit', 'validation_error' ]); const PUBLIC_REPORT_STATUSES = new Set([ 'access_required', 'checking_response', 'completed', 'completed_with_available_evidence', 'decision_limit', 'idle', 'incomplete_stream', 'invalid_decision', 'preparing_answer', 'rate_limited', 'reading_context', 'request_failed', 'reviewing_sources', 'sending', 'service_unavailable', 'stopped', 'tool_limit', 'validation_error', 'working' ]); const DEFAULT_MEMORY_LIMITS = { maxEntries: 25, maxItemChars: 500, maxTotalChars: 6000 }; const state = { history: [], chats: [], activeChatId: null, storageKey: '', legacyStorageKey: '', storageAvailable: true, memoryKey: '', memoryStorageAvailable: true, memories: [], memoryEnabled: false, memoryLimits: { ...DEFAULT_MEMORY_LIMITS }, organizationMemory: [], organizationId: '', modelTier: 'spark', reasoningEffort: 'minimal', executionMode: 'answer', allowedModelTiers: new Set(['spark', 'pro']), allowedReasoningEfforts: new Set(REASONING_EFFORTS), allowedExecutionModes: new Set(['answer']), modelDetails: {}, outputTokenLimit: 0, sourceCapabilities: { advertised: false, officialAvailable: false, officialDefault: 'auto', families: [], webAvailable: false, reviewAvailable: false, reviewDefault: true }, sourcePolicy: { official: 'auto', families: [], webSearch: false, webSourceReview: true }, sharing: { enabled: false, expiresAfterDays: 30, textOnly: true }, connections: { github: { available: false, connected: false, enabled: false, repositories: [], repositoriesLoaded: false, repositoriesLoading: false, repositoryId: '', ref: '', paths: ['README.md'] }, byok: { available: false, configured: false, enabled: false, scope: '', lastFour: '', expiresAt: '' } }, lastShareToken: '', lastShareUrl: '', lastShareChatId: '', ghost: false, files: [], limits: { maxFiles: 4, maxFileBytes: 5000000, maxTotalFileBytes: 10000000, maxTotalTextChars: 20000, historyMaxMessages: 12, historyMaxChars: 12000, historyMaxItemChars: 6000 }, enabled: false, imageInputs: false, imageMediaTypes: new Set(), requestEpoch: 0, activeController: null, activePendingMessage: null, pending: false, lastRequest: null, historyQuery: '', drawerReturnFocus: null, problemReport: { workspace: WORKSPACE, tier: '', reasoningEffort: '', mode: 'answer', requestId: '', httpStatus: 0, progressStatus: 'idle', completionStatus: '', errorCode: '', taskStatus: '' }, problemReportDraftTime: '' }; const IMAGE_EXTENSIONS = new Set(['avif', 'bmp', 'gif', 'heic', 'jpeg', 'jpg', 'png', 'tif', 'tiff', 'webp']); const CHAT_STREAM_LIMITS = Object.freeze({ maxFrameBytes: 256000, maxBufferBytes: 256000, maxAnswerBytes: 262144, maxCitationsBytes: 256000, maxTotalBytes: 2097152, maxFrames: 4096, maxStatusFrames: 512, maxStatusChars: 160, maxCitationCount: 32, maxErrorMessageChars: 500, maxRequestIdChars: 128, maxRetryAfterSeconds: 86400, idleTimeoutMs: 45000, overallTimeoutMs: 720000 }); const SAFE_CHAT_ERROR_CODE = /^[a-z][a-z0-9_]{0,63}$/; const SAFE_CHAT_REQUEST_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; function chatAbortError() { const error = new Error('The request was stopped.'); error.name = 'AbortError'; error.kind = 'abort'; return error; } function chatProtocolError(protocolCode) { const error = new Error('The response stream was invalid. Please retry.'); error.name = 'ChatStreamProtocolError'; error.kind = 'protocol'; error.code = 'stream_protocol_error'; error.protocolCode = String(protocolCode || 'invalid_stream').slice(0, 80); error.retryable = true; return error; } function chatTimeoutError(code) { const error = new Error('The response timed out. Please retry.'); error.name = 'ChatStreamTimeoutError'; error.kind = 'network'; error.code = code === 'overall_timeout' ? 'overall_timeout' : 'idle_timeout'; error.retryable = true; return error; } function normaliseSafeStreamText(value, limit) { return String(value == null ? '' : value) .replace(/[\u0000-\u001f\u007f]+/g, ' ') .replace(/\s+/g, ' ') .trim() .slice(0, limit); } function streamLimitOptions(overrides = {}) { const values = { ...CHAT_STREAM_LIMITS }; Object.keys(values).forEach((key) => { if (!Object.prototype.hasOwnProperty.call(overrides, key)) return; const candidate = Number(overrides[key]); if (Number.isFinite(candidate) && candidate > 0) values[key] = Math.floor(candidate); }); return Object.freeze(values); } function byteArray(value) { if (value instanceof Uint8Array) return value; if (value instanceof ArrayBuffer) return new Uint8Array(value); if (ArrayBuffer.isView(value)) { return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); } throw chatProtocolError('invalid_chunk'); } function joinBytes(left, right, maximum) { if (left.byteLength + right.byteLength > maximum) { throw chatProtocolError('buffer_too_large'); } if (!left.byteLength) return right.slice(); if (!right.byteLength) return left.slice(); const joined = new Uint8Array(left.byteLength + right.byteLength); joined.set(left); joined.set(right, left.byteLength); return joined; } function safeErrorCode(value) { const code = typeof value === 'string' ? value.trim() : ''; return SAFE_CHAT_ERROR_CODE.test(code) ? code : ''; } function safeRequestId(value, limit = CHAT_STREAM_LIMITS.maxRequestIdChars) { const requestId = typeof value === 'string' ? value.trim().slice(0, limit) : ''; return SAFE_CHAT_REQUEST_ID.test(requestId) ? requestId : ''; } function safeStatusCode(value) { const status = Number(value); return Number.isInteger(status) && status >= 400 && status <= 599 ? status : 0; } function boundedRetryAfter(value, maximum = CHAT_STREAM_LIMITS.maxRetryAfterSeconds) { if (typeof value === 'boolean' || value == null || value === '') return 0; const seconds = Number(value); if (!Number.isFinite(seconds) || seconds < 0) return 0; return Math.min(Math.ceil(seconds), maximum); } function parseRetryAfterHeader(value, nowMs = Date.now()) { const raw = typeof value === 'string' ? value.trim() : ''; if (!raw) return 0; if (/^\d+(?:\.\d+)?$/.test(raw)) return boundedRetryAfter(raw); const retryAt = Date.parse(raw); if (!Number.isFinite(retryAt)) return 0; return boundedRetryAfter(Math.max(0, (retryAt - nowMs) / 1000)); } function errorFrameMetadata(frame, limits = CHAT_STREAM_LIMITS) { if (!frame.error || typeof frame.error !== 'object' || Array.isArray(frame.error)) { throw chatProtocolError('malformed_error'); } const raw = frame.error; const rawCode = raw.code ?? raw.type ?? frame.code; const code = safeErrorCode(rawCode); if (!code) throw chatProtocolError('malformed_error_code'); if (typeof raw.message !== 'string' || !raw.message.trim()) { throw chatProtocolError('malformed_error_message'); } const message = normaliseSafeStreamText(raw.message, limits.maxErrorMessageChars); if (!message) throw chatProtocolError('malformed_error_message'); const rawStatus = raw.status ?? frame.status; const status = rawStatus == null ? 0 : safeStatusCode(rawStatus); if (rawStatus != null && !status) throw chatProtocolError('malformed_error_status'); const rawRetryAfter = raw.retry_after ?? frame.retry_after; if ( rawRetryAfter != null && ( typeof rawRetryAfter === 'boolean' || !Number.isFinite(Number(rawRetryAfter)) || Number(rawRetryAfter) < 0 ) ) throw chatProtocolError('malformed_retry_after'); const rawRetryable = raw.retryable ?? frame.retryable; if (rawRetryable != null && typeof rawRetryable !== 'boolean') { throw chatProtocolError('malformed_retryable'); } const rawRequestId = raw.request_id ?? frame.request_id; const requestId = rawRequestId == null ? '' : safeRequestId(rawRequestId, limits.maxRequestIdChars); if (rawRequestId != null && !requestId) throw chatProtocolError('malformed_request_id'); return Object.freeze({ code, message, status, retryAfter: boundedRetryAfter(rawRetryAfter, limits.maxRetryAfterSeconds), retryable: typeof rawRetryable === 'boolean' ? rawRetryable : null, requestId }); } function serverStreamError(metadata) { const error = new Error(metadata.message); error.name = 'ChatStreamServerError'; error.kind = 'server'; error.code = metadata.code; error.status = metadata.status; error.retryAfter = metadata.retryAfter; error.retryable = metadata.retryable; error.requestId = metadata.requestId; return error; } function createNdjsonProtocolState(overrides = {}) { const limits = streamLimitOptions(overrides); const decoder = new TextDecoder('utf-8', { fatal: true }); const encoder = new TextEncoder(); let pending = new Uint8Array(); let totalBytes = 0; let frameCount = 0; let statusCount = 0; let answerBytes = 0; let answer = ''; let citations = null; let citationsFrame = null; let doneFrame = null; let errorMetadata = null; let phase = 'status'; let terminal = ''; let finished = false; let finalResult = null; const decodeFrame = (bytes) => { let frameBytes = bytes; if (frameBytes.byteLength && frameBytes[frameBytes.byteLength - 1] === 13) { frameBytes = frameBytes.slice(0, -1); } if (!frameBytes.byteLength) throw chatProtocolError('blank_frame'); if (frameBytes.byteLength > limits.maxFrameBytes) { throw chatProtocolError('frame_too_large'); } let line = ''; try { line = decoder.decode(frameBytes); } catch (_) { throw chatProtocolError('invalid_utf8'); } if (!line.trim()) throw chatProtocolError('blank_frame'); let frame; try { frame = JSON.parse(line); } catch (_) { throw chatProtocolError('malformed_json'); } if (!frame || typeof frame !== 'object' || Array.isArray(frame)) { throw chatProtocolError('nonobject_frame'); } return { frame, frameBytes: frameBytes.byteLength }; }; const handleFrame = (bytes) => { if (terminal) throw chatProtocolError('frame_after_terminal'); frameCount += 1; if (frameCount > limits.maxFrames) throw chatProtocolError('too_many_frames'); const parsed = decodeFrame(bytes); const frame = parsed.frame; if (typeof frame.type !== 'string') throw chatProtocolError('missing_frame_type'); if (frame.type === 'status') { if (phase !== 'status') throw chatProtocolError('status_out_of_order'); if (typeof frame.message !== 'string') throw chatProtocolError('malformed_status'); const message = normaliseSafeStreamText(frame.message, limits.maxStatusChars); if (!message || frame.message.length > limits.maxStatusChars) { throw chatProtocolError('malformed_status'); } statusCount += 1; if (statusCount > limits.maxStatusFrames) throw chatProtocolError('too_many_status_frames'); return Object.freeze({ type: 'status', message }); } if (frame.type === 'delta') { if (phase !== 'status' && phase !== 'delta') { throw chatProtocolError('delta_out_of_order'); } if (typeof frame.text !== 'string' || !frame.text.length) { throw chatProtocolError('empty_delta'); } const deltaBytes = encoder.encode(frame.text).byteLength; if (answerBytes + deltaBytes > limits.maxAnswerBytes) { throw chatProtocolError('answer_too_large'); } answer += frame.text; answerBytes += deltaBytes; phase = 'delta'; return Object.freeze({ type: 'delta', text: frame.text }); } if (frame.type === 'citations') { if (phase !== 'delta') throw chatProtocolError('citations_out_of_order'); if (!Array.isArray(frame.citations)) throw chatProtocolError('malformed_citations'); if (frame.citations.length > limits.maxCitationCount) { throw chatProtocolError('too_many_citations'); } if ( frame.citation_manifest != null && ( typeof frame.citation_manifest !== 'object' || Array.isArray(frame.citation_manifest) ) ) throw chatProtocolError('malformed_citation_manifest'); if (parsed.frameBytes > limits.maxCitationsBytes) { throw chatProtocolError('citations_too_large'); } citations = frame.citations.slice(); citationsFrame = frame; phase = 'citations'; return Object.freeze({ type: 'citations', citations, citationManifest: frame.citation_manifest || {} }); } if (frame.type === 'done') { if (phase !== 'citations') throw chatProtocolError('done_out_of_order'); if (!answer.trim()) throw chatProtocolError('empty_answer'); terminal = 'done'; phase = 'terminal'; doneFrame = frame; return Object.freeze({ type: 'done', payload: frame }); } if (frame.type === 'error') { errorMetadata = errorFrameMetadata(frame, limits); terminal = 'error'; phase = 'terminal'; return Object.freeze({ type: 'error', error: errorMetadata }); } throw chatProtocolError('unknown_frame'); }; const push = (chunk) => { if (finished) throw chatProtocolError('chunk_after_finish'); const bytes = byteArray(chunk); totalBytes += bytes.byteLength; if (totalBytes > limits.maxTotalBytes) throw chatProtocolError('stream_too_large'); const events = []; let offset = 0; while (offset < bytes.byteLength) { const newline = bytes.indexOf(10, offset); if (newline < 0) { pending = joinBytes( pending, bytes.slice(offset), Math.min(limits.maxBufferBytes, limits.maxFrameBytes) ); offset = bytes.byteLength; break; } const segment = bytes.slice(offset, newline); const frameBytes = joinBytes( pending, segment, Math.min(limits.maxBufferBytes, limits.maxFrameBytes) ); pending = new Uint8Array(); events.push(handleFrame(frameBytes)); offset = newline + 1; } return events; }; const finish = () => { if (finished) return finalResult; if (pending.byteLength) { handleFrame(pending); pending = new Uint8Array(); } if (!terminal) throw chatProtocolError('incomplete_stream'); finished = true; finalResult = terminal === 'done' ? Object.freeze({ terminal, answer, citations: citations || [], citationFrame: citationsFrame, done: doneFrame }) : Object.freeze({ terminal, error: errorMetadata }); return finalResult; }; return Object.freeze({ push, finish, snapshot: () => Object.freeze({ phase, terminal, totalBytes, frameCount, statusCount, answerBytes }) }); } function isNdjsonMediaType(value) { const mediaType = String(value || '').split(';', 1)[0].trim().toLowerCase(); return mediaType === 'application/x-ndjson'; } function csrfToken() { if (typeof document === 'undefined') return ''; const part = document.cookie.split('; ').find((item) => item.startsWith('synderesis_csrf=')); return part ? decodeURIComponent(part.slice(part.indexOf('=') + 1)) : ''; } async function api(path, options = {}) { const headers = new Headers(options.headers || {}); if (options.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json'); if (!['GET', 'HEAD'].includes((options.method || 'GET').toUpperCase())) { const csrf = csrfToken(); if (csrf) headers.set('X-CSRF-Token', csrf); } const response = await fetch(path, { ...options, headers, credentials: 'same-origin', cache: 'no-store' }); let payload = {}; try { payload = await response.json(); } catch (_) { payload = {}; } if (!response.ok) { const message = payload.error && payload.error.message ? payload.error.message : `Request failed (${response.status})`; const error = new Error(message); error.status = response.status; throw error; } return payload; } function showNotice(message, severity = 'error') { const notice = byId('chat-notice'); const tone = ['info', 'warning', 'error'].includes(severity) ? severity : 'error'; notice.textContent = message; notice.dataset.severity = tone; notice.setAttribute('role', tone === 'error' ? 'alert' : 'status'); notice.setAttribute('aria-live', tone === 'error' ? 'assertive' : 'polite'); notice.classList.add('show'); } function clearNotice() { const notice = byId('chat-notice'); notice.textContent = ''; notice.dataset.severity = 'info'; notice.setAttribute('role', 'status'); notice.setAttribute('aria-live', 'polite'); notice.classList.remove('show'); } function addMessage(role, content, pending = false, shouldScroll = true) { const item = document.createElement('article'); item.className = `message ${role}${pending ? ' pending' : ''}`; const label = document.createElement('div'); label.className = 'message-label'; label.textContent = role === 'user' ? 'You' : 'Synderesis'; const body = document.createElement('div'); body.className = 'message-body'; body.textContent = content; item.append(label, body); const messages = byId('messages'); messages.appendChild(item); if (shouldScroll) { if (typeof messages.scrollTo === 'function') { messages.scrollTo({ top: messages.scrollHeight, behavior: 'smooth' }); } else { item.scrollIntoView({ block: 'end', behavior: 'smooth' }); } } return item; } function showWaitingIndicator(message, label = 'Working on your response…') { if (!message || typeof message.querySelector !== 'function') return null; const body = message.querySelector('.message-body'); if (!body || !body.ownerDocument) return null; while (body.firstChild) body.removeChild(body.firstChild); body.textContent = ''; const indicator = body.ownerDocument.createElement('div'); indicator.className = 'waiting-indicator'; const dots = body.ownerDocument.createElement('span'); dots.className = 'waiting-dots'; dots.setAttribute('aria-hidden', 'true'); for (let index = 0; index < 3; index += 1) { const dot = body.ownerDocument.createElement('span'); dot.className = 'waiting-dot'; dots.appendChild(dot); } const status = body.ownerDocument.createElement('span'); status.className = 'waiting-label'; status.textContent = String(label || 'Working on your response…'); indicator.appendChild(dots); indicator.appendChild(status); body.appendChild(indicator); message._synderesisWaitingIndicator = indicator; message._synderesisWaitingLabel = status; return indicator; } function updateWaitingIndicator(message, label) { if (!message || !message._synderesisWaitingLabel) return false; message._synderesisWaitingLabel.textContent = String(label || 'Working on your response…'); return true; } function clearWaitingIndicator(message, { clearBody = true } = {}) { if (!message || typeof message.querySelector !== 'function') return false; const body = message.querySelector('.message-body'); const indicator = message._synderesisWaitingIndicator; if (indicator && indicator.parentNode) indicator.parentNode.removeChild(indicator); message._synderesisWaitingIndicator = null; message._synderesisWaitingLabel = null; if (clearBody && body) body.textContent = ''; return Boolean(indicator); } function extensionOf(file) { const pieces = file.name.toLowerCase().split('.'); return pieces.length > 1 ? pieces.pop() : ''; } function isImage(file) { return file.type.startsWith('image/') || IMAGE_EXTENSIONS.has(extensionOf(file)); } function formatByteLimit(value) { const bytes = Math.max(0, Number(value) || 0); if (bytes >= 1000000) return `${Math.floor(bytes / 1000000)} MB`; return `${Math.floor(bytes / 1000)} KB`; } function formatCount(value) { return new Intl.NumberFormat().format(Math.max(0, Number(value) || 0)); } function boundedText(value, limit = MAX_SOURCE_FIELD_CHARS) { return String(value == null ? '' : value).replace(/\s+/g, ' ').trim().slice(0, limit); } function sourceLabel(value) { const normalized = boundedText(value, 120).replace(/[_-]+/g, ' '); if (!normalized) return ''; const acronyms = new Set(['ccc', 'cic', 'cceo']); return normalized .split(/\s+/) .map((word) => ( acronyms.has(word.toLowerCase()) ? word.toUpperCase() : `${word.charAt(0).toUpperCase()}${word.slice(1).toLowerCase()}` )) .join(' '); } function isPublicSourceHostname(value) { const host = String(value || '').toLowerCase().replace(/\.$/, '').replace(/^\[|\]$/g, ''); if ( !host || host === 'localhost' || !host.includes('.') || [ '.localhost', '.local', '.internal', '.intranet', '.lan', '.home', '.test', '.invalid', '.example', '.onion' ] .some((suffix) => host.endsWith(suffix)) || host.includes(':') ) return false; if (/^[0-9.]+$/.test(host)) { const parts = host.split('.').map(Number); if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) { return false; } const [first, second, third] = parts; if ( first === 0 || first === 10 || first === 127 || first >= 224 || (first === 100 && second >= 64 && second <= 127) || (first === 169 && second === 254) || (first === 172 && second >= 16 && second <= 31) || (first === 192 && second === 168) || (first === 192 && second === 0 && (third === 0 || third === 2)) || (first === 198 && (second === 18 || second === 19 || second === 51)) || (first === 203 && second === 0 && third === 113) ) return false; } return host.split('.').every( (label) => label && label.length <= 63 && !label.startsWith('-') && !label.endsWith('-') && /^[a-z0-9-]+$/.test(label) ); } function isSensitiveUrlQueryName(value) { const normalized = String(value || '') .toLowerCase() .replace(/[-.]+/g, '_') .replace(/^_+|_+$/g, ''); return SENSITIVE_URL_QUERY_NAMES.has(normalized) || ['x_amz_', 'x_goog_', 'auth_'].some((prefix) => normalized.startsWith(prefix)) || ['_token', '_key', '_secret', '_signature', '_credential', '_jwt'] .some((suffix) => normalized.endsWith(suffix)); } function safeHttpsUrl(value) { try { const parsed = new URL(String(value || '')); if ( parsed.protocol !== 'https:' || parsed.username || parsed.password || (parsed.port && parsed.port !== '443') || !isPublicSourceHostname(parsed.hostname) || Array.from(parsed.searchParams.keys()).some(isSensitiveUrlQueryName) ) return ''; parsed.hash = ''; return parsed.href; } catch (_) { return ''; } } function safeReportToken(value, limit = 128) { const normalized = String(value == null ? '' : value).trim().slice(0, limit); return normalized && /^[a-zA-Z0-9._:-]+$/.test(normalized) ? normalized : ''; } function normalisePublicErrorCode(value, httpStatus = 0) { const normalized = safeReportToken(value, 80).toLowerCase().replace(/-/g, '_'); if (PUBLIC_REPORT_CODES.has(normalized)) return normalized; if (Number(httpStatus) === 429) return 'rate_limit'; if (Number(httpStatus) === 413) return 'request_too_large'; if (Number(httpStatus) === 408 || Number(httpStatus) === 504) return 'timeout'; if (Number(httpStatus) === 503) return 'service_unavailable'; if ([400, 422].includes(Number(httpStatus))) return 'validation_error'; if ([401, 403].includes(Number(httpStatus))) return 'access_denied'; return normalized ? 'request_failed' : ''; } function normalisePublicReportStatus(value, fallback = 'working') { const normalized = safeReportToken(value, 80).toLowerCase().replace(/-/g, '_'); return PUBLIC_REPORT_STATUSES.has(normalized) ? normalized : PUBLIC_REPORT_STATUSES.has(fallback) ? fallback : 'working'; } function friendlyStreamStatus(value) { const status = String(value || '').toLowerCase(); if (/(upload|attachment|document|context|extract|read)/.test(status)) { return { message: 'Reading the provided context…', code: 'reading_context' }; } if (/(search|retriev|source|evidence|research)/.test(status)) { return { message: 'Reviewing sources…', code: 'reviewing_sources' }; } if (/(validat|verify|ground|check|review|safety)/.test(status)) { return { message: 'Checking the response…', code: 'checking_response' }; } if (/(answer|draft|write|generat|respond|final)/.test(status)) { return { message: 'Preparing the answer…', code: 'preparing_answer' }; } if (/(task|action|decision|controller|plan|step)/.test(status)) { return { message: 'Working through the task…', code: 'working' }; } return { message: 'Working on your response…', code: 'working' }; } function requestIdFromResponse(response) { if (!response || !response.headers || typeof response.headers.get !== 'function') return ''; return safeReportToken(response.headers.get('X-Request-ID'), 128); } function normaliseProblemReportContext(value) { const raw = value && typeof value === 'object' ? value : {}; const workspace = raw.workspace === 'research' ? 'research' : 'chat'; return { requestId: safeReportToken(raw.requestId, 128), workspace, tier: workspace === 'chat' && ['spark', 'pro'].includes(raw.tier) ? raw.tier : '', reasoningEffort: workspace === 'chat' && REASONING_EFFORTS.includes(raw.reasoningEffort) ? raw.reasoningEffort : '', mode: raw.mode === 'task' ? 'task' : 'answer', progressStatus: normalisePublicReportStatus(raw.progressStatus || 'idle', 'idle'), completionStatus: raw.completionStatus ? normalisePublicReportStatus(raw.completionStatus, 'request_failed') : '', errorCode: raw.errorCode ? normalisePublicErrorCode(raw.errorCode, raw.httpStatus) : '', httpStatus: Number.isInteger(raw.httpStatus) && raw.httpStatus >= 100 && raw.httpStatus <= 599 ? raw.httpStatus : 0, taskStatus: raw.taskStatus ? normalisePublicReportStatus(raw.taskStatus, 'working') : '' }; } function cleanProblemDescription(value) { return String(value == null ? '' : value) .replace(/\r\n?/g, '\n') .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '') .trim() .slice(0, PROBLEM_REPORT_DESCRIPTION_LIMIT); } function createProblemReportText(category, description, context, timestamp = new Date()) { const categoryKey = Object.prototype.hasOwnProperty.call(PROBLEM_REPORT_CATEGORIES, category) ? category : 'other'; const report = normaliseProblemReportContext(context); const requestedTime = new Date(timestamp); const utcTime = Number.isNaN(requestedTime.getTime()) ? new Date().toISOString() : requestedTime.toISOString(); const lines = [ 'Synderesis problem report', '', `Category: ${PROBLEM_REPORT_CATEGORIES[categoryKey]}`, 'Description:', cleanProblemDescription(description) || '(not provided)', '', 'Diagnostic context', `UTC time: ${utcTime}`, `Request ID: ${report.requestId || 'Unavailable'}`, `Workspace: ${report.workspace === 'research' ? 'Research' : 'Catholic'}`, `Mode: ${report.mode === 'task' ? 'Task' : 'Answer'}` ]; if (report.tier) lines.push(`Tier: ${report.tier === 'pro' ? 'Pro' : 'Spark'}`); if (report.reasoningEffort) { lines.push(`Reasoning effort: ${report.reasoningEffort[0].toUpperCase()}${report.reasoningEffort.slice(1)}`); } lines.push(`Progress status: ${report.progressStatus}`); if (report.completionStatus) lines.push(`Completion status: ${report.completionStatus}`); if (report.errorCode) lines.push(`Error code: ${report.errorCode}`); if (report.httpStatus) lines.push(`HTTP status: ${report.httpStatus}`); if (report.taskStatus) lines.push(`Task status: ${report.taskStatus}`); return lines.join('\n'); } function problemReportMailto(category, reportText) { const categoryKey = Object.prototype.hasOwnProperty.call(PROBLEM_REPORT_CATEGORIES, category) ? category : 'other'; const subject = `Synderesis problem report: ${PROBLEM_REPORT_CATEGORIES[categoryKey]}`; return `mailto:hello@synderesis.eu?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(String(reportText || ''))}`; } function requestErrorNotice(error) { const status = Number(error && error.status) || 0; const code = normalisePublicErrorCode( error && (error.code || error.errorCode), status ); if (code === 'invalid_decision') { return { message: 'The Task could not safely continue. Retry when you are ready.', severity: 'warning', code }; } if (code === 'completed_with_available_evidence') { return { message: 'The Task finished with the evidence available before it paused.', severity: 'info', code }; } if (code === 'rate_limit' || code === 'quota_exceeded') { return { message: 'A temporary usage limit was reached. Wait a little and try again.', severity: 'warning', code }; } if (code === 'request_too_large' || code === 'validation_error') { return { message: 'The request could not be used as submitted. Review its text or files and try again.', severity: 'warning', code }; } if (code === 'access_denied') { return { message: 'Chat access needs to be refreshed. Sign in again and retry.', severity: 'warning', code }; } const rawMessage = String(error && error.message || '').trim().slice(0, 320); const exposesImplementation = /(controller|gatekeeper|provider|model route|stack|trace|invalid decision)/i.test(rawMessage); return { message: rawMessage && !exposesImplementation ? rawMessage : 'Synderesis could not complete this request. Please try again.', severity: 'error', code: code || 'request_failed' }; } function normaliseSourceRecord(value, fallbackKind = 'official') { const raw = value && typeof value === 'object' ? value : {}; const review = raw.source_review && typeof raw.source_review === 'object' ? raw.source_review : raw.review && typeof raw.review === 'object' ? raw.review : {}; const kindSignals = [ raw.kind, raw.source_kind, raw.source_family, raw.content_kind, raw.chunk_kind, raw.provenance_kind, raw.provenance, fallbackKind ].map((item) => boundedText(item, 120).toLowerCase()); const kind = kindSignals.some((item) => ( item === 'web' || item === 'provider_excerpt' || item.includes('web_search') )) ? 'web' : 'official'; const contentKind = boundedText(raw.content_kind || raw.chunk_kind, 80).toLowerCase(); const excerptKind = boundedText(raw.excerpt_kind, 80) || ( ['note', 'curated_source_note'].includes(contentKind) ? 'Curated source note' : ['registry', 'registry_metadata'].includes(contentKind) ? 'Source registry metadata' : contentKind === 'provider_excerpt' || kind === 'web' ? 'Web excerpt' : 'Verbatim passage' ); return { kind, citationId: boundedText(raw.citationId || raw.citation_id || raw.id || raw.key, 80), sourceId: boundedText(raw.sourceId || raw.source_id || raw.doc_id, 160), location: boundedText(raw.location || raw.loc, 240), title: boundedText(raw.title || raw.source_title, MAX_SOURCE_FIELD_CHARS), publisher: boundedText(raw.publisher, 240), domain: boundedText(raw.domain, 240), url: safeHttpsUrl(raw.url || raw.canonical_url || raw.source_url || raw.official_url), excerpt: String(raw.excerpt != null ? raw.excerpt : raw.text != null ? raw.text : raw.summary || '').trim().slice(0, MAX_SOURCE_EXCERPT_CHARS), excerptKind: boundedText(raw.excerptKind || excerptKind, 80), excerptHash: boundedText(raw.excerptHash || raw.excerpt_hash, 160), accessedAt: boundedText(raw.accessedAt || raw.accessed_at, 80), authorityClass: boundedText(raw.authorityClass || raw.authority_class, 120), authorityLabel: boundedText(raw.authorityLabel || raw.authority_label, 120) || sourceLabel(raw.authorityClass || raw.authority_class), sourceFamily: boundedText(raw.sourceFamily || raw.source_family, 120), sourceFamilyLabel: boundedText(raw.sourceFamilyLabel || raw.source_family_label, 120) || sourceLabel(raw.sourceFamily || raw.source_family), contentKind: boundedText(raw.contentKind || raw.content_kind || raw.chunk_kind, 120), provenance: boundedText(raw.provenance, 240), reviewStatus: boundedText(raw.reviewStatus || raw.review_status || review.status, 80), reviewReason: boundedText(raw.reviewReason || raw.review_reason || review.reason, MAX_SOURCE_FIELD_CHARS) }; } function sourceIdentity(source) { if (source.citationId) return `${source.kind}:citation:${source.citationId}`; if (source.sourceId && source.location) return `${source.kind}:pair:${source.sourceId}\u0000${source.location}`; if (source.url) return `${source.kind}:url:${source.url}`; return `${source.kind}:title:${source.title}\u0000${source.location}`; } function normaliseSourceManifest(value) { const raw = value && typeof value === 'object' ? value : {}; const entries = Array.isArray(value) ? value : [ ...(Array.isArray(raw.official) ? raw.official.map((item) => ({ item, kind: 'official' })) : []), ...(Array.isArray(raw.web) ? raw.web.map((item) => ({ item, kind: 'web' })) : []) ]; const sources = []; const seen = new Set(); let excerptChars = 0; for (const entry of entries.slice(0, MAX_PERSISTED_SOURCES_PER_MESSAGE * 3)) { const item = entry && entry.item ? entry.item : entry; const kind = entry && entry.kind ? entry.kind : item && (item.kind || item.source_kind); const source = normaliseSourceRecord(item, kind); if ( (!source.citationId && !source.sourceId && !source.url) || !source.title && !source.sourceId && !source.domain ) continue; const key = sourceIdentity(source); if (seen.has(key)) continue; const remainingExcerptChars = Math.max(0, MAX_SOURCE_EXCERPT_TOTAL_CHARS - excerptChars); source.excerpt = source.excerpt.slice(0, remainingExcerptChars); excerptChars += source.excerpt.length; seen.add(key); sources.push(source); if (sources.length >= MAX_PERSISTED_SOURCES_PER_MESSAGE) break; } return sources; } function sourceManifestFromResponse(payload) { if (payload && payload.citation_manifest) { return normaliseSourceManifest(payload.citation_manifest); } const structured = payload && payload.structured_answer; const citations = structured && Array.isArray(structured.citations) ? structured.citations : []; const passages = payload && Array.isArray(payload.retrieved_sources) ? payload.retrieved_sources : []; const matched = []; citations.forEach((citation, index) => { const sourceId = boundedText(citation && (citation.source_id || citation.doc_id), 160); const location = boundedText(citation && (citation.location || citation.loc), 240); const passage = passages.find((item) => ( boundedText(item && item.source_id, 160) === sourceId && boundedText(item && item.location, 240) === location )); if (!passage) return; matched.push({ ...passage, citation_id: passage.citation_id || `o${index + 1}`, kind: 'official' }); }); return normaliseSourceManifest({ official: matched, web: [] }); } function normaliseSourcePolicy(value, capabilities = state.sourceCapabilities) { const raw = value && typeof value === 'object' ? value : {}; const official = ['auto', 'on', 'off'].includes(raw.official) ? raw.official : capabilities.officialDefault || 'auto'; const allowedFamilies = new Set( capabilities.families .filter((item) => item.available !== false) .map((item) => item.value) ); const families = official !== 'off' && Array.isArray(raw.families) ? raw.families.filter((item) => allowedFamilies.has(item)).slice(0, 12) : []; return { official, families, webSearch: capabilities.webAvailable && Boolean(raw.webSearch ?? raw.web_search), webSourceReview: capabilities.reviewAvailable ? Boolean(raw.webSourceReview ?? raw.web_source_review ?? capabilities.reviewDefault) : false }; } function normaliseTranscript(value) { if (!Array.isArray(value)) return []; const pairs = []; for (let index = 0; index + 1 < value.length; index += 2) { const user = value[index]; const assistant = value[index + 1]; if ( !user || !assistant || user.role !== 'user' || assistant.role !== 'assistant' || typeof user.content !== 'string' || typeof assistant.content !== 'string' ) continue; const userContent = user.content.trim().slice(0, 40000); const assistantContent = assistant.content.trim().slice(0, 40000); if (!userContent || !assistantContent) continue; pairs.push([ { id: boundedText(user.id, 128) || `message-${index}-user-${userContent.length}`, role: 'user', content: userContent, createdAt: Number(user.createdAt) > 0 ? Number(user.createdAt) : Date.now() }, { id: boundedText(assistant.id, 128) || `message-${index + 1}-assistant-${assistantContent.length}`, role: 'assistant', content: assistantContent, createdAt: Number(assistant.createdAt) > 0 ? Number(assistant.createdAt) : Date.now(), sources: normaliseSourceManifest(assistant.sources) } ]); } const retained = []; let chars = 0; const maxPairs = Math.floor(MAX_TRANSCRIPT_MESSAGES / 2); for (let index = pairs.length - 1; index >= 0 && retained.length < maxPairs; index -= 1) { const pairChars = pairs[index][0].content.length + pairs[index][1].content.length; if (retained.length && chars + pairChars > MAX_TRANSCRIPT_CHARS) break; retained.push(pairs[index]); chars += pairChars; } return retained.reverse().flat(); } function fairTextAllocations(capacities, totalBudget) { const normalised = capacities.map((value) => Math.max(0, Number(value) || 0)); if (normalised.reduce((sum, value) => sum + value, 0) <= totalBudget) return normalised; const allocations = normalised.map(() => 0); const active = normalised.map((value, index) => value > 0 ? index : -1).filter((index) => index >= 0); let remaining = totalBudget; while (active.length && remaining > 0) { const share = Math.floor(remaining / active.length); const smaller = active.filter((index) => normalised[index] <= share); if (smaller.length) { smaller.forEach((index) => { allocations[index] = normalised[index]; remaining -= normalised[index]; active.splice(active.indexOf(index), 1); }); continue; } active.forEach((index) => { allocations[index] = share; }); remaining -= share * active.length; for (const index of active) { if (remaining <= 0) break; allocations[index] += 1; remaining -= 1; } break; } return allocations; } function clipHistoryContent(content, limit) { const marker = '\n\n[Earlier message truncated for context]'; if (content.length <= limit) return content; if (limit <= marker.length) return marker.trim().slice(0, limit); const prefixLimit = limit - marker.length; let prefix = content.slice(0, prefixLimit); const boundary = prefix.lastIndexOf(' '); if (boundary >= Math.max(1, prefixLimit - 256)) prefix = prefix.slice(0, boundary); return `${prefix.trimEnd()}${marker}`.slice(0, limit); } function compactHistory(history, limits = state.limits) { const completeLength = history.length - (history.length % 2); const pairs = []; for (let index = 0; index < completeLength; index += 2) { if (history[index].role !== 'user' || history[index + 1].role !== 'assistant') continue; pairs.push([history[index], history[index + 1]]); } const selected = []; let remainingChars = limits.historyMaxChars; let remainingMessages = limits.historyMaxMessages; for (let index = pairs.length - 1; index >= 0; index -= 1) { if (remainingMessages < 2 || remainingChars < 2) break; const capacities = pairs[index].map((item) => Math.min(item.content.length, limits.historyMaxItemChars)); const allocations = fairTextAllocations(capacities, remainingChars); if (allocations.some((value) => value <= 0)) break; const compacted = pairs[index].map((item, itemIndex) => ({ role: item.role, content: clipHistoryContent(item.content, allocations[itemIndex]) })); selected.push(compacted); remainingChars -= compacted.reduce((sum, item) => sum + item.content.length, 0); remainingMessages -= 2; } return selected.reverse().flat(); } function chatTitle(message) { const clean = String(message || '').replace(/\s+/g, ' ').trim(); if (!clean) return 'Untitled chat'; if (clean.length <= MAX_CHAT_TITLE_CHARS) return clean; return `${clean.slice(0, MAX_CHAT_TITLE_CHARS - 1).trimEnd()}…`; } function normaliseMemories(value, limits = DEFAULT_MEMORY_LIMITS) { if (!Array.isArray(value)) return []; const entries = []; const seen = new Set(); let totalChars = 0; for (const raw of value.slice(0, limits.maxEntries * 4)) { const source = typeof raw === 'string' ? { content: raw } : raw; if (!source || typeof source.content !== 'string') continue; const content = source.content.replace(/\s+/g, ' ').trim().slice(0, limits.maxItemChars); const key = content.toLocaleLowerCase(); if (!content || seen.has(key) || totalChars + content.length > limits.maxTotalChars) continue; const createdAt = Number(source.createdAt); const updatedAt = Number(source.updatedAt); entries.push({ id: typeof source.id === 'string' && source.id ? source.id.slice(0, 128) : `memory-${entries.length}-${content.length}`, content, createdAt: Number.isFinite(createdAt) && createdAt > 0 ? createdAt : Date.now(), updatedAt: Number.isFinite(updatedAt) && updatedAt > 0 ? updatedAt : Date.now() }); seen.add(key); totalChars += content.length; if (entries.length >= limits.maxEntries) break; } return entries; } function extractLearnedMemories(message, maxItemChars = DEFAULT_MEMORY_LIMITS.maxItemChars) { const sentences = String(message || '') .split(/\n+|(?<=[.!?])\s+/) .map((item) => item.replace(/\s+/g, ' ').trim()) .filter(Boolean); const learned = []; for (const sentence of sentences) { const remember = sentence.match(/^(?:please\s+)?remember(?:\s+that)?\s+(.+)/i); const explicitPreference = /^(?:i (?:prefer|usually|always|never|work as|work at|use)|my (?:name|role|preferred language|preference|organisation|organization) is)\b/i.test(sentence); const content = remember ? remember[1].trim() : explicitPreference ? sentence : ''; if (content.length >= 4 && !content.endsWith('?')) { learned.push(content.slice(0, maxItemChars)); } if (learned.length >= 2) break; } return learned; } function formatConversationDownload(messages, format, title) { const selected = Array.isArray(messages) ? messages.filter((item) => item && (item.role === 'user' || item.role === 'assistant') && typeof item.content === 'string') : []; if (format === 'json') { return JSON.stringify({ title, exported_at: new Date().toISOString(), messages: selected }, null, 2); } if (format === 'text') { return selected .map((item) => `${item.role === 'user' ? 'You' : 'Synderesis'}\n${item.content}`) .join('\n\n'); } const heading = `# ${String(title || 'Synderesis conversation').replace(/\n/g, ' ')}`; const body = selected .map((item) => `## ${item.role === 'user' ? 'You' : 'Synderesis'}\n\n${item.content}`) .join('\n\n'); return `${heading}\n\n${body}`; } function readingListKey(source) { if (source.sourceId) return `${source.kind}:source:${source.sourceId}`; if (source.url) return `${source.kind}:url:${source.url}`; return `${source.kind}:title:${source.title.toLocaleLowerCase()}\u0000${source.publisher.toLocaleLowerCase()}`; } function collectReadingList(messages) { const works = new Map(); (Array.isArray(messages) ? messages : []).forEach((message) => { if (!message || message.role !== 'assistant') return; normaliseSourceManifest(message.sources).forEach((source) => { if (source.kind !== 'official' && source.kind !== 'web') return; const key = readingListKey(source); const existing = works.get(key); if (existing) { if (source.location && !existing.locations.includes(source.location)) { existing.locations.push(source.location); } return; } works.set(key, { kind: source.kind, sourceId: source.sourceId, title: source.title || source.sourceId || source.domain || 'Untitled source', publisher: source.publisher, domain: source.domain, url: source.url, authorityClass: source.authorityClass, authorityLabel: source.authorityLabel, sourceFamily: source.sourceFamily, sourceFamilyLabel: source.sourceFamilyLabel, contentKind: source.contentKind, accessedAt: source.accessedAt, locations: source.location ? [source.location] : [] }); }); }); return Array.from(works.values()).sort((left, right) => left.title.localeCompare(right.title)); } function escapeMarkdownText(value) { return String(value == null ? '' : value) .replace(/\\/g, '\\\\') .replace(/\[/g, '\\[') .replace(/\]/g, '\\]') .replace(/\(/g, '\\(') .replace(/\)/g, '\\)') .replace(/`/g, '\\`') .replace(/\*/g, '\\*') .replace(/_/g, '\\_') .replace(//g, '\\>'); } function markdownLinkDestination(value) { const url = safeHttpsUrl(value); if (!url) return ''; return `<${url.replace(/\\/g, '%5C').replace(//g, '%3E')}>`; } function formatReadingList(items, format, title = 'Synderesis reading list') { const selected = (Array.isArray(items) ? items : []).filter((item) => ( item && typeof item.title === 'string' )); if (format === 'json') { return JSON.stringify({ title, exported_at: new Date().toISOString(), sources: selected.map((item) => ({ kind: item.kind, source_id: item.sourceId, title: item.title, publisher: item.publisher, domain: item.domain, url: item.url, authority_class: item.authorityClass, authority_label: item.authorityLabel, source_family: item.sourceFamily, source_family_label: item.sourceFamilyLabel, content_kind: item.contentKind, accessed_at: item.accessedAt, cited_locations: item.locations })) }, null, 2); } const heading = `# ${escapeMarkdownText( String(title || 'Synderesis reading list').replace(/\n/g, ' ') )}`; const entries = selected.map((item) => { const publisher = item.publisher || item.domain; const details = escapeMarkdownText([ publisher, item.locations.length ? `cited at ${item.locations.join(', ')}` : '', item.authorityLabel ].filter(Boolean).join(' · ')); const labelText = escapeMarkdownText(item.title); const destination = markdownLinkDestination(item.url); const label = destination ? `[${labelText}](${destination})` : labelText; return `- ${label}${details ? ` — ${details}` : ''}`; }); return `${heading}\n\n${entries.join('\n')}`; } function normaliseStoredChats(value, limits = state.limits) { if (!Array.isArray(value)) return []; const seen = new Set(); const chats = []; for (const raw of value.slice(0, MAX_SAVED_CHATS * 5)) { if (!raw || typeof raw !== 'object') continue; const id = typeof raw.id === 'string' ? raw.id.slice(0, 128) : ''; if (!id || seen.has(id)) continue; const history = normaliseTranscript(raw.messages || raw.history); if (history.length < 2) continue; const updatedAt = Number(raw.updatedAt); const createdAt = Number(raw.createdAt); const firstUser = history.find((item) => item.role === 'user'); chats.push({ id, title: chatTitle( typeof raw.title === 'string' && raw.title.trim() ? raw.title.slice(0, MAX_CHAT_TITLE_CHARS * 2) : firstUser && firstUser.content ), history, pinned: raw.pinned === true, createdAt: Number.isFinite(createdAt) && createdAt > 0 ? createdAt : Date.now(), updatedAt: Number.isFinite(updatedAt) && updatedAt > 0 ? updatedAt : Date.now() }); seen.add(id); } return limitChats(chats); } function sortChats(chats) { return chats.slice().sort((left, right) => { if (Boolean(left.pinned) !== Boolean(right.pinned)) return left.pinned ? -1 : 1; return right.updatedAt - left.updatedAt; }); } function limitChats(chats, preferredId = '') { const byRecency = chats.slice().sort((left, right) => right.updatedAt - left.updatedAt); let retained = byRecency.slice(0, MAX_SAVED_CHATS); if (preferredId && !retained.some((chat) => chat.id === preferredId)) { const preferred = chats.find((chat) => chat.id === preferredId); if (preferred) retained = [...retained.slice(0, MAX_SAVED_CHATS - 1), preferred]; } return sortChats(retained); } function filterChats(chats, query) { const normalizedQuery = String(query || '').trim().toLocaleLowerCase(); return sortChats(chats).filter((chat) => chat.title.toLocaleLowerCase().includes(normalizedQuery)); } function serialiseChats(chats, limits = state.limits, preferredId = state.activeChatId || '') { const records = limitChats(chats, preferredId).map((chat) => ({ id: chat.id, title: chat.title, messages: normaliseTranscript(chat.history || chat.messages), pinned: Boolean(chat.pinned), createdAt: chat.createdAt, updatedAt: chat.updatedAt })); while (records.length > 1 && JSON.stringify(records).length > MAX_CHAT_STORAGE_CHARS) { const removable = records .map((chat, index) => ({ chat, index })) .filter((entry) => entry.chat.id !== preferredId) .sort((left, right) => { if (Boolean(left.chat.pinned) !== Boolean(right.chat.pinned)) { return left.chat.pinned ? 1 : -1; } return left.chat.updatedAt - right.chat.updatedAt; })[0]; if (!removable) break; records.splice(removable.index, 1); } return records; } function newChatId() { if (globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function') return globalThis.crypto.randomUUID(); return `chat-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; } function newMessageId() { if (globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function') return globalThis.crypto.randomUUID(); return `message-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; } function formatChatDate(value) { const date = new Date(value); if (Number.isNaN(date.getTime())) return ''; const now = new Date(); const sameDay = date.toDateString() === now.toDateString(); return sameDay ? new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' }).format(date) : new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }).format(date); } function loadSavedChats() { state.chats = []; if (!state.storageKey) return; try { let raw = globalThis.localStorage.getItem(state.storageKey); let migratedLegacy = false; if (!raw && state.legacyStorageKey) { raw = globalThis.localStorage.getItem(state.legacyStorageKey); migratedLegacy = Boolean(raw); } if (!raw) { state.storageAvailable = true; return; } try { state.chats = normaliseStoredChats(JSON.parse(raw)); state.storageAvailable = true; if (migratedLegacy && persistChats()) { globalThis.localStorage.removeItem(state.legacyStorageKey); } } catch (_) { globalThis.localStorage.removeItem(state.storageKey); state.storageAvailable = true; showNotice('Saved chat history was reset because the browser data was unreadable.', 'warning'); } } catch (_) { state.storageAvailable = false; showNotice('Saved chat history is unavailable in this browser. Ghost mode still works.', 'warning'); } } function persistChats() { if (!state.storageKey || !state.storageAvailable) return false; try { const records = serialiseChats(state.chats); globalThis.localStorage.setItem(state.storageKey, JSON.stringify(records)); state.chats = normaliseStoredChats(records); return true; } catch (_) { state.storageAvailable = false; showNotice('This browser could not save chat history. The current conversation remains open.', 'warning'); return false; } } function newMemoryId() { if (globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function') { return globalThis.crypto.randomUUID(); } return `memory-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; } function normaliseProductPreferences( value, allowedModelTiers = new Set(['spark', 'pro']), allowedReasoningEfforts = new Set(REASONING_EFFORTS), allowedExecutionModes = new Set(['answer']) ) { const parsed = value && typeof value === 'object' ? value : {}; const hasLegacyThinkingMode = Object.prototype.hasOwnProperty.call( parsed, 'thinkingMode' ); return { modelTier: allowedModelTiers.has(parsed.modelTier) ? parsed.modelTier : 'spark', reasoningEffort: allowedReasoningEfforts.has(parsed.reasoningEffort) ? parsed.reasoningEffort : hasLegacyThinkingMode && parsed.thinkingMode === true ? 'high' : 'minimal', executionMode: allowedExecutionModes.has(parsed.executionMode) ? parsed.executionMode : 'answer', migratedLegacy: hasLegacyThinkingMode }; } function loadMemorySettings() { state.memories = []; state.memoryEnabled = false; state.modelTier = 'spark'; state.reasoningEffort = 'minimal'; state.executionMode = 'answer'; if (!state.memoryKey) return; try { const raw = globalThis.localStorage.getItem(state.memoryKey); if (!raw) { state.memoryStorageAvailable = true; return; } const parsed = JSON.parse(raw); const productPreferences = normaliseProductPreferences( parsed, state.allowedModelTiers, state.allowedReasoningEfforts, state.allowedExecutionModes ); state.memories = normaliseMemories(parsed && parsed.memories, state.memoryLimits); state.memoryEnabled = Boolean(parsed && parsed.enabled); state.modelTier = productPreferences.modelTier; state.reasoningEffort = productPreferences.reasoningEffort; state.executionMode = productPreferences.executionMode; state.memoryStorageAvailable = true; if (productPreferences.migratedLegacy) persistMemorySettings(); } catch (_) { state.memoryStorageAvailable = false; state.memoryEnabled = false; showNotice('Memory settings are unavailable in this browser.', 'warning'); } } function persistMemorySettings() { if (!state.memoryKey || !state.memoryStorageAvailable) return; try { globalThis.localStorage.setItem(state.memoryKey, JSON.stringify({ enabled: Boolean(state.memoryEnabled), modelTier: state.modelTier, reasoningEffort: state.reasoningEffort, executionMode: state.executionMode, memories: normaliseMemories(state.memories, state.memoryLimits) })); } catch (_) { state.memoryStorageAvailable = false; state.memoryEnabled = false; showNotice('This browser could not save memory settings. Individual memory has been turned off.', 'warning'); } } function memoryPayload() { if (!state.memoryEnabled || !state.memoryStorageAvailable) return []; return normaliseMemories(state.memories, state.memoryLimits).map((item) => item.content); } function addMemoryContent(content, { announce = true } = {}) { const clean = String(content || '').replace(/\s+/g, ' ').trim().slice(0, state.memoryLimits.maxItemChars); if (!clean) return false; const duplicate = state.memories.some((item) => item.content.toLocaleLowerCase() === clean.toLocaleLowerCase()); if (duplicate) return false; const now = Date.now(); state.memories = normaliseMemories([ { id: newMemoryId(), content: clean, createdAt: now, updatedAt: now }, ...state.memories ], state.memoryLimits); if (!state.memories.some((item) => item.content === clean)) return false; persistMemorySettings(); renderMemorySettings(); if (announce) byId('memory-status').textContent = 'Memory added.'; return true; } function updateMemoryContent(memoryId, content) { const memory = state.memories.find((item) => item.id === memoryId); if (!memory) return; const clean = String(content || '').replace(/\s+/g, ' ').trim().slice(0, state.memoryLimits.maxItemChars); if (!clean) { deleteMemory(memoryId); return; } memory.content = clean; memory.updatedAt = Date.now(); state.memories = normaliseMemories(state.memories, state.memoryLimits); persistMemorySettings(); renderMemorySettings(); byId('memory-status').textContent = 'Memory updated.'; } function deleteMemory(memoryId) { state.memories = state.memories.filter((item) => item.id !== memoryId); persistMemorySettings(); renderMemorySettings(); byId('memory-status').textContent = 'Memory deleted.'; } function renderMemorySettings() { const toggle = byId('individual-memory-enabled'); if (!toggle) return; toggle.checked = state.memoryEnabled; toggle.disabled = !state.memoryStorageAvailable; byId('new-memory').disabled = !state.memoryStorageAvailable; byId('add-memory').disabled = !state.memoryStorageAvailable; byId('clear-memories').disabled = !state.memories.length || !state.memoryStorageAvailable; const list = byId('individual-memory-list'); list.replaceChildren(); byId('individual-memory-empty').hidden = state.memories.length > 0; state.memories.forEach((memory) => { const row = document.createElement('div'); row.className = 'memory-row'; const input = document.createElement('input'); input.className = 'memory-edit'; input.type = 'text'; input.maxLength = state.memoryLimits.maxItemChars; input.value = memory.content; input.setAttribute('aria-label', 'Edit individual memory'); const save = document.createElement('button'); save.type = 'button'; save.dataset.telemetryId = 'chat_memory_add'; save.textContent = 'Save'; save.addEventListener('click', () => updateMemoryContent(memory.id, input.value)); const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'memory-delete'; remove.dataset.telemetryId = 'chat_memory_delete'; remove.textContent = 'Delete'; remove.addEventListener('click', () => deleteMemory(memory.id)); input.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); updateMemoryContent(memory.id, input.value); } }); row.append(input, save, remove); list.appendChild(row); }); const organizationList = byId('organization-memory-list'); organizationList.replaceChildren(); byId('organization-memory-empty').hidden = state.organizationMemory.length > 0; state.organizationMemory.forEach((entry) => { const item = document.createElement('li'); item.textContent = entry; organizationList.appendChild(item); }); byId('organization-memory-note').textContent = state.organizationId ? `Managed for ${state.organizationId} and shared only with explicitly assigned members.` : 'Managed by your organization and shared only with explicitly assigned members.'; renderFeatureControls(); } function renderSourceControls() { const trigger = byId('sources-summary'); if (!trigger) return; const capabilities = state.sourceCapabilities; const available = capabilities.advertised && (capabilities.officialAvailable || capabilities.webAvailable); trigger.hidden = !available; if (!available) { const dialog = byId('source-policy-dialog'); if (dialog && dialog.open) closeDialog('source-policy-dialog'); return; } state.sourcePolicy = normaliseSourcePolicy(state.sourcePolicy); const official = byId('official-source-mode'); if (official) { official.value = state.sourcePolicy.official; official.disabled = state.pending || !capabilities.officialAvailable; const label = official.closest('label'); if (label) label.hidden = !capabilities.officialAvailable; } const familyFieldset = byId('source-families'); const familyOptions = byId('source-family-options'); if (familyFieldset && familyOptions) { familyOptions.replaceChildren(); familyFieldset.hidden = !capabilities.officialAvailable || state.sourcePolicy.official === 'off' || !capabilities.families.length; capabilities.families.forEach((family) => { const label = document.createElement('label'); const input = document.createElement('input'); input.type = 'checkbox'; input.value = family.value; input.checked = state.sourcePolicy.families.includes(family.value); input.disabled = state.pending || family.available === false; input.addEventListener('change', () => { const selected = new Set(state.sourcePolicy.families); if (input.checked) selected.add(family.value); else selected.delete(family.value); state.sourcePolicy.families = Array.from(selected); byId('source-policy-status').textContent = `${family.label} ${input.checked ? 'selected' : 'cleared'}.`; renderSourceControls(); }); const text = document.createElement('span'); text.textContent = family.available === false ? `${family.label} · Unavailable` : family.sourceCount > 0 ? `${family.label} · ${family.sourceCount}` : family.label; label.append(input, text); familyOptions.appendChild(label); }); } const web = byId('web-search-enabled'); if (web) { web.checked = state.sourcePolicy.webSearch; web.disabled = state.pending || !capabilities.webAvailable; const setting = web.closest('label'); if (setting) setting.hidden = !capabilities.webAvailable; } const review = byId('web-source-review'); const reviewSetting = byId('web-review-setting'); if (review) { review.checked = state.sourcePolicy.webSourceReview; review.disabled = state.pending || !capabilities.reviewAvailable || !state.sourcePolicy.webSearch; } if (reviewSetting) { reviewSetting.hidden = !capabilities.webAvailable || !capabilities.reviewAvailable; } const parts = []; if (capabilities.officialAvailable && state.sourcePolicy.official !== 'off') { parts.push(state.sourcePolicy.official === 'on' ? 'Official on' : 'Official auto'); } if (state.sourcePolicy.webSearch) { parts.push(state.sourcePolicy.webSourceReview ? 'Web reviewed' : 'Web'); } trigger.textContent = parts.length ? `Sources · ${parts.join(' · ')}` : 'Sources · Off'; } function renderComposerMetadata() { const modelInfo = byId('composer-model-info'); if (modelInfo) { modelInfo.textContent = WORKSPACE === 'research' ? 'Research workspace' : `Synderesis ${state.modelTier === 'pro' ? 'Pro' : 'Spark'} · ${state.executionMode === 'task' ? 'Task' : 'Answer'} · ${state.reasoningEffort[0].toUpperCase()}${state.reasoningEffort.slice(1)}`; } const contextInfo = byId('composer-context-info'); if (!contextInfo) return; const detail = state.modelDetails && state.modelDetails[state.modelTier] ? state.modelDetails[state.modelTier] : {}; const contextTokens = Number( detail.context_tokens || detail.max_context_tokens || detail.context_window_tokens ); const outputTokens = Number( detail.output_tokens || detail.max_output_tokens || state.outputTokenLimit ); const pieces = [ `${formatCount(state.limits.historyMaxMessages)} messages`, `${formatCount(state.limits.historyMaxChars)} characters` ]; if (Number.isFinite(contextTokens) && contextTokens > 0) { pieces.push(`${formatCount(contextTokens)} context tokens`); } if (Number.isFinite(outputTokens) && outputTokens > 0) { pieces.push(`${formatCount(outputTokens)} output tokens`); } contextInfo.textContent = `Request context: ${pieces.join(' · ')}`; } function applyConnectionAccess(access) { if (WORKSPACE !== 'chat') return; const source = access && typeof access === 'object' ? access : {}; const connectorAccess = source.connectors && typeof source.connectors === 'object' ? source.connectors : {}; const githubAccess = connectorAccess.github && typeof connectorAccess.github === 'object' ? connectorAccess.github : {}; const byokAccess = source.byok && typeof source.byok === 'object' ? source.byok : {}; const github = state.connections.github; const connected = Boolean(githubAccess.available && githubAccess.connected); github.available = Boolean(githubAccess.available); github.connected = connected; if (!connected) { github.enabled = false; github.repositories = []; github.repositoriesLoaded = false; github.repositoriesLoading = false; github.repositoryId = ''; github.ref = ''; github.paths = ['README.md']; } const byok = state.connections.byok; byok.available = Boolean(byokAccess.available); byok.configured = Boolean(byok.available && byokAccess.configured); byok.scope = boundedText(byokAccess.scope, 40); byok.lastFour = boundedText(byokAccess.last_four, 12).slice(-4); byok.expiresAt = boundedText(byokAccess.expires_at, 80); if (!byok.configured) byok.enabled = false; renderConnectionControls(); } function normaliseGithubRepositories(value) { if (!Array.isArray(value)) return []; const repositories = []; const seen = new Set(); for (const raw of value.slice(0, 500)) { if (!raw || typeof raw !== 'object') continue; const id = boundedText(raw.id, 160); const fullName = boundedText(raw.full_name, 300); if (!id || !fullName || seen.has(id)) continue; repositories.push({ id, fullName, private: Boolean(raw.private), defaultBranch: boundedText(raw.default_branch, 200) }); seen.add(id); } return repositories; } function renderGithubRepositoryOptions() { const select = byId('github-repository'); if (!select) return; select.replaceChildren(); const placeholder = document.createElement('option'); placeholder.value = ''; placeholder.textContent = state.connections.github.repositoriesLoading ? 'Loading repositories…' : 'Choose a repository'; select.appendChild(placeholder); state.connections.github.repositories.forEach((repository) => { const option = document.createElement('option'); option.value = repository.id; option.textContent = `${repository.fullName} · ${repository.private ? 'Private' : 'Public'}`; select.appendChild(option); }); if (state.connections.github.repositories.some( (repository) => repository.id === state.connections.github.repositoryId )) { select.value = state.connections.github.repositoryId; } else { state.connections.github.repositoryId = ''; select.value = ''; } } function connectionStatus(message) { const status = byId('connections-status'); if (status) status.textContent = message; } function clearRetryForConnectionChange() { state.lastRequest = null; setRequestControls(state.pending); } function byokStatusText() { const byok = state.connections.byok; if (!byok.configured) return 'No key saved'; const pieces = ['Key saved']; if (byok.lastFour) pieces.push(`ending ${byok.lastFour}`); if (byok.scope) pieces.push(byok.scope === 'account' ? 'account scope' : `${byok.scope} scope`); const expiry = formatChatDate(byok.expiresAt); if (expiry) pieces.push(`expires ${expiry}`); return pieces.join(' · '); } function renderConnectionControls() { const button = byId('connections-settings'); if (!button) return; const github = state.connections.github; const byok = state.connections.byok; const available = WORKSPACE === 'chat' && (github.available || byok.available); button.hidden = !available; button.disabled = state.pending; const stateLabel = byId('connections-state'); if (stateLabel) { const active = []; if (github.enabled) active.push('GitHub on'); else if (github.connected) active.push('GitHub ready'); if (byok.enabled) active.push('Own key on'); else if (byok.configured) active.push('Key ready'); stateLabel.textContent = active.length ? active.join(' · ') : 'Off'; } const githubSection = byId('github-connection-section'); if (githubSection) githubSection.hidden = !github.available; const githubStatus = byId('github-connection-status'); if (githubStatus) githubStatus.textContent = github.connected ? 'Connected' : 'Not connected'; const githubDisconnected = byId('github-disconnected'); if (githubDisconnected) githubDisconnected.hidden = github.connected; const githubConnected = byId('github-connected'); if (githubConnected) githubConnected.hidden = !github.connected; const githubProNote = byId('github-pro-note'); if (githubProNote) githubProNote.hidden = true; const githubAuthorize = byId('github-authorize'); if (githubAuthorize) githubAuthorize.disabled = state.pending; const githubEnabled = byId('github-context-enabled'); if (githubEnabled) { githubEnabled.checked = github.enabled; githubEnabled.disabled = state.pending || !github.connected; } const repository = byId('github-repository'); if (repository) { repository.value = github.repositoryId; repository.disabled = state.pending || github.repositoriesLoading; } const ref = byId('github-ref'); if (ref) { ref.value = github.ref; ref.disabled = state.pending; } const paths = byId('github-paths'); if (paths) { paths.value = github.paths.join('\n'); paths.disabled = state.pending; } const githubRefresh = byId('github-refresh'); if (githubRefresh) { githubRefresh.disabled = state.pending || github.repositoriesLoading; } const githubDisconnect = byId('github-disconnect'); if (githubDisconnect) githubDisconnect.disabled = state.pending || !github.connected; const byokSection = byId('byok-section'); if (byokSection) byokSection.hidden = !byok.available; const byokStatus = byId('byok-status'); if (byokStatus) byokStatus.textContent = byokStatusText(); const byokProNote = byId('byok-pro-note'); if (byokProNote) byokProNote.hidden = true; const byokEnabled = byId('byok-enabled'); if (byokEnabled) { byokEnabled.checked = byok.enabled; byokEnabled.disabled = state.pending || !byok.configured; } const byokInput = byId('byok-api-key'); if (byokInput) byokInput.disabled = state.pending; const byokRemember = byId('byok-remember'); if (byokRemember) byokRemember.disabled = state.pending; const byokSave = byId('byok-save'); if (byokSave) byokSave.disabled = state.pending; const byokDelete = byId('byok-delete'); if (byokDelete) byokDelete.disabled = state.pending || !byok.configured; } function normaliseGithubPaths(value) { const rawPaths = Array.isArray(value) ? value : String(value || '').split(/\r?\n/); const paths = []; rawPaths.forEach((rawPath) => { const path = String(rawPath || '').trim(); if (path && !paths.includes(path)) paths.push(path); }); return paths; } function githubContextForRequest() { const github = state.connections.github; if ( WORKSPACE !== 'chat' || !github.available || !github.connected || !github.enabled ) return null; const paths = normaliseGithubPaths(github.paths); if (!github.repositoryId) { throw new Error('Choose a GitHub repository before sending this request.'); } if (paths.length < 1 || paths.length > 6) { throw new Error('Enter between 1 and 6 GitHub paths, one per line.'); } return { repository_id: github.repositoryId, ref: github.ref.trim(), paths }; } function renderFeatureControls() { const memoryState = byId('memory-state'); if (memoryState) { memoryState.textContent = state.memoryEnabled ? `On · ${state.memories.length}` : state.memories.length ? `Off · ${state.memories.length} saved` : 'Off'; } const tierInputs = Array.from(document.querySelectorAll('input[name="model-tier"]')); tierInputs.forEach((input) => { const allowed = state.allowedModelTiers.has(input.value); input.checked = input.value === state.modelTier; input.disabled = state.pending || !allowed; }); document.querySelectorAll('input[name="execution-mode"]').forEach((input) => { const allowed = state.allowedExecutionModes.has(input.value); input.checked = input.value === state.executionMode; input.disabled = state.pending || !allowed; }); const executionModeHelp = byId('execution-mode-help'); if (executionModeHelp) { executionModeHelp.textContent = state.allowedExecutionModes.has('task') ? 'Task performs at most six decisions and four bounded read-only research actions.' : 'Task is unavailable in this workspace. Answer mode remains available.'; } const tierHelp = byId('model-tier-help'); if (tierHelp) { tierHelp.textContent = state.allowedModelTiers.has('pro') ? 'Choose a Synderesis product tier.' : 'Pro is unavailable on this deployment.'; } const effort = byId('reasoning-effort'); if (effort) { effort.value = String(Math.max(0, REASONING_EFFORTS.indexOf(state.reasoningEffort))); effort.disabled = state.pending; effort.setAttribute( 'aria-valuetext', `${state.reasoningEffort[0].toUpperCase()}${state.reasoningEffort.slice(1)}` ); } const effortValue = byId('reasoning-effort-value'); if (effortValue) { effortValue.textContent = `${state.reasoningEffort[0].toUpperCase()}${state.reasoningEffort.slice(1)}`; } const share = byId('share-chat'); if (share) share.disabled = !state.sharing.enabled || state.ghost || state.pending || state.history.length < 2; const download = byId('download-chat'); if (download) download.disabled = state.pending || state.history.length < 1; const readingList = byId('reading-list'); if (readingList) { readingList.disabled = state.pending || !state.history.some((message) => ( Array.isArray(message.sources) && message.sources.length )); } renderConnectionControls(); renderSourceControls(); renderComposerMetadata(); } function learnFromCompletedMessage(message) { if (!state.memoryEnabled || !state.memoryStorageAvailable) return 0; let learned = 0; extractLearnedMemories(message, state.memoryLimits.maxItemChars).forEach((entry) => { if (addMemoryContent(entry, { announce: false })) learned += 1; }); if (learned) { byId('memory-status').textContent = `${learned} ${learned === 1 ? 'memory' : 'memories'} learned from this chat.`; showNotice( `${learned} explicit ${learned === 1 ? 'preference was' : 'preferences were'} added to individual memory.`, 'info' ); } return learned; } function renderHistory({ focusChatId = '', focusTarget = '' } = {}) { const list = byId('history-list'); list.replaceChildren(); byId('history-count').textContent = String(state.chats.length); const query = state.historyQuery.trim().toLocaleLowerCase(); const visibleChats = filterChats(state.chats, query); byId('history-empty').hidden = visibleChats.length > 0; byId('history-empty').textContent = state.chats.length && query ? 'No chats match your search.' : 'No saved chats yet.'; visibleChats.forEach((chat) => { const row = document.createElement('div'); row.className = `history-item${chat.id === state.activeChatId ? ' active' : ''}`; row.dataset.chatId = chat.id; const open = document.createElement('button'); open.type = 'button'; open.className = 'history-open'; open.dataset.telemetryId = 'chat_history_open'; if (chat.id === state.activeChatId) open.setAttribute('aria-current', 'true'); const title = document.createElement('span'); title.className = 'history-title'; title.textContent = chat.title; const time = document.createElement('span'); time.className = 'history-time'; time.textContent = formatChatDate(chat.updatedAt); open.append(title, time); open.addEventListener('click', () => openChat(chat.id)); const actions = document.createElement('details'); actions.className = 'history-overflow'; const summary = document.createElement('summary'); summary.className = 'history-overflow-trigger'; summary.textContent = '•••'; summary.setAttribute('aria-label', `Actions for chat: ${chat.title}`); const menu = document.createElement('div'); menu.className = 'history-actions'; const pin = document.createElement('button'); pin.type = 'button'; pin.className = 'history-pin'; pin.dataset.telemetryId = 'chat_history_pin'; pin.textContent = chat.pinned ? 'Unpin' : 'Pin'; pin.setAttribute('aria-pressed', String(Boolean(chat.pinned))); pin.setAttribute('aria-label', `${chat.pinned ? 'Unpin' : 'Pin'} chat: ${chat.title}`); pin.addEventListener('click', () => { actions.open = false; togglePin(chat.id); }); const rename = document.createElement('button'); rename.type = 'button'; rename.className = 'history-rename'; rename.dataset.telemetryId = 'chat_history_rename'; rename.textContent = 'Rename'; rename.setAttribute('aria-label', `Rename chat: ${chat.title}`); rename.addEventListener('click', () => { actions.open = false; renameChat(chat.id, row); }); const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'history-delete'; remove.dataset.telemetryId = 'chat_history_delete'; remove.textContent = 'Delete'; remove.setAttribute('aria-label', `Delete chat: ${chat.title}`); remove.addEventListener('click', () => { actions.open = false; deleteChat(chat.id); }); menu.append(pin, rename, remove); actions.append(summary, menu); actions.addEventListener('toggle', () => { if (!actions.open) return; list.querySelectorAll('.history-overflow[open]').forEach((other) => { if (other !== actions) other.open = false; }); }); actions.addEventListener('keydown', (event) => { if (event.key !== 'Escape' || !actions.open) return; event.preventDefault(); actions.open = false; summary.focus(); }); row.append(open, actions); list.appendChild(row); }); announceHistory(`${visibleChats.length} ${visibleChats.length === 1 ? 'chat' : 'chats'} shown.`); if (focusChatId) { const escaped = globalThis.CSS && typeof globalThis.CSS.escape === 'function' ? globalThis.CSS.escape(focusChatId) : focusChatId.replace(/["\\]/g, '\\$&'); const focusRow = list.querySelector(`[data-chat-id="${escaped}"]`); const target = focusRow && focusRow.querySelector( focusTarget === 'actions' ? '.history-overflow-trigger' : '.history-open' ); if (target) target.focus(); } } function announceHistory(message) { byId('history-status').textContent = message; } function togglePin(chatId) { const chat = state.chats.find((item) => item.id === chatId); if (!chat) return; chat.pinned = !chat.pinned; state.chats = sortChats(state.chats).slice(0, MAX_SAVED_CHATS); persistChats(); renderHistory({ focusChatId: chat.id, focusTarget: 'actions' }); announceHistory(`${chat.title} ${chat.pinned ? 'pinned' : 'unpinned'}.`); } function renameChat(chatId, row) { const chat = state.chats.find((item) => item.id === chatId); if (!chat) return; const input = document.createElement('input'); input.type = 'text'; input.className = 'history-rename-input'; input.maxLength = MAX_CHAT_TITLE_CHARS; input.value = chat.title; input.setAttribute('aria-label', `New name for ${chat.title}`); row.replaceChildren(input); let finished = false; const finish = (save) => { if (finished) return; finished = true; if (save && input.value.trim()) { chat.title = chatTitle(input.value); chat.updatedAt = Date.now(); state.chats = sortChats(state.chats); persistChats(); renderHistory({ focusChatId: chat.id, focusTarget: 'actions' }); announceHistory(`Chat renamed to ${chat.title}.`); } else { renderHistory({ focusChatId: chat.id, focusTarget: 'actions' }); announceHistory('Rename cancelled.'); } }; input.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); finish(true); } if (event.key === 'Escape') { event.preventDefault(); finish(false); } }); input.addEventListener('blur', () => finish(true)); input.focus(); input.select(); } function renderConversation(history = state.history) { const messages = byId('messages'); messages.replaceChildren(); const conversation = Array.isArray(history) ? history : state.history; if (!conversation.length) { const welcome = state.ghost ? 'Ghost conversation. Messages in this chat will not be saved in browser history. Files remain transient.' : 'New conversation. Ask a question or attach a document for context.'; addMessage('assistant', welcome); return; } conversation.forEach((item, index) => { const message = addMessage(item.role, item.content, false, index === conversation.length - 1); if (item.role === 'assistant') { const renderer = globalThis.SynderesisCitationRenderer; const body = message.querySelector('.message-body'); if (renderer && body && typeof renderer.renderStored === 'function') { renderer.renderStored( body, item.content, Array.isArray(item.sources) ? item.sources : [], { openSource: openSourceDrawer, markdown: true } ); } } }); } function renderMode() { const toggle = byId('ghost-mode'); toggle.setAttribute('aria-pressed', String(state.ghost)); byId('ghost-state').textContent = state.ghost ? 'On · this chat is not saved' : state.storageAvailable ? 'Off · chats save in this browser' : 'Off · history unavailable'; const modeStatus = byId('mode-status'); if (modeStatus) modeStatus.textContent = state.ghost ? 'Ghost mode · not saved' : ''; byId('composer-hint').textContent = 'Enter to send · Shift+Enter for a new line'; byId('model-status').textContent = WORKSPACE === 'research' ? 'Research model ready' : `Synderesis ${state.modelTier === 'pro' ? 'Pro' : 'Spark'} · ${state.executionMode === 'task' ? 'Task' : 'Answer'} · ${state.reasoningEffort[0].toUpperCase()}${state.reasoningEffort.slice(1)}`; byId('chat-shell').classList.toggle('ghost-active', state.ghost); renderFeatureControls(); } function saveActiveChat() { if (state.ghost || !state.storageAvailable || state.history.length < 2) return; const now = Date.now(); const existing = state.activeChatId ? state.chats.find((chat) => chat.id === state.activeChatId) : null; const firstUser = state.history.find((item) => item.role === 'user'); const chat = { id: existing ? existing.id : newChatId(), title: existing ? existing.title : chatTitle(firstUser && firstUser.content), history: normaliseTranscript(state.history), pinned: existing ? Boolean(existing.pinned) : false, createdAt: existing ? existing.createdAt : now, updatedAt: now }; state.activeChatId = chat.id; state.chats = limitChats( [chat, ...state.chats.filter((item) => item.id !== chat.id)], chat.id ); persistChats(); renderHistory(); renderMode(); } function openChat(chatId) { const chat = state.chats.find((item) => item.id === chatId); if (!chat) return; abortActiveRequest({ clearRetry: true }); state.ghost = false; state.activeChatId = chat.id; state.history = normaliseTranscript(chat.history || chat.messages); state.files = []; byId('message').value = ''; byId('send').disabled = false; byId('attachments').disabled = false; byId('add-context').disabled = false; renderFiles(); renderHistory(); renderMode(); renderConversation(); clearNotice(); byId('message').focus(); } function deleteChat(chatId) { const chat = state.chats.find((item) => item.id === chatId); if (!chat) return; if (!globalThis.confirm(`Delete “${chat.title}” from this browser?`)) return; state.chats = state.chats.filter((item) => item.id !== chatId); persistChats(); if (state.activeChatId === chatId) { resetConversation(); } else { renderHistory(); } showNotice('Chat deleted from this browser.', 'info'); } function setGhostMode(enabled) { const next = Boolean(enabled); if (state.ghost === next) return; state.ghost = next; state.lastRequest = null; resetConversation(); showNotice( next ? 'Ghost mode is on. This conversation will not be added to browser history.' : 'Ghost mode is off. New replies will be saved in this browser.', 'info' ); } function setProductPreferences(modelTier, reasoningEffort, executionMode = state.executionMode) { state.modelTier = state.allowedModelTiers.has(modelTier) ? modelTier : 'spark'; state.reasoningEffort = state.allowedReasoningEfforts.has(reasoningEffort) ? reasoningEffort : 'minimal'; state.executionMode = state.allowedExecutionModes.has(executionMode) ? executionMode : 'answer'; persistMemorySettings(); renderMode(); } function renderFiles() { const list = byId('attachment-list'); list.replaceChildren(); state.files.forEach((file, index) => { const chip = document.createElement('div'); chip.className = 'attachment-chip'; const name = document.createElement('span'); name.textContent = file.name; name.title = file.name; const remove = document.createElement('button'); remove.type = 'button'; remove.dataset.telemetryId = 'chat_attachment_remove'; remove.setAttribute('aria-label', `Remove ${file.name}`); remove.textContent = '×'; remove.disabled = state.pending; remove.addEventListener('click', () => { state.files.splice(index, 1); renderFiles(); }); chip.append(name, remove); list.appendChild(chip); }); } function addFiles(files) { clearNotice(); const rejected = []; for (const file of files) { if (state.files.length >= state.limits.maxFiles) { rejected.push(`Only ${state.limits.maxFiles} files can be included in one reply.`); break; } if (isImage(file) && !state.imageInputs) { rejected.push(`${file.name}: images are unavailable with the current model.`); continue; } if (isImage(file) && file.type && !state.imageMediaTypes.has(file.type)) { rejected.push(`${file.name}: use PNG, JPEG, GIF, or WebP for image context.`); continue; } if (file.size > state.limits.maxFileBytes) { rejected.push(`${file.name}: files must be ${formatByteLimit(state.limits.maxFileBytes)} or smaller.`); continue; } const combinedBytes = state.files.reduce((sum, item) => sum + item.size, 0) + file.size; if (combinedBytes > state.limits.maxTotalFileBytes) { rejected.push(`Attachments must be ${formatByteLimit(state.limits.maxTotalFileBytes)} or smaller in total.`); continue; } state.files.push(file); } renderFiles(); if (rejected.length) showNotice(rejected.join(' '), 'warning'); } function freezeRetryValue(value) { if (Array.isArray(value)) { value.forEach((item) => freezeRetryValue(item)); return Object.freeze(value); } if (!value || typeof value !== 'object') return value; const prototype = Object.getPrototypeOf(value); if (prototype !== Object.prototype && prototype !== null) return value; Object.values(value).forEach((item) => freezeRetryValue(item)); return Object.freeze(value); } function createRetrySnapshot(value) { const raw = value && typeof value === 'object' ? value : {}; const preferences = raw.requestPreferences && typeof raw.requestPreferences === 'object' ? raw.requestPreferences : {}; const policy = preferences.sourcePolicy && typeof preferences.sourcePolicy === 'object' ? preferences.sourcePolicy : {}; return freezeRetryValue({ message: String(raw.message || ''), history: normaliseTranscript(raw.history), files: Array.isArray(raw.files) ? raw.files.slice() : [], requestPreferences: { modelTier: String(preferences.modelTier || 'spark'), reasoningEffort: String(preferences.reasoningEffort || 'minimal'), executionMode: String(preferences.executionMode || 'answer'), memoryEnabled: Boolean(preferences.memoryEnabled), individualMemory: Array.isArray(preferences.individualMemory) ? preferences.individualMemory.map((item) => String(item)) : [], sourcePolicy: { official: String(policy.official || 'auto'), families: Array.isArray(policy.families) ? policy.families.map((item) => String(item)).slice(0, 12) : [], webSearch: Boolean(policy.webSearch), webSourceReview: Boolean(policy.webSourceReview) } } }); } function createRequestScope(epoch, chatId, ghost, signal) { return Object.freeze({ epoch: Number(epoch), chatId: String(chatId || ''), ghost: Boolean(ghost), signal: signal || null }); } function requestScopeIsCurrent(scope, current) { if (!scope || !current) return false; return ( !Boolean(scope.signal && scope.signal.aborted) && Number(scope.epoch) === Number(current.epoch) && String(scope.chatId || '') === String(current.chatId || '') && Boolean(scope.ghost) === Boolean(current.ghost) ); } function throwIfChatAborted(signal) { if (signal && signal.aborted) throw chatAbortError(); } function attachmentReadError() { const error = new Error('The selected attachment could not be read.'); error.name = 'ChatAttachmentError'; error.kind = 'validation'; error.code = 'invalid_attachment'; error.retryable = false; return error; } function asBase64(file, { signal = null, fileReaderFactory = null } = {}) { return new Promise((resolve, reject) => { throwIfChatAborted(signal); const reader = typeof fileReaderFactory === 'function' ? fileReaderFactory() : new FileReader(); let settled = false; const cleanup = () => { if (signal && typeof signal.removeEventListener === 'function') { signal.removeEventListener('abort', abortRead); } reader.onerror = null; reader.onabort = null; reader.onload = null; }; const settle = (method, value) => { if (settled) return; settled = true; cleanup(); method(value); }; const abortRead = () => { try { if (typeof reader.abort === 'function') reader.abort(); } catch (_) { // Reject with the stable AbortError below even if the browser abort hook fails. } settle(reject, chatAbortError()); }; reader.onerror = () => settle(reject, attachmentReadError()); reader.onabort = () => settle(reject, chatAbortError()); reader.onload = () => { const value = String(reader.result || ''); const comma = value.indexOf(','); settle(resolve, comma >= 0 ? value.slice(comma + 1) : ''); }; if (signal && typeof signal.addEventListener === 'function') { signal.addEventListener('abort', abortRead, { once: true }); } try { reader.readAsDataURL(file); } catch (_) { settle(reject, attachmentReadError()); } }); } async function uploadAttachments(files, options = {}) { throwIfChatAborted(options.signal); return Promise.all(files.map(async (file) => ({ name: file.name, media_type: file.type || 'application/octet-stream', data_base64: await asBase64(file, options) }))); } function defaultHttpErrorCode(status) { if (status === 401) return 'unauthorized'; if (status === 403) return 'access_denied'; if (status === 408) return 'request_timeout'; if (status === 413) return 'request_too_large'; if (status === 422 || status === 400) return 'invalid_request'; if (status === 429) return 'rate_limited'; return status >= 500 ? 'server_error' : 'request_failed'; } async function httpResponseError(response, nowMs = Date.now()) { let payload = {}; try { payload = typeof response.json === 'function' ? await response.json() : {}; } catch (_) { payload = {}; } const raw = payload && payload.error && typeof payload.error === 'object' ? payload.error : {}; const status = safeStatusCode(response.status) || 500; const header = (name) => ( response.headers && typeof response.headers.get === 'function' ? response.headers.get(name) : '' ); const code = safeErrorCode(raw.code || raw.type) || defaultHttpErrorCode(status); const message = normaliseSafeStreamText( raw.message || 'The request could not be completed.', CHAT_STREAM_LIMITS.maxErrorMessageChars ); const requestId = safeRequestId(raw.request_id || header('X-Request-ID')); const retryAfter = boundedRetryAfter(raw.retry_after) || parseRetryAfterHeader(header('Retry-After'), nowMs); return serverStreamError(Object.freeze({ code, message, status, retryAfter, retryable: typeof raw.retryable === 'boolean' ? raw.retryable : status === 408 || status === 429 || status >= 500, requestId })); } function readerResult(reader, signal) { if (signal && signal.aborted) return Promise.reject(chatAbortError()); return new Promise((resolve, reject) => { let settled = false; const cleanup = () => { if (signal && typeof signal.removeEventListener === 'function') { signal.removeEventListener('abort', aborted); } }; const finish = (method, value) => { if (settled) return; settled = true; cleanup(); method(value); }; const aborted = () => finish(reject, chatAbortError()); if (signal && typeof signal.addEventListener === 'function') { signal.addEventListener('abort', aborted, { once: true }); } Promise.resolve() .then(() => reader.read()) .then((value) => finish(resolve, value)) .catch((error) => finish(reject, error)); }); } async function cancelStreamReader(reader) { if (!reader || typeof reader.cancel !== 'function') return; try { await reader.cancel(); } catch (_) { // Cancellation is best effort; preserve the original failure. } } function transportFailure(error) { if ( error && ( error.name === 'AbortError' || error.name === 'ChatStreamProtocolError' || error.name === 'ChatStreamServerError' || error.name === 'ChatStreamTimeoutError' ) ) return error; const failure = new Error('The network connection was interrupted. Please retry.'); failure.name = 'ChatStreamNetworkError'; failure.kind = 'network'; failure.code = 'network_error'; failure.retryable = true; return failure; } async function streamChat(payload, { signal = null, controller = null, onResponse = null, onStatus = null, onDelta = null, onCitations = null, onDone = null, fetchImpl = null, limits: limitOverrides = {}, idleTimeoutMs = null, overallTimeoutMs = null, timers = null } = {}) { const transportController = controller && controller.signal ? controller : new AbortController(); const transportSignal = transportController.signal; const parentSignal = signal && signal !== transportSignal ? signal : null; const setTimer = timers && typeof timers.setTimeout === 'function' ? timers.setTimeout : globalThis.setTimeout.bind(globalThis); const clearTimer = timers && typeof timers.clearTimeout === 'function' ? timers.clearTimeout : globalThis.clearTimeout.bind(globalThis); const limits = streamLimitOptions(limitOverrides); const idleMs = Number.isFinite(Number(idleTimeoutMs)) && Number(idleTimeoutMs) > 0 ? Number(idleTimeoutMs) : limits.idleTimeoutMs; const overallMs = Number.isFinite(Number(overallTimeoutMs)) && Number(overallTimeoutMs) > 0 ? Number(overallTimeoutMs) : limits.overallTimeoutMs; const fetcher = typeof fetchImpl === 'function' ? fetchImpl : globalThis.fetch; let reader = null; let idleTimer = null; let overallTimer = null; let timeoutCode = ''; let completed = false; const abortTransport = (code = '') => { if (code && !timeoutCode) timeoutCode = code; if (reader) void cancelStreamReader(reader); if (!transportSignal.aborted && typeof transportController.abort === 'function') { transportController.abort(); } }; const parentAborted = () => abortTransport(); if (parentSignal) { if (parentSignal.aborted) abortTransport(); else if (typeof parentSignal.addEventListener === 'function') { parentSignal.addEventListener('abort', parentAborted, { once: true }); } } overallTimer = setTimer(() => abortTransport('overall_timeout'), overallMs); try { const headers = new Headers({ 'Content-Type': 'application/json' }); const csrf = csrfToken(); if (csrf) headers.set('X-CSRF-Token', csrf); throwIfChatAborted(transportSignal); const response = await fetcher('/v1/browser/chat/stream', { method: 'POST', headers, credentials: 'same-origin', cache: 'no-store', signal: transportSignal, body: JSON.stringify(payload) }); if (timeoutCode) throw chatTimeoutError(timeoutCode); if (typeof onResponse === 'function') { onResponse(Object.freeze({ requestId: requestIdFromResponse(response), httpStatus: Number(response.status) || 0 })); } if (!response.ok) throw await httpResponseError(response); if (!response.body || typeof response.body.getReader !== 'function') { throw chatProtocolError('streaming_unavailable'); } reader = response.body.getReader(); const mediaType = response.headers && typeof response.headers.get === 'function' ? response.headers.get('Content-Type') : ''; if (!isNdjsonMediaType(mediaType)) throw chatProtocolError('invalid_media_type'); const protocol = createNdjsonProtocolState(limits); let emptyReads = 0; while (true) { idleTimer = setTimer(() => abortTransport('idle_timeout'), idleMs); let result; try { result = await readerResult(reader, transportSignal); } finally { if (idleTimer != null) clearTimer(idleTimer); idleTimer = null; } if (timeoutCode) throw chatTimeoutError(timeoutCode); if (!result || result.done) break; const value = result.value == null ? new Uint8Array() : byteArray(result.value); if (!value.byteLength) { emptyReads += 1; if (emptyReads > 8) throw chatProtocolError('too_many_empty_chunks'); continue; } emptyReads = 0; const events = protocol.push(value); for (const event of events) { if (event.type === 'status' && typeof onStatus === 'function') { onStatus(event.message); } else if (event.type === 'delta' && typeof onDelta === 'function') { onDelta(event.text); } else if (event.type === 'citations' && typeof onCitations === 'function') { onCitations(event.citations); } else if (event.type === 'done' && typeof onDone === 'function') { onDone(event.payload); } else if (event.type === 'error') { throw serverStreamError(event.error); } } } const result = protocol.finish(); if (result.terminal === 'error') throw serverStreamError(result.error); completed = true; return result; } catch (error) { await cancelStreamReader(reader); if (!transportSignal.aborted && typeof transportController.abort === 'function') { transportController.abort(); } if (timeoutCode) throw chatTimeoutError(timeoutCode); throw transportFailure(error); } finally { if (idleTimer != null) clearTimer(idleTimer); if (overallTimer != null) clearTimer(overallTimer); if (parentSignal && typeof parentSignal.removeEventListener === 'function') { parentSignal.removeEventListener('abort', parentAborted); } if (!completed && reader) await cancelStreamReader(reader); } } const AUTH_FAILURE_CODES = new Set([ 'unauthorized', 'authentication_required', 'invalid_credentials', 'session_expired', 'csrf_failed' ]); const ACCESS_FAILURE_CODES = new Set([ 'access_denied', 'early_access_only', 'workspace_unavailable', 'api_key_required', 'provider_credential_required', 'provider_credential_invalid' ]); const QUOTA_FAILURE_CODES = new Set([ 'quota_exceeded', 'rate_limited', 'credit_exhausted', 'payment_required' ]); function classifyChatFailure(error) { const source = error && typeof error === 'object' ? error : {}; const status = safeStatusCode(source.status); const code = safeErrorCode(source.code); const retryAfter = boundedRetryAfter(source.retryAfter ?? source.retry_after); const requestId = safeRequestId(source.requestId ?? source.request_id); const explicitRetryable = typeof source.retryable === 'boolean' ? source.retryable : null; const inferredServerRetry = ( status >= 500 || code === 'generation_failed' || code === 'server_error' || code === 'internal_error' ); let kind = 'server'; let message = 'Synderesis could not complete this request. Please try again.'; let statusText = 'Request failed.'; let retryable = explicitRetryable == null ? inferredServerRetry : explicitRetryable; let retainRetry = retryable; if (source.stage === 'post_generation') { kind = 'post_generation'; message = 'The answer completed, but this browser could not finish displaying or saving it.'; statusText = 'Answer completed with a browser error.'; retryable = false; retainRetry = false; } else if (source.name === 'AbortError' || source.kind === 'abort') { kind = 'abort'; message = ''; statusText = 'Request stopped.'; retryable = false; retainRetry = true; } else if (AUTH_FAILURE_CODES.has(code) || status === 401 || code === 'csrf_failed') { kind = 'auth'; message = 'Your session is no longer valid. Sign in again to continue.'; statusText = 'Sign-in required.'; retryable = false; retainRetry = false; } else if (ACCESS_FAILURE_CODES.has(code) || status === 403) { kind = 'access'; message = 'This account does not currently have access to that request.'; statusText = 'Access unavailable.'; retryable = false; retainRetry = false; } else if (QUOTA_FAILURE_CODES.has(code) || status === 402 || status === 429) { kind = 'quota'; message = retryAfter ? `The usage limit was reached. Try again in about ${retryAfter} seconds.` : 'The usage limit was reached. Try again after the limit resets.'; statusText = 'Usage limit reached.'; retryable = explicitRetryable !== false; retainRetry = retryable; } else if ( status === 400 || status === 409 || status === 413 || status === 422 || code.startsWith('invalid_') || code.startsWith('unsupported_') || code === 'request_too_large' ) { kind = 'validation'; message = 'Check the message, selected context, and attachments, then send again.'; statusText = 'Request needs changes.'; retryable = false; retainRetry = false; } else if (source.name === 'ChatStreamProtocolError' || source.kind === 'protocol') { kind = 'protocol'; message = 'The response ended in an invalid format. Retry the request.'; statusText = 'Invalid response stream.'; retryable = true; retainRetry = true; } else if ( source.name === 'ChatStreamNetworkError' || source.name === 'ChatStreamTimeoutError' || source.kind === 'network' || source instanceof TypeError || status === 408 || code === 'request_timeout' ) { kind = 'network'; message = code === 'idle_timeout' || code === 'overall_timeout' ? 'The response timed out. Retry when the connection is stable.' : 'The network connection was interrupted. Check your connection and retry.'; statusText = code === 'idle_timeout' || code === 'overall_timeout' ? 'Response timed out.' : 'Network interrupted.'; retryable = true; retainRetry = true; } return Object.freeze({ kind, message, statusText, retryable, retainRetry, code, status, retryAfter, requestId }); } function stripDocumentCitationMarkers(value, citations = []) { const issuedKeys = new Set( (Array.isArray(citations) ? citations : []) .map((citation) => String(citation && citation.key || '').trim()) .filter((key) => /^[a-z0-9]{1,12}$/.test(key)) ); if (!issuedKeys.size) return String(value || ''); return String(value || '').replace(/\[\[([a-z0-9]{1,12})\]\]/g, (marker, key) => ( issuedKeys.has(key) ? '' : marker )); } function stripSourceCitationMarkers(value, sources = []) { const issuedKeys = new Set( normaliseSourceManifest(sources) .map((source) => source.citationId) .filter((key) => /^[a-z0-9]{1,12}$/.test(key)) .map((key) => key.toLowerCase()) ); return String(value || '').replace(/\[\[([a-z0-9]{1,12})\]\]/gi, (marker, rawKey) => { const key = rawKey.toLowerCase(); if (issuedKeys.has(key) || /^w[0-9]{1,10}$/.test(key)) return ''; return marker; }); } function renderDocumentCitations(container, answerText, citations) { const renderer = globalThis.SynderesisCitationRenderer; if (renderer && typeof renderer.renderInlineDocumentCitations === 'function') { return renderer.renderInlineDocumentCitations( container, answerText, Array.isArray(citations) ? citations : [], [], { markdown: true } ); } container.textContent = stripDocumentCitationMarkers(answerText, citations); return false; } function renderGroundedAnswer(container, answerText, documentCitations, payload) { const renderer = globalThis.SynderesisCitationRenderer; const structured = payload && payload.structured_answer; if ( renderer && typeof renderer.render === 'function' && structured && typeof structured === 'object' ) { return renderer.render(container, { ...payload, structured_answer: { ...structured, answer: String(answerText || '') }, document_citations: Array.isArray(documentCitations) ? documentCitations : [] }, { openSource: openSourceDrawer, markdown: true }); } return renderDocumentCitations(container, answerText, documentCitations); } function setRequestControls(pending) { byId('chat-stop').hidden = !pending; byId('chat-retry').hidden = pending || !state.lastRequest; } function setComposerBusy(pending) { byId('send').disabled = pending; byId('attachments').disabled = pending; byId('add-context').disabled = pending; const messages = byId('messages'); if (messages) messages.setAttribute('aria-busy', String(Boolean(pending))); const panel = byId('chat-panel'); if (panel) panel.setAttribute('aria-busy', String(Boolean(pending))); renderFiles(); setRequestControls(pending); renderFeatureControls(); } function finalizeStoppedMessage(message) { if (!message || typeof message.querySelector !== 'function') return false; const body = message.querySelector('.message-body'); if (!body) return false; clearWaitingIndicator(message); body.textContent = 'Response display stopped before completion. Retry to show it again.'; if (message.classList && typeof message.classList.remove === 'function') { message.classList.remove('pending'); } return true; } function shouldClearSubmittedDraft(enabled, currentValue, submittedMessage) { return Boolean(enabled && String(currentValue || '').trim() === String(submittedMessage || '')); } function abortActiveRequest({ clearRetry = false, stopped = false } = {}) { const pendingMessage = state.activePendingMessage; state.requestEpoch += 1; if (state.activeController) state.activeController.abort(); state.activeController = null; state.activePendingMessage = null; state.pending = false; if (pendingMessage) { if (stopped) finalizeStoppedMessage(pendingMessage); else { clearWaitingIndicator(pendingMessage); pendingMessage.remove(); } } if (clearRetry) state.lastRequest = null; setComposerBusy(false); } function resetConversation() { abortActiveRequest({ clearRetry: true }); state.history = []; state.activeChatId = null; state.files = []; byId('message').value = ''; renderFiles(); byId('send').disabled = false; byId('attachments').disabled = false; byId('add-context').disabled = false; renderHistory(); renderMode(); renderConversation(); clearNotice(); state.problemReport = { workspace: WORKSPACE, tier: '', reasoningEffort: '', mode: 'answer', requestId: '', httpStatus: 0, progressStatus: 'idle', completionStatus: '', errorCode: '', taskStatus: '' }; byId('message').focus(); } function stopActiveRequest() { if (!state.pending) return; abortActiveRequest({ stopped: true }); byId('streaming-status').textContent = 'Response display stopped.'; state.problemReport.progressStatus = 'stopped'; state.problemReport.completionStatus = 'stopped'; state.problemReport.errorCode = 'stopped'; showNotice( 'Stopped showing the response. The model may already have finished, so this request can still count toward your usage. Stop does not cancel provider compute or quota.', 'info' ); } function completionStatus(payload) { if (!payload || payload.execution_mode !== 'task') { return { message: 'Response complete.', notice: '', severity: 'info', reportStatus: 'completed' }; } const task = payload.task && typeof payload.task === 'object' ? payload.task : {}; const decisions = Number.isInteger(task.decision_count) ? task.decision_count : 0; const tools = Number.isInteger(task.tool_count) ? task.tool_count : 0; const suffix = `${decisions} decision${decisions === 1 ? '' : 's'}, ` + `${tools} research action${tools === 1 ? '' : 's'}.`; if (task.status === 'completed') { return { message: `Task complete · ${suffix}`, notice: '', severity: 'info', reportStatus: 'completed' }; } if (task.status === 'tool_limit') { return { message: `Task action limit reached · ${suffix}`, notice: 'The Task reached its four-action limit. This answer uses only the evidence gathered before that limit.', severity: 'warning', reportStatus: 'tool_limit' }; } if (task.status === 'decision_limit') { return { message: `Task decision limit reached · ${suffix}`, notice: 'The Task reached its six-decision limit. This answer uses only the evidence gathered before that limit.', severity: 'warning', reportStatus: 'decision_limit' }; } if (task.status === 'invalid_decision') { return { message: `Task paused safely · ${suffix}`, notice: 'The Task could not safely continue. This answer uses only the evidence gathered before it paused.', severity: 'warning', reportStatus: 'invalid_decision' }; } if (task.status === 'completed_with_available_evidence') { return { message: `Task completed with available evidence · ${suffix}`, notice: 'The Task finished with the evidence available before it paused.', severity: 'info', reportStatus: 'completed_with_available_evidence' }; } if (task.status === 'failed' || task.status === 'error') { return { message: `Task could not complete · ${suffix}`, notice: 'The Task could not complete this request. Retry when you are ready.', severity: 'error', reportStatus: 'request_failed' }; } return { message: `Task finished · ${suffix}`, notice: 'The Task finished with the evidence available. You can retry if you expected more work.', severity: 'info', reportStatus: 'completed_with_available_evidence' }; } async function sendRequest( message, history, files, { clearComposerOnSuccess = true, requestPreferences = null } = {} ) { const input = byId('message'); if (!message || !state.enabled || state.pending) return; const preferences = requestPreferences || { modelTier: state.modelTier, reasoningEffort: state.reasoningEffort, executionMode: state.executionMode, memoryEnabled: Boolean(state.memoryEnabled), individualMemory: memoryPayload(), sourcePolicy: { ...state.sourcePolicy, families: state.sourcePolicy.families.slice() } }; let githubContext = null; try { githubContext = githubContextForRequest(); } catch (error) { showNotice(error.message, 'warning'); connectionStatus(error.message); return; } const useProviderCredential = WORKSPACE === 'chat' && state.connections.byok.available && state.connections.byok.configured && state.connections.byok.enabled; const requestHistory = compactHistory(history); const requestSourcePolicy = normaliseSourcePolicy(preferences.sourcePolicy || state.sourcePolicy); const epoch = state.requestEpoch; const controller = new AbortController(); const requestScope = createRequestScope( epoch, state.activeChatId, state.ghost, controller.signal ); const requestIsCurrent = () => requestScopeIsCurrent(requestScope, { epoch: state.requestEpoch, chatId: state.activeChatId, ghost: state.ghost }); state.activeController = controller; state.pending = true; state.problemReport = { workspace: WORKSPACE, tier: WORKSPACE === 'chat' && state.allowedModelTiers.has(preferences.modelTier) ? preferences.modelTier : '', reasoningEffort: WORKSPACE === 'chat' && state.allowedReasoningEfforts.has(preferences.reasoningEffort) ? preferences.reasoningEffort : '', mode: WORKSPACE === 'chat' && preferences.executionMode === 'task' ? 'task' : 'answer', requestId: '', httpStatus: 0, progressStatus: 'sending', completionStatus: '', errorCode: '', taskStatus: '' }; state.lastRequest = { message, history: normaliseTranscript(history), files: files.slice(), requestPreferences: { modelTier: preferences.modelTier, reasoningEffort: preferences.reasoningEffort, executionMode: preferences.executionMode, memoryEnabled: Boolean(preferences.memoryEnabled), individualMemory: Array.isArray(preferences.individualMemory) ? preferences.individualMemory.slice() : [], sourcePolicy: { ...requestSourcePolicy, families: requestSourcePolicy.families.slice() } } }; state.lastRequest = createRetrySnapshot(state.lastRequest); setComposerBusy(true); clearNotice(); byId('streaming-status').textContent = 'Sending request…'; renderConversation(history); addMessage('user', message); const pending = addMessage('assistant', '', true); state.activePendingMessage = pending; const body = pending.querySelector('.message-body'); showWaitingIndicator(pending, 'Sending request…'); let answer = ''; let citations = []; let donePayload = null; try { const attachments = await uploadAttachments(files, { signal: controller.signal }); throwIfChatAborted(controller.signal); if (!requestIsCurrent()) return; const payload = { message, history: requestHistory, attachments, workspace: WORKSPACE, individual_memory: Array.isArray(preferences.individualMemory) ? preferences.individualMemory : [] }; if (state.sourceCapabilities.advertised) { payload.source_policy = { official: requestSourcePolicy.official, families: requestSourcePolicy.families, web_search: requestSourcePolicy.webSearch, web_source_review: requestSourcePolicy.webSourceReview }; } if (WORKSPACE !== 'research') { payload.model_tier = state.allowedModelTiers.has(preferences.modelTier) ? preferences.modelTier : 'spark'; payload.reasoning_effort = state.allowedReasoningEfforts.has(preferences.reasoningEffort) ? preferences.reasoningEffort : 'minimal'; payload.execution_mode = state.allowedExecutionModes.has(preferences.executionMode) ? preferences.executionMode : 'answer'; if (githubContext) payload.github_context = githubContext; if (useProviderCredential) payload.use_provider_credential = true; } const completed = await streamChat(payload, { controller, onResponse: ({ requestId, httpStatus }) => { if (!requestIsCurrent()) return; state.problemReport.requestId = requestId; state.problemReport.httpStatus = httpStatus; }, onStatus: (rawStatus) => { if (!requestIsCurrent()) return; const status = friendlyStreamStatus(rawStatus); byId('streaming-status').textContent = status.message; state.problemReport.progressStatus = normalisePublicReportStatus(status.code); updateWaitingIndicator(pending, status.message); } }); if (!requestIsCurrent()) return; answer = completed.answer; citations = completed.citations; donePayload = completed.done; try { clearWaitingIndicator(pending); body.textContent = answer; renderGroundedAnswer(body, answer, citations, donePayload); pending.classList.remove('pending'); if (state.activePendingMessage === pending) state.activePendingMessage = null; const now = Date.now(); const sourceManifest = sourceManifestFromResponse(donePayload); const storedAnswer = stripSourceCitationMarkers( stripDocumentCitationMarkers(answer, citations), sourceManifest ); state.history = normaliseTranscript([ ...history, { id: newMessageId(), role: 'user', content: message, createdAt: now }, { id: newMessageId(), role: 'assistant', content: storedAnswer, createdAt: now, sources: sourceManifest } ]); saveActiveChat(); if (preferences.memoryEnabled) learnFromCompletedMessage(message); if (clearComposerOnSuccess) { if (shouldClearSubmittedDraft(true, input.value, message)) input.value = ''; state.files = state.files.filter((file) => !files.includes(file)); renderFiles(); } const completion = completionStatus(donePayload); byId('streaming-status').textContent = completion.message; const task = donePayload.task && typeof donePayload.task === 'object' ? donePayload.task : {}; state.problemReport.progressStatus = completion.reportStatus; state.problemReport.completionStatus = completion.reportStatus; state.problemReport.taskStatus = donePayload.execution_mode === 'task' ? normalisePublicReportStatus(task.status, completion.reportStatus) : ''; if (completion.notice) showNotice(completion.notice, completion.severity); const truncated = Array.isArray(donePayload.attachments) ? donePayload.attachments.filter((item) => Boolean(item && item.truncated)) : []; if (truncated.length) { showNotice( `${truncated.map((item) => item.name).join(', ')}: only part of the extracted text fit in this reply’s context window.`, 'warning' ); } } catch (_) { body.textContent = answer; pending.classList.remove('pending'); if (state.activePendingMessage === pending) state.activePendingMessage = null; const postGenerationError = new Error('Post-generation browser processing failed.'); postGenerationError.stage = 'post_generation'; const failure = classifyChatFailure(postGenerationError); state.lastRequest = null; state.problemReport.progressStatus = 'request_failed'; state.problemReport.completionStatus = 'request_failed'; state.problemReport.errorCode = 'request_failed'; byId('streaming-status').textContent = failure.statusText; showNotice(failure.message); } } catch (error) { if (epoch !== state.requestEpoch || error.name === 'AbortError') return; clearWaitingIndicator(pending); pending.remove(); if (state.activePendingMessage === pending) state.activePendingMessage = null; const failure = classifyChatFailure(error); if (!failure.retainRetry) state.lastRequest = null; state.problemReport.requestId = safeReportToken( failure.requestId || state.problemReport.requestId, 128 ); state.problemReport.httpStatus = ( Number.isInteger(failure.status) && failure.status >= 100 && failure.status <= 599 ) ? failure.status : state.problemReport.httpStatus; state.problemReport.progressStatus = 'request_failed'; state.problemReport.completionStatus = state.problemReport.progressStatus; state.problemReport.errorCode = normalisePublicErrorCode( failure.code, failure.status ) || 'request_failed'; byId('streaming-status').textContent = failure.statusText; const reference = failure.requestId ? ` Reference: ${failure.requestId}.` : ''; const severity = ['access', 'auth', 'quota', 'validation'].includes(failure.kind) ? 'warning' : 'error'; showNotice(`${failure.message}${reference}`, severity); } finally { if (epoch === state.requestEpoch) { state.pending = false; if (state.activeController === controller) state.activeController = null; setComposerBusy(false); input.focus(); } } } async function submit(event) { event.preventDefault(); const message = byId('message').value.trim(); await sendRequest(message, state.history.slice(), state.files.slice()); } async function retryLastRequest() { if (!state.lastRequest || state.pending) return; const request = state.lastRequest; await sendRequest( request.message, request.history, request.files, { clearComposerOnSuccess: false, requestPreferences: request.requestPreferences } ); } function openDialog(dialogId) { const dialog = byId(dialogId); if (!dialog) return; if (typeof dialog.showModal === 'function') { if (!dialog.open) dialog.showModal(); } else { dialog.setAttribute('open', ''); } } function closeDialog(dialogId) { const dialog = byId(dialogId); if (!dialog) return; if (typeof dialog.close === 'function') dialog.close(); else dialog.removeAttribute('open'); } function currentProblemReportContext() { return normaliseProblemReportContext({ ...state.problemReport, workspace: state.problemReport.workspace || WORKSPACE, tier: state.problemReport.tier || (WORKSPACE === 'chat' ? state.modelTier : ''), reasoningEffort: state.problemReport.reasoningEffort || (WORKSPACE === 'chat' ? state.reasoningEffort : ''), mode: state.problemReport.mode || state.executionMode }); } function renderProblemReportDraft() { const category = byId('problem-report-category'); const description = byId('problem-report-description'); const preview = byId('problem-report-preview'); const email = byId('problem-report-email'); if (!category || !description || !preview || !email) return ''; const reportText = createProblemReportText( category.value, description.value, currentProblemReportContext(), state.problemReportDraftTime || new Date() ); preview.value = reportText; email.setAttribute('href', problemReportMailto(category.value, reportText)); return reportText; } function resetProblemReportDialog() { const category = byId('problem-report-category'); const description = byId('problem-report-description'); const preview = byId('problem-report-preview'); const status = byId('problem-report-status'); if (category) category.value = 'error'; if (description) { description.value = ''; description.setAttribute('aria-invalid', 'false'); } if (preview) preview.value = ''; if (status) status.textContent = ''; state.problemReportDraftTime = ''; } function openProblemReportDialog() { resetProblemReportDialog(); state.problemReportDraftTime = new Date().toISOString(); renderProblemReportDraft(); openDialog('problem-report-dialog'); const description = byId('problem-report-description'); if (description && typeof description.focus === 'function') description.focus(); } async function copyProblemReport() { const description = byId('problem-report-description'); const status = byId('problem-report-status'); if (!description || !status) return; if (!cleanProblemDescription(description.value)) { description.setAttribute('aria-invalid', 'true'); status.textContent = 'Add a short description before copying the report.'; description.focus(); return; } description.setAttribute('aria-invalid', 'false'); const reportText = renderProblemReportDraft(); try { if ( !globalThis.navigator || !globalThis.navigator.clipboard || typeof globalThis.navigator.clipboard.writeText !== 'function' ) throw new Error('clipboard unavailable'); await globalThis.navigator.clipboard.writeText(reportText); status.textContent = 'Report copied. Nothing was sent.'; } catch (_) { status.textContent = 'Copy is unavailable in this browser. Select the report draft and copy it manually.'; byId('problem-report-preview').focus(); byId('problem-report-preview').select(); } } function openSourceDrawer(source, trigger = null) { const drawer = byId('source-drawer'); if (!drawer) return; const normalized = normaliseSourceRecord(source, source && source.kind); state.drawerReturnFocus = trigger && typeof trigger.focus === 'function' ? trigger : document.activeElement; byId('source-drawer-kind').textContent = normalized.kind === 'web' ? 'Web source' : 'Official source'; byId('source-drawer-title').textContent = normalized.title || normalized.publisher || normalized.domain || normalized.sourceId || 'Source details'; const meta = byId('source-drawer-meta'); meta.replaceChildren(); [ normalized.publisher || normalized.domain, normalized.location, normalized.authorityLabel, normalized.sourceFamilyLabel, normalized.accessedAt ? `Accessed ${normalized.accessedAt}` : '' ].filter(Boolean).forEach((value) => { const item = document.createElement('span'); item.textContent = value; meta.appendChild(item); }); byId('source-drawer-excerpt-label').textContent = normalized.excerptKind || 'Relevant passage'; byId('source-drawer-excerpt').textContent = normalized.excerpt || 'No source excerpt was included with this citation.'; const review = byId('source-drawer-review'); const reviewText = [normalized.reviewStatus, normalized.reviewReason].filter(Boolean).join(' · '); review.hidden = !reviewText; review.textContent = reviewText ? `Source review: ${reviewText}` : ''; const sourceLink = byId('source-drawer-link'); const renderer = globalThis.SynderesisCitationRenderer; const safeUrl = renderer && typeof renderer.safeSourceUrl === 'function' ? renderer.safeSourceUrl({ kind: normalized.kind, url: normalized.url }) : normalized.kind === 'web' ? safeHttpsUrl(normalized.url) : ''; sourceLink.hidden = !safeUrl; if (safeUrl) sourceLink.href = safeUrl; else sourceLink.removeAttribute('href'); openDialog('source-drawer'); } function restoreSourceDrawerFocus() { const target = state.drawerReturnFocus; state.drawerReturnFocus = null; if (target && typeof target.focus === 'function' && target.isConnected !== false) target.focus(); } function currentConversationTitle() { const active = state.chats.find((chat) => chat.id === state.activeChatId); if (active) return active.title; const firstUser = state.history.find((item) => item.role === 'user'); return chatTitle(firstUser && firstUser.content); } async function loadGithubRepositories({ announce = true } = {}) { const github = state.connections.github; if (!github.available || !github.connected || github.repositoriesLoading) return; const previousRepositoryId = github.repositoryId; const previouslyEnabled = github.enabled; github.repositoriesLoading = true; renderGithubRepositoryOptions(); renderConnectionControls(); if (announce) connectionStatus('Loading approved GitHub repositories…'); try { const payload = await api('/v1/account/connectors/github/repositories'); github.repositories = normaliseGithubRepositories(payload.data); github.repositoriesLoaded = true; if (!github.repositories.some((repository) => repository.id === github.repositoryId)) { github.repositoryId = ''; github.enabled = false; } connectionStatus(github.repositories.length ? `${github.repositories.length} ${github.repositories.length === 1 ? 'repository is' : 'repositories are'} available.` : 'No approved repositories are available for this connection.'); } catch (_) { github.repositories = []; github.repositoriesLoaded = false; github.repositoryId = ''; github.enabled = false; connectionStatus('GitHub repositories could not be loaded.'); } finally { github.repositoriesLoading = false; if ( github.repositoryId !== previousRepositoryId || github.enabled !== previouslyEnabled ) { clearRetryForConnectionChange(); } renderGithubRepositoryOptions(); renderConnectionControls(); } } function openConnectionsDialog() { renderConnectionControls(); connectionStatus(''); openDialog('connections-dialog'); if ( state.connections.github.connected && !state.connections.github.repositoriesLoaded ) { loadGithubRepositories({ announce: false }); } } async function authorizeGithub() { if (!state.connections.github.available) { connectionStatus('GitHub is unavailable in this workspace.'); return; } const button = byId('github-authorize'); button.disabled = true; connectionStatus('Preparing the read-only GitHub authorization…'); try { const payload = await api('/v1/account/connectors/github/authorize', { method: 'POST' }); const authorizeUrl = new URL(String(payload.authorize_url || '')); if ( authorizeUrl.protocol !== 'https:' || authorizeUrl.hostname !== 'github.com' || authorizeUrl.username || authorizeUrl.password ) { throw new Error('invalid GitHub authorization URL'); } globalThis.location.assign(authorizeUrl.href); renderConnectionControls(); } catch (_) { connectionStatus('GitHub authorization could not be started.'); renderConnectionControls(); } } async function disconnectGithub() { if (!state.connections.github.connected) return; if (!globalThis.confirm('Disconnect GitHub and remove access to its approved repositories?')) return; const button = byId('github-disconnect'); button.disabled = true; connectionStatus('Disconnecting GitHub…'); try { await api('/v1/account/connectors/github', { method: 'DELETE' }); const github = state.connections.github; github.connected = false; github.enabled = false; github.repositories = []; github.repositoriesLoaded = false; github.repositoryId = ''; github.ref = ''; github.paths = ['README.md']; clearRetryForConnectionChange(); renderGithubRepositoryOptions(); connectionStatus('GitHub disconnected.'); } catch (_) { connectionStatus('GitHub could not be disconnected.'); } finally { renderConnectionControls(); } } async function saveProviderCredential() { const input = byId('byok-api-key'); if (!input || !state.connections.byok.available) { connectionStatus('Provider credentials are unavailable in this workspace.'); return; } if (!input.value.trim()) { input.setAttribute('aria-invalid', 'true'); connectionStatus('Enter an OpenRouter API key.'); input.focus(); return; } const remember = Boolean(byId('byok-remember').checked); const button = byId('byok-save'); button.disabled = true; input.setAttribute('aria-invalid', 'false'); connectionStatus('Saving the provider credential…'); try { const payload = await api('/v1/account/provider-credential', { method: 'PUT', body: JSON.stringify({ api_key: input.value, remember }) }); const byok = state.connections.byok; byok.configured = payload.configured !== false; byok.enabled = false; byok.scope = boundedText(payload.scope, 40) || (remember ? 'account' : 'session'); byok.lastFour = boundedText(payload.last_four, 12).slice(-4); byok.expiresAt = boundedText(payload.expires_at, 80); clearRetryForConnectionChange(); connectionStatus(byok.configured ? 'Provider credential saved. Turn on “Use my key” to use it for requests in this tab.' : 'The provider credential was not saved.'); } catch (_) { connectionStatus('The provider credential could not be saved.'); } finally { input.value = ''; input.setAttribute('aria-invalid', 'false'); renderConnectionControls(); } } async function deleteProviderCredential() { if (!state.connections.byok.configured) return; if (!globalThis.confirm('Remove the saved provider credential?')) return; const button = byId('byok-delete'); button.disabled = true; connectionStatus('Removing the provider credential…'); try { await api('/v1/account/provider-credential', { method: 'DELETE' }); const byok = state.connections.byok; byok.configured = false; byok.enabled = false; byok.scope = ''; byok.lastFour = ''; byok.expiresAt = ''; clearRetryForConnectionChange(); connectionStatus('Provider credential removed.'); } catch (_) { connectionStatus('The provider credential could not be removed.'); } finally { renderConnectionControls(); } } function openMemoryDialog() { renderMemorySettings(); openDialog('memory-dialog'); } function clearShareResult() { state.lastShareToken = ''; state.lastShareUrl = ''; state.lastShareChatId = ''; byId('share-url').value = ''; byId('share-result').hidden = true; byId('share-status').textContent = ''; } function openShareDialog() { if (state.ghost) { showNotice( 'Ghost conversations cannot be shared because sharing creates a server snapshot.', 'warning' ); return; } if (state.history.length < 2) return; if (state.lastShareChatId !== state.activeChatId) clearShareResult(); byId('share-title').value = currentConversationTitle(); if (state.lastShareUrl) { byId('share-url').value = state.lastShareUrl; byId('share-result').hidden = false; } openDialog('share-dialog'); } async function createShareLink() { if (state.ghost || state.history.length < 2) return; const button = byId('create-share'); button.disabled = true; byId('share-status').textContent = 'Creating an expiring text-only snapshot…'; try { const payload = await api('/v1/browser/chat/shares', { method: 'POST', body: JSON.stringify({ title: byId('share-title').value.trim() || currentConversationTitle(), messages: compactHistory(state.history) }) }); state.lastShareToken = String(payload.share_token || ''); state.lastShareChatId = state.activeChatId; state.lastShareUrl = new URL(String(payload.share_path || ''), globalThis.location.href).href; byId('share-url').value = state.lastShareUrl; byId('share-result').hidden = false; byId('share-status').textContent = `Link created. It expires ${formatChatDate(payload.expires_at)}.`; } catch (error) { byId('share-status').textContent = error.message; } finally { button.disabled = false; } } async function copyShareLink() { if (!state.lastShareUrl) return; try { if (!globalThis.navigator.clipboard || typeof globalThis.navigator.clipboard.writeText !== 'function') { throw new Error('clipboard unavailable'); } await globalThis.navigator.clipboard.writeText(state.lastShareUrl); byId('share-status').textContent = 'Share link copied.'; } catch (_) { const input = byId('share-url'); input.focus(); input.select(); byId('share-status').textContent = 'Copy the selected link manually.'; } } async function revokeShareLink() { if (!state.lastShareToken) return; if (!globalThis.confirm('Revoke this share link now? Anyone using it will lose access.')) return; const button = byId('revoke-share'); button.disabled = true; try { await api('/v1/browser/chat/shares', { method: 'DELETE', body: JSON.stringify({ share_token: state.lastShareToken }) }); clearShareResult(); byId('share-status').textContent = 'Share link revoked.'; } catch (error) { byId('share-status').textContent = error.message; } finally { button.disabled = false; } } function renderDownloadSelection() { const list = byId('download-selection-list'); list.replaceChildren(); state.history.forEach((message, index) => { const label = document.createElement('label'); label.className = 'download-item'; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = true; checkbox.dataset.messageIndex = String(index); const text = document.createElement('span'); const role = document.createElement('strong'); role.textContent = message.role === 'user' ? 'You' : 'Synderesis'; const preview = document.createElement('small'); preview.textContent = message.content.replace(/\s+/g, ' ').slice(0, 110); text.append(role, preview); label.append(checkbox, text); list.appendChild(label); }); byId('download-select-all').checked = true; byId('download-status').textContent = ''; } function openDownloadDialog() { if (!state.history.length) return; renderDownloadSelection(); openDialog('download-dialog'); } function downloadSelectedConversation() { const selectedIndexes = Array.from( byId('download-selection-list').querySelectorAll('input[type="checkbox"]:checked') ).map((input) => Number(input.dataset.messageIndex)); const selected = selectedIndexes .filter((index) => Number.isInteger(index) && state.history[index]) .map((index) => state.history[index]); if (!selected.length) { byId('download-status').textContent = 'Select at least one message.'; return; } const format = byId('download-format').value; const title = currentConversationTitle(); const content = formatConversationDownload(selected, format, title); const extensions = { markdown: 'md', text: 'txt', json: 'json' }; const mediaTypes = { markdown: 'text/markdown;charset=utf-8', text: 'text/plain;charset=utf-8', json: 'application/json;charset=utf-8' }; const filenameBase = title .toLocaleLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 60) || 'synderesis-conversation'; const url = URL.createObjectURL(new Blob([content], { type: mediaTypes[format] })); const link = document.createElement('a'); link.href = url; link.download = `${filenameBase}.${extensions[format]}`; link.click(); setTimeout(() => URL.revokeObjectURL(url), 0); byId('download-status').textContent = `${selected.length} ${selected.length === 1 ? 'message' : 'messages'} downloaded.`; } function renderReadingListSelection() { const list = byId('reading-list-selection'); if (!list) return []; const works = collectReadingList(state.history); list.replaceChildren(); works.forEach((source, index) => { const label = document.createElement('label'); label.className = 'download-item'; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = true; checkbox.dataset.sourceIndex = String(index); const text = document.createElement('span'); const title = document.createElement('strong'); title.textContent = source.title; const detail = document.createElement('small'); detail.textContent = [ source.publisher || source.domain, source.locations.join(', ') ].filter(Boolean).join(' · '); text.append(title, detail); label.append(checkbox, text); list.appendChild(label); }); list._synderesisReadingList = works; byId('reading-list-select-all').checked = works.length > 0; byId('reading-list-status').textContent = works.length ? `${works.length} ${works.length === 1 ? 'work' : 'works'} cited.` : 'No eligible official or web citations are stored in this conversation.'; return works; } function openReadingListDialog() { if (!collectReadingList(state.history).length) return; renderReadingListSelection(); openDialog('reading-list-dialog'); } function downloadReadingList() { const list = byId('reading-list-selection'); const works = Array.isArray(list._synderesisReadingList) ? list._synderesisReadingList : collectReadingList(state.history); const selectedIndexes = Array.from( list.querySelectorAll('input[type="checkbox"]:checked') ).map((input) => Number(input.dataset.sourceIndex)); const selected = selectedIndexes .filter((index) => Number.isInteger(index) && works[index]) .map((index) => works[index]); if (!selected.length) { byId('reading-list-status').textContent = 'Select at least one source.'; return; } const format = byId('reading-list-format').value === 'json' ? 'json' : 'markdown'; const title = `${currentConversationTitle()} — reading list`; const content = formatReadingList(selected, format, title); const extension = format === 'json' ? 'json' : 'md'; const mediaType = format === 'json' ? 'application/json;charset=utf-8' : 'text/markdown;charset=utf-8'; const filenameBase = currentConversationTitle() .toLocaleLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 54) || 'synderesis-conversation'; const url = URL.createObjectURL(new Blob([content], { type: mediaType })); const link = document.createElement('a'); link.href = url; link.download = `${filenameBase}-reading-list.${extension}`; link.click(); setTimeout(() => URL.revokeObjectURL(url), 0); byId('reading-list-status').textContent = `${selected.length} ${selected.length === 1 ? 'source' : 'sources'} downloaded.`; } async function initialise() { document.body.classList.remove('chat-workspace-active'); try { const account = await api('/v1/account'); if (!account.chat_access) { byId('waitlist-only').hidden = false; return; } const access = await api('/v1/browser/chat/access'); if (!access.enabled) { byId('waitlist-only').hidden = false; return; } state.enabled = true; state.imageInputs = Boolean(access.image_inputs); state.imageMediaTypes = new Set(Array.isArray(access.attachments.image_media_types) ? access.attachments.image_media_types : []); state.limits = { maxFiles: Number(access.attachments.max_files) || 4, maxFileBytes: Number(access.attachments.max_file_bytes) || 5000000, maxTotalFileBytes: Number(access.attachments.max_total_file_bytes) || 10000000, maxTotalTextChars: Number(access.attachments.max_total_text_chars) || 20000, historyMaxMessages: Number(access.history && access.history.max_messages) || 12, historyMaxChars: Number(access.history && access.history.max_chars) || 12000, historyMaxItemChars: Number(access.history && access.history.max_item_chars) || 6000 }; const individualMemory = access.memory && access.memory.individual ? access.memory.individual : {}; state.memoryLimits = { maxEntries: Number(individualMemory.max_entries) || DEFAULT_MEMORY_LIMITS.maxEntries, maxItemChars: Number(individualMemory.max_item_chars) || DEFAULT_MEMORY_LIMITS.maxItemChars, maxTotalChars: Number(individualMemory.max_total_chars) || DEFAULT_MEMORY_LIMITS.maxTotalChars }; const organizationMemory = access.memory && access.memory.organization ? access.memory.organization : {}; state.organizationMemory = Array.isArray(organizationMemory.entries) ? organizationMemory.entries .filter((entry) => typeof entry === 'string' && entry.trim()) .map((entry) => entry.trim().slice(0, state.memoryLimits.maxItemChars)) : []; state.organizationId = typeof organizationMemory.organization_id === 'string' ? organizationMemory.organization_id.slice(0, 120) : ''; const tiers = access.model_tiers || {}; const efforts = access.reasoning_efforts || {}; const executionModes = access.execution_modes || {}; const allowedModelTiers = Array.isArray(tiers.values) ? tiers.values.filter((value) => ['spark', 'pro'].includes(value)) : ['spark', 'pro']; const allowedReasoningEfforts = Array.isArray(efforts.values) ? efforts.values.filter((value) => REASONING_EFFORTS.includes(value)) : REASONING_EFFORTS; state.allowedModelTiers = new Set(allowedModelTiers.length ? allowedModelTiers : ['spark']); state.allowedReasoningEfforts = new Set( allowedReasoningEfforts.length ? allowedReasoningEfforts : REASONING_EFFORTS ); const allowedExecutionModes = Array.isArray(executionModes.values) ? executionModes.values.filter((value) => ['answer', 'task'].includes(value)) : ['answer']; state.allowedExecutionModes = new Set( allowedExecutionModes.length ? allowedExecutionModes : ['answer'] ); state.modelDetails = tiers.details && typeof tiers.details === 'object' ? tiers.details : {}; state.outputTokenLimit = Number(access.limits && access.limits.output_tokens) || 0; const sourceAccess = access.sources && typeof access.sources === 'object' ? access.sources : null; const officialSources = sourceAccess && (sourceAccess.official_retrieval || sourceAccess.official) ? (sourceAccess.official_retrieval || sourceAccess.official) : {}; const webSources = sourceAccess && sourceAccess.web_search ? sourceAccess.web_search : {}; const families = Array.isArray(officialSources.families) ? officialSources.families.map((item) => { if (typeof item === 'string') { return { value: item.slice(0, 80), label: item.slice(0, 120), available: true, sourceCount: 0 }; } const value = boundedText(item && (item.value || item.id || item.source_family), 80); const label = boundedText(item && (item.label || item.name || value), 120); const sourceCount = Math.max(0, Number(item && item.source_count) || 0); return { value, label, available: item && Object.prototype.hasOwnProperty.call(item, 'available') ? Boolean(item.available) : sourceCount > 0, sourceCount }; }).filter((item) => item.value && item.label) : []; state.sourceCapabilities = { advertised: Boolean(sourceAccess), officialAvailable: Boolean(officialSources.available), officialDefault: ['auto', 'on', 'off'].includes(officialSources.default) ? officialSources.default : 'auto', families, webAvailable: Boolean(webSources.available), reviewAvailable: Boolean( webSources.review_available || Object.prototype.hasOwnProperty.call(webSources, 'source_review_default') || Object.prototype.hasOwnProperty.call(webSources, 'review_default') ), reviewDefault: webSources.source_review_default !== false && webSources.review_default !== false }; if (WORKSPACE === 'research') { state.sourceCapabilities = { ...state.sourceCapabilities, advertised: false, officialAvailable: false, webAvailable: false, reviewAvailable: false }; } state.sourcePolicy = normaliseSourcePolicy({ official: state.sourceCapabilities.officialDefault, families: [], webSearch: Boolean(webSources.default), webSourceReview: state.sourceCapabilities.reviewDefault }); state.sharing = { enabled: Boolean(access.sharing && access.sharing.enabled), expiresAfterDays: Number(access.sharing && access.sharing.expires_after_days) || 30, textOnly: access.sharing ? Boolean(access.sharing.text_only) : true }; applyConnectionAccess(access); state.storageKey = `${CHAT_STORAGE_PREFIX}:${account.customer_id}`; state.legacyStorageKey = `${LEGACY_CHAT_STORAGE_PREFIX}:${account.customer_id}`; state.memoryKey = `${MEMORY_STORAGE_PREFIX}:${account.customer_id}`; loadSavedChats(); loadMemorySettings(); if (!state.allowedModelTiers.has(state.modelTier)) state.modelTier = tiers.default || 'spark'; if (!state.allowedReasoningEfforts.has(state.reasoningEffort)) state.reasoningEffort = efforts.default || 'minimal'; if (!state.allowedExecutionModes.has(state.executionMode)) { state.executionMode = executionModes.default || 'answer'; } byId('account-name').textContent = account.name || account.email; byId('context-status').textContent = access.image_inputs ? 'Documents and images supported' : 'Text documents supported'; const supportedTypes = Array.isArray(access.attachments.supported) ? access.attachments.supported.join(', ') : 'text-readable files, PDF, Office documents, ODT, EPUB, HTML, XML, and RTF'; const imageTypes = access.image_inputs ? ', plus PNG, JPEG, GIF, and WebP images' : ''; byId('attachment-help').textContent = `Up to ${state.limits.maxFiles} transient files. Open File rules for details.`; byId('upload-types').textContent = `Supported: ${supportedTypes}${imageTypes}. File contents are validated by the server.`; byId('upload-limit-details').textContent = `Up to ${state.limits.maxFiles} files, ${formatByteLimit(state.limits.maxFileBytes)} each, ${formatByteLimit(state.limits.maxTotalFileBytes)} combined, and ${formatCount(state.limits.maxTotalTextChars)} extracted text characters across readable files.`; const theme = globalThis.SynderesisTheme; if (theme && typeof theme.getPreference === 'function') { const preference = theme.getPreference(); document.querySelectorAll('input[name="theme-preference"]').forEach((input) => { input.checked = input.value === preference; }); } byId('chat-shell').hidden = false; document.body.classList.add('chat-workspace-active'); globalThis.scrollTo({ top: 0, left: 0, behavior: 'auto' }); renderHistory(); renderMode(); renderConversation(); renderMemorySettings(); } catch (error) { if (error.status === 401) byId('signed-out').hidden = false; else { byId('waitlist-only').hidden = false; } } } function bindConnectionInterface() { const trigger = byId('connections-settings'); if (!trigger) return; trigger.addEventListener('click', openConnectionsDialog); byId('github-authorize').addEventListener('click', authorizeGithub); byId('github-refresh').addEventListener('click', () => loadGithubRepositories()); byId('github-disconnect').addEventListener('click', disconnectGithub); byId('github-repository').addEventListener('change', (event) => { state.connections.github.repositoryId = String(event.target.value || ''); if (!state.connections.github.repositoryId) state.connections.github.enabled = false; clearRetryForConnectionChange(); connectionStatus(state.connections.github.repositoryId ? 'Repository selected for this tab.' : 'Choose a repository to use GitHub context.'); renderConnectionControls(); }); byId('github-ref').addEventListener('input', (event) => { state.connections.github.ref = String(event.target.value || '').slice(0, 200); clearRetryForConnectionChange(); }); byId('github-paths').addEventListener('input', (event) => { state.connections.github.paths = String(event.target.value || '').split(/\r?\n/); clearRetryForConnectionChange(); }); byId('github-context-enabled').addEventListener('change', (event) => { const enabled = Boolean(event.target.checked); const paths = normaliseGithubPaths(state.connections.github.paths); if ( enabled && ( !state.connections.github.repositoryId || paths.length < 1 || paths.length > 6 ) ) { state.connections.github.enabled = false; event.target.checked = false; connectionStatus(!state.connections.github.repositoryId ? 'Choose a repository before enabling GitHub context.' : 'Enter between 1 and 6 GitHub paths, one per line.'); } else { state.connections.github.enabled = enabled; state.connections.github.paths = paths; connectionStatus(enabled ? 'GitHub context enabled for requests in this tab.' : 'GitHub context disabled.'); } clearRetryForConnectionChange(); renderConnectionControls(); }); byId('byok-enabled').addEventListener('change', (event) => { const enabled = Boolean(event.target.checked); if (enabled && !state.connections.byok.configured) { state.connections.byok.enabled = false; event.target.checked = false; connectionStatus('Save an OpenRouter API key before enabling it.'); } else { state.connections.byok.enabled = enabled; connectionStatus(enabled ? 'Your provider credential is enabled for requests in this tab.' : 'Your provider credential is disabled for this tab.'); } clearRetryForConnectionChange(); renderConnectionControls(); }); byId('byok-api-key').addEventListener('input', (event) => { event.target.setAttribute('aria-invalid', 'false'); }); byId('byok-save').addEventListener('click', saveProviderCredential); byId('byok-delete').addEventListener('click', deleteProviderCredential); const dialog = byId('connections-dialog'); const clearProviderInput = () => { const input = byId('byok-api-key'); input.value = ''; input.setAttribute('aria-invalid', 'false'); }; dialog.addEventListener('close', clearProviderInput); const closeButton = dialog.querySelector('[data-close-dialog="connections-dialog"]'); if (closeButton) closeButton.addEventListener('click', clearProviderInput); globalThis.addEventListener('pagehide', () => { const input = byId('byok-api-key'); if (input) input.value = ''; }); } function syncThemeControlLayout(options) { const labels = Array.from(options.querySelectorAll('label')); if (labels.length !== 3) return; const optionsStyle = globalThis.getComputedStyle(options); const contentWidth = options.clientWidth - (Number.parseFloat(optionsStyle.paddingLeft) || 0) - (Number.parseFloat(optionsStyle.paddingRight) || 0); const gap = Number.parseFloat(optionsStyle.columnGap) || 0; const threeColumnWidth = (contentWidth - (gap * 2)) / 3; const shouldStack = labels.some((label) => { const labelStyle = globalThis.getComputedStyle(label); const text = document.createRange(); text.selectNodeContents(label); const chromeWidth = [ labelStyle.paddingLeft, labelStyle.paddingRight, labelStyle.borderLeftWidth, labelStyle.borderRightWidth ].reduce((total, value) => total + (Number.parseFloat(value) || 0), 0); return text.getBoundingClientRect().width + chromeWidth > threeColumnWidth + 0.5; }); options.classList.toggle('is-stacked', shouldStack); } function bindThemeControlLayout() { const options = document.querySelector('.theme-options'); if (!options) return; let frame = 0; const schedule = () => { globalThis.cancelAnimationFrame(frame); frame = globalThis.requestAnimationFrame(() => syncThemeControlLayout(options)); }; if (typeof globalThis.ResizeObserver === 'function') { const observer = new globalThis.ResizeObserver(schedule); observer.observe(options); options.querySelectorAll('label').forEach((label) => observer.observe(label)); } globalThis.addEventListener('resize', schedule); if (document.fonts && document.fonts.ready) { document.fonts.ready.then(schedule).catch(() => {}); } schedule(); } function revealFocusedComposerControl(event) { const target = event.target; if (!target || typeof target.getBoundingClientRect !== 'function') return; const panel = byId('chat-panel'); const panelRect = panel.getBoundingClientRect(); const targetRect = target.getBoundingClientRect(); let offset = 0; if (targetRect.top < panelRect.top) { offset = targetRect.top - panelRect.top; } else if (targetRect.bottom > panelRect.bottom) { offset = targetRect.bottom - panelRect.bottom; } if (offset !== 0) { panel.scrollTo({ top: panel.scrollTop + offset, left: 0, behavior: 'auto' }); } if (globalThis.matchMedia('(max-width: 780px)').matches) { globalThis.requestAnimationFrame(() => { if (!target.isConnected || document.activeElement !== target) return; if (!globalThis.matchMedia('(max-width: 780px)').matches) return; const updatedTargetRect = target.getBoundingClientRect(); const navRect = byId('nav').getBoundingClientRect(); const viewportTop = Math.max(0, navRect.bottom); let pageOffset = 0; if (updatedTargetRect.top < viewportTop) { pageOffset = updatedTargetRect.top - viewportTop; } else if (updatedTargetRect.bottom > globalThis.innerHeight) { pageOffset = updatedTargetRect.bottom - globalThis.innerHeight; } if (pageOffset !== 0) { const scrollingElement = document.scrollingElement || document.documentElement; const previousScrollBehavior = scrollingElement.style.scrollBehavior; try { scrollingElement.style.scrollBehavior = 'auto'; scrollingElement.scrollTop += pageOffset; } finally { scrollingElement.style.scrollBehavior = previousScrollBehavior; } } }); } } function bindProductDisclosure(detailsId) { const details = byId(detailsId); if (!details) return; const summary = details.querySelector(':scope > summary'); if (!summary) return; let keyboardOpen = false; const sync = () => summary.setAttribute('aria-expanded', details.open ? 'true' : 'false'); summary.addEventListener('keydown', (event) => { keyboardOpen = event.key === 'Enter' || event.key === ' '; }); details.addEventListener('toggle', () => { sync(); if (!details.open || !keyboardOpen) { keyboardOpen = false; return; } keyboardOpen = false; globalThis.requestAnimationFrame(() => { const firstControl = details.querySelector('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), a[href]'); if (firstControl) firstControl.focus(); }); }); details.addEventListener('keydown', (event) => { if (event.key !== 'Escape' || !details.open) return; event.preventDefault(); details.open = false; summary.focus(); }); sync(); } function bindChatInterface() { bindProductDisclosure('workspace-tools-disclosure'); bindThemeControlLayout(); bindConnectionInterface(); byId('chat-form').addEventListener('focusin', revealFocusedComposerControl); byId('add-context').addEventListener('click', () => byId('attachments').click()); byId('upload-limits').addEventListener('click', () => openDialog('upload-dialog')); byId('attachments').addEventListener('change', (event) => { addFiles(Array.from(event.target.files || [])); event.target.value = ''; }); byId('message').addEventListener('keydown', (event) => { if (event.key !== 'Enter' || event.shiftKey || event.isComposing) return; event.preventDefault(); byId('chat-form').requestSubmit(); }); byId('chat-form').addEventListener('submit', submit); byId('chat-stop').addEventListener('click', stopActiveRequest); byId('chat-retry').addEventListener('click', retryLastRequest); byId('report-problem').addEventListener('click', openProblemReportDialog); byId('problem-report-category').addEventListener('change', renderProblemReportDraft); byId('problem-report-description').addEventListener('input', (event) => { event.target.setAttribute('aria-invalid', 'false'); byId('problem-report-status').textContent = ''; renderProblemReportDraft(); }); byId('copy-problem-report').addEventListener('click', copyProblemReport); byId('problem-report-email').addEventListener('click', (event) => { const description = byId('problem-report-description'); if (!cleanProblemDescription(description.value)) { event.preventDefault(); description.setAttribute('aria-invalid', 'true'); byId('problem-report-status').textContent = 'Add a short description before opening an email draft.'; description.focus(); return; } description.setAttribute('aria-invalid', 'false'); renderProblemReportDraft(); byId('problem-report-status').textContent = 'Your email app will open a draft. Nothing is sent automatically.'; }); byId('problem-report-dialog').addEventListener('close', resetProblemReportDialog); byId('history-search').addEventListener('input', (event) => { state.historyQuery = String(event.target.value || ''); renderHistory(); }); byId('new-chat').addEventListener('click', resetConversation); byId('ghost-mode').addEventListener('click', () => setGhostMode(!state.ghost)); document.querySelectorAll('input[name="model-tier"]').forEach((input) => { input.addEventListener('change', (event) => { if (event.target.checked) { setProductPreferences(event.target.value, state.reasoningEffort); } }); }); document.querySelectorAll('input[name="execution-mode"]').forEach((input) => { input.addEventListener('change', (event) => { if (event.target.checked) { setProductPreferences( state.modelTier, state.reasoningEffort, event.target.value ); } }); }); const effort = byId('reasoning-effort'); if (effort) effort.addEventListener('input', (event) => setProductPreferences(state.modelTier, REASONING_EFFORTS[Number(event.target.value)] || 'minimal')); document.querySelectorAll('input[name="theme-preference"]').forEach((input) => { input.addEventListener('change', (event) => { if (!event.target.checked) return; const theme = globalThis.SynderesisTheme; if (theme && typeof theme.setPreference === 'function') { theme.setPreference(event.target.value); } }); }); const officialSources = byId('official-source-mode'); if (officialSources) { officialSources.addEventListener('change', (event) => { state.sourcePolicy.official = ['auto', 'on', 'off'].includes(event.target.value) ? event.target.value : 'auto'; if (state.sourcePolicy.official === 'off') state.sourcePolicy.families = []; byId('source-policy-status').textContent = `Official source retrieval set to ${state.sourcePolicy.official}.`; renderSourceControls(); }); } const webSearch = byId('web-search-enabled'); if (webSearch) { webSearch.addEventListener('change', (event) => { state.sourcePolicy.webSearch = Boolean(event.target.checked); if (state.sourcePolicy.webSearch && state.sourceCapabilities.reviewDefault) { state.sourcePolicy.webSourceReview = true; } byId('source-policy-status').textContent = state.sourcePolicy.webSearch ? 'Web search enabled for this tab. Source suitability review is on by default.' : 'Web search disabled.'; renderSourceControls(); }); } const webReview = byId('web-source-review'); if (webReview) { webReview.addEventListener('change', (event) => { state.sourcePolicy.webSourceReview = Boolean(event.target.checked); byId('source-policy-status').textContent = state.sourcePolicy.webSourceReview ? 'Source suitability review enabled.' : 'Source suitability review disabled. Provenance and citation checks remain enabled.'; renderSourceControls(); }); } const sourcePolicyTrigger = byId('sources-summary'); if (sourcePolicyTrigger) { sourcePolicyTrigger.addEventListener('click', () => { renderSourceControls(); openDialog('source-policy-dialog'); }); } byId('memory-settings').addEventListener('click', openMemoryDialog); byId('individual-memory-enabled').addEventListener('change', (event) => { state.memoryEnabled = Boolean(event.target.checked); persistMemorySettings(); renderMemorySettings(); renderMode(); byId('memory-status').textContent = state.memoryEnabled ? 'Individual memory enabled.' : 'Individual memory disabled. Saved entries were retained.'; }); byId('add-memory').addEventListener('click', () => { const input = byId('new-memory'); if (addMemoryContent(input.value)) input.value = ''; else byId('memory-status').textContent = 'Enter a new, non-duplicate memory.'; }); byId('new-memory').addEventListener('keydown', (event) => { if (event.key !== 'Enter') return; event.preventDefault(); byId('add-memory').click(); }); byId('clear-memories').addEventListener('click', () => { if (!state.memories.length || !globalThis.confirm('Clear all individual memories from this browser?')) return; state.memories = []; persistMemorySettings(); renderMemorySettings(); byId('memory-status').textContent = 'All individual memories cleared.'; }); byId('share-chat').addEventListener('click', openShareDialog); byId('create-share').addEventListener('click', createShareLink); byId('copy-share').addEventListener('click', copyShareLink); byId('revoke-share').addEventListener('click', revokeShareLink); byId('download-chat').addEventListener('click', openDownloadDialog); byId('download-selected').addEventListener('click', downloadSelectedConversation); byId('download-select-all').addEventListener('change', (event) => { byId('download-selection-list').querySelectorAll('input[type="checkbox"]').forEach((input) => { input.checked = Boolean(event.target.checked); }); }); byId('download-selection-list').addEventListener('change', () => { const boxes = Array.from(byId('download-selection-list').querySelectorAll('input[type="checkbox"]')); byId('download-select-all').checked = boxes.length > 0 && boxes.every((input) => input.checked); }); byId('reading-list').addEventListener('click', openReadingListDialog); byId('download-reading-list').addEventListener('click', downloadReadingList); byId('reading-list-select-all').addEventListener('change', (event) => { byId('reading-list-selection').querySelectorAll('input[type="checkbox"]').forEach((input) => { input.checked = Boolean(event.target.checked); }); }); byId('reading-list-selection').addEventListener('change', () => { const boxes = Array.from(byId('reading-list-selection').querySelectorAll('input[type="checkbox"]')); byId('reading-list-select-all').checked = boxes.length > 0 && boxes.every((input) => input.checked); }); document.querySelectorAll('[data-close-dialog]').forEach((button) => { button.addEventListener('click', () => closeDialog(String(button.dataset.closeDialog || ''))); }); document.querySelectorAll('[data-starter-prompt]').forEach((button) => { button.addEventListener('click', () => { byId('message').value = String(button.dataset.starterPrompt || ''); byId('message').focus(); }); }); const sourceDrawer = byId('source-drawer'); if (sourceDrawer) sourceDrawer.addEventListener('close', restoreSourceDrawerFocus); document.addEventListener('click', (event) => { document.querySelectorAll('.history-overflow[open]').forEach((details) => { if (!details.contains(event.target)) details.open = false; }); }); } const chatStateExports = { CHAT_STREAM_LIMITS, asBase64, chatTitle, cleanProblemDescription, classifyChatFailure, clipHistoryContent, collectReadingList, completionStatus, compactHistory, createProblemReportText, createNdjsonProtocolState, createRequestScope, createRetrySnapshot, fairTextAllocations, extractLearnedMemories, filterChats, formatConversationDownload, formatReadingList, limitChats, normaliseMemories, normaliseProblemReportContext, normaliseProductPreferences, normalisePublicErrorCode, normalisePublicReportStatus, normaliseSourceManifest, normaliseSourcePolicy, normaliseStoredChats, normaliseTranscript, isNdjsonMediaType, parseRetryAfterHeader, readingListKey, requestErrorNotice, requestIdFromResponse, requestScopeIsCurrent, serialiseChats, safeReportToken, showWaitingIndicator, sourceLabel, sourceManifestFromResponse, friendlyStreamStatus, finalizeStoppedMessage, clearWaitingIndicator, updateWaitingIndicator, problemReportMailto, shouldClearSubmittedDraft, sortChats, streamChat, stripDocumentCitationMarkers, stripSourceCitationMarkers, uploadAttachments }; if (typeof module !== 'undefined' && module.exports) { module.exports = chatStateExports; } else { bindChatInterface(); initialise(); }