@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.
- package/README.md +70 -53
- package/dist/bin/core.js +936 -447
- package/dist/bin/core.js.map +4 -4
- package/dist/bin/preview.js +147 -12
- package/dist/bin/preview.js.map +3 -3
- package/dist/web/app.css +8 -0
- package/dist/web/app.js +7 -2
- package/dist/web/index.html +11 -3
- package/dist/web/widget-delivery.js +28 -0
- package/dist/web/widgets.css +31 -0
- package/dist/web/widgets.js +127 -0
- package/package.json +3 -3
- package/resources/README.md +3 -1
- package/resources/agent-template/package-lock.json +4 -4
- package/resources/agent-template/package.json +1 -1
- package/resources/buddy-creator/INSTALL.md +2 -2
- package/resources/buddy-creator/PATCHES.md +21 -1
- package/resources/buddy-creator/SKILL.md +16 -6
- package/resources/buddy-creator/agents/openai.yaml +1 -1
- package/resources/buddy-creator/assets/preview/index.html +2 -0
- package/resources/buddy-creator/assets/preview/widgets.css +1 -0
- package/resources/buddy-creator/assets/preview/widgets.js +168 -0
- package/resources/buddy-creator/assets/widget-reference/conversation.png +0 -0
- package/resources/buddy-creator/assets/widget-reference/expanded.png +0 -0
- package/resources/buddy-creator/assets/widget-v2/app.js +62 -0
- package/resources/buddy-creator/assets/widget-v2/style.css +1 -0
- package/resources/buddy-creator/assets/widget-v3/app.js +65 -0
- package/resources/buddy-creator/assets/widget-v3/style.css +103 -0
- package/resources/buddy-creator/assets/widget-v4/app.js +66 -0
- package/resources/buddy-creator/assets/widget-v4/style.css +187 -0
- package/resources/buddy-creator/references/artifact-schema.md +2 -0
- package/resources/buddy-creator/references/host-guide.md +12 -6
- package/resources/buddy-creator/references/recovery.md +6 -3
- package/resources/buddy-creator/references/service.md +2 -0
- package/resources/buddy-creator/references/widgets-protocol.md +83 -0
- package/resources/buddy-creator/references/widgets.md +99 -0
- package/resources/buddy-creator/scripts/buddy_core.py +75 -13
- package/resources/buddy-creator/scripts/completion.py +64 -8
- package/resources/buddy-creator/scripts/preview.py +46 -6
- package/resources/buddy-creator/scripts/widget_render.py +66 -0
- package/resources/buddy-creator/scripts/widget_render_v1.py +176 -0
- package/resources/buddy-creator/scripts/widget_render_v2.py +98 -0
- package/resources/buddy-creator/scripts/widget_render_v3.py +86 -0
- package/resources/buddy-creator/scripts/widget_render_v4.py +121 -0
- package/resources/buddy-creator/scripts/widgets.py +227 -0
- package/resources/buddy-creator/version.json +1 -1
- 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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
record['
|
|
134
|
-
|
|
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(['## 服务模式图', '', '', '',
|
|
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
|
|
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
|
-
|
|
308
|
-
|
|
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
|
-
|
|
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'])
|