@aiwuk/team 0.0.0-stage → 5.0.8
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/CHANGELOG.md +175 -0
- package/LICENSE +21 -0
- package/README.md +149 -2
- package/README.zh-CN.md +150 -0
- package/access.js +93 -0
- package/activity-summary.js +36 -0
- package/allocation.js +34 -0
- package/board-dispatch.js +141 -0
- package/brand.js +41 -0
- package/client/00-core-zh.js +284 -0
- package/client/01-i18n-en.js +265 -0
- package/client/02-styles.js +336 -0
- package/client/02b-stage-styles.js +65 -0
- package/client/03-shared.js +82 -0
- package/client/03b-collapsible-list.js +34 -0
- package/client/03c-config-panel.js +605 -0
- package/client/04-tool-cards.js +110 -0
- package/client/04a-dispatch-confirm.js +162 -0
- package/client/04b-more-cards.js +78 -0
- package/client/04c-model-picker.js +68 -0
- package/client/04d-confirm-dock.js +40 -0
- package/client/05-policy.js +217 -0
- package/client/06-usage.js +195 -0
- package/client/06b-model-source.js +67 -0
- package/client/07-board-parts.js +312 -0
- package/client/07a-role-theme.js +25 -0
- package/client/08-artifacts-report.js +375 -0
- package/client/08a-task-control.js +126 -0
- package/client/08b-stage-sprites.js +97 -0
- package/client/08c-stage-colors.js +55 -0
- package/client/08c-stage-scene.js +238 -0
- package/client/08d-stage-adapter.js +65 -0
- package/client/08e-stage-activity.js +83 -0
- package/client/09-team-board.js +181 -0
- package/client/09b-role-view.js +95 -0
- package/client/09c-model-tabs.js +20 -0
- package/client/10-overview.js +364 -0
- package/client/10b-onboarding.js +121 -0
- package/client/11-register.js +249 -0
- package/client.js +5053 -0
- package/config-projection.js +40 -0
- package/config.js +411 -0
- package/cordis.patch.yml +110 -0
- package/defaults.js +222 -0
- package/dispatch-confirm.js +293 -0
- package/dispatch-routes-store.js +31 -0
- package/failover.js +85 -0
- package/file-limits.js +119 -0
- package/free-worker.mjs +125 -0
- package/http-guard.js +14 -0
- package/i18n/roles.en.json +23 -0
- package/i18n/roles.zh.json +23 -0
- package/i18n/server.en.json +659 -0
- package/i18n/server.zh.json +659 -0
- package/i18n.js +34 -0
- package/icon.svg +7 -0
- package/index.js +113 -0
- package/integrations-config.js +301 -0
- package/integrations-messages.js +62 -0
- package/integrations.js +17 -0
- package/lead-route.js +30 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/long-context.js +30 -0
- package/managed-config-route.js +49 -0
- package/managed-ref.js +38 -0
- package/managed-resolve.js +47 -0
- package/member-models.js +84 -0
- package/member-task-control.js +242 -0
- package/memory-backends.js +89 -0
- package/memory-embed.js +70 -0
- package/memory-hub.js +140 -0
- package/memory-openviking.js +101 -0
- package/memory-redact.js +34 -0
- package/memory-store.js +270 -0
- package/memory-sync-queue.js +28 -0
- package/model-policy.js +85 -0
- package/model-prefs.js +104 -0
- package/observability-api.js +76 -0
- package/observability-projection.js +116 -0
- package/onboarding.js +47 -0
- package/opencode.js +74 -0
- package/package.json +155 -4
- package/plan-gate.js +92 -0
- package/preset-guard.js +47 -0
- package/request-evidence.js +41 -0
- package/roles.js +105 -0
- package/roster-defaults.js +70 -0
- package/route-health.js +39 -0
- package/runtime-observability.js +78 -0
- package/runtime-view.js +21 -0
- package/scripts/memory-cli.mjs +138 -0
- package/search-engines.js +119 -0
- package/search.js +164 -0
- package/session-policy.js +59 -0
- package/session-rotation.js +206 -0
- package/settings-schema.js +40 -0
- package/shared/managed-config.js +84 -0
- package/single-model.js +44 -0
- package/skills/project-role-theme/SKILL.md +46 -0
- package/task-bridge.js +32 -0
- package/task-control-api.js +36 -0
- package/task-control-audit.js +60 -0
- package/team.js +101 -0
- package/teammate-context.js +120 -0
- package/teammate-wait.js +18 -0
- package/template.js +46 -0
- package/theme-plugin.js +172 -0
- package/theme.js +115 -0
- package/token-discipline.js +116 -0
- package/token-metrics.js +37 -0
- package/tool-names.js +10 -0
- package/tool-schema.js +23 -0
- package/transcribe.js +104 -0
- package/ui.js +113 -0
- package/usage.js +96 -0
- package/workflow.js +246 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// ---------- right sidebar: team overview ----------
|
|
2
|
+
const TIER_ORDER = ['high', 'upper', 'medium', 'low'];
|
|
3
|
+
const PAGE = 40;
|
|
4
|
+
const isRule = entry => entry.endsWith('*');
|
|
5
|
+
/** Same semantics as the host: exact provider/model keys, or "prefix*" on the full key or the bare model id. */
|
|
6
|
+
const ruleMatcher = list => {
|
|
7
|
+
const exact = new Set(), prefixes = [];
|
|
8
|
+
for (const entry of list) (isRule(entry) ? prefixes.push(entry.slice(0, -1)) : exact.add(entry));
|
|
9
|
+
return key => exact.has(key) || prefixes.some(p => key.startsWith(p) || key.slice(key.indexOf('/') + 1).startsWith(p));
|
|
10
|
+
};
|
|
11
|
+
const ruleCovers = (rule, key) => ruleMatcher([rule])(key);
|
|
12
|
+
/** User input → normalized prefix rule, or null when it is not a usable prefix. */
|
|
13
|
+
const toRule = text => {
|
|
14
|
+
const base = String(text ?? '').trim().replace(/\*+$/, '');
|
|
15
|
+
return base.length >= 2 && !/[*\s]/.test(base) ? base + '*' : null;
|
|
16
|
+
};
|
|
17
|
+
const tierRank = tier => { const i = TIER_ORDER.indexOf(tier); return i === -1 ? (tier === 'unknown' ? 99 : 50) : i; };
|
|
18
|
+
const routeText = route => typeof route === 'string' ? route : route?.provider ? `${route.provider}/${route.model}` : '';
|
|
19
|
+
const when = value => {
|
|
20
|
+
if (value == null) return '';
|
|
21
|
+
const date = new Date(value);
|
|
22
|
+
return Number.isNaN(date.getTime()) ? '' : date.toLocaleString(undefined, { hour12: false, month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
23
|
+
};
|
|
24
|
+
const timeOfDay = value => {
|
|
25
|
+
if (value == null) return '';
|
|
26
|
+
const date = new Date(value);
|
|
27
|
+
return Number.isNaN(date.getTime()) ? '' : date.toLocaleTimeString(undefined, { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
28
|
+
};
|
|
29
|
+
/** Labelled translation that falls back to the raw value when no dictionary entry exists. */
|
|
30
|
+
const label = (t, key, fallback) => { const text = t(key); return text === key ? fallback : text; };
|
|
31
|
+
const stat = (value, text, tone, key) => h('div', { className: 'ctm-ov-stat', 'data-tone': tone, key }, h('strong', null, value), h('span', null, text));
|
|
32
|
+
|
|
33
|
+
function ModelPolicyEditor({ sessionId, policy, save, t }) {
|
|
34
|
+
const [draft, setDraft] = React.useState(policy.disabledRoutes ?? []);
|
|
35
|
+
const [status, setStatus] = React.useState({});
|
|
36
|
+
const [revision, setRevision] = React.useState(policy.revision);
|
|
37
|
+
const [saved, setSaved] = React.useState(policy.disabledRoutes ?? []);
|
|
38
|
+
const [query, setQuery] = React.useState('');
|
|
39
|
+
const [tierTab, setTierTab] = React.useState(null);
|
|
40
|
+
const [limit, setLimit] = React.useState(PAGE);
|
|
41
|
+
const [prefix, setPrefix] = React.useState('');
|
|
42
|
+
const isOff = ruleMatcher(draft);
|
|
43
|
+
const rules = draft.filter(isRule);
|
|
44
|
+
const ruleFor = key => draft.includes(key) ? null : rules.find(rule => ruleCovers(rule, key));
|
|
45
|
+
const pending = draft.filter(key => !saved.includes(key)).length + saved.filter(key => !draft.includes(key)).length;
|
|
46
|
+
const rows = policy.rows ?? [];
|
|
47
|
+
const names = new Map((policy.allocations ?? []).map(a => [a.id, a.displayName]));
|
|
48
|
+
const who = ids => ids.map(id => names.get(id) ?? id).join(t('common.listSep'));
|
|
49
|
+
const needle = query.trim().toLowerCase();
|
|
50
|
+
const matches = row => !needle || [row.key, who(row.primaryFor ?? []), who(row.fallbackFor ?? [])].join(' ').toLowerCase().includes(needle);
|
|
51
|
+
const groups = new Map();
|
|
52
|
+
for (const row of rows) {
|
|
53
|
+
const tier = row.tiers?.[0] ?? 'unknown';
|
|
54
|
+
if (!groups.has(tier)) groups.set(tier, []);
|
|
55
|
+
groups.get(tier).push(row);
|
|
56
|
+
}
|
|
57
|
+
const toggle = (key, enabled) => {
|
|
58
|
+
setDraft(old => enabled ? old.filter(k => k !== key) : [...new Set([...old, key])]);
|
|
59
|
+
setStatus({});
|
|
60
|
+
};
|
|
61
|
+
const submit = async () => {
|
|
62
|
+
setStatus({ busy: true });
|
|
63
|
+
try {
|
|
64
|
+
const result = await save(sessionId, draft, revision);
|
|
65
|
+
setRevision(result.revision); setSaved(result.disabledRoutes); setDraft(result.disabledRoutes);
|
|
66
|
+
setStatus({ success: true });
|
|
67
|
+
} catch (error) { setStatus({ error: String(error.message ?? error) }); }
|
|
68
|
+
};
|
|
69
|
+
const modelRow = (row, showTier) => {
|
|
70
|
+
const enabled = !isOff(row.key);
|
|
71
|
+
const rule = enabled ? null : ruleFor(row.key);
|
|
72
|
+
const usage = [
|
|
73
|
+
row.primaryFor?.length ? chip(t('models.primary'), who(row.primaryFor), 'p') : null,
|
|
74
|
+
row.fallbackFor?.length ? chip(t('models.fallback'), who(row.fallbackFor), 'f') : null,
|
|
75
|
+
].filter(Boolean);
|
|
76
|
+
return h('label', { key: row.key, className: 'ctm-ov-model', ...(enabled ? {} : { 'data-off': '' }) },
|
|
77
|
+
h('input', { type: 'checkbox', className: 'ctm-switch', role: 'switch', checked: enabled, disabled: status.busy || !!rule, title: rule ? fmt(t('models.byRule'), { rule }) : undefined, 'aria-label': `${t('models.enabled')} ${row.key}`,
|
|
78
|
+
onChange: e => toggle(row.key, e.target.checked) }),
|
|
79
|
+
h('div', { className: 'ctm-ov-model-main' },
|
|
80
|
+
h('div', { className: 'ctm-ov-model-name' },
|
|
81
|
+
h('span', { className: 'ctm-name' }, row.model ?? row.key),
|
|
82
|
+
row.provider ? h('span', { className: 'ctm-sub' }, row.provider) : null,
|
|
83
|
+
showTier && row.tiers?.[0] ? badge(label(t, `tier.${row.tiers[0]}`, row.tiers[0]), undefined, 'tier') : null,
|
|
84
|
+
enabled ? null : badge(rule ? fmt(t('models.byRule'), { rule }) : t('models.disabledBadge'), 'warn', 'off')),
|
|
85
|
+
usage.length ? h('div', { className: 'ctm-chips' }, ...usage) : h('span', { className: 'ctm-sub' }, t('models.unused'))));
|
|
86
|
+
};
|
|
87
|
+
const orphans = draft.filter(key => !isRule(key) && !rows.some(row => row.key === key));
|
|
88
|
+
const candidate = toRule(prefix);
|
|
89
|
+
const preview = candidate ? rows.filter(row => ruleCovers(candidate, row.key)) : [];
|
|
90
|
+
const addRule = () => {
|
|
91
|
+
if (!candidate || draft.includes(candidate)) return;
|
|
92
|
+
setDraft(old => [...new Set([...old, candidate])]); setPrefix(''); setStatus({});
|
|
93
|
+
};
|
|
94
|
+
const removeRule = rule => { setDraft(old => old.filter(k => k !== rule)); setStatus({}); };
|
|
95
|
+
const tiers = [...groups.keys()].sort((a, b) => tierRank(a) - tierRank(b));
|
|
96
|
+
// Large catalogs (hundreds of routes): open on one tier, list routes in use first, render in pages.
|
|
97
|
+
const activeTier = tierTab === 'all' || groups.has(tierTab) ? tierTab : tiers[0] ?? 'all';
|
|
98
|
+
const scoped = activeTier === 'all' ? tiers.flatMap(tier => groups.get(tier)) : groups.get(activeTier);
|
|
99
|
+
const wasOff = ruleMatcher(saved);
|
|
100
|
+
const weight = row => row.primaryFor?.length ? 0 : row.fallbackFor?.length ? 1 : wasOff(row.key) ? 2 : 3;
|
|
101
|
+
const shown = scoped.filter(matches).map((row, index) => ({ row, index })).sort((a, b) => weight(a.row) - weight(b.row) || a.index - b.index).map(x => x.row);
|
|
102
|
+
const visible = shown.slice(0, limit);
|
|
103
|
+
const tierTabButton = (id, text, list) => {
|
|
104
|
+
const count = list.filter(row => isOff(row.key)).length;
|
|
105
|
+
return h('button', { key: id, type: 'button', role: 'tab', className: 'ctm-ov-tier', 'aria-selected': activeTier === id, onClick: () => { setTierTab(id); setLimit(PAGE); } },
|
|
106
|
+
text, h('span', { className: 'ctm-ov-count' }, String(list.length)), count ? h('span', { className: 'ctm-ov-offdot', title: fmt(t('models.tierCount'), { n: list.length, d: count }) }) : null);
|
|
107
|
+
};
|
|
108
|
+
return h('div', { className: 'ctm-ov-editor' },
|
|
109
|
+
h('div', { className: 'ctm-ov-toolbar' },
|
|
110
|
+
h('button', { type: 'button', className: 'ctm-btn', 'data-primary': '', disabled: !pending || status.busy, onClick: submit }, t(status.busy ? 'models.saving' : 'models.save')),
|
|
111
|
+
pending && !status.busy ? h('button', { type: 'button', className: 'ctm-btn', onClick: () => { setDraft(saved); setStatus({}); } }, t('models.reset')) : null,
|
|
112
|
+
status.error ? h('span', { role: 'alert', className: 'ctm-ov-note ctm-error' }, status.error)
|
|
113
|
+
: status.success ? h('span', { role: 'status', className: 'ctm-ov-note ctm-success' }, t('models.saved'))
|
|
114
|
+
: pending ? h('span', { className: 'ctm-ov-note ctm-warn' }, fmt(t('models.dirty'), { n: pending })) : null),
|
|
115
|
+
h('p', { className: 'ctm-sub ctm-ov-help' }, t('models.help')),
|
|
116
|
+
h('div', { className: 'ctm-ov-block' },
|
|
117
|
+
h('div', { className: 'ctm-label' }, t('models.preview')),
|
|
118
|
+
revision === policy.revision
|
|
119
|
+
? h('div', { className: 'ctm-ov-allocs' }, ...(policy.allocations ?? []).map(role => h('div', { key: role.id, className: 'ctm-ov-alloc', 'data-status': role.status, title: role.reason ?? '' },
|
|
120
|
+
h('span', { className: 'ctm-name' }, role.displayName),
|
|
121
|
+
role.primary ? h('div', { className: 'ctm-ov-chain' },
|
|
122
|
+
h('span', { className: 'ctm-ov-hop', 'data-primary': '', title: role.primary }, role.primary),
|
|
123
|
+
...(role.fallbacks ?? []).flatMap((route, i) => [h('span', { key: 'a' + i, className: 'ctm-ov-arrow', 'aria-hidden': true }, '›'), h('span', { key: 'r' + i, className: 'ctm-ov-hop', title: route }, route)]))
|
|
124
|
+
: h('span', { className: 'ctm-ov-route ctm-warn' }, t('models.' + role.status)))))
|
|
125
|
+
: h('p', { className: 'ctm-sub' }, t('models.refreshPreview'))),
|
|
126
|
+
rows.length > 6 ? h('input', { type: 'search', className: 'ctm-ov-search', placeholder: t('models.filter'), value: query, onChange: e => { setQuery(e.target.value); setLimit(PAGE); } }) : null,
|
|
127
|
+
h('div', { className: 'ctm-ov-block ctm-ov-rules' },
|
|
128
|
+
h('div', { className: 'ctm-label' }, t('models.prefixTitle')),
|
|
129
|
+
h('div', { className: 'ctm-ov-rule-input' },
|
|
130
|
+
h('input', { type: 'text', className: 'ctm-ov-search', placeholder: t('models.prefixPlaceholder'), value: prefix, disabled: status.busy, 'aria-label': t('models.prefixTitle'),
|
|
131
|
+
onChange: e => setPrefix(e.target.value), onKeyDown: e => { if (e.key === 'Enter') { e.preventDefault(); addRule(); } } }),
|
|
132
|
+
h('button', { type: 'button', className: 'ctm-btn', disabled: !candidate || draft.includes(candidate) || status.busy, onClick: addRule }, t('models.prefixAdd'))),
|
|
133
|
+
prefix.trim() ? h('p', { className: candidate && preview.length ? 'ctm-sub' : 'ctm-sub ctm-warn' },
|
|
134
|
+
!candidate ? t('models.prefixInvalid')
|
|
135
|
+
: draft.includes(candidate) ? t('models.prefixExists')
|
|
136
|
+
: fmt(t('models.prefixPreview'), { rule: candidate, n: preview.length, list: preview.slice(0, 3).map(row => row.key).join(t('common.listSep')) + (preview.length > 3 ? '…' : '') })) : null,
|
|
137
|
+
rules.length ? h('div', { className: 'ctm-chips' }, ...rules.map(rule => h('span', { key: rule, className: 'ctm-chip ctm-ov-rule' },
|
|
138
|
+
h('span', null, rule), h('span', null, fmt(t('models.ruleCount'), { n: rows.filter(row => ruleCovers(rule, row.key)).length })),
|
|
139
|
+
h('button', { type: 'button', className: 'ctm-ov-rule-x', disabled: status.busy, 'aria-label': fmt(t('models.ruleRemove'), { rule }), onClick: () => removeRule(rule) }, '×'))))
|
|
140
|
+
: h('p', { className: 'ctm-sub' }, t('models.prefixHelp'))),
|
|
141
|
+
tiers.length ? h('div', { className: 'ctm-ov-tiers', role: 'tablist', 'aria-label': t('models.tiers') },
|
|
142
|
+
tierTabButton('all', t('models.allTiers'), rows), ...tiers.map(tier => tierTabButton(tier, label(t, `tier.${tier}`, tier), groups.get(tier)))) : null,
|
|
143
|
+
shown.length ? h('div', { className: 'ctm-ov-group' }, h('div', { className: 'ctm-ov-list ctm-ov-list-flat' }, ...visible.map(row => modelRow(row, activeTier === 'all'))))
|
|
144
|
+
: rows.length ? h('p', { className: 'ctm-sub' }, t('models.noMatch')) : null,
|
|
145
|
+
shown.length > visible.length ? h('button', { type: 'button', className: 'ctm-btn ctm-ov-more', onClick: () => setLimit(n => n + PAGE * 2) },
|
|
146
|
+
fmt(t('models.more'), { n: shown.length - visible.length })) : null,
|
|
147
|
+
orphans.length ? h('details', { className: 'ctm-ov-group', open: true },
|
|
148
|
+
h('summary', null, h('span', { className: 'ctm-name' }, t('models.orphans')), h('span', { className: 'ctm-sub' }, String(orphans.length))),
|
|
149
|
+
h('div', { className: 'ctm-ov-list' }, ...orphans.map(key => modelRow({ key })))) : null);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Live polling re-renders the overview every few seconds; the editor only changes with its own data.
|
|
153
|
+
const PolicyEditor = React.memo ? React.memo(ModelPolicyEditor, (a, b) => a.sessionId === b.sessionId && a.policy === b.policy
|
|
154
|
+
&& a.save === b.save && (a.t === b.t || a.t('models.save') === b.t('models.save'))) : ModelPolicyEditor;
|
|
155
|
+
|
|
156
|
+
function RoleCards({ roles, live, t }) {
|
|
157
|
+
return h('div', { className: 'ctm-ov-roles' }, ...localizeRoles(roles, t).map(r => {
|
|
158
|
+
const fallbacks = (Array.isArray(r.fallbacks) ? r.fallbacks : []).map(routeText).filter(Boolean);
|
|
159
|
+
return h('div', { key: r.id, className: 'ctm-ov-role' },
|
|
160
|
+
h('div', { className: 'ctm-ov-role-head' },
|
|
161
|
+
h('span', { className: 'ctm-name' }, r.displayName ?? r.id),
|
|
162
|
+
r.tier ? badge(label(t, `tier.${r.tier}`, r.tier), r.tier === 'high' ? 'brand' : undefined, 'tier') : null,
|
|
163
|
+
r.singleModel ? h('span', { className: 'ctm-badge', 'data-tone': 'warn', 'data-single-model': '', title: t('roles.singleModelHint') }, t('roles.singleModel')) : null),
|
|
164
|
+
h('span', { className: 'ctm-sub' }, [r.id, r.capability].filter(Boolean).join(' · ')),
|
|
165
|
+
r.description ? h('span', { className: 'ctm-ov-desc' }, r.description) : null,
|
|
166
|
+
r.whenToUse ? h('span', { className: 'ctm-sub' }, fmt(t('roles.whenToUse'), { text: r.whenToUse })) : null,
|
|
167
|
+
h('span', { className: r.provider ? 'ctm-ov-route' : 'ctm-ov-route ctm-warn', title: r.blocked },
|
|
168
|
+
r.provider ? `${r.provider}/${r.model}` : t(r.blocked ? 'models.blocked' : 'team.unassigned')),
|
|
169
|
+
liveRoleLine(live?.get(r.id), t, r),
|
|
170
|
+
fallbacks.length ? h('span', { className: 'ctm-sub', title: fallbacks.join('\n') }, fmt(t('roles.fallbackList'), { list: fallbacks.join(t('common.listSep')) })) : null,
|
|
171
|
+
r.maxTokens || r.longContext || r.planFirst ? h('div', { className: 'ctm-chips' },
|
|
172
|
+
r.planFirst ? badge(t('roles.planFirst'), 'brand', 'p') : null,
|
|
173
|
+
r.maxTokens ? badge(fmt(t('roles.maxTokens'), { n: compact(r.maxTokens) }), undefined, 'm') : null,
|
|
174
|
+
r.longContext ? badge(fmt(t('roles.longContext'), { n: compact(r.longContext.thresholdTokens), route: `${r.longContext.provider}/${r.longContext.model}` }), undefined, 'l') : null) : null,
|
|
175
|
+
accessBadges(r.access, t));
|
|
176
|
+
}));
|
|
177
|
+
}
|
|
178
|
+
/** The member currently filling this role: live status and the model of its latest request. */
|
|
179
|
+
function liveRoleLine(member, t, role) {
|
|
180
|
+
if (!member) return null;
|
|
181
|
+
const route = member.model ? [member.provider, member.model].filter(Boolean).join('/') : null;
|
|
182
|
+
return h('div', { className: 'ctm-chips' },
|
|
183
|
+
badge(label(t, `live.${member.status}`, member.status), LIVE[member.status], 's'),
|
|
184
|
+
route ? h('span', { className: 'ctm-sub', title: t('roles.liveModel') }, fmt(t('roles.current'), { route })) : null,
|
|
185
|
+
route && routeOutsideConfig(route, role) ? routeWarnBadge(t) : null);
|
|
186
|
+
}
|
|
187
|
+
/** Permission chips for one role; nothing when the capability has no access policy. */
|
|
188
|
+
function accessBadges(access, t) {
|
|
189
|
+
if (!access || typeof access !== 'object') return null;
|
|
190
|
+
const items = [];
|
|
191
|
+
if (Array.isArray(access.editPaths)) items.push(access.editPaths.length ? fmt(t('roles.editOnly'), { list: access.editPaths.join(t('common.listSep')) }) : t('roles.noEdit'));
|
|
192
|
+
if (access.preset) items.push(fmt(t('roles.preset'), { name: access.preset }));
|
|
193
|
+
else if (access.shell === false) items.push(t('roles.noShell'));
|
|
194
|
+
if (access.deny?.length) items.push(fmt(t('roles.deny'), { list: access.deny.join(t('common.listSep')) }));
|
|
195
|
+
return items.length ? h('div', { className: 'ctm-chips', title: t('roles.accessHint') }, ...items.map((text, i) => badge(text, 'warn', 'a' + i))) : null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function WorkflowList({ workflow, t }) {
|
|
199
|
+
if (!workflow || typeof workflow !== 'object') return h('span', { className: 'ctm-sub' }, '—');
|
|
200
|
+
const value = (key, v) => {
|
|
201
|
+
if (key === 'mode') return label(t, `workflow.mode.${v}`, String(v));
|
|
202
|
+
if (key === 'freeTools') return label(t, `tools.${v}`, String(v));
|
|
203
|
+
if (key === 'analysisBudget' || key === 'directBudget') return fmt(t('workflow.times'), { n: v });
|
|
204
|
+
if (key === 'freeModels' && Array.isArray(v) && !v.length) return t('workflow.allFree');
|
|
205
|
+
if (key === 'tokens') return v ? fmt(t('workflow.tokens.value'), { ratio: Math.round(v.idleCompactRatio * 100), chars: v.trimToolOutputChars }) : t('workflow.fileLimits.off');
|
|
206
|
+
if (key === 'enforceWriteScopes') return t(v ? 'workflow.enforceWriteScopes.on' : 'workflow.enforceWriteScopes.off');
|
|
207
|
+
if (key === 'confirmDispatch') return label(t, `workflow.confirmDispatch.${v}`, String(v));
|
|
208
|
+
if (key === 'rotation') return v ? fmt(t('workflow.rotation.value'), { soft: v.softCompactions, hard: v.hardCompactions, ratio: Math.round(v.hardRatio * 100), turns: v.hardTurns }) : t('workflow.fileLimits.off');
|
|
209
|
+
if (key === 'fileLimits') return v ? fmt(t('workflow.fileLimits.value'), { source: v.sourceMax, test: v.testMax, read: v.readMax, report: v.reportMax }) : t('workflow.fileLimits.off');
|
|
210
|
+
if (Array.isArray(v)) return h('div', { className: 'ctm-chips' }, ...v.map(item => h('span', { key: String(item), className: 'ctm-chip' }, String(item))));
|
|
211
|
+
return typeof v === 'object' ? h('pre', { className: 'ctm-pre', 'data-short': '' }, JSON.stringify(v, null, 2)) : String(v);
|
|
212
|
+
};
|
|
213
|
+
return h('div', { className: 'ctm-grid ctm-ov-kv' }, ...Object.entries(workflow).flatMap(([key, v]) => [
|
|
214
|
+
h('span', { key: `k${key}`, className: 'ctm-sub' }, label(t, `workflow.${key}`, key)),
|
|
215
|
+
h('div', { key: `v${key}`, className: 'ctm-text' }, value(key, v)),
|
|
216
|
+
]));
|
|
217
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
const billable = u => (u?.uncachedInputTokens ?? 0) + (u?.cacheWriteTokens ?? 0) + (u?.outputTokens ?? 0);
|
|
2
|
+
const isUnknownUsage = row => !row || !row.totals || row.coverage === 'unknown' || row.reportedSamples === 0;
|
|
3
|
+
const usageTotal = rows => {
|
|
4
|
+
const known = (rows ?? []).filter(r => !isUnknownUsage(r));
|
|
5
|
+
return known.length ? known.reduce((n, row) => n + billable(row.totals), 0) : null;
|
|
6
|
+
};
|
|
7
|
+
const compact = n => n >= 1e6 ? `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k` : String(n);
|
|
8
|
+
const splitRoute = route => { const i = route.indexOf('/'); return i > 0 ? { provider: route.slice(0, i), model: route.slice(i + 1) } : {}; };
|
|
9
|
+
const PHASE = { provisioning: 'provisioning', failed: 'failed' };
|
|
10
|
+
/** Roster and board tasks from the Lead's live `agentTeam` projection. Models are attached later by withTrustedRoutes. */
|
|
11
|
+
function teamActivityFrom(team, live) {
|
|
12
|
+
return {
|
|
13
|
+
members: (team.members ?? []).map(m => ({ id: m.id, name: m.name, role: m.role,
|
|
14
|
+
status: PHASE[m.phase] ?? (m.phase === 'active' ? (live.running(m.id) ? 'running' : 'idle') : 'inactive'),
|
|
15
|
+
diagnostics: m.error ? [String(m.error.message ?? m.error)] : [] })),
|
|
16
|
+
tasks: (team.tasks ?? []).map(task => ({
|
|
17
|
+
id: task.id,
|
|
18
|
+
subject: task.subject,
|
|
19
|
+
status: task.status,
|
|
20
|
+
...(task.ownerName ? { ownerName: task.ownerName } : {}),
|
|
21
|
+
...(Array.isArray(task.blockedBy) ? { blockedBy: task.blockedBy } : task.blockedBy ? { blockedBy: [task.blockedBy] } : {}),
|
|
22
|
+
...(task.revision != null ? { revision: task.revision } : {}),
|
|
23
|
+
})),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* One usage row per member from DSH's own live projections: totals from token-meter's `tokenUsage`, timing from
|
|
28
|
+
* `sessionStats`, the current model from trustedRoute (own usage routes). Per-model token splits come from the last refresh.
|
|
29
|
+
*/
|
|
30
|
+
function usageRows(d, liveTeam, live) {
|
|
31
|
+
const snapshot = new Map((d?.memberEvidence?.length ? d.memberEvidence : d ? [{ sessionId: d.sessionId, name: 'lead', ...(d.requestEvidence ?? {}) }] : [])
|
|
32
|
+
.filter(m => m.usage).map(m => [m.sessionId, m]));
|
|
33
|
+
const members = liveTeam?.members ?? [...snapshot.values()].map(m => ({ id: m.sessionId, name: m.name }));
|
|
34
|
+
const refreshedAt = d?.at;
|
|
35
|
+
return members.map(m => {
|
|
36
|
+
const values = liveTeam ? live.projections(m.id) : undefined;
|
|
37
|
+
const before = snapshot.get(m.id)?.usage;
|
|
38
|
+
const route = trustedRoute(values, before);
|
|
39
|
+
const ownUsage = values?.capabilityTeamUsage;
|
|
40
|
+
// Follow the backend usage contract: read capabilityTeamUsage / snapshot own usage, never host tokenUsage (fork skew)
|
|
41
|
+
const totals = ownUsage?.totals ?? before?.totals;
|
|
42
|
+
const routes = (ownUsage?.routes?.length ? ownUsage.routes : before?.routes) ?? [];
|
|
43
|
+
const coverage = ownUsage?.coverage ?? (before?.coverage ?? (totals ? 'complete' : 'unknown'));
|
|
44
|
+
const reportedSamples = ownUsage?.reportedSamples ?? before?.reportedSamples ?? (totals ? 1 : 0);
|
|
45
|
+
const attemptsWithoutUsage = ownUsage?.attemptsWithoutUsage ?? before?.attemptsWithoutUsage ?? 0;
|
|
46
|
+
return {
|
|
47
|
+
id: m.id, name: m.name, totals, routes, coverage, reportedSamples, attemptsWithoutUsage,
|
|
48
|
+
refreshedAt: before ? refreshedAt : undefined,
|
|
49
|
+
...(route ? { model: route } : {}),
|
|
50
|
+
...(values?.sessionStats ? { stats: values.sessionStats } : {})
|
|
51
|
+
};
|
|
52
|
+
}).filter(row => row.totals || row.routes.length || row.stats || row.model);
|
|
53
|
+
}
|
|
54
|
+
/** Average model seconds per step, time to first token, and decode speed from one session's `sessionStats`. */
|
|
55
|
+
function statsLine(stats, t) {
|
|
56
|
+
if (!stats?.steps) return null;
|
|
57
|
+
const parts = [fmt(t('usage.step'), { n: stats.steps, s: (stats.llmMs / stats.steps / 1000).toFixed(1) })];
|
|
58
|
+
if (stats.ttftSteps) parts.push(fmt(t('usage.ttft'), { s: (stats.ttftMs / stats.ttftSteps / 1000).toFixed(1) }));
|
|
59
|
+
if (stats.decodeMs) parts.push(fmt(t('usage.decode'), { n: Math.round(stats.decodeTokens / (stats.decodeMs / 1000)) }));
|
|
60
|
+
return parts.join(' · ');
|
|
61
|
+
}
|
|
62
|
+
const STEP_CONTEXT_WARN = 80000; // average prompt per step above this usually means the session should rotate
|
|
63
|
+
/** Average prompt tokens per step and the cache-read share of the prompt, from own usage and step stats. */
|
|
64
|
+
function contextMetrics(totals, stats) {
|
|
65
|
+
if (!totals || !stats?.steps) return null;
|
|
66
|
+
const prompt = (totals.uncachedInputTokens ?? 0) + (totals.cacheReadTokens ?? 0) + (totals.cacheWriteTokens ?? 0);
|
|
67
|
+
if (!prompt) return null;
|
|
68
|
+
return { avgPrompt: Math.round(prompt / stats.steps), cacheShare: (totals.cacheReadTokens ?? 0) / prompt, heavy: prompt / stats.steps >= STEP_CONTEXT_WARN };
|
|
69
|
+
}
|
|
70
|
+
/** Per-member token usage, each role's budget when its capability sets maxTokens, and model time per step. */
|
|
71
|
+
function UsageGroups({ rows: members, roles, t }) {
|
|
72
|
+
const byId = new Map(roles.map(r => [r.id, r]));
|
|
73
|
+
const top = Math.max(1, ...members.map(m => isUnknownUsage(m) ? 0 : billable(m.totals)));
|
|
74
|
+
return h('div', { className: 'ctm-ov-block' },
|
|
75
|
+
h('p', { className: 'ctm-sub ctm-ov-help' }, t('usage.hint')),
|
|
76
|
+
members.length ? h('span', { className: 'ctm-label' }, fmt(t('usage.total'), { n: usageTotal(members) == null ? t('usage.unknown') : compact(usageTotal(members)) })) : null,
|
|
77
|
+
...(members.length ? members.map((m, index) => {
|
|
78
|
+
const role = byId.get(m.name) ?? byId.get(baseName(m.name));
|
|
79
|
+
// Same contract as usageTotal and cardMeters: the server sends zeroed totals with coverage 'unknown'.
|
|
80
|
+
const used = isUnknownUsage(m) ? null : billable(m.totals);
|
|
81
|
+
const max = role?.maxTokens;
|
|
82
|
+
const ratio = max && used !== null ? Math.min(1, used / max) : (used !== null ? used / top : 0);
|
|
83
|
+
const tone = max && used !== null && used >= max ? 'error' : max && ratio >= 0.8 ? 'warn' : undefined;
|
|
84
|
+
const timing = statsLine(m.stats, t);
|
|
85
|
+
const ctxm = used === null ? null : contextMetrics(m.totals, m.stats);
|
|
86
|
+
const freshness = m.refreshedAt ? fmt(t('usage.asOf'), { time: timeOfDay(m.refreshedAt) }) : null;
|
|
87
|
+
return h('details', { key: m.id ?? index, className: 'ctm-ov-group', open: index === 0 },
|
|
88
|
+
h('summary', null,
|
|
89
|
+
h('span', { className: 'ctm-name' }, role?.displayName ?? m.name),
|
|
90
|
+
used !== null
|
|
91
|
+
? h('span', { className: 'ctm-sub ctm-us-num' }, fmt(t('usage.billable'), { n: compact(used) }))
|
|
92
|
+
: h('span', { className: 'ctm-sub ctm-us-num' }, t('usage.unknown')),
|
|
93
|
+
m.coverage === 'partial' ? badge(t('usage.partialLow'), 'warn', 'partial') : null,
|
|
94
|
+
max ? badge(fmt(t('usage.budget'), { used: used !== null ? compact(used) : t('usage.unknown'), max: compact(max) }), tone ?? 'brand', 'b') : null),
|
|
95
|
+
used !== null
|
|
96
|
+
? h('div', { className: 'ctm-us-meter', 'data-tone': tone, role: 'presentation' }, h('i', { style: { width: `${(ratio * 100).toFixed(1)}%` } }))
|
|
97
|
+
: null,
|
|
98
|
+
tone === 'error' ? h('p', { className: 'ctm-ov-note ctm-error' }, t('usage.over')) : null,
|
|
99
|
+
ctxm ? h('div', { className: 'ctm-ov-log', title: t('usage.contextHint') },
|
|
100
|
+
h('span', { className: 'ctm-sub ctm-us-num' }, fmt(t('usage.avgPrompt'), { n: compact(ctxm.avgPrompt) })),
|
|
101
|
+
h('span', { className: 'ctm-sub ctm-us-num' }, fmt(t('usage.cacheShare'), { p: Math.round(ctxm.cacheShare * 100) })),
|
|
102
|
+
ctxm.heavy ? badge(t('usage.heavyContext'), 'warn', 'heavy') : null) : null,
|
|
103
|
+
m.model || m.stats ? h('div', { className: 'ctm-ov-log' },
|
|
104
|
+
m.model ? h('span', { className: 'ctm-ov-route', title: m.model }, m.model) : null,
|
|
105
|
+
timing ? h('span', { className: 'ctm-sub ctm-us-num', title: t('usage.stepHint') }, timing) : null) : null,
|
|
106
|
+
freshness ? h('div', { className: 'ctm-sub ctm-freshness' }, freshness) : null,
|
|
107
|
+
m.routes.length ? h('div', { className: 'ctm-ov-logs' }, ...m.routes.map(r => h('div', { key: r.route, className: 'ctm-ov-log' },
|
|
108
|
+
h('span', { className: 'ctm-ov-route', title: r.route }, r.route),
|
|
109
|
+
h('span', { className: 'ctm-sub ctm-us-num' }, fmt(t('usage.io'), { i: compact(r.uncachedInputTokens + r.cacheWriteTokens), o: compact(r.outputTokens) })),
|
|
110
|
+
r.cacheReadTokens ? h('span', { className: 'ctm-sub ctm-us-num' }, fmt(t('usage.cacheRead'), { n: compact(r.cacheReadTokens) })) : null)))
|
|
111
|
+
: m.model || m.stats ? null : h('p', { className: 'ctm-sub' }, t('usage.none')));
|
|
112
|
+
}) : [h('p', { key: 'none', className: 'ctm-sub' }, t('usage.none'))]));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Live per-member request summary: the model of the latest request and how many steps it took so far. */
|
|
116
|
+
function LiveRequests({ rows, t }) {
|
|
117
|
+
const list = rows.filter(row => row.model || row.stats);
|
|
118
|
+
if (!list.length) return null;
|
|
119
|
+
return h('div', { className: 'ctm-ov-block' },
|
|
120
|
+
h('span', { className: 'ctm-label' }, t('logs.live')),
|
|
121
|
+
h(CollapsibleList, {
|
|
122
|
+
items: list,
|
|
123
|
+
limit: 5,
|
|
124
|
+
className: 'ctm-ov-logs',
|
|
125
|
+
t,
|
|
126
|
+
renderItem: row => h('div', { key: row.id, className: 'ctm-ov-log' },
|
|
127
|
+
h('span', { className: 'ctm-name' }, row.name),
|
|
128
|
+
row.model ? h('span', { className: 'ctm-ov-route', title: row.model }, row.model) : null,
|
|
129
|
+
row.stats?.steps ? h('span', { className: 'ctm-sub ctm-us-num' }, fmt(t('logs.steps'), { n: row.stats.steps })) : null),
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function EvidenceGroups({ d, sessionId, t }) {
|
|
134
|
+
const groups = d.memberEvidence?.length ? d.memberEvidence : [{ sessionId, name: '', ...(d.requestEvidence ?? {}) }];
|
|
135
|
+
return h('div', { className: 'ctm-ov-block' },
|
|
136
|
+
h('p', { className: 'ctm-sub ctm-ov-help' }, t('evidence.hint')),
|
|
137
|
+
...groups.map((member, index) => {
|
|
138
|
+
const records = member.records ?? [];
|
|
139
|
+
const self = member.sessionId === sessionId;
|
|
140
|
+
return h('details', { key: member.sessionId ?? index, className: 'ctm-ov-group', open: index === 0 },
|
|
141
|
+
h('summary', null,
|
|
142
|
+
h('span', { className: 'ctm-name' }, member.name || t('logs.thisSession')),
|
|
143
|
+
self && member.name ? badge(t('logs.thisSession'), 'brand', 'self') : null,
|
|
144
|
+
h('span', { className: 'ctm-sub' }, fmt(t('logs.count'), { n: records.length }))),
|
|
145
|
+
member.unavailable ? h('p', { className: 'ctm-ov-note ctm-warn' }, member.unavailable) : null,
|
|
146
|
+
records.length ? h(CollapsibleList, {
|
|
147
|
+
items: records,
|
|
148
|
+
limit: 5,
|
|
149
|
+
className: 'ctm-ov-logs',
|
|
150
|
+
t,
|
|
151
|
+
renderItem: record => h('div', { key: record.seq, className: 'ctm-ov-log' },
|
|
152
|
+
h('span', { className: 'ctm-sub ctm-ov-seq' }, `#${record.seq}`),
|
|
153
|
+
h('span', { className: 'ctm-ov-route', title: `${record.provider}/${record.model}` }, `${record.provider}/${record.model}`),
|
|
154
|
+
badge(t('evidence.short.' + record.evidence), record.evidence === 'assistant-message' ? 'success' : undefined, 'kind'),
|
|
155
|
+
record.interrupted ? badge(t('logs.interrupted'), 'warn', 'int') : null,
|
|
156
|
+
h('span', { className: 'ctm-sub ctm-ov-time', title: t('evidence.' + record.evidence) }, when(record.time))),
|
|
157
|
+
})
|
|
158
|
+
: h('p', { className: 'ctm-sub' }, t('evidence.empty')));
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Live roster without any logged command: Lead + direct children from the read-only subagent catalog,
|
|
164
|
+
* running/idle from host-pushed agent status, names/models/tasks from the last manual snapshot.
|
|
165
|
+
*/
|
|
166
|
+
/** Pushed status wins; otherwise the catalog/snapshot, never trusting a possibly stale "running". */
|
|
167
|
+
function memberStatus(member, push, fresh) {
|
|
168
|
+
if (push) {
|
|
169
|
+
if (push.error && !push.running) return 'failed';
|
|
170
|
+
return push.running ? 'running' : 'idle';
|
|
171
|
+
}
|
|
172
|
+
if (member.resident === false) return 'inactive';
|
|
173
|
+
if (member.status === 'running' && !fresh) return 'idle';
|
|
174
|
+
return member.status ?? (member.resident ? 'idle' : 'inactive');
|
|
175
|
+
}
|
|
176
|
+
function liveRoster(leadId, snapshot, children, live, snapshotAt) {
|
|
177
|
+
// A snapshot status is trusted only if no push could have been missed since it was taken.
|
|
178
|
+
const fresh = !live || (snapshotAt ?? 0) >= live.startedAt;
|
|
179
|
+
if (!snapshot && !children) return null;
|
|
180
|
+
const known = new Map((snapshot?.members ?? []).map(m => [m.id, m]));
|
|
181
|
+
const rows = children
|
|
182
|
+
? [known.get(leadId) ?? { id: leadId, name: 'lead', role: 'lead', diagnostics: [] },
|
|
183
|
+
...children.filter(c => c.kind === 'child').map(c => ({ ...(known.get(c.id) ?? { name: c.label ?? c.id, role: 'teammate', diagnostics: [] }), id: c.id, resident: c.activity === 'running' }))]
|
|
184
|
+
: snapshot.members ?? [];
|
|
185
|
+
const members = rows.map(m => {
|
|
186
|
+
const push = live?.status?.(m.id);
|
|
187
|
+
const status = memberStatus(m, push, fresh);
|
|
188
|
+
return { ...m, status, since: push?.running ? push.since : undefined,
|
|
189
|
+
diagnostics: push?.error && !push.running ? [push.error, ...(m.diagnostics ?? [])] : m.diagnostics ?? [] };
|
|
190
|
+
});
|
|
191
|
+
return { members, tasks: snapshot?.tasks ?? [], unavailable: snapshot?.unavailable, at: live ? Date.now() : undefined };
|
|
192
|
+
}
|
|
193
|
+
const LIVE = { running: 'brand', provisioning: 'brand', idle: 'success', failed: 'error', inactive: undefined };
|
|
194
|
+
const elapsed = ms => { const s = Math.max(0, Math.floor(ms / 1000)); return s < 60 ? `${s}s` : `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; };
|
|
195
|
+
/** Join configured roles with live roster rows: Lead by role, teammates by their spawn name (= role id). */
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// ---------- which model a member is shown with ----------
|
|
2
|
+
// The host's listMembers model is not kept in sync with real requests, so it is never displayed. Priority:
|
|
3
|
+
// 1) the route of the latest request header, from this plugin's own capabilityTeamUsage.lastRoute
|
|
4
|
+
// (live projection, else the last refresh) — the model a request was actually sent to, failover included;
|
|
5
|
+
// 2) older checkpoints without lastRoute: the recorded usage routes, with modelSelection.next accepted only
|
|
6
|
+
// when it is one of them;
|
|
7
|
+
// 3) otherwise nothing here, and the board falls back to the configured primary, labelled "config".
|
|
8
|
+
// The Lead follows the user's pick (lead-route.js); before its first request it shows that pick, labelled as such.
|
|
9
|
+
const isRouteKey = route => typeof route === 'string' && route !== 'unknown' && route.indexOf('/') > 0;
|
|
10
|
+
/** Routes this member actually sent requests to, in the order the plugin first recorded them. */
|
|
11
|
+
function recordedRoutes(values, snapshotUsage) {
|
|
12
|
+
const own = values?.capabilityTeamUsage?.routes;
|
|
13
|
+
const rows = (own?.length ? own : snapshotUsage?.routes) ?? [];
|
|
14
|
+
return rows.map(row => row?.route).filter(isRouteKey);
|
|
15
|
+
}
|
|
16
|
+
/** Route of the latest request header, or null when none is recorded. */
|
|
17
|
+
function latestRequestRoute(values, snapshotUsage) {
|
|
18
|
+
const last = values?.capabilityTeamUsage?.lastRoute ?? snapshotUsage?.lastRoute;
|
|
19
|
+
return isRouteKey(last) ? last : null;
|
|
20
|
+
}
|
|
21
|
+
/** Latest trusted route for one member, or null when the plugin has not recorded a request yet. */
|
|
22
|
+
function trustedRoute(values, snapshotUsage) {
|
|
23
|
+
const latest = latestRequestRoute(values, snapshotUsage);
|
|
24
|
+
if (latest) return latest;
|
|
25
|
+
const routes = recordedRoutes(values, snapshotUsage);
|
|
26
|
+
if (!routes.length) return null;
|
|
27
|
+
const next = values?.modelSelection?.next;
|
|
28
|
+
const pick = next?.provider && next.model ? `${next.provider}/${next.model}` : null;
|
|
29
|
+
return pick && routes.includes(pick) ? pick : routes[routes.length - 1];
|
|
30
|
+
}
|
|
31
|
+
/** The Lead's actual route; before any recorded request, the model the user picked in the conversation. */
|
|
32
|
+
function leadRoute(values, snapshotUsage) {
|
|
33
|
+
const actual = trustedRoute(values, snapshotUsage);
|
|
34
|
+
if (actual) return { route: actual, from: 'request' };
|
|
35
|
+
const next = values?.modelSelection?.next;
|
|
36
|
+
return next?.provider && next.model ? { route: `${next.provider}/${next.model}`, from: 'pick' } : null;
|
|
37
|
+
}
|
|
38
|
+
/** Replace every roster member's model with its trusted route; members without one carry no model. */
|
|
39
|
+
function withTrustedRoutes(roster, live, d) {
|
|
40
|
+
if (!roster) return roster;
|
|
41
|
+
const evidence = new Map((d?.memberEvidence ?? []).map(m => [m.sessionId, m.usage]));
|
|
42
|
+
if (d?.sessionId && !evidence.has(d.sessionId) && d.requestEvidence?.usage) evidence.set(d.sessionId, d.requestEvidence.usage);
|
|
43
|
+
return { ...roster, members: (roster.members ?? []).map(m => {
|
|
44
|
+
const { provider: _hostProvider, model: _hostModel, ...rest } = m;
|
|
45
|
+
const values = live?.projections?.(m.id), usage = evidence.get(m.id);
|
|
46
|
+
const shown = m.role === 'lead' ? leadRoute(values, usage) : (r => r ? { route: r, from: 'request' } : null)(trustedRoute(values, usage));
|
|
47
|
+
return shown ? { ...rest, ...splitRoute(shown.route), modelFrom: shown.from } : rest;
|
|
48
|
+
}) };
|
|
49
|
+
}
|
|
50
|
+
/** Routes a role may legitimately use: its primary, declared fallbacks and long-context route. */
|
|
51
|
+
function allowedRoutes(role, allocation) {
|
|
52
|
+
const list = [allocation?.primary ?? (role?.provider ? `${role.provider}/${role.model}` : null),
|
|
53
|
+
...(allocation?.fallbacks ?? []), ...(Array.isArray(role?.fallbacks) ? role.fallbacks : []).map(routeText),
|
|
54
|
+
role?.longContext?.provider ? `${role.longContext.provider}/${role.longContext.model}` : null];
|
|
55
|
+
return [...new Set(list.filter(isRouteKey))];
|
|
56
|
+
}
|
|
57
|
+
const isLeadRole = role => role?.id === 'lead' || role?.capability === 'orchestrator';
|
|
58
|
+
/** True when a trusted route lies outside what the role is configured for; unknown config and the Lead never warn. */
|
|
59
|
+
function routeOutsideConfig(route, role, allocation) {
|
|
60
|
+
if (isLeadRole(role)) return false;
|
|
61
|
+
const allowed = allowedRoutes(role, allocation);
|
|
62
|
+
return !!(isRouteKey(route) && allowed.length && !allowed.includes(route));
|
|
63
|
+
}
|
|
64
|
+
/** Small warning marker for a model outside the role's primary and fallbacks. */
|
|
65
|
+
function routeWarnBadge(t) {
|
|
66
|
+
return h('span', { key: 'route-warn', className: 'ctm-badge', 'data-tone': 'warn', 'data-route-warn': '', title: t('live.routeWarnHint') }, t('live.routeWarn'));
|
|
67
|
+
}
|