ai_co_discuss 0.2.72 → 0.2.74
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 +140 -185
- package/README.zh-CN.md +135 -0
- package/backend/remote_launch.py +175 -0
- package/backend/schema.sql +25 -0
- package/backend/server.py +35 -1
- package/docs/architecture.md +2 -0
- package/docs/runner.md +15 -3
- package/package.json +2 -2
- package/runner/discussion_launch.mjs +54 -5
- package/runner/discussion_local.mjs +3 -2
- package/runner/discussion_service.mjs +8 -0
- package/runner/remote_launch.mjs +101 -0
- package/web/app.js +68 -13
- package/web/modern.css +1 -1
- package/web/modern.js +2402 -2070
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Room launches relayed through the website to a paired computer running the local service.
|
|
2
|
+
|
|
3
|
+
A device without the local service (a phone, a tablet) submits the same launch request the
|
|
4
|
+
connected browser would send to the local bridge; the paired computer claims it on its next
|
|
5
|
+
sync, resolves the chosen project to its own directory and runs it. The Worker backend
|
|
6
|
+
implements the same contract in ``worker.mjs``; both operate on ``local_machines`` and
|
|
7
|
+
``remote_launches``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import hmac
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
from .admin import AdminError
|
|
16
|
+
from .runtime import begin_write
|
|
17
|
+
|
|
18
|
+
MACHINE_ONLINE_SECONDS = 30
|
|
19
|
+
MACHINE_NAME_MAX = 60
|
|
20
|
+
PROJECT_NAME_MAX = 80
|
|
21
|
+
PROJECTS_MAX = 100
|
|
22
|
+
CLIENTS_MAX = 40
|
|
23
|
+
CLIENT_FIELD_MAX = 40
|
|
24
|
+
ERROR_MAX = 2000
|
|
25
|
+
MACHINE_ID = re.compile(r"[0-9a-f]{32}")
|
|
26
|
+
MACHINE_TOKEN = re.compile(r"[0-9a-f]{64}")
|
|
27
|
+
LAUNCH_ID = re.compile(r"[A-Za-z0-9_-]{1,80}")
|
|
28
|
+
PROJECT_ID = re.compile(r"[0-9a-f]{8,32}")
|
|
29
|
+
LAUNCH_STAGES = ("not_started", "unconfirmed", "created", "started")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def token_digest(token):
|
|
33
|
+
"""Hash a machine token; malformed values hash to an empty string and never match."""
|
|
34
|
+
return hashlib.sha256(token.encode()).hexdigest() if isinstance(token, str) and MACHINE_TOKEN.fullmatch(token) else ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def machine_view(row, now):
|
|
38
|
+
"""Public machine fields: projects by name and id, and which platforms it can wake."""
|
|
39
|
+
return {"id": row["id"], "name": row["name"], "projects": json.loads(row["projects"]), "clients": json.loads(row["clients"]),
|
|
40
|
+
"seen_at": row["seen_at"], "online": now - row["seen_at"] <= MACHINE_ONLINE_SECONDS}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def launch_view(row):
|
|
44
|
+
"""Report a relayed launch without its request, which carries the room's credentials."""
|
|
45
|
+
return {"id": row["id"], "machine_id": row["machine_id"], "status": row["status"],
|
|
46
|
+
"result": json.loads(row["result"]), "error": row["error"]}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def list_machines(connection, now):
|
|
50
|
+
"""Paired computers, most recently seen first."""
|
|
51
|
+
rows = connection.execute("SELECT * FROM local_machines ORDER BY seen_at DESC, created_at DESC").fetchall()
|
|
52
|
+
return {"machines": [machine_view(row, now) for row in rows]}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def pair_machine(connection, payload, now):
|
|
56
|
+
"""Pair a computer or replace its token; called by the administrator's browser connected to that computer."""
|
|
57
|
+
machine_id, token = payload.get("id"), payload.get("token")
|
|
58
|
+
name = payload.get("name").strip() if isinstance(payload.get("name"), str) else ""
|
|
59
|
+
if not isinstance(machine_id, str) or not MACHINE_ID.fullmatch(machine_id) or not token_digest(token):
|
|
60
|
+
raise ValueError("电脑编号或凭据无效")
|
|
61
|
+
if not 1 <= len(name) <= MACHINE_NAME_MAX:
|
|
62
|
+
raise ValueError(f"电脑名称需为 1–{MACHINE_NAME_MAX} 个字符")
|
|
63
|
+
begin_write(connection)
|
|
64
|
+
connection.execute("INSERT INTO local_machines (id,name,token_hash,created_at) VALUES (?,?,?,?) "
|
|
65
|
+
"ON CONFLICT(id) DO UPDATE SET name=excluded.name, token_hash=excluded.token_hash",
|
|
66
|
+
(machine_id, name, token_digest(token), now))
|
|
67
|
+
return machine_view(connection.execute("SELECT * FROM local_machines WHERE id=?", (machine_id,)).fetchone(), now)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def require_machine(connection, machine_id, token):
|
|
71
|
+
"""Authenticate the computer itself by its own token."""
|
|
72
|
+
row = connection.execute("SELECT * FROM local_machines WHERE id=?", (machine_id,)).fetchone()
|
|
73
|
+
hashed = token_digest(token)
|
|
74
|
+
if not row or not hashed or not hmac.compare_digest(row["token_hash"], hashed):
|
|
75
|
+
raise AdminError("电脑凭据无效,请在这台电脑上重新连接网站", 401)
|
|
76
|
+
return row
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def valid_projects(value):
|
|
80
|
+
"""Project names and ids only; directories never leave the computer."""
|
|
81
|
+
if not isinstance(value, list) or len(value) > PROJECTS_MAX:
|
|
82
|
+
raise ValueError(f"项目列表须为不超过 {PROJECTS_MAX} 项的列表")
|
|
83
|
+
projects = []
|
|
84
|
+
for item in value:
|
|
85
|
+
project_id, name = (item.get("id"), item.get("name")) if isinstance(item, dict) else (None, None)
|
|
86
|
+
if not isinstance(project_id, str) or not PROJECT_ID.fullmatch(project_id) or not isinstance(name, str) or not 1 <= len(name) <= PROJECT_NAME_MAX:
|
|
87
|
+
raise ValueError("项目须包含有效编号与名称")
|
|
88
|
+
projects.append({"id": project_id, "name": name})
|
|
89
|
+
return projects
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def valid_clients(value):
|
|
93
|
+
"""Map each platform the computer can wake to its configured client type."""
|
|
94
|
+
if not isinstance(value, dict) or len(value) > CLIENTS_MAX or any(
|
|
95
|
+
not isinstance(key, str) or not isinstance(item, str) or not 0 < len(key) <= CLIENT_FIELD_MAX or not 0 < len(item) <= CLIENT_FIELD_MAX
|
|
96
|
+
for key, item in value.items()):
|
|
97
|
+
raise ValueError("软件配置须为平台到客户端类型的对象")
|
|
98
|
+
return value
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def machine_sync(connection, machine_id, token, payload, now):
|
|
102
|
+
"""Record the computer's projects and clients, and hand it every launch it has not finished."""
|
|
103
|
+
require_machine(connection, machine_id, token)
|
|
104
|
+
projects, clients = valid_projects(payload.get("projects")), valid_clients(payload.get("clients"))
|
|
105
|
+
begin_write(connection)
|
|
106
|
+
connection.execute("UPDATE local_machines SET projects=?, clients=?, seen_at=? WHERE id=?",
|
|
107
|
+
(json.dumps(projects, ensure_ascii=False), json.dumps(clients, ensure_ascii=False), now, machine_id))
|
|
108
|
+
rows = connection.execute("SELECT id,request FROM remote_launches WHERE machine_id=? AND status IN ('pending','claimed') ORDER BY created_at",
|
|
109
|
+
(machine_id,)).fetchall()
|
|
110
|
+
return {"launches": [{"id": row["id"], "request": json.loads(row["request"])} for row in rows]}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def machine_launch_action(connection, machine_id, launch_id, token, payload, now):
|
|
114
|
+
"""Claim, finish or fail one launch; a finished or failed launch drops its request."""
|
|
115
|
+
require_machine(connection, machine_id, token)
|
|
116
|
+
begin_write(connection)
|
|
117
|
+
row = connection.execute("SELECT * FROM remote_launches WHERE id=? AND machine_id=?", (launch_id, machine_id)).fetchone()
|
|
118
|
+
if not row:
|
|
119
|
+
raise LookupError("启动请求不存在")
|
|
120
|
+
action = payload.get("action")
|
|
121
|
+
if action not in ("claim", "finish", "fail"):
|
|
122
|
+
raise ValueError("action 须为 claim、finish 或 fail")
|
|
123
|
+
if row["status"] not in ("pending", "claimed"):
|
|
124
|
+
raise FileExistsError("启动请求已结束")
|
|
125
|
+
if action == "claim":
|
|
126
|
+
connection.execute("UPDATE remote_launches SET status='claimed', updated_at=? WHERE id=?", (now, launch_id))
|
|
127
|
+
elif action == "finish":
|
|
128
|
+
room_id = payload.get("room_id")
|
|
129
|
+
if not isinstance(room_id, str) or not room_id:
|
|
130
|
+
raise ValueError("完成启动须提供 room_id")
|
|
131
|
+
result = {"room_id": room_id, "status": str(payload.get("status") or "")}
|
|
132
|
+
connection.execute("UPDATE remote_launches SET status='done', request='{}', result=?, error='', updated_at=? WHERE id=?",
|
|
133
|
+
(json.dumps(result), now, launch_id))
|
|
134
|
+
else:
|
|
135
|
+
stage = payload.get("stage") if payload.get("stage") in LAUNCH_STAGES else "unconfirmed"
|
|
136
|
+
result = {"stage": stage, **({"room_id": payload["room_id"]} if isinstance(payload.get("room_id"), str) else {})}
|
|
137
|
+
connection.execute("UPDATE remote_launches SET status='failed', request='{}', result=?, error=?, updated_at=? WHERE id=?",
|
|
138
|
+
(json.dumps(result), str(payload.get("error") or "启动失败")[:ERROR_MAX], now, launch_id))
|
|
139
|
+
return launch_view(connection.execute("SELECT * FROM remote_launches WHERE id=?", (launch_id,)).fetchone())
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def submit_remote_launch(connection, payload, now):
|
|
143
|
+
"""Queue a room creation for the paired computer; resubmitting the same id replays only a failed launch."""
|
|
144
|
+
launch_id, machine_id, request = payload.get("id"), payload.get("machine_id"), payload.get("request")
|
|
145
|
+
if not isinstance(launch_id, str) or not LAUNCH_ID.fullmatch(launch_id):
|
|
146
|
+
raise ValueError("启动编号无效")
|
|
147
|
+
if not isinstance(request, dict) or not isinstance(request.get("room"), dict) or request["room"].get("submission_id") != launch_id:
|
|
148
|
+
raise ValueError("远程启动只用于新建房间,room.submission_id 须与启动编号一致")
|
|
149
|
+
if request.get("cwd") or request.get("access_file"):
|
|
150
|
+
raise ValueError("远程启动只能选择电脑上已配置的项目,不能指定路径")
|
|
151
|
+
begin_write(connection)
|
|
152
|
+
machine = connection.execute("SELECT * FROM local_machines WHERE id=?", (machine_id,)).fetchone()
|
|
153
|
+
if not machine:
|
|
154
|
+
raise LookupError("这台电脑尚未连接网站")
|
|
155
|
+
project_id = request.get("local_project")
|
|
156
|
+
if project_id and project_id not in {project["id"] for project in json.loads(machine["projects"])}:
|
|
157
|
+
raise FileExistsError("所选项目已在电脑上移除或更改,请重新选择")
|
|
158
|
+
row = connection.execute("SELECT * FROM remote_launches WHERE id=?", (launch_id,)).fetchone()
|
|
159
|
+
if row and row["machine_id"] != machine_id:
|
|
160
|
+
raise FileExistsError("启动编号已用于另一台电脑")
|
|
161
|
+
if not row:
|
|
162
|
+
connection.execute("INSERT INTO remote_launches (id,machine_id,request,created_at,updated_at) VALUES (?,?,?,?,?)",
|
|
163
|
+
(launch_id, machine_id, json.dumps(request, ensure_ascii=False), now, now))
|
|
164
|
+
elif row["status"] == "failed":
|
|
165
|
+
connection.execute("UPDATE remote_launches SET status='pending', request=?, result='{}', error='', updated_at=? WHERE id=?",
|
|
166
|
+
(json.dumps(request, ensure_ascii=False), now, launch_id))
|
|
167
|
+
return launch_view(connection.execute("SELECT * FROM remote_launches WHERE id=?", (launch_id,)).fetchone())
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def remote_launch_status(connection, launch_id):
|
|
171
|
+
"""Read a relayed launch's progress."""
|
|
172
|
+
row = connection.execute("SELECT * FROM remote_launches WHERE id=?", (launch_id,)).fetchone()
|
|
173
|
+
if not row:
|
|
174
|
+
raise LookupError("启动请求不存在")
|
|
175
|
+
return launch_view(row)
|
package/backend/schema.sql
CHANGED
|
@@ -740,3 +740,28 @@ END;
|
|
|
740
740
|
-- The review phase behaved exactly like discussing and was retired; move rows left in it.
|
|
741
741
|
UPDATE topic_settings SET phase='discussing' WHERE phase='reviewing';
|
|
742
742
|
UPDATE discussion_runtime SET resume_phase='discussing' WHERE resume_phase='reviewing';
|
|
743
|
+
|
|
744
|
+
-- A computer running the local service, paired by the site administrator. Only project names and ids and the
|
|
745
|
+
-- configured client of each platform are published; directories and credentials stay on that computer.
|
|
746
|
+
CREATE TABLE IF NOT EXISTS local_machines (
|
|
747
|
+
id TEXT PRIMARY KEY,
|
|
748
|
+
name TEXT NOT NULL,
|
|
749
|
+
token_hash TEXT NOT NULL,
|
|
750
|
+
projects TEXT NOT NULL DEFAULT '[]',
|
|
751
|
+
clients TEXT NOT NULL DEFAULT '{}',
|
|
752
|
+
seen_at INTEGER NOT NULL DEFAULT 0,
|
|
753
|
+
created_at INTEGER NOT NULL
|
|
754
|
+
);
|
|
755
|
+
-- A room launch submitted from a device without the local service; the paired computer claims and runs it.
|
|
756
|
+
-- The request (with the room's new credentials) is cleared once the launch finishes or fails.
|
|
757
|
+
CREATE TABLE IF NOT EXISTS remote_launches (
|
|
758
|
+
id TEXT PRIMARY KEY,
|
|
759
|
+
machine_id TEXT NOT NULL,
|
|
760
|
+
request TEXT NOT NULL DEFAULT '{}',
|
|
761
|
+
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','claimed','done','failed')),
|
|
762
|
+
result TEXT NOT NULL DEFAULT '{}',
|
|
763
|
+
error TEXT NOT NULL DEFAULT '',
|
|
764
|
+
created_at INTEGER NOT NULL,
|
|
765
|
+
updated_at INTEGER NOT NULL
|
|
766
|
+
);
|
|
767
|
+
CREATE INDEX IF NOT EXISTS remote_launches_machine ON remote_launches(machine_id, status);
|
package/backend/server.py
CHANGED
|
@@ -21,6 +21,8 @@ from pathlib import Path
|
|
|
21
21
|
from urllib.parse import parse_qs, quote, urlsplit
|
|
22
22
|
|
|
23
23
|
from .admin import AdminError, admin_status, login_admin, logout_admin, register_admin, session_valid, update_profile
|
|
24
|
+
from .remote_launch import (list_machines, machine_launch_action, machine_sync, pair_machine, remote_launch_status,
|
|
25
|
+
submit_remote_launch)
|
|
24
26
|
from .free_room import (add_member, after_free_message, authorize_free_write, check_answer, check_proxy, execution_action, free_view,
|
|
25
27
|
inbox, interrupt_member, member_action, member_values, migrate_free_tables, proxy_member, reserve_execution,
|
|
26
28
|
discussion_members, room_mode, save_room_access, sync_discussion_members, view_execution)
|
|
@@ -1835,7 +1837,7 @@ class DiscussionHandler(BaseHTTPRequestHandler):
|
|
|
1835
1837
|
site = self.local_request_site(method)
|
|
1836
1838
|
route = parts[2:]
|
|
1837
1839
|
if method == "OPTIONS" and route in (["settings"], ["launch"], ["room-models"], ["room-member"], ["models"], ["api-key"], ["api-check"],
|
|
1838
|
-
["scheduler-check"], ["pick-directory"]):
|
|
1840
|
+
["scheduler-check"], ["pick-directory"], ["projects"], ["machine-link"]):
|
|
1839
1841
|
return self.respond({"ok": True})
|
|
1840
1842
|
if route == ["settings"] and method in ("GET", "POST"):
|
|
1841
1843
|
command = "settings"
|
|
@@ -1857,6 +1859,10 @@ class DiscussionHandler(BaseHTTPRequestHandler):
|
|
|
1857
1859
|
command = "room-member"
|
|
1858
1860
|
elif route == ["pick-directory"] and method == "POST":
|
|
1859
1861
|
command = "pick-directory"
|
|
1862
|
+
elif route == ["projects"] and method == "POST":
|
|
1863
|
+
command = "projects"
|
|
1864
|
+
elif route == ["machine-link"] and method == "POST":
|
|
1865
|
+
command = "machine-link"
|
|
1860
1866
|
else:
|
|
1861
1867
|
raise LookupError("本机入口不存在")
|
|
1862
1868
|
payload = None
|
|
@@ -1867,6 +1873,8 @@ class DiscussionHandler(BaseHTTPRequestHandler):
|
|
|
1867
1873
|
if command in ("start", "room-config", "room-models", "room-member"):
|
|
1868
1874
|
payload.pop("access_file", None)
|
|
1869
1875
|
payload.update(site=site, entry="web")
|
|
1876
|
+
if command == "machine-link":
|
|
1877
|
+
payload = {"site": site}
|
|
1870
1878
|
if command == "room-config" and "participants" in payload:
|
|
1871
1879
|
return self.save_local_room_configuration(command, payload)
|
|
1872
1880
|
return self.run_local_command(command, payload)
|
|
@@ -1943,6 +1951,30 @@ class DiscussionHandler(BaseHTTPRequestHandler):
|
|
|
1943
1951
|
connection.commit()
|
|
1944
1952
|
return self.respond(result, 201 if parts[2] == "register" else 200)
|
|
1945
1953
|
|
|
1954
|
+
def handle_remote_launch_api(self, connection, parts, method):
|
|
1955
|
+
"""Pair computers and relay room launches; a computer authenticates with its own token, everything else as the administrator."""
|
|
1956
|
+
now = int(time.time())
|
|
1957
|
+
if len(parts) in (4, 5) and parts[1] == "machines" and method == "POST" and (len(parts) == 4 and parts[3] == "sync" or parts[3:4] == ["launches"]):
|
|
1958
|
+
token = self.headers.get("X-Machine-Token", "")
|
|
1959
|
+
result = (machine_sync(connection, parts[2], token, self.read_payload(), now) if len(parts) == 4
|
|
1960
|
+
else machine_launch_action(connection, parts[2], parts[4], token, self.read_payload(), now))
|
|
1961
|
+
connection.commit()
|
|
1962
|
+
return self.respond(result)
|
|
1963
|
+
if not self.admin_session(connection):
|
|
1964
|
+
raise AdminError("需要登录管理员账号", 401)
|
|
1965
|
+
if parts == ["api", "machines"] and method == "GET":
|
|
1966
|
+
return self.respond(list_machines(connection, now))
|
|
1967
|
+
if parts == ["api", "machines"] and method == "POST":
|
|
1968
|
+
result = pair_machine(connection, self.read_payload(), now)
|
|
1969
|
+
elif parts == ["api", "remote-launches"] and method == "POST":
|
|
1970
|
+
result = submit_remote_launch(connection, self.read_payload(), now)
|
|
1971
|
+
elif len(parts) == 3 and parts[1] == "remote-launches" and method == "GET":
|
|
1972
|
+
return self.respond(remote_launch_status(connection, parts[2]))
|
|
1973
|
+
else:
|
|
1974
|
+
raise LookupError("地址不存在")
|
|
1975
|
+
connection.commit()
|
|
1976
|
+
return self.respond(result)
|
|
1977
|
+
|
|
1946
1978
|
def handle_api(self, connection, parts, params, method):
|
|
1947
1979
|
"""Handle room, state, and message operations used by the web UI."""
|
|
1948
1980
|
if parts == ["api", "health"] and method == "GET":
|
|
@@ -1953,6 +1985,8 @@ class DiscussionHandler(BaseHTTPRequestHandler):
|
|
|
1953
1985
|
if not self.admin_session(connection):
|
|
1954
1986
|
raise AdminError("需要登录管理员账号", 401)
|
|
1955
1987
|
return self.respond(list_feedback_cases(connection, int(params.get("after", 0))))
|
|
1988
|
+
if parts[1:2] in (["machines"], ["remote-launches"]):
|
|
1989
|
+
return self.handle_remote_launch_api(connection, parts, method)
|
|
1956
1990
|
if parts == ["api", "rooms"]:
|
|
1957
1991
|
if method == "GET":
|
|
1958
1992
|
return self.respond({**list_rooms(connection, params), "participants": PARTICIPANTS})
|
package/docs/architecture.md
CHANGED
|
@@ -145,6 +145,8 @@ Python 在一个只读事务快照中查询;D1 使用同一个 batch 事务读
|
|
|
145
145
|
|
|
146
146
|
`frontend/` 保存 Vue、Markdown 和备份源码;`web/` 保存共享控制器、HTML 入口、图标和构建产物。生成产物随仓库提供,保证仅安装 Python 的用户也能运行网页;不在生成文件中手工修改功能。
|
|
147
147
|
|
|
148
|
+
新建房间表单须至少选中一位成员,决策模型不计入成员;创建按钮与表单提交(含 Enter)共用就绪校验,防止键盘绕过禁用状态。成员添加行紧跟“谁来参与”标题,接入方式由卡片上的“程序启动 / 手动启动”选项表达。
|
|
149
|
+
|
|
148
150
|
| 源码 | 职责 |
|
|
149
151
|
| --- | --- |
|
|
150
152
|
| `main.js`、`App.vue`、`ChannelWorkspace.vue` | 协作频道入口、主题与整体布局 |
|
package/docs/runner.md
CHANGED
|
@@ -92,7 +92,7 @@ codiscuss local stop
|
|
|
92
92
|
- **本机软件默认配置**:按参与者保存 CLI / App 类型、原生可执行文件、App 来源聊天 / Sidecar、可选工具许可及软件项目 / 目录。保存在服务目录的 `launch-settings.json`,两个入口复用;不保存全局当前房间、身份或会话 ID。
|
|
93
93
|
- **单场配置**:议题、主持人、专家和上限由房间保存;本次软件覆盖、实际会话绑定、项目 / cwd 与凭据只保存在服务目录 `launches/` 下。默认新建参与会话,显式 `session_id` 才复用;对话入口的主持使用当前实际会话。
|
|
94
94
|
|
|
95
|
-
可执行文件、cwd 和 Sidecar
|
|
95
|
+
可执行文件、cwd 和 Sidecar 目录须填写完整绝对路径,避免从不同入口启动时解析到不同位置。公共 cwd 可从“项目”列表选择:项目名称与绝对路径保存在服务目录的 `launch-projects.json`(`codiscuss launch projects`,保存时整体替换并核对每个路径是这台电脑上已存在的文件夹),只在本机,不上传网站。本机网页另有文件夹按钮,在本机弹出系统目录选择窗口;已连接的线上网页不显示该按钮,因为窗口只会出现在运行服务的电脑上。网页直接显示本次目录、会话新建 / 复用及项目含义;Codex / Claude CLI 的项目名是任务说明,不代表关联 App 项目,未指定目录时使用本次讨论的独立草稿目录。网页成功启动或恢复原启动后,清除与已提交快照相同的单场会话 ID、公共 cwd / 项目草稿,保留等待期间的新编辑;创建并启动失败时保留表单和原草稿,在表单内显示具体参与者的错误,原配置重试沿用提交编号。
|
|
96
96
|
|
|
97
97
|
单场省略参与者字段表示继承本机默认值;明确将该 AI 的 `cwd`、`project_name` 或 `project_id` 设为空字符串,则取消该字段的默认覆盖,采用本次公共目录 / 项目或软件默认位置。网页清空字段也遵循此规则;不点击保存默认配置就不会改动后续讨论的默认值。
|
|
98
98
|
|
|
@@ -140,7 +140,7 @@ codiscuss local connect https://你的站点
|
|
|
140
140
|
|
|
141
141
|
`connect` 只接受 HTTPS 根地址(本机验证允许 `http://127.0.0.1:端口` 或 `http://localhost:端口`),不接受用户名、密码、路径、查询或片段。它启动或复用本机网站及讨论服务,为该站点生成独立的随机连接凭据,打开线上网页。页面从链接片段读取连接后立即清除地址栏片段,保存到该站点的浏览器本地存储;片段不会发送到线上 HTTP 服务。`--no-open` 只输出连接链接供手动打开,链接具有本机配置与启动权限,不应分享。
|
|
142
142
|
|
|
143
|
-
连接成功后,线上页面可以读取 / 保存本机软件默认配置、新建并启动讨论、启动本机尚未登记的现有未启动房间;程序复用原有持久启动日志和会话绑定。首次连接可能需要允许浏览器访问本机网络;[Chrome 的 Local Network Access 说明](https://developer.chrome.com/blog/local-network-access)规定了这类访问的权限要求。须在运行 AI
|
|
143
|
+
连接成功后,线上页面可以读取 / 保存本机软件默认配置、新建并启动讨论、启动本机尚未登记的现有未启动房间;程序复用原有持久启动日志和会话绑定。首次连接可能需要允许浏览器访问本机网络;[Chrome 的 Local Network Access 说明](https://developer.chrome.com/blog/local-network-access)规定了这类访问的权限要求。须在运行 AI 的同一台电脑和保存连接的浏览器中使用;手机、另一台电脑、未获本机网络权限的浏览器不直接访问本机服务,只能经下文的[已配对电脑](#其他设备经已配对电脑建房)建房。已有的线上房间事件消费方式不变。
|
|
144
144
|
|
|
145
145
|
软件路径、连接凭据与启动配置由浏览器直接发到 `http://127.0.0.1:<本机端口>/api/local/`,不经过线上服务器。服务目录的 `site-connections.json` 仅保存站点来源及凭据 SHA-256;每次请求重新核对来源、凭据、回环 Host 和自定义头。CORS 仅返回已登记的精确 Origin,实际请求仍必须提供凭据;预检不启动进程或写配置。启动目标固定为已授权 Origin,忽略请求中伪造的 `site`、`entry` 和 `access_file`。原有控制凭据、去重与暂停后不自动恢复规则继续生效。
|
|
146
146
|
|
|
@@ -150,7 +150,19 @@ codiscuss local connect https://你的站点
|
|
|
150
150
|
codiscuss local disconnect https://你的站点
|
|
151
151
|
```
|
|
152
152
|
|
|
153
|
-
|
|
153
|
+
撤销阻止该站点继续读取配置或发起新启动,同时解除该站点的电脑配对,不终止已运行的讨论,须另行暂停。连接失败或响应丢失时保留原启动待办,恢复后重试原请求;不换编号重建。线上连接暂不开放 `/api/local/room-config`,既有房间的路径修正仍限本机网站的数据库锁保护流程。未连接浏览器仍可使用[下载配置与登记](deployment.md#可选的全自动接入)方式。
|
|
154
|
+
|
|
155
|
+
### 其他设备经已配对电脑建房
|
|
156
|
+
|
|
157
|
+
手机、平板等没有本机服务的设备,登录同一网站的管理员账号后,可以新建由电脑唤醒成员的房间。流程:
|
|
158
|
+
|
|
159
|
+
1. **配对**:管理员在已 `connect` 的电脑浏览器中登录后,页面自动向本机服务取得这台电脑的编号与随机凭据(服务目录 `machine-links.json`,按站点各一份),再以管理员身份登记到网站(`POST /api/machines`)。网站只保存凭据 SHA-256。
|
|
160
|
+
2. **同步**:讨论服务每 3 秒用电脑凭据调用 `POST /api/machines/<编号>/sync`,上报常用项目的编号与名称、各平台已配置的客户端类型,并取回待执行的启动。路径、软件可执行文件与 API 密钥不上传。
|
|
161
|
+
3. **提交**:其他设备读取 `GET /api/machines`(先了解),在建房表单里选择项目与成员,把与已连接浏览器相同的启动请求(含新房间凭据,项目以 `local_project` 编号表示)提交到 `POST /api/remote-launches`(再输出)。网站拒绝携带 `cwd` / `access_file` 的请求,项目编号不在电脑最近上报的列表中时返回 409(程序兜底)。
|
|
162
|
+
4. **执行**:电脑领取(`claim`)后把项目编号换成本机路径,补齐本机保存的 API 密钥引用,按网页启动同一流程(同一 `submission_id` 的持久日志,可安全重试)创建并登记房间,再回报 `finish` 或 `fail`(附失败阶段)。请求结束后网站清空其中的凭据;失败的请求以同一编号重新提交即可重试。
|
|
163
|
+
5. **等待**:提交方最多等待 45 秒;电脑离线或尚未领取时保留在启动待办中,电脑上线后继续。
|
|
164
|
+
|
|
165
|
+
管理员才能列出电脑、配对和提交,只有持有电脑凭据的服务能领取。常用项目在电脑浏览器的“项目目录”控件里管理(`codiscuss launch projects`),保存时核对每个路径是这台电脑上已存在的文件夹;项目编号在改名、改路径后保持不变。
|
|
154
166
|
|
|
155
167
|
### 从 AI 对话发起
|
|
156
168
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai_co_discuss",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.74",
|
|
4
4
|
"bin": {
|
|
5
5
|
"codiscuss": "runner/codiscuss.mjs"
|
|
6
6
|
},
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"dev": "npm run build && wrangler pages dev --ip 127.0.0.1 --port 8787",
|
|
9
9
|
"db:local": "wrangler d1 execute DB --local --file backend/schema.sql",
|
|
10
10
|
"db:remote": "wrangler d1 execute DB --remote --file backend/schema.sql",
|
|
11
|
-
"check": "node --check backend/worker.mjs && node --check web/app.js && node --check runner/discussion_runner.mjs && node --check runner/discussion_adapters.mjs && node --check runner/discussion_sidecar.mjs && node --check runner/codex_app_client.mjs && node --check runner/discussion_service.mjs && node --check runner/codiscuss.mjs && node --check runner/discussion_launch.mjs && node --check runner/discussion_models.mjs && node --check runner/discussion_setup.mjs && node --check runner/discussion_skills.mjs && node --check runner/discussion_tui.mjs && node --check runner/discussion_local.mjs && node --check runner/free_runner.mjs && node --check runner/discussion_api.mjs && node --check runner/api_participant.mjs && node --check runner/scheduler.mjs",
|
|
11
|
+
"check": "node --check backend/worker.mjs && node --check web/app.js && node --check runner/discussion_runner.mjs && node --check runner/discussion_adapters.mjs && node --check runner/discussion_sidecar.mjs && node --check runner/codex_app_client.mjs && node --check runner/discussion_service.mjs && node --check runner/codiscuss.mjs && node --check runner/discussion_launch.mjs && node --check runner/discussion_models.mjs && node --check runner/discussion_setup.mjs && node --check runner/discussion_skills.mjs && node --check runner/discussion_tui.mjs && node --check runner/discussion_local.mjs && node --check runner/free_runner.mjs && node --check runner/discussion_api.mjs && node --check runner/api_participant.mjs && node --check runner/scheduler.mjs && node --check runner/remote_launch.mjs",
|
|
12
12
|
"runner": "node runner/discussion_runner.mjs",
|
|
13
13
|
"service": "node runner/discussion_service.mjs",
|
|
14
14
|
"build:ui": "vite build --config frontend/vite.config.mjs --mode markdown && vite build --config frontend/vite.config.mjs --mode backup && vite build --config frontend/vite.config.mjs",
|
|
@@ -50,6 +50,8 @@ const HELP = `用法:codiscuss launch settings [--file FILE]
|
|
|
50
50
|
codiscuss launch scheduler-check --file FILE
|
|
51
51
|
codiscuss launch room-member --file FILE
|
|
52
52
|
codiscuss launch pick-directory
|
|
53
|
+
codiscuss launch projects [--file FILE]
|
|
54
|
+
codiscuss launch machine-link --file FILE
|
|
53
55
|
|
|
54
56
|
settings 读取本机软件默认配置;指定文件时保存,不启动 AI。
|
|
55
57
|
start 使用启动 JSON 创建或接入房间并启动;--dry-run 只校验和预览。
|
|
@@ -60,6 +62,8 @@ api-key 将 base_url、api_key 保存于本机私有目录,只返回 api_key_r
|
|
|
60
62
|
api-check 用 API profile 向模型发送一次极短请求,核对地址、密钥、协议与模型是否可用。
|
|
61
63
|
scheduler-check 用智能调度配置(provider、model、api_key_ref)向决策模型发送一道是非题,核对供应商、模型与密钥是否可用。
|
|
62
64
|
pick-directory 在本机弹出系统目录选择窗口,返回所选路径;取消时不返回路径。
|
|
65
|
+
projects 读取本机已配置的项目(名称与绝对路径);提供 JSON(projects 列表)时整体替换保存。
|
|
66
|
+
machine-link 返回本机连接指定 site 的电脑编号、名称与凭据,供已登录管理员的网页完成配对;不存在时创建。
|
|
63
67
|
room-member 为运行中的自由房间或讨论邀请、请回一位成员(讨论只接受本机唤醒的成员);须提供 site、room_id、token、action(add/rejoin)、name。
|
|
64
68
|
JSON 指定 site、entry(conversation/web)、room(建房内容,含 submission_id)
|
|
65
69
|
或 room_id(已有房间)、participants(本次软件覆盖配置)。
|
|
@@ -71,7 +75,9 @@ conversation 还须 current_session_id,当前会话接手首个主持任务。
|
|
|
71
75
|
// Windows PowerShell 5.1 ships WinForms. A background process cannot take the foreground, so the owner must be
|
|
72
76
|
// shown (off-screen) as a topmost window; the dialog it owns then stays above the browser that asked for it.
|
|
73
77
|
// windowsHide turns the process's first Show into a hide, so the owner is shown a second time to really appear.
|
|
74
|
-
//
|
|
78
|
+
// Under the local service the whole process chain has no console and no foreground rights: Windows then silently ignores
|
|
79
|
+
// SetWindowPos(HWND_TOPMOST) on the dialog (it returns success). So once the dialog exists, a timer in its message loop
|
|
80
|
+
// briefly attaches to the foreground window's input thread (the browser that asked) to borrow its right to raise the dialog.
|
|
75
81
|
// The C# source must not contain single quotes: it is embedded in a single-quoted PowerShell string.
|
|
76
82
|
const PICKER_RAISE_SOURCE = [
|
|
77
83
|
'using System; using System.Runtime.InteropServices;',
|
|
@@ -80,8 +86,21 @@ const PICKER_RAISE_SOURCE = [
|
|
|
80
86
|
' [DllImport("user32.dll")] static extern bool EnumWindows(EnumProc proc, IntPtr arg);',
|
|
81
87
|
' [DllImport("user32.dll")] static extern IntPtr GetWindow(IntPtr handle, uint command);',
|
|
82
88
|
' [DllImport("user32.dll")] static extern bool SetWindowPos(IntPtr handle, IntPtr after, int x, int y, int width, int height, uint flags);',
|
|
83
|
-
'
|
|
84
|
-
'
|
|
89
|
+
' [DllImport("user32.dll")] static extern IntPtr GetForegroundWindow();',
|
|
90
|
+
' [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr handle, IntPtr process);',
|
|
91
|
+
' [DllImport("kernel32.dll")] static extern uint GetCurrentThreadId();',
|
|
92
|
+
' [DllImport("user32.dll")] static extern bool AttachThreadInput(uint attach, uint to, bool on);',
|
|
93
|
+
' [DllImport("user32.dll")] static extern bool BringWindowToTop(IntPtr handle);',
|
|
94
|
+
' [DllImport("user32.dll")] static extern bool SetForegroundWindow(IntPtr handle);',
|
|
95
|
+
' public static bool OwnedBy(IntPtr owner) {',
|
|
96
|
+
' IntPtr dialog = IntPtr.Zero;',
|
|
97
|
+
' EnumWindows((handle, arg) => { if (GetWindow(handle, 4) == owner) dialog = handle; return dialog == IntPtr.Zero; }, IntPtr.Zero);',
|
|
98
|
+
' if (dialog == IntPtr.Zero) return false;',
|
|
99
|
+
' uint current = GetCurrentThreadId(), foreground = GetWindowThreadProcessId(GetForegroundWindow(), IntPtr.Zero);',
|
|
100
|
+
' bool attached = foreground != 0 && foreground != current && AttachThreadInput(current, foreground, true);',
|
|
101
|
+
' SetWindowPos(dialog, (IntPtr)(-1), 0, 0, 0, 0, 0x13); BringWindowToTop(dialog); SetForegroundWindow(dialog);',
|
|
102
|
+
' if (attached) AttachThreadInput(current, foreground, false);',
|
|
103
|
+
' return true;',
|
|
85
104
|
' }',
|
|
86
105
|
'}',
|
|
87
106
|
].join(' ');
|
|
@@ -91,7 +110,7 @@ const WINDOWS_PICKER = [
|
|
|
91
110
|
'$owner = New-Object System.Windows.Forms.Form -Property @{ TopMost = $true; ShowInTaskbar = $false; FormBorderStyle = "None"; StartPosition = "Manual"; Location = New-Object System.Drawing.Point(-32000, -32000); Size = New-Object System.Drawing.Size(1, 1) }',
|
|
92
111
|
`Add-Type -TypeDefinition '${PICKER_RAISE_SOURCE}'`,
|
|
93
112
|
'$owner.Show(); $owner.Hide(); $owner.Show(); $owner.Activate()',
|
|
94
|
-
'$timer = New-Object System.Windows.Forms.Timer -Property @{ Interval = 150 }; $timer.Add_Tick({ [PickerRaise]::OwnedBy($owner.Handle) }); $timer.Start()',
|
|
113
|
+
'$timer = New-Object System.Windows.Forms.Timer -Property @{ Interval = 150 }; $timer.Add_Tick({ if ([PickerRaise]::OwnedBy($owner.Handle)) { $timer.Stop() } }); $timer.Start()',
|
|
95
114
|
'$dialog = New-Object System.Windows.Forms.FolderBrowserDialog -Property @{ Description = "选择项目目录"; ShowNewFolderButton = $true }',
|
|
96
115
|
'if ($dialog.ShowDialog($owner) -eq "OK") { $dialog.SelectedPath }',
|
|
97
116
|
'$owner.Close()',
|
|
@@ -188,6 +207,34 @@ export function save_launch_settings(home, input) {
|
|
|
188
207
|
return settings;
|
|
189
208
|
}
|
|
190
209
|
|
|
210
|
+
const PROJECT_NAME_LIMIT = 80;
|
|
211
|
+
const PROJECT_ID_PATTERN = /^[0-9a-f]{8,32}$/;
|
|
212
|
+
|
|
213
|
+
/** Read the named project directories saved on this computer, offered as launch cwd choices. */
|
|
214
|
+
export function launch_projects(home) {
|
|
215
|
+
const path = join(home, 'launch-projects.json');
|
|
216
|
+
return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : { projects: [] };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Replace the project list; every path must be an existing folder on this computer. */
|
|
220
|
+
// The id outlives renames and moves: other devices choose a project by id and never see its path.
|
|
221
|
+
export function save_launch_projects(home, input) {
|
|
222
|
+
if (!Array.isArray(input.projects)) throw new Error('projects 必须为列表');
|
|
223
|
+
const projects = input.projects.map(item => ({ id: PROJECT_ID_PATTERN.test(item?.id) ? item.id : randomUUID().replaceAll('-', '').slice(0, 16),
|
|
224
|
+
name: String(item?.name || '').trim(), path: String(item?.path || '').trim() }));
|
|
225
|
+
const names = new Set();
|
|
226
|
+
for (const { name, path } of projects) {
|
|
227
|
+
if (!name || name.length > PROJECT_NAME_LIMIT) throw new Error(`项目名称不能为空,且不超过 ${PROJECT_NAME_LIMIT} 字`);
|
|
228
|
+
if (names.has(name)) throw new Error(`项目名称重复:${name}`);
|
|
229
|
+
names.add(name);
|
|
230
|
+
if (!isAbsolute(path)) throw new Error(`${name}:路径必须为完整绝对路径`);
|
|
231
|
+
if (!existsSync(path) || !statSync(path).isDirectory()) throw new Error(`${name}:这台电脑上不存在此文件夹:${path}`);
|
|
232
|
+
}
|
|
233
|
+
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
234
|
+
atomic_json(join(home, 'launch-projects.json'), { projects });
|
|
235
|
+
return { projects };
|
|
236
|
+
}
|
|
237
|
+
|
|
191
238
|
/** Reject malformed client fields before saving defaults or creating any discussion. */
|
|
192
239
|
function validate_profile(name, profile, binding = false) {
|
|
193
240
|
if (!clients_of(name) || !profile || typeof profile !== 'object' || Array.isArray(profile)) throw new Error(`不支持的软件配置:${name}`);
|
|
@@ -775,7 +822,8 @@ export async function launch_main(args = process.argv.slice(2), input_text) {
|
|
|
775
822
|
assert_shared_home(home);
|
|
776
823
|
let result;
|
|
777
824
|
if (positionals[0] === 'settings') result = { ...(input ? save_launch_settings(home, input) : launch_settings(home)), api_providers: API_PROVIDERS,
|
|
778
|
-
scheduler_providers: SCHEDULER_PROVIDERS, api_keys: saved_api_keys(home) };
|
|
825
|
+
scheduler_providers: SCHEDULER_PROVIDERS, api_keys: saved_api_keys(home), projects: launch_projects(home).projects };
|
|
826
|
+
else if (positionals[0] === 'projects') result = input ? save_launch_projects(home, input) : launch_projects(home);
|
|
779
827
|
else if (positionals[0] === 'api-key' && input) result = save_api_key(home, input);
|
|
780
828
|
else if (positionals[0] === 'api-check' && input) result = await api_check(home, input);
|
|
781
829
|
else if (positionals[0] === 'scheduler-check' && input) result = await scheduler_check(home, input);
|
|
@@ -788,6 +836,7 @@ export async function launch_main(args = process.argv.slice(2), input_text) {
|
|
|
788
836
|
}
|
|
789
837
|
else if (positionals[0] === 'room-member' && input) result = await room_member(home, input);
|
|
790
838
|
else if (positionals[0] === 'pick-directory') result = await pick_directory();
|
|
839
|
+
else if (positionals[0] === 'machine-link' && input) result = (await import('./remote_launch.mjs')).machine_link(home, input.site);
|
|
791
840
|
else if (positionals[0] === 'start' && input) result = await launch_discussion(home, input, { dry_run: values['dry-run'], browser_request: values.browser });
|
|
792
841
|
else if (positionals[0] === 'room-config' && input) result = await room_configuration(home, input, { local_guard: values['configuration-locked'] });
|
|
793
842
|
else throw new Error(HELP);
|
|
@@ -11,6 +11,7 @@ import { atomic_json } from './discussion_adapters.mjs';
|
|
|
11
11
|
import { acquire_lock } from './discussion_runner.mjs';
|
|
12
12
|
import { assert_shared_home, main as service_main, service_home, service_pid, service_ready, start_service } from './discussion_service.mjs';
|
|
13
13
|
import { detect_python, local_settings } from './discussion_setup.mjs';
|
|
14
|
+
import { unlink_machine } from './remote_launch.mjs';
|
|
14
15
|
import { tone } from './discussion_tui.mjs';
|
|
15
16
|
|
|
16
17
|
const SCRIPT = fileURLToPath(import.meta.url);
|
|
@@ -25,7 +26,7 @@ const LOCAL_HELP = `用法:codiscuss local open|start|status|stop [--json]
|
|
|
25
26
|
codiscuss local disconnect SITE
|
|
26
27
|
|
|
27
28
|
connect 允许指定 HTTPS 站点通过此浏览器使用本机软件配置并启动 AI,打开连接链接完成接入。
|
|
28
|
-
disconnect
|
|
29
|
+
disconnect 撤销该站点的新启动权限(含其他设备经网站转来的启动);已运行的讨论须另行暂停。
|
|
29
30
|
数据保留在服务目录中;重新 connect 会替换该站点的旧连接。
|
|
30
31
|
结果摘要写入终端;输出被重定向或加 --json 时 JSON 写入 stdout。
|
|
31
32
|
`;
|
|
@@ -48,7 +49,7 @@ export function save_site_connection(home, site, token = null) {
|
|
|
48
49
|
const path = join(home, 'site-connections.json');
|
|
49
50
|
const settings = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : { sites: {} };
|
|
50
51
|
if (token) settings.sites[origin] = { token_hash: createHash('sha256').update(token).digest('hex') };
|
|
51
|
-
else delete settings.sites[origin];
|
|
52
|
+
else { delete settings.sites[origin]; unlink_machine(home, origin); }
|
|
52
53
|
atomic_json(path, settings);
|
|
53
54
|
} finally { release(); }
|
|
54
55
|
}
|
|
@@ -13,6 +13,7 @@ import { DiscussionRunner, DiscussionStore, acquire_lock, api, load_config, paus
|
|
|
13
13
|
import { FreeRoomRunner } from './free_runner.mjs';
|
|
14
14
|
|
|
15
15
|
const POLL_MS = 2000;
|
|
16
|
+
const REMOTE_SYNC_MS = 3000;
|
|
16
17
|
const START_WAIT_MS = 5000;
|
|
17
18
|
const STOP_WAIT_MS = 20_000;
|
|
18
19
|
const REGISTRY_WAIT_MS = 5000;
|
|
@@ -400,8 +401,15 @@ export async function run_service(home, output = () => {}) {
|
|
|
400
401
|
log_line(`讨论服务已启动 pid=${process.pid}`);
|
|
401
402
|
atomic_json(join(home, 'status.json'), { pid: process.pid, ready: true,
|
|
402
403
|
updated_at: Math.floor(Date.now() / 1000), rooms: [] });
|
|
404
|
+
// Loaded lazily: the relay runs launches, and the launch module itself depends on this one.
|
|
405
|
+
const { sync_remote_launches } = await import('./remote_launch.mjs');
|
|
406
|
+
let synced_at = 0;
|
|
403
407
|
while (!stopped && !existsSync(stop_path)) {
|
|
404
408
|
try { await service.tick(); } catch (error) { log_line(`服务检查失败:${error.message}`); }
|
|
409
|
+
if (Date.now() - synced_at >= REMOTE_SYNC_MS) {
|
|
410
|
+
synced_at = Date.now();
|
|
411
|
+
try { await sync_remote_launches(home, { log: log_line }); } catch (error) { log_line(`远程启动检查失败:${error.message}`); }
|
|
412
|
+
}
|
|
405
413
|
await sleep(POLL_MS);
|
|
406
414
|
}
|
|
407
415
|
} finally {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/** Run room launches that other devices relay through a paired website; directories and credentials stay here. */
|
|
2
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { hostname } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { atomic_json } from './discussion_adapters.mjs';
|
|
8
|
+
import { saved_api_keys } from './discussion_api.mjs';
|
|
9
|
+
import { launch_discussion, launch_projects, launch_settings, platform_of } from './discussion_launch.mjs';
|
|
10
|
+
|
|
11
|
+
const MACHINE_TOKEN_BYTES = 32;
|
|
12
|
+
const MACHINE_NAME_MAX = 60;
|
|
13
|
+
const SYNC_TIMEOUT_MS = 10000;
|
|
14
|
+
const LINKS_FILE = 'machine-links.json';
|
|
15
|
+
|
|
16
|
+
/** Read every site this computer is paired with. */
|
|
17
|
+
export function machine_links(home) {
|
|
18
|
+
const path = join(home, LINKS_FILE);
|
|
19
|
+
return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : { sites: {} };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Return this computer's identity for one site, creating it on first use; the browser then pairs it as administrator. */
|
|
23
|
+
export function machine_link(home, site) {
|
|
24
|
+
const origin = new URL(site).origin;
|
|
25
|
+
const links = machine_links(home);
|
|
26
|
+
links.sites[origin] ||= { id: randomUUID().replaceAll('-', ''), token: randomBytes(MACHINE_TOKEN_BYTES).toString('hex'),
|
|
27
|
+
name: (hostname() || '本机').slice(0, MACHINE_NAME_MAX) };
|
|
28
|
+
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
29
|
+
atomic_json(join(home, LINKS_FILE), links);
|
|
30
|
+
return links.sites[origin];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Forget a site's pairing so it can no longer hand launches to this computer. */
|
|
34
|
+
export function unlink_machine(home, site) {
|
|
35
|
+
const links = machine_links(home);
|
|
36
|
+
if (!links.sites[site]) return;
|
|
37
|
+
delete links.sites[site];
|
|
38
|
+
atomic_json(join(home, LINKS_FILE), links);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Resolve the API key saved for a profile's base URL, as the connected browser does. */
|
|
42
|
+
function api_key_ref(profile, keys) {
|
|
43
|
+
if (profile.api_key_ref || !profile.base_url) return profile.api_key_ref;
|
|
44
|
+
try { return keys[new URL(profile.base_url).href.replace(/\/+$/, '')]; } catch { return undefined; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Platforms this computer can wake without further setup, and the client each uses. */
|
|
48
|
+
export function ready_clients(home) {
|
|
49
|
+
const keys = saved_api_keys(home);
|
|
50
|
+
return Object.fromEntries(Object.entries(launch_settings(home).participants).filter(([, profile]) => profile.adapter === 'api'
|
|
51
|
+
? Boolean(profile.base_url && profile.api_protocol && profile.model && api_key_ref(profile, keys))
|
|
52
|
+
: Boolean(profile.adapter && (profile.adapter === 'antigravity' ? profile.ipc_directory : profile.executable))).map(([name, profile]) => [name, profile.adapter]));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Turn a relayed request into the request the connected browser would send, using this computer's directory and keys. */
|
|
56
|
+
export function local_request(home, site, request) {
|
|
57
|
+
const { local_project, ...input } = request;
|
|
58
|
+
if (input.cwd || input.access_file) throw new Error('远程启动只能选择电脑上已配置的项目');
|
|
59
|
+
const project = local_project ? launch_projects(home).projects.find(item => item.id === local_project) : null;
|
|
60
|
+
if (local_project && !project) throw new Error('所选项目已从这台电脑移除,请重新选择');
|
|
61
|
+
const keys = saved_api_keys(home), defaults = launch_settings(home).participants;
|
|
62
|
+
const participants = { ...(input.participants || {}) };
|
|
63
|
+
for (const member of input.room?.members || []) {
|
|
64
|
+
const profile = defaults[platform_of(member.name)];
|
|
65
|
+
const ref = profile?.adapter === 'api' && !participants[member.name]?.api_key_ref ? api_key_ref(profile, keys) : undefined;
|
|
66
|
+
if (ref) participants[member.name] = { ...participants[member.name], api_key_ref: ref };
|
|
67
|
+
}
|
|
68
|
+
return { ...input, participants, site, entry: 'web', ...(project ? { cwd: project.path } : {}) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function machine_request(site, link, path, body) {
|
|
72
|
+
const response = await fetch(`${site}/api/machines/${link.id}${path}`, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(SYNC_TIMEOUT_MS),
|
|
73
|
+
headers: { 'Content-Type': 'application/json', 'X-Machine-Token': link.token }, body: JSON.stringify(body) });
|
|
74
|
+
const result = await response.json();
|
|
75
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}: ${result.error || '请求失败'}`);
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Publish projects and clients to each paired site, then run every launch it relays, one at a time. */
|
|
80
|
+
export async function sync_remote_launches(home, { launch = launch_discussion, log = () => {} } = {}) {
|
|
81
|
+
const projects = launch_projects(home).projects.filter(project => project.id).map(({ id, name }) => ({ id, name }));
|
|
82
|
+
const clients = ready_clients(home);
|
|
83
|
+
for (const [site, link] of Object.entries(machine_links(home).sites)) {
|
|
84
|
+
let launches;
|
|
85
|
+
try { ({ launches } = await machine_request(site, link, '/sync', { projects, clients })); }
|
|
86
|
+
catch (error) { log(`${site}: 同步电脑状态失败:${error.message}`); continue; }
|
|
87
|
+
for (const { id, request } of launches) {
|
|
88
|
+
const action = body => machine_request(site, link, `/launches/${encodeURIComponent(id)}`, body);
|
|
89
|
+
try {
|
|
90
|
+
await action({ action: 'claim' });
|
|
91
|
+
const result = await launch(home, local_request(home, site, request), { browser_request: true });
|
|
92
|
+
await action({ action: 'finish', room_id: result.room.id, status: result.status });
|
|
93
|
+
log(`${site}: 远程启动 ${id} 已创建房间 ${result.room.id}`);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
log(`${site}: 远程启动 ${id} 失败:${error.message}`);
|
|
96
|
+
await action({ action: 'fail', error: error.message, stage: error.launch?.stage || 'not_started',
|
|
97
|
+
...(error.launch?.room_id ? { room_id: error.launch.room_id } : {}) }).catch(() => {});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|