claude360 0.2.8 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/topup.js CHANGED
@@ -1,4 +1,6 @@
1
- import { renderKeyValueTable, renderSectionTitle } from "./ui.js";
1
+ import { colorLevel } from "./colors.js";
2
+ import { createMessenger } from "./messages.js";
3
+ import { renderChoiceTable, renderDivider, renderKeyValueTable, renderSectionTitle, renderTaskEnd, renderTaskStart, renderTaskStep } from "./ui.js";
2
4
 
3
5
  export async function loadTopUpOptions(api) {
4
6
  if (!api) {
@@ -31,12 +33,26 @@ export async function runWechatTopUp({
31
33
  throw new Error("缺少 API client");
32
34
  }
33
35
 
36
+ const msg = createMessenger({ writeLine, color: writeLine === console.log ? colorLevel() : 0 });
37
+ writeLine(renderTaskStart("微信扫码充值", {
38
+ intro: ["加载充值选项", "选择充值金额", "创建支付订单", "等待微信支付", "刷新余额"],
39
+ }));
40
+ writeLine("");
41
+ writeLine(renderTaskStep(1, 5, "加载充值选项"));
34
42
  const options = await loadTopUpOptions(api);
35
43
  // 后端口径前置拦截(验收 P1-2):禁用时不进金额选择、不创建订单
36
44
  if (options?.wechat_enabled === false) {
37
45
  throw new Error("微信支付未启用,请使用网页充值。");
38
46
  }
39
- const amount = await chooseTopUpAmount({ options, promptSelect, promptInput });
47
+ writeLine("");
48
+ writeLine(renderDivider("section"));
49
+ writeLine("");
50
+ writeLine(renderTaskStep(2, 5, "选择充值金额"));
51
+ const amount = await chooseTopUpAmount({ options, promptSelect, promptInput, writeLine });
52
+ writeLine("");
53
+ writeLine(renderDivider("section"));
54
+ writeLine("");
55
+ writeLine(renderTaskStep(3, 5, "创建支付订单"));
40
56
  const order = await api.post("/api/cli/topup/wechat", { amount });
41
57
  if (!order?.order_id || !order?.code_url) {
42
58
  throw new Error("创建微信充值订单失败");
@@ -49,8 +65,12 @@ export async function runWechatTopUp({
49
65
  ["金额", String(order.money_display || order.money || amount)],
50
66
  ["支付方式", "微信扫码"],
51
67
  ]));
52
- writeLine("请使用微信扫码支付:");
68
+ msg.info("请使用微信扫码支付:");
53
69
  await printQrOrCodeUrl({ codeUrl: order.code_url, renderQr, writeLine });
70
+ writeLine("");
71
+ writeLine(renderDivider("section"));
72
+ writeLine("");
73
+ writeLine(renderTaskStep(4, 5, "等待微信支付"));
54
74
  const status = await waitTopUpPaid({
55
75
  api,
56
76
  orderId: order.order_id,
@@ -59,11 +79,22 @@ export async function runWechatTopUp({
59
79
  maxPolls,
60
80
  writeStatus,
61
81
  });
82
+ writeLine("");
83
+ writeLine(renderDivider("section"));
84
+ writeLine("");
85
+ writeLine(renderTaskStep(5, 5, "刷新余额"));
62
86
  const balance = await api.get("/api/cli/me");
87
+ if (balance?.balance_display) {
88
+ msg.result("当前余额", balance.balance_display);
89
+ }
90
+ writeLine("");
91
+ writeLine(renderTaskEnd("微信扫码充值完成", {
92
+ summary: [["充值金额", String(order.money_display || order.money || amount)]],
93
+ }));
63
94
  return { order, status, balance };
64
95
  }
65
96
 
66
- async function chooseTopUpAmount({ options, promptSelect, promptInput }) {
97
+ async function chooseTopUpAmount({ options, promptSelect, promptInput, writeLine }) {
67
98
  const amountOptions = Array.isArray(options?.amount_options)
68
99
  ? options.amount_options.filter((amount) => Number.isFinite(amount) && amount > 0)
69
100
  : [];
@@ -71,6 +102,18 @@ async function chooseTopUpAmount({ options, promptSelect, promptInput }) {
71
102
  if (typeof promptSelect !== "function") {
72
103
  throw new Error("缺少充值金额选择输入");
73
104
  }
105
+ if (typeof writeLine === "function") {
106
+ writeLine(renderChoiceTable({
107
+ columns: [
108
+ ["index", "序号"],
109
+ ["amount", "金额"],
110
+ ],
111
+ rows: amountOptions.map((amount, index) => ({
112
+ index: String(index + 1),
113
+ amount: `¥${amount}`,
114
+ })),
115
+ }, { width: process.stdout.columns || 0 }));
116
+ }
74
117
  const selected = await promptSelect("选择充值金额", amountOptions.map((amount) => ({
75
118
  label: `¥${amount}`,
76
119
  value: amount,
@@ -78,6 +121,9 @@ async function chooseTopUpAmount({ options, promptSelect, promptInput }) {
78
121
  if (!amountOptions.includes(selected)) {
79
122
  throw new Error("请选择后端返回的充值金额");
80
123
  }
124
+ if (typeof writeLine === "function") {
125
+ writeLine(`✅ 已选择:¥${selected}`);
126
+ }
81
127
  return selected;
82
128
  }
83
129
 
@@ -92,17 +138,21 @@ async function chooseTopUpAmount({ options, promptSelect, promptInput }) {
92
138
  if (minTopUp > 0 && amount < minTopUp) {
93
139
  throw new Error(`充值金额不能低于 ${minTopUp}`);
94
140
  }
141
+ if (typeof writeLine === "function") {
142
+ writeLine(`✅ 已选择:¥${amount}`);
143
+ }
95
144
  return amount;
96
145
  }
97
146
 
98
147
  async function printQrOrCodeUrl({ codeUrl, renderQr, writeLine }) {
148
+ const msg = createMessenger({ writeLine, color: writeLine === console.log ? colorLevel() : 0 });
99
149
  try {
100
150
  const qr = await renderQr(codeUrl);
101
151
  if (qr) {
102
152
  writeLine(qr);
103
153
  }
104
154
  } catch {
105
- writeLine(`二维码渲染失败,请复制 code_url 完成支付:${codeUrl}`);
155
+ msg.warn(`二维码渲染失败,请复制 code_url 完成支付:${codeUrl}`);
106
156
  }
107
157
  }
108
158
 
package/src/ui.js CHANGED
@@ -5,18 +5,22 @@
5
5
 
6
6
  import { displayWidth } from "./banner.js";
7
7
 
8
- import { BOLD, RESET, fg, toLevel } from "./colors.js";
8
+ import { BOLD, PALETTE, RESET, fg, theme, toLevel } from "./colors.js";
9
9
 
10
- // 表格/标题配色:按色彩深度 level 现算(真彩色保留 RGB,256 色量化到调色板)。
10
+ // 表格/标题配色:从统一语义主题取色(见 colors.js theme),不再各自硬编码 RGB
11
11
  function palette(level) {
12
+ const t = theme(level);
12
13
  return {
13
- border: fg(71, 85, 105, level), // 边框:青灰
14
- head: fg(125, 211, 252, level), // 表头:天蓝
14
+ border: t.border, // 边框:青灰
15
+ head: t.info, // 表头:天蓝
15
16
  };
16
17
  }
17
18
 
18
19
  // eslint-disable-next-line no-control-regex
19
20
  const ANSI_PATTERN = /\u001b\[[0-9;]*m/g;
21
+ const PAGE_DIVIDER = "────────────────────────────────────────";
22
+ const SECTION_DIVIDER = "----------------------------------------";
23
+ const LIGHT_DIVIDER = "········································";
20
24
 
21
25
  export function stripAnsi(text) {
22
26
  return String(text ?? "").replace(ANSI_PATTERN, "");
@@ -177,10 +181,10 @@ export function renderHeader(title, { subtitle = "", color = false } = {}) {
177
181
  // 四种状态:文字前缀(mark)始终存在,颜色仅增强层级(需求二「不依赖颜色表达唯一信息」)
178
182
  const BOX_MARKS = { info: "i", warn: "!", error: "×", success: "✓" };
179
183
  const BOX_RGB = {
180
- info: [125, 211, 252],
181
- warn: [250, 204, 21],
182
- error: [248, 113, 113],
183
- success: [74, 222, 128],
184
+ info: PALETTE.info,
185
+ warn: PALETTE.warn,
186
+ error: PALETTE.error,
187
+ success: PALETTE.success,
184
188
  };
185
189
 
186
190
  export function renderBox(message, { kind = "info", color = false, width = 0 } = {}) {
@@ -220,3 +224,255 @@ export function renderModelTable(models = [], { color = false, width = 0 } = {})
220
224
  ]),
221
225
  }, { color, width });
222
226
  }
227
+
228
+ // ──────────────────────────────────────────────
229
+ // 执行流程组件(优化需求 V2):任务页 / 步骤 / 分割线 / 选择卡片 / 结构化错误
230
+ // ──────────────────────────────────────────────
231
+
232
+ export function renderDivider(type = "section") {
233
+ if (type === "page") {
234
+ return PAGE_DIVIDER;
235
+ }
236
+ if (type === "light") {
237
+ return LIGHT_DIVIDER;
238
+ }
239
+ return SECTION_DIVIDER;
240
+ }
241
+
242
+ export function renderTaskStart(title, { intro = [] } = {}) {
243
+ const lines = [
244
+ PAGE_DIVIDER,
245
+ `🚀 任务:${title}`,
246
+ PAGE_DIVIDER,
247
+ ];
248
+ const items = Array.isArray(intro) ? intro.filter(Boolean) : [];
249
+ if (items.length > 0) {
250
+ lines.push("", "本流程将完成:");
251
+ lines.push(...items.slice(0, 5).map((item, index) => `${index + 1}. ${item}`));
252
+ }
253
+ return lines.join("\n");
254
+ }
255
+
256
+ export function renderTaskEnd(title, { summary = [] } = {}) {
257
+ const lines = [
258
+ PAGE_DIVIDER,
259
+ `🎉 ${title}`,
260
+ PAGE_DIVIDER,
261
+ ];
262
+ const items = Array.isArray(summary) ? summary : [];
263
+ if (items.length > 0) {
264
+ lines.push("");
265
+ for (const [label, value] of items) {
266
+ lines.push(`${label}:${value}`);
267
+ }
268
+ }
269
+ return lines.join("\n");
270
+ }
271
+
272
+ export function renderTaskStep(current, total, title, { icon = "▶" } = {}) {
273
+ return `${icon} [${current}/${total}] ${title}`;
274
+ }
275
+
276
+ function normalizeColumns(columns = []) {
277
+ return columns.map((column) => {
278
+ if (Array.isArray(column)) {
279
+ return { key: column[0], label: column[1] ?? column[0] };
280
+ }
281
+ if (typeof column === "string") {
282
+ return { key: column, label: column };
283
+ }
284
+ return { key: column.key, label: column.label ?? column.key };
285
+ }).filter((column) => column.key);
286
+ }
287
+
288
+ function rowValue(row, key) {
289
+ if (Array.isArray(row)) {
290
+ return row[key] ?? "";
291
+ }
292
+ return row?.[key] ?? "";
293
+ }
294
+
295
+ function limitRows(rows, maxRows) {
296
+ const limit = Number(maxRows);
297
+ if (!Number.isInteger(limit) || limit <= 0 || rows.length <= limit) {
298
+ return { visibleRows: rows, hiddenCount: 0 };
299
+ }
300
+ return {
301
+ visibleRows: rows.slice(0, limit),
302
+ hiddenCount: rows.length - limit,
303
+ };
304
+ }
305
+
306
+ function foldedHint(hiddenCount) {
307
+ return `还有 ${hiddenCount} 项,使用 --verbose 或详情命令查看更多。`;
308
+ }
309
+
310
+ export function renderChoiceTable({ columns = [], rows = [] } = {}, { color = false, width = 0, cardBreakpoint = 72, maxRows = 0, titleKey = "" } = {}) {
311
+ const cols = normalizeColumns(columns);
312
+ const { visibleRows, hiddenCount } = limitRows(rows, maxRows);
313
+ const table = renderSeparatedTable({
314
+ head: cols.map((column) => column.label),
315
+ rows: visibleRows.map((row) => cols.map((column) => String(rowValue(row, column.key)))),
316
+ }, { color, width });
317
+ const tableWidth = cellWidth(table.split("\n")[0] ?? "");
318
+ const appendHint = (output) => hiddenCount > 0 ? `${output}\n${foldedHint(hiddenCount)}` : output;
319
+ if (!width || (width >= cardBreakpoint && tableWidth <= width)) {
320
+ return appendHint(table);
321
+ }
322
+ return appendHint(visibleRows.map((row) => renderChoiceCard(row, cols, { color, width, titleKey })).join("\n\n"));
323
+ }
324
+
325
+ function renderSeparatedTable({ head = [], rows = [] } = {}, { color = false, width = 0 } = {}) {
326
+ const level = toLevel(color);
327
+ const widths = resolveWidths(head, rows, width);
328
+ const fit = (row) => widths.map((w, i) => truncateDisplay(row[i] ?? "", w));
329
+ const lines = [borderLine(widths, ["┌", "┬", "┐"], level)];
330
+ if (head.length > 0) {
331
+ lines.push(contentLine(fit(head), widths, { level, bold: true }));
332
+ lines.push(borderLine(widths, ["├", "┼", "┤"], level));
333
+ }
334
+ rows.forEach((row, index) => {
335
+ lines.push(contentLine(fit(row), widths, { level }));
336
+ if (index < rows.length - 1) {
337
+ lines.push(borderLine(widths, ["├", "┼", "┤"], level));
338
+ }
339
+ });
340
+ lines.push(borderLine(widths, ["└", "┴", "┘"], level));
341
+ return lines.join("\n");
342
+ }
343
+
344
+ function renderChoiceCard(row, columns, options) {
345
+ const { titleKey = "" } = options;
346
+ const indexKey = columns.find((column) => column.key === "index")?.key;
347
+ const nameKey = titleKey
348
+ ? columns.find((column) => column.key === titleKey)?.key
349
+ : columns.find((column) => column.key === "name" || column.key === "label")?.key;
350
+ const markKey = columns.find((column) => column.key === "mark" || column.key === "badge")?.key;
351
+ const title = [
352
+ !titleKey && indexKey ? `${rowValue(row, indexKey)}.` : "",
353
+ nameKey ? rowValue(row, nameKey) : "",
354
+ ].filter(Boolean).join(" ").trim() || String(rowValue(row, columns[0]?.key) || "");
355
+ const badge = markKey ? rowValue(row, markKey) : "";
356
+ const fields = columns
357
+ .filter((column) => ![indexKey, nameKey, markKey].includes(column.key))
358
+ .map((column) => [column.label, rowValue(row, column.key)])
359
+ .filter(([, value]) => value !== "" && value !== null && value !== undefined);
360
+ return renderCard(title, fields, { ...options, badge });
361
+ }
362
+
363
+ export function renderCard(title, fields = [], { badge = "", color = false, width = 0 } = {}) {
364
+ const level = toLevel(color);
365
+ const c = level ? palette(level) : null;
366
+ const maxWidth = width > 0 ? width : 0;
367
+ const desiredInner = Math.max(
368
+ cellWidth(`${title}${badge ? ` ${badge}` : ""}`),
369
+ ...fields.map(([label, value]) => cellWidth(`${label}:${value}`)),
370
+ 12,
371
+ ) + 2;
372
+ const inner = maxWidth ? Math.max(8, Math.min(desiredInner, maxWidth - 2)) : desiredInner;
373
+ const contentWidth = Math.max(1, inner - 2);
374
+ const edge = (left, right) => {
375
+ const text = `${left}${"─".repeat(inner)}${right}`;
376
+ return level ? `${c.border}${text}${RESET}` : text;
377
+ };
378
+ const line = (text) => {
379
+ const fitted = truncateDisplay(text, contentWidth);
380
+ const body = ` ${padCell(fitted, contentWidth)} `;
381
+ return level ? `${c.border}│${RESET}${body}${c.border}│${RESET}` : `│${body}│`;
382
+ };
383
+ const badgeText = String(badge);
384
+ const badgeW = cellWidth(badgeText);
385
+ // badge(当前/推荐/异常标记)优先保留:窄卡片下截断 title 而非 badge(需求第七节)
386
+ let titleText;
387
+ if (badgeText) {
388
+ const titleMax = Math.max(1, contentWidth - badgeW - 1);
389
+ const fittedTitle = truncateDisplay(title, titleMax);
390
+ const gap = Math.max(1, contentWidth - cellWidth(fittedTitle) - badgeW);
391
+ titleText = `${fittedTitle}${" ".repeat(gap)}${badgeText}`;
392
+ } else {
393
+ titleText = truncateDisplay(title, contentWidth);
394
+ }
395
+ const lines = [edge("┌", "┐"), line(titleText)];
396
+ for (const [label, value] of fields) {
397
+ lines.push(line(`${label}:${value}`));
398
+ }
399
+ lines.push(edge("└", "┘"));
400
+ return lines.join("\n");
401
+ }
402
+
403
+ export function renderStructuredError(title, {
404
+ reason = "",
405
+ suggestions = [],
406
+ detailCommand = "",
407
+ } = {}) {
408
+ const lines = [`❌ ${title}`];
409
+ if (reason) {
410
+ lines.push("", "原因:", String(reason));
411
+ }
412
+ const items = Array.isArray(suggestions) ? suggestions.filter(Boolean) : [];
413
+ if (items.length > 0) {
414
+ lines.push("", "建议:");
415
+ lines.push(...items.map((item, index) => `${index + 1}. ${item}`));
416
+ }
417
+ if (detailCommand) {
418
+ lines.push("", "查看详细日志:", detailCommand);
419
+ }
420
+ return lines.join("\n");
421
+ }
422
+
423
+ export const taskStart = renderTaskStart;
424
+ export const taskEnd = renderTaskEnd;
425
+ export const step = renderTaskStep;
426
+ export const divider = renderDivider;
427
+ export const errorBlock = renderStructuredError;
428
+ export const card = renderCard;
429
+
430
+ export function warningBlock(title, {
431
+ action = "",
432
+ impact = "",
433
+ prompt = "",
434
+ } = {}) {
435
+ const lines = [`⚠️ ${title}`];
436
+ if (action) {
437
+ lines.push("", "将执行:", String(action));
438
+ }
439
+ if (impact) {
440
+ lines.push("", "影响范围:", String(impact));
441
+ }
442
+ if (prompt) {
443
+ lines.push("", String(prompt));
444
+ }
445
+ return lines.join("\n");
446
+ }
447
+
448
+ export function selectedSummary(title, pairs = []) {
449
+ const lines = [String(title)];
450
+ const body = summary(pairs);
451
+ if (body) {
452
+ lines.push(body);
453
+ }
454
+ return lines.join("\n");
455
+ }
456
+
457
+ export function table(data = {}, options = {}) {
458
+ if (Array.isArray(data.columns)) {
459
+ return renderChoiceTable(data, options);
460
+ }
461
+ return renderTable(data, options);
462
+ }
463
+
464
+ export function summary(items = []) {
465
+ return items.map(([label, value]) => `${label}:${value}`).join("\n");
466
+ }
467
+
468
+ export function command(text) {
469
+ return String(text ?? "");
470
+ }
471
+
472
+ export function filePath(text) {
473
+ return String(text ?? "");
474
+ }
475
+
476
+ export function current(label, value) {
477
+ return `${label}:${value}`;
478
+ }
package/src/workflows.js CHANGED
@@ -8,6 +8,8 @@ import path from "node:path";
8
8
 
9
9
  import { createBackup } from "./backup.js";
10
10
  import { ZCF_ATTRIBUTION_COMMENT } from "./zcf-notice.js";
11
+ import { colorLevel } from "./colors.js";
12
+ import { createMessenger } from "./messages.js";
11
13
 
12
14
  const defaultFs = { copyFile, cp, mkdir, readFile, stat, writeFile };
13
15
 
@@ -229,6 +231,7 @@ async function installWorkflowSet({
229
231
  if (typeof multiSelect !== "function" || typeof confirm !== "function") {
230
232
  throw new Error("缺少交互输入");
231
233
  }
234
+ const msg = createMessenger({ writeLine, color: writeLine === console.log ? colorLevel() : 0 });
232
235
  const selected = await multiSelect({
233
236
  message: "请选择要安装的工作流:",
234
237
  choices: workflows.map((workflow) => ({
@@ -238,23 +241,23 @@ async function installWorkflowSet({
238
241
  })),
239
242
  });
240
243
  if (selected.length === 0) {
241
- writeLine("已跳过工作流安装。");
244
+ msg.info("已跳过工作流安装。");
242
245
  return { installed: [], skipped: true };
243
246
  }
244
247
 
245
248
  const picked = workflows.filter((workflow) => selected.includes(workflow.id));
246
- writeLine(`安装将写入目录:${targetDir}`);
249
+ msg.info(`安装将写入目录:${targetDir}`);
247
250
 
248
251
  // 备份已存在的同名文件(一次性集中备份到时间戳目录)
249
252
  const targets = picked.flatMap((workflow) => workflow.files.map((file) => path.join(targetDir, file.name)));
250
253
  const { backupDir } = await createBackup({ baseDir: backupBaseDir, paths: targets, fs, now });
251
254
  if (backupDir) {
252
- writeLine(`✓ 已创建备份:${backupDir}`);
255
+ msg.success(`已创建备份:${backupDir}`);
253
256
  }
254
257
 
255
258
  const installed = [];
256
259
  for (const workflow of picked) {
257
- writeLine(`正在安装工作流:${workflow.label}...`);
260
+ msg.step(`正在安装工作流:${workflow.label}...`);
258
261
  let wroteAny = false;
259
262
  for (const file of workflow.files) {
260
263
  const filePath = path.join(targetDir, file.name);
@@ -263,19 +266,19 @@ async function installWorkflowSet({
263
266
  if (existing !== file.content) {
264
267
  const approved = await confirm(`文件已存在:${filePath}\n是否覆盖?(原文件已备份)`);
265
268
  if (!approved) {
266
- writeLine(`已跳过:${file.name}`);
269
+ msg.info(`已跳过:${file.name}`);
267
270
  continue;
268
271
  }
269
272
  }
270
273
  }
271
274
  await fs.mkdir(targetDir, { recursive: true });
272
275
  await fs.writeFile(filePath, file.content, "utf8");
273
- writeLine(`✓ 已安装命令:${file.name}`);
276
+ msg.success(`已安装命令:${file.name}`);
274
277
  wroteAny = true;
275
278
  }
276
279
  if (wroteAny) {
277
280
  installed.push(workflow.id);
278
- writeLine(`✓ ${workflow.label}安装成功`);
281
+ msg.success(`${workflow.label}安装成功`);
279
282
  }
280
283
  }
281
284
  if (installed.length > 0 && usageHint) {
package/src/zcf-notice.js CHANGED
@@ -17,6 +17,16 @@ export const OPEN_SOURCE_NOTICE = [
17
17
  "提供授权登录、API Key、余额充值、Claude Code / Codex 配置注入等服务。",
18
18
  ].join("\n");
19
19
 
20
+ export const OPEN_SOURCE_NOTICE_SUMMARY = [
21
+ "开源参考声明",
22
+ "",
23
+ "本流程参考 NPX ZCF 的交互式初始化与推荐配置体验。",
24
+ "感谢作者 UfoMiao 的开源贡献。",
25
+ `项目地址:${ZCF_PROJECT_URL}`,
26
+ "",
27
+ "查看完整声明:claude360 about",
28
+ ].join("\n");
29
+
20
30
  // 工作流 / AGENTS 等改编内容的文件头 attribution(PRD 2.3 / 6.5)
21
31
  export const ZCF_ATTRIBUTION_COMMENT = [
22
32
  "<!--",
@@ -25,12 +35,16 @@ export const ZCF_ATTRIBUTION_COMMENT = [
25
35
  "-->",
26
36
  ].join("\n");
27
37
 
38
+ export function formatOpenSourceNoticeDetail() {
39
+ return OPEN_SOURCE_NOTICE;
40
+ }
41
+
28
42
  // 展示声明并询问是否继续;返回 true 表示继续,false 表示返回上级菜单。
29
43
  export async function showOpenSourceNotice({ promptSelect, writeLine = console.log } = {}) {
30
44
  if (typeof promptSelect !== "function") {
31
45
  throw new Error("缺少选择输入");
32
46
  }
33
- writeLine(OPEN_SOURCE_NOTICE);
47
+ writeLine(OPEN_SOURCE_NOTICE_SUMMARY);
34
48
  writeLine("");
35
49
  const action = await promptSelect("是否继续?", [
36
50
  { label: "继续", value: "continue" },