@my-life-buddies/cli 0.2.0 → 0.4.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.
Files changed (47) hide show
  1. package/README.md +70 -53
  2. package/dist/bin/core.js +936 -447
  3. package/dist/bin/core.js.map +4 -4
  4. package/dist/bin/preview.js +147 -12
  5. package/dist/bin/preview.js.map +3 -3
  6. package/dist/web/app.css +8 -0
  7. package/dist/web/app.js +7 -2
  8. package/dist/web/index.html +11 -3
  9. package/dist/web/widget-delivery.js +28 -0
  10. package/dist/web/widgets.css +31 -0
  11. package/dist/web/widgets.js +127 -0
  12. package/package.json +3 -3
  13. package/resources/README.md +3 -1
  14. package/resources/agent-template/package-lock.json +4 -4
  15. package/resources/agent-template/package.json +1 -1
  16. package/resources/buddy-creator/INSTALL.md +2 -2
  17. package/resources/buddy-creator/PATCHES.md +21 -1
  18. package/resources/buddy-creator/SKILL.md +16 -6
  19. package/resources/buddy-creator/agents/openai.yaml +1 -1
  20. package/resources/buddy-creator/assets/preview/index.html +2 -0
  21. package/resources/buddy-creator/assets/preview/widgets.css +1 -0
  22. package/resources/buddy-creator/assets/preview/widgets.js +168 -0
  23. package/resources/buddy-creator/assets/widget-reference/conversation.png +0 -0
  24. package/resources/buddy-creator/assets/widget-reference/expanded.png +0 -0
  25. package/resources/buddy-creator/assets/widget-v2/app.js +62 -0
  26. package/resources/buddy-creator/assets/widget-v2/style.css +1 -0
  27. package/resources/buddy-creator/assets/widget-v3/app.js +65 -0
  28. package/resources/buddy-creator/assets/widget-v3/style.css +103 -0
  29. package/resources/buddy-creator/assets/widget-v4/app.js +66 -0
  30. package/resources/buddy-creator/assets/widget-v4/style.css +187 -0
  31. package/resources/buddy-creator/references/artifact-schema.md +2 -0
  32. package/resources/buddy-creator/references/host-guide.md +12 -6
  33. package/resources/buddy-creator/references/recovery.md +6 -3
  34. package/resources/buddy-creator/references/service.md +2 -0
  35. package/resources/buddy-creator/references/widgets-protocol.md +83 -0
  36. package/resources/buddy-creator/references/widgets.md +99 -0
  37. package/resources/buddy-creator/scripts/buddy_core.py +75 -13
  38. package/resources/buddy-creator/scripts/completion.py +64 -8
  39. package/resources/buddy-creator/scripts/preview.py +46 -6
  40. package/resources/buddy-creator/scripts/widget_render.py +66 -0
  41. package/resources/buddy-creator/scripts/widget_render_v1.py +176 -0
  42. package/resources/buddy-creator/scripts/widget_render_v2.py +98 -0
  43. package/resources/buddy-creator/scripts/widget_render_v3.py +86 -0
  44. package/resources/buddy-creator/scripts/widget_render_v4.py +121 -0
  45. package/resources/buddy-creator/scripts/widgets.py +227 -0
  46. package/resources/buddy-creator/version.json +1 -1
  47. package/resources/buddy-creator.manifest.json +121 -26
@@ -311,6 +311,7 @@ def resolve_transition_refs(state, artifact):
311
311
 
312
312
 
313
313
  def put_artifacts(state, proposals, by, draft=False):
314
+ import widgets
314
315
  require(isinstance(proposals, list), 'PATCH_SCHEMA', 'artifacts 必须为数组。')
315
316
  ids = [a.get('id') for a in proposals if isinstance(a, dict)]
316
317
  require(len(ids) == len(proposals) and len(set(ids)) == len(ids), 'PATCH_SCHEMA', '每个产物必须是对象,且一次只能修改同一 ID 一次。')
@@ -319,6 +320,21 @@ def put_artifacts(state, proposals, by, draft=False):
319
320
  for item in proposals:
320
321
  required = {'id', 'stage', 'kind', 'title', 'markdown', 'evidence', 'dependencies', 'unresolved'}
321
322
  require(required <= set(item) and not set(item) - (required | {'data'}), 'ARTIFACT_SCHEMA', '产物字段必须包含 id、stage、kind、title、markdown、evidence、dependencies、unresolved,可选 data;不要提交 hash/revision。')
323
+ if item['kind'] in widgets.KINDS:
324
+ require(text(item['title']) and text(item['markdown']) and text_list(item['unresolved'], True), 'ARTIFACT_SCHEMA', '小挂件产物需要标题、说明及待补项。')
325
+ a = copy.deepcopy(item)
326
+ require(isinstance(a.get('data'), dict), 'ARTIFACT_DATA', '小挂件需要结构化 data。')
327
+ evidence(state, a['evidence'])
328
+ evidence(state, a['dependencies'])
329
+ for ref in a['dependencies']:
330
+ if ref['type'] == 'artifact':
331
+ require(confirmed(state, ref['id']), 'UNCONFIRMED_DEPENDENCY', '依赖内容必须已确认。')
332
+ widgets.validate(state, a)
333
+ a['hash'] = digest(a)
334
+ if state['artifacts'].get(a['id'], {}).get('hash') != a['hash']:
335
+ changed.add(a['id'])
336
+ state['artifacts'][a['id']] = a
337
+ continue
322
338
  require(item['stage'] in stages and item['kind'] in KINDS and text(item['title']) and text(item['markdown']) and text_list(item['unresolved'], True), 'ARTIFACT_SCHEMA', '产物结构不完整。')
323
339
  require(stages.index(item['stage']) <= stages.index(state['stage']), 'STAGE_SCOPE', '上游手册尚未确认,不能生成后续阶段产物。')
324
340
  a = copy.deepcopy(item)
@@ -418,6 +434,9 @@ def transition_differences(state):
418
434
 
419
435
 
420
436
  def confirmation_blockers(state, artifact):
437
+ import widgets
438
+ if artifact['kind'] in widgets.KINDS:
439
+ return widgets.blockers(state, artifact)
421
440
  import interview
422
441
  result = {}
423
442
  if artifact['unresolved']:
@@ -465,16 +484,21 @@ def require_confirmation_ready(state, artifacts):
465
484
 
466
485
  def delivery_record(state, proposal, delivery_id, input_id=None, opening=False, explanation=False):
467
486
  import interview
487
+ import widgets
468
488
  require(isinstance(proposal, dict) and text(proposal.get('text')), 'DELIVERY_REQUIRED', '需要给用户的公开回复 text。')
469
- require(not set(proposal) - {'text', 'question', 'confirmationObjectIds', 'confirmationScope', 'mode', 'blocked'}, 'DELIVERY_SCHEMA', 'delivery 包含未支持字段;请按协议提交。')
489
+ require(not set(proposal) - {'text', 'question', 'designQuestion', 'confirmationObjectIds', 'confirmationScope', 'mode', 'blocked'}, 'DELIVERY_SCHEMA', 'delivery 包含未支持字段;请按协议提交。')
470
490
  question, ids = proposal.get('question'), proposal.get('confirmationObjectIds')
491
+ design_question = proposal.get('designQuestion')
471
492
  if explanation:
472
- require(not question and not ids and not proposal.get('blocked'), 'EXPLANATION_ONLY', '纯答疑保留原待答内容,不附加采访问题或确认。')
473
- require(not (question and ids), 'ONE_REPLY_TARGET', '每轮一个核心问题或一次确认,不要同时提交两种。')
493
+ require(not question and not ids and not design_question and not proposal.get('blocked'), 'EXPLANATION_ONLY', '纯答疑保留原待答内容,不附加采访问题或确认。')
494
+ require(sum(bool(v) for v in (question, ids, design_question)) <= 1, 'ONE_REPLY_TARGET', '每轮一个核心问题或一次确认,不要同时提交两种。')
474
495
  result = {'id': delivery_id, 'text': proposal['text'], 'hash': digest(proposal['text']), 'createdAt': now(), 'mode': proposal.get('mode', 'ordinary'), 'inputId': input_id}
475
496
  # The host checks one semantic reply target. Quoted questions and context
476
497
  # length cannot reliably be judged by punctuation or character counts.
477
- if question:
498
+ if design_question:
499
+ require(text(design_question) and widgets.enabled(state) and widgets.books_ready(state), 'WIDGET_STAGE', '小挂件补充问题在四册确认后提出,designQuestion 记录一个具体缺口。')
500
+ result['designQuestion'] = design_question
501
+ elif question:
478
502
  require(isinstance(question, dict) and 'targetId' in question and not set(question) - {'targetId', 'gapId'}, 'QUESTION_SCHEMA', 'question 使用 {targetId, gapId?}。')
479
503
  askable(state, question['targetId'])
480
504
  if result['mode'] == 'transition':
@@ -490,22 +514,23 @@ def delivery_record(state, proposal, delivery_id, input_id=None, opening=False,
490
514
  scope = proposal.get('confirmationScope', 'object')
491
515
  require(scope in {'object', 'booklet'}, 'CONFIRMATION_SCOPE', 'confirmationScope 使用 object 或 booklet。')
492
516
  if scope == 'booklet':
517
+ require(artifacts[0]['stage'] in catalog()['stages'], 'CONFIRMATION_SCOPE', '小挂件使用 object 确认,不新增第五册。')
493
518
  require(set(ids) == set(book_ids(artifacts[0]['stage'])), 'BOOKLET_INCOMPLETE', '整册确认必须包含该册全部固定章节。')
494
519
  result['confirmationTarget'] = {'scope': scope, 'stage': artifacts[0]['stage'], 'objects': [{'id': a['id'], 'hash': a['hash']} for a in artifacts]}
495
520
  blocked = proposal.get('blocked')
496
521
  if blocked is not None:
497
- require(text(blocked) and not question and not ids, 'BLOCKED_SCHEMA', 'blocked 是具体阻塞及恢复条件,只在无合法下一步时使用。')
522
+ require(text(blocked) and not question and not ids and not design_question, 'BLOCKED_SCHEMA', 'blocked 是具体阻塞及恢复条件,只在无合法下一步时使用。')
498
523
  options = available(state)
499
524
  require(not options['questionTargets'] and not options['confirmationObjects'] and not options['canDraftBooklet'],
500
525
  'NOT_BLOCKED', '还有可提问目标、可确认内容或可生成手册,须继续推进。')
501
526
  require(not all(book_confirmed(state, s) for s in catalog()['stages']), 'NOT_BLOCKED', '访谈已完成,不是阻塞。')
502
527
  result['blocked'] = blocked
503
528
  state.pop('questionDeliveryId', None)
504
- if not explanation and not question and not ids and not blocked and not state['paused'] and not all(book_confirmed(state, s) for s in catalog()['stages']):
529
+ if not explanation and not question and not ids and not design_question and not blocked and not state['paused'] and (not all(book_confirmed(state, s) for s in catalog()['stages']) or not widgets.ready(state)):
505
530
  raise BuddyError('CONTINUATION_REQUIRED', '访谈还未完成:请在本轮给出下一条有效问题或确切内容确认,不能只回复“已保存”。若需要后台整理,先 draft_publish,再继续完成同一轮。')
506
531
  state['deliveries'][delivery_id] = result
507
532
  state['currentDeliveryId'] = delivery_id
508
- if question or ids:
533
+ if question or ids or design_question:
509
534
  state['questionDeliveryId'] = delivery_id
510
535
  elif explanation:
511
536
  result['resumeDeliveryId'] = state.get('questionDeliveryId')
@@ -513,6 +538,7 @@ def delivery_record(state, proposal, delivery_id, input_id=None, opening=False,
513
538
 
514
539
 
515
540
  def available(state):
541
+ import widgets
516
542
  questions = []
517
543
  for target_id in catalog()['cards']:
518
544
  try:
@@ -520,12 +546,16 @@ def available(state):
520
546
  questions.append(target_id)
521
547
  except BuddyError:
522
548
  pass
523
- return {'questionTargets': questions, 'confirmationObjects': [a['id'] for a in state['artifacts'].values() if a['stage'] == state['stage'] and confirmation_ready(state, a) and not any(confirmed(state, a['id'], d) for d in ['confirmed', 'accepted', 'rejected'])], 'canDraftBooklet': can_draft(state, state['stage'])}
549
+ candidates = [a['id'] for a in state['artifacts'].values()
550
+ if (a['stage'] == state['stage'] or widgets.enabled(state) and a['kind'] in widgets.KINDS)
551
+ and confirmation_ready(state, a) and not any(confirmed(state, a['id'], d) for d in ['confirmed', 'accepted', 'rejected'])]
552
+ return {'questionTargets': questions, 'confirmationObjects': candidates, 'canDraftBooklet': can_draft(state, state['stage']), 'designPhase': widgets.phase(state)}
524
553
 
525
554
 
526
555
  def context(workspace, state):
527
556
  import interview
528
- return {'workspace': str(Path(workspace).resolve()), 'revision': state['revision'], 'stage': state['stage'], 'paused': state['paused'], 'pendingTurnId': state.get('pendingTurnId'), 'catalog': catalog(), 'gates': gates(state, state['stage']), 'continuation': available(state), 'interviewGuidance': interview.guidance(state), 'state': state, 'contract': str(ROOT / 'references' / 'host-guide.md'), 'note': '宿主负责真实语义判断,runtime 仅校验结构、证据版本、状态与确认关系。每轮先保存原话,再整理、保存、展示并登记实际展示;不能替用户确认。'}
557
+ import widgets
558
+ return {'workspace': str(Path(workspace).resolve()), 'revision': state['revision'], 'stage': state['stage'], 'paused': state['paused'], 'pendingTurnId': state.get('pendingTurnId'), 'catalog': catalog(), 'gates': gates(state, state['stage']), 'continuation': available(state), 'widgetDesign': widgets.projection(state, workspace), 'interviewGuidance': interview.guidance(state), 'state': state, 'contract': str(ROOT / 'references' / 'host-guide.md'), 'note': '宿主负责真实语义判断,runtime 仅校验结构、证据版本、状态与确认关系。每轮先保存原话,再整理、保存、展示并登记实际展示;不能替用户确认。'}
529
559
 
530
560
 
531
561
  def validate_initial_context(value):
@@ -576,6 +606,7 @@ def open_workspace(creation_key=None, workspace=None, initial_context=None):
576
606
  require(not unexpected, 'WORKSPACE_NOT_EMPTY', '新项目需要空目录;不能覆盖已有资料或 Node 版项目。', {'files': unexpected[:10]})
577
607
  cards = catalog()['cards']
578
608
  state = {'schemaVersion': SCHEMA, 'buddyId': base.name, 'workspaceId': 'workspace_' + digest(str(base))[:24], 'creationKeyHash': digest(creation_key), 'revision': '', 'stage': 'definition', 'paused': False, 'inputs': {}, 'turns': {}, 'pendingTurnId': None, 'deliveries': {}, 'presentations': [], 'currentDeliveryId': None, 'targets': {key: {'id': key, 'status': 'unstarted', 'summary': '', 'gaps': [], 'evidence': [], 'answerInputIds': []} for key in cards}, 'artifacts': {}, 'confirmations': [], 'sources': {}, 'sourcePlan': {'sourceIds': [], 'requiredKinds': [], 'discoveryClosed': False}, 'serviceMode': None, 'serviceModelExplained': False, 'drafts': [], 'updatedAt': now()}
609
+ state['widgetDesignVersion'] = 1
579
610
  if initial_context is None:
580
611
  opening = (ROOT / 'references' / 'opening.md').read_text(encoding='utf-8').strip()
581
612
  delivery_record(state, {'text': opening, 'question': {'targetId': 'D01'}, 'mode': 'opening'}, 'delivery_opening', opening=True)
@@ -598,8 +629,10 @@ def active_turn(state, turn_id):
598
629
  return turn
599
630
 
600
631
 
601
- def finish_turn(state, payload):
632
+ def finish_turn(state, payload, workspace=None):
602
633
  import interview
634
+ import widgets
635
+ import widget_render
603
636
  turn = state['turns'].get(payload.get('turnId'))
604
637
  require(turn, 'TURN_NOT_FOUND', '未找到这一轮。')
605
638
  payload_hash = digest({k: v for k, v in payload.items() if k != 'operation'})
@@ -610,10 +643,15 @@ def finish_turn(state, payload):
610
643
  intent = payload.get('intent', 'answer')
611
644
  require(intent in {'answer', 'revision', 'confirmation', 'explanation', 'pause', 'resume'}, 'INTENT_SCHEMA', 'intent 使用 answer/revision/confirmation/explanation/pause/resume。')
612
645
  patch = payload.get('patch', {})
613
- require(isinstance(patch, dict) and not set(patch) - {'targets', 'artifacts', 'confirmations', 'sourcePlan', 'serviceMode', 'serviceModelExplained', 'paused', 'interview'}, 'PATCH_SCHEMA', 'patch 包含未支持字段。')
646
+ require(isinstance(patch, dict) and not set(patch) - {'targets', 'artifacts', 'confirmations', 'sourcePlan', 'serviceMode', 'serviceModelExplained', 'paused', 'interview', 'enableWidgetDesign'}, 'PATCH_SCHEMA', 'patch 包含未支持字段。')
614
647
  inp = state['inputs'][turn['inputId']]
615
648
  if intent == 'explanation':
616
649
  require(not patch, 'EXPLANATION_ONLY', '纯答疑不修改采访目标、产物或确认;需要修订时使用 revision。')
650
+ if 'enableWidgetDesign' in patch:
651
+ request = patch['enableWidgetDesign']
652
+ require(isinstance(request, dict) and set(request) == {'evidence'}, 'WIDGET_OPT_IN', '旧作品追加设计需引用本轮开发者的明确请求。')
653
+ evidence(state, request['evidence'], inp['id'])
654
+ state['widgetDesignVersion'] = 1
617
655
  replied = state['deliveries'].get(inp.get('replyToDeliveryId'), {})
618
656
  target_id = replied.get('question', {}).get('targetId')
619
657
  interview_patch = patch.get('interview', {})
@@ -666,8 +704,23 @@ def finish_turn(state, payload):
666
704
  require(isinstance(patch['paused'], bool), 'PAUSED_SCHEMA', 'paused 使用布尔值,仅按用户明确暂停/恢复意愿设置。')
667
705
  state['paused'] = patch['paused']
668
706
  proposals = patch.get('artifacts', [])
707
+ for record in patch.get('confirmations', []):
708
+ artifact = state['artifacts'].get(record.get('objectId'), {})
709
+ if artifact.get('kind') in {'widget-prototype', 'widget-acceptance'}:
710
+ prototype = artifact if artifact['kind'] == 'widget-prototype' else state['artifacts'].get(widgets.object_id(artifact['data']['widgetId']))
711
+ require(workspace, 'WIDGET_ASSETS_REQUIRED', '通过工作目录调用以校验实际原型资源。')
712
+ widget_render.require_assets(workspace, state, prototype)
669
713
  record_confirmations(state, turn, patch.get('confirmations', []), {a.get('id') for a in proposals})
670
714
  put_artifacts(state, proposals, turn['id'])
715
+ for proposal in proposals:
716
+ if proposal['kind'] == 'widget-prototype' and workspace:
717
+ widget_render.render(workspace, state, state['artifacts'][proposal['id']])
718
+ for key in (payload.get('delivery') or {}).get('confirmationObjectIds', []):
719
+ artifact = state['artifacts'].get(key, {})
720
+ if artifact.get('kind') in {'widget-prototype', 'widget-acceptance'}:
721
+ prototype = artifact if artifact['kind'] == 'widget-prototype' else state['artifacts'].get(widgets.object_id(artifact['data']['widgetId']))
722
+ require(workspace, 'WIDGET_ASSETS_REQUIRED', '先生成实际原型,再展示确认。')
723
+ widget_render.require_assets(workspace, state, prototype)
671
724
  # Faithful base-case capture is a user answer, not invented consent to a new proposal.
672
725
  for proposal in proposals:
673
726
  if proposal['kind'] == 'scenario' and proposal['id'].startswith('scenario.M') and proposal.get('data', {}).get('capture') == 'faithful_user_answer':
@@ -686,7 +739,7 @@ def finish_turn(state, payload):
686
739
  def call(workspace, payload):
687
740
  require(isinstance(payload, dict), 'REQUEST_SCHEMA', '请求必须为 JSON 对象。')
688
741
  operation = payload.get('operation')
689
- require(operation in {'opening_set', 'turn_begin', 'turn_finish', 'turn_continue', 'presentation_record', 'draft_publish', 'source_import', 'finalize', 'snapshot'}, 'OPERATION_UNKNOWN', '未知 operation。')
742
+ require(operation in {'opening_set', 'turn_begin', 'turn_finish', 'turn_continue', 'presentation_record', 'draft_publish', 'source_import', 'widget_render', 'finalize', 'snapshot'}, 'OPERATION_UNKNOWN', '未知 operation。')
690
743
  workspace = Path(workspace).expanduser().resolve()
691
744
  completion_needed = False
692
745
  with workspace_lock(workspace):
@@ -739,7 +792,7 @@ def call(workspace, payload):
739
792
  result = {'turn': turn, 'input': inp, 'directive': 'prepare_turn'}
740
793
  changed = True
741
794
  elif operation == 'turn_finish':
742
- delivery, changed = finish_turn(state, payload)
795
+ delivery, changed = finish_turn(state, payload, workspace)
743
796
  result = {'delivery': delivery, 'turn': state['turns'][payload['turnId']], 'directive': 'present_delivery'}
744
797
  completion_needed = all(book_confirmed(state, stage) for stage in catalog()['stages'])
745
798
  elif operation == 'turn_continue':
@@ -780,8 +833,17 @@ def call(workspace, payload):
780
833
  state['sources'][manifest['id']] = manifest
781
834
  changed = old != manifest
782
835
  result = {'source': manifest, 'directive': 'source_ready' if manifest.get('status') == 'ready' else 'source_action_required'}
836
+ elif operation == 'widget_render':
837
+ import widget_render
838
+ import widgets
839
+ artifact = state['artifacts'].get(payload.get('objectId'), {})
840
+ require(artifact.get('kind') == 'widget-prototype' and not widgets.blockers(state, artifact), 'WIDGET_RENDER_SCOPE', '仅重新生成当前清单的有效原型,不改动确认记录。')
841
+ widget_render.render(workspace, state, artifact)
842
+ result = {'assets': widget_render.asset_status(workspace, artifact, state)}
783
843
  elif operation == 'finalize':
784
844
  require(all(book_confirmed(state, stage) for stage in catalog()['stages']), 'BOOKLETS_UNCONFIRMED', '四册还未全部确认,暂不能完成交付。')
845
+ import widgets
846
+ require(widgets.ready(state), 'WIDGET_DESIGN_UNCONFIRMED', '小挂件清单、原型或设计验收尚未确认。', {'phase': widgets.phase(state)})
785
847
  completion_needed = True
786
848
  else:
787
849
  import preview
@@ -8,6 +8,7 @@ import shutil
8
8
  import tempfile
9
9
  from datetime import datetime, timezone
10
10
  from pathlib import Path
11
+ from buddy_core import BuddyError
11
12
 
12
13
  LABELS = {'definition': '定义', 'knowledge': '知识', 'methods': '方法', 'service': '服务'}
13
14
 
@@ -43,9 +44,46 @@ def _confirmed(state, object_id):
43
44
 
44
45
 
45
46
  def _ready(state, catalog):
47
+ import widgets
46
48
  return not state.get('paused') and not state.get('pendingTurnId') and all(
47
49
  _confirmed(state, '%s.%d' % (stage, index + 1))
48
- for stage in catalog['stages'] for index in range(len(catalog['chapters'][stage])))
50
+ for stage in catalog['stages'] for index in range(len(catalog['chapters'][stage]))) and widgets.ready(state)
51
+
52
+
53
+ def widget_files(workspace, state):
54
+ import widgets
55
+ import widget_render
56
+ if not widgets.enabled(state):
57
+ return {}
58
+ catalog = state['artifacts'][widgets.CATALOG]
59
+ files = {'widgets/catalog.json': _bytes(catalog)}
60
+ guide = ['# 小挂件交互设计', '', '已完成设计验收;真实会话数据接入、生产 H5 和端到端功能验收由后续开发完成。', '',
61
+ '小挂件只读。筛选、切换、折叠和浮层只改变阅读状态;编辑统一通过对话完成。',
62
+ '展开页宽度:屏幕逻辑宽度 − 32;高度:屏幕逻辑高度 × 65%;内容区上下滚动。标题建议 16 Medium、最大 20;正文 16 Regular。', '', catalog['markdown'], '']
63
+ if not widgets.items(state):
64
+ guide.extend(['本作品已明确确认不使用小挂件。', catalog['data']['noWidgetsReason'], ''])
65
+ for item in widgets.items(state):
66
+ prototype = state['artifacts'][widgets.object_id(item['id'])]
67
+ acceptance = state['artifacts'][widgets.object_id(item['id'], 'acceptance')]
68
+ widget_render.require_assets(workspace, state, prototype)
69
+ prefix = 'widgets/' + item['id'] + '/'
70
+ for name, raw in widget_render.files(prototype, item).items():
71
+ files[prefix + name] = raw
72
+ files[prefix + 'prototype.json'] = _bytes(prototype)
73
+ files[prefix + 'acceptance.json'] = _bytes({'artifact': acceptance, 'automaticCheck': widget_render.asset_status(workspace, prototype, state),
74
+ 'developerConfirmations': [c for c in state['confirmations'] if c['objectId'] in (prototype['id'], acceptance['id']) and not c.get('invalidatedBy')]})
75
+ review_labels = {'booklet-fit': '符合搭子需求', 'reading-order': '阅读顺序、长内容与区分度',
76
+ 'chat-consistency': '对话卡片与展开页一致', 'conversation-update': '数据随对话逐步更新',
77
+ 'read-only': '只读交互边界'}
78
+ guide.extend(['## ' + item['name'], '', prototype['markdown'], '',
79
+ '[打开可阅读原型](' + prefix + 'index.html)', '',
80
+ '原型含首次生成、逐步补充、对话更新三态;每态提供对话卡片与展开页 SVG。H5 原型通过 ?view=expanded&state=partial 等参数查看。', '',
81
+ '### 设计验收', '', '当前原型及本版设计验收均已确认。原始确认稿与确认凭据保留在对应 JSON 文件中。', '',
82
+ '原型版本:' + prototype['hash'], ''])
83
+ for key in widgets.REVIEW_CHECKS:
84
+ guide.extend(['#### ' + review_labels[key], '', acceptance['data']['reviews'][key], ''])
85
+ files['WIDGET_DESIGN.md'] = '\n'.join(guide).encode('utf-8')
86
+ return files
49
87
 
50
88
 
51
89
  def _line_chunks(text, columns=32, limit=3):
@@ -128,10 +166,14 @@ def snapshot(workspace, state):
128
166
  record = json.loads(path.read_text(encoding='utf-8'))
129
167
  if record.get('revision') != state.get('revision'):
130
168
  return None
131
- if record.get('status') == 'ready' and not (Path(record['directory']) / 'BUDDY_MANUAL.md').is_file():
132
- record['status'] = 'failed'
133
- record['message'] = '四册确认已保留,本地成果需要重新生成。'
134
- return {key: record.get(key) for key in ('revision', 'status', 'message', 'fileCount', 'updatedAt')}
169
+ checked = return_to_caller(workspace, state, {})
170
+ if checked.get('completion'):
171
+ record.update(checked['completion'])
172
+ result = {key: record.get(key) for key in ('revision', 'status', 'message', 'fileCount', 'updatedAt')}
173
+ if checked.get('directive') == 'creator_complete':
174
+ inventory = json.loads((Path(record['directory']) / 'MANIFEST.json').read_text(encoding='utf-8'))
175
+ result['files'] = [entry['path'] for entry in inventory['files']]
176
+ return result
135
177
  except (OSError, ValueError, KeyError):
136
178
  return None
137
179
 
@@ -161,10 +203,19 @@ def return_to_caller(workspace, state, result):
161
203
  or inventory.get('complete') is not True or len(entries) != 1
162
204
  or _hash(manual.read_bytes()) != entries[0]['sha256']):
163
205
  raise ValueError('手册与已确认成果不一致。')
206
+ import widgets
207
+ if widgets.enabled(state):
208
+ required = {'BUDDY_MANUAL.md', 'SERVICE_MODEL.svg', 'service-blueprint.json', 'versions.json', 'sources.json'} | {'booklets/' + s + '.md' for s in catalog['stages']} | set(widget_files(workspace, state))
209
+ if {entry['path'] for entry in inventory['files']} != required or len(inventory['files']) != len(required):
210
+ raise ValueError('交付文件清单缺项或重复。')
211
+ for entry in inventory['files']:
212
+ target = directory / entry['path']
213
+ if any(p.is_symlink() for p in [target, *target.parents] if p != workspace.parent) or _hash(target.read_bytes()) != entry['sha256']:
214
+ raise ValueError('交付文件缺失或被修改:' + entry['path'])
164
215
  result['completion'] = {key: record[key] for key in
165
216
  ('revision', 'status', 'directory', 'manualPath', 'fileCount', 'message', 'updatedAt')}
166
217
  result['directive'] = 'creator_complete'
167
- except (OSError, ValueError, KeyError, TypeError) as error:
218
+ except (OSError, ValueError, KeyError, TypeError, BuddyError) as error:
168
219
  # Never hand off a stale path or a success-shaped but unusable export.
169
220
  result['completion'] = {'revision': state.get('revision'), 'status': 'failed',
170
221
  'message': '四册确认已保留,当前成果缺失、失效或被修改;先用 finalize 恢复交付。',
@@ -199,13 +250,18 @@ def finalize(workspace, state):
199
250
  manual.extend([text, ''])
200
251
  manual.extend(['## 服务模式图', '', '![服务模式](SERVICE_MODEL.svg)', '',
201
252
  '本手册记录已确认的创作设计,后续开发、内容核实和实际履约仍按具体项目推进。', ''])
253
+ import widgets
254
+ if widgets.enabled(state):
255
+ files.update(widget_files(workspace, state))
256
+ manual.extend(['## 小挂件交互设计', '', '[小挂件清单、原型与设计验收](WIDGET_DESIGN.md)', '',
257
+ '只读 H5 模板由开发者前置定义,对话负责创建与逐步更新实例数据。', ''])
202
258
  files['BUDDY_MANUAL.md'] = '\n'.join(manual).encode('utf-8')
203
259
  files['SERVICE_MODEL.svg'] = _diagram(state).encode('utf-8')
204
260
  files['service-blueprint.json'] = _bytes({
205
261
  'data': _blueprint(state),
206
262
  'sourceArtifact': {key: state['artifacts'].get('service.blueprint', {}).get(key) for key in ('id', 'hash')},
207
263
  'transitionArtifacts': [{key: artifact.get(key) for key in ('id', 'hash')} for artifact in state['artifacts'].values() if artifact.get('kind') == 'transition']})
208
- files['versions.json'] = _bytes({'buddyId': state['buddyId'], 'revision': revision,
264
+ files['versions.json'] = _bytes({'buddyId': state['buddyId'], 'revision': revision, 'bookletRevision': widgets.booklet_revision(state),
209
265
  'artifacts': state['artifacts'], 'confirmations': state['confirmations']})
210
266
  files['sources.json'] = _bytes(list(state.get('sources', {}).values()))
211
267
  files['MANIFEST.json'] = _bytes({'format': 'buddy-creator-1', 'buddyId': state['buddyId'],
@@ -230,7 +286,7 @@ def finalize(workspace, state):
230
286
  os.replace(str(staging), str(output))
231
287
  staging = None
232
288
  record.update(status='ready', directory=str(output), manualPath=str(output / 'BUDDY_MANUAL.md'),
233
- fileCount=len(files), message='四册已确认,创作手册和服务模式图已保存在本机。')
289
+ fileCount=len(files), message='四册及小挂件设计已确认,手册、原型和验收记录已保存在本机。' if widgets.enabled(state) else '四册已确认,创作手册和服务模式图已保存在本机。')
234
290
  except Exception as error:
235
291
  record.update(status='failed', error=str(error), message='四册确认已保留,本地成果生成需要重试。')
236
292
  finally:
@@ -26,7 +26,7 @@ from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_ope
26
26
 
27
27
  ROOT = Path(__file__).resolve().parent.parent
28
28
  RUNTIME_VERSION = "buddy-creator-" + json.loads((ROOT / "version.json").read_text(encoding="utf-8"))["version"]
29
- COMPATIBLE_RUNTIME_VERSIONS = {RUNTIME_VERSION, "buddy-creator-1.2.0", "buddy-creator-1.1.0", "buddy-creator-1.0.0", "python-trial-1"}
29
+ COMPATIBLE_RUNTIME_VERSIONS = {RUNTIME_VERSION}
30
30
  STAGES = ("definition", "knowledge", "methods", "service")
31
31
  STAGE_LABELS = dict(zip(STAGES, ("定义", "知识", "方法", "服务")))
32
32
  LABELS = {"confirmed": "已确认", "accepted": "已采纳", "rejected": "未采纳",
@@ -177,7 +177,7 @@ def snapshot(workspace, state=None):
177
177
  cards, chapters = catalog["cards"], catalog["chapters"]
178
178
  stage = state["stage"]
179
179
  targets = state.get("targets", {})
180
- artifacts = [_artifact_view(state, a) for a in state.get("artifacts", {}).values()]
180
+ artifacts = [_artifact_view(state, a) for a in state.get("artifacts", {}).values() if a['stage'] in STAGES]
181
181
  by_id = {a["id"]: a for a in artifacts}
182
182
  delivery = state.get("deliveries", {}).get(state.get("currentDeliveryId"), {})
183
183
  if delivery.get("resumeDeliveryId"):
@@ -304,11 +304,13 @@ def snapshot(workspace, state=None):
304
304
  except (OSError, ValueError, ImportError):
305
305
  if complete:
306
306
  completion_view = {"revision": state["revision"], "status": "failed", "message": "四册已确认,成果暂时无法读取。"}
307
- if completion_view:
308
- completion_view = {k: completion_view.get(k) for k in ("revision", "status", "message")}
307
+ import widgets
308
+ widget_design = widgets.projection(state, workspace)
309
+ if complete and widgets.enabled(state) and not widgets.ready(state):
310
+ current.update(phase='小挂件交互设计', title=widget_design['nextStep'], status='待确认')
309
311
  return {"buddyId": state["buddyId"], "revision": state["revision"], "stage": stage,
310
312
  "paused": bool(state.get("paused")), "activity": _activity(state, drafts),
311
- "completion": completion_view, "current": current, "stages": stages,
313
+ "completion": completion_view, "widgetDesign": widget_design, "current": current, "stages": stages,
312
314
  "artifacts": artifacts, "sources": sources, "drafts": visible_drafts}
313
315
 
314
316
 
@@ -347,6 +349,11 @@ def _identity(endpoint, workspace_id, record):
347
349
  identity = json.loads(response.read(8192).decode("utf-8"))
348
350
  except (OSError, HTTPError, URLError, ValueError):
349
351
  return False
352
+ if (isinstance(identity, dict) and identity.get("workspaceId") == workspace_id
353
+ and record and identity.get("pid") == record.get("pid") and record.get("workspaceId") == workspace_id
354
+ and record.get("url") == _url(endpoint)
355
+ and identity.get("runtimeVersion") in {"buddy-creator-1.3.1", "buddy-creator-1.3.0", "buddy-creator-1.2.0", "buddy-creator-1.1.0", "buddy-creator-1.0.0", "python-trial-1"}):
356
+ _fail("PREVIEW_UPGRADE_REQUIRED", "当前作品的旧版预览服务仍在运行。由宿主核对该服务后重启,再 open 原工作区;保留固定地址、确认记录和一次性面板提醒。")
350
357
  if not isinstance(identity, dict) or identity.get("workspaceId") != workspace_id or identity.get("runtimeVersion") not in COMPATIBLE_RUNTIME_VERSIONS:
351
358
  _fail("PREVIEW_IDENTITY_MISMATCH", "端口上的服务不属于当前版本的搭子预览,未接管或停止它。")
352
359
  if not record or identity.get("pid") != record.get("pid") or record.get("workspaceId") != workspace_id or record.get("url") != _url(endpoint):
@@ -469,7 +476,8 @@ def serve(workspace):
469
476
  self.send_header("Cache-Control", "no-store")
470
477
  self.send_header("X-Content-Type-Options", "nosniff")
471
478
  self.send_header("Referrer-Policy", "no-referrer")
472
- self.send_header("Content-Security-Policy", "default-src 'self'; script-src 'self'; connect-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'")
479
+ frame = "'self'" if '/widget-assets/' in urlsplit(self.path).path else "'none'"
480
+ self.send_header("Content-Security-Policy", "default-src 'self'; script-src 'self'; connect-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors " + frame)
473
481
 
474
482
  def do_GET(self):
475
483
  expected_host = "127.0.0.1:" + str(self.server.server_port)
@@ -488,6 +496,38 @@ def serve(workspace):
488
496
  self.json_reply(snapshot(workspace))
489
497
  elif route == "api/events":
490
498
  self.events()
499
+ elif route.startswith('widget-assets/'):
500
+ import buddy_core
501
+ import widgets
502
+ import widget_render
503
+ pieces = route.split('/')
504
+ state = buddy_core.load(workspace)
505
+ artifact = next((a for a in state['artifacts'].values() if a.get('kind') == 'widget-prototype' and len(pieces) == 3 and a['hash'] == pieces[1]), None)
506
+ if not artifact or widgets.blockers(state, artifact):
507
+ self.reply(404)
508
+ return
509
+ status = widget_render.asset_status(workspace, artifact, state)
510
+ if not status['pass'] or pieces[2] not in {f['name'] for f in status.get('files', [])}:
511
+ self.reply(404)
512
+ return
513
+ file = widget_render.directory(workspace, artifact) / pieces[2]
514
+ self.reply(200, file.read_bytes(), {'.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml'}[file.suffix])
515
+ elif route.startswith('deliverables/'):
516
+ import buddy_core
517
+ import completion
518
+ state = buddy_core.load(workspace)
519
+ checked = completion.return_to_caller(workspace, state, {})
520
+ parts = route.split('/', 2)
521
+ if checked.get('directive') != 'creator_complete' or len(parts) != 3 or parts[1] != state['revision']:
522
+ self.reply(404)
523
+ return
524
+ directory = workspace / 'deliverables' / state['revision']
525
+ inventory = _read(directory / 'MANIFEST.json')
526
+ if parts[2] not in {entry['path'] for entry in inventory['files']}:
527
+ self.reply(404)
528
+ return
529
+ file = directory / parts[2]
530
+ self.reply(200, file.read_bytes(), mimetypes.guess_type(str(file))[0] or 'text/plain; charset=utf-8')
491
531
  else:
492
532
  file = (assets / (route or "index.html")).resolve()
493
533
  if assets not in file.parents or not file.is_file():
@@ -0,0 +1,66 @@
1
+ """Versioned read-only prototypes; existing files retain their renderer contract."""
2
+ import hashlib
3
+ from pathlib import Path
4
+
5
+ import buddy_core as core
6
+
7
+
8
+ def files(artifact, item=None):
9
+ version = artifact['data'].get('rendererVersion')
10
+ core.require(version in (1, 2, 3, 4), 'WIDGET_RENDERER_VERSION', '不支持的原型模板版本。')
11
+ if version == 1:
12
+ from widget_render_v1 import files as render_files
13
+ elif version == 2:
14
+ from widget_render_v2 import files as render_files
15
+ elif version == 3:
16
+ from widget_render_v3 import files as render_files
17
+ else:
18
+ from widget_render_v4 import files as render_files
19
+ return render_files(artifact, item)
20
+
21
+
22
+ def directory(workspace, artifact):
23
+ root = Path(workspace) / 'widget-assets'
24
+ path = root / artifact['hash']
25
+ core.require(re_full_hash(artifact['hash']) and not root.is_symlink() and not path.is_symlink(), 'WIDGET_ASSET_PATH', '小挂件资源目录无效。')
26
+ return path
27
+
28
+
29
+ def re_full_hash(value):
30
+ return isinstance(value, str) and len(value) == 64 and all(c in '0123456789abcdef' for c in value)
31
+
32
+
33
+ def render(workspace, state, artifact):
34
+ import widgets
35
+ item = next(i for i in widgets.items(state) if i['id'] == artifact['data']['widgetId'])
36
+ output = directory(workspace, artifact)
37
+ output.mkdir(parents=True, exist_ok=True)
38
+ from completion import _atomic
39
+ for name, raw in files(artifact, item).items():
40
+ core.require(not (output / name).is_symlink(), 'WIDGET_ASSET_PATH', '资源文件不能为符号链接。')
41
+ _atomic(output / name, raw)
42
+
43
+
44
+ def asset_status(workspace, artifact, state=None):
45
+ if not artifact:
46
+ return {'pass': False, 'message': '原型尚未生成'}
47
+ try:
48
+ import widgets
49
+ state = state or core.load(workspace)
50
+ core.require(artifact['data'].get('rendererVersion') in (1, 2, 3, 4), 'WIDGET_RENDERER_VERSION', '原型模板版本已变化,需要重新生成并确认设计。')
51
+ item = next(i for i in widgets.items(state) if i['id'] == artifact['data']['widgetId'])
52
+ core.require(widgets.item_hash(item) == artifact['data']['itemHash'], 'WIDGET_ITEM_STALE', '清单项已更新,需要重绘原型。')
53
+ output = directory(workspace, artifact)
54
+ expected = files(artifact, item)
55
+ for name, raw in expected.items():
56
+ path = output / name
57
+ core.require(not path.is_symlink() and path.read_bytes() == raw, 'WIDGET_ASSET_CHANGED', '原型资源缺失或被修改,请用 widget_render 恢复当前版本。', {'file': name})
58
+ return {'pass': True, 'message': '尺寸、字体、只读控件及两视图三态资源检查通过;内容和阅读体验由开发者单独验收。',
59
+ 'files': [{'name': name, 'sha256': hashlib.sha256(raw).hexdigest()} for name, raw in expected.items()]}
60
+ except (core.BuddyError, OSError, StopIteration, KeyError, ValueError) as error:
61
+ return {'pass': False, 'message': str(error) or '当前资源不可用,请重新生成原型。'}
62
+
63
+
64
+ def require_assets(workspace, state, artifact):
65
+ status = asset_status(workspace, artifact, state)
66
+ core.require(status['pass'], 'WIDGET_ASSETS_REQUIRED', status['message'])