OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / ui / lib / ask-browser.js

269 lines · javascript ref:
const DEFAULT_CONTEXT_LIMITS = Object.freeze({
  maxUrlBytes: 2048,
  maxTitleBytes: 512,
  maxTextBytes: 16 * 1024
})

const TERMINAL_STREAM_STATES = new Set(['done', 'cancelled', 'error'])

let fallbackIdSequence = 0

/**
 * Mint a renderer-owned id used to correlate one Ask Browser stream.
 * Browser crypto is preferred, while the monotonic fallback remains useful in
 * runtimes that do not expose Web Crypto during early boot or unit tests.
 */
export function createAskStreamId () {
  const cryptoApi = globalThis.crypto
  if (cryptoApi && typeof cryptoApi.randomUUID === 'function') {
    return `ask-${cryptoApi.randomUUID()}`
  }

  if (cryptoApi && typeof cryptoApi.getRandomValues === 'function') {
    const words = new Uint32Array(3)
    cryptoApi.getRandomValues(words)
    return `ask-${[...words].map(word => word.toString(36)).join('-')}`
  }

  fallbackIdSequence = (fallbackIdSequence + 1) >>> 0
  return `ask-${Date.now().toString(36)}-${fallbackIdSequence.toString(36)}`
}

/**
 * Normalize page text returned by an extraction boundary.
 *
 * URL, title, and tab identity always come from trusted renderer state. The
 * response may contribute page text and a source label, but can never relabel
 * that text as belonging to another page. An explicit tab-id mismatch is
 * treated as a stale async response and its text is discarded.
 */
export function normalizePageContextResponse (response, trustedTab, limits = {}) {
  const safeLimits = normalizeContextLimits(limits)
  const tab = isRecord(trustedTab) ? trustedTab : {}
  const result = isRecord(response) ? response : null

  const tabId = cleanString(tab.id)
  const trustedUrl = cleanString(tab.url) || cleanString(tab.displayUrl)
  const trustedTitle = cleanString(tab.title) || trustedUrl || 'Untitled page'
  const url = capUtf8(trustedUrl, safeLimits.maxUrlBytes)
  const title = capUtf8(trustedTitle, safeLimits.maxTitleBytes)

  const responseTabId = result ? cleanString(result.tabId) : ''
  const stale = !!(tabId && responseTabId && tabId !== responseTabId)
  const nestedContext = result && isRecord(result.context) ? result.context : null
  const nestedText = nestedContext
    ? joinContextText(nestedContext.selection, nestedContext.body)
    : ''
  const rawText = result && typeof result.text === 'string' ? result.text : nestedText
  const validText = !stale && !!result && typeof rawText === 'string' && rawText.length > 0
  const text = capUtf8(validText ? rawText : '', safeLimits.maxTextBytes)
  const source = result ? capUtf8(cleanString(result.source), 80).value : ''

  return {
    tabId,
    url: url.value,
    title: title.value,
    text: text.value,
    textBytes: text.bytes,
    available: !!validText,
    stale,
    truncated: !!(validText && (result.truncated === true || result.flags?.truncated === true || text.truncated)),
    source: source || (validText ? 'browser-page' : 'unavailable'),
    provenance: {
      tabId: 'trusted-tab',
      url: 'trusted-tab',
      title: 'trusted-tab',
      text: validText ? 'context-response' : 'none'
    }
  }
}

export function createInitialAskStreamState (streamId = '') {
  const id = cleanString(streamId)
  return {
    streamId: id,
    status: id ? 'starting' : 'idle',
    text: '',
    modelProgress: null,
    stats: null,
    finishReason: null,
    error: null
  }
}

/**
 * Reduce one correlated QVAC event into renderer state.
 *
 * Expected action shape: { streamId, event }. `requestId` is accepted as an
 * alias for streamId so backend push frames can be passed through directly.
 * Events without the active id, events for stale streams, malformed payloads,
 * and events arriving after a terminal state are ignored by identity.
 */
export function reduceAskStreamEvent (state, action) {
  if (!isRecord(state) || !isRecord(action)) return state

  const streamId = cleanString(action.streamId) || cleanString(action.requestId)
  if (!state.streamId || !streamId || streamId !== state.streamId) return state
  if (TERMINAL_STREAM_STATES.has(state.status)) return state

  const event = isRecord(action.event) ? action.event : action
  const type = cleanString(event.type)

  if (type === 'model-progress') {
    const progress = normalizeProgress(event.progress)
    if (!Number.isFinite(progress)) return state
    return {
      ...state,
      status: 'loading-model',
      modelProgress: Math.max(0, Math.min(1, progress)),
      error: null
    }
  }

  if (type === 'text') {
    if (typeof event.delta !== 'string' || event.delta.length === 0) return state
    return {
      ...state,
      status: 'streaming',
      text: state.text + event.delta,
      error: null
    }
  }

  if (type === 'stats') {
    if (!isRecord(event.stats)) return state
    return { ...state, stats: { ...event.stats } }
  }

  if (type === 'done') {
    const finishReason = cleanString(event.finishReason) || 'eos'
    return {
      ...state,
      status: finishReason === 'cancelled' ? 'cancelled' : 'done',
      finishReason,
      error: null
    }
  }

  if (type === 'error') {
    const message = cleanString(event.message) || 'Local AI request failed'
    const code = cleanString(event.code) || 'inference-failed'
    return {
      ...state,
      status: 'error',
      finishReason: 'error',
      error: { code, message }
    }
  }

  return state
}

export function formatModelLabel (alias) {
  const value = cleanString(alias)
  if (!value) return 'Local model'
  return value
    .split(/[-_\s]+/)
    .filter(Boolean)
    .map(word => {
      if (/^(qvac|qwen|gguf|cpu|gpu)$/i.test(word)) return word.toUpperCase()
      return word.charAt(0).toUpperCase() + word.slice(1)
    })
    .join(' ')
}

export function formatBytes (bytes) {
  if (!Number.isFinite(bytes) || bytes < 0) return '—'
  if (bytes < 1024) return `${Math.round(bytes)} B`

  const units = ['KB', 'MB', 'GB', 'TB']
  let value = bytes
  let unit = -1
  do {
    value /= 1024
    unit++
  } while (value >= 1024 && unit < units.length - 1)

  const digits = value >= 100 ? 0 : (value >= 10 ? 1 : 2)
  return `${Number(value.toFixed(digits))} ${units[unit]}`
}

export function presentAskText (value) {
  let text = typeof value === 'string' ? value : ''
  const lower = text.toLowerCase()
  const open = lower.lastIndexOf('<think>')
  const close = lower.lastIndexOf('</think>')
  if (open > close) text = text.slice(0, open)
  return text
    .replace(/<think>[\s\S]*?<\/think>/gi, '')
    .replace(/<\/?think>/gi, '')
    .trim()
}

function normalizeContextLimits (limits) {
  const source = isRecord(limits) ? limits : {}
  return {
    maxUrlBytes: positiveInteger(source.maxUrlBytes, DEFAULT_CONTEXT_LIMITS.maxUrlBytes),
    maxTitleBytes: positiveInteger(source.maxTitleBytes, DEFAULT_CONTEXT_LIMITS.maxTitleBytes),
    maxTextBytes: positiveInteger(source.maxTextBytes, DEFAULT_CONTEXT_LIMITS.maxTextBytes)
  }
}

function positiveInteger (value, fallback) {
  if (!Number.isFinite(value) || value < 0) return fallback
  return Math.floor(value)
}

function capUtf8 (value, maxBytes) {
  const text = typeof value === 'string' ? value : ''
  let bytes = 0
  let end = 0

  for (const character of text) {
    const size = utf8CodePointBytes(character.codePointAt(0))
    if (bytes + size > maxBytes) break
    bytes += size
    end += character.length
  }

  return {
    value: text.slice(0, end),
    bytes,
    truncated: end < text.length
  }
}

function utf8CodePointBytes (codePoint) {
  if (codePoint <= 0x7f) return 1
  if (codePoint <= 0x7ff) return 2
  if (codePoint <= 0xffff) return 3
  return 4
}

function cleanString (value) {
  return typeof value === 'string' ? value.trim() : ''
}

function joinContextText (selection, body) {
  const selected = cleanString(selection)
  const page = cleanString(body)
  if (selected && page) return `Selected text:\n${selected}\n\nPage text:\n${page}`
  if (selected) return `Selected text:\n${selected}`
  return page
}

function normalizeProgress (progress) {
  if (Number.isFinite(progress)) return progress
  if (!isRecord(progress)) return NaN
  const percentage = Number(progress.percentage)
  if (Number.isFinite(percentage)) return percentage > 1 ? percentage / 100 : percentage
  const completed = Number(progress.completed ?? progress.downloaded)
  const total = Number(progress.total)
  if (Number.isFinite(completed) && Number.isFinite(total) && total > 0) return completed / total
  return NaN
}

function isRecord (value) {
  return !!value && typeof value === 'object' && !Array.isArray(value)
}