diff --git a/lib/client.js b/lib/client.js index ab242bc..e8fcad1 100644 --- a/lib/client.js +++ b/lib/client.js @@ -591,6 +591,11 @@ window.__ModuleLoader__.load({ "unsplash", "wikimedia-commons" ]; + /** + * Ids that once shipped and were since removed (mirrors `RETIRED_IDS`). `sources` still accepts + * them, so a stored one never blocks saving the page, but the page does not list them. + */ + const RETIRED_SOURCE_IDS = []; /** The number fields' schema bounds and defaults (mirrors the `Config` schema). */ const NUMBER_BOUNDS = { limit: { @@ -753,7 +758,7 @@ window.__ModuleLoader__.load({ const write = (ops) => scope.mutate(ops); const model = new _deepseek_ai_dsh_client_ui_primitives.SettingsFormModel(scope, [ ...NUMBER_FIELDS.map((field) => settingsBoundedNumberField(field, NUMBER_BOUNDS[field])), - settingsSourcesField("sources", SOURCE_IDS), + settingsSourcesField("sources", [...SOURCE_IDS, ...RETIRED_SOURCE_IDS]), (0, _deepseek_ai_dsh_client_ui_primitives.settingsTextField)("userAgent"), ...TOGGLE_FIELDS.map((field) => settingsBooleanField(field)) ], KEY_FIELDS_CLIENT.map((field) => secretSpec(field, write))); diff --git a/lib/client.js.map b/lib/client.js.map index 4136754..ec64f1c 100644 --- a/lib/client.js.map +++ b/lib/client.js.map @@ -1 +1 @@ -{"version":3,"file":"client.js","names":["SettingsFormModel","SettingsForm","SettingsValueField","SettingsSecretField","Button","Tag","Switch"],"sources":["../src/core/outcome.ts","../src/client/badges.ts","../src/client/copy.ts","../src/client/styles.ts","../src/client/Card.tsx","../src/client/settings-model.ts","../src/client/settings-controller.ts","../src/client/SettingsPage.tsx","../src/client/index.tsx"],"sourcesContent":["/**\n * Canonical result vocabulary shared by the host half (tool output, render,\n * presentation metadata) and the browser half (the card). No runtime\n * dependencies: plain data plus soft parsers, so a malformed or\n * version-drifted payload degrades to text instead of crashing a render.\n * @module @refkit/dsh-plugin/core\n */\n\nexport const MODALITIES = ['image', 'video', 'audio', 'text'] as const\nexport type Modality = (typeof MODALITIES)[number]\n\nexport const DECISIONS = ['allowed', 'allowed-with-attribution', 'denied', 'needs-review'] as const\nexport type Decision = (typeof DECISIONS)[number]\n\nexport const SOURCE_STATUSES = ['fulfilled', 'failed', 'skipped'] as const\nexport type SourceStatusKind = (typeof SOURCE_STATUSES)[number]\n\n/** Lossless JSON. */\nexport type Json = string | number | boolean | null | Json[] | { [key: string]: Json }\n\nexport interface VerdictSummary {\n decision: Decision\n reason: string\n confidence: 'high' | 'low'\n}\n\n/** One reference as the tool returns it and the card renders it. */\nexport interface RefTile {\n id: string\n modality: Modality\n provider: string\n canonicalUrl: string\n license: string\n title?: string\n kind?: string\n licenseVersion?: string\n author?: string\n thumbnail?: string\n preview?: string\n width?: number\n height?: number\n description?: string\n excerpt?: string\n tags?: string[]\n useVerdict?: VerdictSummary\n attribution?: string\n}\n\nexport interface SourceStatus {\n id: string\n status: SourceStatusKind\n reason?: string\n returned?: number\n}\n\n/** The canonical value of one refkit_search call. */\nexport interface SearchOutcome {\n query: string\n modalities: Modality[]\n intent?: string\n count: number\n references: RefTile[]\n nextCursor?: string\n sources: SourceStatus[]\n warnings: string[]\n note?: string\n /** Core's full SearchMeta, only when the caller passed `explain`. */\n meta?: Json\n}\n\n/** What the card receives: the outcome minus `meta`, descriptions cut, tags dropped. */\nexport type CardOutcome = Omit\n\nconst HTTP = /^https?:\\/\\//i\n\nfunction str(v: unknown): string | undefined {\n return typeof v === 'string' && v.length > 0 ? v : undefined\n}\nfunction num(v: unknown): number | undefined {\n return typeof v === 'number' && Number.isFinite(v) ? v : undefined\n}\n\n/** Shallow copy of `obj` without its undefined-valued own properties (dsh snapshots values as lossless JSON). */\nexport function defined(obj: T): T {\n return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) as T\n}\n\n/** Cut `text` to `max` code points, appending an ellipsis when anything was removed. */\nexport function trunc(text: string, max: number): string {\n const points = Array.from(text)\n if (points.length <= max) return text\n return points.slice(0, Math.max(0, max - 1)).join('') + '…'\n}\n\nfunction narrowVerdict(v: unknown): VerdictSummary | undefined {\n if (typeof v !== 'object' || v === null) return undefined\n const r = v as Record\n if (!DECISIONS.includes(r.decision as Decision)) return undefined\n if (r.confidence !== 'high' && r.confidence !== 'low') return undefined\n return { decision: r.decision as Decision, reason: str(r.reason) ?? '', confidence: r.confidence }\n}\n\n/** Soft-narrow one tile; null for anything unusable. Unknown keys are dropped. */\nexport function narrowTile(value: unknown): RefTile | null {\n if (typeof value !== 'object' || value === null) return null\n const r = value as Record\n const id = str(r.id)\n const provider = str(r.provider)\n const canonicalUrl = str(r.canonicalUrl)\n const license = str(r.license)\n if (id === undefined || provider === undefined || license === undefined) return null\n if (canonicalUrl === undefined || !HTTP.test(canonicalUrl)) return null\n if (!MODALITIES.includes(r.modality as Modality)) return null\n const tile: RefTile = { id, modality: r.modality as Modality, provider, canonicalUrl, license }\n for (const key of ['title', 'kind', 'licenseVersion', 'author', 'description', 'excerpt', 'attribution'] as const) {\n const s = str(r[key])\n if (s !== undefined) tile[key] = s\n }\n for (const key of ['thumbnail', 'preview'] as const) {\n const s = str(r[key])\n if (s !== undefined && HTTP.test(s)) tile[key] = s\n }\n for (const key of ['width', 'height'] as const) {\n const n = num(r[key])\n if (n !== undefined && n > 0) tile[key] = n\n }\n if (Array.isArray(r.tags)) {\n const tags = r.tags.filter((t): t is string => typeof t === 'string' && t.length > 0)\n if (tags.length > 0) tile.tags = tags\n }\n const verdict = narrowVerdict(r.useVerdict)\n if (verdict !== undefined) tile.useVerdict = verdict\n return tile\n}\n\nfunction narrowSource(v: unknown): SourceStatus | null {\n if (typeof v !== 'object' || v === null) return null\n const r = v as Record\n const id = str(r.id)\n if (id === undefined || !SOURCE_STATUSES.includes(r.status as SourceStatusKind)) return null\n const out: SourceStatus = { id, status: r.status as SourceStatusKind }\n const reason = str(r.reason)\n if (reason !== undefined) out.reason = reason\n const returned = num(r.returned)\n if (returned !== undefined) out.returned = returned\n return out\n}\n\n/** Soft-parse a canonical value or presentation metadata; null for the wrong shape. */\nexport function narrowOutcome(value: unknown): SearchOutcome | null {\n if (typeof value !== 'object' || value === null) return null\n const r = value as Record\n const query = typeof r.query === 'string' ? r.query : undefined\n if (query === undefined || typeof r.count !== 'number') return null\n if (!Array.isArray(r.modalities) || !Array.isArray(r.references) || !Array.isArray(r.sources)) return null\n if (!Array.isArray(r.warnings)) return null\n const modalities = r.modalities.filter((m): m is Modality => MODALITIES.includes(m as Modality))\n const references = r.references.map(narrowTile).filter((t): t is RefTile => t !== null)\n const sources = r.sources.map(narrowSource).filter((s): s is SourceStatus => s !== null)\n const warnings = r.warnings.filter((w): w is string => typeof w === 'string')\n const out: SearchOutcome = { query, modalities, count: r.count, references, sources, warnings }\n const intent = str(r.intent)\n if (intent !== undefined) out.intent = intent\n const nextCursor = str(r.nextCursor)\n if (nextCursor !== undefined) out.nextCursor = nextCursor\n const note = str(r.note)\n if (note !== undefined) out.note = note\n if (r.meta !== undefined) out.meta = r.meta as Json\n return out\n}\n","/**\n * Pure presentation helpers for the card: verdict badge classes and labels,\n * license chip text, source bucketing, tile aspect ratio. No DOM, no React,\n * so they are unit-tested directly.\n * @module @refkit/dsh-plugin/client/badges\n */\n\nimport type { Decision, RefTile, SourceStatus } from '../core/outcome.ts'\n\nconst LICENSE_CHARS = 20\n\nexport function verdictBadge(decision: Decision): { className: string; label: string } {\n switch (decision) {\n case 'allowed': return { className: 'rk-badge rk-badge-allowed', label: 'Allowed' }\n case 'allowed-with-attribution': return { className: 'rk-badge rk-badge-attribution', label: 'Credit required' }\n case 'denied': return { className: 'rk-badge rk-badge-denied', label: 'Not allowed' }\n case 'needs-review': return { className: 'rk-badge rk-badge-review', label: 'Needs review' }\n }\n}\n\nexport function licenseLabel(tile: Pick): string {\n const base = tile.licenseVersion ? `${tile.license} ${tile.licenseVersion}` : tile.license\n const points = Array.from(base)\n return points.length > LICENSE_CHARS ? points.slice(0, LICENSE_CHARS).join('') + '…' : base\n}\n\nexport function summarizeSources(sources: readonly SourceStatus[]): { fulfilled: SourceStatus[]; failed: SourceStatus[]; skipped: SourceStatus[] } {\n return {\n fulfilled: sources.filter(s => s.status === 'fulfilled'),\n failed: sources.filter(s => s.status === 'failed'),\n skipped: sources.filter(s => s.status === 'skipped'),\n }\n}\n\n/** CSS `aspect-ratio` value for a tile. */\nexport function aspectRatio(tile: Pick): string {\n return tile.width && tile.height && tile.width > 0 && tile.height > 0 ? `${tile.width} / ${tile.height}` : '4 / 3'\n}\n","/** UI strings for the card and the settings page. One object so a locale swap is one file. */\nexport const COPY = {\n preparing: 'Preparing refkit search…',\n searching: 'Searching refkit sources…',\n refsFor: (count: number, query: string) => `${count} reference${count === 1 ? '' : 's'} for “${query}”`,\n intent: (intent: string) => `intent: ${intent}`,\n more: 'more available — ask for the next page',\n failed: (n: number) => `${n} source${n === 1 ? '' : 's'} failed`,\n skipped: (n: number) => `${n} skipped`,\n open: 'Open',\n copyCredit: 'Copy credit',\n copied: 'Copied',\n copyFailed: 'Select and copy:',\n empty: 'No results. Try broader terms, another modality, or fewer controls.',\n legend: 'Verdict:',\n untitled: '(untitled)',\n settings: {\n summary: (configured: number, total: number) => `License-aware reference search · ${configured} of ${total} keys set`,\n // The shared form frame.\n unavailable: 'refkit is not running, so it cannot be configured right now.',\n readOnly: 'This deployment stores settings read-only.',\n saveFailed: 'The deployment did not accept these values; they were left for you to correct.',\n save: 'Save',\n saving: 'Saving…',\n overridden: 'Overridden',\n reset: 'Reset to default',\n // API keys.\n keysHeading: 'API keys',\n keysNote: 'Each key enables the sources named beside it; keyless sources need none. Keys are stored in plain text in this profile’s cordis.patch.yml (file mode 0600) and apply on the next search.',\n keyHint: (env: readonly string[]) => `Leave blank to keep the stored key. Environment fallback: ${env.join(' / ')}.`,\n keySet: 'Set',\n keyUnset: 'Not set',\n remove: 'Remove',\n removeLabel: (label: string) => `Remove the ${label} key`,\n removing: 'Removing…',\n removeBlocked: 'Save your other edits first: removing a key writes immediately.',\n removeFailed: 'Not removed; try again.',\n // Search tuning.\n searchHeading: 'Search',\n limit: 'Results per call',\n limitHint: 'Also caps per-item detail fetches for met, rijksmuseum and polyhaven.',\n poolFactor: 'Fusion pool factor',\n poolFactorHint: 'Rank-fusion pool multiplier.',\n deadlineMs: 'Search deadline (ms)',\n deadlineMsHint: 'Whole-search deadline.',\n timeoutMs: 'Per-source timeout (ms)',\n timeoutMsHint: 'How long one source may take.',\n range: (hint: string, bounds: { min: number; max: number; default: number }) => `${hint} ${bounds.min}–${bounds.max}, default ${bounds.default}.`,\n invalidRange: (bounds: { min: number; max: number }) => `Enter a whole number from ${bounds.min} to ${bounds.max}, or leave blank for the default.`,\n sources: 'Sources',\n sourcesHint: 'Comma-separated provider ids. A keyed source also needs its key.',\n sourcesPlaceholder: 'empty = all enabled sources',\n sourcesHelp: 'Source ids',\n sourcesInvalid: 'Unknown source id. Use the ids listed under the info button.',\n userAgent: 'User-Agent',\n userAgentHint: 'Sent with provider requests. Blank uses refkit-dsh-plugin/.',\n rerank: 'Lexical rerank',\n rerankHint: 'Rerank fused results over title, description, tags and excerpt.',\n sourceConfidence: 'Source confidence',\n sourceConfidenceHint: 'Down-weight sources whose batch never mentions the query.',\n },\n} as const\n","/**\n * Card and settings-page stylesheet, injected once as