ai_co_discuss 0.3.19 → 0.3.20

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.
@@ -26,16 +26,16 @@ description: "AI CoDiscuss 的通用参与技能,所有参与者(包括主
26
26
 
27
27
  1. **不读取或打印凭据内容**,token 不进入提示或房间。每个成员只用自己的凭据,AI 不借用用户身份发言,房主控制凭据不能代替成员署名。(身份与权限由网站按凭据校验。)
28
28
  2. **执行失效后不再写入**:暂停、到期或被打断的旧任务保留草稿并结束,不换身份、职责或编号绕过拒绝,也不去掉 `--execution` 改作普通发言。任务文件的 `stopped_executions` 列出你此前被用户停止或跳过的执行:会话里如有它们未完成的工作,不接着做、不为它们补交,只处理本次请求。(失效的执行若还能产生可见结果,用户的打断与暂停就失去意义。)
29
- 3. **写入前定好 `--submission-id`**:同次重试沿用编号和原内容,新内容换新编号;结果不明时先按原编号查询,再按 [写入与恢复](references/writing.md) 处理。(网站靠它去重,换号会产生重复发言。)
29
+ 3. **写入前定好 `--submission-id`**:同次重试沿用编号和原内容,新内容换新编号;`message post` 可省略,CLI 按房间、身份、关联与内容生成,用同一份草稿重试即得同一编号;结果不明时先按原编号查询,再按 [写入与恢复](references/writing.md) 处理。(网站靠它去重,换号会产生重复发言。)
30
30
  4. **不创建定时任务、不操作其他软件、不发状态回执**(就绪、开始、完成、停止等说明),除非用户要求或出现需要记录的异常;回执不能作为观点、完成或共识的证据。(这些动作有副作用,回执只会淹没真正的回答。)
31
31
 
32
32
  ## 命令入口与身份
33
33
 
34
34
  用统一命令 `codiscuss room …`、`codiscuss message …` 等;任务或用户显式指定 CLI 路径时沿用指定入口,未安装时用已核实的 `python 路径/runner/discuss.py …`(统一入口需 Node.js 24+,网站读写需 Python 3.10+)。裸 `codiscuss` 会启动服务,不要单独运行;用法查 `codiscuss --help`,字段以本技能为准,这里没写的才查 `codiscuss schema 具体命令`。下文命令均省略入口前缀。
35
35
 
36
- 从实际任务或用户指定值核实站点、房间和身份,不从当前网页或最近聊天猜测:站点用 `--site`、进程 `DISCUSS_URL` 或凭据绑定的站点,房间始终显式指定,写入显式 `--as 本人`。任务标明 `access_env: "DISCUSS_ACCESS_FILE"` 时凭据已由进程绑定,`--access-file FILE` 可省略;否则用任务交付的 `access_file`。项目与 cwd 有值时用于本次获准资料;CLI 路径只定位工具,不把 CLI 仓库当作讨论项目或扩大文件权限。
36
+ 从实际任务或用户指定值核实站点、房间和身份,不从当前网页或最近聊天猜测。任务标明 `access_env: "DISCUSS_ACCESS_FILE"` 时凭据已由进程绑定,`--access-file FILE` 可省略;否则用任务交付的 `access_file`。任务凭据已绑定站点、房间、身份与本次执行,命令省略房间、`--as`、`--execution` 和 `--submission-id`,由 CLI 从凭据和内容补齐,例如 `message post --body-file 草稿.md`;下文命令里的 `ROOM`、`--as`、执行编号只在没有绑定凭据(如手动接入未带凭据)时需要填写。项目与 cwd 有值时用于本次获准资料;CLI 路径只定位工具,不把 CLI 仓库当作讨论项目或扩大文件权限。
37
37
 
38
- 每次 shell 调用都是一次模型往返,手动会话里还可能要用户逐条确认,所以互不依赖的只读命令放进同一条 shell 命令依次执行。正文想好后,生成提交编号、写草稿、`message post` 放进同一条命令,草稿写成功才提交(失败重试仍用这份草稿和编号);不同的网站写入各自单独执行并核对结果。
38
+ 每次 shell 调用都是一次模型往返,手动会话里还可能要用户逐条确认,所以互不依赖的只读命令放进同一条 shell 命令依次执行。正文想好后,先用当前软件的文件写入工具把正文(和需要的摘要)写成草稿文件,再用一条只含 `codiscuss` 的命令以 `--body-file`(`--summary-file`)提交;失败重试仍用这份草稿。不要用 heredoc、`echo` 重定向或命令行字面量传长文本,也不要在命令里调用函数生成编号(如 `[guid]::NewGuid()`、`$(uuidgen)`):自动运行时这类组合命令会被权限检查拒绝,文本中的引号(含中文弯引号)还会截断参数。其他写入命令需要编号时直接写一个新的 UUID 字面值。不同的网站写入各自单独执行并核对结果。
39
39
 
40
40
  ## 阅读
41
41
 
@@ -58,6 +58,6 @@ description: "AI CoDiscuss 的通用参与技能,所有参与者(包括主
58
58
 
59
59
  ## 保存与核验
60
60
 
61
- 正文优先 UTF-8 `--body-file`。需要附件时先 `attachment upload ROOM --file FILE --submission-id 上传编号`,再用 `message post --attachment ID` 关联(最多 10 个,单个 20 MiB,须在执行资格结束前上传)。
61
+ 正文用 UTF-8 `--body-file`,摘要含引号或较长时用 `--summary-file`。需要附件时先 `attachment upload ROOM --file FILE --submission-id 上传编号`,再用 `message post --attachment ID` 关联(最多 10 个,单个 20 MiB,须在执行资格结束前上传)。
62
62
 
63
63
  成功须核对服务端返回的持久记录、作者和关联;返回已是持久记录时不再读一遍,缺少所需证据才按编号补读。答案落库后即可结束,不用旧资格继续写入。代发保留真实作者、完整原文及引用关系,摘要归实际撰写者,不把自己的改写冒充对方回答。执行结束不等于整个房间的工作结束。
@@ -29,13 +29,13 @@
29
29
  自动任务用本次执行编号保存一次完整回答,例如:
30
30
 
31
31
  ```sh
32
- codiscuss message post ROOM --as 本人 --kind chat --execution 执行编号 --body-file draft.md --submission-id 新UUID --access-file FILE
32
+ codiscuss message post ROOM --as 本人 --kind chat --execution 执行编号 --body-file draft.md --access-file FILE
33
33
  ```
34
34
 
35
35
  手动成员用自己的成员凭据和服务端给出的请求编号:
36
36
 
37
37
  ```sh
38
- codiscuss message post ROOM --as 本人 --kind chat --request 请求编号 --body-file draft.md --submission-id 新UUID --access-file FILE
38
+ codiscuss message post ROOM --as 本人 --kind chat --request 请求编号 --body-file draft.md --access-file FILE
39
39
  ```
40
40
 
41
41
  需要摘要时一并使用 `--summary` 或 `--summary-file`。成功提交后当前请求即完成;不要再补一条普通 chat 或状态回执。
@@ -28,7 +28,7 @@
28
28
 
29
29
  主持类写入需要本次任务授予主持职责。网页成员代发须保留真实作者、完整原文、关联请求、原答复的链接/引用对应关系及同房间关联,不把自己的改写冒充他人原文。主持人补充的引用附录需明确标注来源与归属,无法取得的出处说明缺失;不把自己的核验结论混进专家原文。
30
30
 
31
- 房间、消息和安排创建使用显式 `--submission-id`:新内容生成新编号,同一次提交保留编号、原始资料、身份和关联。**先保存编号与原文,再发请求**,避免中断后无法核对。编号为 8–100 位字母、数字、下划线或连字符,可用 UUID。建房重试对照首次创建资料,不使用后来修改的话题内容替换请求。
31
+ 房间、消息和安排创建使用显式 `--submission-id`:新内容生成新编号,同一次提交保留编号、原始资料、身份和关联。**先保存编号与原文,再发请求**,避免中断后无法核对。编号为 8–100 位字母、数字、下划线或连字符,可用 UUID。`message post` 省略编号时由 CLI 按房间、身份、关联与内容生成(`auto-` 开头),草稿文件即原文记录,重试只需用同一份草稿。建房重试对照首次创建资料,不使用后来修改的话题内容替换请求。
32
32
 
33
33
  长正文优先 UTF-8 文件,`--body-file -` 可读 stdin。当前消息正文上限为 32000 个 Unicode 字符,详细讨论状态上限为 4000 字;以目标站点协议为准。完整回答一次提交并完成安排,不能截断或用第一段冒充完整回答。超过上限时保留完整原文并说明限制,先明确完整材料的留档方式。
34
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai_co_discuss",
3
- "version": "0.3.19",
3
+ "version": "0.3.20",
4
4
  "bin": {
5
5
  "codiscuss": "runner/codiscuss.mjs"
6
6
  },
package/runner/discuss.py CHANGED
@@ -54,12 +54,20 @@ class CliError(Exception):
54
54
  self.details = details
55
55
 
56
56
 
57
+ # Shells split a literal text argument when it holds quotes they treat as delimiters; Windows PowerShell also treats
58
+ # Chinese curly quotes as quotes, which surfaces here as leftover arguments.
59
+ UNRECOGNIZED_HINT = "正文或摘要可能被 shell 按引号或空格截断(Windows PowerShell 也把中文弯引号当引号):改用 --body-file / --summary-file 传文本。"
60
+
61
+
57
62
  class Parser(argparse.ArgumentParser):
58
63
  """Report parser failures through the same stderr contract as API errors."""
59
64
 
60
65
  def error(self, message):
61
66
  """Raise a usage error instead of printing a separate argparse format."""
62
- raise CliError(message, hint=f"查看 {self.prog} --help")
67
+ hint = f"查看 {self.prog} --help"
68
+ if message.startswith("unrecognized arguments"):
69
+ hint = UNRECOGNIZED_HINT + hint
70
+ raise CliError(message, hint=hint)
63
71
 
64
72
 
65
73
  class NoRedirect(HTTPRedirectHandler):
@@ -131,6 +139,20 @@ def submission_id(value):
131
139
  return value
132
140
 
133
141
 
142
+ def fill_submission_id(args, plan):
143
+ """Derive an omitted submission ID from the command and its content, so resending the same content is deduplicated
144
+ without the caller generating an ID in the shell; the hosting session ID is left out because it does not change what is written."""
145
+ target = next((part for part in (plan.body, plan.params) if isinstance(part, dict) and "submission_id" in part), None)
146
+ if plan.method != "POST" or target is None or target["submission_id"]:
147
+ return
148
+ body = {key: value for key, value in (plan.body or {}).items() if key not in ("session_id", "session_client")}
149
+ material = {"operation": args.operation, "room": getattr(args, "room", None), "body": body, "params": plan.params}
150
+ if plan.upload_file:
151
+ material["file"] = hashlib.sha256(Path(plan.upload_file).read_bytes()).hexdigest()
152
+ digest = hashlib.sha256(json.dumps(material, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest()
153
+ target["submission_id"] = "auto-" + digest[:40]
154
+
155
+
134
156
  def attachment_id(value):
135
157
  """Accept only opaque file identifiers, never local or remote paths."""
136
158
  if not re.fullmatch(r"[a-f0-9]{32}", value):
@@ -143,7 +165,7 @@ def add_common(parser):
143
165
  options = {"default": argparse.SUPPRESS}
144
166
  parser.add_argument("--site", help="HTTP 服务地址;未指定时读 DISCUSS_URL,再读凭据中的 site", **options)
145
167
  parser.add_argument("--as", dest="actor", type=participant,
146
- help="业务写入的声明操作者;不是登录认证;api 使用原始请求体", **options)
168
+ help="业务写入的声明操作者;省略时用凭据绑定的成员;不是登录认证;api 使用原始请求体", **options)
147
169
  parser.add_argument("-o", "--output", choices=("json", "text"),
148
170
  help="默认 json;成功 stdout,错误 stderr", **options)
149
171
  parser.add_argument("--timeout", type=float, help="请求超时秒数,默认 20", **options)
@@ -180,7 +202,7 @@ def add_leaf(subcommands, registry, name, description, needs_room=False):
180
202
  add_common(parser)
181
203
  parser.set_defaults(operation=name)
182
204
  if needs_room:
183
- parser.add_argument("room", type=room_id, help="房间 ID;始终显式指定")
205
+ parser.add_argument("room", type=room_id, nargs="?", help="房间 ID;省略时用凭据绑定的房间")
184
206
  registry[name] = parser
185
207
  return parser
186
208
 
@@ -203,8 +225,8 @@ def room_commands(subcommands, registry):
203
225
  create = add_leaf(subcommands, registry, "room create", "创建话题;不自动启动讨论或监控")
204
226
  create.add_argument("--folder-id", help="直接在指定文件夹中创建;省略则未归类")
205
227
  add_topic(create, create=True)
206
- create.add_argument("--submission-id", type=submission_id, required=True,
207
- help="新话题唯一编号;未知结果先查询,重试沿用编号和原始资料")
228
+ create.add_argument("--submission-id", type=submission_id,
229
+ help="新话题唯一编号;未知结果先查询,重试沿用编号和原始资料。省略时按命令与内容自动生成,同样内容重试得到同一编号")
208
230
  update = add_leaf(subcommands, registry, "room update", "维护话题;省略的字段保持不变", True)
209
231
  add_topic(update)
210
232
 
@@ -234,7 +256,7 @@ def message_commands(subcommands, registry):
234
256
  post.add_argument("--summary-by", type=participant, help="摘要撰写者:原作者或实际操作者;默认操作者")
235
257
  post.add_argument("--author", type=participant, help="原作者;默认等于 --as;不同即代发")
236
258
  post.add_argument("--kind", choices=("chat", "moderation"), default="chat")
237
- post.add_argument("--execution", type=positive_int, help="自由房间:本次执行编号,仅 chat 可用;执行失效后不能改作普通发言")
259
+ post.add_argument("--execution", type=positive_int, help="自由房间:本次执行编号;省略时用凭据绑定的执行。执行失效后不能改作普通发言")
238
260
  post.add_argument("--to", type=ai_name, action="append", default=[],
239
261
  help="自由房间:主持人点名派活,普通成员提出回应建议(须房主确认);可重复")
240
262
  post.add_argument("--handoff", action="store_true",
@@ -245,8 +267,8 @@ def message_commands(subcommands, registry):
245
267
  post.add_argument("--request", type=positive_int, action="append", default=[],
246
268
  help="自由房间手动接入成员:本次回答完成的请求编号,可重复;不能与 --execution 同用")
247
269
  post.add_argument("--reply-to", type=positive_int, help="被引用的原文编号")
248
- post.add_argument("--submission-id", type=submission_id, required=True,
249
- help="每次新提交的唯一编号;重试必须沿用同编号和正文")
270
+ post.add_argument("--submission-id", type=submission_id,
271
+ help="每次新提交的唯一编号;重试必须沿用同编号和正文。省略时按房间、身份、关联与内容自动生成,同一份草稿重试得到同一编号")
250
272
 
251
273
 
252
274
  def attachment_commands(subcommands, registry):
@@ -259,7 +281,7 @@ def attachment_commands(subcommands, registry):
259
281
  view.add_argument("id", type=attachment_id)
260
282
  upload = add_leaf(subcommands, registry, "attachment upload", "上传原文件;之后用 message post --attachment 关联消息", True)
261
283
  upload.add_argument("--file", required=True, help="本地原文件路径;最多 20 MiB")
262
- upload.add_argument("--submission-id", type=submission_id, required=True)
284
+ upload.add_argument("--submission-id", type=submission_id, help="上传唯一编号;省略时按命令与内容自动生成,同样内容重试得到同一编号")
263
285
  download = add_leaf(subcommands, registry, "attachment download", "下载原文件到显式路径;不覆盖已有文件", True)
264
286
  download.add_argument("id", type=attachment_id)
265
287
  download.add_argument("--dest", required=True, help="保存文件的完整或相对路径;父目录须已存在")
@@ -288,8 +310,8 @@ def turn_commands(subcommands, registry):
288
310
  add_text(parser, "question")
289
311
  parser.add_argument("--plan-file", help='整轮安排 JSON 数组 [{"expert":"Claude","question":"…"}],按顺序接力;- 从标准输入读取')
290
312
  parser.add_argument("--roster", required=True, help="turn roster 返回的 revision;任何新安排前必读,专家状态变化后需重新获取")
291
- parser.add_argument("--submission-id", type=submission_id, required=True,
292
- help="新安排唯一编号;重试沿用同编号和内容")
313
+ parser.add_argument("--submission-id", type=submission_id,
314
+ help="新安排唯一编号;重试沿用同编号和内容。省略时按命令与内容自动生成,同样内容重试得到同一编号")
293
315
  if action == "reorder":
294
316
  parser.add_argument("--revision", required=True, help="turn order 返回的 revision;顺序变化后需重新获取")
295
317
  parser.add_argument("--member", action="append", type=ai_name, required=True,
@@ -297,7 +319,7 @@ def turn_commands(subcommands, registry):
297
319
  if action == "ask":
298
320
  parser.add_argument("--request", type=positive_int, required=True, help="turn roster 中 host_requests 的请求编号")
299
321
  parser.add_argument("--expert", type=ai_name, required=True, help="在场且已注册的目标成员,含已发言者和主持人")
300
- parser.add_argument("--submission-id", type=submission_id, required=True, help="转交唯一编号;重试沿用原编号与内容")
322
+ parser.add_argument("--submission-id", type=submission_id, help="转交唯一编号;重试沿用原编号与内容。省略时按命令与内容自动生成,同样内容重试得到同一编号")
301
323
  if action in ("continue", "ask"):
302
324
  parser.add_argument("--roster", required=True, help="turn roster 返回的 revision;状态变化后须重新读取")
303
325
 
@@ -334,7 +356,7 @@ def execution_commands(subcommands, registry):
334
356
  """Read an execution or report its failure through its durable identifier."""
335
357
  for action in ("view", "fail"):
336
358
  parser = add_leaf(subcommands, registry, f"execution {action}", "读取执行或报告本次执行失败", True)
337
- parser.add_argument("id", type=positive_int)
359
+ parser.add_argument("id", type=positive_int, nargs="?", help="执行编号;省略时用凭据绑定的执行")
338
360
  if action == "fail":
339
361
  parser.add_argument("--reason", required=True)
340
362
 
@@ -354,13 +376,13 @@ def review_commands(subcommands, registry):
354
376
  advance = add_leaf(subcommands, registry, "review advance", "先读 review ready,再带 revision 只推进既定评审并处理对应交接", True)
355
377
  advance.add_argument("--batch", type=positive_int, required=True)
356
378
  advance.add_argument("--revision", required=True)
357
- advance.add_argument("--submission-id", type=submission_id, required=True)
379
+ advance.add_argument("--submission-id", type=submission_id, help="推进唯一编号;省略时按命令与内容自动生成,同样内容重试得到同一编号")
358
380
 
359
381
 
360
382
  def build_parser(prog="discuss"):
361
383
  """Build help and schema from the same command definitions."""
362
384
  parser = Parser(prog=prog, description="AI CoDiscuss CLI:通过 HTTP 操作现有讨论网站。",
363
- allow_abbrev=False, epilog="站点用 --site、DISCUSS_URL 或凭据文件中的 site;不保存当前房间,不自动重试写入。")
385
+ allow_abbrev=False, epilog="站点、房间、操作者与执行编号省略时取自凭据文件;提交编号省略时按内容生成;不保存当前房间,不自动重试写入。")
364
386
  add_common(parser)
365
387
  parser.add_argument("--version", action="version", version=f"{prog} {VERSION}")
366
388
  subcommands = parser.add_subparsers(dest="resource", required=True)
@@ -430,13 +452,32 @@ def text_field(args, field):
430
452
 
431
453
 
432
454
  def require_actor(args):
433
- """Require an explicit declared operator for domain writes."""
434
- actor = getattr(args, "actor", None)
455
+ """Use the declared operator, or the member a task credential is bound to."""
456
+ actor = getattr(args, "actor", None) or load_access(args).get("member")
435
457
  if not actor:
436
- raise CliError("写入必须显式指定 --as;该字段声明操作者,不提供身份认证")
458
+ raise CliError("写入须用 --as 指定操作者,或使用绑定成员的凭据;该字段声明操作者,不提供身份认证")
437
459
  return actor
438
460
 
439
461
 
462
+ def bound_execution(args):
463
+ """Return the execution a task credential is bound to, if any."""
464
+ value = load_access(args).get("execution_id")
465
+ if value is None:
466
+ return None
467
+ try:
468
+ return positive_int(str(value))
469
+ except argparse.ArgumentTypeError as error:
470
+ raise CliError("凭据文件中的 execution_id 无效") from error
471
+
472
+
473
+ def bind_room(args):
474
+ """Fill an omitted room from the credential; a credential for another room is still rejected before sending."""
475
+ if hasattr(args, "room") and args.room is None:
476
+ args.room = load_access(args).get("room") or None
477
+ if not args.room:
478
+ raise CliError("请指定房间 ID,或使用绑定了房间的凭据(--access-file / DISCUSS_ACCESS_FILE)")
479
+
480
+
440
481
  def topic_payload(args):
441
482
  """Collect only supplied topic fields; expert configuration is a full map."""
442
483
  payload = {}
@@ -519,6 +560,8 @@ def plan_message(args):
519
560
  **read_context_params(args)}
520
561
  return RequestPlan(base, params={key: value for key, value in params.items() if value is not None})
521
562
  actor = require_actor(args)
563
+ if not args.execution and not args.request:
564
+ args.execution = bound_execution(args)
522
565
  if (args.to or args.handoff) and args.kind != "chat":
523
566
  raise CliError("--to / --handoff 仅用于 chat 消息")
524
567
  if args.kind == "moderation" and not (args.execution or args.request):
@@ -686,7 +729,10 @@ def plan_run(args):
686
729
 
687
730
  def plan_execution(args):
688
731
  """Read or fail one durable member execution."""
689
- path = f"/api/rooms/{args.room}/executions/{args.id}"
732
+ execution_id = args.id or bound_execution(args)
733
+ if not execution_id:
734
+ raise CliError("请指定执行编号,或使用绑定了执行的凭据")
735
+ path = f"/api/rooms/{args.room}/executions/{execution_id}"
690
736
  if args.action == "view":
691
737
  return RequestPlan(path)
692
738
  return RequestPlan(path, "POST", {"action": "fail", "reason": args.reason})
@@ -721,7 +767,9 @@ def build_plan(args):
721
767
  "attachment": plan_attachment,
722
768
  "state": plan_state, "api": plan_api,
723
769
  "run": plan_run, "execution": plan_execution, "member": plan_member, "review": plan_review}
770
+ bind_room(args)
724
771
  plan = RequestPlan("/api/health") if args.resource == "health" else handlers[args.resource](args)
772
+ fill_submission_id(args, plan)
725
773
  if getattr(args, "dry_run", False) and plan.method != "POST" and plan.download_file is None:
726
774
  raise CliError("--dry-run 仅用于写入命令;读取命令本身不会修改数据")
727
775
  if plan.body is not None and len(json.dumps(plan.body, ensure_ascii=False).encode("utf-8")) > MAX_BODY_BYTES:
@@ -933,6 +981,9 @@ def prepare_creation_access(args, site, plan):
933
981
  raise CliError("建房须用 --access-file 指定私有凭据文件路径;首次自动生成,重试沿用同一文件")
934
982
  path = Path(args.access_file)
935
983
  payload = plan.body
984
+ if path.exists() and not args.submission_id:
985
+ # A retry that omits the ID continues the creation this credential file already recorded.
986
+ payload["submission_id"] = read_object(path).get("submission_id") or payload["submission_id"]
936
987
  access = read_object(path) if path.exists() else {"site": site, "room": "", "token": secrets.token_urlsafe(48),
937
988
  "submission_id": payload.get("submission_id"), "members": {}}
938
989
  if access.get("site") != site or access.get("submission_id") != payload.get("submission_id"):
@@ -959,7 +1010,7 @@ def finish_creation_access(args, site, plan, value):
959
1010
  if member["access"] == "online":
960
1011
  continue
961
1012
  member_path = path.with_name(f"{path.stem}.{member['name']}.json")
962
- save_access(member_path, {"site": site, "room": value["id"], "token": member["token"]})
1013
+ save_access(member_path, {"site": site, "room": value["id"], "member": member["name"], "token": member["token"]})
963
1014
  paths[member["name"]] = str(member_path)
964
1015
  return {"owner_access_file": str(path), "member_access_files": paths}
965
1016
 
@@ -562,7 +562,7 @@ async function member_room_context(home, raw) {
562
562
  /** Write a manual member's private credential next to the room configuration, never into a receipt. */
563
563
  function manual_access(path, input, name, token) {
564
564
  const access_path = join(dirname(path), `manual-${name}.access.json`);
565
- atomic_json(access_path, { site: input.site, room: input.room_id, token });
565
+ atomic_json(access_path, { site: input.site, room: input.room_id, member: name, token });
566
566
  return access_path;
567
567
  }
568
568
 
@@ -110,7 +110,7 @@ export const READ_RULE = '读含中文的文件用 rg --encoding utf-8 . 文件
110
110
  * (messages, read point, execution, role, credentials), the skills it lists say how to use them, and `instructions`
111
111
  * holds only the limits no skill can know. `access_file` is set where the credential is not bound to the process.
112
112
  */
113
- export function free_task_text({ name, discussion, discussion_request, profile, cli, room, messages, read_after, execution_id, task_path, access_file = '', read_rule = '' }) {
113
+ export function free_task_text({ name, discussion, discussion_request, profile, cli, messages, read_after, task_path, access_file = '', read_rule = '' }) {
114
114
  const scope = discussion ? `讨论进行中,用户直接向你提问(${messages}):只回答这个提问,不改变讨论安排和进度。` : '';
115
115
  const commands = runs_commands(profile) ? '' : `命令只用 subcommands 列出的 ${cli} 子命令。`;
116
116
  const instructions = `${software_rules(profile, cli)}${scope}${project_rule(profile, '按请求')}${commands}草稿写在 draft_directory。`;
@@ -122,7 +122,8 @@ export function free_task_text({ name, discussion, discussion_request, profile,
122
122
  // A greeting deserves a quick answer, and reading the skills first costs it one or two model rounds.
123
123
  const access = access_file ? ` --access-file ${access_file}` : '';
124
124
  // A one-line answer goes inline: a draft file guards long or quoted text and retries, which a greeting does not need.
125
- const quick = `问候、致谢等简单消息可跳过技能:${cli} message list ${room} --after ${read_after}${access} 看原文,${cli} message post ${room} --as ${name} --kind chat --execution ${execution_id} --body "回答" --submission-id 新UUID${access} 回复。`;
125
+ // The credential already names the room, the member and this execution, so the commands carry only what changes.
126
+ const quick = `问候、致谢等简单消息可跳过技能:${cli} message list --after ${read_after}${access} 看原文,${cli} message post --body "回答"${access} 回复(正文含引号时先写草稿再用 --body-file)。`;
126
127
  const prompt = `用户在${discussion ? '讨论' : '自由协作'}房间点名了你(${name}),对应消息 ${messages}。${quick}其他请求先读任务文件 ${task_path},按其中列出的技能处理并提交一次回答。${read_rule}`;
127
128
  return { instructions, prompt };
128
129
  }
@@ -85,7 +85,7 @@ export function free_task_files(config, member, execution, binding, { session =
85
85
  const access_path = join(config.directory, `${id}.access.json`);
86
86
  const task_path = join(config.directory, `${id}.task.json`);
87
87
  const profile = config.participants[member.name];
88
- atomic_json(access_path, { site: config.site, room: config.room, token: config.members[member.name].token,
88
+ atomic_json(access_path, { site: config.site, room: config.room, member: member.name, token: config.members[member.name].token,
89
89
  session_id: binding.session_id || '', execution_id: execution.id });
90
90
  const adapter = adapter_of(profile.adapter);
91
91
  const cli = process.platform === 'win32' ? adapter?.windows_command || 'codiscuss.cmd' : 'codiscuss';
package/web/app.js CHANGED
@@ -2703,7 +2703,7 @@ function download_member_access(name) {
2703
2703
  const room = state.room, access = access_snapshot();
2704
2704
  const member = access.members.find(item => item.name === name);
2705
2705
  if (!access.can_manage || !member?.available || member.access === "online") throw new Error("当前浏览器没有此成员可下载的私有配置");
2706
- download_private_json({ site: location.origin, room: room.id, token: member_credentials(room.id)[name].token }, `member-${room.id}-${name}.json`);
2706
+ download_private_json({ site: location.origin, room: room.id, member: name, token: member_credentials(room.id)[name].token }, `member-${room.id}-${name}.json`);
2707
2707
  notify("info", `已下载 ${name} 的私有配置`);
2708
2708
  }
2709
2709
 
@@ -2712,7 +2712,7 @@ async function copy_member_access_text(name) {
2712
2712
  const room = state.room, access = access_snapshot();
2713
2713
  const member = access.members.find(item => item.name === name);
2714
2714
  if (!access.can_manage || !member?.available || member.access === "online") throw new Error("当前浏览器没有此成员可复制的凭证");
2715
- await copy_text(JSON.stringify({ site: location.origin, room: room.id, token: member_credentials(room.id)[name].token }));
2715
+ await copy_text(JSON.stringify({ site: location.origin, room: room.id, member: name, token: member_credentials(room.id)[name].token }));
2716
2716
  notify("info", `已复制 ${name} 的凭证文本(含 token)`);
2717
2717
  }
2718
2718
 
@@ -2731,7 +2731,7 @@ async function copy_member_join(name, with_credential = false) {
2731
2731
  else if (with_credential) instruction = `${skills}凭证文本在下方:每条 codiscuss 命令都在同一命令内把它原样设为环境变量 DISCUSS_ACCESS(不回显;首条 room view 也要带,否则无法识别你),命令省略 --access-file;先执行 codiscuss room view ${room.id} --site ${location.origin} --as ${name},随后立即执行 codiscuss member register ${room.id} --site ${location.origin} --as ${name} 完成注册(只需一次,会话 ID 由程序自动附带,不要自己填写),之后命令保持本站点、房间和身份。模型与思考强度在原软件中设置。${MANUAL_TRIGGER_INSTRUCTION}`;
2732
2732
  else instruction = `${skills}使用属于你的私有文件 member-${room.id}-${name}.json,由用户提供它在本机的实际完整路径;先执行 codiscuss room view ${room.id} --site ${location.origin} --as ${name} --access-file "实际完整路径";随后立即执行 codiscuss member register ${room.id} --site ${location.origin} --as ${name} --access-file "实际完整路径" 完成注册(只需一次,会话 ID 由程序自动附带,不要自己填写),之后命令保持本站点、房间和身份。不要读取或公开文件中的 token;若用户给的是凭证文本而非文件,把它设为环境变量 DISCUSS_ACCESS(不要回显)并省略 --access-file。模型与思考强度在原软件中设置。${MANUAL_TRIGGER_INSTRUCTION}`;
2733
2733
  const credential = with_credential ? `
2734
- 凭证文本(含 token,仅限你使用):${JSON.stringify({ site: location.origin, room: room.id, token: member_credentials(room.id)[name].token })}` : "";
2734
+ 凭证文本(含 token,仅限你使用):${JSON.stringify({ site: location.origin, room: room.id, member: name, token: member_credentials(room.id)[name].token })}` : "";
2735
2735
  await copy_text(`请以 ${name} 身份参与房间。
2736
2736
  ${instruction}
2737
2737
  房间入口:${address}
package/web/index.html CHANGED
@@ -4,7 +4,21 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <!-- Tablets lay out the desktop page at the desktop width and scale it to fit, so cards and panels match the PC exactly. -->
7
- <script>if (matchMedia("(pointer: coarse)").matches && Math.min(screen.width, screen.height) >= 600) document.querySelector('meta[name="viewport"]').content = "width=1440";</script>
7
+ <!-- iOS Safari zooms into inputs whose text renders below 16px; capping the zoom at the fitted scale stops that, and iOS still allows pinch zoom. Android keeps the plain viewport so pinch zoom stays available there. -->
8
+ <script>(function () {
9
+ var meta = document.querySelector('meta[name="viewport"]');
10
+ var tablet = matchMedia("(pointer: coarse)").matches && Math.min(screen.width, screen.height) >= 600;
11
+ var ios = /iP(hone|ad|od)/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
12
+ function apply() {
13
+ if (!ios) { if (tablet) meta.content = "width=1440"; return; }
14
+ if (!tablet) { meta.content = "width=device-width, initial-scale=1, maximum-scale=1"; return; }
15
+ var window_width = window.visualViewport ? visualViewport.width * visualViewport.scale : innerWidth;
16
+ var scale = Math.round(window_width / 1440 * 10000) / 10000;
17
+ meta.content = "width=1440, initial-scale=" + scale + ", maximum-scale=" + scale;
18
+ }
19
+ apply();
20
+ if (ios && tablet) addEventListener("resize", apply);
21
+ })();</script>
8
22
  <meta name="description" content="共议聊天室,让不同 AI 与人围绕同一份记录共同讨论。">
9
23
  <title>共议聊天室 · AI CoDiscuss</title>
10
24
  <link rel="icon" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48cmVjdCB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgcng9IjIyIiBmaWxsPSIjMTUxOTFhIi8+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMTQgMTQpIHNjYWxlKC43MikiPjxkZWZzPjxsaW5lYXJHcmFkaWVudCBpZD0iZjAiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB4MT0iNC41IiB5MT0iNjQiIHgyPSI5NS41IiB5Mj0iNjQiPjxzdG9wIG9mZnNldD0iMC4wJSIgc3RvcC1jb2xvcj0iI2U5ZWJlZCIvPjxzdG9wIG9mZnNldD0iNi4zJSIgc3RvcC1jb2xvcj0iI2UyZTVlNyIvPjxzdG9wIG9mZnNldD0iMTIuNSUiIHN0b3AtY29sb3I9IiNkY2RlZTEiLz48c3RvcCBvZmZzZXQ9IjE4LjglIiBzdG9wLWNvbG9yPSIjZDVkOGRiIi8+PHN0b3Agb2Zmc2V0PSIyNS4wJSIgc3RvcC1jb2xvcj0iI2NlZDJkNSIvPjxzdG9wIG9mZnNldD0iMzEuMyUiIHN0b3AtY29sb3I9IiNjOGNiY2YiLz48c3RvcCBvZmZzZXQ9IjM3LjUlIiBzdG9wLWNvbG9yPSIjYzFjNWNhIi8+PHN0b3Agb2Zmc2V0PSI0My44JSIgc3RvcC1jb2xvcj0iI2JhYmZjNCIvPjxzdG9wIG9mZnNldD0iNTAuMCUiIHN0b3AtY29sb3I9IiNiNGI5YmUiLz48c3RvcCBvZmZzZXQ9IjU2LjMlIiBzdG9wLWNvbG9yPSIjYTlhZWIzIi8+PHN0b3Agb2Zmc2V0PSI2Mi41JSIgc3RvcC1jb2xvcj0iIzllYTNhOSIvPjxzdG9wIG9mZnNldD0iNjguOCUiIHN0b3AtY29sb3I9IiM5Mzk5OWYiLz48c3RvcCBvZmZzZXQ9Ijc1LjAlIiBzdG9wLWNvbG9yPSIjODg4ZTk1Ii8+PHN0b3Agb2Zmc2V0PSI4MS4zJSIgc3RvcC1jb2xvcj0iIzdlODQ4YSIvPjxzdG9wIG9mZnNldD0iODcuNSUiIHN0b3AtY29sb3I9IiM3MzdhODAiLz48c3RvcCBvZmZzZXQ9IjkzLjglIiBzdG9wLWNvbG9yPSIjNjk3MDc3Ii8+PHN0b3Agb2Zmc2V0PSIxMDAuMCUiIHN0b3AtY29sb3I9IiM1ZjY2NmQiLz48L2xpbmVhckdyYWRpZW50PjxsaW5lYXJHcmFkaWVudCBpZD0iZjEiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB4MT0iOC41IiB5MT0iMzYiIHgyPSI5MS41IiB5Mj0iMzYiPjxzdG9wIG9mZnNldD0iMC4wJSIgc3RvcC1jb2xvcj0iIzNmODZmZiIvPjxzdG9wIG9mZnNldD0iNi4zJSIgc3RvcC1jb2xvcj0iIzU1ODhmZiIvPjxzdG9wIG9mZnNldD0iMTIuNSUiIHN0b3AtY29sb3I9IiM2ODg5ZmYiLz48c3RvcCBvZmZzZXQ9IjE4LjglIiBzdG9wLWNvbG9yPSIjNzg4YWZmIi8+PHN0b3Agb2Zmc2V0PSIyNS4wJSIgc3RvcC1jb2xvcj0iIzg3OGJmZiIvPjxzdG9wIG9mZnNldD0iMzEuMyUiIHN0b3AtY29sb3I9IiM5NThjZmYiLz48c3RvcCBvZmZzZXQ9IjM3LjUlIiBzdG9wLWNvbG9yPSIjYTc4YWVmIi8+PHN0b3Agb2Zmc2V0PSI0My44JSIgc3RvcC1jb2xvcj0iI2I5ODZkNyIvPjxzdG9wIG9mZnNldD0iNTAuMCUiIHN0b3AtY29sb3I9IiNjYTgwYmYiLz48c3RvcCBvZmZzZXQ9IjU2LjMlIiBzdG9wLWNvbG9yPSIjZGE3OGE2Ii8+PHN0b3Agb2Zmc2V0PSI2Mi41JSIgc3RvcC1jb2xvcj0iI2VhNmU4YyIvPjxzdG9wIG9mZnNldD0iNjguOCUiIHN0b3AtY29sb3I9IiNmNTZkNzgiLz48c3RvcCBvZmZzZXQ9Ijc1LjAlIiBzdG9wLWNvbG9yPSIjZjc3ZjcyIi8+PHN0b3Agb2Zmc2V0PSI4MS4zJSIgc3RvcC1jb2xvcj0iI2Y4OTA2YiIvPjxzdG9wIG9mZnNldD0iODcuNSUiIHN0b3AtY29sb3I9IiNmOWExNjMiLz48c3RvcCBvZmZzZXQ9IjkzLjglIiBzdG9wLWNvbG9yPSIjZmFiMDU4Ii8+PHN0b3Agb2Zmc2V0PSIxMDAuMCUiIHN0b3AtY29sb3I9IiNmYWMwNGEiLz48L2xpbmVhckdyYWRpZW50PjxsaW5lYXJHcmFkaWVudCBpZD0iZjIiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB4MT0iMjguMDIxIiB5MT0iOTUuNDM1IiB4Mj0iMzkuOTc4IiB5Mj0iNC41NjQiPjxzdG9wIG9mZnNldD0iMC4wJSIgc3RvcC1jb2xvcj0iI2QwNmE0OSIvPjxzdG9wIG9mZnNldD0iNi4zJSIgc3RvcC1jb2xvcj0iI2QzNmY0ZiIvPjxzdG9wIG9mZnNldD0iMTIuNSUiIHN0b3AtY29sb3I9IiNkNjc0NTYiLz48c3RvcCBvZmZzZXQ9IjE4LjglIiBzdG9wLWNvbG9yPSIjZDk3YTVjIi8+PHN0b3Agb2Zmc2V0PSIyNS4wJSIgc3RvcC1jb2xvcj0iI2RjN2Y2MiIvPjxzdG9wIG9mZnNldD0iMzEuMyUiIHN0b3AtY29sb3I9IiNkZjg0NjgiLz48c3RvcCBvZmZzZXQ9IjM3LjUlIiBzdG9wLWNvbG9yPSIjZTI4OTZlIi8+PHN0b3Agb2Zmc2V0PSI0My44JSIgc3RvcC1jb2xvcj0iI2U1OGU3NCIvPjxzdG9wIG9mZnNldD0iNTAuMCUiIHN0b3AtY29sb3I9IiNlODkzN2EiLz48c3RvcCBvZmZzZXQ9IjU2LjMlIiBzdG9wLWNvbG9yPSIjZWI5ZDg1Ii8+PHN0b3Agb2Zmc2V0PSI2Mi41JSIgc3RvcC1jb2xvcj0iI2VlYTY4ZiIvPjxzdG9wIG9mZnNldD0iNjguOCUiIHN0b3AtY29sb3I9IiNmMWIwOWEiLz48c3RvcCBvZmZzZXQ9Ijc1LjAlIiBzdG9wLWNvbG9yPSIjZjRiYWE0Ii8+PHN0b3Agb2Zmc2V0PSI4MS4zJSIgc3RvcC1jb2xvcj0iI2Y2YzNhZiIvPjxzdG9wIG9mZnNldD0iODcuNSUiIHN0b3AtY29sb3I9IiNmOWNkYjkiLz48c3RvcCBvZmZzZXQ9IjkzLjglIiBzdG9wLWNvbG9yPSIjZmJkNmM0Ii8+PHN0b3Agb2Zmc2V0PSIxMDAuMCUiIHN0b3AtY29sb3I9IiNmZGUwY2YiLz48L2xpbmVhckdyYWRpZW50PjxsaW5lYXJHcmFkaWVudCBpZD0iZjMiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB4MT0iNzEuOTc4IiB5MT0iOTUuNDM1IiB4Mj0iNjAuMDIxIiB5Mj0iNC41NjQiPjxzdG9wIG9mZnNldD0iMC4wJSIgc3RvcC1jb2xvcj0iIzRhNWNmZiIvPjxzdG9wIG9mZnNldD0iNi4zJSIgc3RvcC1jb2xvcj0iIzUwNjJmZiIvPjxzdG9wIG9mZnNldD0iMTIuNSUiIHN0b3AtY29sb3I9IiM1NzY4ZmYiLz48c3RvcCBvZmZzZXQ9IjE4LjglIiBzdG9wLWNvbG9yPSIjNWQ2ZGZmIi8+PHN0b3Agb2Zmc2V0PSIyNS4wJSIgc3RvcC1jb2xvcj0iIzY0NzJmZiIvPjxzdG9wIG9mZnNldD0iMzEuMyUiIHN0b3AtY29sb3I9IiM2Yjc3ZmYiLz48c3RvcCBvZmZzZXQ9IjM3LjUlIiBzdG9wLWNvbG9yPSIjNzE3Y2ZmIi8+PHN0b3Agb2Zmc2V0PSI0My44JSIgc3RvcC1jb2xvcj0iIzc4ODFmZiIvPjxzdG9wIG9mZnNldD0iNTAuMCUiIHN0b3AtY29sb3I9IiM3Zjg2ZmYiLz48c3RvcCBvZmZzZXQ9IjU2LjMlIiBzdG9wLWNvbG9yPSIjODk5MGZmIi8+PHN0b3Agb2Zmc2V0PSI2Mi41JSIgc3RvcC1jb2xvcj0iIzkzOTlmZiIvPjxzdG9wIG9mZnNldD0iNjguOCUiIHN0b3AtY29sb3I9IiM5ZGEyZmYiLz48c3RvcCBvZmZzZXQ9Ijc1LjAlIiBzdG9wLWNvbG9yPSIjYThhYmZmIi8+PHN0b3Agb2Zmc2V0PSI4MS4zJSIgc3RvcC1jb2xvcj0iI2IzYjRmZiIvPjxzdG9wIG9mZnNldD0iODcuNSUiIHN0b3AtY29sb3I9IiNiZWJkZmYiLz48c3RvcCBvZmZzZXQ9IjkzLjglIiBzdG9wLWNvbG9yPSIjYzljNWZmIi8+PHN0b3Agb2Zmc2V0PSIxMDAuMCUiIHN0b3AtY29sb3I9IiNkNGNlZmYiLz48L2xpbmVhckdyYWRpZW50PjxjbGlwUGF0aCBpZD0iZjQiPjxwb2x5Z29uIHBvaW50cz0iMTYsNDYuNSA4NCw0Ni41IDg0LDI1LjUgMTYsMjUuNSIvPjwvY2xpcFBhdGg+PGNsaXBQYXRoIGlkPSJmNSI+PHBvbHlnb24gcG9pbnRzPSIxNiw0OC41IDg0LDQ4LjUgODQsMjMuNSAxNiwyMy41Ii8+PC9jbGlwUGF0aD48Y2xpcFBhdGggaWQ9ImY2Ij48cG9seWdvbiBwb2ludHM9IjgxLjQxMCw4Ni42MzAgNzEuNDEwLDEwLjYzMCA1MC41ODksMTMuMzY5IDYwLjU4OSw4OS4zNjkiLz48L2NsaXBQYXRoPjxjbGlwUGF0aCBpZD0iZjciPjxwb2x5Z29uIHBvaW50cz0iODMuMzkzLDg2LjM2OSA3My4zOTMsMTAuMzY5IDQ4LjYwNiwxMy42MzAgNTguNjA2LDg5LjYzMCIvPjwvY2xpcFBhdGg+PGNsaXBQYXRoIGlkPSJmOCI+PHBvbHlnb24gcG9pbnRzPSIxMiw3NC41IDg4LDc0LjUgODgsNTMuNSAxMiw1My41Ii8+PC9jbGlwUGF0aD48Y2xpcFBhdGggaWQ9ImY5Ij48cG9seWdvbiBwb2ludHM9IjEyLDc2LjUgODgsNzYuNSA4OCw1MS41IDEyLDUxLjUiLz48L2NsaXBQYXRoPjxjbGlwUGF0aCBpZD0iZjEwIj48cG9seWdvbiBwb2ludHM9IjM5LjQxMCw4OS4zNjkgNDkuNDEwLDEzLjM2OSAyOC41ODksMTAuNjMwIDE4LjU4OSw4Ni42MzAiLz48L2NsaXBQYXRoPjxjbGlwUGF0aCBpZD0iZjExIj48cG9seWdvbiBwb2ludHM9IjQxLjM5Myw4OS42MzAgNTEuMzkzLDEzLjYzMCAyNi42MDYsMTAuMzY5IDE2LjYwNiw4Ni4zNjkiLz48L2NsaXBQYXRoPjwvZGVmcz48bGluZSB4MT0iMTIiIHkxPSI2NCIgeDI9Ijg4IiB5Mj0iNjQiIHN0cm9rZT0idXJsKCNmMCkiIHN0cm9rZS13aWR0aD0iMTUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPjxsaW5lIHgxPSIxNiIgeTE9IjM2IiB4Mj0iODQiIHkyPSIzNiIgc3Ryb2tlPSJ1cmwoI2YxKSIgc3Ryb2tlLXdpZHRoPSIxNSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+PGxpbmUgeDE9IjI5IiB5MT0iODgiIHgyPSIzOSIgeTI9IjEyIiBzdHJva2U9InVybCgjZjIpIiBzdHJva2Utd2lkdGg9IjE1IiBzdHJva2UtbGluZWNhcD0icm91bmQiLz48bGluZSB4MT0iNzEiIHkxPSI4OCIgeDI9IjYxIiB5Mj0iMTIiIHN0cm9rZT0idXJsKCNmMykiIHN0cm9rZS13aWR0aD0iMTUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPjxnIGNsaXAtcGF0aD0idXJsKCNmNCkiPjxsaW5lIHgxPSIyOSIgeTE9Ijg4IiB4Mj0iMzkiIHkyPSIxMiIgc3Ryb2tlPSIjMTUxOTFhIiBzdHJva2Utd2lkdGg9IjIxIiBzdHJva2UtbGluZWNhcD0icm91bmQiLz48L2c+PGcgY2xpcC1wYXRoPSJ1cmwoI2Y1KSI+PGxpbmUgeDE9IjI5IiB5MT0iODgiIHgyPSIzOSIgeTI9IjEyIiBzdHJva2U9InVybCgjZjIpIiBzdHJva2Utd2lkdGg9IjE1IiBzdHJva2UtbGluZWNhcD0icm91bmQiLz48L2c+PGcgY2xpcC1wYXRoPSJ1cmwoI2Y2KSI+PGxpbmUgeDE9IjE2IiB5MT0iMzYiIHgyPSI4NCIgeTI9IjM2IiBzdHJva2U9IiMxNTE5MWEiIHN0cm9rZS13aWR0aD0iMjEiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPjwvZz48ZyBjbGlwLXBhdGg9InVybCgjZjcpIj48bGluZSB4MT0iMTYiIHkxPSIzNiIgeDI9Ijg0IiB5Mj0iMzYiIHN0cm9rZT0idXJsKCNmMSkiIHN0cm9rZS13aWR0aD0iMTUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPjwvZz48ZyBjbGlwLXBhdGg9InVybCgjZjgpIj48bGluZSB4MT0iNzEiIHkxPSI4OCIgeDI9IjYxIiB5Mj0iMTIiIHN0cm9rZT0iIzE1MTkxYSIgc3Ryb2tlLXdpZHRoPSIyMSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+PC9nPjxnIGNsaXAtcGF0aD0idXJsKCNmOSkiPjxsaW5lIHgxPSI3MSIgeTE9Ijg4IiB4Mj0iNjEiIHkyPSIxMiIgc3Ryb2tlPSJ1cmwoI2YzKSIgc3Ryb2tlLXdpZHRoPSIxNSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+PC9nPjxnIGNsaXAtcGF0aD0idXJsKCNmMTApIj48bGluZSB4MT0iMTIiIHkxPSI2NCIgeDI9Ijg4IiB5Mj0iNjQiIHN0cm9rZT0iIzE1MTkxYSIgc3Ryb2tlLXdpZHRoPSIyMSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+PC9nPjxnIGNsaXAtcGF0aD0idXJsKCNmMTEpIj48bGluZSB4MT0iMTIiIHkxPSI2NCIgeDI9Ijg4IiB5Mj0iNjQiIHN0cm9rZT0idXJsKCNmMCkiIHN0cm9rZS13aWR0aD0iMTUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPjwvZz48L2c+PC9zdmc+">