@goodandready/dsh-moa 0.2.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/LICENSE +21 -0
- package/README.md +120 -0
- package/README.ru.md +83 -0
- package/cordis.patch.yml +4 -0
- package/lib/client.js +1090 -0
- package/lib/file-workspace.js +186 -0
- package/lib/index.js +494 -0
- package/lib/moa-runner.js +672 -0
- package/package.json +62 -0
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mixture of Agents (MoA) execution engine with Interactive Questioning
|
|
3
|
+
* and Isolated Multi-Candidate File Execution.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
extractFileBlocks,
|
|
8
|
+
writeCandidateWorkspace,
|
|
9
|
+
promoteCandidateWorkspace,
|
|
10
|
+
cleanMoaWorkspaces,
|
|
11
|
+
} from './file-workspace.js'
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
extractFileBlocks,
|
|
15
|
+
writeCandidateWorkspace,
|
|
16
|
+
promoteCandidateWorkspace,
|
|
17
|
+
cleanMoaWorkspaces,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const REFERENCE_SYSTEM_PROMPT = `You are an expert candidate engineer model in a Mixture of Agents (MoA) architecture.
|
|
21
|
+
You are directly implementing the solution for the user's task.
|
|
22
|
+
|
|
23
|
+
RULES:
|
|
24
|
+
1. Do NOT emit internal planning or pretend tool calls (no xml, no <invoke>, no brainstorming delays).
|
|
25
|
+
2. Write complete, production-ready, functional code.
|
|
26
|
+
3. Every file you produce MUST be formatted in markdown code blocks with clear file path annotation, e.g.:
|
|
27
|
+
\`\`\`html file="index.html"
|
|
28
|
+
...
|
|
29
|
+
\`\`\`
|
|
30
|
+
or
|
|
31
|
+
\`\`\`javascript file="src/app.js"
|
|
32
|
+
...
|
|
33
|
+
\`\`\`
|
|
34
|
+
4. If the user request is broad, make opinionated, high-quality technical decisions and deliver a complete working project.
|
|
35
|
+
5. Never leave placeholders like "// TODO" or "... rest of code". Deliver exhaustive, working code.`
|
|
36
|
+
|
|
37
|
+
export const ADVISOR_QUESTION_PROMPT = `You are a senior technical advisor in a Mixture of Agents (MoA) architecture.
|
|
38
|
+
The user has provided a prompt that may have multiple design choices, architectural paths, or underspecified requirements.
|
|
39
|
+
|
|
40
|
+
Review the user prompt and identify 1 to 3 critical, high-impact clarifying questions or architectural options that would define the implementation (e.g. framework/vanilla, features, design style, target environment).
|
|
41
|
+
Keep questions very clear, structured, and actionable. Avoid trivial questions. Respond directly in Russian.`
|
|
42
|
+
|
|
43
|
+
export function slotLabel(slot) {
|
|
44
|
+
if (!slot || typeof slot !== 'object') return 'unknown'
|
|
45
|
+
const prov = String(slot.provider || '').trim()
|
|
46
|
+
const model = String(slot.model || '').trim()
|
|
47
|
+
if (prov && model) return `${prov}:${model}`
|
|
48
|
+
return prov || model || 'unknown'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Strips tool results, system prompts, and tool calls from history
|
|
53
|
+
* to give reference advisors a clean, advisory-safe context.
|
|
54
|
+
*/
|
|
55
|
+
export function cleanAdvisoryMessages(messages = [], maxCharBudget = 4000) {
|
|
56
|
+
const trimmed = []
|
|
57
|
+
for (const msg of messages) {
|
|
58
|
+
if (!msg || typeof msg !== 'object') continue
|
|
59
|
+
const role = msg.role
|
|
60
|
+
if (role !== 'user' && role !== 'assistant') continue
|
|
61
|
+
|
|
62
|
+
let text = ''
|
|
63
|
+
if (typeof msg.content === 'string') {
|
|
64
|
+
text = msg.content
|
|
65
|
+
} else if (Array.isArray(msg.content)) {
|
|
66
|
+
text = msg.content
|
|
67
|
+
.filter((part) => part && part.type === 'text' && typeof part.text === 'string')
|
|
68
|
+
.map((part) => part.text)
|
|
69
|
+
.join('\n')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!text.trim()) continue
|
|
73
|
+
|
|
74
|
+
if (text.length > maxCharBudget) {
|
|
75
|
+
const head = text.slice(0, Math.floor(maxCharBudget * 0.7))
|
|
76
|
+
const tail = text.slice(-Math.floor(maxCharBudget * 0.3))
|
|
77
|
+
text = `${head}\n... [trimmed ${text.length - maxCharBudget} characters] ...\n${tail}`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
trimmed.push({ role, content: text })
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return trimmed
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Checks if the prompt should trigger a clarifying questions phase
|
|
88
|
+
* instead of immediate execution.
|
|
89
|
+
*/
|
|
90
|
+
export function isBroadPromptRequiringQuestions(userPrompt = '', messages = []) {
|
|
91
|
+
const prompt = (userPrompt || '').trim()
|
|
92
|
+
if (!prompt) return false
|
|
93
|
+
|
|
94
|
+
// Find the last assistant message in history
|
|
95
|
+
const lastAssistant = [...messages].reverse().find((m) => m && m.role === 'assistant')
|
|
96
|
+
const lastAssistantText = typeof lastAssistant?.content === 'string'
|
|
97
|
+
? lastAssistant.content
|
|
98
|
+
: (Array.isArray(lastAssistant?.content)
|
|
99
|
+
? lastAssistant.content.map((c) => (typeof c === 'string' ? c : c.text || '')).join('')
|
|
100
|
+
: '')
|
|
101
|
+
|
|
102
|
+
// If the last assistant message was an MoA questionnaire, the user is answering it now!
|
|
103
|
+
const isAnsweringQuestions =
|
|
104
|
+
lastAssistantText.includes('Уточнение требований') ||
|
|
105
|
+
lastAssistantText.includes('Уточните, пожалуйста') ||
|
|
106
|
+
lastAssistantText.includes('опросник')
|
|
107
|
+
|
|
108
|
+
if (isAnsweringQuestions) {
|
|
109
|
+
return false
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Trigger on Russian broad verbs
|
|
113
|
+
const lower = prompt.toLowerCase()
|
|
114
|
+
const broadKeywords = ['создай', 'сделай', 'разработай', 'напиши', 'построй', 'сгенерируй']
|
|
115
|
+
const hasBroadKeyword = broadKeywords.some((k) => lower.includes(k))
|
|
116
|
+
|
|
117
|
+
// If it contains a broad keyword and is relatively brief (<= 12 words), ask questions!
|
|
118
|
+
if (hasBroadKeyword) {
|
|
119
|
+
const wordCount = prompt.split(/\s+/).filter(Boolean).length
|
|
120
|
+
if (wordCount <= 12) return true
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return false
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function buildQuestionSynthesisPrompt(userPrompt, referenceOutputs = []) {
|
|
127
|
+
const joined = referenceOutputs
|
|
128
|
+
.map((r, i) => `Советник ${i + 1} (${r.label}):\n${r.text}`)
|
|
129
|
+
.join('\n\n')
|
|
130
|
+
|
|
131
|
+
return `Ты — ведущий архитектор и судья в архитектуре Mixture of Agents (MoA).
|
|
132
|
+
Пользователь дал задачу:
|
|
133
|
+
"${userPrompt}"
|
|
134
|
+
|
|
135
|
+
Советники предложили следующие развилки и уточнения:
|
|
136
|
+
${joined}
|
|
137
|
+
|
|
138
|
+
Твоя задача — синтезировать единый, компактный, дружелюбный и структурированный опросник (2-4 вопроса) для пользователя на русском языке.
|
|
139
|
+
Каждый вопрос должен предлагать 2-3 конкретных рекомендуемых варианта ответа (например: 1. Формат: HTML/JS в одном файле или React? 2. Стиль: Минимализм, iOS или Необрутализм?).
|
|
140
|
+
В конце добавь примечание, что пользователь может ответить кратко (например: "1, 2, темная тема") или довериться выбору по умолчанию.`
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function buildSynthesisPrompt(userPrompt, referenceOutputs = []) {
|
|
144
|
+
const joined = referenceOutputs
|
|
145
|
+
.map((r, i) => {
|
|
146
|
+
const fileSummary = (r.files && r.files.length > 0)
|
|
147
|
+
? ` [Созданные файлы: ${r.files.map((f) => f.relativePath).join(', ')}]`
|
|
148
|
+
: ''
|
|
149
|
+
return `Reference ${i + 1} — ${r.label}:${fileSummary ? ` ${fileSummary}` : ''}\n${r.text}`
|
|
150
|
+
})
|
|
151
|
+
.join('\n\n')
|
|
152
|
+
|
|
153
|
+
return `You are the expert aggregator/judge in a Mixture of Agents (MoA) process. You evaluate solutions from multiple candidate models, judge which one is best (or how to combine their best parts), and deliver the final authoritative verdict and solution.
|
|
154
|
+
|
|
155
|
+
Original user prompt:
|
|
156
|
+
${userPrompt}
|
|
157
|
+
|
|
158
|
+
Reference responses from candidate models:
|
|
159
|
+
${joined}
|
|
160
|
+
|
|
161
|
+
Instructions:
|
|
162
|
+
Your response MUST be structured into three clear parts (respond in the same language as the user's prompt, e.g. Russian):
|
|
163
|
+
|
|
164
|
+
### 1. ⚖️ Вердикт судьи и анализ вариантов
|
|
165
|
+
- **Чей вариант выбран**: Чётко укажи имя модели и номер кандидата (например: "Победитель: Кандидат 1 (opencode-go:deepseek-v4-flash)" или "Выбран вариант модели Reference 1 (label)").
|
|
166
|
+
- Обязательно добавь машинный маркер выбора победителя:
|
|
167
|
+
WINNER_CANDIDATE_INDEX: <число от 1 до N>
|
|
168
|
+
- **Почему сделан этот выбор**: Подробно сравни код, архитектуру, сильные стороны, недочёты и надёжность всех кандидатов.
|
|
169
|
+
|
|
170
|
+
### 2. 📁 Созданные файлы проекта
|
|
171
|
+
- Перечисли файлы победителя, которые были перенесены в корень проекта, и их назначение.
|
|
172
|
+
|
|
173
|
+
### 3. 🚀 Инструкция по запуску и использованию
|
|
174
|
+
- Опиши, как открыть и запустить созданный проект.`
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function parseWinnerIndex(judgeText, defaultIndex = 1) {
|
|
178
|
+
if (!judgeText || typeof judgeText !== 'string') return defaultIndex
|
|
179
|
+
const match = /WINNER_CANDIDATE_INDEX:\s*(\d+)/i.exec(judgeText)
|
|
180
|
+
if (match) {
|
|
181
|
+
const idx = parseInt(match[1], 10)
|
|
182
|
+
if (!isNaN(idx) && idx >= 1) return idx
|
|
183
|
+
}
|
|
184
|
+
// Fallback: look for "Кандидат 1", "Reference 1", "Кандидат 2", "Reference 2"
|
|
185
|
+
if (/(?:Кандидат|Reference)\s*2\b/i.test(judgeText) && !/(?:Кандидат|Reference)\s*1\b/i.test(judgeText)) {
|
|
186
|
+
return 2
|
|
187
|
+
}
|
|
188
|
+
return defaultIndex
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function parseMoACommand(text, presets = []) {
|
|
192
|
+
if (typeof text !== 'string' || !text.startsWith('/moa')) {
|
|
193
|
+
return null
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const remainder = text.slice(4).trim()
|
|
197
|
+
if (!remainder) {
|
|
198
|
+
return {
|
|
199
|
+
presetName: 'default',
|
|
200
|
+
prompt: '',
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const matchPreset = /^--preset=([a-zA-Z0-9_-]+)\s*(.*)/s.exec(remainder)
|
|
205
|
+
if (matchPreset) {
|
|
206
|
+
return {
|
|
207
|
+
presetName: matchPreset[1],
|
|
208
|
+
prompt: matchPreset[2] || '',
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const parts = remainder.split(/\s+/)
|
|
213
|
+
const firstWord = parts[0]
|
|
214
|
+
if (Array.isArray(presets)) {
|
|
215
|
+
const matched = presets.find((p) => p.name === firstWord)
|
|
216
|
+
if (matched) {
|
|
217
|
+
return {
|
|
218
|
+
presetName: matched.name,
|
|
219
|
+
prompt: parts.slice(1).join(' ').trim(),
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
presetName: 'default',
|
|
226
|
+
prompt: remainder,
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Dispatches queries to all reference models in parallel.
|
|
232
|
+
*/
|
|
233
|
+
export async function runReferencesParallel(references, messages, options = {}, callLlm, onProgress) {
|
|
234
|
+
if (!Array.isArray(references) || references.length === 0) {
|
|
235
|
+
return []
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const advisoryMessages = cleanAdvisoryMessages(messages)
|
|
239
|
+
const systemPrompt = options.systemPrompt || REFERENCE_SYSTEM_PROMPT
|
|
240
|
+
const fullMessages = [{ role: 'system', content: systemPrompt }, ...advisoryMessages]
|
|
241
|
+
const timeoutMs = options.timeoutMs ?? 75000
|
|
242
|
+
|
|
243
|
+
const tasks = references.map(async (slot, i) => {
|
|
244
|
+
const label = slotLabel(slot)
|
|
245
|
+
if (typeof onProgress === 'function') {
|
|
246
|
+
onProgress(`⚡ *Кандидат ${i + 1} (${label}) начал генерацию...*\n`)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
const callPromise = callLlm({
|
|
251
|
+
provider: slot.provider,
|
|
252
|
+
model: slot.model,
|
|
253
|
+
messages: fullMessages,
|
|
254
|
+
temperature: options.referenceTemperature ?? 0.6,
|
|
255
|
+
maxTokens: options.maxTokens ?? 4096,
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
const timeoutPromise = new Promise((_, reject) =>
|
|
259
|
+
setTimeout(() => reject(new Error(`Timeout after ${Math.round(timeoutMs / 1000)}s waiting for ${label}`)), timeoutMs).unref()
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
const res = await Promise.race([callPromise, timeoutPromise])
|
|
263
|
+
const text = (typeof res === 'string' ? res : (res?.content || res?.text || '')).trim()
|
|
264
|
+
const files = extractFileBlocks(text)
|
|
265
|
+
|
|
266
|
+
if (typeof onProgress === 'function') {
|
|
267
|
+
const fileMsg = files.length > 0 ? ` (создано файлов: ${files.length})` : ''
|
|
268
|
+
onProgress(`✅ *Кандидат ${i + 1} (${label}) завершил ответ${fileMsg}.*\n`)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
index: i + 1,
|
|
273
|
+
label,
|
|
274
|
+
provider: slot.provider,
|
|
275
|
+
model: slot.model,
|
|
276
|
+
text: text || '(empty response)',
|
|
277
|
+
files,
|
|
278
|
+
ok: true,
|
|
279
|
+
}
|
|
280
|
+
} catch (err) {
|
|
281
|
+
console.warn(`[dsh-moa] Reference ${label} failed:`, err)
|
|
282
|
+
if (typeof onProgress === 'function') {
|
|
283
|
+
onProgress(`⚠️ *Кандидат ${i + 1} (${label}) завершился с ошибкой: ${err?.message || String(err)}*\n`)
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
index: i + 1,
|
|
287
|
+
label,
|
|
288
|
+
provider: slot.provider,
|
|
289
|
+
model: slot.model,
|
|
290
|
+
text: `[failed: ${err?.message || String(err)}]`,
|
|
291
|
+
files: [],
|
|
292
|
+
ok: false,
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
return Promise.all(tasks)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Runs the full Mixture of Agents pipeline:
|
|
302
|
+
* 1. Checks if questions are needed
|
|
303
|
+
* 2. Parallel candidate proposers write to .moa/candidate-X/
|
|
304
|
+
* 3. Aggregator judges and picks winner
|
|
305
|
+
* 4. Promotes winner files and cleans up
|
|
306
|
+
*/
|
|
307
|
+
export async function runMoAPipeline({
|
|
308
|
+
userPrompt,
|
|
309
|
+
messages = [],
|
|
310
|
+
preset,
|
|
311
|
+
callLlm,
|
|
312
|
+
cwd,
|
|
313
|
+
onProgress,
|
|
314
|
+
skipQuestions = false,
|
|
315
|
+
}) {
|
|
316
|
+
if (typeof callLlm !== 'function') {
|
|
317
|
+
throw new Error('callLlm function is required for runMoAPipeline')
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const referenceModels = preset?.reference_models || preset?.referenceModels || []
|
|
321
|
+
const aggregator = preset?.aggregator || { provider: 'default', model: 'default' }
|
|
322
|
+
const refTemp = preset?.reference_temperature ?? preset?.referenceTemperature ?? 0.6
|
|
323
|
+
const aggTemp = preset?.aggregator_temperature ?? preset?.aggregatorTemperature ?? 0.4
|
|
324
|
+
const maxTokens = preset?.max_tokens ?? preset?.maxTokens ?? 4096
|
|
325
|
+
const workDir = cwd || process.cwd()
|
|
326
|
+
|
|
327
|
+
// Phase 1: Check if clarifying questions should be asked
|
|
328
|
+
const needsQuestions = !skipQuestions && isBroadPromptRequiringQuestions(userPrompt, messages)
|
|
329
|
+
if (needsQuestions) {
|
|
330
|
+
if (typeof onProgress === 'function') {
|
|
331
|
+
onProgress('🔍 *Задача общего характера. Советники формируют ключевые развилки...*\n\n')
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const questionOutputs = await runReferencesParallel(
|
|
335
|
+
referenceModels,
|
|
336
|
+
[...messages, { role: 'user', content: userPrompt }],
|
|
337
|
+
{ systemPrompt: ADVISOR_QUESTION_PROMPT, referenceTemperature: 0.5, maxTokens: 1024 },
|
|
338
|
+
callLlm,
|
|
339
|
+
onProgress,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
if (typeof onProgress === 'function') {
|
|
343
|
+
onProgress(`\n⚖️ *Судья (${slotLabel(aggregator)}) синтезирует единый опросник для вас...*\n\n`)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const qSynthPrompt = buildQuestionSynthesisPrompt(userPrompt, questionOutputs)
|
|
347
|
+
let questionsText = ''
|
|
348
|
+
try {
|
|
349
|
+
const qPromise = callLlm({
|
|
350
|
+
provider: aggregator.provider,
|
|
351
|
+
model: aggregator.model,
|
|
352
|
+
messages: [{ role: 'user', content: qSynthPrompt }],
|
|
353
|
+
temperature: 0.3,
|
|
354
|
+
maxTokens: 1500,
|
|
355
|
+
})
|
|
356
|
+
const qTimeout = new Promise((_, reject) =>
|
|
357
|
+
setTimeout(() => reject(new Error('Timeout waiting for judge questions synthesis')), 60000).unref()
|
|
358
|
+
)
|
|
359
|
+
const res = await Promise.race([qPromise, qTimeout])
|
|
360
|
+
questionsText = (typeof res === 'string' ? res : (res?.content || res?.text || '')).trim()
|
|
361
|
+
} catch (err) {
|
|
362
|
+
questionsText = `Не удалось сформировать вопросы судьи: ${err?.message || String(err)}`
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
kind: 'questions',
|
|
367
|
+
content: questionsText,
|
|
368
|
+
aggregator: slotLabel(aggregator),
|
|
369
|
+
references: questionOutputs,
|
|
370
|
+
presetName: preset?.name || 'default',
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Phase 2: Parallel execution & file generation
|
|
375
|
+
if (typeof onProgress === 'function') {
|
|
376
|
+
onProgress(`🚀 *Запускаю параллельную реализацию советниками (${referenceModels.length})...*\n\n`)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const referenceOutputs = await runReferencesParallel(
|
|
380
|
+
referenceModels,
|
|
381
|
+
[...messages, { role: 'user', content: userPrompt }],
|
|
382
|
+
{ referenceTemperature: refTemp, maxTokens },
|
|
383
|
+
callLlm,
|
|
384
|
+
onProgress,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
// Write files for each candidate into .moa/candidate-X/
|
|
388
|
+
for (let i = 0; i < referenceOutputs.length; i++) {
|
|
389
|
+
const ref = referenceOutputs[i]
|
|
390
|
+
if (ref.ok && ref.files && ref.files.length > 0) {
|
|
391
|
+
try {
|
|
392
|
+
await writeCandidateWorkspace(workDir, i + 1, ref.files)
|
|
393
|
+
if (typeof onProgress === 'function') {
|
|
394
|
+
onProgress(`📁 *Кандидат ${i + 1} (${ref.label}) сохранил ${ref.files.length} файл(а) в .moa/candidate-${i + 1}/*\n`)
|
|
395
|
+
}
|
|
396
|
+
} catch (writeErr) {
|
|
397
|
+
console.warn(`[dsh-moa] Failed to write candidate ${i + 1} workspace:`, writeErr)
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Phase 3: Aggregator evaluation
|
|
403
|
+
const aggLabel = slotLabel(aggregator)
|
|
404
|
+
if (typeof onProgress === 'function') {
|
|
405
|
+
onProgress(`\n⚖️ *Все кандидаты завершили генерацию. Судья (${aggLabel}) оценивает код и файлы...*\n\n`)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const synthPrompt = buildSynthesisPrompt(userPrompt, referenceOutputs)
|
|
409
|
+
|
|
410
|
+
let synthesizedText = ''
|
|
411
|
+
try {
|
|
412
|
+
const aggPromise = callLlm({
|
|
413
|
+
provider: aggregator.provider,
|
|
414
|
+
model: aggregator.model,
|
|
415
|
+
messages: [{ role: 'user', content: synthPrompt }],
|
|
416
|
+
temperature: aggTemp,
|
|
417
|
+
maxTokens,
|
|
418
|
+
})
|
|
419
|
+
const aggTimeout = new Promise((_, reject) =>
|
|
420
|
+
setTimeout(() => reject(new Error(`Timeout after 90s waiting for aggregator ${aggLabel}`)), 90000).unref()
|
|
421
|
+
)
|
|
422
|
+
const res = await Promise.race([aggPromise, aggTimeout])
|
|
423
|
+
synthesizedText = (typeof res === 'string' ? res : (res?.content || res?.text || '')).trim()
|
|
424
|
+
} catch (err) {
|
|
425
|
+
synthesizedText = `[Aggregator error: ${err?.message || String(err)}]\n\nFallback candidate outputs:\n\n` +
|
|
426
|
+
referenceOutputs.map((r, i) => `### ${r.label}\n${r.text}`).join('\n\n')
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Phase 4: Promote winner files and cleanup
|
|
430
|
+
const winningIndex = parseWinnerIndex(synthesizedText, 1)
|
|
431
|
+
let promotedFiles = []
|
|
432
|
+
try {
|
|
433
|
+
promotedFiles = await promoteCandidateWorkspace(workDir, winningIndex)
|
|
434
|
+
if (typeof onProgress === 'function' && promotedFiles.length > 0) {
|
|
435
|
+
onProgress(`\n✅ *Файлы победителя (Кандидат ${winningIndex}) перенесены в проект: ${promotedFiles.join(', ')}*\n\n`)
|
|
436
|
+
}
|
|
437
|
+
} catch (promoteErr) {
|
|
438
|
+
console.warn('[dsh-moa] Error promoting candidate files:', promoteErr)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Phase 5: Live Canvas preview auto-registration
|
|
442
|
+
let liveCanvas = null
|
|
443
|
+
if (promotedFiles && promotedFiles.length > 0) {
|
|
444
|
+
const previewCandidate = promotedFiles.find(f => /\.(html|htm|jsx|tsx|vue|svelte)$/i.test(f)) || promotedFiles[0]
|
|
445
|
+
if (previewCandidate) {
|
|
446
|
+
try {
|
|
447
|
+
const port = process.env.PORT || 3080
|
|
448
|
+
const lcRes = await fetch(`http://127.0.0.1:${port}/dsh-live-canvas/api/open-file`, {
|
|
449
|
+
method: 'POST',
|
|
450
|
+
headers: { 'Content-Type': 'application/json' },
|
|
451
|
+
body: JSON.stringify({ filePath: previewCandidate })
|
|
452
|
+
})
|
|
453
|
+
if (lcRes.ok) {
|
|
454
|
+
const lcData = await lcRes.json()
|
|
455
|
+
if (lcData?.canvasId) {
|
|
456
|
+
liveCanvas = {
|
|
457
|
+
canvasId: lcData.canvasId,
|
|
458
|
+
title: lcData.title || previewCandidate,
|
|
459
|
+
filePath: previewCandidate,
|
|
460
|
+
previewUrl: lcData.previewUrl || `/dsh-live-canvas/sandbox/${lcData.canvasId}`
|
|
461
|
+
}
|
|
462
|
+
if (typeof onProgress === 'function') {
|
|
463
|
+
onProgress(`\n🎨 *[Live Canvas]: Файл ${previewCandidate} открыт для предпросмотра!*\n\n`)
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
} catch (err) {
|
|
468
|
+
// live-canvas plugin might not be installed or reachable, non-fatal
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return {
|
|
474
|
+
kind: 'synthesis',
|
|
475
|
+
content: synthesizedText,
|
|
476
|
+
aggregator: aggLabel,
|
|
477
|
+
references: referenceOutputs,
|
|
478
|
+
presetName: preset?.name || 'default',
|
|
479
|
+
winningIndex,
|
|
480
|
+
promotedFiles,
|
|
481
|
+
liveCanvas,
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Formats full MoA output showing candidate outputs, judge synthesis, and promoted files.
|
|
487
|
+
*/
|
|
488
|
+
/**
|
|
489
|
+
* Replaces large code blocks with concise file/code summaries to avoid token waste and huge chat dumps.
|
|
490
|
+
*/
|
|
491
|
+
export function stripOrSummarizeCode(text) {
|
|
492
|
+
if (!text || typeof text !== 'string') return ''
|
|
493
|
+
return text.replace(/```([a-zA-Z0-9_\-\.\/]*)\s*([\w\.\/\-]+\.[a-zA-Z0-9]+)?\n([\s\S]*?)```/g, (match, lang, fileTag, code) => {
|
|
494
|
+
const lines = code.trim().split('\n')
|
|
495
|
+
// Keep very short commands (<= 3 lines) that are not HTML/JSX/source files
|
|
496
|
+
if (lines.length <= 3 && !/html|jsx|tsx|vue|svelte|css|js|ts/i.test(lang)) {
|
|
497
|
+
return match
|
|
498
|
+
}
|
|
499
|
+
const fileHint = fileTag || (code.match(/^\s*(?:\/\/|#|<!--|\/\*)\s*(?:file|filepath|path):\s*([^\s*]+)/im)?.[1])
|
|
500
|
+
const label = fileHint ? `файл \`${fileHint}\`` : (lang ? `код \`${lang}\`` : 'код')
|
|
501
|
+
return `\n> 📄 *[${label} — ${lines.length} строк сохранены на диск]*\n`
|
|
502
|
+
})
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
export function formatMoAResponse({ moaResult, presetName }) {
|
|
506
|
+
const parts = []
|
|
507
|
+
const pName = presetName || moaResult?.presetName || 'default'
|
|
508
|
+
const judge = moaResult?.aggregator || 'unknown'
|
|
509
|
+
const refs = moaResult?.references || []
|
|
510
|
+
|
|
511
|
+
if (moaResult?.kind === 'questions') {
|
|
512
|
+
parts.push('## 🧠 Mixture of Agents — Уточнение требований')
|
|
513
|
+
parts.push(`*Судья (${judge}) и советники проанализировали задачу:*\n`)
|
|
514
|
+
parts.push(moaResult.content)
|
|
515
|
+
return parts.join('\n')
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
parts.push(`## 🧠 Mixture of Agents (Пресет: ${pName} | Судья: ${judge})`)
|
|
519
|
+
parts.push('')
|
|
520
|
+
|
|
521
|
+
const hasPromoted = moaResult?.promotedFiles && moaResult.promotedFiles.length > 0
|
|
522
|
+
if (hasPromoted) {
|
|
523
|
+
parts.push(`> 📦 **Созданы файлы в проекте**: \`${moaResult.promotedFiles.join('`, `')}\``)
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if (moaResult?.liveCanvas?.previewUrl) {
|
|
527
|
+
parts.push(`> 🎨 **Live Canvas**: [🚀 Открыть ${moaResult.liveCanvas.title || 'превью'} в Live Canvas](${moaResult.liveCanvas.previewUrl}) | [↗ Открыть в новой вкладке](${moaResult.liveCanvas.previewUrl})`)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (hasPromoted || moaResult?.liveCanvas) {
|
|
531
|
+
parts.push('')
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
parts.push(`### ⚖️ Вердикт судьи и итоговое решение (Синтез: ${judge})`)
|
|
535
|
+
parts.push('')
|
|
536
|
+
const cleanJudgeContent = hasPromoted
|
|
537
|
+
? stripOrSummarizeCode(moaResult?.content || '')
|
|
538
|
+
: (moaResult?.content || '(нет ответа)')
|
|
539
|
+
parts.push(cleanJudgeContent)
|
|
540
|
+
parts.push('')
|
|
541
|
+
|
|
542
|
+
if (Array.isArray(refs) && refs.length > 0) {
|
|
543
|
+
parts.push(`### 👥 Ответы моделей-советников (${refs.length}):`)
|
|
544
|
+
parts.push('')
|
|
545
|
+
refs.forEach((ref, i) => {
|
|
546
|
+
const statusIcon = ref.ok ? '✅' : '⚠️'
|
|
547
|
+
const fileBadge = ref.files?.length ? ` (${ref.files.length} файл(ов))` : ''
|
|
548
|
+
parts.push(`#### ${statusIcon} Модель ${i + 1}: ${ref.label}${fileBadge}`)
|
|
549
|
+
parts.push('')
|
|
550
|
+
parts.push(stripOrSummarizeCode(ref.text))
|
|
551
|
+
parts.push('')
|
|
552
|
+
parts.push('---')
|
|
553
|
+
parts.push('')
|
|
554
|
+
})
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return parts.join('\n')
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export class MoaRunnerAdapter {
|
|
561
|
+
constructor(getMoaContext) {
|
|
562
|
+
this.getMoaContext = getMoaContext
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
providerInfo(provider) {
|
|
566
|
+
return { id: provider, name: 'Mixture of Agents Runner' }
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
providerRetryPolicy(_provider) {
|
|
570
|
+
return undefined
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
imageRequestPricing(_provider, _model) {
|
|
574
|
+
return undefined
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async listModels(provider) {
|
|
578
|
+
const prov = typeof provider === 'string' && provider.length > 0 ? provider : 'moa-runner'
|
|
579
|
+
return [{ provider: prov, id: 'ensemble', name: 'MoA Ensemble' }]
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async resolveModel(provider, model) {
|
|
583
|
+
return { provider, id: model, name: 'MoA Ensemble' }
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async prepareCall(provider, model) {
|
|
587
|
+
return {
|
|
588
|
+
model: { provider, id: model, name: 'MoA Ensemble' },
|
|
589
|
+
stream: (options) => this.stream(options),
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async *stream(options) {
|
|
594
|
+
const signal = options.signal
|
|
595
|
+
if (signal?.aborted) return
|
|
596
|
+
|
|
597
|
+
yield { type: 'block-start', index: 0, blockType: 'text' }
|
|
598
|
+
yield { type: 'text-delta', index: 0, text: '🧠 *Mixture of Agents запущен...*\n\n' }
|
|
599
|
+
|
|
600
|
+
const { targetPreset, userPrompt, messages, callLlm, cwd } = this.getMoaContext(options)
|
|
601
|
+
|
|
602
|
+
// Queue for streaming progress updates from within runMoAPipeline
|
|
603
|
+
const progressChunks = []
|
|
604
|
+
const onProgress = (text) => {
|
|
605
|
+
progressChunks.push(text)
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
try {
|
|
609
|
+
const pipelinePromise = runMoAPipeline({
|
|
610
|
+
userPrompt,
|
|
611
|
+
messages,
|
|
612
|
+
preset: targetPreset,
|
|
613
|
+
callLlm,
|
|
614
|
+
cwd,
|
|
615
|
+
onProgress,
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
const startTime = Date.now()
|
|
619
|
+
let lastYieldTime = Date.now()
|
|
620
|
+
|
|
621
|
+
// Interval to yield progressive updates while pipeline runs
|
|
622
|
+
while (true) {
|
|
623
|
+
if (signal?.aborted) return
|
|
624
|
+
|
|
625
|
+
let emittedAny = false
|
|
626
|
+
while (progressChunks.length > 0) {
|
|
627
|
+
const chunk = progressChunks.shift()
|
|
628
|
+
yield { type: 'text-delta', index: 0, text: chunk }
|
|
629
|
+
emittedAny = true
|
|
630
|
+
lastYieldTime = Date.now()
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const done = await Promise.race([
|
|
634
|
+
pipelinePromise.then(() => true),
|
|
635
|
+
new Promise((r) => { const t = setTimeout(() => r(false), 500); t.unref?.(); }),
|
|
636
|
+
])
|
|
637
|
+
if (done) break
|
|
638
|
+
|
|
639
|
+
const elapsedSec = Math.floor((Date.now() - startTime) / 1000)
|
|
640
|
+
// If 3 seconds passed without any progress message, send a live tick
|
|
641
|
+
if (!emittedAny && Date.now() - lastYieldTime >= 3000) {
|
|
642
|
+
yield { type: 'text-delta', index: 0, text: `⏳ *[${elapsedSec}с] Идёт обработка...*\n` }
|
|
643
|
+
lastYieldTime = Date.now()
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
while (progressChunks.length > 0) {
|
|
648
|
+
const chunk = progressChunks.shift()
|
|
649
|
+
yield { type: 'text-delta', index: 0, text: chunk }
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const moaResult = await pipelinePromise
|
|
653
|
+
if (signal?.aborted) return
|
|
654
|
+
|
|
655
|
+
const formatted = formatMoAResponse({
|
|
656
|
+
moaResult,
|
|
657
|
+
presetName: targetPreset?.name || 'default',
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
yield { type: 'text-delta', index: 0, text: '\n---\n\n' + formatted }
|
|
661
|
+
yield { type: 'block-end', index: 0, block: { type: 'text', text: formatted } }
|
|
662
|
+
yield { type: 'usage', usage: { inputTokens: 100, outputTokens: formatted.length } }
|
|
663
|
+
yield { type: 'finish', reason: { kind: 'stop' } }
|
|
664
|
+
} catch (err) {
|
|
665
|
+
if (signal?.aborted) return
|
|
666
|
+
const errText = '\n\n⚠️ **Ошибка Mixture of Agents**: ' + (err?.message || String(err))
|
|
667
|
+
yield { type: 'text-delta', index: 0, text: errText }
|
|
668
|
+
yield { type: 'block-end', index: 0, block: { type: 'text', text: errText } }
|
|
669
|
+
yield { type: 'finish', reason: { kind: 'stop' } }
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|