multiarena 0.1.1 → 0.1.3
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 +131 -0
- package/LICENSE +21 -0
- package/README.md +282 -0
- package/dist/cli/args.d.ts +11 -0
- package/dist/cli/args.js +56 -0
- package/dist/config/types.d.ts +10 -0
- package/dist/core/deliberation.d.ts +53 -0
- package/dist/core/deliberation.js +356 -0
- package/dist/core/session.d.ts +1 -1
- package/dist/core/session.js +5 -4
- package/dist/index.js +3 -49
- package/dist/provider/adapters/openai.d.ts +15 -0
- package/dist/provider/adapters/openai.js +63 -49
- package/dist/ui/app.js +404 -53
- package/dist/ui/components/BroadcastSummary.js +1 -1
- package/dist/ui/components/DeliberationView.d.ts +17 -0
- package/dist/ui/components/DeliberationView.js +81 -0
- package/dist/ui/components/InputBar.js +1 -1
- package/dist/ui/components/ModelDetail.js +1 -1
- package/dist/ui/components/OutputArea.d.ts +7 -0
- package/dist/ui/components/OutputArea.js +31 -1
- package/dist/ui/components/formatTokens.d.ts +1 -0
- package/dist/ui/components/formatTokens.js +7 -0
- package/dist/ui/modeTransitions.d.ts +80 -0
- package/dist/ui/modeTransitions.js +176 -0
- package/package.json +13 -8
- package/dist/ui/components/StatusBar.d.ts +0 -10
- package/dist/ui/components/StatusBar.js +0 -50
package/dist/ui/app.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React, { useState, useCallback, useEffect, useRef } from "react";
|
|
2
2
|
import { Box, Text, useInput, useApp } from "ink";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
3
4
|
import { OutputArea } from "./components/OutputArea.js";
|
|
4
5
|
import { InputBar } from "./components/InputBar.js";
|
|
5
6
|
import { Session, contextLimitForModel } from "../core/session.js";
|
|
@@ -9,8 +10,10 @@ import { PermissionManager } from "../tools/permission.js";
|
|
|
9
10
|
import { runTurn } from "../core/turn.js";
|
|
10
11
|
import { WorktreeManager } from "../isolation/worktree.js";
|
|
11
12
|
import { saveSession, loadSession } from "../persistence/session.js";
|
|
13
|
+
import { runDeliberation, runMerge, autoAssignRounds, } from "../core/deliberation.js";
|
|
14
|
+
import { reduceTab, reduceShiftTab, reduceEscape, reduceKeyD, reduceSubmitInTeam, buildModeState, } from "./modeTransitions.js";
|
|
12
15
|
function makeSystemPrompt(modelName, provider) {
|
|
13
|
-
return `You are a helpful AI coding
|
|
16
|
+
return `You are a helpful AI assistant. You can help with coding, writing, analysis, and creative tasks. You are the "${modelName}" model (provider: ${provider}). Respond directly to the user's request — if asked to write content, just write it; only use tools when the task genuinely requires file or command operations.`;
|
|
14
17
|
}
|
|
15
18
|
// App-level (module-scoped) tool registry and permission manager.
|
|
16
19
|
// Created once and shared across all submissions.
|
|
@@ -52,8 +55,28 @@ export const App = ({ sessionId: initialSessionId }) => {
|
|
|
52
55
|
const [modelStates, setModelStates] = useState(() => session.models);
|
|
53
56
|
const [comparisonModel, setComparisonModel] = useState(null);
|
|
54
57
|
const comparisonFromBroadcastRef = useRef(false);
|
|
58
|
+
// Lightweight trigger for re-renders when only session.targetMode changes (not model data)
|
|
59
|
+
const [targetVersion, setTargetVersion] = useState(0);
|
|
60
|
+
// ── Team / Broadcast mode ──────────────────────────────────────
|
|
61
|
+
const [teamMode, setTeamMode] = useState(false);
|
|
62
|
+
// ── Deliberation state ─────────────────────────────────────────
|
|
63
|
+
const [deliberationProgress, setDeliberationProgress] = useState(null);
|
|
64
|
+
const [deliberationDocument, setDeliberationDocument] = useState("");
|
|
65
|
+
const [deliberationRounds, setDeliberationRounds] = useState([]);
|
|
66
|
+
const [deliberationScrollOffset, setDeliberationScrollOffset] = useState(0);
|
|
67
|
+
const deliberatingRef = useRef(false);
|
|
68
|
+
const deliberationAbortRef = useRef(null);
|
|
69
|
+
// Ref mirroring currentModeState() so the raw stdin Esc listener
|
|
70
|
+
// always reads fresh mode state without re-subscribing on every render.
|
|
71
|
+
const modeStateRef = useRef(currentModeState());
|
|
72
|
+
modeStateRef.current = currentModeState();
|
|
55
73
|
const activeScrollModel = session.targetMode.type === "directed" ? session.targetMode.modelName : null;
|
|
56
74
|
const adjustScroll = useCallback((delta) => {
|
|
75
|
+
// Team overview with deliberation content → scroll the deliberation view
|
|
76
|
+
if (teamMode && isOverview() && deliberationProgress) {
|
|
77
|
+
setDeliberationScrollOffset((prev) => Math.max(0, prev + delta));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
57
80
|
const modelName = activeScrollModel;
|
|
58
81
|
if (!modelName)
|
|
59
82
|
return;
|
|
@@ -61,7 +84,7 @@ export const App = ({ sessionId: initialSessionId }) => {
|
|
|
61
84
|
...prev,
|
|
62
85
|
[modelName]: Math.max(0, (prev[modelName] ?? 0) + delta),
|
|
63
86
|
}));
|
|
64
|
-
}, [activeScrollModel]);
|
|
87
|
+
}, [activeScrollModel, teamMode, deliberationProgress]);
|
|
65
88
|
// Input history
|
|
66
89
|
const inputHistoryRef = useRef([]);
|
|
67
90
|
const historyIdxRef = useRef(-1);
|
|
@@ -95,9 +118,13 @@ export const App = ({ sessionId: initialSessionId }) => {
|
|
|
95
118
|
lastTarget,
|
|
96
119
|
});
|
|
97
120
|
}, [session, sessionId]);
|
|
98
|
-
const targetPrefix =
|
|
99
|
-
? "
|
|
100
|
-
|
|
121
|
+
const targetPrefix = teamMode
|
|
122
|
+
? session.targetMode.type === "broadcast"
|
|
123
|
+
? "team"
|
|
124
|
+
: `team:${session.targetMode.modelName}`
|
|
125
|
+
: session.targetMode.type === "broadcast"
|
|
126
|
+
? "all"
|
|
127
|
+
: session.targetMode.modelName;
|
|
101
128
|
const activeModelName = session.targetMode.type === "broadcast" ? null : session.targetMode.modelName;
|
|
102
129
|
// Save session on process exit (Ctrl+C, kill, etc.)
|
|
103
130
|
useEffect(() => {
|
|
@@ -119,6 +146,63 @@ export const App = ({ sessionId: initialSessionId }) => {
|
|
|
119
146
|
const wm = new WorktreeManager(process.cwd());
|
|
120
147
|
wm.sweepOrphans().catch(() => { });
|
|
121
148
|
}, []);
|
|
149
|
+
// Raw stdin listener for Escape key.
|
|
150
|
+
// Ink's useInput key.escape is unreliable on some terminal setups
|
|
151
|
+
// (Windows Terminal + bash in particular). We listen for the raw
|
|
152
|
+
// \x1b byte directly and use a brief timeout to distinguish
|
|
153
|
+
// standalone Esc from escape sequences (arrow keys, etc.).
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
let escTimer = null;
|
|
156
|
+
const handleEsc = () => {
|
|
157
|
+
const state = modeStateRef.current;
|
|
158
|
+
const r = reduceEscape(state, deliberatingRef.current);
|
|
159
|
+
if (r.teamMode !== state.teamMode)
|
|
160
|
+
setTeamMode(r.teamMode);
|
|
161
|
+
if (r.abortDeliberation) {
|
|
162
|
+
deliberationAbortRef.current?.abort();
|
|
163
|
+
deliberatingRef.current = false;
|
|
164
|
+
}
|
|
165
|
+
if (r.resetDeliberation) {
|
|
166
|
+
setDeliberationProgress(null);
|
|
167
|
+
setDeliberationDocument("");
|
|
168
|
+
setDeliberationRounds([]);
|
|
169
|
+
}
|
|
170
|
+
setComparisonModel(r.comparisonModel);
|
|
171
|
+
comparisonFromBroadcastRef.current = r.comparisonFromBroadcast;
|
|
172
|
+
if (r.goToOverview) {
|
|
173
|
+
session.setTarget({ type: "broadcast" });
|
|
174
|
+
setTargetVersion((v) => v + 1);
|
|
175
|
+
}
|
|
176
|
+
if (r.restoreBroadcast) {
|
|
177
|
+
session.setTarget({ type: "broadcast" });
|
|
178
|
+
comparisonFromBroadcastRef.current = false;
|
|
179
|
+
setModelStates([...session.models]);
|
|
180
|
+
}
|
|
181
|
+
shortcutHandledRef.current = true;
|
|
182
|
+
};
|
|
183
|
+
const onData = (data) => {
|
|
184
|
+
// A single 0x1b byte might be standalone Esc or the start of
|
|
185
|
+
// an escape sequence (\x1b[A for Up, etc.). Wait briefly to
|
|
186
|
+
// see if more bytes follow.
|
|
187
|
+
if (data.length === 1 && data[0] === 0x1b) {
|
|
188
|
+
if (escTimer)
|
|
189
|
+
clearTimeout(escTimer);
|
|
190
|
+
escTimer = setTimeout(() => { handleEsc(); escTimer = null; }, 35);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
// Any other input — cancel pending Esc (it was part of a sequence)
|
|
194
|
+
if (escTimer) {
|
|
195
|
+
clearTimeout(escTimer);
|
|
196
|
+
escTimer = null;
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
process.stdin.on("data", onData);
|
|
200
|
+
return () => {
|
|
201
|
+
process.stdin.removeListener("data", onData);
|
|
202
|
+
if (escTimer)
|
|
203
|
+
clearTimeout(escTimer);
|
|
204
|
+
};
|
|
205
|
+
}, []);
|
|
122
206
|
// Clear the input bar whenever a shortcut was handled (runs after the render
|
|
123
207
|
// batch so it overrides any concurrent setInput from ink-text-input).
|
|
124
208
|
useEffect(() => {
|
|
@@ -127,29 +211,83 @@ export const App = ({ sessionId: initialSessionId }) => {
|
|
|
127
211
|
setInput("");
|
|
128
212
|
}
|
|
129
213
|
});
|
|
214
|
+
// Build a ModeState snapshot from current React state so the pure
|
|
215
|
+
// decision functions in modeTransitions.ts can drive the keyboard handler.
|
|
216
|
+
function currentModeState() {
|
|
217
|
+
return buildModeState({
|
|
218
|
+
teamMode,
|
|
219
|
+
deliberationProgress,
|
|
220
|
+
comparisonModel,
|
|
221
|
+
comparisonFromBroadcast: comparisonFromBroadcastRef.current,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
/** True when the current target is the mode overview (broadcast target). */
|
|
225
|
+
function isOverview() {
|
|
226
|
+
return session.targetMode.type === "broadcast";
|
|
227
|
+
}
|
|
130
228
|
// Keyboard input: Tab cycling, scrolling, and single-key shortcuts.
|
|
131
|
-
// Single-key shortcuts (d/m/r) only fire when the input bar is empty so they
|
|
132
|
-
// don't interfere with message typing.
|
|
133
229
|
useInput((inputValue, key) => {
|
|
134
|
-
|
|
135
|
-
|
|
230
|
+
// ── Tab (no shift): cycle target within current mode ──────────
|
|
231
|
+
// Tab never changes teamMode. It cycles: overview → model1 → … → overview.
|
|
232
|
+
if (key.tab && !key.shift) {
|
|
233
|
+
const r = reduceTab(currentModeState());
|
|
234
|
+
if (r.clearComparison) {
|
|
235
|
+
setComparisonModel(null);
|
|
236
|
+
comparisonFromBroadcastRef.current = false;
|
|
237
|
+
}
|
|
238
|
+
if (r.cycleTarget) {
|
|
239
|
+
session.cycleTarget();
|
|
240
|
+
setTargetVersion((v) => v + 1);
|
|
241
|
+
}
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
// ── Shift+Tab: toggle between broadcast ↔ team ──────────────
|
|
245
|
+
if (key.tab && key.shift) {
|
|
246
|
+
const r = reduceShiftTab(currentModeState(), isOverview());
|
|
247
|
+
if (!r)
|
|
248
|
+
return;
|
|
249
|
+
setTeamMode(r.teamMode);
|
|
136
250
|
setComparisonModel(null);
|
|
137
|
-
|
|
138
|
-
|
|
251
|
+
if (r.goToOverview) {
|
|
252
|
+
session.setTarget({ type: "broadcast" });
|
|
253
|
+
setTargetVersion((v) => v + 1);
|
|
254
|
+
}
|
|
255
|
+
if (r.resetDeliberation) {
|
|
256
|
+
setDeliberationProgress(null);
|
|
257
|
+
setDeliberationDocument("");
|
|
258
|
+
setDeliberationRounds([]);
|
|
259
|
+
}
|
|
139
260
|
return;
|
|
140
261
|
}
|
|
141
|
-
// Escape
|
|
262
|
+
// ── Escape: return to current mode's overview ────────────────
|
|
142
263
|
if (key.escape) {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
264
|
+
const r = reduceEscape(currentModeState(), deliberatingRef.current);
|
|
265
|
+
// teamMode is preserved — Esc never toggles it
|
|
266
|
+
if (r.teamMode !== teamMode)
|
|
267
|
+
setTeamMode(r.teamMode);
|
|
268
|
+
if (r.abortDeliberation) {
|
|
269
|
+
deliberationAbortRef.current?.abort();
|
|
270
|
+
deliberatingRef.current = false;
|
|
271
|
+
}
|
|
272
|
+
if (r.resetDeliberation) {
|
|
273
|
+
setDeliberationProgress(null);
|
|
274
|
+
setDeliberationDocument("");
|
|
275
|
+
setDeliberationRounds([]);
|
|
276
|
+
}
|
|
277
|
+
setComparisonModel(r.comparisonModel);
|
|
278
|
+
comparisonFromBroadcastRef.current = r.comparisonFromBroadcast;
|
|
279
|
+
if (r.goToOverview) {
|
|
280
|
+
session.setTarget({ type: "broadcast" });
|
|
281
|
+
setTargetVersion((v) => v + 1);
|
|
282
|
+
}
|
|
283
|
+
if (r.restoreBroadcast) {
|
|
284
|
+
// Exiting comparison that entered from broadcast — restore broadcast
|
|
285
|
+
session.setTarget({ type: "broadcast" });
|
|
286
|
+
comparisonFromBroadcastRef.current = false;
|
|
149
287
|
setModelStates([...session.models]);
|
|
150
|
-
shortcutHandledRef.current = true;
|
|
151
|
-
return;
|
|
152
288
|
}
|
|
289
|
+
shortcutHandledRef.current = true;
|
|
290
|
+
return;
|
|
153
291
|
}
|
|
154
292
|
if (key.upArrow) {
|
|
155
293
|
if (input.length === 0) {
|
|
@@ -193,37 +331,21 @@ export const App = ({ sessionId: initialSessionId }) => {
|
|
|
193
331
|
return;
|
|
194
332
|
// 'd' — toggle comparison mode (current model vs next unmuted model)
|
|
195
333
|
if (inputValue === "d") {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
}
|
|
202
|
-
|
|
334
|
+
const unmutedNames = session.models.filter((m) => !m.muted).map((m) => m.name);
|
|
335
|
+
const currentTarget = session.targetMode.type === "directed" ? session.targetMode.modelName : null;
|
|
336
|
+
const r = reduceKeyD(currentModeState(), unmutedNames, currentTarget);
|
|
337
|
+
// Exiting comparison — restore broadcast if we entered from there
|
|
338
|
+
if (comparisonModel && !r.comparisonModel && comparisonFromBroadcastRef.current) {
|
|
339
|
+
session.setTarget({ type: "broadcast" });
|
|
340
|
+
comparisonFromBroadcastRef.current = false;
|
|
341
|
+
setTargetVersion((v) => v + 1);
|
|
203
342
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
}
|
|
210
|
-
if (session.targetMode.type === "broadcast") {
|
|
211
|
-
// From broadcast: switch to directed for the first model, compare with second
|
|
212
|
-
session.setTarget({ type: "directed", modelName: unmuted[0].name });
|
|
213
|
-
setComparisonModel(unmuted[1].name);
|
|
214
|
-
comparisonFromBroadcastRef.current = true;
|
|
215
|
-
}
|
|
216
|
-
else {
|
|
217
|
-
// From directed: toggle comparison on/off for the current model
|
|
218
|
-
const baseName = session.targetMode.modelName;
|
|
219
|
-
const idx = unmuted.findIndex((m) => m.name === baseName);
|
|
220
|
-
const next = unmuted[(idx + 1) % unmuted.length];
|
|
221
|
-
if (next && next.name !== baseName) {
|
|
222
|
-
setComparisonModel(next.name);
|
|
223
|
-
}
|
|
224
|
-
}
|
|
343
|
+
setComparisonModel(r.comparisonModel);
|
|
344
|
+
comparisonFromBroadcastRef.current = r.comparisonFromBroadcast;
|
|
345
|
+
if (r.setDirectedTarget) {
|
|
346
|
+
session.setTarget({ type: "directed", modelName: r.setDirectedTarget });
|
|
347
|
+
setTargetVersion((v) => v + 1);
|
|
225
348
|
}
|
|
226
|
-
setModelStates([...session.models]);
|
|
227
349
|
shortcutHandledRef.current = true;
|
|
228
350
|
return;
|
|
229
351
|
}
|
|
@@ -254,10 +376,240 @@ export const App = ({ sessionId: initialSessionId }) => {
|
|
|
254
376
|
return;
|
|
255
377
|
}
|
|
256
378
|
});
|
|
379
|
+
// ── Deliberation runner ───────────────────────────────────────
|
|
380
|
+
const runDeliberationPipeline = useCallback(async (task) => {
|
|
381
|
+
if (deliberatingRef.current)
|
|
382
|
+
return;
|
|
383
|
+
deliberatingRef.current = true;
|
|
384
|
+
const activeModels = session.models
|
|
385
|
+
.filter((m) => !m.muted)
|
|
386
|
+
.map((m) => m.name);
|
|
387
|
+
if (activeModels.length < 2) {
|
|
388
|
+
// Need at least 2 models for deliberation
|
|
389
|
+
setDeliberationProgress({
|
|
390
|
+
type: "error",
|
|
391
|
+
round: 0,
|
|
392
|
+
totalRounds: 0,
|
|
393
|
+
error: "Deliberation requires at least 2 active (non-muted) models.",
|
|
394
|
+
});
|
|
395
|
+
deliberatingRef.current = false;
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
// Use config deliberation settings or auto-assign
|
|
399
|
+
const delibConfig = config.deliberation;
|
|
400
|
+
let roundConfigs;
|
|
401
|
+
if (delibConfig?.rounds && delibConfig.rounds.length > 0) {
|
|
402
|
+
roundConfigs = delibConfig.rounds
|
|
403
|
+
.filter((r) => activeModels.includes(r.model) && config.models[r.model])
|
|
404
|
+
.map((r) => ({
|
|
405
|
+
modelName: r.model,
|
|
406
|
+
role: r.role,
|
|
407
|
+
config: config.models[r.model],
|
|
408
|
+
}));
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
roundConfigs = autoAssignRounds(activeModels, config.models);
|
|
412
|
+
}
|
|
413
|
+
if (roundConfigs.length < 2) {
|
|
414
|
+
setDeliberationProgress({
|
|
415
|
+
type: "error",
|
|
416
|
+
round: 0,
|
|
417
|
+
totalRounds: 0,
|
|
418
|
+
error: "Not enough configured models for deliberation (need ≥2).",
|
|
419
|
+
});
|
|
420
|
+
deliberatingRef.current = false;
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
// Load constraint document if configured
|
|
424
|
+
let constraint;
|
|
425
|
+
if (delibConfig?.constraint_file) {
|
|
426
|
+
try {
|
|
427
|
+
constraint = await readFile(delibConfig.constraint_file, "utf-8");
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
// Constraint file not found — proceed without it
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
setDeliberationDocument("");
|
|
434
|
+
setDeliberationRounds([]);
|
|
435
|
+
setDeliberationScrollOffset(0);
|
|
436
|
+
let doc = "";
|
|
437
|
+
const stream = runDeliberation(task, roundConfigs, constraint);
|
|
438
|
+
for await (const event of stream) {
|
|
439
|
+
setDeliberationProgress(event);
|
|
440
|
+
if (event.type === "round_start") {
|
|
441
|
+
doc = "";
|
|
442
|
+
setDeliberationDocument("");
|
|
443
|
+
setDeliberationRounds((prev) => [
|
|
444
|
+
...prev,
|
|
445
|
+
{ round: event.round, modelName: event.modelName, role: event.role },
|
|
446
|
+
]);
|
|
447
|
+
}
|
|
448
|
+
else if (event.type === "text" && event.content) {
|
|
449
|
+
doc += event.content;
|
|
450
|
+
setDeliberationDocument(doc);
|
|
451
|
+
}
|
|
452
|
+
else if (event.type === "round_end") {
|
|
453
|
+
// Update the round entry with change metadata
|
|
454
|
+
setDeliberationRounds((prev) => prev.map((r) => r.round === event.round
|
|
455
|
+
? {
|
|
456
|
+
...r,
|
|
457
|
+
changeCount: event.changeCount,
|
|
458
|
+
changeSamples: event.changeSamples,
|
|
459
|
+
}
|
|
460
|
+
: r));
|
|
461
|
+
}
|
|
462
|
+
else if (event.type === "done") {
|
|
463
|
+
setDeliberationDocument(event.document ?? doc);
|
|
464
|
+
}
|
|
465
|
+
else if (event.type === "error") {
|
|
466
|
+
deliberatingRef.current = false;
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
// Keep the final document visible; user presses Esc to dismiss
|
|
471
|
+
// Inject the final document into each model's message history
|
|
472
|
+
// so they can discuss it when the user switches to chat mode.
|
|
473
|
+
if (doc) {
|
|
474
|
+
const contextMsg = `[团队审议结果]\n\n以下是你与其他模型协作完成的最终文档。用户可以就此文档与你讨论。\n\n---\n${doc}\n---`;
|
|
475
|
+
for (const m of session.models) {
|
|
476
|
+
if (!m.muted) {
|
|
477
|
+
m.messages.push({ role: "user", content: contextMsg });
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
deliberatingRef.current = false;
|
|
482
|
+
}, [session.models, config]);
|
|
483
|
+
// ── Merge runner ──────────────────────────────────────────────
|
|
484
|
+
const runMergePipeline = useCallback(async () => {
|
|
485
|
+
if (deliberatingRef.current)
|
|
486
|
+
return;
|
|
487
|
+
deliberatingRef.current = true;
|
|
488
|
+
// Collect the last assistant response from each non-muted model
|
|
489
|
+
const outputs = [];
|
|
490
|
+
for (const m of session.models) {
|
|
491
|
+
if (m.muted)
|
|
492
|
+
continue;
|
|
493
|
+
// Find the last assistant message
|
|
494
|
+
const lastAssistant = [...m.messages].reverse().find((msg) => msg.role === "assistant");
|
|
495
|
+
if (lastAssistant?.content) {
|
|
496
|
+
outputs.push({ modelName: m.name, content: lastAssistant.content });
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (outputs.length < 2) {
|
|
500
|
+
setDeliberationProgress({
|
|
501
|
+
type: "error",
|
|
502
|
+
round: 0,
|
|
503
|
+
totalRounds: 0,
|
|
504
|
+
error: "需要至少 2 个模型有回复才能合并。",
|
|
505
|
+
});
|
|
506
|
+
deliberatingRef.current = false;
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
// Find the last user message as the task
|
|
510
|
+
const firstModel = session.models.find((m) => !m.muted);
|
|
511
|
+
const lastUserMsg = firstModel?.messages
|
|
512
|
+
? [...firstModel.messages].reverse().find((m) => m.role === "user")
|
|
513
|
+
: null;
|
|
514
|
+
const task = lastUserMsg?.content ?? "合并以下模型输出";
|
|
515
|
+
// Use the first non-muted model as the merger
|
|
516
|
+
const mergerName = session.models.find((m) => !m.muted).name;
|
|
517
|
+
const mergerConfig = config.models[mergerName];
|
|
518
|
+
if (!mergerConfig) {
|
|
519
|
+
setDeliberationProgress({
|
|
520
|
+
type: "error",
|
|
521
|
+
round: 0,
|
|
522
|
+
totalRounds: 0,
|
|
523
|
+
error: `未找到模型 "${mergerName}" 的配置。`,
|
|
524
|
+
});
|
|
525
|
+
deliberatingRef.current = false;
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
setDeliberationDocument("");
|
|
529
|
+
setDeliberationRounds([]);
|
|
530
|
+
setDeliberationScrollOffset(0);
|
|
531
|
+
let doc = "";
|
|
532
|
+
const stream = runMerge(task, outputs, mergerConfig, mergerName);
|
|
533
|
+
for await (const event of stream) {
|
|
534
|
+
setDeliberationProgress(event);
|
|
535
|
+
if (event.type === "round_start") {
|
|
536
|
+
doc = "";
|
|
537
|
+
setDeliberationDocument("");
|
|
538
|
+
setDeliberationRounds([
|
|
539
|
+
{ round: 1, modelName: mergerName, role: "draft" },
|
|
540
|
+
]);
|
|
541
|
+
}
|
|
542
|
+
else if (event.type === "text" && event.content) {
|
|
543
|
+
doc += event.content;
|
|
544
|
+
setDeliberationDocument(doc);
|
|
545
|
+
}
|
|
546
|
+
else if (event.type === "done") {
|
|
547
|
+
setDeliberationDocument(event.document ?? doc);
|
|
548
|
+
}
|
|
549
|
+
else if (event.type === "error") {
|
|
550
|
+
deliberatingRef.current = false;
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
// Inject the merged document into each model's history so they
|
|
555
|
+
// can discuss it when the user switches to chat mode.
|
|
556
|
+
if (doc) {
|
|
557
|
+
const contextMsg = `[合并结果]\n\n以下是将各模型输出合并后的最终文档。用户可以就此文档与你讨论。\n\n---\n${doc}\n---`;
|
|
558
|
+
for (const m of session.models) {
|
|
559
|
+
if (!m.muted) {
|
|
560
|
+
m.messages.push({ role: "user", content: contextMsg });
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
deliberatingRef.current = false;
|
|
565
|
+
}, [session.models, config]);
|
|
257
566
|
const handleSubmit = useCallback(async (value) => {
|
|
258
567
|
const trimmed = value.trim();
|
|
259
568
|
if (!trimmed)
|
|
260
569
|
return;
|
|
570
|
+
// ── Team mode: submit runs deliberation or routes to model ──
|
|
571
|
+
if (teamMode) {
|
|
572
|
+
const r = reduceSubmitInTeam(currentModeState(), isOverview());
|
|
573
|
+
if (r.action === "block")
|
|
574
|
+
return;
|
|
575
|
+
if (r.action === "deliberate") {
|
|
576
|
+
inputHistoryRef.current.push(trimmed);
|
|
577
|
+
historyIdxRef.current = -1;
|
|
578
|
+
setInput("");
|
|
579
|
+
setDeliberationDocument("");
|
|
580
|
+
// Reset target to overview so OutputArea shows deliberation progress
|
|
581
|
+
session.setTarget({ type: "broadcast" });
|
|
582
|
+
setTargetVersion((v) => v + 1);
|
|
583
|
+
runDeliberationPipeline(trimmed);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
// r.action === "route_normally" — send as directed/broadcast message
|
|
587
|
+
}
|
|
588
|
+
// ── Team mode toggle ────────────────────────────────────
|
|
589
|
+
if (trimmed === "/team" || trimmed === "/t") {
|
|
590
|
+
inputHistoryRef.current.push(trimmed);
|
|
591
|
+
historyIdxRef.current = -1;
|
|
592
|
+
setInput("");
|
|
593
|
+
setTeamMode((prev) => !prev);
|
|
594
|
+
// Entering a mode always lands on its overview
|
|
595
|
+
session.setTarget({ type: "broadcast" });
|
|
596
|
+
setTargetVersion((v) => v + 1);
|
|
597
|
+
setDeliberationProgress(null);
|
|
598
|
+
setDeliberationDocument("");
|
|
599
|
+
setDeliberationRounds([]);
|
|
600
|
+
setComparisonModel(null);
|
|
601
|
+
shortcutHandledRef.current = true;
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
// ── Merge command: synthesize last outputs ───────────────
|
|
605
|
+
if (trimmed === "/merge" || trimmed === "/m") {
|
|
606
|
+
inputHistoryRef.current.push(trimmed);
|
|
607
|
+
historyIdxRef.current = -1;
|
|
608
|
+
setInput("");
|
|
609
|
+
setDeliberationDocument("");
|
|
610
|
+
runMergePipeline();
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
261
613
|
// Add to input history
|
|
262
614
|
inputHistoryRef.current.push(trimmed);
|
|
263
615
|
historyIdxRef.current = -1;
|
|
@@ -322,7 +674,7 @@ export const App = ({ sessionId: initialSessionId }) => {
|
|
|
322
674
|
await worktreeManager.cleanup(taskId);
|
|
323
675
|
// ── Auto-save session ─────────────────────────────────────
|
|
324
676
|
saveCurrentSession();
|
|
325
|
-
}, [session, config, sessionId, saveCurrentSession]);
|
|
677
|
+
}, [session, config, sessionId, saveCurrentSession, teamMode, deliberationProgress, runDeliberationPipeline, setTargetVersion]);
|
|
326
678
|
const terminalWidth = process.stdout.columns ?? 80;
|
|
327
679
|
// ── No models configured: show startup guide ──────────────────
|
|
328
680
|
if (modelStates.length === 0) {
|
|
@@ -365,8 +717,7 @@ broadcast = true`;
|
|
|
365
717
|
configWarnings.length > 0 && (React.createElement(Box, { flexDirection: "column" }, configWarnings.map((w, i) => (React.createElement(Text, { key: i, color: "yellow" },
|
|
366
718
|
"\u26A0 ",
|
|
367
719
|
w.message))))),
|
|
368
|
-
React.createElement(
|
|
369
|
-
|
|
370
|
-
React.createElement(Text, null, "─".repeat(terminalWidth)),
|
|
720
|
+
React.createElement(Box, { flexGrow: 1 },
|
|
721
|
+
React.createElement(OutputArea, { models: modelStates, targetMode: session.targetMode, scrollOffsets: scrollOffsets, comparisonModel: comparisonModel, terminalWidth: terminalWidth, deliberationProgress: deliberationProgress, deliberationDocument: deliberationDocument, deliberationRounds: deliberationRounds, teamMode: teamMode, deliberationScrollOffset: deliberationScrollOffset })),
|
|
371
722
|
React.createElement(InputBar, { models: modelStates, activeModelName: activeModelName, prefix: targetPrefix, value: input, onChange: handleInputChange, onSubmit: handleSubmit })));
|
|
372
723
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
|
-
import { formatTokens } from "./
|
|
3
|
+
import { formatTokens } from "./formatTokens.js";
|
|
4
4
|
const PANEL_LINES = 4;
|
|
5
5
|
export const BroadcastSummary = ({ models, terminalWidth }) => {
|
|
6
6
|
const activeModels = models.filter((m) => !m.muted);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { DeliberationProgress, RoundRole } from "../../core/deliberation.js";
|
|
3
|
+
export interface RoundSummary {
|
|
4
|
+
round: number;
|
|
5
|
+
modelName: string;
|
|
6
|
+
role: RoundRole;
|
|
7
|
+
changeCount?: number;
|
|
8
|
+
changeSamples?: string[];
|
|
9
|
+
}
|
|
10
|
+
interface Props {
|
|
11
|
+
progress: DeliberationProgress;
|
|
12
|
+
document: string;
|
|
13
|
+
rounds: RoundSummary[];
|
|
14
|
+
scrollOffset?: number;
|
|
15
|
+
}
|
|
16
|
+
export declare const DeliberationView: React.FC<Props>;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { roundLabel } from "../../core/deliberation.js";
|
|
4
|
+
const ROLE_COLORS = {
|
|
5
|
+
draft: "cyan",
|
|
6
|
+
revise: "yellow",
|
|
7
|
+
polish: "green",
|
|
8
|
+
review: "magenta",
|
|
9
|
+
};
|
|
10
|
+
function spinner(frame) {
|
|
11
|
+
const chars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
12
|
+
return chars[frame % chars.length] ?? ".";
|
|
13
|
+
}
|
|
14
|
+
export const DeliberationView = ({ progress, document, rounds, scrollOffset = 0 }) => {
|
|
15
|
+
const role = progress.role ?? "draft";
|
|
16
|
+
const roleColor = ROLE_COLORS[role];
|
|
17
|
+
const isActive = progress.type !== "done" && progress.type !== "error";
|
|
18
|
+
const isDone = progress.type === "done";
|
|
19
|
+
const spin = spinner(Date.now() % 10);
|
|
20
|
+
const allLines = document ? document.split("\n") : [];
|
|
21
|
+
const lines = allLines.slice(scrollOffset);
|
|
22
|
+
// Count total changes across all rounds
|
|
23
|
+
const totalChanges = rounds.reduce((sum, r) => sum + (r.changeCount ?? 0), 0);
|
|
24
|
+
return (React.createElement(Box, { flexDirection: "column", flexGrow: 1, padding: 1 },
|
|
25
|
+
React.createElement(Box, { flexDirection: "row" },
|
|
26
|
+
React.createElement(Text, { bold: true }, isDone
|
|
27
|
+
? `审议完成 · ${rounds.length} 轮 · ${totalChanges} 处修改`
|
|
28
|
+
: isActive
|
|
29
|
+
? `第 ${progress.round}/${progress.totalRounds} 轮:${progress.modelName ?? "?"} (${roundLabel(role)})`
|
|
30
|
+
: "审议出错")),
|
|
31
|
+
React.createElement(Box, { flexDirection: "row", marginY: 1 }, rounds.map((r, i) => {
|
|
32
|
+
const isPast = isDone || r.round < progress.round;
|
|
33
|
+
const isCurrent = !isDone && r.round === progress.round;
|
|
34
|
+
return (React.createElement(Box, { key: i, marginRight: 2, flexDirection: "column" },
|
|
35
|
+
React.createElement(Box, { flexDirection: "row" },
|
|
36
|
+
React.createElement(Text, { color: isPast ? "green" : isCurrent ? roleColor : "gray" },
|
|
37
|
+
isPast ? "✓" : isCurrent ? spin : "○",
|
|
38
|
+
" ",
|
|
39
|
+
roundLabel(r.role))),
|
|
40
|
+
React.createElement(Box, { flexDirection: "row" },
|
|
41
|
+
React.createElement(Text, { dimColor: true }, r.modelName))));
|
|
42
|
+
})),
|
|
43
|
+
isActive && (React.createElement(Box, { marginBottom: 1 },
|
|
44
|
+
React.createElement(Text, { color: roleColor },
|
|
45
|
+
roundLabel(role),
|
|
46
|
+
"\u4E2D \u2014 ",
|
|
47
|
+
progress.modelName,
|
|
48
|
+
" \u2014 \u6B63\u5728\u751F\u6210\u2026"))),
|
|
49
|
+
progress.type === "error" && (React.createElement(Box, { marginBottom: 1 },
|
|
50
|
+
React.createElement(Text, { color: "red" },
|
|
51
|
+
"\u9519\u8BEF\uFF1A",
|
|
52
|
+
progress.error))),
|
|
53
|
+
isDone && rounds.length > 0 && (React.createElement(Box, { flexDirection: "column", marginBottom: 1 },
|
|
54
|
+
React.createElement(Text, { bold: true }, "\u2500\u2500 \u5BA1\u8BAE\u8FC7\u7A0B \u2500\u2500"),
|
|
55
|
+
rounds.map((r) => {
|
|
56
|
+
const hasChanges = (r.changeCount ?? 0) > 0;
|
|
57
|
+
return (React.createElement(Box, { key: r.round, flexDirection: "column", marginTop: 1 },
|
|
58
|
+
React.createElement(Text, null,
|
|
59
|
+
React.createElement(Text, { color: "cyan" },
|
|
60
|
+
r.round,
|
|
61
|
+
"."),
|
|
62
|
+
" ",
|
|
63
|
+
React.createElement(Text, { bold: true }, r.modelName),
|
|
64
|
+
React.createElement(Text, { color: "gray" },
|
|
65
|
+
"\uFF08",
|
|
66
|
+
roundLabel(r.role),
|
|
67
|
+
"\uFF09"),
|
|
68
|
+
hasChanges && (React.createElement(Text, { color: "yellow" },
|
|
69
|
+
" \u2014 ",
|
|
70
|
+
r.changeCount,
|
|
71
|
+
" \u5904\u4FEE\u6539")),
|
|
72
|
+
!hasChanges && r.role !== "draft" && (React.createElement(Text, { color: "gray" }, " \u2014 \u65E0\u4FEE\u6539"))),
|
|
73
|
+
r.changeSamples && r.changeSamples.length > 0 && (React.createElement(Box, { flexDirection: "column", marginLeft: 2 }, r.changeSamples.map((s, j) => (React.createElement(Text, { key: j, dimColor: true }, ` ${s}`)))))));
|
|
74
|
+
}),
|
|
75
|
+
React.createElement(Text, null, " "))),
|
|
76
|
+
isDone && (React.createElement(Box, { marginBottom: 1 },
|
|
77
|
+
React.createElement(Text, { bold: true, color: "green" }, "\u2500\u2500 \u6700\u7EC8\u6587\u6863 \u2500\u2500"))),
|
|
78
|
+
React.createElement(Box, { flexDirection: "column", flexGrow: 1 },
|
|
79
|
+
lines.length === 0 && isActive && (React.createElement(Text, { dimColor: true }, "\u7B49\u5F85\u8F93\u51FA\u2026")),
|
|
80
|
+
lines.map((line, i) => (React.createElement(Text, { key: i }, line))))));
|
|
81
|
+
};
|
|
@@ -10,7 +10,7 @@ export const InputBar = ({ models, activeModelName, prefix, value, onChange, onS
|
|
|
10
10
|
isTargeted && !m.muted && React.createElement(Text, { color: "yellow" }, " \u25CF"),
|
|
11
11
|
m.muted && React.createElement(Text, { color: "gray" }, " [muted]")));
|
|
12
12
|
}),
|
|
13
|
-
React.createElement(Text, { dimColor: true }, " \u2014 Tab:
|
|
13
|
+
React.createElement(Text, { dimColor: true }, " \u2014 Shift+Tab:/team Tab:model d:compare m:mute r:reset q:quit \u2191\u2193:scroll/history Esc:cancel")),
|
|
14
14
|
React.createElement(Box, { height: 1, flexDirection: "row" },
|
|
15
15
|
React.createElement(Box, { marginRight: 1 },
|
|
16
16
|
React.createElement(Text, { color: "green" },
|