@playcraft/cli 0.0.48 → 0.0.51
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 +757 -0
- package/dist/commands/login.js +82 -32
- package/dist/commands/remix.js +883 -176
- package/dist/utils/playcraft-http-client.js +132 -0
- package/dist/utils/remix-image-attachments.js +125 -0
- package/package.json +7 -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,414 @@
|
|
|
1
1
|
import inquirer from 'inquirer';
|
|
2
|
-
import { exec } from 'child_process';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { exec } from 'node:child_process';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { PlayCraftApiError, PlayCraftHttpClient, isPlayCraftApiError } from '../utils/playcraft-http-client.js';
|
|
5
|
+
import { IMAGE_OPTION_HELP, buildReferenceImagesPrompt, readImageAttachments, uploadImageAttachments, } from '../utils/remix-image-attachments.js';
|
|
5
6
|
import { initTemplateCommand } from '../remix/init-template.js';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
globalConfig.backendUrl ||
|
|
12
|
-
DEFAULT_PLAYCRAFT_CLOUD_BASE;
|
|
13
|
-
return raw.replace(/\/+$/, '');
|
|
14
|
-
}
|
|
15
|
-
function resolveJwtToken(options) {
|
|
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;
|
|
22
|
-
}
|
|
23
|
-
function openBrowser(url) {
|
|
24
|
-
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
25
|
-
exec(`${cmd} "${url}"`, () => undefined);
|
|
7
|
+
const JSON_OUTPUT_HELP = '输出一条 JSON (JavaScript Object Notation, JavaScript 对象表示法) 结果';
|
|
8
|
+
const SERVICE_ADDRESS_HELP = 'PlayCraft 服务地址';
|
|
9
|
+
const PAT_HELP = 'PAT (Personal Access Token, 个人访问令牌)';
|
|
10
|
+
function emitProgress(options, message) {
|
|
11
|
+
(options.json ? process.stderr : process.stdout).write(`${message}\n`);
|
|
26
12
|
}
|
|
27
13
|
function formatIdleSec(idleSec) {
|
|
28
14
|
if (!idleSec || idleSec <= 0)
|
|
29
15
|
return '0s';
|
|
30
16
|
if (idleSec < 60)
|
|
31
17
|
return `${idleSec}s`;
|
|
32
|
-
|
|
33
|
-
const s = idleSec % 60;
|
|
34
|
-
return `${m}m${s}s`;
|
|
18
|
+
return `${Math.floor(idleSec / 60)}m${idleSec % 60}s`;
|
|
35
19
|
}
|
|
36
|
-
function
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
20
|
+
function parseAgentQuestions(input) {
|
|
21
|
+
let raw = input;
|
|
22
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
23
|
+
raw = raw.questions ?? [raw];
|
|
24
|
+
}
|
|
25
|
+
if (typeof raw === 'string') {
|
|
26
|
+
try {
|
|
27
|
+
raw = JSON.parse(raw);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (!Array.isArray(raw))
|
|
34
|
+
return [];
|
|
35
|
+
return raw.flatMap((value) => {
|
|
36
|
+
if (!value || typeof value !== 'object')
|
|
37
|
+
return [];
|
|
38
|
+
const question = value;
|
|
39
|
+
const text = [question.question, question.prompt, question.text]
|
|
40
|
+
.find((candidate) => typeof candidate === 'string' && candidate.trim().length > 0);
|
|
41
|
+
if (!text)
|
|
42
|
+
return [];
|
|
43
|
+
const options = (Array.isArray(question.options) ? question.options : []).flatMap((option) => {
|
|
44
|
+
if (typeof option === 'string' && option.trim())
|
|
45
|
+
return [{ label: option.trim() }];
|
|
46
|
+
if (!option || typeof option !== 'object')
|
|
47
|
+
return [];
|
|
48
|
+
const item = option;
|
|
49
|
+
const label = [item.label, item.text, item.value, item.description]
|
|
50
|
+
.find((candidate) => typeof candidate === 'string' && candidate.trim().length > 0);
|
|
51
|
+
if (!label)
|
|
52
|
+
return [];
|
|
53
|
+
return [{
|
|
54
|
+
label: label.trim(),
|
|
55
|
+
...(typeof item.description === 'string' && item.description.trim()
|
|
56
|
+
? { description: item.description.trim() }
|
|
57
|
+
: {}),
|
|
58
|
+
}];
|
|
59
|
+
});
|
|
60
|
+
const custom = question.custom !== false;
|
|
61
|
+
if (!options.length && !custom)
|
|
62
|
+
return [];
|
|
63
|
+
return [{
|
|
64
|
+
question: text.trim(),
|
|
65
|
+
...(typeof question.header === 'string' && question.header.trim()
|
|
66
|
+
? { header: question.header.trim() }
|
|
67
|
+
: {}),
|
|
68
|
+
options,
|
|
69
|
+
multiple: question.multiple === true,
|
|
70
|
+
custom,
|
|
71
|
+
}];
|
|
72
|
+
});
|
|
40
73
|
}
|
|
41
|
-
async function
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
74
|
+
async function promptForAgentAnswers(questions) {
|
|
75
|
+
const answers = [];
|
|
76
|
+
for (let index = 0; index < questions.length; index += 1) {
|
|
77
|
+
const question = questions[index];
|
|
78
|
+
const message = `${question.header ? `${question.header}:` : ''}${question.question}`;
|
|
79
|
+
const choices = question.options.map((option, optionIndex) => ({
|
|
80
|
+
name: option.description && option.description !== option.label
|
|
81
|
+
? `${option.label} — ${option.description}`
|
|
82
|
+
: option.label,
|
|
83
|
+
value: optionIndex,
|
|
84
|
+
}));
|
|
85
|
+
const customValue = -1;
|
|
86
|
+
if (question.custom)
|
|
87
|
+
choices.push({ name: '其他(手动输入)', value: customValue });
|
|
88
|
+
let selectedIndexes;
|
|
89
|
+
if (!question.options.length && question.custom) {
|
|
90
|
+
selectedIndexes = [customValue];
|
|
91
|
+
}
|
|
92
|
+
else if (question.multiple) {
|
|
93
|
+
const selected = await inquirer.prompt([{
|
|
94
|
+
type: 'checkbox',
|
|
95
|
+
name: 'values',
|
|
96
|
+
message: `${message}(可多选)`,
|
|
97
|
+
choices,
|
|
98
|
+
validate: (values) => values.length > 0 || '请至少选择一项。',
|
|
99
|
+
}]);
|
|
100
|
+
selectedIndexes = selected.values;
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
const selected = await inquirer.prompt([{
|
|
104
|
+
type: 'list',
|
|
105
|
+
name: 'value',
|
|
106
|
+
message,
|
|
107
|
+
choices,
|
|
108
|
+
}]);
|
|
109
|
+
selectedIndexes = [selected.value];
|
|
110
|
+
}
|
|
111
|
+
const answer = selectedIndexes
|
|
112
|
+
.filter((selected) => selected !== customValue)
|
|
113
|
+
.map((selected) => question.options[selected]?.label)
|
|
114
|
+
.filter((label) => Boolean(label));
|
|
115
|
+
if (selectedIndexes.includes(customValue)) {
|
|
116
|
+
const custom = await inquirer.prompt([{
|
|
117
|
+
type: 'input',
|
|
118
|
+
name: 'text',
|
|
119
|
+
message: `${question.header || `问题 ${index + 1}`},请输入自定义答案:`,
|
|
120
|
+
validate: (value) => Boolean(value.trim()) || '答案不能为空。',
|
|
121
|
+
}]);
|
|
122
|
+
answer.push(custom.text.trim());
|
|
123
|
+
}
|
|
124
|
+
answers.push(answer);
|
|
125
|
+
}
|
|
126
|
+
return answers;
|
|
127
|
+
}
|
|
128
|
+
function parseAgentQuestionRequests(input) {
|
|
129
|
+
const values = Array.isArray(input) ? input : input && typeof input === 'object' ? [input] : [];
|
|
130
|
+
return values.flatMap((value) => {
|
|
131
|
+
if (!value || typeof value !== 'object')
|
|
132
|
+
return [];
|
|
133
|
+
const payload = value;
|
|
134
|
+
const id = [payload.id, payload.requestID, payload.requestId]
|
|
135
|
+
.find((candidate) => typeof candidate === 'string' && candidate.length > 0);
|
|
136
|
+
if (!id)
|
|
137
|
+
return [];
|
|
138
|
+
const sessionId = [payload.sessionID, payload.sessionId, payload.session_id]
|
|
139
|
+
.find((candidate) => typeof candidate === 'string' && candidate.length > 0);
|
|
140
|
+
return [{
|
|
141
|
+
id,
|
|
142
|
+
...(sessionId ? { sessionId } : {}),
|
|
143
|
+
questions: parseAgentQuestions(payload.questions ?? payload),
|
|
144
|
+
raw: value,
|
|
145
|
+
}];
|
|
55
146
|
});
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
147
|
+
}
|
|
148
|
+
async function replyToAgentQuestion(client, ready, options, request, handledQuestionIds) {
|
|
149
|
+
if (request.sessionId && request.sessionId !== ready.agentSessionId)
|
|
150
|
+
return;
|
|
151
|
+
if (handledQuestionIds.has(request.id))
|
|
152
|
+
return;
|
|
153
|
+
handledQuestionIds.add(request.id);
|
|
154
|
+
if (!request.questions.length) {
|
|
155
|
+
throw new PlayCraftApiError({
|
|
156
|
+
code: 'AGENT_REQUEST_FAILED',
|
|
157
|
+
message: 'Agent 请求用户选择,但返回的问题格式无法识别。请打开 Remix 编辑器回答。',
|
|
158
|
+
details: request.raw,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
if (!options.interactive) {
|
|
162
|
+
throw new PlayCraftApiError({
|
|
163
|
+
code: 'AGENT_REQUEST_FAILED',
|
|
164
|
+
message: 'Agent 正在等待用户选择。请在交互式终端运行命令,或打开 Remix 编辑器回答。',
|
|
165
|
+
details: { requestId: request.id, questions: request.questions },
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
emitProgress(options, `[等待选择] Agent 提出了 ${request.questions.length} 个问题:`);
|
|
169
|
+
const answers = await promptForAgentAnswers(request.questions);
|
|
170
|
+
await client.post(`${ready.agentEndpoint}/question/${encodeURIComponent(request.id)}/reply`, { answers });
|
|
171
|
+
emitProgress(options, '[已回复] 选择已发送,Agent 继续执行...');
|
|
172
|
+
}
|
|
173
|
+
function openBrowser(url) {
|
|
174
|
+
const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
175
|
+
exec(`${command} "${url}"`, () => undefined);
|
|
176
|
+
}
|
|
177
|
+
function localServiceUrl(baseUrl, fromPort, toPort) {
|
|
178
|
+
const url = new URL(baseUrl);
|
|
179
|
+
const isLocal = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
|
|
180
|
+
if (isLocal && url.port === fromPort)
|
|
181
|
+
url.port = toPort;
|
|
182
|
+
return url.toString().replace(/\/+$/, '');
|
|
183
|
+
}
|
|
184
|
+
function createRemixClient(options) {
|
|
185
|
+
const configured = new PlayCraftHttpClient(options);
|
|
186
|
+
const apiBaseUrl = localServiceUrl(configured.baseUrl, '3000', '3001');
|
|
187
|
+
if (apiBaseUrl === configured.baseUrl)
|
|
188
|
+
return configured;
|
|
189
|
+
return new PlayCraftHttpClient({
|
|
190
|
+
url: apiBaseUrl,
|
|
191
|
+
token: configured.token,
|
|
192
|
+
requestId: configured.requestId,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
function editorUrl(client, projectId, branch) {
|
|
196
|
+
const configured = process.env.PLAYCRAFT_FRONTEND_URL || process.env.FRONTEND_URL;
|
|
197
|
+
const base = configured || localServiceUrl(client.baseUrl, '3001', '3000');
|
|
198
|
+
return `${base.replace(/\/+$/, '')}/editor/remix/${encodeURIComponent(projectId)}?branch=${encodeURIComponent(branch)}`;
|
|
199
|
+
}
|
|
200
|
+
function remixDetailUrl(client, projectId, branch) {
|
|
201
|
+
const configured = process.env.PLAYCRAFT_FRONTEND_URL || process.env.FRONTEND_URL;
|
|
202
|
+
const base = configured || localServiceUrl(client.baseUrl, '3001', '3000');
|
|
203
|
+
return `${base.replace(/\/+$/, '')}/projects/${encodeURIComponent(projectId)}/remixes/${encodeURIComponent(branch)}`;
|
|
204
|
+
}
|
|
205
|
+
function isAgentPromptExit(value) {
|
|
206
|
+
return ['', 'q', 'quit', 'exit', '退出'].includes(value.trim().toLowerCase());
|
|
207
|
+
}
|
|
208
|
+
async function resolveEditorProjectId(client, projectId) {
|
|
209
|
+
const project = await client.get(`/projects/${encodeURIComponent(projectId)}`);
|
|
210
|
+
if (project?.projectType === 'external' && typeof project.uuid === 'string' && project.uuid) {
|
|
211
|
+
return project.uuid;
|
|
59
212
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
213
|
+
if (project?.id !== undefined && project?.id !== null)
|
|
214
|
+
return String(project.id);
|
|
215
|
+
return projectId;
|
|
216
|
+
}
|
|
217
|
+
async function resolveEditorProjectIdBestEffort(client, projectId, options, fallbackProjectId = projectId) {
|
|
218
|
+
try {
|
|
219
|
+
return await resolveEditorProjectId(client, projectId);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
emitProgress(options, `[提示] 项目操作已成功,但暂时无法解析编辑器项目标识,链接临时使用项目 ID:${fallbackProjectId}`);
|
|
223
|
+
return fallbackProjectId;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function parseList(payload) {
|
|
227
|
+
if (Array.isArray(payload))
|
|
228
|
+
return payload;
|
|
229
|
+
if (payload && typeof payload === 'object' && Array.isArray(payload.result)) {
|
|
230
|
+
return payload.result;
|
|
231
|
+
}
|
|
232
|
+
return [];
|
|
233
|
+
}
|
|
234
|
+
function parseProjectTemplates(payload) {
|
|
235
|
+
return parseList(payload).map((project) => ({
|
|
236
|
+
id: String(project.id),
|
|
237
|
+
uuid: project.uuid,
|
|
238
|
+
name: project.name,
|
|
239
|
+
projectType: project.projectType,
|
|
240
|
+
description: project.description,
|
|
241
|
+
tags: project.tags,
|
|
242
|
+
thumbnailUrl: project.thumbnailUrl,
|
|
243
|
+
sourceBranch: project.settings?.git?.branch || 'main',
|
|
244
|
+
}));
|
|
245
|
+
}
|
|
246
|
+
function readPrompt(options) {
|
|
247
|
+
if (options.prompt && options.promptFile) {
|
|
248
|
+
throw new PlayCraftApiError({ code: 'AGENT_REQUEST_FAILED', message: '--prompt 和 --prompt-file 只能使用一个。' });
|
|
249
|
+
}
|
|
250
|
+
const prompt = options.promptFile ? readFileSync(options.promptFile, 'utf8') : options.prompt || '';
|
|
251
|
+
const trimmed = prompt.trim();
|
|
252
|
+
if (!trimmed) {
|
|
253
|
+
throw new PlayCraftApiError({ code: 'AGENT_REQUEST_FAILED', message: '需求描述不能为空。' });
|
|
254
|
+
}
|
|
255
|
+
if (Buffer.byteLength(trimmed, 'utf8') > 256 * 1024) {
|
|
256
|
+
throw new PlayCraftApiError({ code: 'AGENT_REQUEST_FAILED', message: '需求描述超过 256 KiB (Kibibyte, 千二进制字节) 限制。' });
|
|
257
|
+
}
|
|
258
|
+
return trimmed;
|
|
259
|
+
}
|
|
260
|
+
function resultEnvelope(command, client, data) {
|
|
261
|
+
return { schemaVersion: '1.0', ok: true, command, requestId: client.requestId, data };
|
|
262
|
+
}
|
|
263
|
+
function errorEnvelope(command, client, error) {
|
|
264
|
+
const apiError = isPlayCraftApiError(error)
|
|
265
|
+
? error
|
|
266
|
+
: new PlayCraftApiError({ code: 'AGENT_REQUEST_FAILED', message: error instanceof Error ? error.message : String(error) });
|
|
267
|
+
return {
|
|
268
|
+
schemaVersion: '1.0',
|
|
269
|
+
ok: false,
|
|
270
|
+
command,
|
|
271
|
+
...(client ? { requestId: client.requestId } : {}),
|
|
272
|
+
error: {
|
|
273
|
+
code: apiError.code,
|
|
274
|
+
message: apiError.message,
|
|
275
|
+
retryable: apiError.retryable,
|
|
276
|
+
...(apiError.details === undefined ? {} : { details: apiError.details }),
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
function exitCodeForError(error) {
|
|
281
|
+
const code = isPlayCraftApiError(error) ? error.code : '';
|
|
282
|
+
if (code === 'AUTH_REQUIRED' || code === 'AUTH_EXPIRED')
|
|
283
|
+
return 3;
|
|
284
|
+
if (code === 'FORBIDDEN' || code === 'TEAM_FORBIDDEN' || code === 'TEAM_NOT_FOUND' || code === 'TEMPLATE_NOT_FOUND')
|
|
285
|
+
return 4;
|
|
286
|
+
if (code === 'AGENT_TIMEOUT' || code === 'AGENT_REQUEST_FAILED' || code === 'AGENT_UNAVAILABLE')
|
|
287
|
+
return 5;
|
|
288
|
+
return 2;
|
|
289
|
+
}
|
|
290
|
+
function handleCommandError(command, options, client, error) {
|
|
291
|
+
if (options.json) {
|
|
292
|
+
process.stdout.write(`${JSON.stringify(errorEnvelope(command, client, error))}\n`);
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
const code = isPlayCraftApiError(error) ? error.code : undefined;
|
|
296
|
+
const message = code === 'AUTH_REQUIRED'
|
|
297
|
+
? '需要登录。请先运行 `playcraft login`,或提供 PLAYCRAFT_TOKEN。'
|
|
298
|
+
: code === 'AUTH_EXPIRED'
|
|
299
|
+
? '登录已过期,请重新运行 `playcraft login`。'
|
|
300
|
+
: error instanceof Error ? error.message : String(error);
|
|
301
|
+
console.error(`错误:${message}`);
|
|
302
|
+
}
|
|
303
|
+
process.exitCode = exitCodeForError(error);
|
|
304
|
+
}
|
|
305
|
+
async function remixTeams(options) {
|
|
306
|
+
let client;
|
|
307
|
+
try {
|
|
308
|
+
client = createRemixClient(options);
|
|
309
|
+
const teams = parseList(await client.get('/teams'));
|
|
310
|
+
const data = { items: teams, page: 1, limit: teams.length, total: teams.length, hasMore: false };
|
|
311
|
+
if (options.json) {
|
|
312
|
+
console.log(JSON.stringify(resultEnvelope('remix.teams', client, data)));
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
if (!teams.length)
|
|
316
|
+
console.log('当前账号没有可用团队。');
|
|
317
|
+
else
|
|
318
|
+
teams.forEach((team) => console.log(`${team.id}\t${team.name}`));
|
|
319
|
+
console.log('\n下一步:');
|
|
320
|
+
console.log(' playcraft remix templates --team-id <团队ID>');
|
|
321
|
+
console.log('\n或直接运行完整交互流程:');
|
|
322
|
+
console.log(' playcraft remix run');
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
handleCommandError('remix.teams', options, client, error);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
async function remixTemplates(options) {
|
|
330
|
+
let client;
|
|
331
|
+
try {
|
|
332
|
+
if (!options.teamId)
|
|
333
|
+
throw new PlayCraftApiError({ code: 'TEAM_NOT_FOUND', message: '必须提供 --team-id。' });
|
|
334
|
+
client = createRemixClient(options);
|
|
335
|
+
const templates = parseProjectTemplates(await client.get('/projects', {
|
|
336
|
+
teamId: options.teamId,
|
|
337
|
+
limit: '100',
|
|
338
|
+
skip: '0',
|
|
339
|
+
}));
|
|
340
|
+
const filtered = options.tag
|
|
341
|
+
? templates.filter((template) => Object.values(template.tags || {})
|
|
342
|
+
.flatMap((tags) => Array.isArray(tags) ? tags : [])
|
|
343
|
+
.some((tag) => typeof tag === 'string' && tag.toLowerCase().includes(options.tag.toLowerCase())))
|
|
344
|
+
: templates;
|
|
345
|
+
const data = { items: filtered, page: 1, limit: filtered.length, total: filtered.length, hasMore: false };
|
|
346
|
+
if (options.json) {
|
|
347
|
+
console.log(JSON.stringify(resultEnvelope('remix.templates', client, data)));
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
if (!filtered.length)
|
|
351
|
+
console.log('所选团队当前没有可见项目。');
|
|
352
|
+
else
|
|
353
|
+
filtered.forEach((template) => console.log(`${template.id}\t${template.name}`));
|
|
354
|
+
console.log('\n下一步:');
|
|
355
|
+
console.log(` playcraft remix run --team-id ${options.teamId} --template-id <模板ID>`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
handleCommandError('remix.templates', options, client, error);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
async function remixCreate(options) {
|
|
363
|
+
const client = createRemixClient(options);
|
|
364
|
+
const branch = await client.post(`/projects/${encodeURIComponent(options.projectId)}/branches`, {
|
|
365
|
+
name: options.name,
|
|
366
|
+
ref: options.sourceBranch,
|
|
367
|
+
...(options.teamId ? { teamId: options.teamId } : {}),
|
|
368
|
+
});
|
|
369
|
+
const branchName = branch.name;
|
|
370
|
+
const editorProjectId = options.editorProjectId || await resolveEditorProjectIdBestEffort(client, options.projectId, options);
|
|
371
|
+
return {
|
|
372
|
+
projectId: options.projectId,
|
|
373
|
+
branch: branchName,
|
|
374
|
+
displayName: branch.displayName,
|
|
375
|
+
editorUrl: editorUrl(client, editorProjectId, branchName),
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
function resolveEnsureStreamUrl(client, projectId) {
|
|
379
|
+
const endpoint = new URL(client.url(`/projects/${encodeURIComponent(projectId)}/sandbox/ensure-stream`));
|
|
380
|
+
const isLocalFrontend = ['localhost', '127.0.0.1', '[::1]'].includes(endpoint.hostname)
|
|
381
|
+
&& endpoint.port === '3000';
|
|
382
|
+
if (isLocalFrontend)
|
|
383
|
+
endpoint.port = '3001';
|
|
384
|
+
return endpoint;
|
|
63
385
|
}
|
|
64
386
|
async function remixEnsureStream(options) {
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
const endpoint = new URL(`${baseUrl}/api/projects/${encodeURIComponent(options.projectId)}/sandbox/ensure-stream`);
|
|
387
|
+
const client = createRemixClient(options);
|
|
388
|
+
const endpoint = resolveEnsureStreamUrl(client, options.projectId);
|
|
68
389
|
endpoint.searchParams.set('branch', options.branch);
|
|
390
|
+
if (options.sourceBranch)
|
|
391
|
+
endpoint.searchParams.set('sourceBranch', options.sourceBranch);
|
|
69
392
|
if (options.evictConfirmed)
|
|
70
393
|
endpoint.searchParams.set('evictConfirmed', 'true');
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
},
|
|
394
|
+
if (options.evictSlotId)
|
|
395
|
+
endpoint.searchParams.set('evictSlotId', options.evictSlotId);
|
|
396
|
+
emitProgress(options, '[连接中] 正在连接沙箱服务...');
|
|
397
|
+
const response = await fetch(endpoint, {
|
|
398
|
+
headers: { Authorization: `Bearer ${client.token}`, Accept: 'text/event-stream' },
|
|
77
399
|
});
|
|
78
|
-
if (!
|
|
79
|
-
|
|
80
|
-
throw new Error(`ensure-stream 失败: ${res.status} ${text}`);
|
|
400
|
+
if (!response.ok || !response.body) {
|
|
401
|
+
throw new PlayCraftApiError({ code: response.status === 401 ? 'AUTH_EXPIRED' : 'AGENT_REQUEST_FAILED', message: `启动沙箱失败,状态码:${response.status}`, status: response.status, retryable: response.status >= 500 });
|
|
81
402
|
}
|
|
82
|
-
const reader =
|
|
403
|
+
const reader = response.body.getReader();
|
|
83
404
|
const decoder = new TextDecoder();
|
|
84
405
|
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)
|
|
406
|
+
const parseEvent = (block) => {
|
|
407
|
+
const data = block.split(/\r?\n/).filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trim()).join('\n');
|
|
408
|
+
if (!data)
|
|
92
409
|
return null;
|
|
93
410
|
try {
|
|
94
|
-
return JSON.parse(
|
|
411
|
+
return JSON.parse(data);
|
|
95
412
|
}
|
|
96
413
|
catch {
|
|
97
414
|
return null;
|
|
@@ -102,134 +419,524 @@ async function remixEnsureStream(options) {
|
|
|
102
419
|
if (done)
|
|
103
420
|
break;
|
|
104
421
|
buffer += decoder.decode(value, { stream: true });
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (!event) {
|
|
111
|
-
sepIdx = buffer.indexOf('\n\n');
|
|
422
|
+
const frames = buffer.split(/\r?\n\r?\n/);
|
|
423
|
+
buffer = frames.pop() || '';
|
|
424
|
+
for (const frame of frames) {
|
|
425
|
+
const event = parseEvent(frame);
|
|
426
|
+
if (!event)
|
|
112
427
|
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
|
-
}
|
|
428
|
+
if (event.type === 'phase')
|
|
429
|
+
emitProgress(options, `[${event.phase}]${event.detail ? ` ${event.detail}` : ''}`);
|
|
430
|
+
else if (event.type === 'log')
|
|
431
|
+
emitProgress(options, event.message);
|
|
432
|
+
else if (event.type === 'error')
|
|
433
|
+
throw new PlayCraftApiError({ code: 'AGENT_REQUEST_FAILED', message: event.message });
|
|
123
434
|
else if (event.type === 'capacity_exceeded') {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
throw new Error(`容量已满 (${event.current}/${event.max}),但服务端未返回可驱逐候选`);
|
|
435
|
+
if (!options.interactive) {
|
|
436
|
+
throw new PlayCraftApiError({ code: 'SANDBOX_CAPACITY_EXCEEDED', message: `沙箱容量已满(${event.current}/${event.max}),请传入 --evict-slot-id 和 --evict-confirmed。`, details: { candidates: event.candidates } });
|
|
127
437
|
}
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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('用户取消操作');
|
|
150
|
-
}
|
|
151
|
-
return remixEnsureStream({ ...options, evictConfirmed: true });
|
|
438
|
+
const candidates = event.candidates.filter((candidate) => candidate.evictable !== false);
|
|
439
|
+
if (!candidates.length)
|
|
440
|
+
throw new PlayCraftApiError({ code: 'SANDBOX_CAPACITY_EXCEEDED', message: `沙箱容量已满(${event.current}/${event.max}),没有可释放的槽位。` });
|
|
441
|
+
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 })) }]);
|
|
442
|
+
const confirmed = await inquirer.prompt([{ type: 'confirm', name: 'ok', message: `确定释放槽位 ${selected.slotId} 并重试吗?`, default: false }]);
|
|
443
|
+
if (!confirmed.ok)
|
|
444
|
+
throw new PlayCraftApiError({ code: 'SANDBOX_CAPACITY_EXCEEDED', message: '操作已取消。' });
|
|
445
|
+
return remixEnsureStream({ ...options, evictConfirmed: true, evictSlotId: selected.slotId });
|
|
152
446
|
}
|
|
153
447
|
else if (event.type === 'done') {
|
|
154
448
|
return {
|
|
155
449
|
sandboxId: event.sandboxId,
|
|
156
450
|
previewUrl: event.previewUrl,
|
|
157
451
|
agentBaseUrl: event.agentBaseUrl,
|
|
452
|
+
agentEndpoint: event.agentEndpoint,
|
|
453
|
+
agentSessionId: event.agentSessionId,
|
|
454
|
+
codeAgentSlug: event.codeAgentSlug,
|
|
455
|
+
slotId: event.slotId,
|
|
456
|
+
status: event.status,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
throw new PlayCraftApiError({ code: 'AGENT_UNAVAILABLE', message: '沙箱启动连接已结束,但沙箱尚未就绪。' });
|
|
462
|
+
}
|
|
463
|
+
function sessionPath(ready) {
|
|
464
|
+
if (!ready.agentEndpoint || !ready.agentSessionId) {
|
|
465
|
+
throw new PlayCraftApiError({ code: 'AGENT_UNAVAILABLE', message: '沙箱未返回 Agent 服务地址或当前会话。' });
|
|
466
|
+
}
|
|
467
|
+
return `${ready.agentEndpoint}/session/${encodeURIComponent(ready.agentSessionId)}`;
|
|
468
|
+
}
|
|
469
|
+
async function ensureAgentSession(client, ready, options) {
|
|
470
|
+
if (!ready.agentEndpoint) {
|
|
471
|
+
throw new PlayCraftApiError({ code: 'AGENT_UNAVAILABLE', message: '沙箱未返回 Agent 服务地址。' });
|
|
472
|
+
}
|
|
473
|
+
if (ready.agentSessionId)
|
|
474
|
+
return ready;
|
|
475
|
+
const findLatestSession = async () => {
|
|
476
|
+
const sessions = await client.get(`${ready.agentEndpoint}/session`);
|
|
477
|
+
return [...(Array.isArray(sessions) ? sessions : [])]
|
|
478
|
+
.filter((session) => typeof session.id === 'string' && session.id.length > 0)
|
|
479
|
+
.sort((a, b) => (b.time?.created ?? 0) - (a.time?.created ?? 0))[0];
|
|
480
|
+
};
|
|
481
|
+
const latest = await findLatestSession();
|
|
482
|
+
if (latest?.id)
|
|
483
|
+
return { ...ready, agentSessionId: latest.id };
|
|
484
|
+
emitProgress(options, '[会话中] 正在创建 Agent 会话...');
|
|
485
|
+
let created;
|
|
486
|
+
try {
|
|
487
|
+
created = await client.post(`${ready.agentEndpoint}/session`, {}, {
|
|
488
|
+
signal: AbortSignal.timeout(30_000),
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
catch (error) {
|
|
492
|
+
emitProgress(options, '[会话中] 创建响应未返回,正在查询已创建的会话...');
|
|
493
|
+
const recovered = await findLatestSession();
|
|
494
|
+
if (recovered?.id)
|
|
495
|
+
return { ...ready, agentSessionId: recovered.id };
|
|
496
|
+
throw error;
|
|
497
|
+
}
|
|
498
|
+
if (!created?.id) {
|
|
499
|
+
throw new PlayCraftApiError({ code: 'AGENT_UNAVAILABLE', message: 'Agent 会话创建成功,但未返回会话 ID。' });
|
|
500
|
+
}
|
|
501
|
+
return { ...ready, agentSessionId: created.id };
|
|
502
|
+
}
|
|
503
|
+
async function listPendingAgentQuestions(client, ready) {
|
|
504
|
+
if (!ready.agentEndpoint)
|
|
505
|
+
return [];
|
|
506
|
+
try {
|
|
507
|
+
const payload = await client.get(`${ready.agentEndpoint}/question`);
|
|
508
|
+
return parseAgentQuestionRequests(payload)
|
|
509
|
+
.filter((request) => !request.sessionId || request.sessionId === ready.agentSessionId);
|
|
510
|
+
}
|
|
511
|
+
catch (error) {
|
|
512
|
+
if (isPlayCraftApiError(error) && error.status === 404)
|
|
513
|
+
return [];
|
|
514
|
+
throw error;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
async function waitForAgentIdle(client, ready, timeoutMs, options, handledQuestionIds, baselineAssistantState) {
|
|
518
|
+
const path = `${sessionPath(ready).replace(/\/session\/[^/]+$/, '')}/event`;
|
|
519
|
+
const controller = new AbortController();
|
|
520
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
521
|
+
try {
|
|
522
|
+
const response = await fetch(client.url(path), { headers: { Authorization: `Bearer ${client.token}`, Accept: 'text/event-stream' }, signal: controller.signal });
|
|
523
|
+
if (!response.ok || !response.body)
|
|
524
|
+
throw new Error(`Agent 事件流连接失败,状态码:${response.status}`);
|
|
525
|
+
const reader = response.body.getReader();
|
|
526
|
+
const decoder = new TextDecoder();
|
|
527
|
+
let buffer = '';
|
|
528
|
+
const pendingQuestions = await listPendingAgentQuestions(client, ready);
|
|
529
|
+
for (const request of pendingQuestions) {
|
|
530
|
+
await replyToAgentQuestion(client, ready, options, request, handledQuestionIds);
|
|
531
|
+
}
|
|
532
|
+
if (await hasAgentTurnCompleted(client, ready, baselineAssistantState, true))
|
|
533
|
+
return;
|
|
534
|
+
while (true) {
|
|
535
|
+
const { done, value } = await reader.read();
|
|
536
|
+
if (done)
|
|
537
|
+
break;
|
|
538
|
+
buffer += decoder.decode(value, { stream: true });
|
|
539
|
+
const frames = buffer.split(/\r?\n\r?\n/);
|
|
540
|
+
buffer = frames.pop() || '';
|
|
541
|
+
for (const frame of frames) {
|
|
542
|
+
const data = frame.split(/\r?\n/).filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trim()).join('\n');
|
|
543
|
+
if (!data)
|
|
544
|
+
continue;
|
|
545
|
+
let event;
|
|
546
|
+
try {
|
|
547
|
+
event = JSON.parse(data);
|
|
548
|
+
}
|
|
549
|
+
catch {
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
const properties = event?.properties || {};
|
|
553
|
+
const eventSessionId = properties.sessionID || properties.sessionId || properties.session_id;
|
|
554
|
+
if (event?.type === 'question.asked') {
|
|
555
|
+
const questionPayload = Object.keys(properties).length > 0 ? properties : event;
|
|
556
|
+
const [request] = parseAgentQuestionRequests(questionPayload);
|
|
557
|
+
if (request) {
|
|
558
|
+
await replyToAgentQuestion(client, ready, options, request, handledQuestionIds);
|
|
559
|
+
}
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
const idle = event?.type === 'session.idle' || (event?.type === 'session.status' && properties.status?.type === 'idle');
|
|
563
|
+
if (idle
|
|
564
|
+
&& (!eventSessionId || eventSessionId === ready.agentSessionId)
|
|
565
|
+
&& await hasAgentTurnCompleted(client, ready, baselineAssistantState, false))
|
|
566
|
+
return;
|
|
567
|
+
if (event?.type === 'session.error' && (!eventSessionId || eventSessionId === ready.agentSessionId)) {
|
|
568
|
+
throw new PlayCraftApiError({ code: 'AGENT_REQUEST_FAILED', message: properties.message || 'Agent 会话执行失败。' });
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
throw new Error('Agent 事件流已结束,但会话尚未进入空闲状态。');
|
|
573
|
+
}
|
|
574
|
+
catch (error) {
|
|
575
|
+
if (controller.signal.aborted) {
|
|
576
|
+
throw new PlayCraftApiError({ code: 'AGENT_TIMEOUT', message: `Agent 在 ${Math.ceil(timeoutMs / 1000)} 秒内未完成任务。`, retryable: true });
|
|
577
|
+
}
|
|
578
|
+
throw error;
|
|
579
|
+
}
|
|
580
|
+
finally {
|
|
581
|
+
clearTimeout(timer);
|
|
582
|
+
if (!controller.signal.aborted)
|
|
583
|
+
controller.abort();
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
async function listAgentMessages(client, ready) {
|
|
587
|
+
const messages = await client.get(`${sessionPath(ready)}/message`);
|
|
588
|
+
return Array.isArray(messages) ? messages : [];
|
|
589
|
+
}
|
|
590
|
+
function assistantState(messages) {
|
|
591
|
+
return JSON.stringify(messages.filter((message) => (message.info?.role || message.role) === 'assistant'));
|
|
592
|
+
}
|
|
593
|
+
async function currentAgentSessionStatus(client, ready) {
|
|
594
|
+
if (!ready.agentEndpoint || !ready.agentSessionId)
|
|
595
|
+
return undefined;
|
|
596
|
+
try {
|
|
597
|
+
const payload = await client.get(`${ready.agentEndpoint}/session/status`);
|
|
598
|
+
const record = payload && typeof payload === 'object' && !Array.isArray(payload)
|
|
599
|
+
? payload
|
|
600
|
+
: undefined;
|
|
601
|
+
const direct = record?.[ready.agentSessionId];
|
|
602
|
+
if (record && direct === undefined)
|
|
603
|
+
return 'idle';
|
|
604
|
+
const value = direct?.status ?? direct;
|
|
605
|
+
return typeof value?.type === 'string' ? value.type : undefined;
|
|
606
|
+
}
|
|
607
|
+
catch {
|
|
608
|
+
return undefined;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
async function hasAgentTurnCompleted(client, ready, baselineAssistantState, requireKnownIdle) {
|
|
612
|
+
const messages = await listAgentMessages(client, ready);
|
|
613
|
+
if (assistantState(messages) === baselineAssistantState)
|
|
614
|
+
return false;
|
|
615
|
+
const status = await currentAgentSessionStatus(client, ready);
|
|
616
|
+
return status === 'idle' || (!requireKnownIdle && status === undefined);
|
|
617
|
+
}
|
|
618
|
+
async function lastAssistantText(client, ready) {
|
|
619
|
+
const messages = await listAgentMessages(client, ready);
|
|
620
|
+
const assistants = messages.filter((message) => (message.info?.role || message.role) === 'assistant');
|
|
621
|
+
const last = assistants[assistants.length - 1];
|
|
622
|
+
const text = (last?.parts || []).filter((part) => part.type === 'text' && part.text).map((part) => part.text).join('').trim();
|
|
623
|
+
return text || undefined;
|
|
624
|
+
}
|
|
625
|
+
async function remixPrompt(options) {
|
|
626
|
+
const commandName = options.commandName || 'remix.prompt';
|
|
627
|
+
let client;
|
|
628
|
+
try {
|
|
629
|
+
const prompt = readPrompt(options);
|
|
630
|
+
const imageAttachments = readImageAttachments(options.images);
|
|
631
|
+
client = createRemixClient(options);
|
|
632
|
+
const sandbox = await remixEnsureStream({ ...options, interactive: options.interactive ?? false });
|
|
633
|
+
const ready = await ensureAgentSession(client, sandbox, options);
|
|
634
|
+
const session = sessionPath(ready);
|
|
635
|
+
const editorProjectId = await resolveEditorProjectIdBestEffort(client, options.projectId, options);
|
|
636
|
+
const urls = {
|
|
637
|
+
editor: editorUrl(client, editorProjectId, options.branch),
|
|
638
|
+
...(ready.previewUrl ? { preview: ready.previewUrl } : {}),
|
|
639
|
+
};
|
|
640
|
+
emitProgress(options, `[已就绪] Remix 编辑器地址:${urls.editor}`);
|
|
641
|
+
const pendingQuestions = await listPendingAgentQuestions(client, ready);
|
|
642
|
+
const handledQuestionIds = new Set();
|
|
643
|
+
if (pendingQuestions.length) {
|
|
644
|
+
emitProgress(options, `[恢复中] 检测到 ${pendingQuestions.length} 个待回答问题,不会重复发送原需求。`);
|
|
645
|
+
}
|
|
646
|
+
const wait = options.wait !== false;
|
|
647
|
+
const baselineAssistantState = wait
|
|
648
|
+
? assistantState(await listAgentMessages(client, ready))
|
|
649
|
+
: undefined;
|
|
650
|
+
let uploadedImageAttachments = [];
|
|
651
|
+
if (pendingQuestions.length) {
|
|
652
|
+
if (imageAttachments.length > 0) {
|
|
653
|
+
emitProgress(options, `[提示] 当前会话有待回答问题,本次 ${imageAttachments.length} 张图片未上传,也不会重复发送原需求。`);
|
|
654
|
+
}
|
|
655
|
+
for (const request of pendingQuestions) {
|
|
656
|
+
await replyToAgentQuestion(client, ready, options, request, handledQuestionIds);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
else {
|
|
660
|
+
if (imageAttachments.length > 0) {
|
|
661
|
+
uploadedImageAttachments = await uploadImageAttachments(client, options.projectId, options.branch, imageAttachments);
|
|
662
|
+
}
|
|
663
|
+
const promptToSend = buildReferenceImagesPrompt(prompt, uploadedImageAttachments);
|
|
664
|
+
await client.post(`${session}/prompt_async`, { parts: [{ type: 'text', text: promptToSend }] });
|
|
665
|
+
}
|
|
666
|
+
if (baselineAssistantState !== undefined) {
|
|
667
|
+
await waitForAgentIdle(client, ready, options.timeout * 1000, options, handledQuestionIds, baselineAssistantState);
|
|
668
|
+
}
|
|
669
|
+
const assistantText = wait ? await lastAssistantText(client, ready) : undefined;
|
|
670
|
+
const data = {
|
|
671
|
+
projectId: options.projectId,
|
|
672
|
+
branch: options.branch,
|
|
673
|
+
created: options.created ?? false,
|
|
674
|
+
...(options.teamId ? { teamId: options.teamId } : {}),
|
|
675
|
+
...(options.templateId ? { templateId: options.templateId } : {}),
|
|
676
|
+
urls,
|
|
677
|
+
...(uploadedImageAttachments.length > 0
|
|
678
|
+
? {
|
|
679
|
+
attachments: {
|
|
680
|
+
images: uploadedImageAttachments.map(({ fileName, sandboxPath }) => ({ fileName, sandboxPath })),
|
|
681
|
+
},
|
|
682
|
+
}
|
|
683
|
+
: imageAttachments.length > 0 && pendingQuestions.length > 0
|
|
684
|
+
? { attachments: { requestedImages: imageAttachments.length, sentImages: 0, status: 'not_sent_pending_question' } }
|
|
685
|
+
: {}),
|
|
686
|
+
sandbox: { id: ready.sandboxId, slotId: ready.slotId, status: ready.status || 'ready' },
|
|
687
|
+
agent: { slug: ready.codeAgentSlug, sessionId: ready.agentSessionId, status: wait ? 'completed' : 'running', ...(assistantText ? { lastAssistantText: assistantText } : {}) },
|
|
688
|
+
};
|
|
689
|
+
if (options.open)
|
|
690
|
+
openBrowser(data.urls.editor);
|
|
691
|
+
if (options.json)
|
|
692
|
+
console.log(JSON.stringify(resultEnvelope(commandName, client, data)));
|
|
693
|
+
else
|
|
694
|
+
console.log(`需求已${wait ? '完成' : '接收'}:${data.urls.editor}`);
|
|
695
|
+
}
|
|
696
|
+
catch (error) {
|
|
697
|
+
handleCommandError(commandName, options, client, error);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
async function remixRun(options) {
|
|
701
|
+
let client;
|
|
702
|
+
try {
|
|
703
|
+
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY && !options.json);
|
|
704
|
+
const requestedImageCount = options.images?.length ?? 0;
|
|
705
|
+
if (requestedImageCount > 0 && !options.prompt && !options.promptFile && !interactive) {
|
|
706
|
+
throw new PlayCraftApiError({
|
|
707
|
+
code: 'ATTACHMENT_PROMPT_REQUIRED',
|
|
708
|
+
message: '非交互模式使用 --image 时必须同时提供 --prompt 或 --prompt-file。',
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
client = createRemixClient(options);
|
|
712
|
+
let teamId = options.teamId;
|
|
713
|
+
let templateId = options.templateId;
|
|
714
|
+
let selectedTemplate;
|
|
715
|
+
let created = false;
|
|
716
|
+
if (options.projectId && (options.templateId || options.name || options.teamId)) {
|
|
717
|
+
throw new PlayCraftApiError({ code: 'AGENT_REQUEST_FAILED', message: '已有 Remix 模式的 --project-id/--branch 不能与 --team-id、--template-id 或 --name 同时使用。' });
|
|
718
|
+
}
|
|
719
|
+
if (!options.projectId) {
|
|
720
|
+
if (!teamId) {
|
|
721
|
+
if (!interactive)
|
|
722
|
+
throw new PlayCraftApiError({ code: 'TEAM_NOT_FOUND', message: '非交互模式必须提供 --team-id。' });
|
|
723
|
+
const teams = parseList(await client.get('/teams'));
|
|
724
|
+
if (!teams.length)
|
|
725
|
+
throw new PlayCraftApiError({ code: 'TEAM_NOT_FOUND', message: '当前登录账号没有可用团队。' });
|
|
726
|
+
teamId = (await inquirer.prompt([{ type: 'list', name: 'teamId', message: '请选择团队:', choices: teams.map((team) => ({ name: team.name, value: team.id })) }])).teamId;
|
|
727
|
+
}
|
|
728
|
+
const templates = parseProjectTemplates(await client.get('/projects', {
|
|
729
|
+
teamId,
|
|
730
|
+
limit: '100',
|
|
731
|
+
skip: '0',
|
|
732
|
+
}));
|
|
733
|
+
if (!templates.length)
|
|
734
|
+
throw new PlayCraftApiError({ code: 'TEMPLATE_NOT_FOUND', message: '所选团队当前没有可见项目。' });
|
|
735
|
+
if (!templateId) {
|
|
736
|
+
if (!interactive)
|
|
737
|
+
throw new PlayCraftApiError({ code: 'TEMPLATE_NOT_FOUND', message: '非交互模式必须提供 --template-id。' });
|
|
738
|
+
templateId = (await inquirer.prompt([{ type: 'list', name: 'templateId', message: '请选择模板:', choices: templates.map((template) => ({ name: template.name, value: template.id })) }])).templateId;
|
|
739
|
+
}
|
|
740
|
+
selectedTemplate = templates.find((template) => template.id === templateId);
|
|
741
|
+
if (!selectedTemplate)
|
|
742
|
+
throw new PlayCraftApiError({ code: 'TEMPLATE_NOT_FOUND', message: `模板 ${templateId} 不存在或对所选团队不可见。` });
|
|
743
|
+
if (!options.name && !interactive)
|
|
744
|
+
throw new PlayCraftApiError({ code: 'AGENT_REQUEST_FAILED', message: '非交互模式必须提供 --name。' });
|
|
745
|
+
const name = (options.name || (await inquirer.prompt([{ type: 'input', name: 'name', message: '请输入 Remix 名称:', validate: (value) => Boolean(value.trim()) || 'Remix 名称不能为空。' }])).name).trim();
|
|
746
|
+
const createdRemix = await remixCreate({
|
|
747
|
+
...options,
|
|
748
|
+
projectId: selectedTemplate.id,
|
|
749
|
+
name,
|
|
750
|
+
sourceBranch: selectedTemplate.sourceBranch || options.sourceBranch,
|
|
751
|
+
teamId,
|
|
752
|
+
editorProjectId: selectedTemplate.projectType === 'external' && selectedTemplate.uuid
|
|
753
|
+
? selectedTemplate.uuid
|
|
754
|
+
: selectedTemplate.id,
|
|
755
|
+
});
|
|
756
|
+
options.projectId = createdRemix.projectId;
|
|
757
|
+
options.branch = createdRemix.branch;
|
|
758
|
+
created = true;
|
|
759
|
+
const createdRemixDetailUrl = remixDetailUrl(client, createdRemix.projectId, createdRemix.branch);
|
|
760
|
+
if (!options.json) {
|
|
761
|
+
emitProgress(options, `✓ Remix 创建完成:${createdRemix.displayName || name}`);
|
|
762
|
+
emitProgress(options, `Remix 详情地址:${createdRemixDetailUrl}`);
|
|
763
|
+
}
|
|
764
|
+
let prompt;
|
|
765
|
+
if (options.prompt || options.promptFile) {
|
|
766
|
+
prompt = readPrompt(options);
|
|
767
|
+
}
|
|
768
|
+
else if (interactive) {
|
|
769
|
+
const answer = await inquirer.prompt([{
|
|
770
|
+
type: 'input',
|
|
771
|
+
name: 'prompt',
|
|
772
|
+
message: '请输入需求描述(如不需要使用 Agent 生成,请直接回车或输入 exit 退出):',
|
|
773
|
+
}]);
|
|
774
|
+
if (!isAgentPromptExit(answer.prompt))
|
|
775
|
+
prompt = answer.prompt.trim();
|
|
776
|
+
}
|
|
777
|
+
if (!prompt) {
|
|
778
|
+
const data = {
|
|
779
|
+
projectId: createdRemix.projectId,
|
|
780
|
+
branch: createdRemix.branch,
|
|
781
|
+
created: true,
|
|
782
|
+
teamId,
|
|
783
|
+
templateId,
|
|
784
|
+
urls: {
|
|
785
|
+
remix: createdRemixDetailUrl,
|
|
786
|
+
editor: createdRemix.editorUrl,
|
|
787
|
+
},
|
|
788
|
+
...(requestedImageCount > 0
|
|
789
|
+
? { attachments: { requestedImages: requestedImageCount, sentImages: 0, status: 'not_sent' } }
|
|
790
|
+
: {}),
|
|
791
|
+
agent: { started: false },
|
|
158
792
|
};
|
|
793
|
+
if (options.json)
|
|
794
|
+
console.log(JSON.stringify(resultEnvelope('remix.run', client, data)));
|
|
795
|
+
else
|
|
796
|
+
emitProgress(options, requestedImageCount > 0
|
|
797
|
+
? '已结束:Remix 已创建,未启动 Agent;所选图片未上传。'
|
|
798
|
+
: '已结束:Remix 已创建,未启动 Agent。');
|
|
799
|
+
return;
|
|
159
800
|
}
|
|
160
|
-
|
|
801
|
+
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 });
|
|
802
|
+
return;
|
|
161
803
|
}
|
|
804
|
+
if (!options.branch)
|
|
805
|
+
throw new PlayCraftApiError({ code: 'REMIX_NOT_FOUND', message: '已有 Remix 模式必须同时提供 --project-id 和 --branch。' });
|
|
806
|
+
const prompt = options.prompt || options.promptFile
|
|
807
|
+
? readPrompt(options)
|
|
808
|
+
: interactive
|
|
809
|
+
? (await inquirer.prompt([{ type: 'input', name: 'prompt', message: '请输入需求描述:', validate: (value) => Boolean(value.trim()) || '需求描述不能为空。' }])).prompt.trim()
|
|
810
|
+
: readPrompt(options);
|
|
811
|
+
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 });
|
|
812
|
+
}
|
|
813
|
+
catch (error) {
|
|
814
|
+
handleCommandError('remix.run', options, client, error);
|
|
162
815
|
}
|
|
163
|
-
throw new Error('ensure-stream 结束但未收到 done 事件');
|
|
164
816
|
}
|
|
165
817
|
export function registerRemixCommands(program) {
|
|
818
|
+
const helpTitles = {
|
|
819
|
+
'Usage:': '用法:',
|
|
820
|
+
'Arguments:': '参数:',
|
|
821
|
+
'Options:': '选项:',
|
|
822
|
+
'Global Options:': '全局选项:',
|
|
823
|
+
'Commands:': '命令:',
|
|
824
|
+
};
|
|
166
825
|
const remix = program
|
|
167
826
|
.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
|
-
.
|
|
827
|
+
.description('Remix 工作流命令')
|
|
828
|
+
.helpOption('-h, --help', '显示帮助信息')
|
|
829
|
+
.helpCommand('help [command]', '显示指定命令的帮助信息')
|
|
830
|
+
.configureHelp({ styleTitle: (title) => helpTitles[title] || title });
|
|
831
|
+
remix.command('init-template')
|
|
832
|
+
.description('应用引擎脚手架或烘焙模板')
|
|
833
|
+
.option('--engine <name>', '引擎适配器:phaser | threejs')
|
|
834
|
+
.option('--from-atom-tree', '从 docs/atom-tree.json 读取引擎配置')
|
|
835
|
+
.option('--project-dir <path>', '项目根目录', process.cwd())
|
|
836
|
+
.option('--force', '引擎适配器不一致时仍替换 game/ 目录')
|
|
837
|
+
.option('--bake', '烘焙维护中的项目模板')
|
|
838
|
+
.option('--all-engines', '烘焙所有引擎模板')
|
|
839
|
+
.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) }));
|
|
840
|
+
remix.command('teams')
|
|
841
|
+
.description('列出当前登录账号可用的团队')
|
|
842
|
+
.option('--url <url>', SERVICE_ADDRESS_HELP)
|
|
843
|
+
.option('--token <token>', PAT_HELP)
|
|
844
|
+
.option('--json', JSON_OUTPUT_HELP)
|
|
845
|
+
.action((opts) => remixTeams(opts));
|
|
846
|
+
remix.command('templates')
|
|
847
|
+
.description('列出项目页中指定团队可见的项目模板')
|
|
848
|
+
.requiredOption('--team-id <id>', '团队 ID (Identifier, 标识符)')
|
|
849
|
+
.option('--tag <filter>', '按标签筛选')
|
|
850
|
+
.option('--url <url>', SERVICE_ADDRESS_HELP)
|
|
851
|
+
.option('--token <token>', PAT_HELP)
|
|
852
|
+
.option('--json', JSON_OUTPUT_HELP)
|
|
853
|
+
.action((opts) => remixTemplates(opts));
|
|
854
|
+
remix.command('create')
|
|
855
|
+
.description('创建真实的 Remix 分支')
|
|
191
856
|
.requiredOption('--project-id <id>', '项目 ID')
|
|
192
|
-
.requiredOption('--name <name>', '
|
|
857
|
+
.requiredOption('--name <name>', 'Remix 展示名称')
|
|
858
|
+
.option('--team-id <id>', '所选团队 ID')
|
|
193
859
|
.option('--source-branch <branch>', '源分支', 'main')
|
|
194
|
-
.option('--url <url>',
|
|
195
|
-
.option('--token <
|
|
860
|
+
.option('--url <url>', SERVICE_ADDRESS_HELP)
|
|
861
|
+
.option('--token <token>', PAT_HELP)
|
|
862
|
+
.option('--json', JSON_OUTPUT_HELP)
|
|
196
863
|
.action(async (opts) => {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
sourceBranch: String(opts.sourceBranch || 'main')
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
864
|
+
let client;
|
|
865
|
+
try {
|
|
866
|
+
client = createRemixClient(opts);
|
|
867
|
+
const data = await remixCreate({ ...opts, projectId: String(opts.projectId), name: String(opts.name), sourceBranch: String(opts.sourceBranch || 'main') });
|
|
868
|
+
if (opts.json)
|
|
869
|
+
console.log(JSON.stringify(resultEnvelope('remix.create', client, data)));
|
|
870
|
+
else
|
|
871
|
+
console.log(`Remix 创建成功:${data.editorUrl}`);
|
|
872
|
+
}
|
|
873
|
+
catch (error) {
|
|
874
|
+
handleCommandError('remix.create', opts, client, error);
|
|
875
|
+
}
|
|
204
876
|
});
|
|
205
|
-
remix
|
|
206
|
-
.
|
|
207
|
-
.description('启动/复用 remix 沙箱(GET ensure-stream SSE)')
|
|
877
|
+
remix.command('start')
|
|
878
|
+
.description('启动或复用 Remix 沙箱')
|
|
208
879
|
.requiredOption('--project-id <id>', '项目 ID')
|
|
209
|
-
.requiredOption('--branch <name>', '
|
|
210
|
-
.option('--
|
|
211
|
-
.option('--
|
|
212
|
-
.option('--
|
|
213
|
-
.option('--
|
|
880
|
+
.requiredOption('--branch <name>', 'Remix 分支')
|
|
881
|
+
.option('--source-branch <branch>', '分支不存在时使用的源分支')
|
|
882
|
+
.option('--evict-slot-id <id>', '容量已满时要释放的槽位 ID')
|
|
883
|
+
.option('--evict-confirmed', '确认释放指定槽位')
|
|
884
|
+
.option('--open', '沙箱就绪后打开编辑器地址')
|
|
885
|
+
.option('--url <url>', SERVICE_ADDRESS_HELP)
|
|
886
|
+
.option('--token <token>', PAT_HELP)
|
|
887
|
+
.option('--json', JSON_OUTPUT_HELP)
|
|
214
888
|
.action(async (opts) => {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
console.log(`- ttydUrl: ${ttydUrl}`);
|
|
231
|
-
if (opts.open && ready.previewUrl) {
|
|
232
|
-
openBrowser(ready.previewUrl);
|
|
889
|
+
let client;
|
|
890
|
+
try {
|
|
891
|
+
client = createRemixClient(opts);
|
|
892
|
+
const ready = await remixEnsureStream({ ...opts, projectId: String(opts.projectId), branch: String(opts.branch), interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY && !opts.json) });
|
|
893
|
+
const editorProjectId = await resolveEditorProjectIdBestEffort(client, String(opts.projectId), opts);
|
|
894
|
+
const data = { projectId: String(opts.projectId), branch: String(opts.branch), urls: { editor: editorUrl(client, editorProjectId, String(opts.branch)), ...(ready.previewUrl ? { preview: ready.previewUrl } : {}) }, sandbox: { id: ready.sandboxId, slotId: ready.slotId, status: ready.status || 'ready' }, agent: { slug: ready.codeAgentSlug, sessionId: ready.agentSessionId } };
|
|
895
|
+
if (opts.open)
|
|
896
|
+
openBrowser(data.urls.editor);
|
|
897
|
+
if (opts.json)
|
|
898
|
+
console.log(JSON.stringify(resultEnvelope('remix.start', client, data)));
|
|
899
|
+
else
|
|
900
|
+
console.log(`沙箱已就绪:${data.urls.editor}`);
|
|
901
|
+
}
|
|
902
|
+
catch (error) {
|
|
903
|
+
handleCommandError('remix.start', opts, client, error);
|
|
233
904
|
}
|
|
234
905
|
});
|
|
906
|
+
remix.command('prompt')
|
|
907
|
+
.description('向当前 Remix 沙箱会话发送需求和参考图片')
|
|
908
|
+
.requiredOption('--project-id <id>', '项目 ID')
|
|
909
|
+
.requiredOption('--branch <name>', 'Remix 分支')
|
|
910
|
+
.option('--prompt <text>', '直接传入需求描述')
|
|
911
|
+
.option('--prompt-file <path>', '包含需求描述的 UTF-8 (Unicode Transformation Format 8-bit, Unicode 八位转换格式) 文件')
|
|
912
|
+
.option('--image <path...>', IMAGE_OPTION_HELP)
|
|
913
|
+
.option('--wait', '等待会话执行完成(默认)')
|
|
914
|
+
.option('--no-wait', '需求被接收后立即返回')
|
|
915
|
+
.option('--timeout <seconds>', '等待超时秒数', '1800')
|
|
916
|
+
.option('--evict-slot-id <id>', '容量已满时要释放的槽位 ID')
|
|
917
|
+
.option('--evict-confirmed', '确认释放指定槽位')
|
|
918
|
+
.option('--open', '执行完成后打开编辑器地址')
|
|
919
|
+
.option('--url <url>', SERVICE_ADDRESS_HELP)
|
|
920
|
+
.option('--token <token>', PAT_HELP)
|
|
921
|
+
.option('--json', JSON_OUTPUT_HELP)
|
|
922
|
+
.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) }));
|
|
923
|
+
remix.command('run')
|
|
924
|
+
.description('选择模板并创建 Remix,可选向 Agent 发送需求和参考图片')
|
|
925
|
+
.option('--team-id <id>', '所选团队 ID')
|
|
926
|
+
.option('--template-id <id>', '项目模板 ID')
|
|
927
|
+
.option('--project-id <id>', '已有 Remix 的项目 ID')
|
|
928
|
+
.option('--branch <name>', '已有 Remix 的分支')
|
|
929
|
+
.option('--name <name>', '新 Remix 的展示名称')
|
|
930
|
+
.option('--source-branch <branch>', '项目未配置默认分支时使用的源分支', 'main')
|
|
931
|
+
.option('--prompt <text>', '直接传入需求描述')
|
|
932
|
+
.option('--prompt-file <path>', '包含需求描述的 UTF-8 (Unicode Transformation Format 8-bit, Unicode 八位转换格式) 文件')
|
|
933
|
+
.option('--image <path...>', IMAGE_OPTION_HELP)
|
|
934
|
+
.option('--wait', '等待会话执行完成(默认)')
|
|
935
|
+
.option('--no-wait', '需求被接收后立即返回')
|
|
936
|
+
.option('--timeout <seconds>', '等待超时秒数', '1800')
|
|
937
|
+
.option('--open', '执行完成后打开编辑器地址')
|
|
938
|
+
.option('--url <url>', SERVICE_ADDRESS_HELP)
|
|
939
|
+
.option('--token <token>', PAT_HELP)
|
|
940
|
+
.option('--json', JSON_OUTPUT_HELP)
|
|
941
|
+
.action((opts) => remixRun({ ...opts, images: opts.image, timeout: Math.max(1, Number(opts.timeout) || 1800) }));
|
|
235
942
|
}
|