@my-life-buddies/cli 0.14.1 → 0.15.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 +63 -15
- package/dist/bin/buddy.js +28 -9
- package/dist/bin/buddy.js.map +2 -2
- package/dist/bin/core.js +1293 -603
- package/dist/bin/core.js.map +4 -4
- package/dist/bin/preview.js +43 -8
- package/dist/bin/preview.js.map +2 -2
- package/dist/web/app.js +11 -6
- package/dist/web/widgets.css +59 -28
- package/dist/web/widgets.js +230 -76
- package/package.json +3 -3
- package/resources/README.md +6 -50
- package/resources/buddy-creator/INSTALL.md +0 -25
- package/resources/buddy-creator/PATCHES.md +0 -53
- package/resources/buddy-creator/SKILL.md +0 -76
- package/resources/buddy-creator/THIRD_PARTY_NOTICES.md +0 -154
- package/resources/buddy-creator/agents/openai.yaml +0 -4
- package/resources/buddy-creator/assets/licenses/bail.txt +0 -26
- package/resources/buddy-creator/assets/licenses/ccount.txt +0 -26
- package/resources/buddy-creator/assets/licenses/character-entities-html4.txt +0 -26
- package/resources/buddy-creator/assets/licenses/character-entities-legacy.txt +0 -26
- package/resources/buddy-creator/assets/licenses/character-entities.txt +0 -26
- package/resources/buddy-creator/assets/licenses/character-reference-invalid.txt +0 -26
- package/resources/buddy-creator/assets/licenses/comma-separated-tokens.txt +0 -26
- package/resources/buddy-creator/assets/licenses/debug.txt +0 -24
- package/resources/buddy-creator/assets/licenses/decode-named-character-reference.txt +0 -26
- package/resources/buddy-creator/assets/licenses/dequal.txt +0 -25
- package/resources/buddy-creator/assets/licenses/devlop.txt +0 -26
- package/resources/buddy-creator/assets/licenses/escape-string-regexp.txt +0 -13
- package/resources/buddy-creator/assets/licenses/estree-util-is-identifier-name.txt +0 -26
- package/resources/buddy-creator/assets/licenses/extend.txt +0 -27
- package/resources/buddy-creator/assets/licenses/hast-util-to-jsx-runtime.txt +0 -26
- package/resources/buddy-creator/assets/licenses/hast-util-whitespace.txt +0 -26
- package/resources/buddy-creator/assets/licenses/html-url-attributes.txt +0 -25
- package/resources/buddy-creator/assets/licenses/inline-style-parser.txt +0 -13
- package/resources/buddy-creator/assets/licenses/is-alphabetical.txt +0 -26
- package/resources/buddy-creator/assets/licenses/is-alphanumerical.txt +0 -26
- package/resources/buddy-creator/assets/licenses/is-decimal.txt +0 -26
- package/resources/buddy-creator/assets/licenses/is-hexadecimal.txt +0 -26
- package/resources/buddy-creator/assets/licenses/is-plain-obj.txt +0 -13
- package/resources/buddy-creator/assets/licenses/longest-streak.txt +0 -26
- package/resources/buddy-creator/assets/licenses/markdown-table.txt +0 -26
- package/resources/buddy-creator/assets/licenses/mdast-util-find-and-replace.txt +0 -26
- package/resources/buddy-creator/assets/licenses/mdast-util-from-markdown.txt +0 -26
- package/resources/buddy-creator/assets/licenses/mdast-util-gfm-autolink-literal.txt +0 -26
- package/resources/buddy-creator/assets/licenses/mdast-util-gfm-footnote.txt +0 -26
- package/resources/buddy-creator/assets/licenses/mdast-util-gfm-strikethrough.txt +0 -26
- package/resources/buddy-creator/assets/licenses/mdast-util-gfm-table.txt +0 -26
- package/resources/buddy-creator/assets/licenses/mdast-util-gfm-task-list-item.txt +0 -26
- package/resources/buddy-creator/assets/licenses/mdast-util-gfm.txt +0 -26
- package/resources/buddy-creator/assets/licenses/mdast-util-mdx-expression.txt +0 -26
- package/resources/buddy-creator/assets/licenses/mdast-util-mdx-jsx.txt +0 -26
- package/resources/buddy-creator/assets/licenses/mdast-util-mdxjs-esm.txt +0 -26
- package/resources/buddy-creator/assets/licenses/mdast-util-phrasing.txt +0 -27
- package/resources/buddy-creator/assets/licenses/mdast-util-to-hast.txt +0 -26
- package/resources/buddy-creator/assets/licenses/mdast-util-to-markdown.txt +0 -26
- package/resources/buddy-creator/assets/licenses/mdast-util-to-string.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-core-commonmark.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-extension-gfm-autolink-literal.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-extension-gfm-footnote.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-extension-gfm-strikethrough.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-extension-gfm-table.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-extension-gfm-tagfilter.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-extension-gfm-task-list-item.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-extension-gfm.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-factory-destination.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-factory-label.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-factory-space.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-factory-title.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-factory-whitespace.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-util-character.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-util-chunked.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-util-classify-character.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-util-combine-extensions.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-util-decode-numeric-character-reference.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-util-decode-string.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-util-encode.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-util-html-tag-name.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-util-normalize-identifier.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-util-resolve-all.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-util-sanitize-uri.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-util-subtokenize.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-util-symbol.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark-util-types.txt +0 -26
- package/resources/buddy-creator/assets/licenses/micromark.txt +0 -26
- package/resources/buddy-creator/assets/licenses/ms.txt +0 -25
- package/resources/buddy-creator/assets/licenses/parse-entities.txt +0 -26
- package/resources/buddy-creator/assets/licenses/phosphor-icons--react.txt +0 -25
- package/resources/buddy-creator/assets/licenses/property-information.txt +0 -26
- package/resources/buddy-creator/assets/licenses/react-dom.txt +0 -25
- package/resources/buddy-creator/assets/licenses/react-markdown.txt +0 -25
- package/resources/buddy-creator/assets/licenses/react.txt +0 -25
- package/resources/buddy-creator/assets/licenses/remark-gfm.txt +0 -26
- package/resources/buddy-creator/assets/licenses/remark-parse.txt +0 -25
- package/resources/buddy-creator/assets/licenses/remark-rehype.txt +0 -26
- package/resources/buddy-creator/assets/licenses/remark-stringify.txt +0 -25
- package/resources/buddy-creator/assets/licenses/scheduler.txt +0 -25
- package/resources/buddy-creator/assets/licenses/space-separated-tokens.txt +0 -26
- package/resources/buddy-creator/assets/licenses/stringify-entities.txt +0 -26
- package/resources/buddy-creator/assets/licenses/style-to-js.txt +0 -26
- package/resources/buddy-creator/assets/licenses/style-to-object.txt +0 -26
- package/resources/buddy-creator/assets/licenses/trim-lines.txt +0 -26
- package/resources/buddy-creator/assets/licenses/trough.txt +0 -25
- package/resources/buddy-creator/assets/licenses/types--debug.txt +0 -25
- package/resources/buddy-creator/assets/licenses/types--estree-jsx.txt +0 -25
- package/resources/buddy-creator/assets/licenses/types--estree.txt +0 -25
- package/resources/buddy-creator/assets/licenses/types--hast.txt +0 -25
- package/resources/buddy-creator/assets/licenses/types--mdast.txt +0 -25
- package/resources/buddy-creator/assets/licenses/types--ms.txt +0 -25
- package/resources/buddy-creator/assets/licenses/types--unist.txt +0 -25
- package/resources/buddy-creator/assets/licenses/ungap--structured-clone.txt +0 -19
- package/resources/buddy-creator/assets/licenses/unified.txt +0 -25
- package/resources/buddy-creator/assets/licenses/unist-util-is.txt +0 -26
- package/resources/buddy-creator/assets/licenses/unist-util-position.txt +0 -26
- package/resources/buddy-creator/assets/licenses/unist-util-stringify-position.txt +0 -26
- package/resources/buddy-creator/assets/licenses/unist-util-visit-parents.txt +0 -26
- package/resources/buddy-creator/assets/licenses/unist-util-visit.txt +0 -26
- package/resources/buddy-creator/assets/licenses/vfile-message.txt +0 -26
- package/resources/buddy-creator/assets/licenses/vfile.txt +0 -25
- package/resources/buddy-creator/assets/licenses/zwitch.txt +0 -26
- package/resources/buddy-creator/assets/preview/assets/index-B5VmkoSx.js +0 -39
- package/resources/buddy-creator/assets/preview/assets/index-DWKP6b-3.css +0 -4
- package/resources/buddy-creator/assets/preview/brand-logo.png +0 -0
- package/resources/buddy-creator/assets/preview/brand.css +0 -100
- package/resources/buddy-creator/assets/preview/index.html +0 -7
- package/resources/buddy-creator/assets/preview/reader.css +0 -165
- package/resources/buddy-creator/assets/preview/reader.js +0 -135
- package/resources/buddy-creator/assets/preview/widgets.css +0 -1
- package/resources/buddy-creator/assets/preview/widgets.js +0 -168
- 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 +0 -62
- package/resources/buddy-creator/assets/widget-v2/style.css +0 -1
- package/resources/buddy-creator/assets/widget-v3/app.js +0 -65
- package/resources/buddy-creator/assets/widget-v3/style.css +0 -103
- package/resources/buddy-creator/assets/widget-v4/app.js +0 -66
- package/resources/buddy-creator/assets/widget-v4/style.css +0 -187
- package/resources/buddy-creator/references/artifact-schema.md +0 -70
- package/resources/buddy-creator/references/catalog.json +0 -401
- package/resources/buddy-creator/references/definition.md +0 -24
- package/resources/buddy-creator/references/dialogue-examples.md +0 -133
- package/resources/buddy-creator/references/host-guide.md +0 -219
- package/resources/buddy-creator/references/interview.md +0 -235
- package/resources/buddy-creator/references/knowledge.md +0 -40
- package/resources/buddy-creator/references/methods.md +0 -77
- package/resources/buddy-creator/references/opening.md +0 -14
- package/resources/buddy-creator/references/preview-panel.md +0 -66
- package/resources/buddy-creator/references/recovery.md +0 -46
- package/resources/buddy-creator/references/service.md +0 -76
- package/resources/buddy-creator/references/widgets-protocol.md +0 -83
- package/resources/buddy-creator/references/widgets.md +0 -99
- package/resources/buddy-creator/scripts/buddy.py +0 -108
- package/resources/buddy-creator/scripts/buddy_client.py +0 -67
- package/resources/buddy-creator/scripts/buddy_core.py +0 -865
- package/resources/buddy-creator/scripts/completion.py +0 -296
- package/resources/buddy-creator/scripts/interview.py +0 -215
- package/resources/buddy-creator/scripts/preview.py +0 -617
- package/resources/buddy-creator/scripts/preview_panel.py +0 -80
- package/resources/buddy-creator/scripts/sources.py +0 -239
- package/resources/buddy-creator/scripts/widget_render.py +0 -66
- package/resources/buddy-creator/scripts/widget_render_v1.py +0 -176
- package/resources/buddy-creator/scripts/widget_render_v2.py +0 -98
- package/resources/buddy-creator/scripts/widget_render_v3.py +0 -86
- package/resources/buddy-creator/scripts/widget_render_v4.py +0 -121
- package/resources/buddy-creator/scripts/widgets.py +0 -227
- package/resources/buddy-creator/version.json +0 -8
- package/resources/buddy-creator.manifest.json +0 -791
- package/resources/dada-object-avatar/SKILL.md +0 -37
- package/resources/dada-object-avatar/agents/openai.yaml +0 -4
- package/resources/dada-object-avatar/assets/examples/cat.png +0 -0
- package/resources/dada-object-avatar/assets/examples/cooking.png +0 -0
- package/resources/dada-object-avatar/assets/examples/divination.png +0 -0
- package/resources/dada-object-avatar/assets/examples/doctor.png +0 -0
- package/resources/dada-object-avatar/assets/examples/relationship.png +0 -0
- package/resources/dada-object-avatar/assets/examples/work.png +0 -0
- package/resources/dada-object-avatar/assets/preview.png +0 -0
- package/resources/dada-object-avatar/references/evaluation-rubric.md +0 -22
- package/resources/dada-object-avatar/references/examples.json +0 -8
- package/resources/dada-object-avatar/references/prompt-template.md +0 -27
- package/resources/dada-object-avatar/references/style-system.md +0 -54
- package/resources/dada-object-avatar/references/subject-design.md +0 -23
- package/resources/dada-object-avatar/references/training-guide.md +0 -14
- package/resources/dada-object-avatar/references/training-log.jsonl +0 -0
- package/resources/dada-object-avatar/references/training-record.schema.json +0 -20
- package/resources/dada-object-avatar/scripts/choose_gaze.py +0 -36
- package/resources/dada-object-avatar/scripts/record_example.py +0 -22
- package/resources/dada-object-avatar/scripts/validate_png.py +0 -52
- package/resources/dada-object-avatar.manifest.json +0 -85
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
"""Host-observed panel presence and a durable, at-most-once reopen offer.
|
|
2
|
-
|
|
3
|
-
This module cannot see browser tabs. It records the host's observations;
|
|
4
|
-
it never opens a UI or interprets an interview answer as consent.
|
|
5
|
-
"""
|
|
6
|
-
import json
|
|
7
|
-
from datetime import datetime, timezone
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
|
|
10
|
-
import buddy_core as core
|
|
11
|
-
|
|
12
|
-
VISIBILITIES = {'open', 'closed', 'unknown'}
|
|
13
|
-
REMINDERS = {'not_offered', 'offered', 'accepted', 'declined', 'ignored'}
|
|
14
|
-
SOURCES = {'host_inventory', 'user_report', 'unavailable'}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def _load(path, workspace_id):
|
|
18
|
-
if not path.exists():
|
|
19
|
-
return {'workspaceId': workspace_id, 'visibility': 'unknown', 'source': 'unavailable',
|
|
20
|
-
'observedAt': None, 'reminder': 'not_offered', 'offeredAt': None}
|
|
21
|
-
try:
|
|
22
|
-
panel = json.loads(path.read_text(encoding='utf-8'))
|
|
23
|
-
valid = (isinstance(panel, dict) and panel.get('workspaceId') == workspace_id
|
|
24
|
-
and panel.get('visibility') in VISIBILITIES and panel.get('source') in SOURCES
|
|
25
|
-
and panel.get('reminder') in REMINDERS)
|
|
26
|
-
except (OSError, ValueError, TypeError):
|
|
27
|
-
valid = False
|
|
28
|
-
core.require(valid, 'PREVIEW_PANEL_STATE', '面板提醒记录无法读取或不属于本作品;保留记录,停止主动询问。用户仍可明确要求打开面板。')
|
|
29
|
-
return panel
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def _freshly_closed(panel):
|
|
33
|
-
if panel['visibility'] != 'closed' or panel['source'] == 'unavailable':
|
|
34
|
-
return False
|
|
35
|
-
try:
|
|
36
|
-
observed = datetime.fromisoformat(panel['observedAt'].replace('Z', '+00:00'))
|
|
37
|
-
return 0 <= (datetime.now(timezone.utc) - observed).total_seconds() <= 120
|
|
38
|
-
except (KeyError, AttributeError, TypeError, ValueError):
|
|
39
|
-
return False
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def call(workspace, request):
|
|
43
|
-
core.require(isinstance(request, dict), 'PREVIEW_PANEL_INPUT', '面板请求必须为 JSON 对象。')
|
|
44
|
-
action = request.get('action', 'status')
|
|
45
|
-
core.require(isinstance(action, str) and action in {'status', 'observe', 'offer', 'respond'},
|
|
46
|
-
'PREVIEW_PANEL_ACTION', 'action 使用 status、observe、offer 或 respond。')
|
|
47
|
-
workspace = Path(workspace).expanduser().resolve()
|
|
48
|
-
with core.workspace_lock(workspace):
|
|
49
|
-
state = core.load(workspace)
|
|
50
|
-
path = workspace / 'preview-panel.json'
|
|
51
|
-
panel = _load(path, state['workspaceId'])
|
|
52
|
-
should_ask = False
|
|
53
|
-
if action == 'observe':
|
|
54
|
-
visibility, source = request.get('visibility'), request.get('source')
|
|
55
|
-
core.require(isinstance(visibility, str) and visibility in VISIBILITIES
|
|
56
|
-
and isinstance(source, str) and source in SOURCES,
|
|
57
|
-
'PREVIEW_PANEL_OBSERVATION', '提供真实 visibility 和 source;不能用服务连通性推测界面状态。')
|
|
58
|
-
core.require(source != 'unavailable' or visibility == 'unknown',
|
|
59
|
-
'PREVIEW_PANEL_OBSERVATION', '无法检查界面时只能记录 unknown。')
|
|
60
|
-
panel.update(visibility=visibility, source=source, observedAt=core.now())
|
|
61
|
-
elif action == 'offer':
|
|
62
|
-
# Persist before returning permission to ask. A lost receipt must
|
|
63
|
-
# err on the side of silence, never a second unsolicited question.
|
|
64
|
-
should_ask = panel['reminder'] == 'not_offered' and _freshly_closed(panel)
|
|
65
|
-
if should_ask:
|
|
66
|
-
panel.update(reminder='offered', offeredAt=core.now())
|
|
67
|
-
elif action == 'respond':
|
|
68
|
-
response = request.get('response')
|
|
69
|
-
core.require(isinstance(response, str) and response in {'accepted', 'declined', 'ignored'},
|
|
70
|
-
'PREVIEW_PANEL_RESPONSE', 'response 使用 accepted、declined 或 ignored。')
|
|
71
|
-
core.require(response != 'ignored' or panel['reminder'] != 'not_offered',
|
|
72
|
-
'PREVIEW_PANEL_RESPONSE', '尚未询问时不能记为未回应。')
|
|
73
|
-
# Explicit requests/refusals also apply before or after an offer.
|
|
74
|
-
# None of these outcomes re-enables unsolicited offers.
|
|
75
|
-
panel['reminder'] = response
|
|
76
|
-
if action != 'status':
|
|
77
|
-
core.atomic_json(path, panel)
|
|
78
|
-
return {'workspace': str(workspace), 'panel': panel,
|
|
79
|
-
'canOffer': panel['reminder'] == 'not_offered' and _freshly_closed(panel),
|
|
80
|
-
'shouldAsk': should_ask}
|
|
@@ -1,239 +0,0 @@
|
|
|
1
|
-
"""Archive original sources and actual host-tool extracts; no document converters."""
|
|
2
|
-
import hashlib
|
|
3
|
-
import json
|
|
4
|
-
import os
|
|
5
|
-
import shutil
|
|
6
|
-
import tempfile
|
|
7
|
-
from datetime import datetime, timezone
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
from urllib.parse import urlsplit
|
|
10
|
-
|
|
11
|
-
KINDS = {'file', 'webpage', 'history', 'mindmap', 'skill', 'oral', 'scan', 'audio', 'video'}
|
|
12
|
-
TEXT_EXTENSIONS = {'.txt', '.md', '.markdown', '.csv', '.tsv', '.json', '.jsonl', '.yaml', '.yml', '.xml', '.opml', '.mm', '.html', '.htm', '.log'}
|
|
13
|
-
MAX_BYTES = 512 * 1024 * 1024
|
|
14
|
-
MAX_TEXT = 32 * 1024 * 1024
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def digest(value):
|
|
18
|
-
if isinstance(value, str):
|
|
19
|
-
value = value.encode('utf-8')
|
|
20
|
-
return hashlib.sha256(value).hexdigest()
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def encoded(value):
|
|
24
|
-
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(',', ':'), allow_nan=False).encode('utf-8')
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def fail(code, message):
|
|
28
|
-
raise ValueError(code + ': ' + message)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def _part(text, locator):
|
|
32
|
-
if not isinstance(text, str) or not text.strip() or not isinstance(locator, str) or not locator.strip():
|
|
33
|
-
fail('SOURCE_PART', '每段提取结果需要真实原文和来源位置。')
|
|
34
|
-
if len(text.encode('utf-8')) > MAX_TEXT:
|
|
35
|
-
fail('SOURCE_PART_SIZE', '单段资料过大,请按实际来源分段。')
|
|
36
|
-
return {'text': text, 'locator': locator}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def _text(path):
|
|
40
|
-
if path.stat().st_size > MAX_TEXT:
|
|
41
|
-
fail('SOURCE_TEXT_SIZE', '文本过大,请由宿主分段读取后提交 hostResult。')
|
|
42
|
-
try:
|
|
43
|
-
result = path.read_text(encoding='utf-8-sig')
|
|
44
|
-
except UnicodeError:
|
|
45
|
-
fail('HOST_SOURCE_REQUIRED', '资料不是 UTF-8 文本,请由宿主读取后提交 hostResult。')
|
|
46
|
-
if '\x00' in result:
|
|
47
|
-
fail('HOST_SOURCE_REQUIRED', '这份资料需要宿主工具读取,不能当作普通文本。')
|
|
48
|
-
return result
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def _history(text):
|
|
52
|
-
try:
|
|
53
|
-
data = json.loads(text)
|
|
54
|
-
rows = data.get('messages', []) if isinstance(data, dict) else data
|
|
55
|
-
except json.JSONDecodeError:
|
|
56
|
-
try:
|
|
57
|
-
rows = [json.loads(line) for line in text.splitlines() if line.strip()]
|
|
58
|
-
except json.JSONDecodeError:
|
|
59
|
-
fail('HISTORY_FORMAT', '历史资料须为用户选定的可见消息 JSON/JSONL,或由宿主提取可见原文。')
|
|
60
|
-
if not isinstance(rows, list):
|
|
61
|
-
fail('HISTORY_FORMAT', '没有找到选定的可见消息列表。')
|
|
62
|
-
parts = []
|
|
63
|
-
for index, row in enumerate(rows):
|
|
64
|
-
if not isinstance(row, dict) or row.get('role') not in ('user', 'assistant'):
|
|
65
|
-
continue
|
|
66
|
-
if row.get('channel') not in (None, 'final') or row.get('hidden') or row.get('visibility') == 'hidden':
|
|
67
|
-
continue
|
|
68
|
-
content = row.get('content', row.get('text', ''))
|
|
69
|
-
if isinstance(content, list):
|
|
70
|
-
content = '\n'.join(item.get('text', '') for item in content if isinstance(item, dict) and item.get('type') in ('text', 'input_text', 'output_text'))
|
|
71
|
-
if isinstance(content, str) and content.strip():
|
|
72
|
-
parts.append(_part(content, '选定可见消息 %s / %s' % (index + 1, row['role'])))
|
|
73
|
-
if not parts:
|
|
74
|
-
fail('HISTORY_EMPTY', '没有可导入的用户或助手可见正文。')
|
|
75
|
-
return parts
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def _copy_file(path, destination):
|
|
79
|
-
if not path.is_file() or path.is_symlink():
|
|
80
|
-
fail('SOURCE_FILE', '请提供实际文件,不能用符号链接替代原件。')
|
|
81
|
-
size = path.stat().st_size
|
|
82
|
-
if size > MAX_BYTES:
|
|
83
|
-
fail('SOURCE_SIZE', '单份原件超过 512 MiB 归档上限,请选定必要部分。')
|
|
84
|
-
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
-
total = 0
|
|
86
|
-
sha = hashlib.sha256()
|
|
87
|
-
with path.open('rb') as incoming, destination.open('xb') as outgoing:
|
|
88
|
-
while True:
|
|
89
|
-
block = incoming.read(1024 * 1024)
|
|
90
|
-
if not block:
|
|
91
|
-
break
|
|
92
|
-
total += len(block)
|
|
93
|
-
if total > MAX_BYTES:
|
|
94
|
-
fail('SOURCE_SIZE', '读取过程中资料超过大小上限。')
|
|
95
|
-
sha.update(block)
|
|
96
|
-
outgoing.write(block)
|
|
97
|
-
outgoing.flush()
|
|
98
|
-
os.fsync(outgoing.fileno())
|
|
99
|
-
return {'hash': sha.hexdigest(), 'bytes': total}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def import_source(workspace, state, payload):
|
|
103
|
-
"""Called under the workspace writer lock. Returns an immutable manifest."""
|
|
104
|
-
workspace = Path(workspace)
|
|
105
|
-
op_id = payload.get('operationId')
|
|
106
|
-
if not isinstance(op_id, str) or not op_id.strip() or len(op_id) > 256:
|
|
107
|
-
fail('OPERATION_ID_REQUIRED', '资料导入需要稳定的 operationId;重试沿用,补充结果使用新身份。')
|
|
108
|
-
kind = payload.get('kind')
|
|
109
|
-
if kind not in KINDS:
|
|
110
|
-
fail('SOURCE_UNSUPPORTED', '不支持这类来源;此包不包含小红书账号采集。')
|
|
111
|
-
uri = payload.get('uri', '')
|
|
112
|
-
if not isinstance(uri, str):
|
|
113
|
-
fail('SOURCE_URI', '资料位置必须是字符串。')
|
|
114
|
-
if kind == 'webpage':
|
|
115
|
-
url = urlsplit(uri)
|
|
116
|
-
host = (url.hostname or '').lower().rstrip('.')
|
|
117
|
-
if url.scheme not in ('http', 'https') or not host or url.username or url.password:
|
|
118
|
-
fail('SOURCE_URL', '网页来源需要不含账号密码的 HTTP(S) 地址。')
|
|
119
|
-
if any(host == domain or host.endswith('.' + domain) for domain in ('xiaohongshu.com', 'xhslink.com')):
|
|
120
|
-
fail('SOURCE_UNSUPPORTED', '此包不采集小红书网页或账号;用户自行提供的原文可作为本地资料。')
|
|
121
|
-
request_hash = digest(encoded({key: value for key, value in payload.items() if key not in ('operation', 'operationId')}))
|
|
122
|
-
source_id = 'source_' + digest(op_id)[:24]
|
|
123
|
-
directory = workspace / 'sources' / source_id
|
|
124
|
-
manifest_path = directory / 'manifest.json'
|
|
125
|
-
if manifest_path.exists():
|
|
126
|
-
previous = json.loads(manifest_path.read_text(encoding='utf-8'))
|
|
127
|
-
if previous.get('requestHash') != request_hash:
|
|
128
|
-
fail('IDEMPOTENCY_CONFLICT', '同一资料导入身份对应的内容已改变,请为新结果使用新的 operationId。')
|
|
129
|
-
return previous
|
|
130
|
-
host_result = payload.get('hostResult')
|
|
131
|
-
parts, extraction = [], None
|
|
132
|
-
if host_result is not None:
|
|
133
|
-
if not isinstance(host_result, dict) or not isinstance(host_result.get('tool'), str) or not host_result['tool'].strip():
|
|
134
|
-
fail('HOST_SOURCE_TOOL', '请注明实际读取资料的宿主工具。')
|
|
135
|
-
if host_result.get('coverage') not in ('complete', 'partial'):
|
|
136
|
-
fail('HOST_SOURCE_COVERAGE', 'coverage 必须如实填写 complete 或 partial。')
|
|
137
|
-
rows = host_result.get('parts')
|
|
138
|
-
if not isinstance(rows, list) or not rows or len(rows) > 10000:
|
|
139
|
-
fail('HOST_SOURCE_PARTS', '请提供真实提取的正文和定位。')
|
|
140
|
-
for row in rows:
|
|
141
|
-
if not isinstance(row, dict):
|
|
142
|
-
fail('HOST_SOURCE_PARTS', '每段资料必须是 text 和 locator 组成的对象。')
|
|
143
|
-
parts.append(_part(row.get('text'), row.get('locator')))
|
|
144
|
-
notes = host_result.get('notes', [])
|
|
145
|
-
if not isinstance(notes, list) or any(not isinstance(note, str) for note in notes):
|
|
146
|
-
fail('HOST_SOURCE_NOTES', 'notes 须为实际处理说明的文本列表。')
|
|
147
|
-
extraction = {'provider': 'host', 'tool': host_result['tool'], 'coverage': host_result['coverage'], 'notes': notes}
|
|
148
|
-
elif kind in ('webpage', 'scan', 'audio', 'video'):
|
|
149
|
-
fail('HOST_SOURCE_REQUIRED', '请先用宿主实际可用工具读取资料,再提交 hostResult。')
|
|
150
|
-
source_root = workspace / 'sources'
|
|
151
|
-
source_root.mkdir(parents=True, exist_ok=True)
|
|
152
|
-
staging = Path(tempfile.mkdtemp(prefix='.incoming-', dir=str(source_root)))
|
|
153
|
-
files = []
|
|
154
|
-
try:
|
|
155
|
-
if kind == 'webpage':
|
|
156
|
-
raw = encoded({'url': uri, 'hostResult': host_result, 'description': '宿主提取快照,不是完整网页原件'})
|
|
157
|
-
(staging / 'webpage-snapshot.json').write_bytes(raw)
|
|
158
|
-
files.append({'path': 'webpage-snapshot.json', 'hash': digest(raw), 'bytes': len(raw)})
|
|
159
|
-
elif uri:
|
|
160
|
-
path = Path(uri).expanduser()
|
|
161
|
-
if not path.is_absolute():
|
|
162
|
-
fail('SOURCE_ABSOLUTE_PATH', '本地资料请使用绝对路径。')
|
|
163
|
-
if path.is_symlink():
|
|
164
|
-
fail('SOURCE_FILE', '请提供实际原件路径。')
|
|
165
|
-
if path.is_dir() and kind == 'skill':
|
|
166
|
-
total, count = 0, 0
|
|
167
|
-
for folder, dirs, names in os.walk(str(path), followlinks=False):
|
|
168
|
-
dirs[:] = [name for name in sorted(dirs) if name not in ('.git', 'node_modules', '__pycache__', '.venv') and not (Path(folder) / name).is_symlink()]
|
|
169
|
-
for name in sorted(names):
|
|
170
|
-
item = Path(folder) / name
|
|
171
|
-
if item.is_symlink() or item.suffix.lower() not in TEXT_EXTENSIONS:
|
|
172
|
-
continue
|
|
173
|
-
count += 1
|
|
174
|
-
total += item.stat().st_size
|
|
175
|
-
if count > 5000 or total > MAX_BYTES:
|
|
176
|
-
fail('SOURCE_SIZE', 'Skill 资料超出文本归档上限,请选定相关文档。')
|
|
177
|
-
relative = item.relative_to(path).as_posix()
|
|
178
|
-
saved = _copy_file(item, staging / 'original' / relative)
|
|
179
|
-
files.append(dict(saved, path='original/' + relative))
|
|
180
|
-
if host_result is None:
|
|
181
|
-
text = _text(staging / 'original' / relative)
|
|
182
|
-
if text.strip():
|
|
183
|
-
parts.append(_part(text, relative))
|
|
184
|
-
elif path.is_file():
|
|
185
|
-
saved = _copy_file(path, staging / 'original' / path.name)
|
|
186
|
-
files.append(dict(saved, path='original/' + path.name))
|
|
187
|
-
if host_result is None:
|
|
188
|
-
if path.suffix.lower() not in TEXT_EXTENSIONS:
|
|
189
|
-
fail('HOST_SOURCE_REQUIRED', '此格式请先由宿主工具读取,再提交 hostResult。')
|
|
190
|
-
text = _text(staging / 'original' / path.name)
|
|
191
|
-
parts = _history(text) if kind == 'history' else [_part(text, path.name)]
|
|
192
|
-
else:
|
|
193
|
-
fail('SOURCE_FILE', '资料文件不存在,或该来源类型不支持目录。')
|
|
194
|
-
elif payload.get('text') is not None:
|
|
195
|
-
text = payload['text']
|
|
196
|
-
part = _part(text, '创作者提供的原文' if kind == 'oral' else '用户选定原文')
|
|
197
|
-
raw = text.encode('utf-8')
|
|
198
|
-
(staging / 'original.txt').write_bytes(raw)
|
|
199
|
-
files.append({'path': 'original.txt', 'hash': digest(raw), 'bytes': len(raw)})
|
|
200
|
-
if host_result is None:
|
|
201
|
-
parts = _history(text) if kind == 'history' else [part]
|
|
202
|
-
else:
|
|
203
|
-
fail('SOURCE_ORIGINAL_REQUIRED', '请提供本地原件路径、网页地址或用户实际提供的原文。')
|
|
204
|
-
if not parts:
|
|
205
|
-
fail('SOURCE_EMPTY', '资料中没有可归档的可读正文。')
|
|
206
|
-
if sum(len(part['text'].encode('utf-8')) for part in parts) > MAX_BYTES:
|
|
207
|
-
fail('SOURCE_SIZE', '提取结果总量超过 512 MiB,请按实际范围分批。')
|
|
208
|
-
if host_result is not None:
|
|
209
|
-
raw = encoded(host_result)
|
|
210
|
-
(staging / 'host-result.json').write_bytes(raw)
|
|
211
|
-
files.append({'path': 'host-result.json', 'hash': digest(raw), 'bytes': len(raw)})
|
|
212
|
-
chunks = [dict(part, id='chunk_%04d' % (index + 1), hash=digest(part['text'])) for index, part in enumerate(parts)]
|
|
213
|
-
complete = extraction is None or extraction['coverage'] == 'complete'
|
|
214
|
-
title = payload.get('title') or (Path(uri).name if uri and kind != 'webpage' else uri or '口述资料')
|
|
215
|
-
manifest = {'id': source_id, 'version': digest(encoded({'files': files, 'chunks': chunks})),
|
|
216
|
-
'operationId': op_id, 'requestHash': request_hash, 'kind': kind, 'title': str(title), 'uri': uri,
|
|
217
|
-
'status': 'ready' if complete else 'failed', 'files': files, 'chunks': chunks,
|
|
218
|
-
'extraction': extraction, 'warnings': [] if complete else ['仅保存已提取部分,尚未完整读取。'],
|
|
219
|
-
'parser': 'host-tools' if extraction else 'python-text-archive',
|
|
220
|
-
'acquiredAt': datetime.now(timezone.utc).isoformat(),
|
|
221
|
-
'error': None if complete else 'HOST_SOURCE_INCOMPLETE',
|
|
222
|
-
'processing': {'acquisition': 'complete' if complete else 'partial',
|
|
223
|
-
'parsing': 'complete' if complete else 'partial', 'semanticReview': 'requires_host_calibration'}}
|
|
224
|
-
(staging / 'manifest.json').write_bytes(encoded(manifest))
|
|
225
|
-
for file in staging.rglob('*'):
|
|
226
|
-
if file.is_file():
|
|
227
|
-
with file.open('rb') as handle:
|
|
228
|
-
os.fsync(handle.fileno())
|
|
229
|
-
os.replace(str(staging), str(directory))
|
|
230
|
-
if os.name != 'nt':
|
|
231
|
-
fd = os.open(str(source_root), os.O_RDONLY)
|
|
232
|
-
try:
|
|
233
|
-
os.fsync(fd)
|
|
234
|
-
finally:
|
|
235
|
-
os.close(fd)
|
|
236
|
-
return manifest
|
|
237
|
-
except BaseException:
|
|
238
|
-
shutil.rmtree(str(staging), ignore_errors=True)
|
|
239
|
-
raise
|
|
@@ -1,66 +0,0 @@
|
|
|
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'])
|
|
@@ -1,176 +0,0 @@
|
|
|
1
|
-
"""Deterministic, portable read-only prototypes. No supplied executable H5."""
|
|
2
|
-
import hashlib
|
|
3
|
-
import html
|
|
4
|
-
import json
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
|
|
7
|
-
import buddy_core as core
|
|
8
|
-
|
|
9
|
-
CSS = '''*{box-sizing:border-box}html,body{margin:0;width:100%;height:100%;font:400 16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#222;background:#fff}button{font:inherit;color:inherit;cursor:pointer}button:focus-visible,a:focus-visible,summary:focus-visible{outline:3px solid var(--accent);outline-offset:3px}header{height:88px;display:flex;align-items:center;justify-content:space-between;padding:28px 20px 12px;font-weight:500}main{padding:12px 16px 100px}.bubble{background:#f1f3f4;border-radius:18px;padding:14px 16px;margin:12px 0;max-width:calc(100% - 36px);white-space:pre-wrap;overflow-wrap:anywhere}.user{margin-left:auto;background:#e5f6fc}.buddy{margin-left:32px}.chat-card{margin:16px 0 0 32px;border:1px solid #eee;border-radius:18px;padding:16px;max-width:calc(100% - 32px)}h1,h2,h3,p{margin:0}h1,h2,h3{font-weight:500;font-size:var(--title-size)}.chat-card p{margin:12px 0;white-space:pre-wrap;overflow-wrap:anywhere}.open{width:100%;background:#f1f3f4;border:0;border-radius:12px;padding:9px}.caption{color:#73777c;font-size:13px;margin-top:12px}.composer{position:fixed;bottom:20px;left:16px;right:16px;background:#f7f8f9;border:1px solid #eee;border-radius:14px;padding:12px;color:#767b80;font-size:14px}.overlay{position:fixed;inset:0;background:#0003;display:grid;place-items:center}.overlay[hidden],.detail[hidden]{display:none}.sheet{width:calc(100% - 32px);height:65%;background:#fff;border-radius:24px;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 20px 80px #0001}.sheet-head{display:flex;align-items:center;gap:12px;padding:16px;flex:none;border-bottom:1px solid #f1f1f1}.sheet-head h2{flex:1;text-align:center;overflow-wrap:anywhere}.close{border:1px solid #eee;background:#fff;border-radius:50%;width:36px;height:36px;flex:none}.spacer{width:36px}.scroll{overflow-y:auto;overscroll-behavior:contain;padding:16px;flex:1;min-height:0}.tabs,.filters{display:flex;gap:8px;overflow-x:auto;padding:2px 2px 14px;flex:none}.tabs button,.filters button{flex:none;border:1px solid #eceff1;background:#f1f3f4;border-radius:28px;padding:10px 16px;font-size:16px}.tabs button[aria-pressed=true],.filters button[aria-pressed=true]{border:2px solid var(--accent);padding:9px 15px;font-weight:500}.section{margin:8px 0 22px}.section h3,summary{margin:0 0 12px;font-size:16px;font-weight:500}summary{cursor:pointer}.row{padding:10px 0;border-bottom:1px solid #f2f3f4;overflow-wrap:anywhere}.row-label{color:#777;margin-bottom:4px}.row-value{white-space:pre-wrap}.detail-open{border:0;background:#f1f3f4;border-radius:8px;padding:6px 10px;margin-top:8px;font-size:14px}.cards .row{padding:14px;border-radius:14px;background:#f7f8f9;margin-bottom:10px;border:0}.timeline .row{border-left:2px solid var(--accent);padding-left:16px;margin-left:4px;border-bottom:0}.detail{position:absolute;inset:0;background:#0003;display:grid;place-items:center}.detail-box{width:calc(100% - 32px);max-height:60%;overflow:auto;border-radius:20px;padding:20px;background:#fff;white-space:pre-wrap;overflow-wrap:anywhere}.detail-box button{float:right}.note{font-size:14px;color:#73777c;margin-top:20px}.screen-title{max-width:75%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}@media(prefers-reduced-motion:no-preference){button{transition:background .15s ease}}'''
|
|
10
|
-
|
|
11
|
-
JS = '''"use strict";
|
|
12
|
-
const data=JSON.parse(document.getElementById("prototype-data").textContent);
|
|
13
|
-
const params=new URLSearchParams(location.search);
|
|
14
|
-
const index=Math.max(0,data.states.findIndex(s=>s.id===params.get("state")));
|
|
15
|
-
const sample=data.states[index], interactions=data.interactions;
|
|
16
|
-
const make=(tag,text,cls)=>{const e=document.createElement(tag);if(text!==undefined)e.textContent=text;if(cls)e.className=cls;return e;};
|
|
17
|
-
document.documentElement.style.setProperty("--accent",data.design.accent);
|
|
18
|
-
document.documentElement.style.setProperty("--title-size",data.design.titleSize+"px");
|
|
19
|
-
document.querySelector(".screen-title").textContent=data.name;
|
|
20
|
-
const main=document.querySelector("main");
|
|
21
|
-
main.append(make("div",sample.userMessage,"bubble user"),make("div",sample.buddyMessage,"bubble buddy"));
|
|
22
|
-
const card=make("article",undefined,"chat-card");card.append(make("h1",data.name),make("p",sample.summary));
|
|
23
|
-
const open=make("button","去查看","open");card.append(open,make("div","内容随对话更新","caption"));main.append(card);
|
|
24
|
-
const overlay=document.querySelector(".overlay"),scroll=document.querySelector(".scroll"),detail=document.querySelector(".detail");
|
|
25
|
-
document.querySelector(".sheet-head h2").textContent=data.name;
|
|
26
|
-
scroll.classList.add(data.design.layout);
|
|
27
|
-
let selected=interactions.includes("tabs")?sample.sections[0].id:"all";
|
|
28
|
-
function render(){scroll.replaceChildren();
|
|
29
|
-
for(const mode of ["tabs","filter"]){if(!interactions.includes(mode))continue;
|
|
30
|
-
const nav=make("nav",undefined,mode==="tabs"?"tabs":"filters");nav.setAttribute("aria-label",mode==="tabs"?"切换阅读分组":"筛选已有内容");
|
|
31
|
-
const options=mode==="filter"?[{id:"all",title:"全部"},...sample.sections]:sample.sections;
|
|
32
|
-
for(const section of options){const b=make("button",section.title);b.setAttribute("aria-pressed",String(section.id===selected));b.onclick=()=>{selected=section.id;render();};nav.append(b);}scroll.append(nav);}
|
|
33
|
-
for(const section of sample.sections){if(selected!=="all"&§ion.id!==selected)continue;
|
|
34
|
-
const group=make(interactions.includes("collapse")?"details":"section",undefined,"section");
|
|
35
|
-
if(group.tagName==="DETAILS")group.open=true;
|
|
36
|
-
group.append(make(group.tagName==="DETAILS"?"summary":"h3",section.title));
|
|
37
|
-
for(const row of section.rows){const cell=make("div",undefined,"row");cell.append(make("div",row.label,"row-label"),make("div",row.value,"row-value"));
|
|
38
|
-
if(row.detail){const b=make("button","查看说明","detail-open");b.onclick=()=>{document.querySelector(".detail-text").textContent=row.detail;detail.hidden=false;detail.querySelector("button").focus();detail.returnFocus=b;};cell.append(b);}group.append(cell);}scroll.append(group);}
|
|
39
|
-
scroll.append(make("p","如需修改内容,请回到对话中告诉搭子。","note"));}
|
|
40
|
-
function closeDetail(){detail.hidden=true;if(detail.returnFocus)detail.returnFocus.focus();}
|
|
41
|
-
function closeSheet(){closeDetail();overlay.hidden=true;open.focus();}
|
|
42
|
-
open.onclick=()=>{overlay.hidden=false;overlay.querySelector("button").focus();};
|
|
43
|
-
document.querySelector(".sheet-close").onclick=closeSheet;
|
|
44
|
-
document.querySelector(".detail-close").onclick=closeDetail;
|
|
45
|
-
document.addEventListener("keydown",e=>{if(e.key==="Escape"){if(!detail.hidden)closeDetail();else if(!overlay.hidden)closeSheet();}
|
|
46
|
-
if(e.key==="Tab"&&!overlay.hidden){const area=detail.hidden?document.querySelector(".sheet"):document.querySelector(".detail-box");const nodes=[...area.querySelectorAll("button,summary")].filter(n=>n.getClientRects().length);const first=nodes[0],last=nodes[nodes.length-1];if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus();}else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus();}}});
|
|
47
|
-
render();overlay.hidden=params.get("view")!=="expanded";
|
|
48
|
-
'''
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def _svg(data, sample, expanded):
|
|
52
|
-
"""Export two annotated visual views per data state; HTML is the full scrollable view."""
|
|
53
|
-
d = data['design']
|
|
54
|
-
w, h = d['screenWidth'], d['screenHeight']
|
|
55
|
-
parts = ['<svg xmlns="http://www.w3.org/2000/svg" width="%s" height="%s" viewBox="0 0 %s %s"><rect width="100%%" height="100%%" fill="white"/><g font-family="-apple-system, sans-serif" font-size="16" fill="#222">' % (w, h, w, h)]
|
|
56
|
-
def rect(x, y, width, height, fill, radius=16):
|
|
57
|
-
parts.append('<rect x="%s" y="%s" width="%s" height="%s" rx="%s" fill="%s"/>' % (x, y, width, height, radius, fill))
|
|
58
|
-
def lines(text, x, y, width, limit=4, size=16, weight=400):
|
|
59
|
-
chars, current, measured = [], '', 0
|
|
60
|
-
for ch in text:
|
|
61
|
-
measure = size if ord(ch) > 127 else size * .55
|
|
62
|
-
if measured + measure > width or ch == '\n':
|
|
63
|
-
chars.append(current); current, measured = '', 0
|
|
64
|
-
if ch != '\n':
|
|
65
|
-
current += ch; measured += measure
|
|
66
|
-
if current:
|
|
67
|
-
chars.append(current)
|
|
68
|
-
if len(chars) > limit:
|
|
69
|
-
chars = chars[:limit]; chars[-1] = chars[-1][:-1] + '…'
|
|
70
|
-
for i, line in enumerate(chars):
|
|
71
|
-
parts.append('<text x="%s" y="%s" font-size="%s" font-weight="%s">%s</text>' % (x, y + i * (size + 8), size, weight, html.escape(line)))
|
|
72
|
-
return y + len(chars) * (size + 8)
|
|
73
|
-
lines('9:41', 24, 28, 70, 1, 14, 500)
|
|
74
|
-
lines(data['name'], 48, 73, w-96, 1, 16, 500)
|
|
75
|
-
rect(64, 106, w-80, 82, '#e5f6fc'); lines(sample['userMessage'], 80, 133, w-112, 2)
|
|
76
|
-
rect(48, 204, w-64, 106, '#f1f3f4'); lines(sample['buddyMessage'], 64, 230, w-96, 3)
|
|
77
|
-
rect(48, 326, w-64, 178, '#fafafa'); lines(data['name'], 64, 354, w-96, 1, d['titleSize'], 500)
|
|
78
|
-
lines(sample['summary'], 64, 386, w-96, 2)
|
|
79
|
-
rect(64, 442, w-96, 42, '#eff1f3', 10); lines('去查看', w/2-24, 469, 80, 1)
|
|
80
|
-
rect(16, h-80, w-32, 52, '#f6f7f8', 12); lines('在对话中告诉搭子你的修改', 32, h-48, w-64, 1, 14)
|
|
81
|
-
if expanded:
|
|
82
|
-
parts.append('<rect width="100%" height="100%" fill="#000" opacity=".18"/>')
|
|
83
|
-
height = h*.65; top = (h-height)/2
|
|
84
|
-
rect(16, top, w-32, height, '#fff', 24)
|
|
85
|
-
lines('×', 32, top+38, 28, 1, 20); lines(data['name'], 76, top+37, w-128, 1, d['titleSize'], 500)
|
|
86
|
-
parts.append('<defs><clipPath id="content"><rect x="32" y="%s" width="%s" height="%s"/></clipPath></defs><g clip-path="url(#content)">' % (top+64, w-64, height-100))
|
|
87
|
-
y = top+89
|
|
88
|
-
if 'tabs' in data['interactions'] or 'filter' in data['interactions']:
|
|
89
|
-
x=32
|
|
90
|
-
for section in sample['sections'][:3]:
|
|
91
|
-
rect(x, y-21, 84, 42, '#f1f3f4', 20); lines(section['title'], x+10, y+5, 64, 1); x+=92
|
|
92
|
-
y+=54
|
|
93
|
-
sections = sample['sections'][:1] if 'tabs' in data['interactions'] else sample['sections']
|
|
94
|
-
for section in sections:
|
|
95
|
-
y=lines(section['title'], 32, y, w-64, 2, 16, 500)+8
|
|
96
|
-
for row in section['rows']:
|
|
97
|
-
x, width = 32, w-64
|
|
98
|
-
if d['layout'] == 'cards':
|
|
99
|
-
rect(32, y-18, w-64, 116 if row.get('detail') else 92, '#f7f8f9', 14)
|
|
100
|
-
x, width, y = 46, w-92, y+4
|
|
101
|
-
elif d['layout'] == 'timeline':
|
|
102
|
-
rect(36, y-16, 2, 84, d['accent'], 0)
|
|
103
|
-
x, width = 52, w-84
|
|
104
|
-
parts.append('<g fill="#777">')
|
|
105
|
-
y=lines(row['label'], x, y, width, 1)+2
|
|
106
|
-
parts.append('</g>')
|
|
107
|
-
y=lines(row['value'], x, y, width, 2)
|
|
108
|
-
if row.get('detail'):
|
|
109
|
-
y=lines('查看说明', x, y+4, width, 1, 14)
|
|
110
|
-
y+=22
|
|
111
|
-
parts.append('</g>')
|
|
112
|
-
lines('上下滚动阅读 · 修改请通过对话', 32, top+height-20, w-64, 1, 14)
|
|
113
|
-
parts.append('</g></svg>')
|
|
114
|
-
return ''.join(parts).encode('utf-8')
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def files(artifact, item=None):
|
|
118
|
-
data = artifact['data']
|
|
119
|
-
# The exact template contract travels with the prototype; callers supply the confirmed item.
|
|
120
|
-
item = item or artifact.get('renderItem')
|
|
121
|
-
core.require(item, 'WIDGET_RENDER_ITEM', '生成原型需要对应清单项。')
|
|
122
|
-
value = {**data, 'name': item['name'], 'interactions': item['readInteractions']}
|
|
123
|
-
raw_json = json.dumps(value, ensure_ascii=False).replace('<', '\\u003c').replace('>', '\\u003e').replace('&', '\\u0026')
|
|
124
|
-
page = '''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>小挂件原型</title><link rel="stylesheet" href="style.css"></head><body><header><span>‹</span><span class="screen-title"></span><span>···</span></header><main></main><div class="composer">在对话中告诉搭子你的修改</div><div class="overlay" hidden><section class="sheet" role="dialog" aria-modal="true" aria-label="小挂件展开页"><div class="sheet-head"><button class="close sheet-close" aria-label="关闭小挂件">×</button><h2></h2><span class="spacer"></span></div><div class="scroll"></div></section><div class="detail" hidden><section class="detail-box" role="dialog" aria-modal="true" aria-label="只读详情"><button class="close detail-close" aria-label="关闭详情">×</button><p class="detail-text"></p></section></div></div><script type="application/json" id="prototype-data">''' + raw_json + '''</script><script src="app.js"></script></body></html>'''
|
|
125
|
-
result = {'index.html': page.encode('utf-8'), 'style.css': CSS.encode('utf-8'), 'app.js': JS.encode('utf-8')}
|
|
126
|
-
for sample in data['states']:
|
|
127
|
-
for view in ('chat', 'expanded'):
|
|
128
|
-
result[view+'-'+sample['id']+'.svg'] = _svg(value, sample, view == 'expanded')
|
|
129
|
-
return result
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
def directory(workspace, artifact):
|
|
133
|
-
root = Path(workspace) / 'widget-assets'
|
|
134
|
-
path = root / artifact['hash']
|
|
135
|
-
core.require(re_full_hash(artifact['hash']) and not root.is_symlink() and not path.is_symlink(), 'WIDGET_ASSET_PATH', '小挂件资源目录无效。')
|
|
136
|
-
return path
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def re_full_hash(value):
|
|
140
|
-
return isinstance(value, str) and len(value) == 64 and all(c in '0123456789abcdef' for c in value)
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
def render(workspace, state, artifact):
|
|
144
|
-
import widgets
|
|
145
|
-
item = next(i for i in widgets.items(state) if i['id'] == artifact['data']['widgetId'])
|
|
146
|
-
output = directory(workspace, artifact)
|
|
147
|
-
output.mkdir(parents=True, exist_ok=True)
|
|
148
|
-
from completion import _atomic
|
|
149
|
-
for name, raw in files(artifact, item).items():
|
|
150
|
-
core.require(not (output / name).is_symlink(), 'WIDGET_ASSET_PATH', '资源文件不能为符号链接。')
|
|
151
|
-
_atomic(output / name, raw)
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
def asset_status(workspace, artifact, state=None):
|
|
155
|
-
if not artifact:
|
|
156
|
-
return {'pass': False, 'message': '原型尚未生成'}
|
|
157
|
-
try:
|
|
158
|
-
import widgets
|
|
159
|
-
state = state or core.load(workspace)
|
|
160
|
-
core.require(artifact['data'].get('rendererVersion') == 1, 'WIDGET_RENDERER_VERSION', '原型模板版本已变化,需要重新生成并确认设计。')
|
|
161
|
-
item = next(i for i in widgets.items(state) if i['id'] == artifact['data']['widgetId'])
|
|
162
|
-
core.require(widgets.item_hash(item) == artifact['data']['itemHash'], 'WIDGET_ITEM_STALE', '清单项已更新,需要重绘原型。')
|
|
163
|
-
output = directory(workspace, artifact)
|
|
164
|
-
expected = files(artifact, item)
|
|
165
|
-
for name, raw in expected.items():
|
|
166
|
-
path = output / name
|
|
167
|
-
core.require(not path.is_symlink() and path.read_bytes() == raw, 'WIDGET_ASSET_CHANGED', '原型资源缺失或被修改,请用 widget_render 恢复当前版本。', {'file': name})
|
|
168
|
-
return {'pass': True, 'message': '尺寸、字体、只读控件及两视图三态资源检查通过;内容和阅读体验由开发者单独验收。',
|
|
169
|
-
'files': [{'name': name, 'sha256': hashlib.sha256(raw).hexdigest()} for name, raw in expected.items()]}
|
|
170
|
-
except (core.BuddyError, OSError, StopIteration, KeyError, ValueError) as error:
|
|
171
|
-
return {'pass': False, 'message': str(error) or '当前资源不可用,请重新生成原型。'}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
def require_assets(workspace, state, artifact):
|
|
175
|
-
status = asset_status(workspace, artifact, state)
|
|
176
|
-
core.require(status['pass'], 'WIDGET_ASSETS_REQUIRED', status['message'])
|