@ran-sh/dsh-crew 0.3.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/.claude-plugin/marketplace.json +17 -0
- package/.claude-plugin/plugin.json +8 -0
- package/.mcp.json +8 -0
- package/LICENSE +21 -0
- package/README.de.md +359 -0
- package/README.es.md +359 -0
- package/README.fr.md +359 -0
- package/README.hi.md +359 -0
- package/README.id.md +359 -0
- package/README.ja.md +359 -0
- package/README.ko.md +359 -0
- package/README.md +360 -0
- package/README.pt.md +359 -0
- package/README.ru.md +359 -0
- package/README.th.md +359 -0
- package/README.tr.md +359 -0
- package/README.vi.md +359 -0
- package/README.zh-TW.md +359 -0
- package/README.zh.md +305 -0
- package/agents/ds-flash.md +26 -0
- package/agents/ds-pro.md +32 -0
- package/agents/ds-reviewer.md +23 -0
- package/agents/ds-worker.md +22 -0
- package/codex/agents/ds-flash.toml +30 -0
- package/codex/agents/ds-pro.toml +31 -0
- package/codex/agents/ds-reviewer.toml +28 -0
- package/codex/agents/ds-worker.toml +28 -0
- package/codex/prompts/dsh-config.md +3 -0
- package/codex/prompts/dsh-status.md +1 -0
- package/commands/config.md +11 -0
- package/commands/off.md +5 -0
- package/commands/on.md +5 -0
- package/commands/status.md +5 -0
- package/cordis.patch.yml +4 -0
- package/docs/images/dsh-crew-host.png +0 -0
- package/docs/images/dsh-crew-jobs.png +0 -0
- package/docs/images/dsh-crew-logo.png +0 -0
- package/docs/images/dsh-crew-overview.png +0 -0
- package/lib/client.js +2765 -0
- package/package.json +125 -0
- package/scripts/build-client.mjs +28 -0
- package/scripts/live-crew-smoke.mjs +39 -0
- package/scripts/live-policy-matrix.mjs +177 -0
- package/scripts/policy-probe.mjs +101 -0
- package/scripts/setup.mjs +294 -0
- package/scripts/smoke-real.mjs +110 -0
- package/scripts/smoke.mjs +78 -0
- package/scripts/verify-installer-fix.mjs +26 -0
- package/src/adaptive-routing.mjs +260 -0
- package/src/client/activation-summary.tsx +64 -0
- package/src/client/entry.tsx +236 -0
- package/src/client/index.tsx +1120 -0
- package/src/config-readiness.mjs +59 -0
- package/src/delivery.mjs +205 -0
- package/src/dsh-cli-runtime.mjs +251 -0
- package/src/failure-classification.mjs +172 -0
- package/src/hub/entry.mjs +98 -0
- package/src/hub/index.mjs +757 -0
- package/src/hub-client.mjs +132 -0
- package/src/hub-compatibility.mjs +49 -0
- package/src/i18n.mjs +19 -0
- package/src/install/cli.mjs +28 -0
- package/src/install/install-legacy.mjs +460 -0
- package/src/install/install.mjs +451 -0
- package/src/jobs.mjs +275 -0
- package/src/mcp-runtime.mjs +257 -0
- package/src/model-catalog.mjs +173 -0
- package/src/model-routing.mjs +391 -0
- package/src/multimodal.mjs +0 -0
- package/src/policy-legacy.mjs +830 -0
- package/src/policy.mjs +197 -0
- package/src/readiness-matrix.mjs +169 -0
- package/src/runtime-controls.mjs +90 -0
- package/src/runtime-identity.mjs +108 -0
- package/src/server.mjs +477 -0
- package/src/status-shard.mjs +52 -0
- package/src/structured-error-code.mjs +39 -0
- package/src/vision-route.mjs +138 -0
- package/src/workflow-runtime.mjs +567 -0
- package/src/workflow.mjs +160 -0
- package/src/workspace-audit.mjs +231 -0
- package/src/workspace-isolation.mjs +306 -0
- package/statusline/statusline.sh +14 -0
- package/statusline/worker-segment.sh +35 -0
- package/worker.cordis.yml +77 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// Opt-in adaptive model routing primitives.
|
|
2
|
+
//
|
|
3
|
+
// This module is deliberately pure/process-local: it never reads credentials,
|
|
4
|
+
// provider quota, billing data, persistent files, or DSH profile state. The
|
|
5
|
+
// caller may record only outcomes Crew already observed, and routing decisions
|
|
6
|
+
// expose bounded evidence suitable for model-selection trace metadata.
|
|
7
|
+
|
|
8
|
+
export const ADAPTIVE_ROUTING_VERSION = 1;
|
|
9
|
+
export const ADAPTIVE_ROUTING_DEFAULTS = Object.freeze({
|
|
10
|
+
enabled: false,
|
|
11
|
+
window_size: 8,
|
|
12
|
+
min_samples: 2,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const ADAPTIVE_ROUTING_REASON_CODES = Object.freeze({
|
|
16
|
+
DISABLED: 'DISABLED',
|
|
17
|
+
EXPLICIT_PRIORITY: 'EXPLICIT_PRIORITY',
|
|
18
|
+
NO_ELIGIBLE_CANDIDATES: 'NO_ELIGIBLE_CANDIDATES',
|
|
19
|
+
SINGLE_CANDIDATE: 'SINGLE_CANDIDATE',
|
|
20
|
+
INSUFFICIENT_HISTORY: 'INSUFFICIENT_HISTORY',
|
|
21
|
+
HEALTH_NEUTRAL: 'HEALTH_NEUTRAL',
|
|
22
|
+
HEALTH_REORDERED: 'HEALTH_REORDERED',
|
|
23
|
+
HEALTH_ORDER_UNCHANGED: 'HEALTH_ORDER_UNCHANGED',
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function clampInteger(value, fallback, min, max) {
|
|
27
|
+
const n = Number(value);
|
|
28
|
+
if (!Number.isInteger(n)) return fallback;
|
|
29
|
+
return Math.max(min, Math.min(max, n));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function normalizeAdaptiveRouting(raw = {}) {
|
|
33
|
+
const windowSize = clampInteger(raw?.window_size, ADAPTIVE_ROUTING_DEFAULTS.window_size, 1, 32);
|
|
34
|
+
return {
|
|
35
|
+
enabled: raw?.enabled === true,
|
|
36
|
+
window_size: windowSize,
|
|
37
|
+
min_samples: clampInteger(raw?.min_samples, ADAPTIVE_ROUTING_DEFAULTS.min_samples, 1, windowSize),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeRef(raw) {
|
|
42
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
43
|
+
const provider = typeof raw.provider === 'string' ? raw.provider.trim() : '';
|
|
44
|
+
const model = typeof raw.model === 'string' ? raw.model.trim() : '';
|
|
45
|
+
return provider && model ? { provider, model } : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function refKey(ref, role = 'worker') {
|
|
49
|
+
const normalized = normalizeRef(ref);
|
|
50
|
+
return normalized ? `${role === 'reviewer' ? 'reviewer' : 'worker'}\0${normalized.provider}\0${normalized.model}` : '';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeLatencyMs(value) {
|
|
54
|
+
const n = Number(value);
|
|
55
|
+
if (!Number.isFinite(n) || n < 0) return null;
|
|
56
|
+
// Bound pathological values; adaptive routing only needs a coarse signal.
|
|
57
|
+
return Math.min(Math.round(n), 3_600_000);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function latencyBucket(samples) {
|
|
61
|
+
const values = samples.map((sample) => sample.latency_ms).filter(Number.isFinite);
|
|
62
|
+
if (values.length === 0) return 'unknown';
|
|
63
|
+
const average = values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
64
|
+
if (average <= 30_000) return 'fast';
|
|
65
|
+
if (average <= 120_000) return 'medium';
|
|
66
|
+
return 'slow';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function outcomeKind(observation = {}) {
|
|
70
|
+
const stopReason = String(observation.stopReason ?? observation.stop_reason ?? '').toLowerCase();
|
|
71
|
+
if (observation.timed_out === true || stopReason === 'timeout' || stopReason === 'timed_out') return 'timeout';
|
|
72
|
+
const status = String(observation.status ?? '').toLowerCase();
|
|
73
|
+
if (status === 'done' || status === 'success' || status === 'completed') return 'success';
|
|
74
|
+
if (status === 'failed' || status === 'failure' || status === 'error') return 'failure';
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function healthScore(summary) {
|
|
79
|
+
let score = summary.successes * 4 - summary.failures * 3 - summary.timeouts * 6;
|
|
80
|
+
if (summary.latency_bucket === 'medium') score -= 1;
|
|
81
|
+
else if (summary.latency_bucket === 'slow') score -= 2;
|
|
82
|
+
return score;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createAdaptiveHealthStore({ maxSamples = 32 } = {}) {
|
|
86
|
+
const limit = clampInteger(maxSamples, 32, 1, 32);
|
|
87
|
+
const history = new Map();
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
record(ref, observation = {}) {
|
|
91
|
+
const key = refKey(ref, observation.role);
|
|
92
|
+
const outcome = outcomeKind(observation);
|
|
93
|
+
if (!key || !outcome) return false;
|
|
94
|
+
const samples = history.get(key) ?? [];
|
|
95
|
+
samples.push({
|
|
96
|
+
outcome,
|
|
97
|
+
latency_ms: normalizeLatencyMs(observation.latencyMs ?? observation.latency_ms),
|
|
98
|
+
});
|
|
99
|
+
if (samples.length > limit) samples.splice(0, samples.length - limit);
|
|
100
|
+
history.set(key, samples);
|
|
101
|
+
return true;
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
snapshot(ref, { role = 'worker', windowSize = ADAPTIVE_ROUTING_DEFAULTS.window_size } = {}) {
|
|
105
|
+
const key = refKey(ref, role);
|
|
106
|
+
const window = clampInteger(windowSize, ADAPTIVE_ROUTING_DEFAULTS.window_size, 1, 32);
|
|
107
|
+
const samples = key ? (history.get(key) ?? []).slice(-window) : [];
|
|
108
|
+
const summary = {
|
|
109
|
+
samples: samples.length,
|
|
110
|
+
successes: samples.filter((sample) => sample.outcome === 'success').length,
|
|
111
|
+
failures: samples.filter((sample) => sample.outcome === 'failure').length,
|
|
112
|
+
timeouts: samples.filter((sample) => sample.outcome === 'timeout').length,
|
|
113
|
+
latency_bucket: latencyBucket(samples),
|
|
114
|
+
};
|
|
115
|
+
return { ...summary, score: healthScore(summary) };
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
clear() {
|
|
119
|
+
history.clear();
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// One bounded store per Node.js process. Hub routing and its entry wrapper share
|
|
125
|
+
// this module instance, so a completed opt-in Hub attempt can influence a later
|
|
126
|
+
// opt-in Hub selection without writing any persistent state. Process restart is
|
|
127
|
+
// intentionally the reset boundary.
|
|
128
|
+
const PROCESS_ADAPTIVE_HEALTH = createAdaptiveHealthStore();
|
|
129
|
+
|
|
130
|
+
export function getProcessAdaptiveHealthStore() {
|
|
131
|
+
return PROCESS_ADAPTIVE_HEALTH;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function uniqueCandidates(candidates) {
|
|
135
|
+
const seen = new Set();
|
|
136
|
+
const out = [];
|
|
137
|
+
for (const candidate of Array.isArray(candidates) ? candidates : []) {
|
|
138
|
+
const ref = normalizeRef(candidate);
|
|
139
|
+
if (!ref) continue;
|
|
140
|
+
const key = `${ref.provider}\0${ref.model}`;
|
|
141
|
+
if (seen.has(key)) continue;
|
|
142
|
+
seen.add(key);
|
|
143
|
+
out.push(ref);
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function traceBase(config, reason, candidates = []) {
|
|
149
|
+
return {
|
|
150
|
+
version: ADAPTIVE_ROUTING_VERSION,
|
|
151
|
+
enabled: config.enabled,
|
|
152
|
+
decision_supported: false,
|
|
153
|
+
applied: false,
|
|
154
|
+
reason,
|
|
155
|
+
window_size: config.window_size,
|
|
156
|
+
min_samples: config.min_samples,
|
|
157
|
+
candidates,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Stable health-based ordering for automatically derived candidates.
|
|
163
|
+
*
|
|
164
|
+
* Explicit user priority is a hard bypass: health never changes its order.
|
|
165
|
+
* Candidates below min_samples are scored as neutral (0), allowing a mature
|
|
166
|
+
* unhealthy candidate to yield to an unknown one without overreacting to a
|
|
167
|
+
* single sample. Ties always retain baseline order.
|
|
168
|
+
*/
|
|
169
|
+
export function rankAdaptiveCandidates(candidates, {
|
|
170
|
+
config: rawConfig,
|
|
171
|
+
healthStore = PROCESS_ADAPTIVE_HEALTH,
|
|
172
|
+
role = 'worker',
|
|
173
|
+
explicitPriority = false,
|
|
174
|
+
} = {}) {
|
|
175
|
+
const config = normalizeAdaptiveRouting(rawConfig);
|
|
176
|
+
const baseline = uniqueCandidates(candidates);
|
|
177
|
+
|
|
178
|
+
if (!config.enabled) {
|
|
179
|
+
return { candidates: baseline, trace: traceBase(config, ADAPTIVE_ROUTING_REASON_CODES.DISABLED) };
|
|
180
|
+
}
|
|
181
|
+
if (explicitPriority) {
|
|
182
|
+
return { candidates: baseline, trace: traceBase(config, ADAPTIVE_ROUTING_REASON_CODES.EXPLICIT_PRIORITY) };
|
|
183
|
+
}
|
|
184
|
+
if (baseline.length === 0) {
|
|
185
|
+
return { candidates: baseline, trace: traceBase(config, ADAPTIVE_ROUTING_REASON_CODES.NO_ELIGIBLE_CANDIDATES) };
|
|
186
|
+
}
|
|
187
|
+
if (baseline.length === 1) {
|
|
188
|
+
return { candidates: baseline, trace: traceBase(config, ADAPTIVE_ROUTING_REASON_CODES.SINGLE_CANDIDATE) };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const scored = baseline.map((candidate, baselineRank) => {
|
|
192
|
+
const summary = healthStore?.snapshot?.(candidate, { role, windowSize: config.window_size }) ?? {
|
|
193
|
+
samples: 0, successes: 0, failures: 0, timeouts: 0, latency_bucket: 'unknown', score: 0,
|
|
194
|
+
};
|
|
195
|
+
const mature = summary.samples >= config.min_samples;
|
|
196
|
+
return {
|
|
197
|
+
candidate,
|
|
198
|
+
baselineRank,
|
|
199
|
+
mature,
|
|
200
|
+
effectiveScore: mature ? summary.score : 0,
|
|
201
|
+
summary,
|
|
202
|
+
};
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const mature = scored.filter((entry) => entry.mature);
|
|
206
|
+
if (mature.length === 0) {
|
|
207
|
+
const candidatesTrace = scored.map((entry) => ({
|
|
208
|
+
...entry.candidate,
|
|
209
|
+
baseline_rank: entry.baselineRank,
|
|
210
|
+
adaptive_rank: entry.baselineRank,
|
|
211
|
+
health_state: 'warming',
|
|
212
|
+
samples: entry.summary.samples,
|
|
213
|
+
successes: entry.summary.successes,
|
|
214
|
+
failures: entry.summary.failures,
|
|
215
|
+
timeouts: entry.summary.timeouts,
|
|
216
|
+
latency_bucket: entry.summary.latency_bucket,
|
|
217
|
+
score: null,
|
|
218
|
+
}));
|
|
219
|
+
return {
|
|
220
|
+
candidates: baseline,
|
|
221
|
+
trace: traceBase(config, ADAPTIVE_ROUTING_REASON_CODES.INSUFFICIENT_HISTORY, candidatesTrace),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const hasSignal = mature.some((entry) => entry.effectiveScore !== 0);
|
|
226
|
+
const ranked = [...scored].sort((a, b) => {
|
|
227
|
+
if (a.effectiveScore !== b.effectiveScore) return b.effectiveScore - a.effectiveScore;
|
|
228
|
+
return a.baselineRank - b.baselineRank;
|
|
229
|
+
});
|
|
230
|
+
const orderChanged = ranked.some((entry, index) => entry.baselineRank !== index);
|
|
231
|
+
const rankByKey = new Map(ranked.map((entry, index) => [`${entry.candidate.provider}\0${entry.candidate.model}`, index]));
|
|
232
|
+
const candidatesTrace = scored.map((entry) => ({
|
|
233
|
+
...entry.candidate,
|
|
234
|
+
baseline_rank: entry.baselineRank,
|
|
235
|
+
adaptive_rank: rankByKey.get(`${entry.candidate.provider}\0${entry.candidate.model}`),
|
|
236
|
+
health_state: entry.mature ? 'mature' : 'warming',
|
|
237
|
+
samples: entry.summary.samples,
|
|
238
|
+
successes: entry.summary.successes,
|
|
239
|
+
failures: entry.summary.failures,
|
|
240
|
+
timeouts: entry.summary.timeouts,
|
|
241
|
+
latency_bucket: entry.summary.latency_bucket,
|
|
242
|
+
score: entry.mature ? entry.effectiveScore : null,
|
|
243
|
+
}));
|
|
244
|
+
|
|
245
|
+
if (!hasSignal) {
|
|
246
|
+
return {
|
|
247
|
+
candidates: baseline,
|
|
248
|
+
trace: traceBase(config, ADAPTIVE_ROUTING_REASON_CODES.HEALTH_NEUTRAL, candidatesTrace),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const trace = traceBase(
|
|
253
|
+
config,
|
|
254
|
+
orderChanged ? ADAPTIVE_ROUTING_REASON_CODES.HEALTH_REORDERED : ADAPTIVE_ROUTING_REASON_CODES.HEALTH_ORDER_UNCHANGED,
|
|
255
|
+
candidatesTrace,
|
|
256
|
+
);
|
|
257
|
+
trace.decision_supported = true;
|
|
258
|
+
trace.applied = orderChanged;
|
|
259
|
+
return { candidates: ranked.map((entry) => entry.candidate), trace };
|
|
260
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
type Boundary = 'live' | 'next-workflow' | 'next-session' | 'restart-required';
|
|
4
|
+
type ActivationEntry = { global?: Boundary; session?: Boundary | null; note?: string };
|
|
5
|
+
|
|
6
|
+
const ORDER: Boundary[] = ['live', 'next-workflow', 'next-session', 'restart-required'];
|
|
7
|
+
|
|
8
|
+
const LABELS = {
|
|
9
|
+
zh: {
|
|
10
|
+
title: '配置生效边界',
|
|
11
|
+
hint: '这里显示全局 Settings 保存后的实际生效时机;会话内 dsh_worker_config 覆盖可能更早生效。',
|
|
12
|
+
boundary: {
|
|
13
|
+
live: 'Live · 当前运行时',
|
|
14
|
+
'next-workflow': 'Next workflow · 下一个任务',
|
|
15
|
+
'next-session': 'Next session · 新 CC / Codex 会话',
|
|
16
|
+
'restart-required': 'Restart required · 重启 DSH / MCP',
|
|
17
|
+
} as Record<Boundary, string>,
|
|
18
|
+
},
|
|
19
|
+
en: {
|
|
20
|
+
title: 'Configuration activation boundaries',
|
|
21
|
+
hint: 'Shows when persisted Settings changes actually take effect. Session-level dsh_worker_config overrides may activate earlier.',
|
|
22
|
+
boundary: {
|
|
23
|
+
live: 'Live · current runtime',
|
|
24
|
+
'next-workflow': 'Next workflow',
|
|
25
|
+
'next-session': 'Next session · new CC / Codex session',
|
|
26
|
+
'restart-required': 'Restart required · restart DSH / MCP',
|
|
27
|
+
} as Record<Boundary, string>,
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function groupActivationBoundaries(activation: Record<string, ActivationEntry> = {}) {
|
|
32
|
+
const grouped = Object.fromEntries(ORDER.map((boundary) => [boundary, [] as string[]])) as Record<Boundary, string[]>;
|
|
33
|
+
for (const [key, entry] of Object.entries(activation)) {
|
|
34
|
+
if (entry?.global && grouped[entry.global]) grouped[entry.global].push(key);
|
|
35
|
+
}
|
|
36
|
+
for (const boundary of ORDER) grouped[boundary].sort();
|
|
37
|
+
return grouped;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function ActivationSummary({ activation, locale }: {
|
|
41
|
+
activation?: Record<string, ActivationEntry>;
|
|
42
|
+
locale?: string;
|
|
43
|
+
}) {
|
|
44
|
+
if (!activation || Object.keys(activation).length === 0) return null;
|
|
45
|
+
const lang = locale === 'zh' ? 'zh' : 'en';
|
|
46
|
+
const copy = LABELS[lang];
|
|
47
|
+
const grouped = groupActivationBoundaries(activation);
|
|
48
|
+
return (
|
|
49
|
+
<div style={{ border: '1px solid rgba(128,128,128,0.22)', borderRadius: 8, padding: '9px 12px', display: 'flex', flexDirection: 'column', gap: 5 }}>
|
|
50
|
+
<div style={{ fontWeight: 600, fontSize: 12.5 }}>{copy.title}</div>
|
|
51
|
+
<div style={{ fontSize: 11, opacity: 0.6 }}>{copy.hint}</div>
|
|
52
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '5px 12px' }}>
|
|
53
|
+
{ORDER.map((boundary) => (
|
|
54
|
+
<div key={boundary} style={{ minWidth: 0 }}>
|
|
55
|
+
<div style={{ fontSize: 10.5, opacity: 0.7, fontWeight: 600 }}>{copy.boundary[boundary]}</div>
|
|
56
|
+
<div style={{ fontSize: 10.5, opacity: 0.55, wordBreak: 'break-word' }}>
|
|
57
|
+
{grouped[boundary].length ? grouped[boundary].join(' · ') : '—'}
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
))}
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import React, { useEffect, useState, useSyncExternalStore } from 'react';
|
|
2
|
+
import { apply as applyCrew, inject as crewInject } from './index';
|
|
3
|
+
import { ActivationSummary } from './activation-summary';
|
|
4
|
+
|
|
5
|
+
export const inject = crewInject;
|
|
6
|
+
const API = '/_dsh/dsh-crew';
|
|
7
|
+
|
|
8
|
+
type AdaptiveConfig = {
|
|
9
|
+
enabled: boolean;
|
|
10
|
+
window_size: number;
|
|
11
|
+
min_samples: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const DEFAULT_ADAPTIVE: AdaptiveConfig = { enabled: false, window_size: 8, min_samples: 2 };
|
|
15
|
+
|
|
16
|
+
function clampInt(value: unknown, fallback: number, min: number, max: number) {
|
|
17
|
+
const parsed = Number(value);
|
|
18
|
+
if (!Number.isInteger(parsed)) return fallback;
|
|
19
|
+
return Math.max(min, Math.min(max, parsed));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizeAdaptive(value: any): AdaptiveConfig {
|
|
23
|
+
const windowSize = clampInt(value?.window_size, DEFAULT_ADAPTIVE.window_size, 1, 32);
|
|
24
|
+
return {
|
|
25
|
+
enabled: value?.enabled === true,
|
|
26
|
+
window_size: windowSize,
|
|
27
|
+
min_samples: clampInt(value?.min_samples, DEFAULT_ADAPTIVE.min_samples, 1, windowSize),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function useLocale(ctx: any) {
|
|
32
|
+
return useSyncExternalStore(
|
|
33
|
+
(notify: () => void) => ctx.on('locale/change', notify),
|
|
34
|
+
() => ctx.locale.getLocale().active,
|
|
35
|
+
() => ctx.locale.getLocale().active,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function AdaptiveRoutingPanel({ ctx }: { ctx: any }) {
|
|
40
|
+
const locale = useLocale(ctx);
|
|
41
|
+
const zh = locale === 'zh';
|
|
42
|
+
const [adaptive, setAdaptive] = useState<AdaptiveConfig>(DEFAULT_ADAPTIVE);
|
|
43
|
+
const [loaded, setLoaded] = useState(false);
|
|
44
|
+
const [saving, setSaving] = useState(false);
|
|
45
|
+
const [message, setMessage] = useState('');
|
|
46
|
+
|
|
47
|
+
const copy = zh ? {
|
|
48
|
+
title: '自适应模型路由(实验)',
|
|
49
|
+
hint: '默认关闭。仅对系统自动产生的候选做健康排序;显式 Provider / Model 优先级永远保持原序。信号只来自本进程内 Crew 已观察到的成功、失败、超时与粗粒度延迟,不读取额度、价格或凭据。重启 Hub 会清空健康历史。',
|
|
50
|
+
enabled: '启用自适应路由',
|
|
51
|
+
window: '健康窗口',
|
|
52
|
+
minSamples: '最少样本',
|
|
53
|
+
windowHint: '每个 role/provider/model 最多参考最近 1–32 次结果。',
|
|
54
|
+
minHint: '达到该样本数后才允许健康分数影响自动候选顺序。',
|
|
55
|
+
boundary: '生效边界:下一工作流',
|
|
56
|
+
saved: '已保存',
|
|
57
|
+
loading: '加载中…',
|
|
58
|
+
} : {
|
|
59
|
+
title: 'Adaptive Model Routing (experimental)',
|
|
60
|
+
hint: 'Off by default. Health ordering applies only to automatically derived candidates; explicit Provider / Model priorities always keep their order. Signals are limited to Crew-observed success, failure, timeout, and coarse latency in this process—never quota, pricing, or credentials. Restarting the Hub clears the history.',
|
|
61
|
+
enabled: 'Enable adaptive routing',
|
|
62
|
+
window: 'Health window',
|
|
63
|
+
minSamples: 'Minimum samples',
|
|
64
|
+
windowHint: 'Use at most the most recent 1–32 outcomes per role/provider/model.',
|
|
65
|
+
minHint: 'Health may affect automatic candidate order only after this many samples.',
|
|
66
|
+
boundary: 'Activation boundary: next workflow',
|
|
67
|
+
saved: 'Saved',
|
|
68
|
+
loading: 'Loading…',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
let cancelled = false;
|
|
73
|
+
const load = async () => {
|
|
74
|
+
try {
|
|
75
|
+
const lang = zh ? 'zh' : 'en';
|
|
76
|
+
const res = await fetch(`${API}/config?lang=${lang}`, { cache: 'no-store' });
|
|
77
|
+
const body = await res.json();
|
|
78
|
+
if (!res.ok || body?.ok === false) throw new Error(body?.error ?? `HTTP ${res.status}`);
|
|
79
|
+
if (!cancelled) {
|
|
80
|
+
setAdaptive(normalizeAdaptive(body?.config?.worker?.model_policy?.adaptive));
|
|
81
|
+
setMessage('');
|
|
82
|
+
setLoaded(true);
|
|
83
|
+
}
|
|
84
|
+
} catch (err: any) {
|
|
85
|
+
if (!cancelled) {
|
|
86
|
+
setMessage(err?.message ?? String(err));
|
|
87
|
+
setLoaded(true);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
void load();
|
|
92
|
+
return () => { cancelled = true; };
|
|
93
|
+
}, [zh]);
|
|
94
|
+
|
|
95
|
+
const save = async (candidate: AdaptiveConfig) => {
|
|
96
|
+
const next = normalizeAdaptive(candidate);
|
|
97
|
+
setAdaptive(next);
|
|
98
|
+
setSaving(true);
|
|
99
|
+
try {
|
|
100
|
+
const lang = zh ? 'zh' : 'en';
|
|
101
|
+
const res = await fetch(`${API}/config?lang=${lang}`, {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
headers: { 'content-type': 'application/json' },
|
|
104
|
+
body: JSON.stringify({ worker: { model_policy: { adaptive: next } } }),
|
|
105
|
+
});
|
|
106
|
+
const body = await res.json();
|
|
107
|
+
if (!res.ok || body?.ok === false) throw new Error(body?.error ?? `HTTP ${res.status}`);
|
|
108
|
+
setAdaptive(normalizeAdaptive(body?.config?.worker?.model_policy?.adaptive));
|
|
109
|
+
setMessage(copy.saved);
|
|
110
|
+
setTimeout(() => setMessage(''), 1500);
|
|
111
|
+
} catch (err: any) {
|
|
112
|
+
setMessage(err?.message ?? String(err));
|
|
113
|
+
} finally {
|
|
114
|
+
setSaving(false);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
if (!loaded) return <div style={{ fontSize: 12, opacity: 0.6 }}>{copy.loading}</div>;
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, fontSize: 13, lineHeight: 1.55 }}>
|
|
122
|
+
<div>
|
|
123
|
+
<div style={{ fontWeight: 600, fontSize: 14 }}>{copy.title}</div>
|
|
124
|
+
<div style={{ opacity: 0.68, fontSize: 12, marginTop: 2 }}>{copy.hint}</div>
|
|
125
|
+
</div>
|
|
126
|
+
<label style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
|
127
|
+
<input
|
|
128
|
+
type="checkbox"
|
|
129
|
+
checked={adaptive.enabled}
|
|
130
|
+
disabled={saving}
|
|
131
|
+
onChange={(event) => { void save({ ...adaptive, enabled: event.target.checked }); }}
|
|
132
|
+
/>
|
|
133
|
+
<span>{copy.enabled}</span>
|
|
134
|
+
</label>
|
|
135
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 10 }}>
|
|
136
|
+
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
137
|
+
<span style={{ fontSize: 12, fontWeight: 500 }}>{copy.window}</span>
|
|
138
|
+
<input
|
|
139
|
+
type="number"
|
|
140
|
+
min={1}
|
|
141
|
+
max={32}
|
|
142
|
+
value={adaptive.window_size}
|
|
143
|
+
disabled={saving}
|
|
144
|
+
onChange={(event) => {
|
|
145
|
+
const windowSize = clampInt(event.target.value, adaptive.window_size, 1, 32);
|
|
146
|
+
setAdaptive((current) => ({
|
|
147
|
+
...current,
|
|
148
|
+
window_size: windowSize,
|
|
149
|
+
min_samples: Math.min(current.min_samples, windowSize),
|
|
150
|
+
}));
|
|
151
|
+
}}
|
|
152
|
+
onBlur={() => { void save(adaptive); }}
|
|
153
|
+
style={{ padding: '5px 7px', borderRadius: 5, border: '1px solid rgba(128,128,128,0.35)', background: 'transparent', color: 'inherit' }}
|
|
154
|
+
/>
|
|
155
|
+
<span style={{ fontSize: 10.5, opacity: 0.55 }}>{copy.windowHint}</span>
|
|
156
|
+
</label>
|
|
157
|
+
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
158
|
+
<span style={{ fontSize: 12, fontWeight: 500 }}>{copy.minSamples}</span>
|
|
159
|
+
<input
|
|
160
|
+
type="number"
|
|
161
|
+
min={1}
|
|
162
|
+
max={adaptive.window_size}
|
|
163
|
+
value={adaptive.min_samples}
|
|
164
|
+
disabled={saving}
|
|
165
|
+
onChange={(event) => setAdaptive((current) => ({
|
|
166
|
+
...current,
|
|
167
|
+
min_samples: clampInt(event.target.value, current.min_samples, 1, current.window_size),
|
|
168
|
+
}))}
|
|
169
|
+
onBlur={() => { void save(adaptive); }}
|
|
170
|
+
style={{ padding: '5px 7px', borderRadius: 5, border: '1px solid rgba(128,128,128,0.35)', background: 'transparent', color: 'inherit' }}
|
|
171
|
+
/>
|
|
172
|
+
<span style={{ fontSize: 10.5, opacity: 0.55 }}>{copy.minHint}</span>
|
|
173
|
+
</label>
|
|
174
|
+
</div>
|
|
175
|
+
<div style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: 11.5, opacity: 0.62 }}>
|
|
176
|
+
<span>{copy.boundary}</span>
|
|
177
|
+
{message && <span>· {message}</span>}
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function ActivationBoundaryPanel({ ctx }: { ctx: any }) {
|
|
184
|
+
const locale = useLocale(ctx);
|
|
185
|
+
const [activation, setActivation] = useState<any>(null);
|
|
186
|
+
const [error, setError] = useState('');
|
|
187
|
+
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
let cancelled = false;
|
|
190
|
+
const load = async () => {
|
|
191
|
+
try {
|
|
192
|
+
const lang = locale === 'zh' ? 'zh' : 'en';
|
|
193
|
+
const res = await fetch(`${API}/config?lang=${lang}`, { cache: 'no-store' });
|
|
194
|
+
const body = await res.json();
|
|
195
|
+
if (!cancelled) {
|
|
196
|
+
if (!res.ok || body?.ok === false) throw new Error(body?.error ?? `HTTP ${res.status}`);
|
|
197
|
+
setActivation(body?.config?.config_activation ?? null);
|
|
198
|
+
setError('');
|
|
199
|
+
}
|
|
200
|
+
} catch (err: any) {
|
|
201
|
+
if (!cancelled) setError(err?.message ?? String(err));
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
void load();
|
|
205
|
+
return () => { cancelled = true; };
|
|
206
|
+
}, [locale]);
|
|
207
|
+
|
|
208
|
+
return (
|
|
209
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, fontSize: 13, lineHeight: 1.55 }}>
|
|
210
|
+
{error && <div style={{ fontSize: 11.5, opacity: 0.6 }}>{error}</div>}
|
|
211
|
+
<ActivationSummary activation={activation ?? undefined} locale={locale} />
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function apply(ctx: any): void {
|
|
217
|
+
applyCrew(ctx);
|
|
218
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register(
|
|
219
|
+
{
|
|
220
|
+
name: 'settings.section',
|
|
221
|
+
id: 'dsh-crew-adaptive-routing',
|
|
222
|
+
order: 65,
|
|
223
|
+
label: () => ctx.locale.getLocale().active === 'zh' ? 'DSH Crew · 自适应路由' : 'DSH Crew · Adaptive Routing',
|
|
224
|
+
},
|
|
225
|
+
() => <AdaptiveRoutingPanel ctx={ctx} />,
|
|
226
|
+
));
|
|
227
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register(
|
|
228
|
+
{
|
|
229
|
+
name: 'settings.section',
|
|
230
|
+
id: 'dsh-crew-runtime-controls',
|
|
231
|
+
order: 66,
|
|
232
|
+
label: () => ctx.locale.getLocale().active === 'zh' ? 'DSH Crew · 生效边界' : 'DSH Crew · Activation',
|
|
233
|
+
},
|
|
234
|
+
() => <ActivationBoundaryPanel ctx={ctx} />,
|
|
235
|
+
));
|
|
236
|
+
}
|