@playcraft/cli 0.0.49 → 0.0.52
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/README.md +65 -44
- package/REMIX.md +760 -0
- package/dist/commands/login.js +82 -32
- package/dist/commands/remix.js +1210 -176
- package/dist/utils/playcraft-http-client.js +172 -0
- package/dist/utils/remix-image-attachments.js +125 -0
- package/package.json +8 -3
- package/dist/agent/cloud-connection.test.js +0 -67
- package/dist/agent/local-backend.test.js +0 -52
- package/dist/commands/fix-ids.test.js +0 -264
- package/dist/commands/tools-research.test.js +0 -216
- package/dist/prad/check.test.js +0 -49
- package/dist/sync/sync-manager.test.js +0 -80
package/dist/commands/remix.js
CHANGED
|
@@ -1,97 +1,604 @@
|
|
|
1
1
|
import inquirer from 'inquirer';
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import autocomplete from 'inquirer-autocomplete-prompt';
|
|
3
|
+
import { exec } from 'node:child_process';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { PlayCraftApiError, PlayCraftHttpClient, isPlayCraftApiError } from '../utils/playcraft-http-client.js';
|
|
6
|
+
import { IMAGE_OPTION_HELP, buildReferenceImagesPrompt, readImageAttachments, uploadImageAttachments, } from '../utils/remix-image-attachments.js';
|
|
5
7
|
import { initTemplateCommand } from '../remix/init-template.js';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const globalConfig = loadGlobalConfig();
|
|
17
|
-
const token = options.token || process.env.PLAYCRAFT_TOKEN || globalConfig.token;
|
|
18
|
-
if (!token) {
|
|
19
|
-
throw new Error('缺少 JWT token。请先执行 `playcraft login`,或传入 --token / PLAYCRAFT_TOKEN。');
|
|
20
|
-
}
|
|
21
|
-
return token;
|
|
8
|
+
// Register autocomplete prompt
|
|
9
|
+
inquirer.registerPrompt('autocomplete', autocomplete);
|
|
10
|
+
const JSON_OUTPUT_HELP = '输出一条 JSON (JavaScript Object Notation, JavaScript 对象表示法) 结果';
|
|
11
|
+
const SERVICE_ADDRESS_HELP = 'PlayCraft 服务地址';
|
|
12
|
+
const PAT_HELP = 'PAT (Personal Access Token, 个人访问令牌)';
|
|
13
|
+
const BACK_TO_TEAMS = '__back_to_teams__';
|
|
14
|
+
const BACK_TO_TEMPLATES = '__back_to_templates__';
|
|
15
|
+
const NO_MATCH = '__no_match__';
|
|
16
|
+
function emitProgress(options, message) {
|
|
17
|
+
(options.json ? process.stderr : process.stdout).write(`${message}\n`);
|
|
22
18
|
}
|
|
23
|
-
function
|
|
24
|
-
|
|
25
|
-
|
|
19
|
+
function shellQuote(value) {
|
|
20
|
+
if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value))
|
|
21
|
+
return value;
|
|
22
|
+
return `"${value.replace(/"/g, '\\"')}"`;
|
|
23
|
+
}
|
|
24
|
+
function buildOneLineRetryCommand(command, options) {
|
|
25
|
+
const parts = ['playcraft'];
|
|
26
|
+
if (command === 'remix.start')
|
|
27
|
+
parts.push('remix', 'start');
|
|
28
|
+
else if (command === 'remix.prompt')
|
|
29
|
+
parts.push('remix', 'prompt');
|
|
30
|
+
else if (command === 'remix.run')
|
|
31
|
+
parts.push('remix', 'run');
|
|
32
|
+
else if (command === 'remix.create')
|
|
33
|
+
parts.push('remix', 'create');
|
|
34
|
+
else
|
|
35
|
+
return undefined;
|
|
36
|
+
const projectId = options.projectId != null ? String(options.projectId) : '';
|
|
37
|
+
const branch = options.branch != null ? String(options.branch) : '';
|
|
38
|
+
if (projectId)
|
|
39
|
+
parts.push('--project-id', shellQuote(projectId));
|
|
40
|
+
if (branch)
|
|
41
|
+
parts.push('--branch', shellQuote(branch));
|
|
42
|
+
if (options.sourceBranch)
|
|
43
|
+
parts.push('--source-branch', shellQuote(String(options.sourceBranch)));
|
|
44
|
+
if (options.teamId)
|
|
45
|
+
parts.push('--team-id', shellQuote(String(options.teamId)));
|
|
46
|
+
if (options.templateId)
|
|
47
|
+
parts.push('--template-id', shellQuote(String(options.templateId)));
|
|
48
|
+
if (options.name)
|
|
49
|
+
parts.push('--name', shellQuote(String(options.name)));
|
|
50
|
+
if (options.prompt)
|
|
51
|
+
parts.push('--prompt', shellQuote(String(options.prompt)));
|
|
52
|
+
if (options.promptFile)
|
|
53
|
+
parts.push('--prompt-file', shellQuote(String(options.promptFile)));
|
|
54
|
+
if (options.evictSlotId)
|
|
55
|
+
parts.push('--evict-slot-id', shellQuote(String(options.evictSlotId)));
|
|
56
|
+
if (options.evictConfirmed)
|
|
57
|
+
parts.push('--evict-confirmed');
|
|
58
|
+
if (options.open)
|
|
59
|
+
parts.push('--open');
|
|
60
|
+
if (options.wait === false)
|
|
61
|
+
parts.push('--no-wait');
|
|
62
|
+
if (options.url)
|
|
63
|
+
parts.push('--url', shellQuote(String(options.url)));
|
|
64
|
+
if (Array.isArray(options.images) && options.images.length) {
|
|
65
|
+
parts.push('--image', ...options.images.map((image) => shellQuote(String(image))));
|
|
66
|
+
}
|
|
67
|
+
return parts.join(' ');
|
|
68
|
+
}
|
|
69
|
+
function isNetworkLikeError(error) {
|
|
70
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
71
|
+
return /网络请求失败|fetch failed|ECONNRESET|ETIMEDOUT|ENOTFOUND|ECONNREFUSED|socket hang up/i.test(message);
|
|
72
|
+
}
|
|
73
|
+
async function sleep(ms) {
|
|
74
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
75
|
+
}
|
|
76
|
+
async function fetchWithRetry(input, init, options = {}) {
|
|
77
|
+
const attempts = options.attempts ?? 3;
|
|
78
|
+
const delays = [1000, 2000, 4000];
|
|
79
|
+
let lastError;
|
|
80
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
81
|
+
try {
|
|
82
|
+
return await fetch(input, init);
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
lastError = error;
|
|
86
|
+
if (attempt === attempts - 1)
|
|
87
|
+
break;
|
|
88
|
+
await sleep(delays[attempt] ?? 4000);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
throw new PlayCraftApiError({
|
|
92
|
+
code: 'AGENT_REQUEST_FAILED',
|
|
93
|
+
message: `网络请求失败(${lastError instanceof Error ? lastError.message : String(lastError)})。请稍后重试。`,
|
|
94
|
+
retryable: true,
|
|
95
|
+
});
|
|
26
96
|
}
|
|
27
97
|
function formatIdleSec(idleSec) {
|
|
28
98
|
if (!idleSec || idleSec <= 0)
|
|
29
99
|
return '0s';
|
|
30
100
|
if (idleSec < 60)
|
|
31
101
|
return `${idleSec}s`;
|
|
32
|
-
|
|
33
|
-
const s = idleSec % 60;
|
|
34
|
-
return `${m}m${s}s`;
|
|
102
|
+
return `${Math.floor(idleSec / 60)}m${idleSec % 60}s`;
|
|
35
103
|
}
|
|
36
|
-
function
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
104
|
+
function parseAgentQuestions(input) {
|
|
105
|
+
let raw = input;
|
|
106
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
107
|
+
raw = raw.questions ?? [raw];
|
|
108
|
+
}
|
|
109
|
+
if (typeof raw === 'string') {
|
|
110
|
+
try {
|
|
111
|
+
raw = JSON.parse(raw);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (!Array.isArray(raw))
|
|
118
|
+
return [];
|
|
119
|
+
return raw.flatMap((value) => {
|
|
120
|
+
if (!value || typeof value !== 'object')
|
|
121
|
+
return [];
|
|
122
|
+
const question = value;
|
|
123
|
+
const text = [question.question, question.prompt, question.text]
|
|
124
|
+
.find((candidate) => typeof candidate === 'string' && candidate.trim().length > 0);
|
|
125
|
+
if (!text)
|
|
126
|
+
return [];
|
|
127
|
+
const options = (Array.isArray(question.options) ? question.options : []).flatMap((option) => {
|
|
128
|
+
if (typeof option === 'string' && option.trim())
|
|
129
|
+
return [{ label: option.trim() }];
|
|
130
|
+
if (!option || typeof option !== 'object')
|
|
131
|
+
return [];
|
|
132
|
+
const item = option;
|
|
133
|
+
const label = [item.label, item.text, item.value, item.description]
|
|
134
|
+
.find((candidate) => typeof candidate === 'string' && candidate.trim().length > 0);
|
|
135
|
+
if (!label)
|
|
136
|
+
return [];
|
|
137
|
+
return [{
|
|
138
|
+
label: label.trim(),
|
|
139
|
+
...(typeof item.description === 'string' && item.description.trim()
|
|
140
|
+
? { description: item.description.trim() }
|
|
141
|
+
: {}),
|
|
142
|
+
}];
|
|
143
|
+
});
|
|
144
|
+
const custom = question.custom !== false;
|
|
145
|
+
if (!options.length && !custom)
|
|
146
|
+
return [];
|
|
147
|
+
return [{
|
|
148
|
+
question: text.trim(),
|
|
149
|
+
...(typeof question.header === 'string' && question.header.trim()
|
|
150
|
+
? { header: question.header.trim() }
|
|
151
|
+
: {}),
|
|
152
|
+
options,
|
|
153
|
+
multiple: question.multiple === true,
|
|
154
|
+
custom,
|
|
155
|
+
}];
|
|
156
|
+
});
|
|
40
157
|
}
|
|
41
|
-
async function
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
158
|
+
async function promptForAgentAnswers(questions) {
|
|
159
|
+
const answers = [];
|
|
160
|
+
for (let index = 0; index < questions.length; index += 1) {
|
|
161
|
+
const question = questions[index];
|
|
162
|
+
const message = `${question.header ? `${question.header}:` : ''}${question.question}`;
|
|
163
|
+
const choices = question.options.map((option, optionIndex) => ({
|
|
164
|
+
name: option.description && option.description !== option.label
|
|
165
|
+
? `${option.label} — ${option.description}`
|
|
166
|
+
: option.label,
|
|
167
|
+
value: optionIndex,
|
|
168
|
+
}));
|
|
169
|
+
const customValue = -1;
|
|
170
|
+
if (question.custom)
|
|
171
|
+
choices.push({ name: '其他(手动输入)', value: customValue });
|
|
172
|
+
let selectedIndexes;
|
|
173
|
+
if (!question.options.length && question.custom) {
|
|
174
|
+
selectedIndexes = [customValue];
|
|
175
|
+
}
|
|
176
|
+
else if (question.multiple) {
|
|
177
|
+
const selected = await inquirer.prompt([{
|
|
178
|
+
type: 'checkbox',
|
|
179
|
+
name: 'values',
|
|
180
|
+
message: `${message}(可多选)`,
|
|
181
|
+
choices,
|
|
182
|
+
validate: (values) => values.length > 0 || '请至少选择一项。',
|
|
183
|
+
}]);
|
|
184
|
+
selectedIndexes = selected.values;
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
const selected = await inquirer.prompt([{
|
|
188
|
+
type: 'list',
|
|
189
|
+
name: 'value',
|
|
190
|
+
message,
|
|
191
|
+
choices,
|
|
192
|
+
}]);
|
|
193
|
+
selectedIndexes = [selected.value];
|
|
194
|
+
}
|
|
195
|
+
const answer = selectedIndexes
|
|
196
|
+
.filter((selected) => selected !== customValue)
|
|
197
|
+
.map((selected) => question.options[selected]?.label)
|
|
198
|
+
.filter((label) => Boolean(label));
|
|
199
|
+
if (selectedIndexes.includes(customValue)) {
|
|
200
|
+
const custom = await inquirer.prompt([{
|
|
201
|
+
type: 'input',
|
|
202
|
+
name: 'text',
|
|
203
|
+
message: `${question.header || `问题 ${index + 1}`},请输入自定义答案:`,
|
|
204
|
+
validate: (value) => Boolean(value.trim()) || '答案不能为空。',
|
|
205
|
+
}]);
|
|
206
|
+
answer.push(custom.text.trim());
|
|
207
|
+
}
|
|
208
|
+
answers.push(answer);
|
|
209
|
+
}
|
|
210
|
+
return answers;
|
|
211
|
+
}
|
|
212
|
+
function parseAgentQuestionRequests(input) {
|
|
213
|
+
const values = Array.isArray(input) ? input : input && typeof input === 'object' ? [input] : [];
|
|
214
|
+
return values.flatMap((value) => {
|
|
215
|
+
if (!value || typeof value !== 'object')
|
|
216
|
+
return [];
|
|
217
|
+
const payload = value;
|
|
218
|
+
const id = [payload.id, payload.requestID, payload.requestId]
|
|
219
|
+
.find((candidate) => typeof candidate === 'string' && candidate.length > 0);
|
|
220
|
+
if (!id)
|
|
221
|
+
return [];
|
|
222
|
+
const sessionId = [payload.sessionID, payload.sessionId, payload.session_id]
|
|
223
|
+
.find((candidate) => typeof candidate === 'string' && candidate.length > 0);
|
|
224
|
+
return [{
|
|
225
|
+
id,
|
|
226
|
+
...(sessionId ? { sessionId } : {}),
|
|
227
|
+
questions: parseAgentQuestions(payload.questions ?? payload),
|
|
228
|
+
raw: value,
|
|
229
|
+
}];
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
async function replyToAgentQuestion(client, ready, options, request, handledQuestionIds) {
|
|
233
|
+
if (request.sessionId && request.sessionId !== ready.agentSessionId)
|
|
234
|
+
return;
|
|
235
|
+
if (handledQuestionIds.has(request.id))
|
|
236
|
+
return;
|
|
237
|
+
handledQuestionIds.add(request.id);
|
|
238
|
+
if (!request.questions.length) {
|
|
239
|
+
throw new PlayCraftApiError({
|
|
240
|
+
code: 'AGENT_REQUEST_FAILED',
|
|
241
|
+
message: 'Agent 请求用户选择,但返回的问题格式无法识别。请打开 Remix 编辑器回答。',
|
|
242
|
+
details: request.raw,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
if (!options.interactive) {
|
|
246
|
+
throw new PlayCraftApiError({
|
|
247
|
+
code: 'AGENT_REQUEST_FAILED',
|
|
248
|
+
message: 'Agent 正在等待用户选择。请在交互式终端运行命令,或打开 Remix 编辑器回答。',
|
|
249
|
+
details: { requestId: request.id, questions: request.questions },
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
emitProgress(options, `[等待选择] Agent 提出了 ${request.questions.length} 个问题:`);
|
|
253
|
+
const answers = await promptForAgentAnswers(request.questions);
|
|
254
|
+
await client.post(`${ready.agentEndpoint}/question/${encodeURIComponent(request.id)}/reply`, { answers });
|
|
255
|
+
emitProgress(options, '[已回复] 选择已发送,Agent 继续执行...');
|
|
256
|
+
}
|
|
257
|
+
function openBrowser(url) {
|
|
258
|
+
const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
259
|
+
exec(`${command} "${url}"`, () => undefined);
|
|
260
|
+
}
|
|
261
|
+
function localServiceUrl(baseUrl, fromPort, toPort) {
|
|
262
|
+
const url = new URL(baseUrl);
|
|
263
|
+
const isLocal = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
|
|
264
|
+
if (isLocal && url.port === fromPort)
|
|
265
|
+
url.port = toPort;
|
|
266
|
+
return url.toString().replace(/\/+$/, '');
|
|
267
|
+
}
|
|
268
|
+
function createRemixClient(options) {
|
|
269
|
+
const configured = new PlayCraftHttpClient(options);
|
|
270
|
+
const apiBaseUrl = localServiceUrl(configured.baseUrl, '3000', '3001');
|
|
271
|
+
if (apiBaseUrl === configured.baseUrl)
|
|
272
|
+
return configured;
|
|
273
|
+
return new PlayCraftHttpClient({
|
|
274
|
+
url: apiBaseUrl,
|
|
275
|
+
token: configured.token,
|
|
276
|
+
requestId: configured.requestId,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
function editorUrl(client, projectId, branch) {
|
|
280
|
+
const configured = process.env.PLAYCRAFT_FRONTEND_URL || process.env.FRONTEND_URL;
|
|
281
|
+
const base = configured || localServiceUrl(client.baseUrl, '3001', '3000');
|
|
282
|
+
return `${base.replace(/\/+$/, '')}/editor/remix/${encodeURIComponent(projectId)}?branch=${encodeURIComponent(branch)}`;
|
|
283
|
+
}
|
|
284
|
+
function remixDetailUrl(client, projectId, branch) {
|
|
285
|
+
const configured = process.env.PLAYCRAFT_FRONTEND_URL || process.env.FRONTEND_URL;
|
|
286
|
+
const base = configured || localServiceUrl(client.baseUrl, '3001', '3000');
|
|
287
|
+
return `${base.replace(/\/+$/, '')}/projects/${encodeURIComponent(projectId)}/remixes/${encodeURIComponent(branch)}`;
|
|
288
|
+
}
|
|
289
|
+
function isAgentPromptExit(value) {
|
|
290
|
+
return ['', 'q', 'quit', 'exit', '退出'].includes(value.trim().toLowerCase());
|
|
291
|
+
}
|
|
292
|
+
async function resolveEditorProjectId(client, projectId) {
|
|
293
|
+
const project = await client.get(`/projects/${encodeURIComponent(projectId)}`);
|
|
294
|
+
if (project?.projectType === 'external' && typeof project.uuid === 'string' && project.uuid) {
|
|
295
|
+
return project.uuid;
|
|
296
|
+
}
|
|
297
|
+
if (project?.id !== undefined && project?.id !== null)
|
|
298
|
+
return String(project.id);
|
|
299
|
+
return projectId;
|
|
300
|
+
}
|
|
301
|
+
async function resolveEditorProjectIdBestEffort(client, projectId, options, fallbackProjectId = projectId) {
|
|
302
|
+
try {
|
|
303
|
+
return await resolveEditorProjectId(client, projectId);
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
emitProgress(options, `[提示] 项目操作已成功,但暂时无法解析编辑器项目标识,链接临时使用项目 ID:${fallbackProjectId}`);
|
|
307
|
+
return fallbackProjectId;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
function parseList(payload) {
|
|
311
|
+
if (Array.isArray(payload))
|
|
312
|
+
return payload;
|
|
313
|
+
if (payload && typeof payload === 'object' && Array.isArray(payload.result)) {
|
|
314
|
+
return payload.result;
|
|
315
|
+
}
|
|
316
|
+
return [];
|
|
317
|
+
}
|
|
318
|
+
function parseProjectTemplates(payload) {
|
|
319
|
+
return parseList(payload).map((project) => ({
|
|
320
|
+
id: String(project.id),
|
|
321
|
+
uuid: project.uuid,
|
|
322
|
+
name: project.name,
|
|
323
|
+
projectType: project.projectType,
|
|
324
|
+
description: project.description,
|
|
325
|
+
tags: project.tags,
|
|
326
|
+
thumbnailUrl: project.thumbnailUrl,
|
|
327
|
+
sourceBranch: project.settings?.git?.branch || 'main',
|
|
328
|
+
}));
|
|
329
|
+
}
|
|
330
|
+
export function parseRecommendedPromptTemplates(payload) {
|
|
331
|
+
const items = payload && typeof payload === 'object' && Array.isArray(payload.items)
|
|
332
|
+
? payload.items
|
|
333
|
+
: [];
|
|
334
|
+
return items.flatMap((item) => {
|
|
335
|
+
const projectId = item.baseProject?.numericId ?? item.baseProject?.id ?? item.baseProjectId;
|
|
336
|
+
const name = item.name?.trim();
|
|
337
|
+
if (!projectId || !name)
|
|
338
|
+
return [];
|
|
339
|
+
return [{
|
|
340
|
+
id: String(projectId),
|
|
341
|
+
uuid: item.baseProject?.id,
|
|
342
|
+
choiceValue: `recommended-prompt:${item.id ?? name}:${projectId}`,
|
|
343
|
+
name,
|
|
344
|
+
baseProjectName: item.baseProject?.name,
|
|
345
|
+
projectType: item.baseProject?.projectType,
|
|
346
|
+
thumbnailUrl: item.coverImageUrl ?? item.baseProject?.thumbnailUrl,
|
|
347
|
+
sourceBranch: 'main',
|
|
348
|
+
useExactSourceBranch: true,
|
|
349
|
+
source: 'system',
|
|
350
|
+
}];
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
export function parseGalleryTemplates(payload) {
|
|
354
|
+
const items = payload && typeof payload === 'object' && Array.isArray(payload.items)
|
|
355
|
+
? payload.items
|
|
356
|
+
: [];
|
|
357
|
+
return items.flatMap((item) => {
|
|
358
|
+
const projectId = item.projectId ?? item.template?.id;
|
|
359
|
+
const name = item.displayName?.trim() || item.template?.name?.trim();
|
|
360
|
+
if (projectId === undefined || !name)
|
|
361
|
+
return [];
|
|
362
|
+
const sourceBranch = item.isMain ? 'main' : item.branchName?.trim() || 'main';
|
|
363
|
+
return [{
|
|
364
|
+
id: String(projectId),
|
|
365
|
+
uuid: item.projectUuid,
|
|
366
|
+
choiceValue: `recommended-remix:${item.id ?? `${projectId}/${sourceBranch}`}`,
|
|
367
|
+
name,
|
|
368
|
+
baseProjectName: item.template?.name,
|
|
369
|
+
projectType: item.projectType,
|
|
370
|
+
thumbnailUrl: item.thumbnailUrl,
|
|
371
|
+
sourceBranch,
|
|
372
|
+
useExactSourceBranch: true,
|
|
373
|
+
source: 'system',
|
|
374
|
+
}];
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
export function buildTemplateChoices(templates) {
|
|
378
|
+
return templates.map((template) => {
|
|
379
|
+
const sourceLabel = template.source === 'system' ? '[系统推荐]' : '[团队模板]';
|
|
380
|
+
const baseLabel = template.baseProjectName && template.baseProjectName !== template.name
|
|
381
|
+
? ` (模板: ${template.baseProjectName}, ID: ${template.id})`
|
|
382
|
+
: ` (ID: ${template.id})`;
|
|
383
|
+
return {
|
|
384
|
+
name: `${sourceLabel} ${template.name}${baseLabel}`,
|
|
385
|
+
value: template.choiceValue ?? template.id,
|
|
386
|
+
};
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
export function isTemplateProjectAccessDenied(error) {
|
|
390
|
+
return isPlayCraftApiError(error) && (error.status === 403 || error.code === 'FORBIDDEN');
|
|
391
|
+
}
|
|
392
|
+
export function buildRemixBranchChoices(payload, mainBranch) {
|
|
393
|
+
const seen = new Set([mainBranch]);
|
|
394
|
+
const remixChoices = parseList(payload).flatMap((branch) => {
|
|
395
|
+
const branchName = branch.id?.trim();
|
|
396
|
+
if (!branchName || seen.has(branchName) || branchName === 'main' || branchName === 'master') {
|
|
397
|
+
return [];
|
|
398
|
+
}
|
|
399
|
+
seen.add(branchName);
|
|
400
|
+
return [{
|
|
401
|
+
name: `[Remix] ${branch.name?.trim() || branchName} (${branchName})`,
|
|
402
|
+
value: branchName,
|
|
403
|
+
}];
|
|
55
404
|
});
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
405
|
+
return [
|
|
406
|
+
{ name: '[返回] 重新选择模板(输入 back 快速定位)', value: BACK_TO_TEMPLATES },
|
|
407
|
+
{ name: `${mainBranch} (从模板主分支创建新 Remix)`, value: mainBranch },
|
|
408
|
+
...remixChoices,
|
|
409
|
+
];
|
|
410
|
+
}
|
|
411
|
+
function filterPromptChoices(choices, input) {
|
|
412
|
+
const rawQuery = input?.trim().toLowerCase() || '';
|
|
413
|
+
const query = rawQuery === 'back' ? '返回' : rawQuery;
|
|
414
|
+
if (!query)
|
|
415
|
+
return choices;
|
|
416
|
+
const filtered = choices.filter((choice) => choice.name.toLowerCase().includes(query) || choice.value.toLowerCase().includes(query));
|
|
417
|
+
return filtered.length
|
|
418
|
+
? filtered
|
|
419
|
+
: [{ name: '[无匹配] 请按 Backspace 删除关键词,或输入 back 返回', value: NO_MATCH }];
|
|
420
|
+
}
|
|
421
|
+
function readPrompt(options) {
|
|
422
|
+
if (options.prompt && options.promptFile) {
|
|
423
|
+
throw new PlayCraftApiError({ code: 'AGENT_REQUEST_FAILED', message: '--prompt 和 --prompt-file 只能使用一个。' });
|
|
424
|
+
}
|
|
425
|
+
const prompt = options.promptFile ? readFileSync(options.promptFile, 'utf8') : options.prompt || '';
|
|
426
|
+
const trimmed = prompt.trim();
|
|
427
|
+
if (!trimmed) {
|
|
428
|
+
throw new PlayCraftApiError({ code: 'AGENT_REQUEST_FAILED', message: '需求描述不能为空。' });
|
|
429
|
+
}
|
|
430
|
+
if (Buffer.byteLength(trimmed, 'utf8') > 256 * 1024) {
|
|
431
|
+
throw new PlayCraftApiError({ code: 'AGENT_REQUEST_FAILED', message: '需求描述超过 256 KiB (Kibibyte, 千二进制字节) 限制。' });
|
|
59
432
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
433
|
+
return trimmed;
|
|
434
|
+
}
|
|
435
|
+
function resultEnvelope(command, client, data) {
|
|
436
|
+
return { schemaVersion: '1.0', ok: true, command, requestId: client.requestId, data };
|
|
437
|
+
}
|
|
438
|
+
function errorEnvelope(command, client, error) {
|
|
439
|
+
const apiError = isPlayCraftApiError(error)
|
|
440
|
+
? error
|
|
441
|
+
: new PlayCraftApiError({ code: 'AGENT_REQUEST_FAILED', message: error instanceof Error ? error.message : String(error) });
|
|
442
|
+
return {
|
|
443
|
+
schemaVersion: '1.0',
|
|
444
|
+
ok: false,
|
|
445
|
+
command,
|
|
446
|
+
...(client ? { requestId: client.requestId } : {}),
|
|
447
|
+
error: {
|
|
448
|
+
code: apiError.code,
|
|
449
|
+
message: apiError.message,
|
|
450
|
+
retryable: apiError.retryable,
|
|
451
|
+
...(apiError.details === undefined ? {} : { details: apiError.details }),
|
|
452
|
+
},
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
function exitCodeForError(error) {
|
|
456
|
+
const code = isPlayCraftApiError(error) ? error.code : '';
|
|
457
|
+
if (code === 'AUTH_REQUIRED' || code === 'AUTH_EXPIRED')
|
|
458
|
+
return 3;
|
|
459
|
+
if (code === 'FORBIDDEN' || code === 'TEAM_FORBIDDEN' || code === 'TEAM_NOT_FOUND' || code === 'TEMPLATE_NOT_FOUND')
|
|
460
|
+
return 4;
|
|
461
|
+
if (code === 'AGENT_TIMEOUT' || code === 'AGENT_REQUEST_FAILED' || code === 'AGENT_UNAVAILABLE')
|
|
462
|
+
return 5;
|
|
463
|
+
return 2;
|
|
464
|
+
}
|
|
465
|
+
function handleCommandError(command, options, client, error) {
|
|
466
|
+
if (options.json) {
|
|
467
|
+
process.stdout.write(`${JSON.stringify(errorEnvelope(command, client, error))}\n`);
|
|
468
|
+
}
|
|
469
|
+
else {
|
|
470
|
+
const code = isPlayCraftApiError(error) ? error.code : undefined;
|
|
471
|
+
let message = code === 'AUTH_REQUIRED'
|
|
472
|
+
? '需要登录。请先运行 `playcraft login`,或提供 PLAYCRAFT_TOKEN。'
|
|
473
|
+
: code === 'AUTH_EXPIRED'
|
|
474
|
+
? '登录已过期,请重新运行 `playcraft login`。'
|
|
475
|
+
: error instanceof Error ? error.message : String(error);
|
|
476
|
+
// 把裸的 fetch failed 转成可操作的中文提示
|
|
477
|
+
if (message === 'fetch failed' || /^TypeError: fetch failed$/i.test(message)) {
|
|
478
|
+
message = '网络请求失败(fetch failed)。请稍后重试。';
|
|
479
|
+
}
|
|
480
|
+
const retryable = isPlayCraftApiError(error) ? error.retryable : isNetworkLikeError(error);
|
|
481
|
+
const retryCommand = (retryable || isNetworkLikeError(error))
|
|
482
|
+
? buildOneLineRetryCommand(command, options)
|
|
483
|
+
: undefined;
|
|
484
|
+
if (retryCommand) {
|
|
485
|
+
console.error(`错误:${message}`);
|
|
486
|
+
console.error('请重试:');
|
|
487
|
+
console.error(retryCommand);
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
console.error(`错误:${message}`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
process.exitCode = exitCodeForError(error);
|
|
494
|
+
}
|
|
495
|
+
async function remixTeams(options) {
|
|
496
|
+
let client;
|
|
497
|
+
try {
|
|
498
|
+
client = createRemixClient(options);
|
|
499
|
+
const teams = parseList(await client.get('/teams'));
|
|
500
|
+
const data = { items: teams, page: 1, limit: teams.length, total: teams.length, hasMore: false };
|
|
501
|
+
if (options.json) {
|
|
502
|
+
console.log(JSON.stringify(resultEnvelope('remix.teams', client, data)));
|
|
503
|
+
}
|
|
504
|
+
else {
|
|
505
|
+
if (!teams.length)
|
|
506
|
+
console.log('当前账号没有可用团队。');
|
|
507
|
+
else
|
|
508
|
+
teams.forEach((team) => console.log(`${team.id}\t${team.name}`));
|
|
509
|
+
console.log('\n下一步:');
|
|
510
|
+
console.log(' playcraft remix templates --team-id <团队ID>');
|
|
511
|
+
console.log('\n或直接运行完整交互流程:');
|
|
512
|
+
console.log(' playcraft remix run');
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
catch (error) {
|
|
516
|
+
handleCommandError('remix.teams', options, client, error);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
async function remixTemplates(options) {
|
|
520
|
+
let client;
|
|
521
|
+
try {
|
|
522
|
+
if (!options.teamId)
|
|
523
|
+
throw new PlayCraftApiError({ code: 'TEAM_NOT_FOUND', message: '必须提供 --team-id。' });
|
|
524
|
+
client = createRemixClient(options);
|
|
525
|
+
const templates = parseProjectTemplates(await client.get('/projects/templates', {
|
|
526
|
+
teamId: options.teamId,
|
|
527
|
+
limit: '100',
|
|
528
|
+
skip: '0',
|
|
529
|
+
}));
|
|
530
|
+
const filtered = options.tag
|
|
531
|
+
? templates.filter((template) => Object.values(template.tags || {})
|
|
532
|
+
.flatMap((tags) => Array.isArray(tags) ? tags : [])
|
|
533
|
+
.some((tag) => typeof tag === 'string' && tag.toLowerCase().includes(options.tag.toLowerCase())))
|
|
534
|
+
: templates;
|
|
535
|
+
const data = { items: filtered, page: 1, limit: filtered.length, total: filtered.length, hasMore: false };
|
|
536
|
+
if (options.json) {
|
|
537
|
+
console.log(JSON.stringify(resultEnvelope('remix.templates', client, data)));
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
if (!filtered.length)
|
|
541
|
+
console.log('所选团队当前没有可见项目。');
|
|
542
|
+
else
|
|
543
|
+
filtered.forEach((template) => console.log(`${template.id}\t${template.name}`));
|
|
544
|
+
console.log('\n下一步:');
|
|
545
|
+
console.log(` playcraft remix run --team-id ${options.teamId} --template-id <模板ID>`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
catch (error) {
|
|
549
|
+
handleCommandError('remix.templates', options, client, error);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
async function remixCreate(options) {
|
|
553
|
+
const client = createRemixClient(options);
|
|
554
|
+
const branch = await client.post(`/projects/${encodeURIComponent(options.projectId)}/branches`, {
|
|
555
|
+
name: options.name,
|
|
556
|
+
ref: options.sourceBranch,
|
|
557
|
+
...(options.teamId ? { teamId: options.teamId } : {}),
|
|
558
|
+
});
|
|
559
|
+
const branchName = branch.name;
|
|
560
|
+
const editorProjectId = options.editorProjectId || await resolveEditorProjectIdBestEffort(client, options.projectId, options);
|
|
561
|
+
return {
|
|
562
|
+
projectId: options.projectId,
|
|
563
|
+
branch: branchName,
|
|
564
|
+
displayName: branch.displayName,
|
|
565
|
+
editorUrl: editorUrl(client, editorProjectId, branchName),
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
function resolveEnsureStreamUrl(client, projectId) {
|
|
569
|
+
const endpoint = new URL(client.url(`/projects/${encodeURIComponent(projectId)}/sandbox/ensure-stream`));
|
|
570
|
+
const isLocalFrontend = ['localhost', '127.0.0.1', '[::1]'].includes(endpoint.hostname)
|
|
571
|
+
&& endpoint.port === '3000';
|
|
572
|
+
if (isLocalFrontend)
|
|
573
|
+
endpoint.port = '3001';
|
|
574
|
+
return endpoint;
|
|
63
575
|
}
|
|
64
576
|
async function remixEnsureStream(options) {
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
const endpoint = new URL(`${baseUrl}/api/projects/${encodeURIComponent(options.projectId)}/sandbox/ensure-stream`);
|
|
577
|
+
const client = createRemixClient(options);
|
|
578
|
+
const endpoint = resolveEnsureStreamUrl(client, options.projectId);
|
|
68
579
|
endpoint.searchParams.set('branch', options.branch);
|
|
580
|
+
if (options.sourceBranch)
|
|
581
|
+
endpoint.searchParams.set('sourceBranch', options.sourceBranch);
|
|
69
582
|
if (options.evictConfirmed)
|
|
70
583
|
endpoint.searchParams.set('evictConfirmed', 'true');
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const text = await res.text();
|
|
80
|
-
throw new Error(`ensure-stream 失败: ${res.status} ${text}`);
|
|
584
|
+
if (options.evictSlotId)
|
|
585
|
+
endpoint.searchParams.set('evictSlotId', options.evictSlotId);
|
|
586
|
+
emitProgress(options, '[连接中] 正在连接沙箱服务...');
|
|
587
|
+
const response = await fetchWithRetry(endpoint, {
|
|
588
|
+
headers: { Authorization: `Bearer ${client.token}`, Accept: 'text/event-stream' },
|
|
589
|
+
}, { label: 'ensure-stream' });
|
|
590
|
+
if (!response.ok || !response.body) {
|
|
591
|
+
throw new PlayCraftApiError({ code: response.status === 401 ? 'AUTH_EXPIRED' : 'AGENT_REQUEST_FAILED', message: `启动沙箱失败,状态码:${response.status}`, status: response.status, retryable: response.status >= 500 });
|
|
81
592
|
}
|
|
82
|
-
const reader =
|
|
593
|
+
const reader = response.body.getReader();
|
|
83
594
|
const decoder = new TextDecoder();
|
|
84
595
|
let buffer = '';
|
|
85
|
-
const
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
.filter((line) => line.startsWith('data:'))
|
|
89
|
-
.map((line) => line.slice(5).trim())
|
|
90
|
-
.join('\n');
|
|
91
|
-
if (!payload)
|
|
596
|
+
const parseEvent = (block) => {
|
|
597
|
+
const data = block.split(/\r?\n/).filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trim()).join('\n');
|
|
598
|
+
if (!data)
|
|
92
599
|
return null;
|
|
93
600
|
try {
|
|
94
|
-
return JSON.parse(
|
|
601
|
+
return JSON.parse(data);
|
|
95
602
|
}
|
|
96
603
|
catch {
|
|
97
604
|
return null;
|
|
@@ -102,134 +609,661 @@ async function remixEnsureStream(options) {
|
|
|
102
609
|
if (done)
|
|
103
610
|
break;
|
|
104
611
|
buffer += decoder.decode(value, { stream: true });
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (!event) {
|
|
111
|
-
sepIdx = buffer.indexOf('\n\n');
|
|
612
|
+
const frames = buffer.split(/\r?\n\r?\n/);
|
|
613
|
+
buffer = frames.pop() || '';
|
|
614
|
+
for (const frame of frames) {
|
|
615
|
+
const event = parseEvent(frame);
|
|
616
|
+
if (!event)
|
|
112
617
|
continue;
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
else if (event.type === '
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
else if (event.type === 'error') {
|
|
121
|
-
throw new Error(event.message || 'ensure-stream 返回 error 事件');
|
|
122
|
-
}
|
|
618
|
+
if (event.type === 'phase')
|
|
619
|
+
emitProgress(options, `[${event.phase}]${event.detail ? ` ${event.detail}` : ''}`);
|
|
620
|
+
else if (event.type === 'log')
|
|
621
|
+
emitProgress(options, event.message);
|
|
622
|
+
else if (event.type === 'error')
|
|
623
|
+
throw new PlayCraftApiError({ code: 'AGENT_REQUEST_FAILED', message: event.message });
|
|
123
624
|
else if (event.type === 'capacity_exceeded') {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
throw new Error(`容量已满 (${event.current}/${event.max}),但服务端未返回可驱逐候选`);
|
|
127
|
-
}
|
|
128
|
-
const selected = await inquirer.prompt([
|
|
129
|
-
{
|
|
130
|
-
type: 'list',
|
|
131
|
-
name: 'slotId',
|
|
132
|
-
message: `沙箱容量已满 (${event.current}/${event.max}),请选择一个候选项继续驱逐:`,
|
|
133
|
-
choices: event.candidates.map((c) => ({
|
|
134
|
-
name: `${c.slotId} (${c.projectId}/${c.branchName}) status=${c.agentStatus ?? 'unknown'} idle=${formatIdleSec(c.idleSec)}${c.hasUnpushedChanges ? ' [unpushed]' : ''}`,
|
|
135
|
-
value: c.slotId,
|
|
136
|
-
disabled: c.evictable === false ? (c.disabledReason ?? 'not evictable') : false,
|
|
137
|
-
})),
|
|
138
|
-
},
|
|
139
|
-
]);
|
|
140
|
-
const confirmed = await inquirer.prompt([
|
|
141
|
-
{
|
|
142
|
-
type: 'confirm',
|
|
143
|
-
name: 'ok',
|
|
144
|
-
message: `确认继续驱逐并重试? selected=${selected.slotId}`,
|
|
145
|
-
default: false,
|
|
146
|
-
},
|
|
147
|
-
]);
|
|
148
|
-
if (!confirmed.ok) {
|
|
149
|
-
throw new Error('用户取消操作');
|
|
625
|
+
if (!options.interactive) {
|
|
626
|
+
throw new PlayCraftApiError({ code: 'SANDBOX_CAPACITY_EXCEEDED', message: `沙箱容量已满(${event.current}/${event.max}),请传入 --evict-slot-id 和 --evict-confirmed。`, details: { candidates: event.candidates } });
|
|
150
627
|
}
|
|
151
|
-
|
|
628
|
+
const candidates = event.candidates.filter((candidate) => candidate.evictable !== false);
|
|
629
|
+
if (!candidates.length)
|
|
630
|
+
throw new PlayCraftApiError({ code: 'SANDBOX_CAPACITY_EXCEEDED', message: `沙箱容量已满(${event.current}/${event.max}),没有可释放的槽位。` });
|
|
631
|
+
const selected = await inquirer.prompt([{ type: 'list', name: 'slotId', message: `沙箱容量已满(${event.current}/${event.max}),请选择要释放的槽位:`, choices: event.candidates.map((candidate) => ({ name: `${candidate.slotId}(${candidate.projectId}/${candidate.branchName})状态=${candidate.agentStatus ?? '未知'} 空闲=${formatIdleSec(candidate.idleSec)}`, value: candidate.slotId, disabled: candidate.evictable === false ? candidate.disabledReason || '不可释放' : false })) }]);
|
|
632
|
+
const confirmed = await inquirer.prompt([{ type: 'confirm', name: 'ok', message: `确定释放槽位 ${selected.slotId} 并重试吗?`, default: false }]);
|
|
633
|
+
if (!confirmed.ok)
|
|
634
|
+
throw new PlayCraftApiError({ code: 'SANDBOX_CAPACITY_EXCEEDED', message: '操作已取消。' });
|
|
635
|
+
return remixEnsureStream({ ...options, evictConfirmed: true, evictSlotId: selected.slotId });
|
|
152
636
|
}
|
|
153
637
|
else if (event.type === 'done') {
|
|
154
638
|
return {
|
|
155
639
|
sandboxId: event.sandboxId,
|
|
156
640
|
previewUrl: event.previewUrl,
|
|
157
641
|
agentBaseUrl: event.agentBaseUrl,
|
|
642
|
+
agentEndpoint: event.agentEndpoint,
|
|
643
|
+
agentSessionId: event.agentSessionId,
|
|
644
|
+
codeAgentSlug: event.codeAgentSlug,
|
|
645
|
+
slotId: event.slotId,
|
|
646
|
+
status: event.status,
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
throw new PlayCraftApiError({ code: 'AGENT_UNAVAILABLE', message: '沙箱启动连接已结束,但沙箱尚未就绪。' });
|
|
652
|
+
}
|
|
653
|
+
function sessionPath(ready) {
|
|
654
|
+
if (!ready.agentEndpoint || !ready.agentSessionId) {
|
|
655
|
+
throw new PlayCraftApiError({ code: 'AGENT_UNAVAILABLE', message: '沙箱未返回 Agent 服务地址或当前会话。' });
|
|
656
|
+
}
|
|
657
|
+
return `${ready.agentEndpoint}/session/${encodeURIComponent(ready.agentSessionId)}`;
|
|
658
|
+
}
|
|
659
|
+
async function ensureAgentSession(client, ready, options) {
|
|
660
|
+
if (!ready.agentEndpoint) {
|
|
661
|
+
throw new PlayCraftApiError({ code: 'AGENT_UNAVAILABLE', message: '沙箱未返回 Agent 服务地址。' });
|
|
662
|
+
}
|
|
663
|
+
if (ready.agentSessionId)
|
|
664
|
+
return ready;
|
|
665
|
+
const findLatestSession = async () => {
|
|
666
|
+
const sessions = await client.get(`${ready.agentEndpoint}/session`);
|
|
667
|
+
return [...(Array.isArray(sessions) ? sessions : [])]
|
|
668
|
+
.filter((session) => typeof session.id === 'string' && session.id.length > 0)
|
|
669
|
+
.sort((a, b) => (b.time?.created ?? 0) - (a.time?.created ?? 0))[0];
|
|
670
|
+
};
|
|
671
|
+
const latest = await findLatestSession();
|
|
672
|
+
if (latest?.id)
|
|
673
|
+
return { ...ready, agentSessionId: latest.id };
|
|
674
|
+
emitProgress(options, '[会话中] 正在创建 Agent 会话...');
|
|
675
|
+
let created;
|
|
676
|
+
try {
|
|
677
|
+
created = await client.post(`${ready.agentEndpoint}/session`, {}, {
|
|
678
|
+
signal: AbortSignal.timeout(30_000),
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
catch (error) {
|
|
682
|
+
emitProgress(options, '[会话中] 创建响应未返回,正在查询已创建的会话...');
|
|
683
|
+
const recovered = await findLatestSession();
|
|
684
|
+
if (recovered?.id)
|
|
685
|
+
return { ...ready, agentSessionId: recovered.id };
|
|
686
|
+
throw error;
|
|
687
|
+
}
|
|
688
|
+
if (!created?.id) {
|
|
689
|
+
throw new PlayCraftApiError({ code: 'AGENT_UNAVAILABLE', message: 'Agent 会话创建成功,但未返回会话 ID。' });
|
|
690
|
+
}
|
|
691
|
+
return { ...ready, agentSessionId: created.id };
|
|
692
|
+
}
|
|
693
|
+
async function listPendingAgentQuestions(client, ready) {
|
|
694
|
+
if (!ready.agentEndpoint)
|
|
695
|
+
return [];
|
|
696
|
+
try {
|
|
697
|
+
const payload = await client.get(`${ready.agentEndpoint}/question`);
|
|
698
|
+
return parseAgentQuestionRequests(payload)
|
|
699
|
+
.filter((request) => !request.sessionId || request.sessionId === ready.agentSessionId);
|
|
700
|
+
}
|
|
701
|
+
catch (error) {
|
|
702
|
+
if (isPlayCraftApiError(error) && error.status === 404)
|
|
703
|
+
return [];
|
|
704
|
+
throw error;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
async function waitForAgentIdle(client, ready, timeoutMs, options, handledQuestionIds, baselineAssistantState) {
|
|
708
|
+
const path = `${sessionPath(ready).replace(/\/session\/[^/]+$/, '')}/event`;
|
|
709
|
+
const controller = new AbortController();
|
|
710
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
711
|
+
try {
|
|
712
|
+
const response = await fetchWithRetry(client.url(path), { headers: { Authorization: `Bearer ${client.token}`, Accept: 'text/event-stream' }, signal: controller.signal }, { label: 'agent-event-stream' });
|
|
713
|
+
if (!response.ok || !response.body)
|
|
714
|
+
throw new Error(`Agent 事件流连接失败,状态码:${response.status}`);
|
|
715
|
+
const reader = response.body.getReader();
|
|
716
|
+
const decoder = new TextDecoder();
|
|
717
|
+
let buffer = '';
|
|
718
|
+
const pendingQuestions = await listPendingAgentQuestions(client, ready);
|
|
719
|
+
for (const request of pendingQuestions) {
|
|
720
|
+
await replyToAgentQuestion(client, ready, options, request, handledQuestionIds);
|
|
721
|
+
}
|
|
722
|
+
if (await hasAgentTurnCompleted(client, ready, baselineAssistantState, true))
|
|
723
|
+
return;
|
|
724
|
+
while (true) {
|
|
725
|
+
const { done, value } = await reader.read();
|
|
726
|
+
if (done)
|
|
727
|
+
break;
|
|
728
|
+
buffer += decoder.decode(value, { stream: true });
|
|
729
|
+
const frames = buffer.split(/\r?\n\r?\n/);
|
|
730
|
+
buffer = frames.pop() || '';
|
|
731
|
+
for (const frame of frames) {
|
|
732
|
+
const data = frame.split(/\r?\n/).filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trim()).join('\n');
|
|
733
|
+
if (!data)
|
|
734
|
+
continue;
|
|
735
|
+
let event;
|
|
736
|
+
try {
|
|
737
|
+
event = JSON.parse(data);
|
|
738
|
+
}
|
|
739
|
+
catch {
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
const properties = event?.properties || {};
|
|
743
|
+
const eventSessionId = properties.sessionID || properties.sessionId || properties.session_id;
|
|
744
|
+
if (event?.type === 'question.asked') {
|
|
745
|
+
const questionPayload = Object.keys(properties).length > 0 ? properties : event;
|
|
746
|
+
const [request] = parseAgentQuestionRequests(questionPayload);
|
|
747
|
+
if (request) {
|
|
748
|
+
await replyToAgentQuestion(client, ready, options, request, handledQuestionIds);
|
|
749
|
+
}
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
const idle = event?.type === 'session.idle' || (event?.type === 'session.status' && properties.status?.type === 'idle');
|
|
753
|
+
if (idle
|
|
754
|
+
&& (!eventSessionId || eventSessionId === ready.agentSessionId)
|
|
755
|
+
&& await hasAgentTurnCompleted(client, ready, baselineAssistantState, false))
|
|
756
|
+
return;
|
|
757
|
+
if (event?.type === 'session.error' && (!eventSessionId || eventSessionId === ready.agentSessionId)) {
|
|
758
|
+
throw new PlayCraftApiError({ code: 'AGENT_REQUEST_FAILED', message: properties.message || 'Agent 会话执行失败。' });
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
throw new Error('Agent 事件流已结束,但会话尚未进入空闲状态。');
|
|
763
|
+
}
|
|
764
|
+
catch (error) {
|
|
765
|
+
if (controller.signal.aborted) {
|
|
766
|
+
throw new PlayCraftApiError({ code: 'AGENT_TIMEOUT', message: `Agent 在 ${Math.ceil(timeoutMs / 1000)} 秒内未完成任务。`, retryable: true });
|
|
767
|
+
}
|
|
768
|
+
throw error;
|
|
769
|
+
}
|
|
770
|
+
finally {
|
|
771
|
+
clearTimeout(timer);
|
|
772
|
+
if (!controller.signal.aborted)
|
|
773
|
+
controller.abort();
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
async function listAgentMessages(client, ready) {
|
|
777
|
+
const messages = await client.get(`${sessionPath(ready)}/message`);
|
|
778
|
+
return Array.isArray(messages) ? messages : [];
|
|
779
|
+
}
|
|
780
|
+
function assistantState(messages) {
|
|
781
|
+
return JSON.stringify(messages.filter((message) => (message.info?.role || message.role) === 'assistant'));
|
|
782
|
+
}
|
|
783
|
+
async function currentAgentSessionStatus(client, ready) {
|
|
784
|
+
if (!ready.agentEndpoint || !ready.agentSessionId)
|
|
785
|
+
return undefined;
|
|
786
|
+
try {
|
|
787
|
+
const payload = await client.get(`${ready.agentEndpoint}/session/status`);
|
|
788
|
+
const record = payload && typeof payload === 'object' && !Array.isArray(payload)
|
|
789
|
+
? payload
|
|
790
|
+
: undefined;
|
|
791
|
+
const direct = record?.[ready.agentSessionId];
|
|
792
|
+
if (record && direct === undefined)
|
|
793
|
+
return 'idle';
|
|
794
|
+
const value = direct?.status ?? direct;
|
|
795
|
+
return typeof value?.type === 'string' ? value.type : undefined;
|
|
796
|
+
}
|
|
797
|
+
catch {
|
|
798
|
+
return undefined;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
async function hasAgentTurnCompleted(client, ready, baselineAssistantState, requireKnownIdle) {
|
|
802
|
+
const messages = await listAgentMessages(client, ready);
|
|
803
|
+
if (assistantState(messages) === baselineAssistantState)
|
|
804
|
+
return false;
|
|
805
|
+
const status = await currentAgentSessionStatus(client, ready);
|
|
806
|
+
return status === 'idle' || (!requireKnownIdle && status === undefined);
|
|
807
|
+
}
|
|
808
|
+
async function lastAssistantText(client, ready) {
|
|
809
|
+
const messages = await listAgentMessages(client, ready);
|
|
810
|
+
const assistants = messages.filter((message) => (message.info?.role || message.role) === 'assistant');
|
|
811
|
+
const last = assistants[assistants.length - 1];
|
|
812
|
+
const text = (last?.parts || []).filter((part) => part.type === 'text' && part.text).map((part) => part.text).join('').trim();
|
|
813
|
+
return text || undefined;
|
|
814
|
+
}
|
|
815
|
+
async function remixPrompt(options) {
|
|
816
|
+
const commandName = options.commandName || 'remix.prompt';
|
|
817
|
+
let client;
|
|
818
|
+
try {
|
|
819
|
+
const prompt = readPrompt(options);
|
|
820
|
+
const imageAttachments = readImageAttachments(options.images);
|
|
821
|
+
client = createRemixClient(options);
|
|
822
|
+
const sandbox = await remixEnsureStream({ ...options, interactive: options.interactive ?? false });
|
|
823
|
+
const ready = await ensureAgentSession(client, sandbox, options);
|
|
824
|
+
const session = sessionPath(ready);
|
|
825
|
+
const editorProjectId = await resolveEditorProjectIdBestEffort(client, options.projectId, options);
|
|
826
|
+
const urls = {
|
|
827
|
+
editor: editorUrl(client, editorProjectId, options.branch),
|
|
828
|
+
...(ready.previewUrl ? { preview: ready.previewUrl } : {}),
|
|
829
|
+
};
|
|
830
|
+
emitProgress(options, `[已就绪] Remix 编辑器地址:${urls.editor}`);
|
|
831
|
+
const pendingQuestions = await listPendingAgentQuestions(client, ready);
|
|
832
|
+
const handledQuestionIds = new Set();
|
|
833
|
+
if (pendingQuestions.length) {
|
|
834
|
+
emitProgress(options, `[恢复中] 检测到 ${pendingQuestions.length} 个待回答问题,不会重复发送原需求。`);
|
|
835
|
+
}
|
|
836
|
+
const wait = options.wait !== false;
|
|
837
|
+
const baselineAssistantState = wait
|
|
838
|
+
? assistantState(await listAgentMessages(client, ready))
|
|
839
|
+
: undefined;
|
|
840
|
+
let uploadedImageAttachments = [];
|
|
841
|
+
if (pendingQuestions.length) {
|
|
842
|
+
if (imageAttachments.length > 0) {
|
|
843
|
+
emitProgress(options, `[提示] 当前会话有待回答问题,本次 ${imageAttachments.length} 张图片未上传,也不会重复发送原需求。`);
|
|
844
|
+
}
|
|
845
|
+
for (const request of pendingQuestions) {
|
|
846
|
+
await replyToAgentQuestion(client, ready, options, request, handledQuestionIds);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
else {
|
|
850
|
+
if (imageAttachments.length > 0) {
|
|
851
|
+
uploadedImageAttachments = await uploadImageAttachments(client, options.projectId, options.branch, imageAttachments);
|
|
852
|
+
}
|
|
853
|
+
const promptToSend = buildReferenceImagesPrompt(prompt, uploadedImageAttachments);
|
|
854
|
+
await client.post(`${session}/prompt_async`, { parts: [{ type: 'text', text: promptToSend }] });
|
|
855
|
+
}
|
|
856
|
+
if (baselineAssistantState !== undefined) {
|
|
857
|
+
await waitForAgentIdle(client, ready, options.timeout * 1000, options, handledQuestionIds, baselineAssistantState);
|
|
858
|
+
}
|
|
859
|
+
const assistantText = wait ? await lastAssistantText(client, ready) : undefined;
|
|
860
|
+
const data = {
|
|
861
|
+
projectId: options.projectId,
|
|
862
|
+
branch: options.branch,
|
|
863
|
+
created: options.created ?? false,
|
|
864
|
+
...(options.teamId ? { teamId: options.teamId } : {}),
|
|
865
|
+
...(options.templateId ? { templateId: options.templateId } : {}),
|
|
866
|
+
urls,
|
|
867
|
+
...(uploadedImageAttachments.length > 0
|
|
868
|
+
? {
|
|
869
|
+
attachments: {
|
|
870
|
+
images: uploadedImageAttachments.map(({ fileName, sandboxPath }) => ({ fileName, sandboxPath })),
|
|
871
|
+
},
|
|
872
|
+
}
|
|
873
|
+
: imageAttachments.length > 0 && pendingQuestions.length > 0
|
|
874
|
+
? { attachments: { requestedImages: imageAttachments.length, sentImages: 0, status: 'not_sent_pending_question' } }
|
|
875
|
+
: {}),
|
|
876
|
+
sandbox: { id: ready.sandboxId, slotId: ready.slotId, status: ready.status || 'ready' },
|
|
877
|
+
agent: { slug: ready.codeAgentSlug, sessionId: ready.agentSessionId, status: wait ? 'completed' : 'running', ...(assistantText ? { lastAssistantText: assistantText } : {}) },
|
|
878
|
+
};
|
|
879
|
+
if (options.open)
|
|
880
|
+
openBrowser(data.urls.editor);
|
|
881
|
+
if (options.json)
|
|
882
|
+
console.log(JSON.stringify(resultEnvelope(commandName, client, data)));
|
|
883
|
+
else
|
|
884
|
+
console.log(`需求已${wait ? '完成' : '接收'}:${data.urls.editor}`);
|
|
885
|
+
}
|
|
886
|
+
catch (error) {
|
|
887
|
+
handleCommandError(commandName, options, client, error);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
async function remixRun(options) {
|
|
891
|
+
let client;
|
|
892
|
+
try {
|
|
893
|
+
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY && !options.json);
|
|
894
|
+
const requestedImageCount = options.images?.length ?? 0;
|
|
895
|
+
if (requestedImageCount > 0 && !options.prompt && !options.promptFile && !interactive) {
|
|
896
|
+
throw new PlayCraftApiError({
|
|
897
|
+
code: 'ATTACHMENT_PROMPT_REQUIRED',
|
|
898
|
+
message: '非交互模式使用 --image 时必须同时提供 --prompt 或 --prompt-file。',
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
client = createRemixClient(options);
|
|
902
|
+
let teamId = options.teamId;
|
|
903
|
+
let templateId = options.templateId;
|
|
904
|
+
let selectedTemplate;
|
|
905
|
+
let created = false;
|
|
906
|
+
if (options.projectId && (options.templateId || options.name || options.teamId)) {
|
|
907
|
+
throw new PlayCraftApiError({ code: 'AGENT_REQUEST_FAILED', message: '已有 Remix 模式的 --project-id/--branch 不能与 --team-id、--template-id 或 --name 同时使用。' });
|
|
908
|
+
}
|
|
909
|
+
if (!options.projectId) {
|
|
910
|
+
let sourceBranch;
|
|
911
|
+
let teams;
|
|
912
|
+
while (!selectedTemplate || !sourceBranch) {
|
|
913
|
+
if (!teamId) {
|
|
914
|
+
if (!interactive)
|
|
915
|
+
throw new PlayCraftApiError({ code: 'TEAM_NOT_FOUND', message: '非交互模式必须提供 --team-id。' });
|
|
916
|
+
teams ??= parseList(await client.get('/teams'));
|
|
917
|
+
if (!teams.length)
|
|
918
|
+
throw new PlayCraftApiError({ code: 'TEAM_NOT_FOUND', message: '当前登录账号没有可用团队。' });
|
|
919
|
+
teamId = (await inquirer.prompt([{
|
|
920
|
+
type: 'list',
|
|
921
|
+
name: 'teamId',
|
|
922
|
+
message: '请选择团队:',
|
|
923
|
+
choices: teams.map((team) => ({ name: team.name, value: team.id })),
|
|
924
|
+
}])).teamId;
|
|
925
|
+
}
|
|
926
|
+
let projectTemplates = [];
|
|
927
|
+
let recommendedPromptTemplates = [];
|
|
928
|
+
let galleryTemplates = [];
|
|
929
|
+
try {
|
|
930
|
+
const projectData = await client.get('/projects/templates', {
|
|
931
|
+
teamId,
|
|
932
|
+
limit: '100',
|
|
933
|
+
skip: '0',
|
|
934
|
+
});
|
|
935
|
+
projectTemplates = parseProjectTemplates(projectData)
|
|
936
|
+
.map((template) => ({ ...template, source: 'team' }));
|
|
937
|
+
}
|
|
938
|
+
catch (error) {
|
|
939
|
+
if (!isTemplateProjectAccessDenied(error))
|
|
940
|
+
throw error;
|
|
941
|
+
}
|
|
942
|
+
const [promptResult, remixResult] = await Promise.allSettled([
|
|
943
|
+
client.get('/recommended-prompts', { teamId, limit: '100' }),
|
|
944
|
+
client.get('/remixes', {
|
|
945
|
+
scope: 'gallery',
|
|
946
|
+
recommended: 'true',
|
|
947
|
+
teamId,
|
|
948
|
+
limit: '100',
|
|
949
|
+
}),
|
|
950
|
+
]);
|
|
951
|
+
if (promptResult.status === 'fulfilled') {
|
|
952
|
+
recommendedPromptTemplates = parseRecommendedPromptTemplates(promptResult.value);
|
|
953
|
+
}
|
|
954
|
+
else if (!options.json) {
|
|
955
|
+
emitProgress(options, '[警告] 获取系统推荐 Prompt 模板失败');
|
|
956
|
+
}
|
|
957
|
+
if (remixResult.status === 'fulfilled') {
|
|
958
|
+
galleryTemplates = parseGalleryTemplates(remixResult.value);
|
|
959
|
+
}
|
|
960
|
+
else if (!options.json) {
|
|
961
|
+
emitProgress(options, '[警告] 获取系统推荐 Remix 失败');
|
|
962
|
+
}
|
|
963
|
+
const templates = [
|
|
964
|
+
...projectTemplates,
|
|
965
|
+
...recommendedPromptTemplates,
|
|
966
|
+
...galleryTemplates,
|
|
967
|
+
];
|
|
968
|
+
if (!templates.length) {
|
|
969
|
+
throw new PlayCraftApiError({ code: 'TEMPLATE_NOT_FOUND', message: '当前没有可用的模板项目。请联系管理员开通权限或检查系统推荐模板配置。' });
|
|
970
|
+
}
|
|
971
|
+
if (!templateId) {
|
|
972
|
+
if (!interactive)
|
|
973
|
+
throw new PlayCraftApiError({ code: 'TEMPLATE_NOT_FOUND', message: '非交互模式必须提供 --template-id。' });
|
|
974
|
+
const templateChoices = buildTemplateChoices(templates);
|
|
975
|
+
if (!options.teamId) {
|
|
976
|
+
templateChoices.unshift({
|
|
977
|
+
name: '[返回] 重新选择团队(输入 back 快速定位)',
|
|
978
|
+
value: BACK_TO_TEAMS,
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
let answer = NO_MATCH;
|
|
982
|
+
while (answer === NO_MATCH) {
|
|
983
|
+
answer = (await inquirer.prompt([{
|
|
984
|
+
type: 'autocomplete',
|
|
985
|
+
name: 'templateId',
|
|
986
|
+
message: '请选择模板(可输入关键词搜索,输入 back 返回):',
|
|
987
|
+
source: async (_answersSoFar, input) => filterPromptChoices(templateChoices, input),
|
|
988
|
+
}])).templateId;
|
|
989
|
+
}
|
|
990
|
+
if (answer === BACK_TO_TEAMS) {
|
|
991
|
+
teamId = undefined;
|
|
992
|
+
templateId = undefined;
|
|
993
|
+
selectedTemplate = undefined;
|
|
994
|
+
continue;
|
|
995
|
+
}
|
|
996
|
+
templateId = answer;
|
|
997
|
+
}
|
|
998
|
+
selectedTemplate = templates.find((template) => template.choiceValue === templateId || template.id === templateId);
|
|
999
|
+
if (!selectedTemplate) {
|
|
1000
|
+
throw new PlayCraftApiError({ code: 'TEMPLATE_NOT_FOUND', message: `模板 ${templateId} 不存在或对所选团队不可见。` });
|
|
1001
|
+
}
|
|
1002
|
+
templateId = selectedTemplate.id;
|
|
1003
|
+
if (selectedTemplate.useExactSourceBranch) {
|
|
1004
|
+
sourceBranch = selectedTemplate.sourceBranch || 'main';
|
|
1005
|
+
continue;
|
|
1006
|
+
}
|
|
1007
|
+
if (!options.json)
|
|
1008
|
+
emitProgress(options, '[加载中] 获取模板的 Remix 分支列表...');
|
|
1009
|
+
const branchData = await client.get(`/projects/${encodeURIComponent(selectedTemplate.id)}/branches`, {
|
|
1010
|
+
includeAll: 'true',
|
|
1011
|
+
teamId,
|
|
1012
|
+
});
|
|
1013
|
+
const mainBranch = selectedTemplate.sourceBranch || options.sourceBranch || 'main';
|
|
1014
|
+
const branchChoices = buildRemixBranchChoices(branchData, mainBranch);
|
|
1015
|
+
let branchAnswer = NO_MATCH;
|
|
1016
|
+
while (branchAnswer === NO_MATCH) {
|
|
1017
|
+
branchAnswer = (await inquirer.prompt([{
|
|
1018
|
+
type: 'autocomplete',
|
|
1019
|
+
name: 'branch',
|
|
1020
|
+
message: '请选择基础分支(输入 back 返回模板列表):',
|
|
1021
|
+
source: async (_answersSoFar, input) => filterPromptChoices(branchChoices, input),
|
|
1022
|
+
}])).branch;
|
|
1023
|
+
}
|
|
1024
|
+
if (branchAnswer === BACK_TO_TEMPLATES) {
|
|
1025
|
+
templateId = undefined;
|
|
1026
|
+
selectedTemplate = undefined;
|
|
1027
|
+
continue;
|
|
1028
|
+
}
|
|
1029
|
+
sourceBranch = branchAnswer;
|
|
1030
|
+
}
|
|
1031
|
+
if (!selectedTemplate || !sourceBranch) {
|
|
1032
|
+
throw new PlayCraftApiError({ code: 'TEMPLATE_NOT_FOUND', message: '未选择有效的模板或基础分支。' });
|
|
1033
|
+
}
|
|
1034
|
+
if (!options.json)
|
|
1035
|
+
emitProgress(options, `[已选择] 基于分支: ${sourceBranch}`);
|
|
1036
|
+
// Auto-generate remix name following platform naming convention
|
|
1037
|
+
const timestamp = Date.now();
|
|
1038
|
+
const randomSuffix = Math.random().toString(36).substring(2, 10);
|
|
1039
|
+
const baseName = selectedTemplate.name.replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase();
|
|
1040
|
+
const name = options.name || `remix-${baseName}-${timestamp}-${randomSuffix}`;
|
|
1041
|
+
if (!options.json)
|
|
1042
|
+
emitProgress(options, `[自动生成] Remix 名称: ${name}`);
|
|
1043
|
+
const createdRemix = await remixCreate({
|
|
1044
|
+
...options,
|
|
1045
|
+
projectId: selectedTemplate.id,
|
|
1046
|
+
name,
|
|
1047
|
+
sourceBranch, // Use the sourceBranch determined above
|
|
1048
|
+
teamId,
|
|
1049
|
+
editorProjectId: selectedTemplate.projectType === 'external' && selectedTemplate.uuid
|
|
1050
|
+
? selectedTemplate.uuid
|
|
1051
|
+
: selectedTemplate.id,
|
|
1052
|
+
});
|
|
1053
|
+
options.projectId = createdRemix.projectId;
|
|
1054
|
+
options.branch = createdRemix.branch;
|
|
1055
|
+
created = true;
|
|
1056
|
+
const createdRemixDetailUrl = remixDetailUrl(client, createdRemix.projectId, createdRemix.branch);
|
|
1057
|
+
if (!options.json) {
|
|
1058
|
+
emitProgress(options, `✓ Remix 创建完成:${createdRemix.displayName || name}`);
|
|
1059
|
+
emitProgress(options, `Remix 详情地址:${createdRemixDetailUrl}`);
|
|
1060
|
+
}
|
|
1061
|
+
let prompt;
|
|
1062
|
+
if (options.prompt || options.promptFile) {
|
|
1063
|
+
prompt = readPrompt(options);
|
|
1064
|
+
}
|
|
1065
|
+
else if (interactive) {
|
|
1066
|
+
const answer = await inquirer.prompt([{
|
|
1067
|
+
type: 'input',
|
|
1068
|
+
name: 'prompt',
|
|
1069
|
+
message: '请输入需求描述(如不需要使用 Agent 生成,请直接回车或输入 exit 退出):',
|
|
1070
|
+
}]);
|
|
1071
|
+
if (!isAgentPromptExit(answer.prompt))
|
|
1072
|
+
prompt = answer.prompt.trim();
|
|
1073
|
+
}
|
|
1074
|
+
if (!prompt) {
|
|
1075
|
+
const data = {
|
|
1076
|
+
projectId: createdRemix.projectId,
|
|
1077
|
+
branch: createdRemix.branch,
|
|
1078
|
+
created: true,
|
|
1079
|
+
teamId,
|
|
1080
|
+
templateId,
|
|
1081
|
+
urls: {
|
|
1082
|
+
remix: createdRemixDetailUrl,
|
|
1083
|
+
editor: createdRemix.editorUrl,
|
|
1084
|
+
},
|
|
1085
|
+
...(requestedImageCount > 0
|
|
1086
|
+
? { attachments: { requestedImages: requestedImageCount, sentImages: 0, status: 'not_sent' } }
|
|
1087
|
+
: {}),
|
|
1088
|
+
agent: { started: false },
|
|
158
1089
|
};
|
|
1090
|
+
if (options.json)
|
|
1091
|
+
console.log(JSON.stringify(resultEnvelope('remix.run', client, data)));
|
|
1092
|
+
else
|
|
1093
|
+
emitProgress(options, requestedImageCount > 0
|
|
1094
|
+
? '已结束:Remix 已创建,未启动 Agent;所选图片未上传。'
|
|
1095
|
+
: '已结束:Remix 已创建,未启动 Agent。');
|
|
1096
|
+
return;
|
|
159
1097
|
}
|
|
160
|
-
|
|
1098
|
+
await remixPrompt({ ...options, promptFile: undefined, projectId: options.projectId, branch: options.branch, prompt, wait: options.wait, timeout: options.timeout, open: options.open, commandName: 'remix.run', created, teamId, templateId, interactive });
|
|
1099
|
+
return;
|
|
161
1100
|
}
|
|
1101
|
+
if (!options.branch)
|
|
1102
|
+
throw new PlayCraftApiError({ code: 'REMIX_NOT_FOUND', message: '已有 Remix 模式必须同时提供 --project-id 和 --branch。' });
|
|
1103
|
+
const prompt = options.prompt || options.promptFile
|
|
1104
|
+
? readPrompt(options)
|
|
1105
|
+
: interactive
|
|
1106
|
+
? (await inquirer.prompt([{ type: 'input', name: 'prompt', message: '请输入需求描述:', validate: (value) => Boolean(value.trim()) || '需求描述不能为空。' }])).prompt.trim()
|
|
1107
|
+
: readPrompt(options);
|
|
1108
|
+
await remixPrompt({ ...options, promptFile: undefined, projectId: options.projectId, branch: options.branch, prompt, wait: options.wait, timeout: options.timeout, open: options.open, commandName: 'remix.run', created, teamId, templateId, interactive });
|
|
1109
|
+
}
|
|
1110
|
+
catch (error) {
|
|
1111
|
+
handleCommandError('remix.run', options, client, error);
|
|
162
1112
|
}
|
|
163
|
-
throw new Error('ensure-stream 结束但未收到 done 事件');
|
|
164
1113
|
}
|
|
165
1114
|
export function registerRemixCommands(program) {
|
|
1115
|
+
const helpTitles = {
|
|
1116
|
+
'Usage:': '用法:',
|
|
1117
|
+
'Arguments:': '参数:',
|
|
1118
|
+
'Options:': '选项:',
|
|
1119
|
+
'Global Options:': '全局选项:',
|
|
1120
|
+
'Commands:': '命令:',
|
|
1121
|
+
};
|
|
166
1122
|
const remix = program
|
|
167
1123
|
.command('remix')
|
|
168
|
-
.description('Remix
|
|
169
|
-
|
|
170
|
-
.
|
|
171
|
-
.
|
|
172
|
-
|
|
173
|
-
.
|
|
174
|
-
.option('--
|
|
175
|
-
.option('--
|
|
176
|
-
.option('--
|
|
177
|
-
.option('--
|
|
178
|
-
.
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
.
|
|
190
|
-
.
|
|
1124
|
+
.description('Remix 工作流命令')
|
|
1125
|
+
.helpOption('-h, --help', '显示帮助信息')
|
|
1126
|
+
.helpCommand('help [command]', '显示指定命令的帮助信息')
|
|
1127
|
+
.configureHelp({ styleTitle: (title) => helpTitles[title] || title });
|
|
1128
|
+
remix.command('init-template')
|
|
1129
|
+
.description('应用引擎脚手架或烘焙模板')
|
|
1130
|
+
.option('--engine <name>', '引擎适配器:phaser | threejs')
|
|
1131
|
+
.option('--from-atom-tree', '从 docs/atom-tree.json 读取引擎配置')
|
|
1132
|
+
.option('--project-dir <path>', '项目根目录', process.cwd())
|
|
1133
|
+
.option('--force', '引擎适配器不一致时仍替换 game/ 目录')
|
|
1134
|
+
.option('--bake', '烘焙维护中的项目模板')
|
|
1135
|
+
.option('--all-engines', '烘焙所有引擎模板')
|
|
1136
|
+
.action(async (opts) => initTemplateCommand({ engine: opts.engine, fromAtomTree: Boolean(opts.fromAtomTree), projectDir: String(opts.projectDir || process.cwd()), force: Boolean(opts.force), bake: Boolean(opts.bake), allEngines: Boolean(opts.allEngines) }));
|
|
1137
|
+
remix.command('teams')
|
|
1138
|
+
.description('列出当前登录账号可用的团队')
|
|
1139
|
+
.option('--url <url>', SERVICE_ADDRESS_HELP)
|
|
1140
|
+
.option('--token <token>', PAT_HELP)
|
|
1141
|
+
.option('--json', JSON_OUTPUT_HELP)
|
|
1142
|
+
.action((opts) => remixTeams(opts));
|
|
1143
|
+
remix.command('templates')
|
|
1144
|
+
.description('列出项目页中指定团队可见的项目模板')
|
|
1145
|
+
.requiredOption('--team-id <id>', '团队 ID (Identifier, 标识符)')
|
|
1146
|
+
.option('--tag <filter>', '按标签筛选')
|
|
1147
|
+
.option('--url <url>', SERVICE_ADDRESS_HELP)
|
|
1148
|
+
.option('--token <token>', PAT_HELP)
|
|
1149
|
+
.option('--json', JSON_OUTPUT_HELP)
|
|
1150
|
+
.action((opts) => remixTemplates(opts));
|
|
1151
|
+
remix.command('create')
|
|
1152
|
+
.description('创建真实的 Remix 分支')
|
|
191
1153
|
.requiredOption('--project-id <id>', '项目 ID')
|
|
192
|
-
.requiredOption('--name <name>', '
|
|
1154
|
+
.requiredOption('--name <name>', 'Remix 展示名称')
|
|
1155
|
+
.option('--team-id <id>', '所选团队 ID')
|
|
193
1156
|
.option('--source-branch <branch>', '源分支', 'main')
|
|
194
|
-
.option('--url <url>',
|
|
195
|
-
.option('--token <
|
|
1157
|
+
.option('--url <url>', SERVICE_ADDRESS_HELP)
|
|
1158
|
+
.option('--token <token>', PAT_HELP)
|
|
1159
|
+
.option('--json', JSON_OUTPUT_HELP)
|
|
196
1160
|
.action(async (opts) => {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
sourceBranch: String(opts.sourceBranch || 'main')
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
1161
|
+
let client;
|
|
1162
|
+
try {
|
|
1163
|
+
client = createRemixClient(opts);
|
|
1164
|
+
const data = await remixCreate({ ...opts, projectId: String(opts.projectId), name: String(opts.name), sourceBranch: String(opts.sourceBranch || 'main') });
|
|
1165
|
+
if (opts.json)
|
|
1166
|
+
console.log(JSON.stringify(resultEnvelope('remix.create', client, data)));
|
|
1167
|
+
else
|
|
1168
|
+
console.log(`Remix 创建成功:${data.editorUrl}`);
|
|
1169
|
+
}
|
|
1170
|
+
catch (error) {
|
|
1171
|
+
handleCommandError('remix.create', opts, client, error);
|
|
1172
|
+
}
|
|
204
1173
|
});
|
|
205
|
-
remix
|
|
206
|
-
.
|
|
207
|
-
.description('启动/复用 remix 沙箱(GET ensure-stream SSE)')
|
|
1174
|
+
remix.command('start')
|
|
1175
|
+
.description('启动或复用 Remix 沙箱')
|
|
208
1176
|
.requiredOption('--project-id <id>', '项目 ID')
|
|
209
|
-
.requiredOption('--branch <name>', '
|
|
210
|
-
.option('--
|
|
211
|
-
.option('--
|
|
212
|
-
.option('--
|
|
213
|
-
.option('--
|
|
1177
|
+
.requiredOption('--branch <name>', 'Remix 分支')
|
|
1178
|
+
.option('--source-branch <branch>', '分支不存在时使用的源分支')
|
|
1179
|
+
.option('--evict-slot-id <id>', '容量已满时要释放的槽位 ID')
|
|
1180
|
+
.option('--evict-confirmed', '确认释放指定槽位')
|
|
1181
|
+
.option('--open', '沙箱就绪后打开编辑器地址')
|
|
1182
|
+
.option('--url <url>', SERVICE_ADDRESS_HELP)
|
|
1183
|
+
.option('--token <token>', PAT_HELP)
|
|
1184
|
+
.option('--json', JSON_OUTPUT_HELP)
|
|
214
1185
|
.action(async (opts) => {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
1186
|
+
let client;
|
|
1187
|
+
try {
|
|
1188
|
+
const options = {
|
|
1189
|
+
...opts,
|
|
1190
|
+
projectId: String(opts.projectId),
|
|
1191
|
+
branch: String(opts.branch),
|
|
1192
|
+
interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY && !opts.json),
|
|
1193
|
+
};
|
|
1194
|
+
client = createRemixClient(options);
|
|
1195
|
+
const sandbox = await remixEnsureStream(options);
|
|
1196
|
+
const ready = await ensureAgentSession(client, sandbox, options);
|
|
1197
|
+
const editorProjectId = await resolveEditorProjectIdBestEffort(client, options.projectId, options);
|
|
1198
|
+
const pendingQuestions = await listPendingAgentQuestions(client, ready);
|
|
1199
|
+
const handledQuestionIds = new Set();
|
|
1200
|
+
if (pendingQuestions.length) {
|
|
1201
|
+
emitProgress(options, `[恢复中] 检测到 ${pendingQuestions.length} 个待回答问题,Agent 正在等待你的选择。`);
|
|
1202
|
+
for (const request of pendingQuestions) {
|
|
1203
|
+
await replyToAgentQuestion(client, ready, options, request, handledQuestionIds);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
const data = {
|
|
1207
|
+
projectId: options.projectId,
|
|
1208
|
+
branch: options.branch,
|
|
1209
|
+
urls: {
|
|
1210
|
+
editor: editorUrl(client, editorProjectId, options.branch),
|
|
1211
|
+
...(ready.previewUrl ? { preview: ready.previewUrl } : {}),
|
|
1212
|
+
},
|
|
1213
|
+
sandbox: { id: ready.sandboxId, slotId: ready.slotId, status: ready.status || 'ready' },
|
|
1214
|
+
agent: {
|
|
1215
|
+
slug: ready.codeAgentSlug,
|
|
1216
|
+
sessionId: ready.agentSessionId,
|
|
1217
|
+
pendingQuestions: pendingQuestions.length,
|
|
1218
|
+
},
|
|
1219
|
+
};
|
|
1220
|
+
if (opts.open)
|
|
1221
|
+
openBrowser(data.urls.editor);
|
|
1222
|
+
if (opts.json)
|
|
1223
|
+
console.log(JSON.stringify(resultEnvelope('remix.start', client, data)));
|
|
1224
|
+
else if (pendingQuestions.length)
|
|
1225
|
+
console.log(`待回答问题已处理,沙箱已就绪:${data.urls.editor}`);
|
|
1226
|
+
else
|
|
1227
|
+
console.log(`沙箱已就绪:${data.urls.editor}`);
|
|
1228
|
+
}
|
|
1229
|
+
catch (error) {
|
|
1230
|
+
handleCommandError('remix.start', opts, client, error);
|
|
233
1231
|
}
|
|
234
1232
|
});
|
|
1233
|
+
remix.command('prompt')
|
|
1234
|
+
.description('向当前 Remix 沙箱会话发送需求和参考图片')
|
|
1235
|
+
.requiredOption('--project-id <id>', '项目 ID')
|
|
1236
|
+
.requiredOption('--branch <name>', 'Remix 分支')
|
|
1237
|
+
.option('--prompt <text>', '直接传入需求描述')
|
|
1238
|
+
.option('--prompt-file <path>', '包含需求描述的 UTF-8 (Unicode Transformation Format 8-bit, Unicode 八位转换格式) 文件')
|
|
1239
|
+
.option('--image <path...>', IMAGE_OPTION_HELP)
|
|
1240
|
+
.option('--wait', '等待会话执行完成(默认)')
|
|
1241
|
+
.option('--no-wait', '需求被接收后立即返回')
|
|
1242
|
+
.option('--timeout <seconds>', '等待超时秒数', '1800')
|
|
1243
|
+
.option('--evict-slot-id <id>', '容量已满时要释放的槽位 ID')
|
|
1244
|
+
.option('--evict-confirmed', '确认释放指定槽位')
|
|
1245
|
+
.option('--open', '执行完成后打开编辑器地址')
|
|
1246
|
+
.option('--url <url>', SERVICE_ADDRESS_HELP)
|
|
1247
|
+
.option('--token <token>', PAT_HELP)
|
|
1248
|
+
.option('--json', JSON_OUTPUT_HELP)
|
|
1249
|
+
.action((opts) => remixPrompt({ ...opts, images: opts.image, projectId: String(opts.projectId), branch: String(opts.branch), timeout: Math.max(1, Number(opts.timeout) || 1800), interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY && !opts.json) }));
|
|
1250
|
+
remix.command('run')
|
|
1251
|
+
.description('选择模板并创建 Remix,可选向 Agent 发送需求和参考图片')
|
|
1252
|
+
.option('--team-id <id>', '所选团队 ID')
|
|
1253
|
+
.option('--template-id <id>', '项目模板 ID')
|
|
1254
|
+
.option('--project-id <id>', '已有 Remix 的项目 ID')
|
|
1255
|
+
.option('--branch <name>', '已有 Remix 的分支')
|
|
1256
|
+
.option('--name <name>', '新 Remix 的展示名称')
|
|
1257
|
+
.option('--source-branch <branch>', '项目未配置默认分支时使用的源分支', 'main')
|
|
1258
|
+
.option('--prompt <text>', '直接传入需求描述')
|
|
1259
|
+
.option('--prompt-file <path>', '包含需求描述的 UTF-8 (Unicode Transformation Format 8-bit, Unicode 八位转换格式) 文件')
|
|
1260
|
+
.option('--image <path...>', IMAGE_OPTION_HELP)
|
|
1261
|
+
.option('--wait', '等待会话执行完成(默认)')
|
|
1262
|
+
.option('--no-wait', '需求被接收后立即返回')
|
|
1263
|
+
.option('--timeout <seconds>', '等待超时秒数', '1800')
|
|
1264
|
+
.option('--open', '执行完成后打开编辑器地址')
|
|
1265
|
+
.option('--url <url>', SERVICE_ADDRESS_HELP)
|
|
1266
|
+
.option('--token <token>', PAT_HELP)
|
|
1267
|
+
.option('--json', JSON_OUTPUT_HELP)
|
|
1268
|
+
.action((opts) => remixRun({ ...opts, images: opts.image, timeout: Math.max(1, Number(opts.timeout) || 1800) }));
|
|
235
1269
|
}
|