ai_co_discuss 0.2.72 → 0.2.73
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 +128 -185
- package/README.zh-CN.md +122 -0
- package/backend/remote_launch.py +175 -0
- package/backend/schema.sql +25 -0
- package/backend/server.py +35 -1
- 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 +2404 -2068
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/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.73",
|
|
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
|
+
}
|
package/web/app.js
CHANGED
|
@@ -28,6 +28,9 @@ const MEMBER_ACCESS_PREFIX = "discuss-members:";
|
|
|
28
28
|
const MEMBER_PENDING_PREFIX = "discuss-member-pending:";
|
|
29
29
|
const LOCAL_CONNECTION_KEY = "discuss-local-connection";
|
|
30
30
|
const LOCAL_CONNECTION_TIMEOUT_MS = 15000;
|
|
31
|
+
// A relayed launch waits this long for the paired computer before the request is kept as a pending item.
|
|
32
|
+
const RELAY_WAIT_MS = 45000;
|
|
33
|
+
const RELAY_POLL_MS = 1500;
|
|
31
34
|
const MODEL_CATALOG_TIMEOUT_MS = 35000;
|
|
32
35
|
let local_connection = null;
|
|
33
36
|
const DEFAULT_USER_LABEL = "User";
|
|
@@ -71,7 +74,7 @@ state.directory_pending = new Map();
|
|
|
71
74
|
state.directory_version = 0;
|
|
72
75
|
state.navigation_version = 0;
|
|
73
76
|
state.admin = { loaded: false, local: false, registered: false, setup_available: false, logged_in: false, profile: null, identity_version: 0 };
|
|
74
|
-
state.launch = { available: false, remote: false, connect_command: "", profiles: {}, workspace: {}, scheduler: {}, create: true, error: "", notice: "", recoveries: [] };
|
|
77
|
+
state.launch = { available: false, remote: false, relay: null, connect_command: "", profiles: {}, projects: [], workspace: {}, scheduler: {}, create: true, error: "", notice: "", recoveries: [] };
|
|
75
78
|
const PLATFORM_CLIENTS = { Codex: [{ value: "codex_cli", label: "Codex CLI" }, { value: "codex", label: "Codex App" }],
|
|
76
79
|
Claude: [{ value: "claude", label: "Claude Code CLI" }],
|
|
77
80
|
Gemini: [{ value: "antigravity_cli", label: "Antigravity CLI" }, { value: "antigravity", label: "Antigravity App" }] };
|
|
@@ -221,6 +224,8 @@ function admin_error(error) {
|
|
|
221
224
|
function accept_admin_session({ token, profile }) {
|
|
222
225
|
store_admin_token(token);
|
|
223
226
|
apply_admin_state({ registered: true, setup_available: false, logged_in: true, profile });
|
|
227
|
+
// Pairing and the paired computer both need the session, so read them again now.
|
|
228
|
+
load_launch_settings();
|
|
224
229
|
}
|
|
225
230
|
|
|
226
231
|
/** Create the single administrator account and sign in with it. */
|
|
@@ -244,6 +249,7 @@ async function logout_admin() {
|
|
|
244
249
|
catch { /* The token is removed below either way. */ }
|
|
245
250
|
store_admin_token("");
|
|
246
251
|
state.admin.logged_in = false;
|
|
252
|
+
state.launch.relay = null;
|
|
247
253
|
publish_view();
|
|
248
254
|
}
|
|
249
255
|
|
|
@@ -1540,8 +1546,9 @@ async function submit_room(event, folder_id = null) {
|
|
|
1540
1546
|
const free = payload.mode === "free";
|
|
1541
1547
|
// A discussion can still be created without the local service and started later from the room;
|
|
1542
1548
|
// a free room needs the service only for members it wakes or for smart scheduling.
|
|
1543
|
-
const
|
|
1544
|
-
|
|
1549
|
+
const launchable = state.launch.available || Boolean(state.launch.relay);
|
|
1550
|
+
const launching = free ? Object.values(payload.experts).includes("cli") || (launchable && Boolean(creation_launch_options().scheduler || state.launch.workspace.cwd || state.launch.workspace.local_project)) : state.launch.create && state.launch.available;
|
|
1551
|
+
if (launching && !launchable) throw new Error("本机唤醒的成员需要连接本机服务;也可改为手动接入或网页代发");
|
|
1545
1552
|
const launch_draft = launching ? creation_launch_options() : null;
|
|
1546
1553
|
// A discussion is only a way to push the room forward: the room is created as a free room, then opened with these limits.
|
|
1547
1554
|
const discussion = free && state.create_discussion ? { goal: payload.goal.trim(), named_followup: state.named_followup,
|
|
@@ -1675,7 +1682,8 @@ async function submit_local_launch(input, title, direct = false, signature) {
|
|
|
1675
1682
|
const id = input.room?.submission_id || `room-${input.room_id}`;
|
|
1676
1683
|
const key = LAUNCH_PENDING_PREFIX + id;
|
|
1677
1684
|
const previous = localStorage.getItem(key);
|
|
1678
|
-
const
|
|
1685
|
+
const relay = !direct && !state.launch.available ? state.launch.relay?.id : undefined;
|
|
1686
|
+
const record = previous ? JSON.parse(previous) : { id, title, input, room_id: input.room_id, direct, signature, ...(relay ? { machine_id: relay } : {}) };
|
|
1679
1687
|
if (previous && JSON.stringify(record.input) !== JSON.stringify(input)) throw new Error("此房间有未完成的原启动请求,请先继续该请求,不要替换配置");
|
|
1680
1688
|
record.attempt_id = crypto.randomUUID();
|
|
1681
1689
|
try { localStorage.setItem(key, JSON.stringify(record)); }
|
|
@@ -1683,7 +1691,7 @@ async function submit_local_launch(input, title, direct = false, signature) {
|
|
|
1683
1691
|
refresh_launch_recoveries();
|
|
1684
1692
|
try {
|
|
1685
1693
|
const result = record.direct ? { room: await request_json("/api/rooms", record.input.room, record.input.token), status: "created" }
|
|
1686
|
-
: await local_launch_request("launch", record.input);
|
|
1694
|
+
: record.machine_id ? await relay_launch_request(record.input, record.machine_id) : await local_launch_request("launch", record.input);
|
|
1687
1695
|
save_room_credentials(result.room, record.input.token, record.input.room?.members || []);
|
|
1688
1696
|
localStorage.removeItem(key);
|
|
1689
1697
|
refresh_launch_recoveries();
|
|
@@ -1752,21 +1760,54 @@ async function load_launch_settings() {
|
|
|
1752
1760
|
local_connection = restore_local_connection();
|
|
1753
1761
|
state.launch.remote = Boolean(local_connection);
|
|
1754
1762
|
if (local_connection) state.launch.connect_command = `codiscuss local connect "${location.origin}"`;
|
|
1755
|
-
if (!local_connection && !local) return;
|
|
1763
|
+
if (!local_connection && !local) { state.launch.relay = await load_relay_machine(); return; }
|
|
1756
1764
|
const settings = await local_launch_request("settings");
|
|
1757
1765
|
// Keep the saved machine defaults so every new form can start from them again.
|
|
1758
1766
|
state.launch.saved_profiles = JSON.parse(JSON.stringify(settings.participants || {}));
|
|
1759
|
-
Object.assign(state.launch, { available: true, profiles: settings.participants, api_providers: settings.api_providers || {}, api_keys: settings.api_keys || {},
|
|
1767
|
+
Object.assign(state.launch, { available: true, profiles: settings.participants, projects: settings.projects || [], api_providers: settings.api_providers || {}, api_keys: settings.api_keys || {},
|
|
1760
1768
|
scheduler_providers: settings.scheduler_providers || {}, error: "",
|
|
1761
1769
|
notice: local_connection ? "已连接本机服务,讨论保存在当前网站" : "" });
|
|
1762
1770
|
Object.values(state.launch.profiles).forEach(fill_api_key);
|
|
1771
|
+
if (local_connection) pair_local_machine();
|
|
1763
1772
|
// Settings arrive after the form was reset; only an untouched draft gets the default.
|
|
1764
1773
|
if (!Object.keys(state.launch.scheduler).length) state.launch.scheduler = default_scheduler();
|
|
1765
1774
|
load_room_models();
|
|
1766
|
-
} catch (error) {
|
|
1775
|
+
} catch (error) {
|
|
1776
|
+
Object.assign(state.launch, { available: false, room_models: null, error: error.message });
|
|
1777
|
+
// Without a reachable local service, launches can still go through the paired computer.
|
|
1778
|
+
state.launch.relay = await load_relay_machine();
|
|
1779
|
+
if (state.launch.relay) state.launch.error = "";
|
|
1780
|
+
}
|
|
1767
1781
|
finally { refresh_launch_recoveries(); render_launch_settings(); publish_view(); }
|
|
1768
1782
|
}
|
|
1769
1783
|
|
|
1784
|
+
/** The computer paired with this site, most recently seen first; only the administrator can read or use it. */
|
|
1785
|
+
async function load_relay_machine() {
|
|
1786
|
+
if (!admin_token()) return null;
|
|
1787
|
+
try { return (await request_json("/api/machines")).machines[0] || null; } catch { return null; }
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
/** Pair the connected computer so devices without the local service can launch on it; needs the administrator session. */
|
|
1791
|
+
async function pair_local_machine() {
|
|
1792
|
+
if (!admin_token()) return;
|
|
1793
|
+
try { await request_json("/api/machines", await local_launch_request("machine-link", {})); }
|
|
1794
|
+
catch (error) { console.warn("Pairing this computer failed:", error.message); }
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
/** Hand a room creation to the paired computer through the website and wait for it to finish. */
|
|
1798
|
+
async function relay_launch_request(input, machine_id) {
|
|
1799
|
+
const id = input.room.submission_id;
|
|
1800
|
+
let launch = await request_json("/api/remote-launches", { id, machine_id, request: input });
|
|
1801
|
+
for (let waited = 0; ["pending", "claimed"].includes(launch.status) && waited < RELAY_WAIT_MS; waited += RELAY_POLL_MS) {
|
|
1802
|
+
await new Promise(resolve => setTimeout(resolve, RELAY_POLL_MS));
|
|
1803
|
+
launch = await request_json(`/api/remote-launches/${encodeURIComponent(id)}`);
|
|
1804
|
+
}
|
|
1805
|
+
if (launch.status === "failed") throw Object.assign(new Error(launch.error), { launch: { stage: launch.result.stage, room_id: launch.result.room_id } });
|
|
1806
|
+
const name = state.launch.relay?.name || "电脑";
|
|
1807
|
+
if (launch.status !== "done") throw Object.assign(new Error(`${name}${launch.status === "pending" ? "尚未接手" : "仍在启动"},请保持它开机并运行 codiscuss,稍后在待办中继续`), { launch: { stage: "unconfirmed" } });
|
|
1808
|
+
return { room: await request_json(`/api/rooms/${launch.result.room_id}`, undefined, input.token), status: launch.result.status };
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1770
1811
|
/** Re-check the local connection and explain when nothing has been connected yet. */
|
|
1771
1812
|
async function retry_launch_connection() {
|
|
1772
1813
|
await load_launch_settings();
|
|
@@ -1948,7 +1989,9 @@ function restore_launch_profile(name, profile) {
|
|
|
1948
1989
|
|
|
1949
1990
|
/** Capture local overrides before asynchronous work or navigation can change the draft. */
|
|
1950
1991
|
function launch_options() {
|
|
1951
|
-
const
|
|
1992
|
+
const { local_project, ...workspace } = state.launch.workspace;
|
|
1993
|
+
// The paired computer resolves a project id to its own folder; a browser with the local service sends the folder itself.
|
|
1994
|
+
const draft = JSON.parse(JSON.stringify({ participants: state.launch.profiles, ...workspace, ...(!state.launch.available && local_project ? { local_project } : {}), entry: "web" }));
|
|
1952
1995
|
for (const profile of Object.values(draft.participants)) if (profile.adapter === "api") profile.project_access = "read";
|
|
1953
1996
|
return draft;
|
|
1954
1997
|
}
|
|
@@ -2077,23 +2120,35 @@ function clear_launch_overrides(draft) {
|
|
|
2077
2120
|
if (state.launch.profiles[name]?.[field] === profile[field]) delete state.launch.profiles[name]?.[field];
|
|
2078
2121
|
}
|
|
2079
2122
|
}
|
|
2080
|
-
for (const field of ["cwd", "discussion_project", "project_access"]) {
|
|
2123
|
+
for (const field of ["cwd", "local_project", "discussion_project", "project_access"]) {
|
|
2081
2124
|
if (state.launch.workspace[field] === draft[field]) delete state.launch.workspace[field];
|
|
2082
2125
|
}
|
|
2083
2126
|
render_launch_settings();
|
|
2084
2127
|
publish_view();
|
|
2085
2128
|
}
|
|
2086
2129
|
|
|
2130
|
+
/** Browse for a folder on the computer running the local service; resolves to "" when cancelled. */
|
|
2131
|
+
async function browse_local_directory() {
|
|
2132
|
+
return (await local_launch_request("pick-directory", {})).path || "";
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2087
2135
|
/** Let the user browse for the project folder on the computer running the local service. */
|
|
2088
2136
|
async function pick_launch_directory() {
|
|
2089
2137
|
try {
|
|
2090
|
-
const
|
|
2091
|
-
if (
|
|
2138
|
+
const path = await browse_local_directory();
|
|
2139
|
+
if (path) write_launch_field("workspace", "cwd", path);
|
|
2092
2140
|
state.launch.error = "";
|
|
2093
2141
|
} catch (error) { state.launch.error = error.message; }
|
|
2094
2142
|
publish_view();
|
|
2095
2143
|
}
|
|
2096
2144
|
|
|
2145
|
+
/** Replace the named project directories kept on the computer running the local service. */
|
|
2146
|
+
async function save_launch_projects(projects) {
|
|
2147
|
+
const result = await local_launch_request("projects", { projects });
|
|
2148
|
+
state.launch.projects = result.projects;
|
|
2149
|
+
publish_view();
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2097
2152
|
/** Save machine defaults without per-discussion session IDs or project write access. */
|
|
2098
2153
|
async function save_launch_defaults() {
|
|
2099
2154
|
// Defaults are per platform; an extra instance's model and effort apply to its own discussion only.
|
|
@@ -3367,7 +3422,7 @@ function change_view_theme(theme) {
|
|
|
3367
3422
|
|
|
3368
3423
|
window.discuss_view = { snapshot: view_snapshot, write_field: write_view_field,
|
|
3369
3424
|
write_launch: write_launch_field, write_room_connection, write_room_adapter, launch_clients: LAUNCH_CLIENTS,
|
|
3370
|
-
launch_model_state, load_launch_models, write_launch_model, save_launch_api_key, write_scheduler, save_scheduler_api_key, check_scheduler, write_named_followup, write_create_discussion, check_launch_api, launch_summary, launch_can_set_access, launch_can_bind_session, pick_launch_directory, restore_launch_profile, reset_create_form, empty_discussion_message,
|
|
3425
|
+
launch_model_state, load_launch_models, write_launch_model, save_launch_api_key, write_scheduler, save_scheduler_api_key, check_scheduler, write_named_followup, write_create_discussion, check_launch_api, launch_summary, launch_can_set_access, launch_can_bind_session, pick_launch_directory, browse_local_directory, save_launch_projects, restore_launch_profile, reset_create_form, empty_discussion_message,
|
|
3371
3426
|
start_drag: start_directory_drag,
|
|
3372
3427
|
attachment_markup, attachment_size, attachment_file_icon, drop_attachments, paste_attachments,
|
|
3373
3428
|
write_expert: write_view_expert, add_instance: add_view_instance, platform_of, execute: execute_view_action, theme: change_view_theme,
|