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.
@@ -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)
@@ -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})
@@ -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 目录须填写完整绝对路径,避免从不同入口启动时解析到不同位置。网页直接显示本次目录、会话新建 / 复用及项目含义;Codex / Claude CLI 的项目名是任务说明,不代表关联 App 项目,未指定目录时使用本次讨论的独立草稿目录。网页成功启动或恢复原启动后,清除与已提交快照相同的单场会话 ID、公共 cwd / 项目草稿,保留等待期间的新编辑;创建并启动失败时保留表单和原草稿,在表单内显示具体参与者的错误,原配置重试沿用提交编号。
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
- 撤销阻止该站点继续读取配置或发起新启动,不终止已运行的讨论,须另行暂停。连接失败或响应丢失时保留原启动待办,恢复后重试原请求;不换编号重建。线上连接暂不开放 `/api/local/room-config`,既有房间的路径修正仍限本机网站的数据库锁保护流程。未连接浏览器仍可使用[下载配置与登记](deployment.md#可选的全自动接入)方式。
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.72",
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
- // Foreground rights are not guaranteed to a background process either, so a timer in the dialog's message loop pins the dialog owned by the hidden form topmost.
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
- ' public static void OwnedBy(IntPtr owner) {',
84
- ' EnumWindows((handle, arg) => { if (GetWindow(handle, 4) == owner) SetWindowPos(handle, (IntPtr)(-1), 0, 0, 0, 0, 0x13); return true; }, IntPtr.Zero);',
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
+ }