@chatpanel/events 0.54.0 → 0.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cowriter-router.js +77 -0
- package/cowriter.js +190 -0
- package/index.js +37 -0
- package/model-picker.js +186 -0
- package/note-actions.js +252 -0
- package/note-graph.js +158 -0
- package/note-links.js +68 -0
- package/note-mentions.js +117 -0
- package/note-plan.js +113 -0
- package/note-research.js +135 -0
- package/package.json +22 -2
- package/web-search.js +160 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Who does which job: appointing a model to each role of the co-writer team.
|
|
2
|
+
//
|
|
3
|
+
// A co-writer is not one model. Proofreading runs constantly and must be cheap; drafting
|
|
4
|
+
// ahead runs rarely and should be the best thing available; research sits between them. Using
|
|
5
|
+
// one model for all three means either paying strong-model prices to catch "the the", or
|
|
6
|
+
// asking a small model to write prose. So each ROLE states a preference and this appoints the
|
|
7
|
+
// nearest candidate to it.
|
|
8
|
+
//
|
|
9
|
+
// Pure and dependency-free on purpose — no settings, no license, no DOM. The caller
|
|
10
|
+
// normalizes whatever roster it holds (the extension's configured endpoints and agents, the
|
|
11
|
+
// desktop's gateway model list) into candidates; this only decides. That is what makes the
|
|
12
|
+
// same appointment logic available to a gateway or a bridge that wanted to offer it.
|
|
13
|
+
//
|
|
14
|
+
// candidate: { id, name, kind, model, tier?, subagents?, usable? }
|
|
15
|
+
// role: { id, prefer: 'cheap' | 'balanced' | 'strong' }
|
|
16
|
+
|
|
17
|
+
const TIER_RANK = { cheap: 0, balanced: 1, strong: 2 };
|
|
18
|
+
|
|
19
|
+
// Infer a capability tier from a model id (best-effort, provider-agnostic).
|
|
20
|
+
export function classifyModel(model = '') {
|
|
21
|
+
const m = String(model).toLowerCase();
|
|
22
|
+
if (/haiku|mini|flash|nano|lite|small|instant|\b[1-9]b\b|8b|7b|3b/.test(m)) return 'cheap';
|
|
23
|
+
if (/opus|ultra|o1|o3|405b|70b|72b|large|gpt-4(?!o)|gpt-5/.test(m)) return 'strong';
|
|
24
|
+
if (/sonnet|gpt-4o|mixtral|medium|32b|command-r/.test(m)) return 'balanced';
|
|
25
|
+
return 'balanced'; // unknown → treat as mid so it's never wrongly picked as "cheapest"
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Native-subagent capable = bridge CLIs that orchestrate their own subagents.
|
|
29
|
+
export function supportsSubagents(candidate) {
|
|
30
|
+
return candidate?.kind === 'bridge' && /^(claude|codex)$/i.test(candidate.bridgeAgent || candidate.model || '');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function withTierAndMode(c) {
|
|
34
|
+
const tier = c.tier || classifyModel(c.model);
|
|
35
|
+
return { ...c, tier, mode: (c.subagents ?? supportsSubagents(c)) ? 'subagent' : 'api' };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Appoint one role → the best available candidate (or null if none usable).
|
|
39
|
+
export function appoint(role, candidates, { overrides = {} } = {}) {
|
|
40
|
+
const usable = (candidates || []).filter((c) => c && c.usable !== false && c.model);
|
|
41
|
+
if (!usable.length) return null;
|
|
42
|
+
const ovId = overrides[role.id];
|
|
43
|
+
if (ovId) {
|
|
44
|
+
const m = usable.find((c) => c.id === ovId);
|
|
45
|
+
if (m) return withTierAndMode(m);
|
|
46
|
+
}
|
|
47
|
+
const want = TIER_RANK[role.prefer] ?? 1;
|
|
48
|
+
const best = usable
|
|
49
|
+
.map((c) => ({ c: withTierAndMode(c), d: Math.abs((TIER_RANK[classifyModel(c.model)] ?? 1) - want) }))
|
|
50
|
+
.sort((a, b) => a.d - b.d || (a.c.name || a.c.id).localeCompare(b.c.name || b.c.id))[0];
|
|
51
|
+
return best.c;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Appoint a whole team → { [roleId]: appointment | null }.
|
|
55
|
+
export function routeTeam(roles, candidates, opts = {}) {
|
|
56
|
+
const out = {};
|
|
57
|
+
for (const role of roles || []) out[role.id] = appoint(role, candidates, opts);
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The team, and what each member is for.
|
|
63
|
+
*
|
|
64
|
+
* Here rather than in a client because a role is a CONTRACT — "the editor proofreads as you
|
|
65
|
+
* type, cheaply, constantly" — and a second client inventing its own list is a client whose
|
|
66
|
+
* Writer is not the same job as everyone else's. The icons and descriptions travel with it so
|
|
67
|
+
* two products describe the team in one voice.
|
|
68
|
+
*/
|
|
69
|
+
export const SWARM_ROLES = Object.freeze([
|
|
70
|
+
Object.freeze({ id: 'editor', prefer: 'cheap', icon: '\u270d\ufe0f', name: 'Editor', desc: 'Proofreads as you type' }),
|
|
71
|
+
Object.freeze({ id: 'researcher', prefer: 'balanced', icon: '\ud83d\udd0e', name: 'Researcher', desc: 'Finds related material' }),
|
|
72
|
+
Object.freeze({ id: 'writer', prefer: 'strong', icon: '\u2728', name: 'Writer', desc: 'Drafts ahead and rewrites' }),
|
|
73
|
+
Object.freeze({ id: 'factcheck', prefer: 'strong', icon: '\u26a0\ufe0f', name: 'Fact-checker', desc: 'Flags shaky claims' }),
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
/** A role by id, or null — so a stored override naming a role this build dropped is ignored. */
|
|
77
|
+
export const roleById = (id) => SWARM_ROLES.find((r) => r.id === id) || null;
|
package/cowriter.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// The co-writer's two deterministic halves: what is mechanically wrong with a paragraph, and
|
|
2
|
+
// what the SMALLEST change is that fixes a sentence.
|
|
3
|
+
//
|
|
4
|
+
// A co-writer that replaces a paragraph with its own version is a rewriter, and people do not
|
|
5
|
+
// want their prose rewritten — they want the doubled word caught. Everything here exists to
|
|
6
|
+
// keep a suggestion small enough to accept with one click and small enough to be obviously
|
|
7
|
+
// right when you look at it.
|
|
8
|
+
//
|
|
9
|
+
// THE DETERMINISTIC PASS RUNS FIRST, AND OFTEN ENDS IT. `lintText` catches the mechanical
|
|
10
|
+
// mistakes — doubled words, double spaces, a space before a comma, the lone lowercase "i" —
|
|
11
|
+
// for free, so a token is spent only on text that is already mechanically clean. That order
|
|
12
|
+
// is the difference between a co-writer that idles at zero cost and one that bills for
|
|
13
|
+
// noticing "the the".
|
|
14
|
+
//
|
|
15
|
+
// Both halves were `cowriter-lint.js` and `cowriter-diff.js` in the extension, written pure
|
|
16
|
+
// and dependency-free from the start and explicitly noted there as portable. They are here so
|
|
17
|
+
// the second and third clients inherit them rather than copy them.
|
|
18
|
+
|
|
19
|
+
// ── the deterministic pass ──────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Mechanical mistakes in `text`, as minimal edits.
|
|
23
|
+
*
|
|
24
|
+
* Returns `[{ start, end, before, after }]` — the same shape `wordDiff` produces, so a client
|
|
25
|
+
* renders a lint fix and a model fix with one component — non-overlapping and left-to-right.
|
|
26
|
+
*/
|
|
27
|
+
export function lintText(text = '') {
|
|
28
|
+
const src = String(text);
|
|
29
|
+
const raw = [];
|
|
30
|
+
const add = (start, end, after) => {
|
|
31
|
+
if (after !== src.slice(start, end)) raw.push({ start, end, before: src.slice(start, end), after });
|
|
32
|
+
};
|
|
33
|
+
let m;
|
|
34
|
+
|
|
35
|
+
// 1) doubled word: "the the" → "the" (case-insensitive, same word).
|
|
36
|
+
const dup = /\b(\w+)(\s+)\1\b/gi;
|
|
37
|
+
while ((m = dup.exec(src))) add(m.index, m.index + m[0].length, m[1]);
|
|
38
|
+
|
|
39
|
+
// 2) a run of 2+ spaces between visible chars → a single space.
|
|
40
|
+
const runs = /(\S)( {2,})(\S)/g;
|
|
41
|
+
while ((m = runs.exec(src))) { const s = m.index + 1; add(s, s + m[2].length, ' '); runs.lastIndex = s + 1; }
|
|
42
|
+
|
|
43
|
+
// 3) whitespace before sentence punctuation: "word ," → "word,".
|
|
44
|
+
const sp = /(\S)(\s+)([,.;:!?])/g;
|
|
45
|
+
while ((m = sp.exec(src))) add(m.index + 1, m.index + m[0].length, m[3]);
|
|
46
|
+
|
|
47
|
+
// 4) standalone lowercase "i" → "I" (skipping the "i.e." abbreviation).
|
|
48
|
+
const iRe = /(^|[ \t(])i(?=[ \t.,;:!?)]|$)/g;
|
|
49
|
+
while ((m = iRe.exec(src))) {
|
|
50
|
+
const at = m.index + m[1].length;
|
|
51
|
+
if (src[at + 1] === '.' && /[a-z]/i.test(src[at + 2] || '')) continue; // i.e., i.g.
|
|
52
|
+
add(at, at + 1, 'I');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Sort left-to-right and drop any edit overlapping one already kept: two fixes over the
|
|
56
|
+
// same characters cannot both be applied, and applying one invalidates the other's offsets.
|
|
57
|
+
raw.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
58
|
+
const out = [];
|
|
59
|
+
let lastEnd = -1;
|
|
60
|
+
for (const e of raw) {
|
|
61
|
+
if (e.start < lastEnd) continue;
|
|
62
|
+
out.push(e);
|
|
63
|
+
lastEnd = e.end;
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── the minimal diff ────────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
/** Non-whitespace tokens with their char offsets in the source string. */
|
|
71
|
+
function words(text) {
|
|
72
|
+
const out = [];
|
|
73
|
+
const re = /\S+/g;
|
|
74
|
+
let m;
|
|
75
|
+
while ((m = re.exec(text))) out.push({ w: m[0], start: m.index, end: m.index + m[0].length });
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Matched index pairs between two word arrays (longest common subsequence). */
|
|
80
|
+
function lcsPairs(a, b) {
|
|
81
|
+
const n = a.length;
|
|
82
|
+
const m = b.length;
|
|
83
|
+
const dp = Array.from({ length: n + 1 }, () => new Int32Array(m + 1));
|
|
84
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
85
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
86
|
+
dp[i][j] = a[i].w === b[j].w ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const pairs = [];
|
|
90
|
+
let i = 0;
|
|
91
|
+
let j = 0;
|
|
92
|
+
while (i < n && j < m) {
|
|
93
|
+
if (a[i].w === b[j].w) { pairs.push([i, j]); i++; j++; }
|
|
94
|
+
else if (dp[i + 1][j] >= dp[i][j + 1]) i++;
|
|
95
|
+
else j++;
|
|
96
|
+
}
|
|
97
|
+
return pairs;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Minimal edits turning `original` into `corrected`, with offsets into `original`.
|
|
102
|
+
*
|
|
103
|
+
* Word-run replacements, insertions (start === end, before '') and deletions (after ''). The
|
|
104
|
+
* whitespace handling is the fiddly half and the reason this is one tested function rather
|
|
105
|
+
* than an idea each client implements: an insertion carries a space so the new word is not
|
|
106
|
+
* glued to its neighbour, and a deletion absorbs one adjacent space so it leaves neither a
|
|
107
|
+
* double space nor a leading one.
|
|
108
|
+
*/
|
|
109
|
+
export function wordDiff(original, corrected) {
|
|
110
|
+
const A = words(original);
|
|
111
|
+
const B = words(corrected);
|
|
112
|
+
const matches = lcsPairs(A, B);
|
|
113
|
+
const edits = [];
|
|
114
|
+
const push = (aFrom, aTo, bFrom, bTo) => {
|
|
115
|
+
if (aFrom === aTo && bFrom === bTo) return;
|
|
116
|
+
const insWords = B.slice(bFrom, bTo).map((x) => x.w);
|
|
117
|
+
let start;
|
|
118
|
+
let end;
|
|
119
|
+
let after;
|
|
120
|
+
if (aFrom === aTo) {
|
|
121
|
+
if (aFrom < A.length) { start = A[aFrom].start; end = start; after = `${insWords.join(' ')} `; }
|
|
122
|
+
else { start = A.length ? A[A.length - 1].end : 0; end = start; after = `${A.length ? ' ' : ''}${insWords.join(' ')}`; }
|
|
123
|
+
} else if (bFrom === bTo) {
|
|
124
|
+
start = A[aFrom].start;
|
|
125
|
+
if (aTo < A.length) end = A[aTo].start;
|
|
126
|
+
else { end = A[aTo - 1].end; if (aFrom > 0) start = A[aFrom - 1].end; }
|
|
127
|
+
after = '';
|
|
128
|
+
} else {
|
|
129
|
+
start = A[aFrom].start;
|
|
130
|
+
end = A[aTo - 1].end;
|
|
131
|
+
after = insWords.join(' ');
|
|
132
|
+
}
|
|
133
|
+
edits.push({ start, end, before: original.slice(start, end), after });
|
|
134
|
+
};
|
|
135
|
+
let ai = 0;
|
|
136
|
+
let bi = 0;
|
|
137
|
+
for (const [am, bm] of matches) {
|
|
138
|
+
if (am > ai || bm > bi) push(ai, am, bi, bm);
|
|
139
|
+
ai = am + 1;
|
|
140
|
+
bi = bm + 1;
|
|
141
|
+
}
|
|
142
|
+
if (ai < A.length || bi < B.length) push(ai, A.length, bi, B.length);
|
|
143
|
+
return edits;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Keep only SMALL corrections — a typo, a comma, a missing "the".
|
|
148
|
+
*
|
|
149
|
+
* Without this the co-writer restructures prose: ask a model to "fix the mistakes" in a
|
|
150
|
+
* paragraph and it will happily return a better paragraph, which arrives here as one enormous
|
|
151
|
+
* edit and is offered as a one-click "fix". A suggestion nobody can check at a glance is not
|
|
152
|
+
* a suggestion, so anything large is dropped rather than shown.
|
|
153
|
+
*/
|
|
154
|
+
export function filterTypoEdits(edits, { maxWords = 5, maxLen = 48 } = {}) {
|
|
155
|
+
return (edits || []).filter((e) => {
|
|
156
|
+
if (e.before === e.after) return false;
|
|
157
|
+
if (e.before.length > maxLen || e.after.length > maxLen) return false;
|
|
158
|
+
const bw = e.before ? e.before.split(/\s+/).length : 0;
|
|
159
|
+
const aw = e.after ? e.after.split(/\s+/).length : 0;
|
|
160
|
+
if (Math.max(bw, aw) > maxWords) return false;
|
|
161
|
+
// A pure insertion is only "small" if it is a word or two (a missing "the", a comma).
|
|
162
|
+
if (!e.before && aw > 2) return false;
|
|
163
|
+
return true;
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Stable identity for a suggestion, so a fix the user dismissed is not offered again. */
|
|
168
|
+
export function editKey(edit) {
|
|
169
|
+
return `${edit.before}␟${edit.after}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Apply non-overlapping edits right-to-left, so earlier offsets stay valid as it goes. */
|
|
173
|
+
export function applyEdits(text, edits) {
|
|
174
|
+
let out = String(text);
|
|
175
|
+
for (const e of [...(edits || [])].sort((a, b) => b.start - a.start)) {
|
|
176
|
+
out = out.slice(0, e.start) + e.after + out.slice(e.end);
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The instruction a co-writing model is given, and the guardrail inside it.
|
|
183
|
+
*
|
|
184
|
+
* It is told to return the corrected text and NOTHING else, because the answer is diffed
|
|
185
|
+
* against the original rather than read: a preamble becomes a spurious insertion at offset 0,
|
|
186
|
+
* offered to the user as a "fix" that pastes "Sure, here is the corrected text:" into their
|
|
187
|
+
* note.
|
|
188
|
+
*/
|
|
189
|
+
export const COWRITER_SYSTEM = 'You are a meticulous copy editor. Fix ONLY spelling, grammar, punctuation and obvious word mistakes in the text. Do NOT rewrite, rephrase, restructure, shorten or improve the style, and do not add or remove content. Preserve the markdown exactly. Output ONLY the corrected text.';
|
|
190
|
+
export const COWRITER_TEMPERATURE = 0;
|
package/index.js
CHANGED
|
@@ -18,6 +18,43 @@ export {
|
|
|
18
18
|
export { REF_KINDS, RESOLUTION, makeRef, isRef, resolveRef } from './ref.js';
|
|
19
19
|
export { linearize, compareEvents, causesAreWellFormed } from './order.js';
|
|
20
20
|
export { pendingQueue, isQueued, dequeue, moveQueued, promoteQueued } from './queue.js';
|
|
21
|
+
export {
|
|
22
|
+
groupModels, filterSections, defaultModelId, modelSummary,
|
|
23
|
+
} from './model-picker.js';
|
|
24
|
+
export {
|
|
25
|
+
NOTE_ACTIONS, NOTE_ACTION_ORDER, NOTE_ACTION_TEMPERATURE, NOTE_ACTION_ERRORS,
|
|
26
|
+
NOTE_COMMANDS, NOTE_COMMAND_TEMPERATURE, NOTE_COMMAND_MAX_TOKENS,
|
|
27
|
+
frameNoteAction, noteActionLabel, noteActionItems, filterNoteActions,
|
|
28
|
+
commandLineAt, triggerQueryAt,
|
|
29
|
+
} from './note-actions.js';
|
|
30
|
+
export {
|
|
31
|
+
parseAgentMention, agentMentionAt, parseSkillMention, mergeSkillPrompt,
|
|
32
|
+
findSkillByName, findTargetByName, mentionAnswerPrefix,
|
|
33
|
+
} from './note-mentions.js';
|
|
34
|
+
export { wikiQueryAt, rankLinkTargets } from './note-links.js';
|
|
35
|
+
export {
|
|
36
|
+
SWARM_ROLES, roleById, classifyModel, supportsSubagents, appoint, routeTeam,
|
|
37
|
+
} from './cowriter-router.js';
|
|
38
|
+
export {
|
|
39
|
+
SEARCH_ENGINES, RESULTS_PER_ENGINE, buildSearchUrl, unwrapRedirect, isResultHost,
|
|
40
|
+
pickResults, mergeEngineResults, engineOrder,
|
|
41
|
+
} from './web-search.js';
|
|
42
|
+
export {
|
|
43
|
+
MAX_GRAPH_NODES, buildNoteGraph, egoGraph, trimGraph, graphStats,
|
|
44
|
+
} from './note-graph.js';
|
|
45
|
+
export {
|
|
46
|
+
lintText, wordDiff, filterTypoEdits, editKey, applyEdits,
|
|
47
|
+
COWRITER_SYSTEM, COWRITER_TEMPERATURE,
|
|
48
|
+
} from './cowriter.js';
|
|
49
|
+
export {
|
|
50
|
+
salientTerms, topicTerms, researchRelevance, webQuery, researchSnippet,
|
|
51
|
+
rankResearchCards, mergeResearchLanes,
|
|
52
|
+
} from './note-research.js';
|
|
53
|
+
export {
|
|
54
|
+
PLAN_ROLES, PLAN_AUTHORS, PLAN_DECOMPOSE_SYSTEM, PLAN_DECOMPOSE_MAX_TOKENS,
|
|
55
|
+
PLAN_DECOMPOSE_TEMPERATURE, PLAN_SECTION_MAX_TOKENS, PLAN_SECTION_TEMPERATURE,
|
|
56
|
+
planSectionSystem, parsePlanTasks, planTitleFor, planParts, planBody, planAttribution,
|
|
57
|
+
} from './note-plan.js';
|
|
21
58
|
export { REACH, reachRank, reachSatisfies } from './reach.js';
|
|
22
59
|
export { UPCASTERS, upcast, upcastAll } from './upcast.js';
|
|
23
60
|
export {
|
package/model-picker.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// THE MODEL PICKER'S SHAPE — one flat list of models in, the menu a person can read out.
|
|
2
|
+
//
|
|
3
|
+
// Every client is handed the same flat array by the gateway's /v1/models: fifty-odd entries
|
|
4
|
+
// mixing local CLI agents with a dozen providers' cloud models, in whatever order the routing
|
|
5
|
+
// table happened to enumerate them. Rendered literally that is a single scrolling column of
|
|
6
|
+
// opaque ids — `claude-code`, `gpt-4o-mini`, `qwen2.5-coder:7b` — where the two things a user
|
|
7
|
+
// actually distinguishes are invisible:
|
|
8
|
+
//
|
|
9
|
+
// • AN AGENT IS NOT A MODEL. A bridge-backed CLI runs on this machine, holds a session, can
|
|
10
|
+
// touch files and costs a process; a cloud model is a stateless HTTP call. Picking between
|
|
11
|
+
// them is a different decision from picking between two models, and a flat list forces
|
|
12
|
+
// both decisions through one control.
|
|
13
|
+
// • A PROVIDER IS THE UNIT PEOPLE THINK IN. "the Anthropic one", "my Ollama". Sorting by id
|
|
14
|
+
// interleaves providers so the same provider's models are scattered down the list.
|
|
15
|
+
//
|
|
16
|
+
// So this turns the flat list into SECTIONS, and it lives here rather than in a client because
|
|
17
|
+
// it is pure input → output with no window in it: the desktop renders it as a menu, the
|
|
18
|
+
// extension as its agent menu, a mobile client as a grouped list, and all three group and
|
|
19
|
+
// order identically. Writing it in one client is how `displayName` got duplicated.
|
|
20
|
+
//
|
|
21
|
+
// UNAVAILABLE MODELS ARE KEPT, NOT DROPPED. A CLI the user has not installed is the single
|
|
22
|
+
// most common reason a first message fails, and a picker that silently omits it answers
|
|
23
|
+
// "where did Claude Code go?" with nothing. It is listed, ordered last within its section,
|
|
24
|
+
// flagged, and carries the reason — see `reason` on the entry.
|
|
25
|
+
|
|
26
|
+
/** A bridge-backed CLI agent, as opposed to an HTTP model endpoint. */
|
|
27
|
+
const isAgent = (m) => m?.providerType === 'agent' || m?.viaBridge === true;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The provider a model belongs to, in the words the gateway used.
|
|
31
|
+
*
|
|
32
|
+
* `provider` is what gateways from 0.6.64 report; `owned_by`/`owner` is the older field. A
|
|
33
|
+
* name is NEVER parsed for a provider — reading `gpt-` as "OpenAI" breaks the moment someone
|
|
34
|
+
* serves a GPT-named model from their own Ollama, and that user's whole point was that it is
|
|
35
|
+
* local.
|
|
36
|
+
*/
|
|
37
|
+
const providerOf = (m) => String(m?.provider || m?.owner || '').trim();
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* How a provider is spelled when a person wrote it down.
|
|
41
|
+
*
|
|
42
|
+
* Capitalising the first letter is right for `anthropic` and wrong for every brand with
|
|
43
|
+
* internal capitals — it renders "Openai", "Deepseek", "Xai". A heading is the most-read text
|
|
44
|
+
* in the picker, and misspelling the company in it reads as carelessness about everything
|
|
45
|
+
* else, so the handful that do not follow the rule are simply listed.
|
|
46
|
+
*/
|
|
47
|
+
const PROVIDER_NAMES = {
|
|
48
|
+
openai: 'OpenAI', openrouter: 'OpenRouter', deepseek: 'DeepSeek', xai: 'xAI',
|
|
49
|
+
vllm: 'vLLM', lmstudio: 'LM Studio', llamacpp: 'llama.cpp', 'llama.cpp': 'llama.cpp',
|
|
50
|
+
huggingface: 'Hugging Face', togetherai: 'Together AI', together: 'Together AI',
|
|
51
|
+
githubcopilot: 'GitHub Copilot', awsbedrock: 'AWS Bedrock', bedrock: 'AWS Bedrock',
|
|
52
|
+
azureopenai: 'Azure OpenAI', googleai: 'Google AI', vertexai: 'Vertex AI',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Sentence case for a bare provider slug, leaving names that already have shape alone. */
|
|
56
|
+
function providerLabel(raw) {
|
|
57
|
+
const p = String(raw || '').trim();
|
|
58
|
+
if (!p) return 'Other';
|
|
59
|
+
const known = PROVIDER_NAMES[p.toLowerCase().replace(/[\s_-]/g, '')];
|
|
60
|
+
if (known) return known;
|
|
61
|
+
if (/[A-Z ]/.test(p)) return p; // already presentable: "Together AI", "MyCorp LLM"
|
|
62
|
+
return p.charAt(0).toUpperCase() + p.slice(1);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const availableFirst = (a, b) => (
|
|
66
|
+
(a.available === false) - (b.available === false)
|
|
67
|
+
|| String(a.id).localeCompare(String(b.id))
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Group a flat model list into the sections a picker draws.
|
|
72
|
+
*
|
|
73
|
+
* Sections come back in the order they should be shown: **Agents first**, then each provider
|
|
74
|
+
* alphabetically. Agents lead because a local CLI is the one target a fresh install can be
|
|
75
|
+
* sure of — no key, no account — which is the same reason `target-choice.js` defaults to one.
|
|
76
|
+
*
|
|
77
|
+
* @param models the gateway's models, each `{ id, provider, providerType, viaBridge,
|
|
78
|
+
* available, reason, owner, label? }`
|
|
79
|
+
* @param selectedId currently chosen id, so a caller can mark it without a second pass
|
|
80
|
+
* @returns `[{ key, label, kind: 'agent'|'provider', items: [...] }]`, where each item is the
|
|
81
|
+
* model object plus `{ label, selected }`. Empty sections are never emitted.
|
|
82
|
+
*/
|
|
83
|
+
export function groupModels(models, { selectedId = '' } = {}) {
|
|
84
|
+
const list = Array.isArray(models) ? models.filter(Boolean) : [];
|
|
85
|
+
|
|
86
|
+
const agents = [];
|
|
87
|
+
const byProvider = new Map();
|
|
88
|
+
for (const m of list) {
|
|
89
|
+
if (isAgent(m)) { agents.push(m); continue; }
|
|
90
|
+
const key = providerOf(m) || 'other';
|
|
91
|
+
if (!byProvider.has(key)) byProvider.set(key, []);
|
|
92
|
+
byProvider.get(key).push(m);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const decorate = (m) => ({
|
|
96
|
+
...m,
|
|
97
|
+
label: m.label || m.id,
|
|
98
|
+
selected: !!selectedId && m.id === selectedId,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const sections = [];
|
|
102
|
+
if (agents.length) {
|
|
103
|
+
sections.push({
|
|
104
|
+
key: 'agents',
|
|
105
|
+
label: 'Agents',
|
|
106
|
+
kind: 'agent',
|
|
107
|
+
items: agents.slice().sort(availableFirst).map(decorate),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
for (const key of [...byProvider.keys()].sort((a, b) => a.localeCompare(b))) {
|
|
111
|
+
sections.push({
|
|
112
|
+
key: `provider:${key}`,
|
|
113
|
+
label: providerLabel(key),
|
|
114
|
+
kind: 'provider',
|
|
115
|
+
items: byProvider.get(key).slice().sort(availableFirst).map(decorate),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return sections;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Filter the grouped sections by a typed query, keeping the grouping.
|
|
123
|
+
*
|
|
124
|
+
* A provider name matches ALL of its models: someone typing "ollama" is asking to see that
|
|
125
|
+
* provider, not to see the models whose ids happen to contain the string — and with local
|
|
126
|
+
* models the id usually does not contain it at all.
|
|
127
|
+
*
|
|
128
|
+
* Sections that end up empty are dropped, so an unmatched provider does not leave a heading
|
|
129
|
+
* floating over nothing.
|
|
130
|
+
*/
|
|
131
|
+
export function filterSections(sections, query) {
|
|
132
|
+
const q = String(query || '').trim().toLowerCase();
|
|
133
|
+
if (!q) return sections;
|
|
134
|
+
const out = [];
|
|
135
|
+
for (const s of sections) {
|
|
136
|
+
if (s.label.toLowerCase().includes(q)) { out.push(s); continue; }
|
|
137
|
+
const items = s.items.filter((m) => (
|
|
138
|
+
String(m.label).toLowerCase().includes(q) || String(m.id).toLowerCase().includes(q)
|
|
139
|
+
));
|
|
140
|
+
if (items.length) out.push({ ...s, items });
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The id a picker should start on when the record does not name one.
|
|
147
|
+
*
|
|
148
|
+
* Prefers a REACHABLE agent, then any reachable model, and only then something known to be
|
|
149
|
+
* unavailable. The rule is the product's, and it is the same one `target-choice.js` states:
|
|
150
|
+
* never default to a target that cannot answer, because the user is the one who finds out.
|
|
151
|
+
*/
|
|
152
|
+
export function defaultModelId(models) {
|
|
153
|
+
const list = Array.isArray(models) ? models.filter(Boolean) : [];
|
|
154
|
+
const usable = list.filter((m) => m.available !== false);
|
|
155
|
+
return (usable.find(isAgent) || usable[0] || list[0] || {}).id || '';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* How to describe the chosen model in one line — what the picker's own button says.
|
|
160
|
+
*
|
|
161
|
+
* The three-valued `available` is the honesty rule applied to a button: an id that names
|
|
162
|
+
* nothing is only BROKEN if there was a list for it to be absent from. Before /v1/models has
|
|
163
|
+
* answered there is no such list, so the truthful answer is `null` — "nobody has looked yet"
|
|
164
|
+
* — and a caller must not paint that as a red dot. Marking an unloaded picker unavailable is
|
|
165
|
+
* the same mistake as drawing "0 redactions" when nothing was inspected.
|
|
166
|
+
*/
|
|
167
|
+
export function modelSummary(models, id) {
|
|
168
|
+
const list = Array.isArray(models) ? models.filter(Boolean) : [];
|
|
169
|
+
const m = list.find((x) => x.id === id);
|
|
170
|
+
if (!m) {
|
|
171
|
+
return {
|
|
172
|
+
label: id || 'No model',
|
|
173
|
+
available: list.length ? false : null,
|
|
174
|
+
reason: list.length && id ? `${id} is not offered by the gateway any more` : '',
|
|
175
|
+
agent: false,
|
|
176
|
+
provider: '',
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
label: m.label || m.id,
|
|
181
|
+
available: m.available !== false,
|
|
182
|
+
reason: m.reason || '',
|
|
183
|
+
agent: isAgent(m),
|
|
184
|
+
provider: providerLabel(providerOf(m)),
|
|
185
|
+
};
|
|
186
|
+
}
|