@my-life-buddies/cli 0.1.2
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 +314 -0
- package/dist/bin/buddy.js +443 -0
- package/dist/bin/buddy.js.map +7 -0
- package/dist/bin/core.js +5638 -0
- package/dist/bin/core.js.map +7 -0
- package/dist/bin/preview.js +1429 -0
- package/dist/bin/preview.js.map +7 -0
- package/dist/web/app.css +508 -0
- package/dist/web/app.js +724 -0
- package/dist/web/assets/developer-platform-icon.png.base64 +1 -0
- package/dist/web/brand.css +100 -0
- package/dist/web/index.html +236 -0
- package/dist/web/tokens.css +15 -0
- package/package.json +45 -0
- package/resources/README.md +52 -0
- package/resources/agent-template/package-lock.json +1726 -0
- package/resources/agent-template/package.json +16 -0
- package/resources/brand/README.md +11 -0
- package/resources/brand/login-result.css +25 -0
- package/resources/brand/logo.png +0 -0
- package/resources/brand/tokens.css +100 -0
- package/resources/buddy-creator/INSTALL.md +25 -0
- package/resources/buddy-creator/PATCHES.md +33 -0
- package/resources/buddy-creator/SKILL.md +66 -0
- package/resources/buddy-creator/THIRD_PARTY_NOTICES.md +154 -0
- package/resources/buddy-creator/agents/openai.yaml +4 -0
- package/resources/buddy-creator/assets/licenses/bail.txt +26 -0
- package/resources/buddy-creator/assets/licenses/ccount.txt +26 -0
- package/resources/buddy-creator/assets/licenses/character-entities-html4.txt +26 -0
- package/resources/buddy-creator/assets/licenses/character-entities-legacy.txt +26 -0
- package/resources/buddy-creator/assets/licenses/character-entities.txt +26 -0
- package/resources/buddy-creator/assets/licenses/character-reference-invalid.txt +26 -0
- package/resources/buddy-creator/assets/licenses/comma-separated-tokens.txt +26 -0
- package/resources/buddy-creator/assets/licenses/debug.txt +24 -0
- package/resources/buddy-creator/assets/licenses/decode-named-character-reference.txt +26 -0
- package/resources/buddy-creator/assets/licenses/dequal.txt +25 -0
- package/resources/buddy-creator/assets/licenses/devlop.txt +26 -0
- package/resources/buddy-creator/assets/licenses/escape-string-regexp.txt +13 -0
- package/resources/buddy-creator/assets/licenses/estree-util-is-identifier-name.txt +26 -0
- package/resources/buddy-creator/assets/licenses/extend.txt +27 -0
- package/resources/buddy-creator/assets/licenses/hast-util-to-jsx-runtime.txt +26 -0
- package/resources/buddy-creator/assets/licenses/hast-util-whitespace.txt +26 -0
- package/resources/buddy-creator/assets/licenses/html-url-attributes.txt +25 -0
- package/resources/buddy-creator/assets/licenses/inline-style-parser.txt +13 -0
- package/resources/buddy-creator/assets/licenses/is-alphabetical.txt +26 -0
- package/resources/buddy-creator/assets/licenses/is-alphanumerical.txt +26 -0
- package/resources/buddy-creator/assets/licenses/is-decimal.txt +26 -0
- package/resources/buddy-creator/assets/licenses/is-hexadecimal.txt +26 -0
- package/resources/buddy-creator/assets/licenses/is-plain-obj.txt +13 -0
- package/resources/buddy-creator/assets/licenses/longest-streak.txt +26 -0
- package/resources/buddy-creator/assets/licenses/markdown-table.txt +26 -0
- package/resources/buddy-creator/assets/licenses/mdast-util-find-and-replace.txt +26 -0
- package/resources/buddy-creator/assets/licenses/mdast-util-from-markdown.txt +26 -0
- package/resources/buddy-creator/assets/licenses/mdast-util-gfm-autolink-literal.txt +26 -0
- package/resources/buddy-creator/assets/licenses/mdast-util-gfm-footnote.txt +26 -0
- package/resources/buddy-creator/assets/licenses/mdast-util-gfm-strikethrough.txt +26 -0
- package/resources/buddy-creator/assets/licenses/mdast-util-gfm-table.txt +26 -0
- package/resources/buddy-creator/assets/licenses/mdast-util-gfm-task-list-item.txt +26 -0
- package/resources/buddy-creator/assets/licenses/mdast-util-gfm.txt +26 -0
- package/resources/buddy-creator/assets/licenses/mdast-util-mdx-expression.txt +26 -0
- package/resources/buddy-creator/assets/licenses/mdast-util-mdx-jsx.txt +26 -0
- package/resources/buddy-creator/assets/licenses/mdast-util-mdxjs-esm.txt +26 -0
- package/resources/buddy-creator/assets/licenses/mdast-util-phrasing.txt +27 -0
- package/resources/buddy-creator/assets/licenses/mdast-util-to-hast.txt +26 -0
- package/resources/buddy-creator/assets/licenses/mdast-util-to-markdown.txt +26 -0
- package/resources/buddy-creator/assets/licenses/mdast-util-to-string.txt +26 -0
- package/resources/buddy-creator/assets/licenses/micromark-core-commonmark.txt +26 -0
- package/resources/buddy-creator/assets/licenses/micromark-extension-gfm-autolink-literal.txt +26 -0
- package/resources/buddy-creator/assets/licenses/micromark-extension-gfm-footnote.txt +26 -0
- package/resources/buddy-creator/assets/licenses/micromark-extension-gfm-strikethrough.txt +26 -0
- package/resources/buddy-creator/assets/licenses/micromark-extension-gfm-table.txt +26 -0
- package/resources/buddy-creator/assets/licenses/micromark-extension-gfm-tagfilter.txt +26 -0
- package/resources/buddy-creator/assets/licenses/micromark-extension-gfm-task-list-item.txt +26 -0
- package/resources/buddy-creator/assets/licenses/micromark-extension-gfm.txt +26 -0
- package/resources/buddy-creator/assets/licenses/micromark-factory-destination.txt +26 -0
- package/resources/buddy-creator/assets/licenses/micromark-factory-label.txt +26 -0
- package/resources/buddy-creator/assets/licenses/micromark-factory-space.txt +26 -0
- package/resources/buddy-creator/assets/licenses/micromark-factory-title.txt +26 -0
- package/resources/buddy-creator/assets/licenses/micromark-factory-whitespace.txt +26 -0
- package/resources/buddy-creator/assets/licenses/micromark-util-character.txt +26 -0
- package/resources/buddy-creator/assets/licenses/micromark-util-chunked.txt +26 -0
- package/resources/buddy-creator/assets/licenses/micromark-util-classify-character.txt +26 -0
- package/resources/buddy-creator/assets/licenses/micromark-util-combine-extensions.txt +26 -0
- package/resources/buddy-creator/assets/licenses/micromark-util-decode-numeric-character-reference.txt +26 -0
- package/resources/buddy-creator/assets/licenses/micromark-util-decode-string.txt +26 -0
- package/resources/buddy-creator/assets/licenses/micromark-util-encode.txt +26 -0
- package/resources/buddy-creator/assets/licenses/micromark-util-html-tag-name.txt +26 -0
- package/resources/buddy-creator/assets/licenses/micromark-util-normalize-identifier.txt +26 -0
- package/resources/buddy-creator/assets/licenses/micromark-util-resolve-all.txt +26 -0
- package/resources/buddy-creator/assets/licenses/micromark-util-sanitize-uri.txt +26 -0
- package/resources/buddy-creator/assets/licenses/micromark-util-subtokenize.txt +26 -0
- package/resources/buddy-creator/assets/licenses/micromark-util-symbol.txt +26 -0
- package/resources/buddy-creator/assets/licenses/micromark-util-types.txt +26 -0
- package/resources/buddy-creator/assets/licenses/micromark.txt +26 -0
- package/resources/buddy-creator/assets/licenses/ms.txt +25 -0
- package/resources/buddy-creator/assets/licenses/parse-entities.txt +26 -0
- package/resources/buddy-creator/assets/licenses/phosphor-icons--react.txt +25 -0
- package/resources/buddy-creator/assets/licenses/property-information.txt +26 -0
- package/resources/buddy-creator/assets/licenses/react-dom.txt +25 -0
- package/resources/buddy-creator/assets/licenses/react-markdown.txt +25 -0
- package/resources/buddy-creator/assets/licenses/react.txt +25 -0
- package/resources/buddy-creator/assets/licenses/remark-gfm.txt +26 -0
- package/resources/buddy-creator/assets/licenses/remark-parse.txt +25 -0
- package/resources/buddy-creator/assets/licenses/remark-rehype.txt +26 -0
- package/resources/buddy-creator/assets/licenses/remark-stringify.txt +25 -0
- package/resources/buddy-creator/assets/licenses/scheduler.txt +25 -0
- package/resources/buddy-creator/assets/licenses/space-separated-tokens.txt +26 -0
- package/resources/buddy-creator/assets/licenses/stringify-entities.txt +26 -0
- package/resources/buddy-creator/assets/licenses/style-to-js.txt +26 -0
- package/resources/buddy-creator/assets/licenses/style-to-object.txt +26 -0
- package/resources/buddy-creator/assets/licenses/trim-lines.txt +26 -0
- package/resources/buddy-creator/assets/licenses/trough.txt +25 -0
- package/resources/buddy-creator/assets/licenses/types--debug.txt +25 -0
- package/resources/buddy-creator/assets/licenses/types--estree-jsx.txt +25 -0
- package/resources/buddy-creator/assets/licenses/types--estree.txt +25 -0
- package/resources/buddy-creator/assets/licenses/types--hast.txt +25 -0
- package/resources/buddy-creator/assets/licenses/types--mdast.txt +25 -0
- package/resources/buddy-creator/assets/licenses/types--ms.txt +25 -0
- package/resources/buddy-creator/assets/licenses/types--unist.txt +25 -0
- package/resources/buddy-creator/assets/licenses/ungap--structured-clone.txt +19 -0
- package/resources/buddy-creator/assets/licenses/unified.txt +25 -0
- package/resources/buddy-creator/assets/licenses/unist-util-is.txt +26 -0
- package/resources/buddy-creator/assets/licenses/unist-util-position.txt +26 -0
- package/resources/buddy-creator/assets/licenses/unist-util-stringify-position.txt +26 -0
- package/resources/buddy-creator/assets/licenses/unist-util-visit-parents.txt +26 -0
- package/resources/buddy-creator/assets/licenses/unist-util-visit.txt +26 -0
- package/resources/buddy-creator/assets/licenses/vfile-message.txt +26 -0
- package/resources/buddy-creator/assets/licenses/vfile.txt +25 -0
- package/resources/buddy-creator/assets/licenses/zwitch.txt +26 -0
- package/resources/buddy-creator/assets/preview/assets/index-B5VmkoSx.js +39 -0
- package/resources/buddy-creator/assets/preview/assets/index-DWKP6b-3.css +4 -0
- package/resources/buddy-creator/assets/preview/brand-logo.png +0 -0
- package/resources/buddy-creator/assets/preview/brand.css +100 -0
- package/resources/buddy-creator/assets/preview/index.html +5 -0
- package/resources/buddy-creator/assets/preview/reader.css +165 -0
- package/resources/buddy-creator/assets/preview/reader.js +135 -0
- package/resources/buddy-creator/references/artifact-schema.md +68 -0
- package/resources/buddy-creator/references/catalog.json +401 -0
- package/resources/buddy-creator/references/definition.md +24 -0
- package/resources/buddy-creator/references/dialogue-examples.md +133 -0
- package/resources/buddy-creator/references/host-guide.md +213 -0
- package/resources/buddy-creator/references/interview.md +235 -0
- package/resources/buddy-creator/references/knowledge.md +40 -0
- package/resources/buddy-creator/references/methods.md +77 -0
- package/resources/buddy-creator/references/opening.md +14 -0
- package/resources/buddy-creator/references/preview-panel.md +66 -0
- package/resources/buddy-creator/references/recovery.md +43 -0
- package/resources/buddy-creator/references/service.md +74 -0
- package/resources/buddy-creator/scripts/buddy.py +108 -0
- package/resources/buddy-creator/scripts/buddy_client.py +67 -0
- package/resources/buddy-creator/scripts/buddy_core.py +803 -0
- package/resources/buddy-creator/scripts/completion.py +240 -0
- package/resources/buddy-creator/scripts/interview.py +215 -0
- package/resources/buddy-creator/scripts/preview.py +577 -0
- package/resources/buddy-creator/scripts/preview_panel.py +80 -0
- package/resources/buddy-creator/scripts/sources.py +239 -0
- package/resources/buddy-creator/version.json +8 -0
- package/resources/buddy-creator.manifest.json +696 -0
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
"""Read-only, local preview for Buddy Creator Skill.
|
|
2
|
+
|
|
3
|
+
The bundled browser application is prebuilt static JavaScript: running it in a
|
|
4
|
+
browser does not require Node.js. Business writes belong exclusively to core.py.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import contextlib
|
|
8
|
+
import datetime
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import mimetypes
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
import re
|
|
15
|
+
import secrets
|
|
16
|
+
import socket
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
22
|
+
from urllib.error import HTTPError, URLError
|
|
23
|
+
from urllib.parse import unquote, urlsplit
|
|
24
|
+
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
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"}
|
|
30
|
+
STAGES = ("definition", "knowledge", "methods", "service")
|
|
31
|
+
STAGE_LABELS = dict(zip(STAGES, ("定义", "知识", "方法", "服务")))
|
|
32
|
+
LABELS = {"confirmed": "已确认", "accepted": "已采纳", "rejected": "未采纳",
|
|
33
|
+
"pending": "待确认", "revised": "待重新确认"}
|
|
34
|
+
PATHS = {"acquisition-paid": "体验后订阅", "acquisition-maintenance": "暂不订阅",
|
|
35
|
+
"paid-paid": "续费", "paid-maintenance": "停止续费", "maintenance-paid": "恢复订阅"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _read(path):
|
|
39
|
+
with Path(path).open("r", encoding="utf-8") as handle:
|
|
40
|
+
return json.load(handle)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _optional(path):
|
|
44
|
+
try:
|
|
45
|
+
return _read(path)
|
|
46
|
+
except FileNotFoundError:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _write(path, data):
|
|
51
|
+
path = Path(path)
|
|
52
|
+
temporary = path.with_name(path.name + "." + secrets.token_hex(8) + ".tmp")
|
|
53
|
+
try:
|
|
54
|
+
descriptor = os.open(str(temporary), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
55
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
56
|
+
json.dump(data, handle, ensure_ascii=False, separators=(",", ":"))
|
|
57
|
+
handle.flush()
|
|
58
|
+
os.fsync(handle.fileno())
|
|
59
|
+
os.replace(str(temporary), str(path))
|
|
60
|
+
if os.name != "nt":
|
|
61
|
+
directory = os.open(str(path.parent), os.O_RDONLY)
|
|
62
|
+
try:
|
|
63
|
+
os.fsync(directory)
|
|
64
|
+
finally:
|
|
65
|
+
os.close(directory)
|
|
66
|
+
finally:
|
|
67
|
+
with contextlib.suppress(FileNotFoundError):
|
|
68
|
+
temporary.unlink()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _fail(code, message):
|
|
72
|
+
raise RuntimeError(code + ": " + message)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _decision(state, artifact, decision="confirmed"):
|
|
76
|
+
return any(c.get("objectId") == artifact["id"] and c.get("hash") == artifact.get("hash")
|
|
77
|
+
and not c.get("invalidatedBy") and c.get("decision") == decision
|
|
78
|
+
for c in state.get("confirmations", []))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _status(state, artifact):
|
|
82
|
+
for decision in ("confirmed", "accepted", "rejected"):
|
|
83
|
+
if _decision(state, artifact, decision):
|
|
84
|
+
return decision
|
|
85
|
+
return "revised" if any(c.get("objectId") == artifact["id"]
|
|
86
|
+
for c in state.get("confirmations", [])) else "pending"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _book_confirmed(state, stage, chapters):
|
|
90
|
+
artifacts = state.get("artifacts", {})
|
|
91
|
+
return bool(chapters[stage]) and all(
|
|
92
|
+
artifacts.get(stage + "." + str(i + 1))
|
|
93
|
+
and _decision(state, artifacts[stage + "." + str(i + 1)])
|
|
94
|
+
for i in range(len(chapters[stage])))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _object_for_target(state, target_id):
|
|
98
|
+
candidate = None
|
|
99
|
+
if re.fullmatch(r"H\d+", target_id):
|
|
100
|
+
candidate = "hypothesis.H" + str(int(target_id[1:]))
|
|
101
|
+
elif re.fullmatch(r"[ME]\d+", target_id):
|
|
102
|
+
candidate = "scenario." + target_id
|
|
103
|
+
elif target_id.startswith("T."):
|
|
104
|
+
candidate = "transition." + target_id.split(".")[1]
|
|
105
|
+
elif re.fullmatch(r"D\d+", target_id):
|
|
106
|
+
candidate = "definition." + str(int(target_id[1:]))
|
|
107
|
+
return candidate if candidate in state.get("artifacts", {}) else None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _phase(target_id, stage):
|
|
111
|
+
target_id = target_id or ""
|
|
112
|
+
if target_id.startswith(("hypothesis.", "H")):
|
|
113
|
+
return "候选校准"
|
|
114
|
+
if target_id.startswith(("scenario.M", "M")):
|
|
115
|
+
return "基础情境"
|
|
116
|
+
if target_id.startswith(("scenario.E", "E")):
|
|
117
|
+
return "拓展情境"
|
|
118
|
+
if target_id.startswith(("T.", "transition.")):
|
|
119
|
+
return "用户路径"
|
|
120
|
+
if target_id == "service.blueprint":
|
|
121
|
+
return "服务模式确认"
|
|
122
|
+
if target_id == "R00":
|
|
123
|
+
return "本次优化"
|
|
124
|
+
if target_id == "S00":
|
|
125
|
+
return "规划方式"
|
|
126
|
+
if re.fullmatch(r"(definition|knowledge|methods|service)\.\d+", target_id):
|
|
127
|
+
return "手册确认"
|
|
128
|
+
return {"definition": "创作定义", "knowledge": "知识整理",
|
|
129
|
+
"methods": "候选校准", "service": "服务规划"}[stage]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _artifact_view(state, artifact):
|
|
133
|
+
# Do not expose dependencies, raw turn input, archives, local paths or host
|
|
134
|
+
# context. Only authored public content and its selected citations are sent.
|
|
135
|
+
view = {key: artifact.get(key) for key in ("id", "stage", "kind", "title", "hash")}
|
|
136
|
+
view.update(markdown=artifact.get("markdown", ""), data=artifact.get("data") or {},
|
|
137
|
+
unresolved=artifact.get("unresolved") or [], status=_status(state, artifact))
|
|
138
|
+
view["evidence"] = [{key: ref[key] for key in ("type", "id", "hash", "locator", "quote")
|
|
139
|
+
if key in ref} for ref in artifact.get("evidence", [])]
|
|
140
|
+
return view
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _time(value):
|
|
144
|
+
try:
|
|
145
|
+
return datetime.datetime.fromisoformat(str(value).replace("Z", "+00:00")).timestamp()
|
|
146
|
+
except (ValueError, TypeError, OverflowError):
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _activity(state, drafts):
|
|
151
|
+
if state.get("paused"):
|
|
152
|
+
return {"mode": "paused", "label": "已暂停"}
|
|
153
|
+
pending = state.get("turns", {}).get(state.get("pendingTurnId"))
|
|
154
|
+
if pending and pending.get("status") == "working":
|
|
155
|
+
last_signal = max([_time(pending.get("updatedAt") or pending.get("createdAt"))]
|
|
156
|
+
+ [_time(d.get("updatedAt")) for d in drafts])
|
|
157
|
+
elapsed = max(0, time.time() - last_signal)
|
|
158
|
+
if not last_signal or elapsed >= 120:
|
|
159
|
+
return {"mode": "waiting", "label": "本轮还未完成", "detail": "回主对话查看进展"}
|
|
160
|
+
result = {"mode": "working", "label": "正在整理手册" if drafts else "正在梳理你的想法"}
|
|
161
|
+
if elapsed >= 30:
|
|
162
|
+
result["detail"] = "可先阅读已有内容"
|
|
163
|
+
return result
|
|
164
|
+
selected = state.get("sourcePlan", {}).get("sourceIds", [])
|
|
165
|
+
if any(s.get("status") == "failed" for s in state.get("sources", {}).values() if s.get("id") in selected):
|
|
166
|
+
return {"mode": "attention", "label": "资料需要处理", "detail": "回主对话查看原因"}
|
|
167
|
+
current_delivery = state.get("currentDeliveryId")
|
|
168
|
+
if current_delivery and current_delivery not in state.get("presentations", []):
|
|
169
|
+
return {"mode": "ready", "label": "回复已整理", "detail": "等待主对话展示"}
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def snapshot(workspace, state=None):
|
|
174
|
+
workspace = Path(workspace)
|
|
175
|
+
state = state if state is not None else _read(workspace / "state.json")
|
|
176
|
+
catalog = _read(ROOT / "references" / "catalog.json")
|
|
177
|
+
cards, chapters = catalog["cards"], catalog["chapters"]
|
|
178
|
+
stage = state["stage"]
|
|
179
|
+
targets = state.get("targets", {})
|
|
180
|
+
artifacts = [_artifact_view(state, a) for a in state.get("artifacts", {}).values()]
|
|
181
|
+
by_id = {a["id"]: a for a in artifacts}
|
|
182
|
+
delivery = state.get("deliveries", {}).get(state.get("currentDeliveryId"), {})
|
|
183
|
+
if delivery.get("resumeDeliveryId"):
|
|
184
|
+
delivery = state.get("deliveries", {}).get(delivery["resumeDeliveryId"], delivery)
|
|
185
|
+
target_id = (delivery.get("question") or {}).get("targetId")
|
|
186
|
+
if cards.get(target_id, {}).get("stage") != stage:
|
|
187
|
+
target_id = None
|
|
188
|
+
confirmation = delivery.get("confirmationTarget") or {}
|
|
189
|
+
requested = [r["id"] for r in confirmation.get("objects", [])
|
|
190
|
+
if confirmation.get("stage") == stage and by_id.get(r.get("id"), {}).get("hash") == r.get("hash")]
|
|
191
|
+
hypotheses = sorted([a for a in artifacts if a["kind"] == "hypothesis"], key=lambda a: a["id"])
|
|
192
|
+
target_object = _object_for_target(state, target_id) if target_id else None
|
|
193
|
+
complete = all(_book_confirmed(state, s, chapters) for s in STAGES)
|
|
194
|
+
focus_ids = [target_object] if target_object else [a_id for a_id in requested
|
|
195
|
+
if by_id[a_id]["status"] in ("pending", "revised")]
|
|
196
|
+
if not focus_ids and not target_id:
|
|
197
|
+
candidate = next((a for a in artifacts if a["stage"] == stage and a["status"] in ("pending", "revised")), None)
|
|
198
|
+
if candidate:
|
|
199
|
+
focus_ids = [candidate["id"]]
|
|
200
|
+
focus = by_id.get(focus_ids[0]) if focus_ids else None
|
|
201
|
+
first_target = next((i for i, c in cards.items() if c["stage"] == stage
|
|
202
|
+
and not i.startswith(("H", "T.", "R00"))
|
|
203
|
+
and targets.get(i, {}).get("status", "unstarted") == "unstarted"), None)
|
|
204
|
+
current_target = target_id or (first_target if not focus and not complete
|
|
205
|
+
and not (stage == "methods" and not hypotheses) else None)
|
|
206
|
+
topic_title = cards.get(current_target, {}).get("title")
|
|
207
|
+
if current_target and current_target.startswith("T."):
|
|
208
|
+
pieces = current_target.split(".")
|
|
209
|
+
topic_title = PATHS.get(pieces[1], "用户路径") + " / " + ("时点", "权益", "表达")[int(pieces[2]) - 1]
|
|
210
|
+
def target_summary(identifier):
|
|
211
|
+
item = targets.get(identifier, {})
|
|
212
|
+
summary = item.get("summary", "")
|
|
213
|
+
gaps = item.get("gaps", [])
|
|
214
|
+
return summary + (("\n\n" if summary else "") + "待补:" + ";".join(gaps) if gaps else "")
|
|
215
|
+
|
|
216
|
+
current = {
|
|
217
|
+
"stage": stage,
|
|
218
|
+
"phase": "创作完成" if complete and not target_id and not focus else _phase(current_target or (focus or {}).get("id"), stage),
|
|
219
|
+
"title": STAGE_LABELS[stage] + "手册" if len(focus_ids) > 1 else
|
|
220
|
+
(focus or {}).get("title") or topic_title or ("四本手册已完成" if complete else "整理方法候选" if stage == "methods" else "整理当前内容"),
|
|
221
|
+
"targetId": current_target,
|
|
222
|
+
"artifactIds": focus_ids,
|
|
223
|
+
"summary": target_summary(current_target) if current_target else delivery.get("blocked", ""),
|
|
224
|
+
"status": "已暂停" if state.get("paused") else "待补充" if delivery.get("blocked") else LABELS[focus["status"]] if focus else "已完成" if complete else "讨论中",
|
|
225
|
+
}
|
|
226
|
+
if focus and focus["kind"] == "hypothesis":
|
|
227
|
+
current["position"] = {"index": next(i + 1 for i, a in enumerate(hypotheses) if a["id"] == focus["id"]), "total": len(hypotheses)}
|
|
228
|
+
|
|
229
|
+
def topic(identifier):
|
|
230
|
+
object_id = _object_for_target(state, identifier)
|
|
231
|
+
artifact = by_id.get(object_id)
|
|
232
|
+
target = targets.get(identifier, {})
|
|
233
|
+
return {"id": identifier,
|
|
234
|
+
"title": artifact["title"] if artifact and artifact["kind"] == "hypothesis" else cards.get(identifier, {}).get("title", identifier),
|
|
235
|
+
"summary": target_summary(identifier), "artifactId": object_id,
|
|
236
|
+
"current": identifier == current_target or bool(object_id and object_id in focus_ids),
|
|
237
|
+
"status": LABELS[artifact["status"]] if artifact else "已记录" if target.get("status") == "sufficient"
|
|
238
|
+
else "已跳过" if target.get("status") == "skipped" else "待补充" if target.get("summary") or target.get("status") in ("uncertain", "exhausted") else "未开始"}
|
|
239
|
+
|
|
240
|
+
stages = []
|
|
241
|
+
for index, stage_id in enumerate(STAGES):
|
|
242
|
+
identifiers = [i for i, c in cards.items() if c["stage"] == stage_id]
|
|
243
|
+
groups = []
|
|
244
|
+
if stage_id == "methods":
|
|
245
|
+
groups.extend([
|
|
246
|
+
{"id": "candidates", "title": "方法候选", "topics": [topic("H" + a["id"].split("H")[-1].zfill(2)) for a in hypotheses]},
|
|
247
|
+
{"id": "base", "title": "基础情境", "topics": [topic(i) for i in identifiers if i.startswith("M")]},
|
|
248
|
+
{"id": "extended", "title": "拓展情境", "topics": [topic(i) for i in identifiers if i.startswith("E")]},
|
|
249
|
+
])
|
|
250
|
+
elif stage_id == "service":
|
|
251
|
+
groups.append({"id": "planning", "title": "服务规划", "topics": [topic(i) for i in identifiers if i.startswith("S")]})
|
|
252
|
+
path_topics = []
|
|
253
|
+
for path_id, title in PATHS.items():
|
|
254
|
+
artifact = by_id.get("transition." + path_id)
|
|
255
|
+
path_topics.append({"id": "path." + path_id, "title": title, "summary": "",
|
|
256
|
+
"artifactId": artifact["id"] if artifact else None,
|
|
257
|
+
"current": bool(current_target and current_target.startswith("T." + path_id + ".")) or "transition." + path_id in focus_ids,
|
|
258
|
+
"status": LABELS[artifact["status"]] if artifact else "系统固定" if path_id == "maintenance-paid" else "未开始"})
|
|
259
|
+
groups.append({"id": "paths", "title": "用户路径", "topics": path_topics})
|
|
260
|
+
elif stage_id == "knowledge":
|
|
261
|
+
groups.append({"id": "interview", "title": "知识整理", "topics": [topic(i) for i in identifiers]})
|
|
262
|
+
book_topics = []
|
|
263
|
+
for chapter_index, title in enumerate(chapters[stage_id]):
|
|
264
|
+
identifier = stage_id + "." + str(chapter_index + 1)
|
|
265
|
+
artifact = by_id.get(identifier)
|
|
266
|
+
target_key = "D" + str(chapter_index + 1).zfill(2) if stage_id == "definition" else None
|
|
267
|
+
target = targets.get(target_key, {})
|
|
268
|
+
book_topics.append({"id": identifier, "title": title, "summary": target_summary(target_key) if target_key else "",
|
|
269
|
+
"artifactId": artifact["id"] if artifact else None,
|
|
270
|
+
"current": identifier in focus_ids or bool(target_key and target_key == current_target),
|
|
271
|
+
"status": LABELS[artifact["status"]] if artifact else "已记录" if target.get("status") == "sufficient" else "待补充" if target.get("summary") else "待形成"})
|
|
272
|
+
groups.append({"id": "booklet", "title": "Booklet", "topics": book_topics})
|
|
273
|
+
members = [a for a in artifacts if a["stage"] == stage_id]
|
|
274
|
+
status = ("已确认" if _book_confirmed(state, stage_id, chapters)
|
|
275
|
+
else "待重新确认" if any(a["status"] == "revised" for a in members)
|
|
276
|
+
else "待确认" if any(a["kind"] == "chapter" for a in members)
|
|
277
|
+
else "进行中" if stage_id == stage or any(targets.get(i, {}).get("status", "unstarted") != "unstarted" for i in identifiers)
|
|
278
|
+
else "未开始")
|
|
279
|
+
stages.append({"id": stage_id, "number": index + 1, "title": STAGE_LABELS[stage_id],
|
|
280
|
+
"current": stage_id == stage, "status": status, "groups": groups,
|
|
281
|
+
"confirmedChapters": sum(a["kind"] == "chapter" and a["status"] == "confirmed" for a in members),
|
|
282
|
+
"totalChapters": len(chapters[stage_id])})
|
|
283
|
+
pending_id = state.get("pendingTurnId")
|
|
284
|
+
drafts = [d for d in state.get("drafts", []) if pending_id and d.get("turnId") == pending_id
|
|
285
|
+
and state.get("turns", {}).get(pending_id, {}).get("status") == "working"]
|
|
286
|
+
drafts.sort(key=lambda d: d.get("sequence", 0))
|
|
287
|
+
visible_drafts = []
|
|
288
|
+
for draft in drafts[-1:]:
|
|
289
|
+
visible_drafts.append({"id": draft.get("id", "draft." + pending_id), "stepId": pending_id,
|
|
290
|
+
"title": draft.get("title", "手册草稿"), "markdown": draft.get("markdown", ""),
|
|
291
|
+
"hash": draft.get("hash") or hashlib.sha256(draft.get("markdown", "").encode("utf-8")).hexdigest()})
|
|
292
|
+
sources = []
|
|
293
|
+
for source in state.get("sources", {}).values():
|
|
294
|
+
# Source processing error objects may contain local paths. Keep the
|
|
295
|
+
# browser message actionable but generic; details stay in the main chat.
|
|
296
|
+
sources.append({"id": source["id"], "title": source.get("title", "知识资料"),
|
|
297
|
+
"kind": source.get("kind", "file"), "status": source.get("status", "failed"),
|
|
298
|
+
"version": source.get("version"), "warnings": [],
|
|
299
|
+
"error": "资料尚未完整处理,请在主对话中补充或重试。" if source.get("status") == "failed" else None})
|
|
300
|
+
completion_view = None
|
|
301
|
+
try:
|
|
302
|
+
import completion
|
|
303
|
+
completion_view = completion.snapshot(workspace, state)
|
|
304
|
+
except (OSError, ValueError, ImportError):
|
|
305
|
+
if complete:
|
|
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")}
|
|
309
|
+
return {"buddyId": state["buddyId"], "revision": state["revision"], "stage": stage,
|
|
310
|
+
"paused": bool(state.get("paused")), "activity": _activity(state, drafts),
|
|
311
|
+
"completion": completion_view, "current": current, "stages": stages,
|
|
312
|
+
"artifacts": artifacts, "sources": sources, "drafts": visible_drafts}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _endpoint(workspace, workspace_id):
|
|
316
|
+
endpoint = _optional(workspace / "preview-endpoint.json")
|
|
317
|
+
if endpoint is not None and not (isinstance(endpoint, dict) and endpoint.get("version") == 1
|
|
318
|
+
and endpoint.get("workspaceId") == workspace_id and type(endpoint.get("port")) is int
|
|
319
|
+
and 1 <= endpoint["port"] <= 65535
|
|
320
|
+
and isinstance(endpoint.get("token"), str) and re.fullmatch(r"[a-f0-9]{48}", endpoint["token"])):
|
|
321
|
+
_fail("PREVIEW_ADDRESS_INVALID", "固定预览地址无效或属于其他搭子;已保留原记录。")
|
|
322
|
+
return endpoint
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _url(endpoint):
|
|
326
|
+
return "http://127.0.0.1:%s/p/%s/" % (endpoint["port"], endpoint["token"])
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _listening(port):
|
|
330
|
+
try:
|
|
331
|
+
with socket.create_connection(("127.0.0.1", port), timeout=0.5):
|
|
332
|
+
return True
|
|
333
|
+
except OSError:
|
|
334
|
+
return False
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _identity(endpoint, workspace_id, record):
|
|
338
|
+
class NoRedirect(HTTPRedirectHandler):
|
|
339
|
+
def redirect_request(self, request, response, code, message, headers, new_url):
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
# Loopback health requests must not pass through configured HTTP proxies.
|
|
344
|
+
with build_opener(ProxyHandler({}), NoRedirect()).open(Request(_url(endpoint) + "api/health"), timeout=1.2) as response:
|
|
345
|
+
if response.geturl() != _url(endpoint) + "api/health":
|
|
346
|
+
_fail("PREVIEW_IDENTITY_MISMATCH", "预览身份不匹配,未接管此端口。")
|
|
347
|
+
identity = json.loads(response.read(8192).decode("utf-8"))
|
|
348
|
+
except (OSError, HTTPError, URLError, ValueError):
|
|
349
|
+
return False
|
|
350
|
+
if not isinstance(identity, dict) or identity.get("workspaceId") != workspace_id or identity.get("runtimeVersion") not in COMPATIBLE_RUNTIME_VERSIONS:
|
|
351
|
+
_fail("PREVIEW_IDENTITY_MISMATCH", "端口上的服务不属于当前版本的搭子预览,未接管或停止它。")
|
|
352
|
+
if not record or identity.get("pid") != record.get("pid") or record.get("workspaceId") != workspace_id or record.get("url") != _url(endpoint):
|
|
353
|
+
_fail("PREVIEW_IDENTITY_MISMATCH", "预览进程记录与端口上的服务不一致,未接管或停止它。")
|
|
354
|
+
return True
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _alive(pid):
|
|
358
|
+
if type(pid) is not int or pid <= 0:
|
|
359
|
+
return True
|
|
360
|
+
try:
|
|
361
|
+
os.kill(pid, 0)
|
|
362
|
+
return True
|
|
363
|
+
except ProcessLookupError:
|
|
364
|
+
return False
|
|
365
|
+
except (PermissionError, OSError):
|
|
366
|
+
return True
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@contextlib.contextmanager
|
|
370
|
+
def _lock(workspace, name):
|
|
371
|
+
path = workspace / name
|
|
372
|
+
owner = {"pid": os.getpid(), "token": secrets.token_hex(12)}
|
|
373
|
+
deadline = time.monotonic() + 12
|
|
374
|
+
while True:
|
|
375
|
+
try:
|
|
376
|
+
descriptor = os.open(str(path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
377
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
378
|
+
json.dump(owner, handle)
|
|
379
|
+
handle.flush()
|
|
380
|
+
os.fsync(handle.fileno())
|
|
381
|
+
break
|
|
382
|
+
except FileExistsError:
|
|
383
|
+
try:
|
|
384
|
+
observed = _read(path)
|
|
385
|
+
if not _alive(observed.get("pid")) and _read(path) == observed:
|
|
386
|
+
path.unlink()
|
|
387
|
+
continue
|
|
388
|
+
except (OSError, ValueError, AttributeError):
|
|
389
|
+
pass
|
|
390
|
+
if time.monotonic() >= deadline:
|
|
391
|
+
_fail("PREVIEW_BUSY", "预览启动正在进行或锁记录需检查,请稍后重试。")
|
|
392
|
+
time.sleep(0.1)
|
|
393
|
+
try:
|
|
394
|
+
yield
|
|
395
|
+
finally:
|
|
396
|
+
with contextlib.suppress(OSError, ValueError):
|
|
397
|
+
if _read(path) == owner:
|
|
398
|
+
path.unlink()
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def start(workspace):
|
|
402
|
+
workspace = Path(workspace).resolve()
|
|
403
|
+
state = _read(workspace / "state.json")
|
|
404
|
+
workspace_id = state["workspaceId"]
|
|
405
|
+
with _lock(workspace, ".preview-lifecycle.lock"):
|
|
406
|
+
endpoint = _endpoint(workspace, workspace_id)
|
|
407
|
+
record = _optional(workspace / "preview-server.json")
|
|
408
|
+
if endpoint and _identity(endpoint, workspace_id, record):
|
|
409
|
+
return {"url": _url(endpoint), "readonly": True, "addressPolicy": "stable_per_workspace"}
|
|
410
|
+
if endpoint and _listening(endpoint["port"]):
|
|
411
|
+
_fail("PREVIEW_PORT_IN_USE", "当前搭子的固定端口已被其他进程占用;保留原地址,未切换端口或停止其他程序。")
|
|
412
|
+
arguments = [sys.executable, str(ROOT / "scripts" / "buddy.py"), "serve", "--workspace", str(workspace)]
|
|
413
|
+
options = {"stdin": subprocess.DEVNULL, "stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL,
|
|
414
|
+
"cwd": str(ROOT), "close_fds": True}
|
|
415
|
+
if os.name == "nt":
|
|
416
|
+
options["creationflags"] = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
|
417
|
+
else:
|
|
418
|
+
options["start_new_session"] = True
|
|
419
|
+
process = subprocess.Popen(arguments, **options)
|
|
420
|
+
for _ in range(80):
|
|
421
|
+
time.sleep(0.1)
|
|
422
|
+
if process.poll() is not None:
|
|
423
|
+
_fail("PREVIEW_START_FAILED", "本地预览未启动。可在主对话中重试打开;固定地址与已保存内容保持不变。")
|
|
424
|
+
endpoint = _endpoint(workspace, workspace_id)
|
|
425
|
+
record = _optional(workspace / "preview-server.json")
|
|
426
|
+
if endpoint and record and record.get("pid") == process.pid and _identity(endpoint, workspace_id, record):
|
|
427
|
+
return {"url": _url(endpoint), "readonly": True, "addressPolicy": "stable_per_workspace"}
|
|
428
|
+
# Only stop the still-running child created by this invocation. Never
|
|
429
|
+
# signal a PID recovered from disk or another listener on the port.
|
|
430
|
+
if process.poll() is None:
|
|
431
|
+
process.terminate()
|
|
432
|
+
_fail("PREVIEW_START_FAILED", "本地预览启动超时;请稍后重试,固定地址已保留。")
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def serve(workspace):
|
|
436
|
+
workspace = Path(workspace).resolve()
|
|
437
|
+
state = _read(workspace / "state.json")
|
|
438
|
+
workspace_id = state["workspaceId"]
|
|
439
|
+
assets = (ROOT / "assets" / "preview").resolve()
|
|
440
|
+
if not (assets / "index.html").is_file():
|
|
441
|
+
_fail("PREVIEW_ASSETS_MISSING", "安装包中的预览文件缺失,请重新解压完整安装包。")
|
|
442
|
+
endpoint = _endpoint(workspace, workspace_id)
|
|
443
|
+
token = endpoint["token"] if endpoint else secrets.token_hex(24)
|
|
444
|
+
base = "/p/" + token + "/"
|
|
445
|
+
streams = threading.BoundedSemaphore(16)
|
|
446
|
+
|
|
447
|
+
class Handler(BaseHTTPRequestHandler):
|
|
448
|
+
protocol_version = "HTTP/1.1"
|
|
449
|
+
server_version = "BuddyPreview"
|
|
450
|
+
sys_version = ""
|
|
451
|
+
|
|
452
|
+
def log_message(self, format, *args):
|
|
453
|
+
pass
|
|
454
|
+
|
|
455
|
+
def send_error(self, code, message=None, explain=None):
|
|
456
|
+
self.reply(code, "请求无法处理。".encode("utf-8"), "text/plain; charset=utf-8")
|
|
457
|
+
|
|
458
|
+
def reply(self, code, body=b"", content_type="text/plain; charset=utf-8"):
|
|
459
|
+
self.send_response(code)
|
|
460
|
+
self.headers_for(content_type)
|
|
461
|
+
self.send_header("Content-Length", str(len(body)))
|
|
462
|
+
self.send_header("Connection", "close")
|
|
463
|
+
self.end_headers()
|
|
464
|
+
self.wfile.write(body)
|
|
465
|
+
self.close_connection = True
|
|
466
|
+
|
|
467
|
+
def headers_for(self, content_type):
|
|
468
|
+
self.send_header("Content-Type", content_type)
|
|
469
|
+
self.send_header("Cache-Control", "no-store")
|
|
470
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
471
|
+
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'")
|
|
473
|
+
|
|
474
|
+
def do_GET(self):
|
|
475
|
+
expected_host = "127.0.0.1:" + str(self.server.server_port)
|
|
476
|
+
if self.headers.get("Host") != expected_host or self.headers.get("Origin") not in (None, "http://" + expected_host):
|
|
477
|
+
self.reply(403)
|
|
478
|
+
return
|
|
479
|
+
route_path = urlsplit(self.path)
|
|
480
|
+
if route_path.scheme or route_path.netloc or not route_path.path.startswith(base):
|
|
481
|
+
self.reply(404)
|
|
482
|
+
return
|
|
483
|
+
route = unquote(route_path.path[len(base):])
|
|
484
|
+
try:
|
|
485
|
+
if route == "api/health":
|
|
486
|
+
self.json_reply({"workspaceId": workspace_id, "pid": os.getpid(), "runtimeVersion": RUNTIME_VERSION})
|
|
487
|
+
elif route == "api/snapshot":
|
|
488
|
+
self.json_reply(snapshot(workspace))
|
|
489
|
+
elif route == "api/events":
|
|
490
|
+
self.events()
|
|
491
|
+
else:
|
|
492
|
+
file = (assets / (route or "index.html")).resolve()
|
|
493
|
+
if assets not in file.parents or not file.is_file():
|
|
494
|
+
self.reply(404)
|
|
495
|
+
return
|
|
496
|
+
content_type = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8",
|
|
497
|
+
".css": "text/css; charset=utf-8", ".svg": "image/svg+xml", ".woff2": "font/woff2"}.get(file.suffix, mimetypes.guess_type(str(file))[0] or "application/octet-stream")
|
|
498
|
+
self.reply(200, file.read_bytes(), content_type)
|
|
499
|
+
except (BrokenPipeError, ConnectionResetError, TimeoutError):
|
|
500
|
+
self.close_connection = True
|
|
501
|
+
except Exception:
|
|
502
|
+
self.reply(503, "内容暂时无法读取,请保留页面并回到主对话检查。".encode("utf-8"))
|
|
503
|
+
|
|
504
|
+
def json_reply(self, data):
|
|
505
|
+
self.reply(200, json.dumps(data, ensure_ascii=False, separators=(",", ":")).encode("utf-8"), "application/json; charset=utf-8")
|
|
506
|
+
|
|
507
|
+
def events(self):
|
|
508
|
+
if not streams.acquire(blocking=False):
|
|
509
|
+
self.reply(503)
|
|
510
|
+
return
|
|
511
|
+
try:
|
|
512
|
+
self.send_response(200)
|
|
513
|
+
self.headers_for("text/event-stream; charset=utf-8")
|
|
514
|
+
self.send_header("Connection", "keep-alive")
|
|
515
|
+
self.end_headers()
|
|
516
|
+
self.connection.settimeout(15)
|
|
517
|
+
self.wfile.write(b"event: ready\ndata: {}\n\n")
|
|
518
|
+
self.wfile.flush()
|
|
519
|
+
previous = None
|
|
520
|
+
unavailable = False
|
|
521
|
+
while True:
|
|
522
|
+
try:
|
|
523
|
+
current = hashlib.sha256(json.dumps(snapshot(workspace), ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest()
|
|
524
|
+
if current != previous or unavailable:
|
|
525
|
+
payload = ('event: changed\ndata: {"version":"' + current + '"}\n\n').encode("utf-8")
|
|
526
|
+
previous, unavailable = current, False
|
|
527
|
+
else:
|
|
528
|
+
payload = b": heartbeat\n\n"
|
|
529
|
+
except Exception:
|
|
530
|
+
payload = b"event: unavailable\ndata: {}\n\n"
|
|
531
|
+
unavailable = True
|
|
532
|
+
self.wfile.write(payload)
|
|
533
|
+
self.wfile.flush()
|
|
534
|
+
time.sleep(1)
|
|
535
|
+
except (OSError, TimeoutError):
|
|
536
|
+
self.close_connection = True
|
|
537
|
+
finally:
|
|
538
|
+
streams.release()
|
|
539
|
+
|
|
540
|
+
def readonly(self):
|
|
541
|
+
self.reply(405, "预览只读,请回到主对话修改。".encode("utf-8"))
|
|
542
|
+
|
|
543
|
+
do_POST = readonly
|
|
544
|
+
do_PUT = readonly
|
|
545
|
+
do_PATCH = readonly
|
|
546
|
+
do_DELETE = readonly
|
|
547
|
+
do_HEAD = readonly
|
|
548
|
+
do_OPTIONS = readonly
|
|
549
|
+
|
|
550
|
+
class Server(ThreadingHTTPServer):
|
|
551
|
+
daemon_threads = True
|
|
552
|
+
|
|
553
|
+
def handle_error(self, request, client_address):
|
|
554
|
+
# HTTP errors must never print archive paths, input or tracebacks.
|
|
555
|
+
pass
|
|
556
|
+
|
|
557
|
+
with _lock(workspace, ".preview-binding.lock"):
|
|
558
|
+
endpoint = _endpoint(workspace, workspace_id)
|
|
559
|
+
if endpoint:
|
|
560
|
+
token = endpoint["token"]
|
|
561
|
+
base = "/p/" + token + "/"
|
|
562
|
+
try:
|
|
563
|
+
server = Server(("127.0.0.1", endpoint["port"] if endpoint else 0), Handler)
|
|
564
|
+
except OSError:
|
|
565
|
+
_fail("PREVIEW_PORT_IN_USE", "当前搭子的固定端口无法使用;未切换地址或停止其他程序。")
|
|
566
|
+
endpoint = {"version": 1, "workspaceId": workspace_id, "port": server.server_port, "token": token}
|
|
567
|
+
try:
|
|
568
|
+
_write(workspace / "preview-endpoint.json", endpoint)
|
|
569
|
+
_write(workspace / "preview-server.json", {"workspaceId": workspace_id, "pid": os.getpid(),
|
|
570
|
+
"url": _url(endpoint), "runtimeVersion": RUNTIME_VERSION})
|
|
571
|
+
except Exception:
|
|
572
|
+
server.server_close()
|
|
573
|
+
raise
|
|
574
|
+
try:
|
|
575
|
+
server.serve_forever(poll_interval=0.5)
|
|
576
|
+
finally:
|
|
577
|
+
server.server_close()
|
|
@@ -0,0 +1,80 @@
|
|
|
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}
|