cuivienen's picture
Deploy Synderesis API Docker Space
97a66d4 verified
Raw
History Blame Contribute Delete
172 kB
'use strict';
const byId = (id) => document.getElementById(id);
const workspaceValue = String(
typeof document !== 'undefined' && document.body
? document.body.dataset.workspace || 'chat'
: 'chat'
);
const WORKSPACE = workspaceValue === 'research' ? 'research' : 'chat';
const CHAT_STORAGE_PREFIX = WORKSPACE === 'research'
? 'synderesis_research_history_v2'
: 'synderesis_chat_history_v2';
const LEGACY_CHAT_STORAGE_PREFIX = WORKSPACE === 'research'
? 'synderesis_research_history_v1'
: 'synderesis_chat_history_v1';
const MEMORY_STORAGE_PREFIX = 'synderesis_individual_memory_v1';
const MAX_SAVED_CHATS = 20;
const MAX_CHAT_TITLE_CHARS = 54;
const MAX_TRANSCRIPT_MESSAGES = 100;
const MAX_TRANSCRIPT_CHARS = 100000;
const MAX_CHAT_STORAGE_CHARS = 2500000;
const MAX_PERSISTED_SOURCES_PER_MESSAGE = 24;
const MAX_SOURCE_EXCERPT_CHARS = 2400;
const MAX_SOURCE_EXCERPT_TOTAL_CHARS = 6000;
const MAX_SOURCE_FIELD_CHARS = 500;
const PROBLEM_REPORT_DESCRIPTION_LIMIT = 800;
const REASONING_EFFORTS = ['minimal', 'low', 'medium', 'high', 'max'];
const PROBLEM_REPORT_CATEGORIES = {
error: 'Error or interruption',
answer: 'Answer display',
task: 'Task behavior',
usage: 'Usage or access',
interface: 'Interface',
other: 'Other'
};
const SENSITIVE_URL_QUERY_NAMES = new Set([
'access_token',
'api_key',
'apikey',
'auth',
'authorization',
'client_secret',
'code',
'credential',
'jwt',
'key',
'password',
'passwd',
'secret',
'session',
'sig',
'signature',
'token'
]);
const PUBLIC_REPORT_CODES = new Set([
'access_denied',
'completed_with_available_evidence',
'decision_limit',
'incomplete_stream',
'invalid_decision',
'network_error',
'quota_exceeded',
'rate_limit',
'request_failed',
'request_too_large',
'service_unavailable',
'stopped',
'stream_error',
'timeout',
'tool_limit',
'validation_error'
]);
const PUBLIC_REPORT_STATUSES = new Set([
'access_required',
'checking_response',
'completed',
'completed_with_available_evidence',
'decision_limit',
'idle',
'incomplete_stream',
'invalid_decision',
'preparing_answer',
'rate_limited',
'reading_context',
'request_failed',
'reviewing_sources',
'sending',
'service_unavailable',
'stopped',
'tool_limit',
'validation_error',
'working'
]);
const DEFAULT_MEMORY_LIMITS = {
maxEntries: 25,
maxItemChars: 500,
maxTotalChars: 6000
};
const state = {
history: [],
chats: [],
activeChatId: null,
storageKey: '',
legacyStorageKey: '',
storageAvailable: true,
memoryKey: '',
memoryStorageAvailable: true,
memories: [],
memoryEnabled: false,
memoryLimits: { ...DEFAULT_MEMORY_LIMITS },
organizationMemory: [],
organizationId: '',
modelTier: 'spark',
reasoningEffort: 'minimal',
executionMode: 'answer',
allowedModelTiers: new Set(['spark', 'pro']),
allowedReasoningEfforts: new Set(REASONING_EFFORTS),
allowedExecutionModes: new Set(['answer']),
modelDetails: {},
outputTokenLimit: 0,
sourceCapabilities: {
advertised: false,
officialAvailable: false,
officialDefault: 'auto',
families: [],
webAvailable: false,
reviewAvailable: false,
reviewDefault: true
},
sourcePolicy: {
official: 'auto',
families: [],
webSearch: false,
webSourceReview: true
},
sharing: {
enabled: false,
expiresAfterDays: 30,
textOnly: true
},
connections: {
github: {
available: false,
connected: false,
enabled: false,
repositories: [],
repositoriesLoaded: false,
repositoriesLoading: false,
repositoryId: '',
ref: '',
paths: ['README.md']
},
byok: {
available: false,
configured: false,
enabled: false,
scope: '',
lastFour: '',
expiresAt: ''
}
},
lastShareToken: '',
lastShareUrl: '',
lastShareChatId: '',
ghost: false,
files: [],
limits: {
maxFiles: 4,
maxFileBytes: 5000000,
maxTotalFileBytes: 10000000,
maxTotalTextChars: 20000,
historyMaxMessages: 12,
historyMaxChars: 12000,
historyMaxItemChars: 6000
},
enabled: false,
imageInputs: false,
imageMediaTypes: new Set(),
requestEpoch: 0,
activeController: null,
activePendingMessage: null,
pending: false,
lastRequest: null,
historyQuery: '',
drawerReturnFocus: null,
problemReport: {
workspace: WORKSPACE,
tier: '',
reasoningEffort: '',
mode: 'answer',
requestId: '',
httpStatus: 0,
progressStatus: 'idle',
completionStatus: '',
errorCode: '',
taskStatus: ''
},
problemReportDraftTime: ''
};
const IMAGE_EXTENSIONS = new Set(['avif', 'bmp', 'gif', 'heic', 'jpeg', 'jpg', 'png', 'tif', 'tiff', 'webp']);
const CHAT_STREAM_LIMITS = Object.freeze({
maxFrameBytes: 256000,
maxBufferBytes: 256000,
maxAnswerBytes: 262144,
maxCitationsBytes: 256000,
maxTotalBytes: 2097152,
maxFrames: 4096,
maxStatusFrames: 512,
maxStatusChars: 160,
maxCitationCount: 32,
maxErrorMessageChars: 500,
maxRequestIdChars: 128,
maxRetryAfterSeconds: 86400,
idleTimeoutMs: 45000,
overallTimeoutMs: 720000
});
const SAFE_CHAT_ERROR_CODE = /^[a-z][a-z0-9_]{0,63}$/;
const SAFE_CHAT_REQUEST_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
function chatAbortError() {
const error = new Error('The request was stopped.');
error.name = 'AbortError';
error.kind = 'abort';
return error;
}
function chatProtocolError(protocolCode) {
const error = new Error('The response stream was invalid. Please retry.');
error.name = 'ChatStreamProtocolError';
error.kind = 'protocol';
error.code = 'stream_protocol_error';
error.protocolCode = String(protocolCode || 'invalid_stream').slice(0, 80);
error.retryable = true;
return error;
}
function chatTimeoutError(code) {
const error = new Error('The response timed out. Please retry.');
error.name = 'ChatStreamTimeoutError';
error.kind = 'network';
error.code = code === 'overall_timeout' ? 'overall_timeout' : 'idle_timeout';
error.retryable = true;
return error;
}
function normaliseSafeStreamText(value, limit) {
return String(value == null ? '' : value)
.replace(/[\u0000-\u001f\u007f]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, limit);
}
function streamLimitOptions(overrides = {}) {
const values = { ...CHAT_STREAM_LIMITS };
Object.keys(values).forEach((key) => {
if (!Object.prototype.hasOwnProperty.call(overrides, key)) return;
const candidate = Number(overrides[key]);
if (Number.isFinite(candidate) && candidate > 0) values[key] = Math.floor(candidate);
});
return Object.freeze(values);
}
function byteArray(value) {
if (value instanceof Uint8Array) return value;
if (value instanceof ArrayBuffer) return new Uint8Array(value);
if (ArrayBuffer.isView(value)) {
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
}
throw chatProtocolError('invalid_chunk');
}
function joinBytes(left, right, maximum) {
if (left.byteLength + right.byteLength > maximum) {
throw chatProtocolError('buffer_too_large');
}
if (!left.byteLength) return right.slice();
if (!right.byteLength) return left.slice();
const joined = new Uint8Array(left.byteLength + right.byteLength);
joined.set(left);
joined.set(right, left.byteLength);
return joined;
}
function safeErrorCode(value) {
const code = typeof value === 'string' ? value.trim() : '';
return SAFE_CHAT_ERROR_CODE.test(code) ? code : '';
}
function safeRequestId(value, limit = CHAT_STREAM_LIMITS.maxRequestIdChars) {
const requestId = typeof value === 'string' ? value.trim().slice(0, limit) : '';
return SAFE_CHAT_REQUEST_ID.test(requestId) ? requestId : '';
}
function safeStatusCode(value) {
const status = Number(value);
return Number.isInteger(status) && status >= 400 && status <= 599 ? status : 0;
}
function boundedRetryAfter(value, maximum = CHAT_STREAM_LIMITS.maxRetryAfterSeconds) {
if (typeof value === 'boolean' || value == null || value === '') return 0;
const seconds = Number(value);
if (!Number.isFinite(seconds) || seconds < 0) return 0;
return Math.min(Math.ceil(seconds), maximum);
}
function parseRetryAfterHeader(value, nowMs = Date.now()) {
const raw = typeof value === 'string' ? value.trim() : '';
if (!raw) return 0;
if (/^\d+(?:\.\d+)?$/.test(raw)) return boundedRetryAfter(raw);
const retryAt = Date.parse(raw);
if (!Number.isFinite(retryAt)) return 0;
return boundedRetryAfter(Math.max(0, (retryAt - nowMs) / 1000));
}
function errorFrameMetadata(frame, limits = CHAT_STREAM_LIMITS) {
if (!frame.error || typeof frame.error !== 'object' || Array.isArray(frame.error)) {
throw chatProtocolError('malformed_error');
}
const raw = frame.error;
const rawCode = raw.code ?? raw.type ?? frame.code;
const code = safeErrorCode(rawCode);
if (!code) throw chatProtocolError('malformed_error_code');
if (typeof raw.message !== 'string' || !raw.message.trim()) {
throw chatProtocolError('malformed_error_message');
}
const message = normaliseSafeStreamText(raw.message, limits.maxErrorMessageChars);
if (!message) throw chatProtocolError('malformed_error_message');
const rawStatus = raw.status ?? frame.status;
const status = rawStatus == null ? 0 : safeStatusCode(rawStatus);
if (rawStatus != null && !status) throw chatProtocolError('malformed_error_status');
const rawRetryAfter = raw.retry_after ?? frame.retry_after;
if (
rawRetryAfter != null
&& (
typeof rawRetryAfter === 'boolean'
|| !Number.isFinite(Number(rawRetryAfter))
|| Number(rawRetryAfter) < 0
)
) throw chatProtocolError('malformed_retry_after');
const rawRetryable = raw.retryable ?? frame.retryable;
if (rawRetryable != null && typeof rawRetryable !== 'boolean') {
throw chatProtocolError('malformed_retryable');
}
const rawRequestId = raw.request_id ?? frame.request_id;
const requestId = rawRequestId == null
? ''
: safeRequestId(rawRequestId, limits.maxRequestIdChars);
if (rawRequestId != null && !requestId) throw chatProtocolError('malformed_request_id');
return Object.freeze({
code,
message,
status,
retryAfter: boundedRetryAfter(rawRetryAfter, limits.maxRetryAfterSeconds),
retryable: typeof rawRetryable === 'boolean' ? rawRetryable : null,
requestId
});
}
function serverStreamError(metadata) {
const error = new Error(metadata.message);
error.name = 'ChatStreamServerError';
error.kind = 'server';
error.code = metadata.code;
error.status = metadata.status;
error.retryAfter = metadata.retryAfter;
error.retryable = metadata.retryable;
error.requestId = metadata.requestId;
return error;
}
function createNdjsonProtocolState(overrides = {}) {
const limits = streamLimitOptions(overrides);
const decoder = new TextDecoder('utf-8', { fatal: true });
const encoder = new TextEncoder();
let pending = new Uint8Array();
let totalBytes = 0;
let frameCount = 0;
let statusCount = 0;
let answerBytes = 0;
let answer = '';
let citations = null;
let citationsFrame = null;
let doneFrame = null;
let errorMetadata = null;
let phase = 'status';
let terminal = '';
let finished = false;
let finalResult = null;
const decodeFrame = (bytes) => {
let frameBytes = bytes;
if (frameBytes.byteLength && frameBytes[frameBytes.byteLength - 1] === 13) {
frameBytes = frameBytes.slice(0, -1);
}
if (!frameBytes.byteLength) throw chatProtocolError('blank_frame');
if (frameBytes.byteLength > limits.maxFrameBytes) {
throw chatProtocolError('frame_too_large');
}
let line = '';
try {
line = decoder.decode(frameBytes);
} catch (_) {
throw chatProtocolError('invalid_utf8');
}
if (!line.trim()) throw chatProtocolError('blank_frame');
let frame;
try {
frame = JSON.parse(line);
} catch (_) {
throw chatProtocolError('malformed_json');
}
if (!frame || typeof frame !== 'object' || Array.isArray(frame)) {
throw chatProtocolError('nonobject_frame');
}
return { frame, frameBytes: frameBytes.byteLength };
};
const handleFrame = (bytes) => {
if (terminal) throw chatProtocolError('frame_after_terminal');
frameCount += 1;
if (frameCount > limits.maxFrames) throw chatProtocolError('too_many_frames');
const parsed = decodeFrame(bytes);
const frame = parsed.frame;
if (typeof frame.type !== 'string') throw chatProtocolError('missing_frame_type');
if (frame.type === 'status') {
if (phase !== 'status') throw chatProtocolError('status_out_of_order');
if (typeof frame.message !== 'string') throw chatProtocolError('malformed_status');
const message = normaliseSafeStreamText(frame.message, limits.maxStatusChars);
if (!message || frame.message.length > limits.maxStatusChars) {
throw chatProtocolError('malformed_status');
}
statusCount += 1;
if (statusCount > limits.maxStatusFrames) throw chatProtocolError('too_many_status_frames');
return Object.freeze({ type: 'status', message });
}
if (frame.type === 'delta') {
if (phase !== 'status' && phase !== 'delta') {
throw chatProtocolError('delta_out_of_order');
}
if (typeof frame.text !== 'string' || !frame.text.length) {
throw chatProtocolError('empty_delta');
}
const deltaBytes = encoder.encode(frame.text).byteLength;
if (answerBytes + deltaBytes > limits.maxAnswerBytes) {
throw chatProtocolError('answer_too_large');
}
answer += frame.text;
answerBytes += deltaBytes;
phase = 'delta';
return Object.freeze({ type: 'delta', text: frame.text });
}
if (frame.type === 'citations') {
if (phase !== 'delta') throw chatProtocolError('citations_out_of_order');
if (!Array.isArray(frame.citations)) throw chatProtocolError('malformed_citations');
if (frame.citations.length > limits.maxCitationCount) {
throw chatProtocolError('too_many_citations');
}
if (
frame.citation_manifest != null
&& (
typeof frame.citation_manifest !== 'object'
|| Array.isArray(frame.citation_manifest)
)
) throw chatProtocolError('malformed_citation_manifest');
if (parsed.frameBytes > limits.maxCitationsBytes) {
throw chatProtocolError('citations_too_large');
}
citations = frame.citations.slice();
citationsFrame = frame;
phase = 'citations';
return Object.freeze({
type: 'citations',
citations,
citationManifest: frame.citation_manifest || {}
});
}
if (frame.type === 'done') {
if (phase !== 'citations') throw chatProtocolError('done_out_of_order');
if (!answer.trim()) throw chatProtocolError('empty_answer');
terminal = 'done';
phase = 'terminal';
doneFrame = frame;
return Object.freeze({ type: 'done', payload: frame });
}
if (frame.type === 'error') {
errorMetadata = errorFrameMetadata(frame, limits);
terminal = 'error';
phase = 'terminal';
return Object.freeze({ type: 'error', error: errorMetadata });
}
throw chatProtocolError('unknown_frame');
};
const push = (chunk) => {
if (finished) throw chatProtocolError('chunk_after_finish');
const bytes = byteArray(chunk);
totalBytes += bytes.byteLength;
if (totalBytes > limits.maxTotalBytes) throw chatProtocolError('stream_too_large');
const events = [];
let offset = 0;
while (offset < bytes.byteLength) {
const newline = bytes.indexOf(10, offset);
if (newline < 0) {
pending = joinBytes(
pending,
bytes.slice(offset),
Math.min(limits.maxBufferBytes, limits.maxFrameBytes)
);
offset = bytes.byteLength;
break;
}
const segment = bytes.slice(offset, newline);
const frameBytes = joinBytes(
pending,
segment,
Math.min(limits.maxBufferBytes, limits.maxFrameBytes)
);
pending = new Uint8Array();
events.push(handleFrame(frameBytes));
offset = newline + 1;
}
return events;
};
const finish = () => {
if (finished) return finalResult;
if (pending.byteLength) {
handleFrame(pending);
pending = new Uint8Array();
}
if (!terminal) throw chatProtocolError('incomplete_stream');
finished = true;
finalResult = terminal === 'done'
? Object.freeze({
terminal,
answer,
citations: citations || [],
citationFrame: citationsFrame,
done: doneFrame
})
: Object.freeze({ terminal, error: errorMetadata });
return finalResult;
};
return Object.freeze({
push,
finish,
snapshot: () => Object.freeze({
phase,
terminal,
totalBytes,
frameCount,
statusCount,
answerBytes
})
});
}
function isNdjsonMediaType(value) {
const mediaType = String(value || '').split(';', 1)[0].trim().toLowerCase();
return mediaType === 'application/x-ndjson';
}
function csrfToken() {
if (typeof document === 'undefined') return '';
const part = document.cookie.split('; ').find((item) => item.startsWith('synderesis_csrf='));
return part ? decodeURIComponent(part.slice(part.indexOf('=') + 1)) : '';
}
async function api(path, options = {}) {
const headers = new Headers(options.headers || {});
if (options.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
if (!['GET', 'HEAD'].includes((options.method || 'GET').toUpperCase())) {
const csrf = csrfToken();
if (csrf) headers.set('X-CSRF-Token', csrf);
}
const response = await fetch(path, {
...options,
headers,
credentials: 'same-origin',
cache: 'no-store'
});
let payload = {};
try { payload = await response.json(); } catch (_) { payload = {}; }
if (!response.ok) {
const message = payload.error && payload.error.message ? payload.error.message : `Request failed (${response.status})`;
const error = new Error(message);
error.status = response.status;
throw error;
}
return payload;
}
function showNotice(message, severity = 'error') {
const notice = byId('chat-notice');
const tone = ['info', 'warning', 'error'].includes(severity) ? severity : 'error';
notice.textContent = message;
notice.dataset.severity = tone;
notice.setAttribute('role', tone === 'error' ? 'alert' : 'status');
notice.setAttribute('aria-live', tone === 'error' ? 'assertive' : 'polite');
notice.classList.add('show');
}
function clearNotice() {
const notice = byId('chat-notice');
notice.textContent = '';
notice.dataset.severity = 'info';
notice.setAttribute('role', 'status');
notice.setAttribute('aria-live', 'polite');
notice.classList.remove('show');
}
function addMessage(role, content, pending = false, shouldScroll = true) {
const item = document.createElement('article');
item.className = `message ${role}${pending ? ' pending' : ''}`;
const label = document.createElement('div');
label.className = 'message-label';
label.textContent = role === 'user' ? 'You' : 'Synderesis';
const body = document.createElement('div');
body.className = 'message-body';
body.textContent = content;
item.append(label, body);
const messages = byId('messages');
messages.appendChild(item);
if (shouldScroll) {
if (typeof messages.scrollTo === 'function') {
messages.scrollTo({ top: messages.scrollHeight, behavior: 'smooth' });
} else {
item.scrollIntoView({ block: 'end', behavior: 'smooth' });
}
}
return item;
}
function showWaitingIndicator(message, label = 'Working on your response…') {
if (!message || typeof message.querySelector !== 'function') return null;
const body = message.querySelector('.message-body');
if (!body || !body.ownerDocument) return null;
while (body.firstChild) body.removeChild(body.firstChild);
body.textContent = '';
const indicator = body.ownerDocument.createElement('div');
indicator.className = 'waiting-indicator';
const dots = body.ownerDocument.createElement('span');
dots.className = 'waiting-dots';
dots.setAttribute('aria-hidden', 'true');
for (let index = 0; index < 3; index += 1) {
const dot = body.ownerDocument.createElement('span');
dot.className = 'waiting-dot';
dots.appendChild(dot);
}
const status = body.ownerDocument.createElement('span');
status.className = 'waiting-label';
status.textContent = String(label || 'Working on your response…');
indicator.appendChild(dots);
indicator.appendChild(status);
body.appendChild(indicator);
message._synderesisWaitingIndicator = indicator;
message._synderesisWaitingLabel = status;
return indicator;
}
function updateWaitingIndicator(message, label) {
if (!message || !message._synderesisWaitingLabel) return false;
message._synderesisWaitingLabel.textContent = String(label || 'Working on your response…');
return true;
}
function clearWaitingIndicator(message, { clearBody = true } = {}) {
if (!message || typeof message.querySelector !== 'function') return false;
const body = message.querySelector('.message-body');
const indicator = message._synderesisWaitingIndicator;
if (indicator && indicator.parentNode) indicator.parentNode.removeChild(indicator);
message._synderesisWaitingIndicator = null;
message._synderesisWaitingLabel = null;
if (clearBody && body) body.textContent = '';
return Boolean(indicator);
}
function extensionOf(file) {
const pieces = file.name.toLowerCase().split('.');
return pieces.length > 1 ? pieces.pop() : '';
}
function isImage(file) {
return file.type.startsWith('image/') || IMAGE_EXTENSIONS.has(extensionOf(file));
}
function formatByteLimit(value) {
const bytes = Math.max(0, Number(value) || 0);
if (bytes >= 1000000) return `${Math.floor(bytes / 1000000)} MB`;
return `${Math.floor(bytes / 1000)} KB`;
}
function formatCount(value) {
return new Intl.NumberFormat().format(Math.max(0, Number(value) || 0));
}
function boundedText(value, limit = MAX_SOURCE_FIELD_CHARS) {
return String(value == null ? '' : value).replace(/\s+/g, ' ').trim().slice(0, limit);
}
function sourceLabel(value) {
const normalized = boundedText(value, 120).replace(/[_-]+/g, ' ');
if (!normalized) return '';
const acronyms = new Set(['ccc', 'cic', 'cceo']);
return normalized
.split(/\s+/)
.map((word) => (
acronyms.has(word.toLowerCase())
? word.toUpperCase()
: `${word.charAt(0).toUpperCase()}${word.slice(1).toLowerCase()}`
))
.join(' ');
}
function isPublicSourceHostname(value) {
const host = String(value || '').toLowerCase().replace(/\.$/, '').replace(/^\[|\]$/g, '');
if (
!host
|| host === 'localhost'
|| !host.includes('.')
|| [
'.localhost', '.local', '.internal', '.intranet', '.lan', '.home',
'.test', '.invalid', '.example', '.onion'
]
.some((suffix) => host.endsWith(suffix))
|| host.includes(':')
) return false;
if (/^[0-9.]+$/.test(host)) {
const parts = host.split('.').map(Number);
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
return false;
}
const [first, second, third] = parts;
if (
first === 0
|| first === 10
|| first === 127
|| first >= 224
|| (first === 100 && second >= 64 && second <= 127)
|| (first === 169 && second === 254)
|| (first === 172 && second >= 16 && second <= 31)
|| (first === 192 && second === 168)
|| (first === 192 && second === 0 && (third === 0 || third === 2))
|| (first === 198 && (second === 18 || second === 19 || second === 51))
|| (first === 203 && second === 0 && third === 113)
) return false;
}
return host.split('.').every(
(label) => label
&& label.length <= 63
&& !label.startsWith('-')
&& !label.endsWith('-')
&& /^[a-z0-9-]+$/.test(label)
);
}
function isSensitiveUrlQueryName(value) {
const normalized = String(value || '')
.toLowerCase()
.replace(/[-.]+/g, '_')
.replace(/^_+|_+$/g, '');
return SENSITIVE_URL_QUERY_NAMES.has(normalized)
|| ['x_amz_', 'x_goog_', 'auth_'].some((prefix) => normalized.startsWith(prefix))
|| ['_token', '_key', '_secret', '_signature', '_credential', '_jwt']
.some((suffix) => normalized.endsWith(suffix));
}
function safeHttpsUrl(value) {
try {
const parsed = new URL(String(value || ''));
if (
parsed.protocol !== 'https:'
|| parsed.username
|| parsed.password
|| (parsed.port && parsed.port !== '443')
|| !isPublicSourceHostname(parsed.hostname)
|| Array.from(parsed.searchParams.keys()).some(isSensitiveUrlQueryName)
) return '';
parsed.hash = '';
return parsed.href;
} catch (_) {
return '';
}
}
function safeReportToken(value, limit = 128) {
const normalized = String(value == null ? '' : value).trim().slice(0, limit);
return normalized && /^[a-zA-Z0-9._:-]+$/.test(normalized) ? normalized : '';
}
function normalisePublicErrorCode(value, httpStatus = 0) {
const normalized = safeReportToken(value, 80).toLowerCase().replace(/-/g, '_');
if (PUBLIC_REPORT_CODES.has(normalized)) return normalized;
if (Number(httpStatus) === 429) return 'rate_limit';
if (Number(httpStatus) === 413) return 'request_too_large';
if (Number(httpStatus) === 408 || Number(httpStatus) === 504) return 'timeout';
if (Number(httpStatus) === 503) return 'service_unavailable';
if ([400, 422].includes(Number(httpStatus))) return 'validation_error';
if ([401, 403].includes(Number(httpStatus))) return 'access_denied';
return normalized ? 'request_failed' : '';
}
function normalisePublicReportStatus(value, fallback = 'working') {
const normalized = safeReportToken(value, 80).toLowerCase().replace(/-/g, '_');
return PUBLIC_REPORT_STATUSES.has(normalized)
? normalized
: PUBLIC_REPORT_STATUSES.has(fallback) ? fallback : 'working';
}
function friendlyStreamStatus(value) {
const status = String(value || '').toLowerCase();
if (/(upload|attachment|document|context|extract|read)/.test(status)) {
return { message: 'Reading the provided context…', code: 'reading_context' };
}
if (/(search|retriev|source|evidence|research)/.test(status)) {
return { message: 'Reviewing sources…', code: 'reviewing_sources' };
}
if (/(validat|verify|ground|check|review|safety)/.test(status)) {
return { message: 'Checking the response…', code: 'checking_response' };
}
if (/(answer|draft|write|generat|respond|final)/.test(status)) {
return { message: 'Preparing the answer…', code: 'preparing_answer' };
}
if (/(task|action|decision|controller|plan|step)/.test(status)) {
return { message: 'Working through the task…', code: 'working' };
}
return { message: 'Working on your response…', code: 'working' };
}
function requestIdFromResponse(response) {
if (!response || !response.headers || typeof response.headers.get !== 'function') return '';
return safeReportToken(response.headers.get('X-Request-ID'), 128);
}
function normaliseProblemReportContext(value) {
const raw = value && typeof value === 'object' ? value : {};
const workspace = raw.workspace === 'research' ? 'research' : 'chat';
return {
requestId: safeReportToken(raw.requestId, 128),
workspace,
tier: workspace === 'chat' && ['spark', 'pro'].includes(raw.tier) ? raw.tier : '',
reasoningEffort: workspace === 'chat' && REASONING_EFFORTS.includes(raw.reasoningEffort)
? raw.reasoningEffort
: '',
mode: raw.mode === 'task' ? 'task' : 'answer',
progressStatus: normalisePublicReportStatus(raw.progressStatus || 'idle', 'idle'),
completionStatus: raw.completionStatus
? normalisePublicReportStatus(raw.completionStatus, 'request_failed')
: '',
errorCode: raw.errorCode
? normalisePublicErrorCode(raw.errorCode, raw.httpStatus)
: '',
httpStatus: Number.isInteger(raw.httpStatus) && raw.httpStatus >= 100 && raw.httpStatus <= 599
? raw.httpStatus
: 0,
taskStatus: raw.taskStatus
? normalisePublicReportStatus(raw.taskStatus, 'working')
: ''
};
}
function cleanProblemDescription(value) {
return String(value == null ? '' : value)
.replace(/\r\n?/g, '\n')
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')
.trim()
.slice(0, PROBLEM_REPORT_DESCRIPTION_LIMIT);
}
function createProblemReportText(category, description, context, timestamp = new Date()) {
const categoryKey = Object.prototype.hasOwnProperty.call(PROBLEM_REPORT_CATEGORIES, category)
? category : 'other';
const report = normaliseProblemReportContext(context);
const requestedTime = new Date(timestamp);
const utcTime = Number.isNaN(requestedTime.getTime())
? new Date().toISOString()
: requestedTime.toISOString();
const lines = [
'Synderesis problem report',
'',
`Category: ${PROBLEM_REPORT_CATEGORIES[categoryKey]}`,
'Description:',
cleanProblemDescription(description) || '(not provided)',
'',
'Diagnostic context',
`UTC time: ${utcTime}`,
`Request ID: ${report.requestId || 'Unavailable'}`,
`Workspace: ${report.workspace === 'research' ? 'Research' : 'Catholic'}`,
`Mode: ${report.mode === 'task' ? 'Task' : 'Answer'}`
];
if (report.tier) lines.push(`Tier: ${report.tier === 'pro' ? 'Pro' : 'Spark'}`);
if (report.reasoningEffort) {
lines.push(`Reasoning effort: ${report.reasoningEffort[0].toUpperCase()}${report.reasoningEffort.slice(1)}`);
}
lines.push(`Progress status: ${report.progressStatus}`);
if (report.completionStatus) lines.push(`Completion status: ${report.completionStatus}`);
if (report.errorCode) lines.push(`Error code: ${report.errorCode}`);
if (report.httpStatus) lines.push(`HTTP status: ${report.httpStatus}`);
if (report.taskStatus) lines.push(`Task status: ${report.taskStatus}`);
return lines.join('\n');
}
function problemReportMailto(category, reportText) {
const categoryKey = Object.prototype.hasOwnProperty.call(PROBLEM_REPORT_CATEGORIES, category)
? category : 'other';
const subject = `Synderesis problem report: ${PROBLEM_REPORT_CATEGORIES[categoryKey]}`;
return `mailto:hello@synderesis.eu?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(String(reportText || ''))}`;
}
function requestErrorNotice(error) {
const status = Number(error && error.status) || 0;
const code = normalisePublicErrorCode(
error && (error.code || error.errorCode),
status
);
if (code === 'invalid_decision') {
return {
message: 'The Task could not safely continue. Retry when you are ready.',
severity: 'warning',
code
};
}
if (code === 'completed_with_available_evidence') {
return {
message: 'The Task finished with the evidence available before it paused.',
severity: 'info',
code
};
}
if (code === 'rate_limit' || code === 'quota_exceeded') {
return {
message: 'A temporary usage limit was reached. Wait a little and try again.',
severity: 'warning',
code
};
}
if (code === 'request_too_large' || code === 'validation_error') {
return {
message: 'The request could not be used as submitted. Review its text or files and try again.',
severity: 'warning',
code
};
}
if (code === 'access_denied') {
return {
message: 'Chat access needs to be refreshed. Sign in again and retry.',
severity: 'warning',
code
};
}
const rawMessage = String(error && error.message || '').trim().slice(0, 320);
const exposesImplementation = /(controller|gatekeeper|provider|model route|stack|trace|invalid decision)/i.test(rawMessage);
return {
message: rawMessage && !exposesImplementation
? rawMessage
: 'Synderesis could not complete this request. Please try again.',
severity: 'error',
code: code || 'request_failed'
};
}
function normaliseSourceRecord(value, fallbackKind = 'official') {
const raw = value && typeof value === 'object' ? value : {};
const review = raw.source_review && typeof raw.source_review === 'object'
? raw.source_review
: raw.review && typeof raw.review === 'object' ? raw.review : {};
const kindSignals = [
raw.kind,
raw.source_kind,
raw.source_family,
raw.content_kind,
raw.chunk_kind,
raw.provenance_kind,
raw.provenance,
fallbackKind
].map((item) => boundedText(item, 120).toLowerCase());
const kind = kindSignals.some((item) => (
item === 'web' || item === 'provider_excerpt' || item.includes('web_search')
)) ? 'web' : 'official';
const contentKind = boundedText(raw.content_kind || raw.chunk_kind, 80).toLowerCase();
const excerptKind = boundedText(raw.excerpt_kind, 80) || (
['note', 'curated_source_note'].includes(contentKind)
? 'Curated source note'
: ['registry', 'registry_metadata'].includes(contentKind)
? 'Source registry metadata'
: contentKind === 'provider_excerpt' || kind === 'web'
? 'Web excerpt'
: 'Verbatim passage'
);
return {
kind,
citationId: boundedText(raw.citationId || raw.citation_id || raw.id || raw.key, 80),
sourceId: boundedText(raw.sourceId || raw.source_id || raw.doc_id, 160),
location: boundedText(raw.location || raw.loc, 240),
title: boundedText(raw.title || raw.source_title, MAX_SOURCE_FIELD_CHARS),
publisher: boundedText(raw.publisher, 240),
domain: boundedText(raw.domain, 240),
url: safeHttpsUrl(raw.url || raw.canonical_url || raw.source_url || raw.official_url),
excerpt: String(raw.excerpt != null
? raw.excerpt
: raw.text != null ? raw.text : raw.summary || '').trim().slice(0, MAX_SOURCE_EXCERPT_CHARS),
excerptKind: boundedText(raw.excerptKind || excerptKind, 80),
excerptHash: boundedText(raw.excerptHash || raw.excerpt_hash, 160),
accessedAt: boundedText(raw.accessedAt || raw.accessed_at, 80),
authorityClass: boundedText(raw.authorityClass || raw.authority_class, 120),
authorityLabel: boundedText(raw.authorityLabel || raw.authority_label, 120)
|| sourceLabel(raw.authorityClass || raw.authority_class),
sourceFamily: boundedText(raw.sourceFamily || raw.source_family, 120),
sourceFamilyLabel: boundedText(raw.sourceFamilyLabel || raw.source_family_label, 120)
|| sourceLabel(raw.sourceFamily || raw.source_family),
contentKind: boundedText(raw.contentKind || raw.content_kind || raw.chunk_kind, 120),
provenance: boundedText(raw.provenance, 240),
reviewStatus: boundedText(raw.reviewStatus || raw.review_status || review.status, 80),
reviewReason: boundedText(raw.reviewReason || raw.review_reason || review.reason, MAX_SOURCE_FIELD_CHARS)
};
}
function sourceIdentity(source) {
if (source.citationId) return `${source.kind}:citation:${source.citationId}`;
if (source.sourceId && source.location) return `${source.kind}:pair:${source.sourceId}\u0000${source.location}`;
if (source.url) return `${source.kind}:url:${source.url}`;
return `${source.kind}:title:${source.title}\u0000${source.location}`;
}
function normaliseSourceManifest(value) {
const raw = value && typeof value === 'object' ? value : {};
const entries = Array.isArray(value)
? value
: [
...(Array.isArray(raw.official) ? raw.official.map((item) => ({ item, kind: 'official' })) : []),
...(Array.isArray(raw.web) ? raw.web.map((item) => ({ item, kind: 'web' })) : [])
];
const sources = [];
const seen = new Set();
let excerptChars = 0;
for (const entry of entries.slice(0, MAX_PERSISTED_SOURCES_PER_MESSAGE * 3)) {
const item = entry && entry.item ? entry.item : entry;
const kind = entry && entry.kind ? entry.kind : item && (item.kind || item.source_kind);
const source = normaliseSourceRecord(item, kind);
if (
(!source.citationId && !source.sourceId && !source.url)
|| !source.title && !source.sourceId && !source.domain
) continue;
const key = sourceIdentity(source);
if (seen.has(key)) continue;
const remainingExcerptChars = Math.max(0, MAX_SOURCE_EXCERPT_TOTAL_CHARS - excerptChars);
source.excerpt = source.excerpt.slice(0, remainingExcerptChars);
excerptChars += source.excerpt.length;
seen.add(key);
sources.push(source);
if (sources.length >= MAX_PERSISTED_SOURCES_PER_MESSAGE) break;
}
return sources;
}
function sourceManifestFromResponse(payload) {
if (payload && payload.citation_manifest) {
return normaliseSourceManifest(payload.citation_manifest);
}
const structured = payload && payload.structured_answer;
const citations = structured && Array.isArray(structured.citations) ? structured.citations : [];
const passages = payload && Array.isArray(payload.retrieved_sources) ? payload.retrieved_sources : [];
const matched = [];
citations.forEach((citation, index) => {
const sourceId = boundedText(citation && (citation.source_id || citation.doc_id), 160);
const location = boundedText(citation && (citation.location || citation.loc), 240);
const passage = passages.find((item) => (
boundedText(item && item.source_id, 160) === sourceId
&& boundedText(item && item.location, 240) === location
));
if (!passage) return;
matched.push({
...passage,
citation_id: passage.citation_id || `o${index + 1}`,
kind: 'official'
});
});
return normaliseSourceManifest({ official: matched, web: [] });
}
function normaliseSourcePolicy(value, capabilities = state.sourceCapabilities) {
const raw = value && typeof value === 'object' ? value : {};
const official = ['auto', 'on', 'off'].includes(raw.official)
? raw.official
: capabilities.officialDefault || 'auto';
const allowedFamilies = new Set(
capabilities.families
.filter((item) => item.available !== false)
.map((item) => item.value)
);
const families = official !== 'off' && Array.isArray(raw.families)
? raw.families.filter((item) => allowedFamilies.has(item)).slice(0, 12)
: [];
return {
official,
families,
webSearch: capabilities.webAvailable && Boolean(raw.webSearch ?? raw.web_search),
webSourceReview: capabilities.reviewAvailable
? Boolean(raw.webSourceReview ?? raw.web_source_review ?? capabilities.reviewDefault)
: false
};
}
function normaliseTranscript(value) {
if (!Array.isArray(value)) return [];
const pairs = [];
for (let index = 0; index + 1 < value.length; index += 2) {
const user = value[index];
const assistant = value[index + 1];
if (
!user || !assistant
|| user.role !== 'user'
|| assistant.role !== 'assistant'
|| typeof user.content !== 'string'
|| typeof assistant.content !== 'string'
) continue;
const userContent = user.content.trim().slice(0, 40000);
const assistantContent = assistant.content.trim().slice(0, 40000);
if (!userContent || !assistantContent) continue;
pairs.push([
{
id: boundedText(user.id, 128) || `message-${index}-user-${userContent.length}`,
role: 'user',
content: userContent,
createdAt: Number(user.createdAt) > 0 ? Number(user.createdAt) : Date.now()
},
{
id: boundedText(assistant.id, 128) || `message-${index + 1}-assistant-${assistantContent.length}`,
role: 'assistant',
content: assistantContent,
createdAt: Number(assistant.createdAt) > 0 ? Number(assistant.createdAt) : Date.now(),
sources: normaliseSourceManifest(assistant.sources)
}
]);
}
const retained = [];
let chars = 0;
const maxPairs = Math.floor(MAX_TRANSCRIPT_MESSAGES / 2);
for (let index = pairs.length - 1; index >= 0 && retained.length < maxPairs; index -= 1) {
const pairChars = pairs[index][0].content.length + pairs[index][1].content.length;
if (retained.length && chars + pairChars > MAX_TRANSCRIPT_CHARS) break;
retained.push(pairs[index]);
chars += pairChars;
}
return retained.reverse().flat();
}
function fairTextAllocations(capacities, totalBudget) {
const normalised = capacities.map((value) => Math.max(0, Number(value) || 0));
if (normalised.reduce((sum, value) => sum + value, 0) <= totalBudget) return normalised;
const allocations = normalised.map(() => 0);
const active = normalised.map((value, index) => value > 0 ? index : -1).filter((index) => index >= 0);
let remaining = totalBudget;
while (active.length && remaining > 0) {
const share = Math.floor(remaining / active.length);
const smaller = active.filter((index) => normalised[index] <= share);
if (smaller.length) {
smaller.forEach((index) => {
allocations[index] = normalised[index];
remaining -= normalised[index];
active.splice(active.indexOf(index), 1);
});
continue;
}
active.forEach((index) => { allocations[index] = share; });
remaining -= share * active.length;
for (const index of active) {
if (remaining <= 0) break;
allocations[index] += 1;
remaining -= 1;
}
break;
}
return allocations;
}
function clipHistoryContent(content, limit) {
const marker = '\n\n[Earlier message truncated for context]';
if (content.length <= limit) return content;
if (limit <= marker.length) return marker.trim().slice(0, limit);
const prefixLimit = limit - marker.length;
let prefix = content.slice(0, prefixLimit);
const boundary = prefix.lastIndexOf(' ');
if (boundary >= Math.max(1, prefixLimit - 256)) prefix = prefix.slice(0, boundary);
return `${prefix.trimEnd()}${marker}`.slice(0, limit);
}
function compactHistory(history, limits = state.limits) {
const completeLength = history.length - (history.length % 2);
const pairs = [];
for (let index = 0; index < completeLength; index += 2) {
if (history[index].role !== 'user' || history[index + 1].role !== 'assistant') continue;
pairs.push([history[index], history[index + 1]]);
}
const selected = [];
let remainingChars = limits.historyMaxChars;
let remainingMessages = limits.historyMaxMessages;
for (let index = pairs.length - 1; index >= 0; index -= 1) {
if (remainingMessages < 2 || remainingChars < 2) break;
const capacities = pairs[index].map((item) => Math.min(item.content.length, limits.historyMaxItemChars));
const allocations = fairTextAllocations(capacities, remainingChars);
if (allocations.some((value) => value <= 0)) break;
const compacted = pairs[index].map((item, itemIndex) => ({
role: item.role,
content: clipHistoryContent(item.content, allocations[itemIndex])
}));
selected.push(compacted);
remainingChars -= compacted.reduce((sum, item) => sum + item.content.length, 0);
remainingMessages -= 2;
}
return selected.reverse().flat();
}
function chatTitle(message) {
const clean = String(message || '').replace(/\s+/g, ' ').trim();
if (!clean) return 'Untitled chat';
if (clean.length <= MAX_CHAT_TITLE_CHARS) return clean;
return `${clean.slice(0, MAX_CHAT_TITLE_CHARS - 1).trimEnd()}…`;
}
function normaliseMemories(value, limits = DEFAULT_MEMORY_LIMITS) {
if (!Array.isArray(value)) return [];
const entries = [];
const seen = new Set();
let totalChars = 0;
for (const raw of value.slice(0, limits.maxEntries * 4)) {
const source = typeof raw === 'string' ? { content: raw } : raw;
if (!source || typeof source.content !== 'string') continue;
const content = source.content.replace(/\s+/g, ' ').trim().slice(0, limits.maxItemChars);
const key = content.toLocaleLowerCase();
if (!content || seen.has(key) || totalChars + content.length > limits.maxTotalChars) continue;
const createdAt = Number(source.createdAt);
const updatedAt = Number(source.updatedAt);
entries.push({
id: typeof source.id === 'string' && source.id
? source.id.slice(0, 128)
: `memory-${entries.length}-${content.length}`,
content,
createdAt: Number.isFinite(createdAt) && createdAt > 0 ? createdAt : Date.now(),
updatedAt: Number.isFinite(updatedAt) && updatedAt > 0 ? updatedAt : Date.now()
});
seen.add(key);
totalChars += content.length;
if (entries.length >= limits.maxEntries) break;
}
return entries;
}
function extractLearnedMemories(message, maxItemChars = DEFAULT_MEMORY_LIMITS.maxItemChars) {
const sentences = String(message || '')
.split(/\n+|(?<=[.!?])\s+/)
.map((item) => item.replace(/\s+/g, ' ').trim())
.filter(Boolean);
const learned = [];
for (const sentence of sentences) {
const remember = sentence.match(/^(?:please\s+)?remember(?:\s+that)?\s+(.+)/i);
const explicitPreference = /^(?:i (?:prefer|usually|always|never|work as|work at|use)|my (?:name|role|preferred language|preference|organisation|organization) is)\b/i.test(sentence);
const content = remember ? remember[1].trim() : explicitPreference ? sentence : '';
if (content.length >= 4 && !content.endsWith('?')) {
learned.push(content.slice(0, maxItemChars));
}
if (learned.length >= 2) break;
}
return learned;
}
function formatConversationDownload(messages, format, title) {
const selected = Array.isArray(messages)
? messages.filter((item) => item && (item.role === 'user' || item.role === 'assistant') && typeof item.content === 'string')
: [];
if (format === 'json') {
return JSON.stringify({ title, exported_at: new Date().toISOString(), messages: selected }, null, 2);
}
if (format === 'text') {
return selected
.map((item) => `${item.role === 'user' ? 'You' : 'Synderesis'}\n${item.content}`)
.join('\n\n');
}
const heading = `# ${String(title || 'Synderesis conversation').replace(/\n/g, ' ')}`;
const body = selected
.map((item) => `## ${item.role === 'user' ? 'You' : 'Synderesis'}\n\n${item.content}`)
.join('\n\n');
return `${heading}\n\n${body}`;
}
function readingListKey(source) {
if (source.sourceId) return `${source.kind}:source:${source.sourceId}`;
if (source.url) return `${source.kind}:url:${source.url}`;
return `${source.kind}:title:${source.title.toLocaleLowerCase()}\u0000${source.publisher.toLocaleLowerCase()}`;
}
function collectReadingList(messages) {
const works = new Map();
(Array.isArray(messages) ? messages : []).forEach((message) => {
if (!message || message.role !== 'assistant') return;
normaliseSourceManifest(message.sources).forEach((source) => {
if (source.kind !== 'official' && source.kind !== 'web') return;
const key = readingListKey(source);
const existing = works.get(key);
if (existing) {
if (source.location && !existing.locations.includes(source.location)) {
existing.locations.push(source.location);
}
return;
}
works.set(key, {
kind: source.kind,
sourceId: source.sourceId,
title: source.title || source.sourceId || source.domain || 'Untitled source',
publisher: source.publisher,
domain: source.domain,
url: source.url,
authorityClass: source.authorityClass,
authorityLabel: source.authorityLabel,
sourceFamily: source.sourceFamily,
sourceFamilyLabel: source.sourceFamilyLabel,
contentKind: source.contentKind,
accessedAt: source.accessedAt,
locations: source.location ? [source.location] : []
});
});
});
return Array.from(works.values()).sort((left, right) => left.title.localeCompare(right.title));
}
function escapeMarkdownText(value) {
return String(value == null ? '' : value)
.replace(/\\/g, '\\\\')
.replace(/\[/g, '\\[')
.replace(/\]/g, '\\]')
.replace(/\(/g, '\\(')
.replace(/\)/g, '\\)')
.replace(/`/g, '\\`')
.replace(/\*/g, '\\*')
.replace(/_/g, '\\_')
.replace(/</g, '\\<')
.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();
}