init
This commit is contained in:
commit
0964819cdc
34 files changed
+7912
No files matched your search
@@ -0,0 +1,644 @@
|
||||
/**
|
||||
* dsh-recall — browser half: the card in Settings → Plugins → Plugin configuration.
|
||||
*
|
||||
* The Host half registers a `dsh-recall` settings namespace. This file registers
|
||||
* a card under that exact namespace key. Both are required: the tab pairs the
|
||||
* namespaces the Host serves with the cards the browser claims, so a served
|
||||
* namespace with no card draws nothing at all, and a card whose namespace is not
|
||||
* served is never dispatched.
|
||||
*
|
||||
* The chrome follows the framework's `PluginCard`: the root is an `<li>` (the tab
|
||||
* renders every card into a `<ul>`), the header is its own `<button>` that
|
||||
* discloses the controls in place, the card starts collapsed, it marks itself
|
||||
* when it holds unsaved edits, and it collapses again after a successful save.
|
||||
* None of that is imported — `PluginCard` lives in that package's client bundle
|
||||
* and the client purity gate forbids cross-plugin value imports — so the
|
||||
* behaviour is reproduced here rather than borrowed.
|
||||
*
|
||||
* Hand-written plain JavaScript inside the module-loader wrapper, with no build
|
||||
* step and no bundler. The only import is `react`, which is in the platform seed
|
||||
* table. Styling is inline for the same reason: there is no stylesheet this file
|
||||
* may reach.
|
||||
*/
|
||||
|
||||
window.__ModuleLoader__.load({
|
||||
id: 'dsh-recall',
|
||||
factory: (require) => {
|
||||
var module = { exports: {} }
|
||||
var exports = module.exports
|
||||
|
||||
const react = require('react')
|
||||
|
||||
const NS = 'dsh-recall'
|
||||
const h = react.createElement
|
||||
|
||||
const TITLE = 'Recall'
|
||||
const DESCRIPTION = 'Local embedding endpoint, what gets indexed, and how many tokens a result may cost.'
|
||||
|
||||
/**
|
||||
* The settings this card edits, grouped for scanning.
|
||||
*
|
||||
* This list mirrors the Host schema in index.js. It is duplicated rather
|
||||
* than fetched because the card renders its own controls — there is no
|
||||
* generic schema-to-form renderer in the framework — and a field the Host
|
||||
* does not know is rejected there anyway, so drift shows up as a clear error
|
||||
* rather than as silent data loss.
|
||||
*/
|
||||
const FIELDS = [
|
||||
{
|
||||
key: 'embeddingApiBase',
|
||||
group: 'Endpoint',
|
||||
label: 'Embedding endpoint',
|
||||
hint: 'e.g. http://127.0.0.1:11434 for Ollama. Must be reachable from the machine running the harness.',
|
||||
},
|
||||
{
|
||||
key: 'embeddingModel',
|
||||
group: 'Endpoint',
|
||||
label: 'Embedding model',
|
||||
hint: 'The model id as the endpoint knows it. Changing this discards the existing index.',
|
||||
},
|
||||
{
|
||||
key: 'embeddingDimensions',
|
||||
group: 'Endpoint',
|
||||
label: 'Embedding dimensions',
|
||||
kind: 'number',
|
||||
hint: 'Must match what the model returns. Changing this discards the existing index.',
|
||||
},
|
||||
{
|
||||
key: 'embeddingApiKey',
|
||||
group: 'Endpoint',
|
||||
label: 'Embedding API key',
|
||||
kind: 'secret',
|
||||
hint: 'Stored as a secret and never returned to this page. Leave blank to keep the current value; use Reset to clear it.',
|
||||
},
|
||||
{
|
||||
key: 'embeddingKeepAlive',
|
||||
group: 'Endpoint',
|
||||
label: 'Keep-alive',
|
||||
hint: 'Ollama keep_alive, e.g. 5m. Keeps the model resident for the length of an index run.',
|
||||
},
|
||||
{
|
||||
key: 'embeddingTimeoutMs',
|
||||
group: 'Endpoint',
|
||||
label: 'Request timeout (ms)',
|
||||
kind: 'number',
|
||||
hint: 'Short on purpose. A wedged endpoint accepts the connection and never answers, so a long timeout looks like a hang.',
|
||||
},
|
||||
{
|
||||
key: 'embeddingBatchSize',
|
||||
group: 'Endpoint',
|
||||
label: 'Inputs per request',
|
||||
kind: 'number',
|
||||
},
|
||||
{
|
||||
key: 'embeddingConcurrency',
|
||||
group: 'Endpoint',
|
||||
label: 'Requests in flight',
|
||||
kind: 'number',
|
||||
hint: 'Keep this low on a small host. A cold index is a sustained CPU load, and too much of it can leave the endpoint unable to load the model.',
|
||||
},
|
||||
|
||||
{
|
||||
key: 'extensions',
|
||||
group: 'What gets indexed',
|
||||
label: 'Extensions',
|
||||
kind: 'list',
|
||||
hint: 'Comma separated. Empty uses the built-in code set (no prose or markup).',
|
||||
},
|
||||
{
|
||||
key: 'exclude',
|
||||
group: 'What gets indexed',
|
||||
label: 'Extra exclude patterns',
|
||||
kind: 'list',
|
||||
hint: 'Comma separated globs, e.g. src/generated/. Added to the built-in directory list rather than replacing it.',
|
||||
},
|
||||
{
|
||||
key: 'maxFileBytes',
|
||||
group: 'What gets indexed',
|
||||
label: 'Skip files larger than (bytes)',
|
||||
kind: 'number',
|
||||
hint: 'Generated and minified files are usually large; they are skipped rather than indexed as noise.',
|
||||
},
|
||||
{
|
||||
key: 'maxFiles',
|
||||
group: 'What gets indexed',
|
||||
label: 'Most files to consider',
|
||||
kind: 'number',
|
||||
},
|
||||
|
||||
{ key: 'chunkLines', group: 'Chunking', label: 'Target lines per chunk', kind: 'number' },
|
||||
{ key: 'maxChunkLines', group: 'Chunking', label: 'Hard cap before splitting', kind: 'number' },
|
||||
{
|
||||
key: 'overlapLines',
|
||||
group: 'Chunking',
|
||||
label: 'Lines shared when splitting',
|
||||
kind: 'number',
|
||||
hint: 'Must be smaller than the hard cap, or a split never advances.',
|
||||
},
|
||||
|
||||
{
|
||||
key: 'excerptLines',
|
||||
group: 'Results',
|
||||
label: 'Lines per excerpt',
|
||||
kind: 'number',
|
||||
hint: 'A chunk is a bounded semantic unit, so this defaults to the chunk cap and results come back whole. Truncating below it is how a search turns into a follow-up read that costs more than it saved.',
|
||||
},
|
||||
{
|
||||
key: 'totalExcerptLines',
|
||||
group: 'Results',
|
||||
label: 'Total lines across one result set',
|
||||
kind: 'number',
|
||||
hint: 'Shared budget, allocated by rank, so the best answer gets the room and the response stays bounded.',
|
||||
},
|
||||
{ key: 'defaultLimit', group: 'Results', label: 'Excerpts returned', kind: 'number' },
|
||||
{ key: 'maxLimit', group: 'Results', label: 'Most a caller may ask for', kind: 'number' },
|
||||
{
|
||||
key: 'perFileLimit',
|
||||
group: 'Results',
|
||||
label: 'Most chunks from one file',
|
||||
kind: 'number',
|
||||
hint: 'Stops one large file filling the whole result set.',
|
||||
},
|
||||
{
|
||||
key: 'minSimilarity',
|
||||
group: 'Results',
|
||||
label: 'Weak-match threshold',
|
||||
kind: 'number',
|
||||
hint: 'A cosine similarity between 0 and 1. Below this best score, results are reported as a weak match and the model is told to prefer grep.',
|
||||
},
|
||||
|
||||
{
|
||||
key: 'syncBudgetMs',
|
||||
group: 'Index',
|
||||
label: 'Index warm-up budget per search (ms)',
|
||||
kind: 'number',
|
||||
hint: 'How long one search may spend embedding changed files before reporting the rest as pending.',
|
||||
},
|
||||
{
|
||||
key: 'stateDir',
|
||||
group: 'Index',
|
||||
label: 'Index directory',
|
||||
hint: 'Empty uses $DSH_HOME/dsh-recall/workspaces/<workspace>.',
|
||||
},
|
||||
]
|
||||
|
||||
/** Fields the Host redacts, so the page never holds their real value. */
|
||||
const SECRET_KEYS = new Set(FIELDS.filter((field) => field.kind === 'secret').map((field) => field.key))
|
||||
|
||||
const BORDER = '1px solid var(--dsh-border, #3a3a3a)'
|
||||
|
||||
const S = {
|
||||
// The tab renders into a <ul>, so the root must be an <li> or the list
|
||||
// styling never reaches it.
|
||||
card: { listStyle: 'none', border: BORDER, borderRadius: '8px', margin: '8px 0', overflow: 'hidden' },
|
||||
cardOpen: { background: 'var(--dsh-surface, rgba(255,255,255,0.02))' },
|
||||
header: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'inherit',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
font: 'inherit',
|
||||
},
|
||||
headText: { display: 'flex', flexDirection: 'column', gap: '2px', flex: '1 1 auto', minWidth: 0 },
|
||||
name: { fontSize: '14px', fontWeight: 600 },
|
||||
description: { fontSize: '11px', opacity: 0.65, lineHeight: 1.45 },
|
||||
pending: {
|
||||
flex: '0 0 auto',
|
||||
fontSize: '10px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
border: BORDER,
|
||||
opacity: 0.8,
|
||||
},
|
||||
chevron: { flex: '0 0 auto', fontSize: '10px', opacity: 0.7, transition: 'transform 120ms ease' },
|
||||
chevronOpen: { transform: 'rotate(180deg)' },
|
||||
body: { padding: '0 16px 16px 16px' },
|
||||
readOnly: { fontSize: '11px', opacity: 0.75, marginTop: 0, marginBottom: '12px' },
|
||||
group: {
|
||||
fontSize: '10px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.06em',
|
||||
opacity: 0.55,
|
||||
marginTop: '18px',
|
||||
marginBottom: '2px',
|
||||
paddingBottom: '4px',
|
||||
borderBottom: BORDER,
|
||||
},
|
||||
hint: { fontSize: '11px', opacity: 0.65, marginTop: '2px', lineHeight: 1.45 },
|
||||
row: { marginTop: '12px' },
|
||||
label: { display: 'block', fontSize: '12px', fontWeight: 500, marginBottom: '4px' },
|
||||
input: {
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
padding: '6px 8px',
|
||||
fontSize: '12px',
|
||||
borderRadius: '6px',
|
||||
border: BORDER,
|
||||
background: 'transparent',
|
||||
color: 'inherit',
|
||||
},
|
||||
actions: { display: 'flex', gap: '8px', alignItems: 'center', marginTop: '16px', flexWrap: 'wrap' },
|
||||
button: {
|
||||
padding: '6px 12px',
|
||||
fontSize: '12px',
|
||||
borderRadius: '6px',
|
||||
border: BORDER,
|
||||
background: 'transparent',
|
||||
color: 'inherit',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
primary: { fontWeight: 600 },
|
||||
note: { fontSize: '11px', opacity: 0.75, marginLeft: 'auto' },
|
||||
footer: { fontSize: '11px', opacity: 0.6, marginTop: '16px', lineHeight: 1.5 },
|
||||
}
|
||||
|
||||
/** The editable string form of one field's value. */
|
||||
function toText(field, value) {
|
||||
if (field.kind === 'list') return Array.isArray(value) ? value.join(', ') : ''
|
||||
if (value === undefined || value === null) return ''
|
||||
return String(value)
|
||||
}
|
||||
|
||||
/** The value to write for one field, from its string form. */
|
||||
function fromText(field, text) {
|
||||
const trimmed = typeof text === 'string' ? text.trim() : ''
|
||||
if (field.kind === 'number') return Number(trimmed)
|
||||
if (field.kind === 'list') {
|
||||
return trimmed === ''
|
||||
? []
|
||||
: trimmed
|
||||
.split(/[,\n]/)
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part !== '')
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
/** Build a draft of every field from a resolved section. */
|
||||
function toDraft(value) {
|
||||
const draft = {}
|
||||
for (const field of FIELDS) draft[field.key] = toText(field, value?.[field.key])
|
||||
return draft
|
||||
}
|
||||
|
||||
function Field(props) {
|
||||
const { field, value, overridden, disabled, onChange } = props
|
||||
const common = {
|
||||
style: S.input,
|
||||
value,
|
||||
disabled,
|
||||
'aria-label': field.label,
|
||||
onChange: (event) => onChange(event.target.value),
|
||||
}
|
||||
return h(
|
||||
'div',
|
||||
{ style: S.row },
|
||||
h(
|
||||
'label',
|
||||
{ style: S.label },
|
||||
field.label,
|
||||
overridden ? h('span', { style: { opacity: 0.6, fontWeight: 400 } }, ' · overridden') : null,
|
||||
),
|
||||
h('input', {
|
||||
...common,
|
||||
type: field.kind === 'secret' ? 'password' : 'text',
|
||||
...(field.kind === 'secret' ? { placeholder: '(unchanged)' } : {}),
|
||||
}),
|
||||
field.hint === undefined ? null : h('div', { style: S.hint }, field.hint),
|
||||
)
|
||||
}
|
||||
|
||||
function Card(props) {
|
||||
const scope = props.scope
|
||||
const [snapshot, setSnapshot] = react.useState(() => scope.getSnapshot())
|
||||
const [draft, setDraft] = react.useState(null)
|
||||
const [busy, setBusy] = react.useState(false)
|
||||
const [note, setNote] = react.useState('')
|
||||
// Collapsed on mount, like every other card in the tab.
|
||||
const [open, setOpen] = react.useState(false)
|
||||
|
||||
react.useEffect(() => scope.subscribe(() => setSnapshot(scope.getSnapshot())), [scope])
|
||||
|
||||
const status = snapshot.status
|
||||
const value = snapshot.value
|
||||
const baseline = react.useMemo(() => toDraft(value), [value])
|
||||
const current = draft ?? baseline
|
||||
const user = snapshot.user
|
||||
|
||||
if (status === 'unavailable') {
|
||||
// A deployment without the Host half, or a remote browser: show no trace.
|
||||
return null
|
||||
}
|
||||
|
||||
const dirtyKeys = FIELDS.filter((field) => current[field.key] !== baseline[field.key]).map((field) => field.key)
|
||||
const overriddenKeys = user !== null && typeof user === 'object' ? Object.keys(user) : []
|
||||
const writable = snapshot.writable === true
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true)
|
||||
setNote('')
|
||||
try {
|
||||
for (const field of FIELDS) {
|
||||
const next = current[field.key]
|
||||
if (next === baseline[field.key]) continue
|
||||
const trimmed = String(next).trim()
|
||||
// A redacted secret always reads as blank. Blank therefore means
|
||||
// "leave it alone", not "clear it" — otherwise opening this card and
|
||||
// pressing Save would silently delete the key.
|
||||
if (SECRET_KEYS.has(field.key) && trimmed === '') continue
|
||||
// Clearing any other field is an unset, not a write of "", so the
|
||||
// value re-inherits the composition layer instead of becoming empty.
|
||||
if (trimmed === '') await scope.unset(field.key)
|
||||
else await scope.set(field.key, fromText(field, next))
|
||||
}
|
||||
setDraft(null)
|
||||
setNote('Saved.')
|
||||
// A landed save folds the card back up. A failed or blocked one stays
|
||||
// open with the drafts, which is the whole point of staging them.
|
||||
setOpen(false)
|
||||
} catch (error) {
|
||||
setNote(`Save failed: ${error?.message ?? error}`)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const reset = async () => {
|
||||
setBusy(true)
|
||||
setNote('')
|
||||
try {
|
||||
for (const key of overriddenKeys) await scope.unset(key)
|
||||
setDraft(null)
|
||||
setNote('Reset to the deployment defaults.')
|
||||
} catch (error) {
|
||||
setNote(`Reset failed: ${error?.message ?? error}`)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const rows = []
|
||||
let group
|
||||
for (const field of FIELDS) {
|
||||
if (field.group !== group) {
|
||||
group = field.group
|
||||
rows.push(h('div', { key: `group-${group}`, style: S.group }, group))
|
||||
}
|
||||
rows.push(
|
||||
h(Field, {
|
||||
key: field.key,
|
||||
field,
|
||||
value: current[field.key],
|
||||
overridden: overriddenKeys.includes(field.key),
|
||||
disabled: busy || !writable,
|
||||
onChange: (next) => setDraft({ ...current, [field.key]: next }),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return h(
|
||||
'li',
|
||||
{ style: open ? { ...S.card, ...S.cardOpen } : S.card },
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
style: S.header,
|
||||
'aria-expanded': open,
|
||||
'aria-label': `${open ? 'Collapse' : 'Expand'}: ${TITLE}`,
|
||||
onClick: () => setOpen(!open),
|
||||
},
|
||||
h(
|
||||
'span',
|
||||
{ style: S.headText },
|
||||
h('span', { style: S.name }, TITLE),
|
||||
h('span', { style: S.description }, DESCRIPTION),
|
||||
),
|
||||
// Marked on the header, so unsaved edits are visible while collapsed.
|
||||
dirtyKeys.length > 0 ? h('span', { style: S.pending }, 'unsaved') : null,
|
||||
h('span', { style: open ? { ...S.chevron, ...S.chevronOpen } : S.chevron }, '▾'),
|
||||
),
|
||||
open
|
||||
? h(
|
||||
'div',
|
||||
{ style: S.body },
|
||||
writable ? null : h('p', { style: S.readOnly, role: 'status' }, 'This namespace is read-only here.'),
|
||||
...rows,
|
||||
h(
|
||||
'div',
|
||||
{ style: S.actions },
|
||||
h(
|
||||
'button',
|
||||
{ type: 'button', style: { ...S.button, ...S.primary }, disabled: busy || dirtyKeys.length === 0, onClick: save },
|
||||
dirtyKeys.length === 0 ? 'Save' : `Save ${dirtyKeys.length} change${dirtyKeys.length === 1 ? '' : 's'}`,
|
||||
),
|
||||
h(
|
||||
'button',
|
||||
{ type: 'button', style: S.button, disabled: busy || overriddenKeys.length === 0, onClick: reset },
|
||||
`Reset ${overriddenKeys.length} override${overriddenKeys.length === 1 ? '' : 's'}`,
|
||||
),
|
||||
note === '' ? null : h('span', { style: S.note }, note),
|
||||
),
|
||||
h(
|
||||
'p',
|
||||
{ style: S.footer },
|
||||
'Values are checked when saved, not when next used. The endpoint is not contacted here — run ' +
|
||||
'recall with action "status" to probe it.',
|
||||
),
|
||||
)
|
||||
: null,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the card, but only once a settings transport exists.
|
||||
*
|
||||
* `ctx.inject(['settingsScope'], …)` rather than a top-level `inject`,
|
||||
* because a required dependency that is unsatisfied means this plugin never
|
||||
* applies at all — the card would vanish along with everything else instead
|
||||
* of simply not appearing.
|
||||
*/
|
||||
/** The projection key the host half registers. */
|
||||
const USAGE_KEY = 'recallUsage'
|
||||
|
||||
/** Byte counts a person reads at a glance. */
|
||||
function humanBytes(bytes) {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0'
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return Math.round(bytes / 1024) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the usage projection, whatever the slot can offer.
|
||||
*
|
||||
* Called unconditionally and before any early return, because it is a hook:
|
||||
* a conditional call would break the moment the chip had nothing to show,
|
||||
* which is its normal state in a conversation that has not used the tool.
|
||||
*/
|
||||
function useUsage(props) {
|
||||
if (typeof props.useProjection !== 'function') return undefined
|
||||
return props.useProjection(USAGE_KEY)
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-line summary: what it kept out, not what it cost.
|
||||
*
|
||||
* The dollar figure is deliberately absent here. At the measured cache-read
|
||||
* rate a whole conversation's avoided context is worth a fraction of a
|
||||
* penny, and a chip advertising that would be read as the tool being
|
||||
* useless rather than as the arithmetic being honest. The detail lives in
|
||||
* the tab and in the tooltip.
|
||||
*/
|
||||
function chipText(usage) {
|
||||
const parts = ['Recall ' + usage.searches]
|
||||
if (usage.compression > 1) parts.push(usage.compression + '\u00d7')
|
||||
if (usage.avoidedBytes > 0) parts.push(humanBytes(usage.avoidedBytes) + ' kept out')
|
||||
return parts.join(' \u00b7 ')
|
||||
}
|
||||
|
||||
/** Everything a hover can hold, including what is not worth a chip. */
|
||||
function chipDetail(usage) {
|
||||
const lines = [
|
||||
usage.searches + ' search' + (usage.searches === 1 ? '' : 'es') + (usage.indexes ? ', ' + usage.indexes + ' index' : ''),
|
||||
usage.excerpts + ' excerpt' + (usage.excerpts === 1 ? '' : 's') + ' from ' + usage.files + ' file' + (usage.files === 1 ? '' : 's'),
|
||||
humanBytes(usage.returnedBytes) + ' returned where ' + humanBytes(usage.sourceBytes) + ' would have been read',
|
||||
]
|
||||
if (usage.rereadTokens > 0) {
|
||||
lines.push(
|
||||
'about ' + usage.rereadTokens.toLocaleString() + ' tokens re-read since, if those files had been read in full',
|
||||
)
|
||||
}
|
||||
lines.push('estimate: reading every file the excerpts came from, not a measurement of what you would have done')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function RecallChip(props) {
|
||||
const usage = useUsage(props)
|
||||
// The corner is laid out only while its occupant renders something, so a
|
||||
// conversation that never called the tool costs no layout at all.
|
||||
if (usage === undefined || !usage.searches) return null
|
||||
return h(
|
||||
'span',
|
||||
{
|
||||
title: chipDetail(usage),
|
||||
'aria-label': chipText(usage) + ' \u2014 recall usage for this conversation',
|
||||
style: {
|
||||
fontSize: '11px',
|
||||
opacity: 0.75,
|
||||
whiteSpace: 'nowrap',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '999px',
|
||||
border: '1px solid var(--dsh-border, #3a3a3a)',
|
||||
},
|
||||
},
|
||||
chipText(usage),
|
||||
)
|
||||
}
|
||||
|
||||
function RecallTabTitle() {
|
||||
return h('span', { style: { fontSize: '12px' } }, 'Recall')
|
||||
}
|
||||
|
||||
/** One labelled figure in the panel. */
|
||||
function Figure(props) {
|
||||
const { label, value, note } = props
|
||||
return h(
|
||||
'div',
|
||||
{ style: { marginBottom: '10px' } },
|
||||
h('div', { style: { fontSize: '11px', opacity: 0.6 } }, label),
|
||||
h('div', { style: { fontSize: '15px', fontWeight: 600 } }, value),
|
||||
note === undefined ? null : h('div', { style: { fontSize: '11px', opacity: 0.6, lineHeight: 1.4 } }, note),
|
||||
)
|
||||
}
|
||||
|
||||
function RecallPanel(props) {
|
||||
const usage = useUsage(props)
|
||||
if (usage === undefined) {
|
||||
// A host without the projection, or a session that predates it. Say so
|
||||
// rather than showing zeroes, which would read as "this did nothing".
|
||||
return h('div', { style: { padding: '16px', fontSize: '12px', opacity: 0.7 } }, 'Recall usage is not available for this conversation.')
|
||||
}
|
||||
if (!usage.searches && !usage.indexes) {
|
||||
return h(
|
||||
'div',
|
||||
{ style: { padding: '16px', fontSize: '12px', opacity: 0.7, lineHeight: 1.5 } },
|
||||
'This conversation has not used recall yet. It returns ranked excerpts instead of whole files, so context it keeps out is never re-sent on a later step.',
|
||||
)
|
||||
}
|
||||
|
||||
const head = usage.compression > 1 ? usage.compression + '\u00d7' : '\u2014'
|
||||
return h(
|
||||
'div',
|
||||
{ style: { padding: '16px', fontSize: '12px' } },
|
||||
h(Figure, {
|
||||
label: 'Kept out of this conversation',
|
||||
value: humanBytes(usage.avoidedBytes),
|
||||
note: humanBytes(usage.returnedBytes) + ' returned, where reading those files would have been ' + humanBytes(usage.sourceBytes),
|
||||
}),
|
||||
h(Figure, {
|
||||
label: 'Smaller than reading the files',
|
||||
value: head,
|
||||
note: 'The excerpt is a slice; the comparison is against the whole of each file it came from.',
|
||||
}),
|
||||
h(Figure, {
|
||||
label: 'Re-read since',
|
||||
value: usage.rereadTokens > 0 ? '~' + usage.rereadTokens.toLocaleString() + ' tokens' : 'not yet',
|
||||
note: 'Context a conversation carries is re-sent on every later step. Avoided context is avoided every time.',
|
||||
}),
|
||||
h(Figure, {
|
||||
label: 'Searches',
|
||||
value: String(usage.searches) + (usage.indexes ? ' + ' + usage.indexes + ' indexed' : ''),
|
||||
note: usage.excerpts + ' excerpts from ' + usage.files + ' files' + (usage.weak ? '; ' + usage.weak + ' too weak to use' : ''),
|
||||
}),
|
||||
h(
|
||||
'p',
|
||||
{ style: { fontSize: '11px', opacity: 0.6, lineHeight: 1.5, marginTop: '4px' } },
|
||||
'An estimate, and an upper bound: it assumes a whole file would have been read each time. Token counts are bytes \u00f7 4.',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function apply(ctx) {
|
||||
// The chip and the panel are session-scoped, so the framework hands them
|
||||
// `useProjection` as a standard prop — the same wire view the host
|
||||
// projection registers. Both render nothing when there is nothing to say.
|
||||
ctx.slots.inject('conversation.session.header.corner', () =>
|
||||
ctx.slots.register({ name: 'conversation.session.header.corner' }, (props) => h(RecallChip, props)),
|
||||
)
|
||||
ctx.slots.inject('sidebar.right.pane.tab', () =>
|
||||
ctx.slots.register({ name: 'sidebar.right.pane.tab', key: NS }, (props) => h(RecallPanel, props)),
|
||||
)
|
||||
ctx.slots.inject('sidebar.right.pane.tab.title', () =>
|
||||
ctx.slots.register({ name: 'sidebar.right.pane.tab.title', key: NS }, () => h(RecallTabTitle)),
|
||||
)
|
||||
|
||||
ctx.inject(['settingsScope'], (scoped) => {
|
||||
const binder = scoped.settingsScope
|
||||
if (binder === undefined) return
|
||||
const scope = binder.bind({ namespace: NS })
|
||||
scoped.slots.inject('settings.plugin.item', () =>
|
||||
scoped.slots.register(
|
||||
{
|
||||
name: 'settings.plugin.item',
|
||||
key: NS,
|
||||
inject: () => ({ scope }),
|
||||
},
|
||||
(props) => h(Card, props),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { name: NS, inject: ['slots'], apply }
|
||||
return module.exports
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user