OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / ui / lib / qvac-widget.js

136 lines · javascript ref:
const DEFAULT_QUICK_ASK_MAX_TOKENS = 192
const DEFAULT_QUICK_ASK_TEMPERATURE = 0.3
const MAX_QUICK_ASK_QUESTION_CHARS = 2000

/**
 * Pure state/presentation helpers for the chrome-owned QVAC widget.
 *
 * The widget is the blank-tab "Local AI" surface. It talks to the same
 * browser-internal Ask Browser RPC contract as the side panel (capabilities,
 * start, cancel, stream events) but carries no page context: a quick ask is a
 * plain question to a browser-approved local model. Everything here is
 * renderer logic with no I/O so it stays unit-testable like ask-browser.js.
 */

/**
 * Normalize a CMD_ASK_BROWSER_CAPABILITIES response into what the widget
 * renders. Malformed or unavailable responses collapse into a stable
 * `{ available: false, reason }` shape instead of leaking undefined reads.
 */
export function summarizeAiCapabilities (capabilities) {
  const record = isRecord(capabilities) ? capabilities : null
  const models = record && Array.isArray(record.models)
    ? record.models.filter(isRecord).map(normalizeModel).filter(model => model.alias)
    : []
  const loaded = models.filter(model => model.installed)

  if (!record || record.available !== true || models.length === 0) {
    return {
      available: false,
      reason: cleanString(record?.reason) ||
        (record && models.length === 0 && record.available === true ? 'no-models' : '') ||
        (record ? 'runtime-unavailable' : 'no-capabilities'),
      busy: false,
      queueDepth: 0,
      modelCount: models.length,
      loadedCount: 0,
      models
    }
  }

  return {
    available: true,
    reason: '',
    busy: record.busy === true,
    queueDepth: Number.isFinite(record.queueDepth) ? Math.max(0, record.queueDepth) : 0,
    modelCount: models.length,
    loadedCount: loaded.length,
    models
  }
}

/**
 * Choose the widget's default model with the same preference order as the
 * Ask Browser panel: an explicit still-valid choice wins, then a recommended
 * model, then any Ollama-discovered model, then the first approved alias.
 */
export function pickQuickAskModel (models, preferredAlias = '') {
  const list = Array.isArray(models) ? models.filter(isRecord) : []
  const preferred = cleanString(preferredAlias)
  if (preferred && list.some(model => model.alias === preferred)) return preferred
  const chosen = list.find(model => model.recommended === true) ||
    list.find(model => model.provider === 'ollama') ||
    list[0]
  return cleanString(chosen?.alias)
}

/**
 * One-line widget status. Deterministic strings so UI tests can assert on
 * them and the widget never renders raw error objects.
 */
export function describeAiStatus (summary) {
  const state = isRecord(summary) ? summary : summarizeAiCapabilities(null)
  if (!state.available) {
    const reason = cleanString(state.reason)
    return reason ? `Local AI unavailable · ${reason}` : 'Local AI unavailable'
  }

  const count = state.modelCount === 1 ? '1 local model' : `${state.modelCount} local models`
  if (state.busy || state.queueDepth > 0) return `${count} · generating`
  if (state.loadedCount > 0) return `${count} · ready in memory`
  return `${count} · loads on first use`
}

/**
 * Build the CMD_ASK_BROWSER_START payload for a widget quick ask. The widget
 * never captures page context — `page` is empty metadata by contract — and
 * history stays short single-surface turns.
 */
export function buildQuickAskRequest ({ streamId, model, question, history } = {}) {
  const id = cleanString(streamId)
  const alias = cleanString(model)
  const text = cleanString(question).slice(0, MAX_QUICK_ASK_QUESTION_CHARS)
  if (!id) throw new Error('A quick ask requires a stream id')
  if (!alias) throw new Error('A quick ask requires a browser-approved model alias')
  if (!text) throw new Error('A quick ask requires a non-empty question')

  return {
    streamId: id,
    model: alias,
    question: text,
    history: normalizeQuickAskHistory(history),
    page: {},
    maxTokens: DEFAULT_QUICK_ASK_MAX_TOKENS,
    temperature: DEFAULT_QUICK_ASK_TEMPERATURE
  }
}

function normalizeQuickAskHistory (history) {
  if (!Array.isArray(history)) return []
  return history
    .filter(turn => isRecord(turn) && (turn.role === 'user' || turn.role === 'assistant'))
    .map(turn => ({ role: turn.role, content: cleanString(turn.content) }))
    .filter(turn => turn.content)
    .slice(-6)
}

function normalizeModel (model) {
  return {
    alias: cleanString(model.alias),
    label: cleanString(model.label),
    provider: cleanString(model.provider),
    installed: model.installed === true,
    recommended: model.recommended === true,
    expectedSize: Number.isFinite(model.expectedSize) ? model.expectedSize : undefined,
    quantization: cleanString(model.quantization)
  }
}

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

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