ai_co_discuss 0.2.63 → 0.2.65
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/backend/free_room.py +23 -13
- package/backend/server.py +2 -2
- package/docs/workflow.md +3 -1
- package/package.json +1 -1
- package/runner/discussion_service.mjs +3 -2
- package/web/app.js +29 -12
- package/web/modern.css +1 -1
- package/web/modern.js +1081 -1000
package/backend/free_room.py
CHANGED
|
@@ -223,6 +223,11 @@ def possible_conflicts(connection, room_id, names):
|
|
|
223
223
|
return conflicts
|
|
224
224
|
|
|
225
225
|
|
|
226
|
+
# An invited manual member that has not registered counts as absent: it receives no request and is never a target.
|
|
227
|
+
REGISTERED_MEMBER = """(room_members.access<>'manual' OR EXISTS(SELECT 1 FROM member_registrations r
|
|
228
|
+
WHERE r.member_id=room_members.id AND r.token_hash=room_members.token_hash))"""
|
|
229
|
+
|
|
230
|
+
|
|
226
231
|
def free_view(connection, room_id):
|
|
227
232
|
"""Summarize members, their live execution, recent requests and possible file conflicts."""
|
|
228
233
|
now = int(time.time())
|
|
@@ -230,6 +235,8 @@ def free_view(connection, room_id):
|
|
|
230
235
|
for row in connection.execute("SELECT * FROM room_members WHERE room_id=? AND status<>'removed' ORDER BY id", (room_id,)):
|
|
231
236
|
member = {key: row[key] for key in MEMBER_FIELDS}
|
|
232
237
|
member["registered_at"] = registered_at(connection, row)
|
|
238
|
+
reported = connection.execute("SELECT session_id,client FROM member_sessions WHERE member_id=?", (row["id"],)).fetchone()
|
|
239
|
+
member["reported_session"], member["reported_client"] = (reported["session_id"], reported["client"]) if reported else ("", "")
|
|
233
240
|
latest = connection.execute("SELECT * FROM member_executions WHERE member_id=? ORDER BY id DESC LIMIT 1",
|
|
234
241
|
(row["id"],)).fetchone()
|
|
235
242
|
member["execution"] = public_execution(latest)
|
|
@@ -240,7 +247,7 @@ def free_view(connection, room_id):
|
|
|
240
247
|
members.append(member)
|
|
241
248
|
names = {row["id"]: row["name"] for row in connection.execute("SELECT id,name FROM room_members WHERE room_id=?", (room_id,))}
|
|
242
249
|
requests = [{**dict(row), "member": names.get(row["member_id"], "")} for row in connection.execute(
|
|
243
|
-
"""SELECT id,member_id,message_id,status,execution_id,answer_id,chain_id,batch_id,purpose,created_by FROM member_requests
|
|
250
|
+
"""SELECT id,member_id,message_id,status,execution_id,answer_id,chain_id,batch_id,purpose,created_by,created_at FROM member_requests
|
|
244
251
|
WHERE room_id=? ORDER BY id DESC LIMIT ?""", (room_id, RECENT_REQUESTS))]
|
|
245
252
|
chains = [dict(row) for row in connection.execute(
|
|
246
253
|
"SELECT chain_id,reason FROM free_chains WHERE room_id=? ORDER BY chain_id DESC LIMIT 10", (room_id,))]
|
|
@@ -307,9 +314,10 @@ def authorize_free_write(connection, room_id, token, operation, payload):
|
|
|
307
314
|
return require_member(connection, room_id, token, author)
|
|
308
315
|
|
|
309
316
|
|
|
310
|
-
def active_members(connection, room_id):
|
|
311
|
-
"""Map active member names to their rows."""
|
|
312
|
-
return {row["name"]: row for row in connection.execute(
|
|
317
|
+
def active_members(connection, room_id, registered_only=True):
|
|
318
|
+
"""Map active member names to their rows; unregistered manual members are left out unless a discussion start needs them pending."""
|
|
319
|
+
return {row["name"]: row for row in connection.execute(
|
|
320
|
+
f"SELECT * FROM room_members WHERE room_id=? AND status='active' AND ({REGISTERED_MEMBER} OR ?=0)", (room_id, int(registered_only)))}
|
|
313
321
|
|
|
314
322
|
|
|
315
323
|
def named_members(active, names, exclude=None):
|
|
@@ -593,7 +601,7 @@ def asks_view(connection, room_id):
|
|
|
593
601
|
"""Direct questions of a discussion room: recent requests with their member, for status badges beside messages."""
|
|
594
602
|
names = {row["id"]: row["name"] for row in connection.execute("SELECT id,name FROM room_members WHERE room_id=?", (room_id,))}
|
|
595
603
|
requests = [{**dict(row), "member": names.get(row["member_id"], "")} for row in connection.execute(
|
|
596
|
-
"""SELECT id,member_id,message_id,status,execution_id,answer_id,chain_id,batch_id,purpose,created_by FROM member_requests
|
|
604
|
+
"""SELECT id,member_id,message_id,status,execution_id,answer_id,chain_id,batch_id,purpose,created_by,created_at FROM member_requests
|
|
597
605
|
WHERE room_id=? ORDER BY id DESC LIMIT ?""", (room_id, RECENT_REQUESTS))]
|
|
598
606
|
return {"requests": requests[::-1], "executions": recent_executions(connection, room_id), "execution_minutes": execution_minutes(connection, room_id)}
|
|
599
607
|
|
|
@@ -636,7 +644,7 @@ def inbox(connection, room_id, token):
|
|
|
636
644
|
archived = connection.execute("SELECT archived FROM room_directory WHERE room_id=?", (room_id,)).fetchone()[0]
|
|
637
645
|
members, observers = [], []
|
|
638
646
|
# Manual and online members are never delivered to; the runner only reads them so smart scheduling can route to them too.
|
|
639
|
-
for row in connection.execute("SELECT * FROM room_members WHERE room_id=? AND status='active' ORDER BY id", (room_id,)):
|
|
647
|
+
for row in connection.execute(f"SELECT * FROM room_members WHERE room_id=? AND status='active' AND {REGISTERED_MEMBER} ORDER BY id", (room_id,)):
|
|
640
648
|
pending = [dict(item) for item in connection.execute(
|
|
641
649
|
"SELECT id,message_id,purpose FROM member_requests WHERE member_id=? AND status='pending' ORDER BY id", (row["id"],))]
|
|
642
650
|
(members if row["access"] == "runner" else observers).append({**{key: row[key] for key in MEMBER_FIELDS}, "pending": pending})
|
|
@@ -663,8 +671,8 @@ def recommend_member(connection, room_id, token, payload):
|
|
|
663
671
|
route = host_route_request(connection, message_id) if type(message_id) is int else None
|
|
664
672
|
if not route or route["room_id"] != room_id:
|
|
665
673
|
raise LookupError("只能为交给主持人的未点名消息给出推荐")
|
|
666
|
-
target = connection.execute("""SELECT * FROM room_members WHERE room_id=? AND id=? AND status='active'
|
|
667
|
-
AND id<>?""", (room_id, payload.get("member_id"), route["member_id"])).fetchone()
|
|
674
|
+
target = connection.execute(f"""SELECT * FROM room_members WHERE room_id=? AND id=? AND status='active'
|
|
675
|
+
AND {REGISTERED_MEMBER} AND id<>?""", (room_id, payload.get("member_id"), route["member_id"])).fetchone()
|
|
668
676
|
if not target:
|
|
669
677
|
raise LookupError("推荐对象须为在场的其他成员")
|
|
670
678
|
now = int(time.time())
|
|
@@ -698,8 +706,8 @@ def route_wide(connection, room_id, message_id, action):
|
|
|
698
706
|
else:
|
|
699
707
|
# The host keeps its row (one request per message and member) and now answers its own share, without dispatching.
|
|
700
708
|
connection.execute("UPDATE member_requests SET purpose='all' WHERE id=?", (route["id"],))
|
|
701
|
-
connection.execute("""INSERT OR IGNORE INTO member_requests (room_id,member_id,message_id,chain_id,purpose,created_by,created_at)
|
|
702
|
-
SELECT room_id,id,?,?,'all','user',? FROM room_members WHERE room_id=? AND status='active' ORDER BY id""",
|
|
709
|
+
connection.execute(f"""INSERT OR IGNORE INTO member_requests (room_id,member_id,message_id,chain_id,purpose,created_by,created_at)
|
|
710
|
+
SELECT room_id,id,?,?,'all','user',? FROM room_members WHERE room_id=? AND status='active' AND {REGISTERED_MEMBER} ORDER BY id""",
|
|
703
711
|
(message_id, message_id, int(time.time()), room_id))
|
|
704
712
|
requests = connection.execute("SELECT * FROM member_requests WHERE message_id=? ORDER BY id", (message_id,)).fetchall()
|
|
705
713
|
return {"action": action, "requests": [dict(row) for row in requests]}
|
|
@@ -982,8 +990,8 @@ def adopt_manual_member(connection, room_id, member, payload):
|
|
|
982
990
|
raise ValueError("成员尚未注册,不能转为托管")
|
|
983
991
|
if not reported or reported["session_id"] != payload.get("session_id") or payload.get("adapter") not in ADOPTABLE_ADAPTERS:
|
|
984
992
|
raise ValueError("成员尚未上报与之相符的会话,不能转为托管")
|
|
985
|
-
if member["access"] != "manual" or member["status"] != "active" or not
|
|
986
|
-
raise ValueError("
|
|
993
|
+
if member["access"] != "manual" or member["status"] != "active" or not (room_mode(connection, room_id) == "free" or (run and run["mode"] == "automatic")):
|
|
994
|
+
raise ValueError("只有自由协作或全自动讨论中在场的手动专家可以转为托管")
|
|
987
995
|
connection.execute("UPDATE room_members SET access='runner',adapter=?,session_mode='borrowed' WHERE id=?", (payload["adapter"], member["id"]))
|
|
988
996
|
connection.execute("DELETE FROM member_sessions WHERE member_id=?", (member["id"],))
|
|
989
997
|
|
|
@@ -1016,6 +1024,8 @@ def member_action(connection, room_id, member_id, token, payload):
|
|
|
1016
1024
|
raise ValueError("只有离开的成员可以请回,且须提供新的成员凭证")
|
|
1017
1025
|
require_fresh_member_token(connection, room_id, payload.get("token"))
|
|
1018
1026
|
connection.execute("UPDATE room_members SET status='active',token_hash=? WHERE id=?", (hashed, member_id))
|
|
1027
|
+
elif action == "adopt":
|
|
1028
|
+
adopt_manual_member(connection, room_id, member, payload)
|
|
1019
1029
|
elif action == "role":
|
|
1020
1030
|
role = payload.get("role")
|
|
1021
1031
|
if role not in ("host", "member") or (role == "host" and member["access"] == "online"):
|
|
@@ -1026,7 +1036,7 @@ def member_action(connection, room_id, member_id, token, payload):
|
|
|
1026
1036
|
connection.execute("UPDATE room_members SET role='member' WHERE room_id=? AND role='host'", (room_id,))
|
|
1027
1037
|
connection.execute("UPDATE room_members SET role=? WHERE id=?", (role, member_id))
|
|
1028
1038
|
else:
|
|
1029
|
-
raise ValueError("action 须为 away、active、remove 或
|
|
1039
|
+
raise ValueError("action 须为 away、active、remove、role 或 adopt")
|
|
1030
1040
|
settle_batches(connection, room_id)
|
|
1031
1041
|
sync_participants(connection, room_id)
|
|
1032
1042
|
return free_view(connection, room_id)
|
package/backend/server.py
CHANGED
|
@@ -738,7 +738,7 @@ def start_discussion(connection, room_id, token, payload):
|
|
|
738
738
|
if room["archived"]:
|
|
739
739
|
raise ValueError("房间已存档;恢复后才能开启讨论")
|
|
740
740
|
sync_participants(connection, room_id)
|
|
741
|
-
active = active_members(connection, room_id)
|
|
741
|
+
active = active_members(connection, room_id, registered_only=False)
|
|
742
742
|
host = next((row for row in active.values() if row["role"] == "host"), None)
|
|
743
743
|
if not host or host["access"] != "runner":
|
|
744
744
|
raise ValueError("开启讨论需要一位由本机托管的 AI 主持人;先在成员栏指定或转为自动")
|
|
@@ -1022,7 +1022,7 @@ def record_reported_session(connection, room_id, token, payload):
|
|
|
1022
1022
|
session_id, client = payload.get("session_id"), payload.get("session_client")
|
|
1023
1023
|
if client not in ("claude", "codex", "antigravity") or not isinstance(session_id, str) or not SESSION_ID_PATTERN.fullmatch(session_id) or not token:
|
|
1024
1024
|
return
|
|
1025
|
-
member = connection.execute("SELECT id FROM room_members WHERE room_id=? AND token_hash=? AND status='active' AND access='manual' AND adapter
|
|
1025
|
+
member = connection.execute("SELECT id FROM room_members WHERE room_id=? AND token_hash=? AND status='active' AND access='manual' AND adapter IN ('manual','')",
|
|
1026
1026
|
(room_id, token_hash(token))).fetchone()
|
|
1027
1027
|
if member:
|
|
1028
1028
|
connection.execute("""INSERT INTO member_sessions (member_id,room_id,session_id,client) VALUES (?,?,?,?)
|
package/docs/workflow.md
CHANGED
|
@@ -25,7 +25,9 @@
|
|
|
25
25
|
|
|
26
26
|
自由协作房间从同一角色库选择一位或多位成员。用户是需求方而不是主持人:只要有能自行发言的成员(本机唤醒或手动接入),房间就始终有一位 AI 主持人,默认是第一位本机唤醒的成员,可点皇冠或在成员菜单中更换;主持人不能直接卸任,离开或被移出时由下一位成员接任,原先由用户主持的旧房间在下一条消息时自动指定。只有一位成员时,每条消息都交给它;有多位时,你的消息默认发给主持人;在正文中 `@成员` 才让其他成员发言(代码和引用中的 @ 不算,输入框中会生效的 @ 显示为蓝色),被点名的成员各自处理,主持人也可以 @ 其他成员派活。输入框下方的备注行显示这条消息会发给谁。只有网页 AI 成员时没有主持人,未点名的消息只作留言。输入 `@` 后,输入框上方弹出在场成员菜单,继续输入可筛选;用方向键与 Enter / Tab 或点击补全,Esc 关闭。选择只修改草稿。输入框 Enter 发送、Shift + Enter 换行;发送按钮始终用于发言,无正文或附件时禁用,成员处理中也不切换为停止按钮。自动成员空闲时,服务把它名下所有未处理的消息打包成一次执行交给原会话;执行期间再发的消息排队,下一次一起投递。手动成员自行读取自己的请求,在线成员由用户转交。成员按时间顺序处理,后面的消息可以修正前面的。
|
|
27
27
|
|
|
28
|
-
运行中可在右侧成员栏邀请 AI(沿用本机软件配置和本房间工作目录),也可让成员离开、请回或移除。自动成员的项目文件权限默认只读;需要它修改项目时,在邀请前选择“项目文件可写”,权限只应用于这位成员;App 与手动成员的文件权限由其软件设置,API 与网页成员不访问项目文件。填写已有会话 ID
|
|
28
|
+
运行中可在右侧成员栏邀请 AI(沿用本机软件配置和本房间工作目录),也可让成员离开、请回或移除。自动成员的项目文件权限默认只读;需要它修改项目时,在邀请前选择“项目文件可写”,权限只应用于这位成员;App 与手动成员的文件权限由其软件设置,API 与网页成员不访问项目文件。填写已有会话 ID 时续接该原会话,不新建。离开会立即停止它的工作;请回时换发新凭证并续接它原来的会话。邀请时可选“手动接入”:成员不会被自动唤醒,而是得到一份私有凭据文件,由你在它的会话中按参与技能读写。自由协作房间里,手动成员执行 `member register` 之前视为不存在:不能被点名,不收请求,不进“全体各自回应”和智能调度候选;注册后与其他成员一样可被点名,其 CLI 上报会话后本机服务按软件转为托管(Claude Code、Codex、agy),此后与自动成员无异。绑定你正在使用的会话的成员会标注“借用会话”:它工作时不要在原窗口继续操作。
|
|
29
|
+
|
|
30
|
+
输入 `@全体成员`(或 `@all`)等同于点名当前所有可点名成员(含主持人,不含未注册的手动成员),各自回答,主持人不汇总;成员不止一位时 @ 菜单首项提供该选项。
|
|
29
31
|
|
|
30
32
|
可以把一位本机成员设为主持人。之后没有点名的消息交给主持人分配;主持人在回答中点名成员派活,只点名一位成员时,其回答直接面向用户,主持人不再被唤醒;点名多位成员时,都结束后主持人再被唤醒一次,继续汇总或安排下一步。派出的成员全部失败时不再唤醒主持人;同一条消息引出的 AI 请求最多 8 次,用完即停,成员栏会提示,发一条新消息即重新开始。
|
|
31
33
|
|
package/package.json
CHANGED
|
@@ -266,8 +266,9 @@ class RoomWorker {
|
|
|
266
266
|
}
|
|
267
267
|
/** A manual Claude or Codex expert that has registered and whose CLI reported its session is handed to the service, which resumes that session from the next turn on. */
|
|
268
268
|
async adopt_manual_members() {
|
|
269
|
-
|
|
270
|
-
|
|
269
|
+
// A discussion runner lists members itself; a free room's registered manual members come from the room view.
|
|
270
|
+
const members = this.config.mode === 'free' ? (await api(this.config, '')).free?.members || [] : this.runner.members || [];
|
|
271
|
+
for (const member of members) {
|
|
271
272
|
const key = `${member.name}:${member.reported_session}`;
|
|
272
273
|
if (member.access !== 'manual' || member.status !== 'active' || !member.registered_at || !member.reported_session || this.adopt_failed.has(key)) continue;
|
|
273
274
|
try {
|
package/web/app.js
CHANGED
|
@@ -938,9 +938,9 @@ function message_payload() {
|
|
|
938
938
|
if (online) return { author: online.name, text, reply_to: state.reply_to, kind: "chat", submitted_by: "用户",
|
|
939
939
|
request_ids: state.room.free.requests.filter(item => item.member === online.name && item.status === "pending").map(item => item.id) };
|
|
940
940
|
const payload = { author: "用户", text, reply_to: state.reply_to, kind: "chat", submitted_by: "用户" };
|
|
941
|
-
const active = (state.room.free?.members || []).filter(member => member.status === "active").map(member => member.name);
|
|
942
|
-
// @mentions outside code name members; without any, the server gives the message to the only member or the host.
|
|
943
|
-
const named = active.filter(name => mentioned(text, name));
|
|
941
|
+
const active = (state.room.free?.members || []).filter(member => member.status === "active" && (member.access !== "manual" || member.registered_at)).map(member => member.name);
|
|
942
|
+
// @mentions outside code name members (@全体成员 or @all names them all); without any, the server gives the message to the only member or the host.
|
|
943
|
+
const named = mentioned(text, "全体成员") || mentioned(text, "all") ? active : active.filter(name => mentioned(text, name));
|
|
944
944
|
if (named.length) payload.to = named;
|
|
945
945
|
return payload;
|
|
946
946
|
}
|
|
@@ -989,7 +989,7 @@ function reset_create_form() {
|
|
|
989
989
|
state.create_parent = null;
|
|
990
990
|
state.launch.create = true;
|
|
991
991
|
state.launch.workspace = {};
|
|
992
|
-
state.launch.scheduler =
|
|
992
|
+
state.launch.scheduler = default_scheduler();
|
|
993
993
|
state.named_followup = false;
|
|
994
994
|
state.create_discussion = false;
|
|
995
995
|
state.launch.profiles = JSON.parse(JSON.stringify(state.launch.saved_profiles || {}));
|
|
@@ -1744,6 +1744,8 @@ async function load_launch_settings() {
|
|
|
1744
1744
|
scheduler_providers: settings.scheduler_providers || {}, error: "",
|
|
1745
1745
|
notice: local_connection ? "已连接本机服务,讨论保存在当前网站" : "" });
|
|
1746
1746
|
Object.values(state.launch.profiles).forEach(fill_api_key);
|
|
1747
|
+
// Settings arrive after the form was reset; only an untouched draft gets the default.
|
|
1748
|
+
if (!Object.keys(state.launch.scheduler).length) state.launch.scheduler = default_scheduler();
|
|
1747
1749
|
load_room_models();
|
|
1748
1750
|
} catch (error) { state.launch.available = false; state.launch.room_models = null; state.launch.error = error.message; }
|
|
1749
1751
|
finally { refresh_launch_recoveries(); render_launch_settings(); publish_view(); }
|
|
@@ -1953,6 +1955,15 @@ function write_create_discussion(value) {
|
|
|
1953
1955
|
publish_view();
|
|
1954
1956
|
}
|
|
1955
1957
|
|
|
1958
|
+
/** A fresh form turns smart scheduling on when some provider already has a saved key. */
|
|
1959
|
+
function default_scheduler() {
|
|
1960
|
+
const providers = state.launch.scheduler_providers || {};
|
|
1961
|
+
const provider = Object.keys(providers).find((name) => state.launch.api_keys?.[providers[name].base_url]);
|
|
1962
|
+
if (!provider) return {};
|
|
1963
|
+
const { base_url, models } = providers[provider];
|
|
1964
|
+
return { enabled: true, provider, model: models[0] || "", api_key_ref: state.launch.api_keys[base_url] };
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1956
1967
|
/** Change the smart scheduling draft; a new provider starts from its preset model and any key saved for its root. */
|
|
1957
1968
|
function write_scheduler(field, value) {
|
|
1958
1969
|
const scheduler = state.launch.scheduler;
|
|
@@ -2435,8 +2446,7 @@ function download_member_access(name) {
|
|
|
2435
2446
|
if (!access.can_manage || !member?.available || member.access === "online") throw new Error("当前浏览器没有此成员可下载的私有配置");
|
|
2436
2447
|
download_private_json({ site: location.origin, room: room.id, token: member_credentials(room.id)[name].token }, `member-${room.id}-${name}.json`);
|
|
2437
2448
|
state.access_error = "";
|
|
2438
|
-
|
|
2439
|
-
: `已下载 ${name} 的私有配置;交给该成员的本地会话,下载不代表已接入。`;
|
|
2449
|
+
flash_access_notice(`已下载 ${name} 的私有配置`);
|
|
2440
2450
|
}
|
|
2441
2451
|
|
|
2442
2452
|
/** Copy the same room-scoped credential as one line of text for an agent that cannot take a file. */
|
|
@@ -2445,8 +2455,19 @@ async function copy_member_access_text(name) {
|
|
|
2445
2455
|
const member = access.members.find(item => item.name === name);
|
|
2446
2456
|
if (!access.can_manage || !member?.available || member.access === "online") throw new Error("当前浏览器没有此成员可复制的凭证");
|
|
2447
2457
|
await copy_text(JSON.stringify({ site: location.origin, room: room.id, token: member_credentials(room.id)[name].token }));
|
|
2458
|
+
flash_access_notice(`已复制 ${name} 的凭证文本(含 token)`);
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
/** Confirm a copy or download briefly in the member rail; the note clears itself. */
|
|
2462
|
+
function flash_access_notice(message) {
|
|
2448
2463
|
state.access_error = "";
|
|
2449
|
-
state.access_notice =
|
|
2464
|
+
state.access_notice = message;
|
|
2465
|
+
clearTimeout(state.access_notice_timer);
|
|
2466
|
+
state.access_notice_timer = setTimeout(() => {
|
|
2467
|
+
if (state.access_notice !== message) return;
|
|
2468
|
+
state.access_notice = "";
|
|
2469
|
+
publish_view();
|
|
2470
|
+
}, TOAST_DURATION_MS);
|
|
2450
2471
|
}
|
|
2451
2472
|
|
|
2452
2473
|
/** Copy a member-specific entry instruction; only the launch variant carries the credential, never conversation context. */
|
|
@@ -2469,11 +2490,7 @@ async function copy_member_join(name, with_credential = false) {
|
|
|
2469
2490
|
${instruction}
|
|
2470
2491
|
房间入口:${address}
|
|
2471
2492
|
先核对最新阶段、任务和原文编号,再按房间协议参与。${credential}`);
|
|
2472
|
-
if (state.room?.id === room.id) {
|
|
2473
|
-
state.access_error = "";
|
|
2474
|
-
state.access_notice = with_credential ? `已复制 ${name} 的启动指令,含 token,只交给该成员;复制不代表已经送达或接入。`
|
|
2475
|
-
: `已复制 ${name} 的参与说明,未包含凭据;复制不代表已经送达或接入。`;
|
|
2476
|
-
}
|
|
2493
|
+
if (state.room?.id === room.id) flash_access_notice(with_credential ? `已复制 ${name} 的启动指令(含 token)` : `已复制 ${name} 的参与说明`);
|
|
2477
2494
|
}
|
|
2478
2495
|
|
|
2479
2496
|
/** How a manual member learns it is its turn: free rooms are pulled by the member, discussions only on the host's assignment. */
|