Spaces:
Running
Running
| ; | |
| 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, '\\<') | |
| .replace(/>/g, '\\>'); | |
| } | |
| function markdownLinkDestination(value) { | |
| const url = safeHttpsUrl(value); | |
| if (!url) return ''; | |
| return `<${url.replace(/\\/g, '%5C').replace(/</g, '%3C').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(); | |
| } | |