bothouse-dsh 0.1.50 → 0.1.52
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.en.md +1 -1
- package/README.md +2 -2
- package/collab/cli-member.mjs +9 -1
- package/collab/devices.html +319 -0
- package/collab/dsh-member.mjs +2 -1
- package/collab/pages-migrate.mjs +50 -0
- package/collab/plugin.mjs +11 -1
- package/collab/team-mode-rules.md +5 -5
- package/collab/wiki-galaxy/index.html +345 -0
- package/cordis.patch.yml +15 -2
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/{client → node_modules/bothouse-dsh-channels}/client.js +3 -99
- package/node_modules/bothouse-dsh-channels/index.js +2 -0
- package/node_modules/bothouse-dsh-channels/locale/en.json +6 -0
- package/node_modules/bothouse-dsh-channels/locale/zh.json +6 -0
- package/node_modules/bothouse-dsh-channels/package.json +29 -0
- package/node_modules/bothouse-dsh-desk/client.js +36 -0
- package/node_modules/bothouse-dsh-desk/index.js +2 -0
- package/node_modules/bothouse-dsh-desk/locale/en.json +6 -0
- package/node_modules/bothouse-dsh-desk/locale/zh.json +6 -0
- package/node_modules/bothouse-dsh-desk/package.json +34 -0
- package/node_modules/bothouse-dsh-devices/client.js +44 -0
- package/node_modules/bothouse-dsh-devices/index.js +2 -0
- package/node_modules/bothouse-dsh-devices/locale/en.json +6 -0
- package/node_modules/bothouse-dsh-devices/locale/zh.json +6 -0
- package/node_modules/bothouse-dsh-devices/package.json +34 -0
- package/node_modules/bothouse-dsh-panel/client.js +42 -0
- package/node_modules/bothouse-dsh-panel/index.js +2 -0
- package/node_modules/bothouse-dsh-panel/locale/en.json +6 -0
- package/node_modules/bothouse-dsh-panel/locale/zh.json +6 -0
- package/node_modules/bothouse-dsh-panel/package.json +34 -0
- package/package.json +15 -16
package/README.en.md
CHANGED
|
@@ -14,7 +14,7 @@ BotHouse channels inside DeepSeek Harness (DSH): a Discord-style channels page w
|
|
|
14
14
|
dsh plugin --profile web add bothouse-dsh
|
|
15
15
|
~~~
|
|
16
16
|
|
|
17
|
-
Restart DSH once and click "Channels" in the sidebar. #general already has Whale Girl, running on the model you configured in DSH; nothing else to fill in. @ her to get a reply; tell her "hand this to a workspace: …" and she opens a workspace, does the work and reports back. For more characters or models, use "Add member".
|
|
17
|
+
Restart DSH once and click "Channels" in the sidebar. Team desk, bothouse panel and other computers start off; switch the one you want on under "Plugins → bothouse" and it appears in the sidebar right away. #general already has Whale Girl, running on the model you configured in DSH; nothing else to fill in. @ her to get a reply; tell her "hand this to a workspace: …" and she opens a workspace, does the work and reports back. For more characters or models, use "Add member".
|
|
18
18
|
|
|
19
19
|
If the terminal install says `pnpm was not found`, run `npm i -g pnpm` first.
|
|
20
20
|
|
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
dsh plugin --profile web add bothouse-dsh
|
|
15
15
|
~~~
|
|
16
16
|
|
|
17
|
-
装完重启一次 DSH
|
|
17
|
+
装完重启一次 DSH,点左侧「频道」。团队工作台、bothouse 面板、其他电脑默认不在左侧,要用就到「插件 → bothouse」里打开对应那一项,左侧马上出现。#general 里已经有鲸鱼娘,用的是你在 DSH 里配好的那个模型,不用再填任何东西。@鲸鱼娘 就回话;对她说「交给工作区做:……」,她会开工作区干完再交回。想要多角色、多模型,再点「添加成员」。
|
|
18
18
|
|
|
19
19
|
命令行装时提示 `pnpm was not found`:先 `npm i -g pnpm` 再装。
|
|
20
20
|
|
|
@@ -27,7 +27,7 @@ dsh plugin --profile web add bothouse-dsh
|
|
|
27
27
|
|
|
28
28
|
## 和完整版 BotHouse 的区别
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
插件版以频道为主(团队工作台、面板、其他电脑可在插件页打开)。完整版 BotHouse 另外提供:
|
|
31
31
|
|
|
32
32
|
- 安装向导:可以选角色、选每个角色用哪个本机 CLI。插件版第一次启动自动放进鲸鱼娘一个角色,其余要自己点「添加成员」。
|
|
33
33
|
- 代存 Key:向导替你把 DeepSeek Key 写进 DSH。插件版在 DSH 设置里自己填。
|
package/collab/cli-member.mjs
CHANGED
|
@@ -133,8 +133,16 @@ export class CliMember {
|
|
|
133
133
|
})
|
|
134
134
|
exited.catch(() => {})
|
|
135
135
|
this.exited = exited
|
|
136
|
+
// Windows starts the CLI through a shell, which runs even when the CLI or the folder is missing: the ACP
|
|
137
|
+
// stream then closes before the process reports exit/error, and the member showed "ACP connection closed"
|
|
138
|
+
// instead of the install advice (or COLLAB_CLI_START). On a failed handshake, report how the process ended.
|
|
139
|
+
const init = this.conn.initialize({ protocolVersion: acp.PROTOCOL_VERSION, clientCapabilities: {} })
|
|
140
|
+
.catch(error => {
|
|
141
|
+
if (proc.exitCode === null && proc.signalCode === null && proc.pid !== undefined) proc.kill()
|
|
142
|
+
return exited.then(() => { throw error })
|
|
143
|
+
})
|
|
136
144
|
this.ready = Promise.race([
|
|
137
|
-
|
|
145
|
+
init,
|
|
138
146
|
exited,
|
|
139
147
|
]).then((init) => { this.canLoad = init?.agentCapabilities?.loadSession === true; return init })
|
|
140
148
|
return this.ready
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>其他电脑</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { --bg: #f6f7f9; --card: #fff; --text: #1d2129; --muted: #6b7280; --line: #e5e7eb; --accent: #2563eb; --ok: #16a34a; --warn: #b45309; --bad: #dc2626; --chip: #eef2ff; }
|
|
9
|
+
@media (prefers-color-scheme: dark) {
|
|
10
|
+
:root { --bg: #16181d; --card: #1f2229; --text: #e6e8eb; --muted: #9aa1ab; --line: #2e323b; --accent: #6b9bff; --ok: #4ade80; --warn: #fbbf24; --bad: #f87171; --chip: #262b3a; }
|
|
11
|
+
}
|
|
12
|
+
* { box-sizing: border-box; }
|
|
13
|
+
body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.55 -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; }
|
|
14
|
+
main { max-width: 880px; margin: 0 auto; padding: 20px 16px 40px; }
|
|
15
|
+
h1 { font-size: 20px; margin: 0 0 4px; }
|
|
16
|
+
.sub { color: var(--muted); margin: 0 0 18px; }
|
|
17
|
+
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
18
|
+
@media (max-width: 760px) { .grid { grid-template-columns: 1fr; } }
|
|
19
|
+
section { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 16px; }
|
|
20
|
+
h2 { font-size: 16px; margin: 0 0 6px; }
|
|
21
|
+
h3 { font-size: 13px; color: var(--muted); font-weight: 600; margin: 16px 0 6px; }
|
|
22
|
+
p.hint { color: var(--muted); margin: 0 0 12px; }
|
|
23
|
+
button { font: inherit; border-radius: 8px; border: 1px solid var(--line); background: var(--card); color: var(--text); padding: 6px 12px; cursor: pointer; }
|
|
24
|
+
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
25
|
+
button.danger { color: var(--bad); }
|
|
26
|
+
button:disabled { opacity: .5; cursor: default; }
|
|
27
|
+
.code { font: 600 40px/1.2 ui-monospace, "SF Mono", Menlo, monospace; letter-spacing: 6px; text-align: center; padding: 14px 0 4px; user-select: all; }
|
|
28
|
+
.center { text-align: center; }
|
|
29
|
+
.muted { color: var(--muted); }
|
|
30
|
+
.err { color: var(--bad); min-height: 1.4em; margin: 6px 0 0; }
|
|
31
|
+
.ok { color: var(--ok); }
|
|
32
|
+
ul.list { list-style: none; margin: 0; padding: 0; }
|
|
33
|
+
ul.list li { display: flex; align-items: center; gap: 8px; padding: 8px 0; border-top: 1px solid var(--line); }
|
|
34
|
+
ul.list li:first-child { border-top: 0; }
|
|
35
|
+
.grow { flex: 1; min-width: 0; }
|
|
36
|
+
.name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
37
|
+
.tag { font-size: 12px; border-radius: 999px; padding: 1px 8px; background: var(--chip); color: var(--muted); white-space: nowrap; }
|
|
38
|
+
.tag.ok { color: var(--ok); } .tag.warn { color: var(--warn); }
|
|
39
|
+
li.peer { cursor: pointer; border-radius: 8px; padding-left: 6px; padding-right: 6px; }
|
|
40
|
+
li.peer.sel { outline: 2px solid var(--accent); }
|
|
41
|
+
li.peer.off { cursor: default; opacity: .55; }
|
|
42
|
+
.connect { margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--line); }
|
|
43
|
+
input.digits { font: 600 22px ui-monospace, "SF Mono", Menlo, monospace; letter-spacing: 4px; width: 100%; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--line); background: var(--bg); color: var(--text); text-align: center; }
|
|
44
|
+
label.mode { display: flex; gap: 8px; align-items: flex-start; padding: 8px; border: 1px solid var(--line); border-radius: 8px; margin-top: 8px; cursor: pointer; }
|
|
45
|
+
label.mode input { margin-top: 3px; }
|
|
46
|
+
.dur { margin: 6px 0 0 24px; display: grid; gap: 4px; }
|
|
47
|
+
.dur[hidden] { display: none; }
|
|
48
|
+
.dur label { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; cursor: pointer; }
|
|
49
|
+
.dur input[type=number] { width: 80px; font: inherit; padding: 2px 6px; border-radius: 6px; border: 1px solid var(--line); background: var(--bg); color: var(--text); }
|
|
50
|
+
.dur select { font: inherit; border-radius: 6px; border: 1px solid var(--line); background: var(--bg); color: var(--text); }
|
|
51
|
+
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
52
|
+
.empty { color: var(--muted); padding: 6px 0; }
|
|
53
|
+
#viewer { position: fixed; inset: 0; background: var(--bg); display: none; flex-direction: column; }
|
|
54
|
+
#viewer.on { display: flex; }
|
|
55
|
+
#viewer header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--line); background: var(--card); flex-wrap: wrap; }
|
|
56
|
+
#viewer header .tabs button.on { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
57
|
+
#viewer iframe { flex: 1; border: 0; width: 100%; background: var(--bg); }
|
|
58
|
+
</style>
|
|
59
|
+
</head>
|
|
60
|
+
<body>
|
|
61
|
+
<main>
|
|
62
|
+
<h1>其他电脑</h1>
|
|
63
|
+
<p class="sub">两台电脑都开着 DSH、都连着同一个 Tailscale,就能用一串数字连起来。只走 Tailscale 内网,不上公网。</p>
|
|
64
|
+
<div class="grid">
|
|
65
|
+
<section id="host">
|
|
66
|
+
<h2>让别的电脑连我</h2>
|
|
67
|
+
<p class="hint">在这台电脑生成一串数字,到另一台电脑的 DSH 里输入,那台就能看到并操作这台的频道、工作台和面板。</p>
|
|
68
|
+
<div id="hostIdle"><button class="primary" id="newCode">生成数字</button></div>
|
|
69
|
+
<div id="hostCode" hidden>
|
|
70
|
+
<div class="code" id="codeText">------</div>
|
|
71
|
+
<p class="center muted" id="codeLeft"></p>
|
|
72
|
+
<p class="center muted">在另一台电脑点「连到另一台电脑」,选这台电脑,输入上面的数字。只能用一次。</p>
|
|
73
|
+
<div class="center"><button id="cancelCode">不连了</button></div>
|
|
74
|
+
</div>
|
|
75
|
+
<p class="err" id="hostErr"></p>
|
|
76
|
+
<h3>已连接的设备</h3>
|
|
77
|
+
<ul class="list" id="devices"><li class="empty">还没有别的电脑连进来</li></ul>
|
|
78
|
+
</section>
|
|
79
|
+
<section id="ctl">
|
|
80
|
+
<h2>连到另一台电脑</h2>
|
|
81
|
+
<p class="hint">先在那台电脑点「让别的电脑连我」,再在下面选它、输数字。</p>
|
|
82
|
+
<div class="row"><button id="refresh">刷新列表</button><span class="muted" id="peersNote"></span></div>
|
|
83
|
+
<ul class="list" id="peers"><li class="empty">正在找同一网络里的电脑…</li></ul>
|
|
84
|
+
<div class="connect" id="connect" hidden>
|
|
85
|
+
<div class="muted">连到:<b id="selName"></b></div>
|
|
86
|
+
<input class="digits" id="digits" inputmode="numeric" autocomplete="one-time-code" maxlength="7" placeholder="输入那台电脑上的数字">
|
|
87
|
+
<label class="mode"><input type="radio" name="mode" value="temp" checked><span><b>临时</b><br><span class="muted">断开后下次要重新输数字。</span></span></label>
|
|
88
|
+
<div class="dur" id="dur">
|
|
89
|
+
<label><input type="radio" name="dur" value="until" checked>直到我断开<span class="muted">(任一台点「断开」,或关掉 DSH 窗口;刷新页面不会断)</span></label>
|
|
90
|
+
<label><input type="radio" name="dur" value="custom">自定义时长:<input type="number" id="durNum" min="1" step="1" value="1" inputmode="numeric"><select id="durUnit"><option value="60000">分钟</option><option value="3600000" selected>小时</option></select><span class="muted">到时自动断</span></label>
|
|
91
|
+
</div>
|
|
92
|
+
<label class="mode"><input type="radio" name="mode" value="remember"><span><b>记住这台电脑</b><br><span class="muted">以后打开就能直接连,不用再输。那台电脑随时可以在「已连接的设备」里踢掉。</span></span></label>
|
|
93
|
+
<div class="row" style="margin-top:10px"><button class="primary" id="doConnect">连接</button></div>
|
|
94
|
+
</div>
|
|
95
|
+
<p class="err" id="ctlErr"></p>
|
|
96
|
+
<h3>我连着的电脑</h3>
|
|
97
|
+
<ul class="list" id="links"><li class="empty">还没有连着的电脑</li></ul>
|
|
98
|
+
</section>
|
|
99
|
+
</div>
|
|
100
|
+
</main>
|
|
101
|
+
<div id="viewer">
|
|
102
|
+
<header>
|
|
103
|
+
<button id="back">← 返回</button>
|
|
104
|
+
<b id="viewName"></b>
|
|
105
|
+
<span class="tabs row">
|
|
106
|
+
<button data-tab="">频道</button>
|
|
107
|
+
<button data-tab="desk">工作台</button>
|
|
108
|
+
<button data-tab="panel/">面板</button>
|
|
109
|
+
</span>
|
|
110
|
+
</header>
|
|
111
|
+
<iframe id="frame" title="另一台电脑"></iframe>
|
|
112
|
+
</div>
|
|
113
|
+
<script>
|
|
114
|
+
'use strict';
|
|
115
|
+
const $ = id => document.getElementById(id);
|
|
116
|
+
const MODE = { temp: '临时', remember: '记住' };
|
|
117
|
+
let codeTimer, codePoll, selected, viewing, links = [];
|
|
118
|
+
|
|
119
|
+
async function api(path, init) {
|
|
120
|
+
const res = await fetch(path, { credentials: 'same-origin', ...init });
|
|
121
|
+
let body = null;
|
|
122
|
+
try { body = await res.json(); } catch { /* empty */ }
|
|
123
|
+
if (!res.ok) {
|
|
124
|
+
const msg = body && body.error ? body.error : (res.status === 401 ? '请先在这台电脑的 DSH 网页登录' : `出错了(${res.status})`);
|
|
125
|
+
throw new Error(msg);
|
|
126
|
+
}
|
|
127
|
+
return body;
|
|
128
|
+
}
|
|
129
|
+
const post = (path, body) => api(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body ?? {}) });
|
|
130
|
+
const del = path => api(path, { method: 'DELETE' });
|
|
131
|
+
function clock(ms) {
|
|
132
|
+
const d = new Date(ms);
|
|
133
|
+
const t = d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
|
134
|
+
return d.toDateString() === new Date().toDateString() ? t : `${d.getMonth() + 1}月${d.getDate()}日 ${t}`;
|
|
135
|
+
}
|
|
136
|
+
// How long a temporary link lasts, as shown in both lists.
|
|
137
|
+
function lasts(x) {
|
|
138
|
+
if (x.mode !== 'temp') return '记住了';
|
|
139
|
+
return x.expires_at ? `临时 · ${clock(x.expires_at)} 自动断` : '临时 · 直到断开';
|
|
140
|
+
}
|
|
141
|
+
function ago(ms) {
|
|
142
|
+
const s = Math.max(0, Math.round((Date.now() - ms) / 1000));
|
|
143
|
+
if (s < 60) return '刚刚';
|
|
144
|
+
if (s < 3600) return `${Math.floor(s / 60)} 分钟前`;
|
|
145
|
+
return `${Math.floor(s / 3600)} 小时前`;
|
|
146
|
+
}
|
|
147
|
+
function el(tag, attrs, ...kids) {
|
|
148
|
+
const n = document.createElement(tag);
|
|
149
|
+
for (const [k, v] of Object.entries(attrs || {})) {
|
|
150
|
+
if (k === 'class') n.className = v; else if (k.startsWith('on')) n.addEventListener(k.slice(2), v); else n.setAttribute(k, v);
|
|
151
|
+
}
|
|
152
|
+
for (const k of kids) if (k != null) n.append(k);
|
|
153
|
+
return n;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---- 让别的电脑连我 ----
|
|
157
|
+
function showCode(code, expiresAt) {
|
|
158
|
+
$('hostIdle').hidden = true; $('hostCode').hidden = false;
|
|
159
|
+
$('codeText').textContent = code.slice(0, 3) + ' ' + code.slice(3);
|
|
160
|
+
clearInterval(codeTimer);
|
|
161
|
+
const tick = () => {
|
|
162
|
+
const left = Math.max(0, Math.round((expiresAt - Date.now()) / 1000));
|
|
163
|
+
$('codeLeft').textContent = left > 0 ? `还剩 ${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}` : '已过期';
|
|
164
|
+
if (left <= 0) hideCode('这串数字过期了,需要的话再生成一个。');
|
|
165
|
+
};
|
|
166
|
+
tick();
|
|
167
|
+
codeTimer = setInterval(tick, 1000);
|
|
168
|
+
clearInterval(codePoll);
|
|
169
|
+
const before = new Set([...document.querySelectorAll('#devices li[data-id]')].map(li => li.dataset.id));
|
|
170
|
+
codePoll = setInterval(async () => {
|
|
171
|
+
try {
|
|
172
|
+
const s = await api('api/pair/code');
|
|
173
|
+
if (!s.waiting) {
|
|
174
|
+
const { devices } = await api('api/pair/devices');
|
|
175
|
+
renderDevices(devices);
|
|
176
|
+
const fresh = devices.find(d => !before.has(d.id));
|
|
177
|
+
hideCode(fresh ? '' : '这串数字已失效(输错太多次或被取消),需要的话再生成一个。');
|
|
178
|
+
if (fresh) $('hostErr').innerHTML = '', $('hostErr').append(el('span', { class: 'ok' }, `已连上:${fresh.name}`));
|
|
179
|
+
}
|
|
180
|
+
} catch { /* keep showing */ }
|
|
181
|
+
}, 2000);
|
|
182
|
+
}
|
|
183
|
+
function hideCode(note) {
|
|
184
|
+
clearInterval(codeTimer); clearInterval(codePoll);
|
|
185
|
+
$('hostIdle').hidden = false; $('hostCode').hidden = true;
|
|
186
|
+
$('hostErr').textContent = note || '';
|
|
187
|
+
}
|
|
188
|
+
$('newCode').onclick = async () => {
|
|
189
|
+
$('hostErr').textContent = '';
|
|
190
|
+
$('newCode').disabled = true;
|
|
191
|
+
try { const r = await post('api/pair/code'); showCode(r.code, r.expires_at); }
|
|
192
|
+
catch (e) { $('hostErr').textContent = e.message; }
|
|
193
|
+
finally { $('newCode').disabled = false; }
|
|
194
|
+
};
|
|
195
|
+
$('cancelCode').onclick = async () => { try { await del('api/pair/code'); } catch { /* ignore */ } hideCode(''); };
|
|
196
|
+
|
|
197
|
+
function renderDevices(devices) {
|
|
198
|
+
const ul = $('devices');
|
|
199
|
+
ul.replaceChildren();
|
|
200
|
+
if (!devices.length) { ul.append(el('li', { class: 'empty' }, '还没有别的电脑连进来')); return; }
|
|
201
|
+
for (const d of devices) {
|
|
202
|
+
ul.append(el('li', { 'data-id': d.id },
|
|
203
|
+
el('div', { class: 'grow' }, el('div', { class: 'name' }, d.name), el('div', { class: 'muted' }, `${lasts(d)} · 最近活动 ${ago(d.last_seen)}`)),
|
|
204
|
+
el('button', { class: 'danger', onclick: async () => {
|
|
205
|
+
if (!confirm(`踢掉「${d.name}」?它要重新输数字才能再连。`)) return;
|
|
206
|
+
try { await del(`api/pair/devices/${d.id}`); loadDevices(); } catch (e) { $('hostErr').textContent = e.message; }
|
|
207
|
+
} }, '踢掉')));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
async function loadDevices() {
|
|
211
|
+
try { renderDevices((await api('api/pair/devices')).devices); } catch (e) { $('hostErr').textContent = e.message; }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---- 连到另一台电脑 ----
|
|
215
|
+
function peerTag(p) {
|
|
216
|
+
if (!p.online) return el('span', { class: 'tag' }, '离线');
|
|
217
|
+
if (p.waiting) return el('span', { class: 'tag ok' }, '在等配对');
|
|
218
|
+
if (p.dsh) return el('span', { class: 'tag' }, '开着 DSH');
|
|
219
|
+
return el('span', { class: 'tag' }, '没在等配对');
|
|
220
|
+
}
|
|
221
|
+
async function loadPeers() {
|
|
222
|
+
$('peersNote').textContent = '正在找…';
|
|
223
|
+
$('ctlErr').textContent = '';
|
|
224
|
+
try {
|
|
225
|
+
const { peers } = await api('api/remote/peers');
|
|
226
|
+
const ul = $('peers');
|
|
227
|
+
ul.replaceChildren();
|
|
228
|
+
if (!peers.length) ul.append(el('li', { class: 'empty' }, 'Tailscale 里没有别的电脑'));
|
|
229
|
+
for (const p of peers) {
|
|
230
|
+
const li = el('li', { class: 'peer' + (p.online ? '' : ' off') + (selected && selected.ip === p.ip ? ' sel' : '') },
|
|
231
|
+
el('div', { class: 'grow' }, el('div', { class: 'name' }, p.dsh_name || p.name), el('div', { class: 'muted' }, `${p.ip}${p.os ? ' · ' + p.os : ''}`)),
|
|
232
|
+
peerTag(p));
|
|
233
|
+
if (p.online) li.onclick = () => select(p, li);
|
|
234
|
+
ul.append(li);
|
|
235
|
+
}
|
|
236
|
+
$('peersNote').textContent = '';
|
|
237
|
+
} catch (e) { $('peersNote').textContent = ''; $('ctlErr').textContent = e.message; }
|
|
238
|
+
}
|
|
239
|
+
function select(p, li) {
|
|
240
|
+
selected = p;
|
|
241
|
+
for (const n of document.querySelectorAll('li.peer')) n.classList.toggle('sel', n === li);
|
|
242
|
+
$('selName').textContent = p.dsh_name || p.name;
|
|
243
|
+
$('connect').hidden = false;
|
|
244
|
+
$('digits').value = '';
|
|
245
|
+
$('digits').focus();
|
|
246
|
+
if (!p.waiting) $('ctlErr').textContent = '那台电脑现在没在等配对:先在那台点「让别的电脑连我」,再点「刷新列表」。';
|
|
247
|
+
else $('ctlErr').textContent = '';
|
|
248
|
+
}
|
|
249
|
+
$('refresh').onclick = loadPeers;
|
|
250
|
+
// Only the custom-length box belongs to 临时.
|
|
251
|
+
for (const r of document.querySelectorAll('input[name=mode]')) r.addEventListener('change', () => { $('dur').hidden = document.querySelector('input[name=mode]:checked').value !== 'temp'; });
|
|
252
|
+
$('durNum').addEventListener('focus', () => { document.querySelector('input[name=dur][value=custom]').checked = true; });
|
|
253
|
+
// 「直到我断开」: this page beats while it is open (the DSH shell beats too, so switching panels
|
|
254
|
+
// keeps the link); a refresh comes back within the grace, closing the window lets it lapse.
|
|
255
|
+
// 来源:与 pairing.mjs ALIVE_EVERY_MS 同一节拍。
|
|
256
|
+
const ALIVE_EVERY_MS = 3000;
|
|
257
|
+
const beat = () => fetch('api/remote/alive', { method: 'POST', credentials: 'same-origin' }).catch(() => {});
|
|
258
|
+
beat();
|
|
259
|
+
setInterval(beat, ALIVE_EVERY_MS);
|
|
260
|
+
$('digits').addEventListener('keydown', e => { if (e.key === 'Enter') $('doConnect').click(); });
|
|
261
|
+
$('doConnect').onclick = async () => {
|
|
262
|
+
if (!selected) return;
|
|
263
|
+
const code = $('digits').value.replace(/\D/g, '');
|
|
264
|
+
if (!code) { $('ctlErr').textContent = '先输入那台电脑上显示的数字'; return; }
|
|
265
|
+
const mode = document.querySelector('input[name=mode]:checked').value;
|
|
266
|
+
let duration_ms = null;
|
|
267
|
+
if (mode === 'temp' && document.querySelector('input[name=dur]:checked').value === 'custom') {
|
|
268
|
+
const n = Number($('durNum').value);
|
|
269
|
+
if (!Number.isInteger(n) || n <= 0) { $('ctlErr').textContent = '自定义时长要填大于 0 的整数'; return; }
|
|
270
|
+
duration_ms = n * Number($('durUnit').value);
|
|
271
|
+
}
|
|
272
|
+
$('doConnect').disabled = true; $('ctlErr').textContent = '';
|
|
273
|
+
try {
|
|
274
|
+
const { link } = await post('api/remote/links', { host: selected.ip, code, mode, name: selected.dsh_name || selected.name, duration_ms });
|
|
275
|
+
$('connect').hidden = true; selected = undefined;
|
|
276
|
+
await loadLinks();
|
|
277
|
+
open(link, '');
|
|
278
|
+
} catch (e) { $('ctlErr').textContent = e.message; }
|
|
279
|
+
finally { $('doConnect').disabled = false; }
|
|
280
|
+
};
|
|
281
|
+
async function loadLinks() {
|
|
282
|
+
try {
|
|
283
|
+
({ links } = await api('api/remote/links'));
|
|
284
|
+
const ul = $('links');
|
|
285
|
+
ul.replaceChildren();
|
|
286
|
+
if (!links.length) { ul.append(el('li', { class: 'empty' }, '还没有连着的电脑')); return; }
|
|
287
|
+
for (const l of links) {
|
|
288
|
+
ul.append(el('li', null,
|
|
289
|
+
el('div', { class: 'grow' }, el('div', { class: 'name' }, l.name), el('div', { class: 'muted' }, lasts(l))),
|
|
290
|
+
el('button', { class: 'primary', onclick: () => open(l, '') }, '打开'),
|
|
291
|
+
el('button', { onclick: async () => {
|
|
292
|
+
if (!confirm(`断开「${l.name}」?下次要重新输数字。`)) return;
|
|
293
|
+
try { await del(`api/remote/links/${l.id}`); loadLinks(); } catch (e) { $('ctlErr').textContent = e.message; }
|
|
294
|
+
} }, '断开')));
|
|
295
|
+
}
|
|
296
|
+
} catch (e) { $('ctlErr').textContent = e.message; }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ---- 看那台电脑 ----
|
|
300
|
+
function open(link, tab) {
|
|
301
|
+
viewing = link;
|
|
302
|
+
$('viewName').textContent = link.name;
|
|
303
|
+
$('viewer').classList.add('on');
|
|
304
|
+
show(tab);
|
|
305
|
+
}
|
|
306
|
+
function show(tab) {
|
|
307
|
+
for (const b of document.querySelectorAll('#viewer [data-tab]')) b.classList.toggle('on', b.dataset.tab === tab);
|
|
308
|
+
$('frame').src = `remote/${viewing.id}/${tab}`;
|
|
309
|
+
}
|
|
310
|
+
for (const b of document.querySelectorAll('#viewer [data-tab]')) b.onclick = () => show(b.dataset.tab);
|
|
311
|
+
$('back').onclick = () => { $('viewer').classList.remove('on'); $('frame').src = 'about:blank'; viewing = undefined; loadLinks(); };
|
|
312
|
+
|
|
313
|
+
(async () => {
|
|
314
|
+
loadDevices(); loadLinks(); loadPeers();
|
|
315
|
+
try { const s = await api('api/pair/code'); if (s.waiting) $('hostErr').textContent = '刚才生成的数字还在等待中;看不到数字的话点「生成数字」换一个。'; } catch { /* ignore */ }
|
|
316
|
+
})();
|
|
317
|
+
</script>
|
|
318
|
+
</body>
|
|
319
|
+
</html>
|
package/collab/dsh-member.mjs
CHANGED
|
@@ -69,7 +69,8 @@ const MAX_LOCAL_IMAGE_BYTES = 8 * 1024 * 1024;
|
|
|
69
69
|
const MAX_LOCAL_IMAGES = 10;
|
|
70
70
|
const IMAGE_MIME = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp' };
|
|
71
71
|
// Markdown targets may contain spaces; bare paths end at whitespace or punctuation.
|
|
72
|
-
|
|
72
|
+
// An absolute path starts with / or, on Windows, a drive (C:\ or C:/): members there write C:\Users\…\shot.png.
|
|
73
|
+
const LOCAL_IMAGE = /!\[([^\]\n]*)\]\(((?:\/|[A-Za-z]:[\\/])[^)\n]+?\.(?:png|jpe?g|gif|webp))\)|(?<![\w/\\:])(?:\/|[A-Za-z]:[\\/])[^\s<>"'`()[\]{},。;,;]+?\.(?:png|jpe?g|gif|webp)(?![\w/\\])/gi;
|
|
73
74
|
|
|
74
75
|
/** Only files physically inside this room's working directory may leave the machine. */
|
|
75
76
|
export function localImageReply(text, workspacePath, files, logger) {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// #1044: team desk, bothouse panel and other computers used to sit in the DSH sidebar. They are
|
|
2
|
+
// separate rows on the Plugins page now and start off. Someone who had them keeps them: the first
|
|
3
|
+
// start of this version on an existing install switches on, once, exactly the pages the old version
|
|
4
|
+
// showed, through DSH's own plugin manager (the same call as the row switch on the Plugins page).
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { dirname } from 'node:path';
|
|
7
|
+
|
|
8
|
+
export const PAGE_ROWS = ['bothouse-desk', 'bothouse-panel', 'bothouse-devices'];
|
|
9
|
+
// What bothouse-dsh on npm showed before: 0.1.47 and 0.1.48 had desk and panel, no version had
|
|
10
|
+
// other computers (0.1.46 and older had channels only, but cannot be told apart after the upgrade).
|
|
11
|
+
export const NPM_PAGES_BEFORE = ['bothouse-desk', 'bothouse-panel'];
|
|
12
|
+
// Bridge install (conest-channels-ui) and npm install (bothouse-channels).
|
|
13
|
+
const BRIDGE_CHANNELS = 'conest-channels-ui';
|
|
14
|
+
const NPM_CHANNELS = 'bothouse-channels';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Switch on the pages this install showed before, then record that it ran.
|
|
18
|
+
* @param {{ listPlugins(): Promise<any[]>, setPluginEnabled(id: string, enabled: boolean): Promise<unknown> } | undefined} manager
|
|
19
|
+
* @param {{ markerFile: string, beforeFile: string, existed: boolean, logger?: { info: Function } }} opts
|
|
20
|
+
* existed — the collab database was there before this start (an install from before #1044).
|
|
21
|
+
* beforeFile — the bridge writes the rows its old channels UI showed (bridge core/setup_dsh_embed.go).
|
|
22
|
+
* @returns {Promise<string>} 'done-before' | 'fresh' | 'no-manager' | 'channels-off' | 'enabled'
|
|
23
|
+
*/
|
|
24
|
+
export async function migratePageRows(manager, { markerFile, beforeFile, existed, logger }) {
|
|
25
|
+
if (existsSync(markerFile)) return 'done-before';
|
|
26
|
+
const mark = (outcome) => {
|
|
27
|
+
mkdirSync(dirname(markerFile), { recursive: true });
|
|
28
|
+
writeFileSync(markerFile, `${outcome} ${new Date().toISOString()}\n`);
|
|
29
|
+
return outcome;
|
|
30
|
+
};
|
|
31
|
+
if (!existed) return mark('fresh');
|
|
32
|
+
// No plugin manager (not a base-backed profile): try again next start.
|
|
33
|
+
if (!manager) return 'no-manager';
|
|
34
|
+
const rows = await manager.listPlugins();
|
|
35
|
+
const channels = rows.find(row => row.patchId === BRIDGE_CHANNELS || row.patchId === NPM_CHANNELS);
|
|
36
|
+
// Channels off before = no page showed before (they all came with the channels UI).
|
|
37
|
+
if (channels && channels.enabled === false) return mark('channels-off');
|
|
38
|
+
let before = [];
|
|
39
|
+
if (existsSync(beforeFile)) before = JSON.parse(readFileSync(beforeFile, 'utf8')).rows ?? [];
|
|
40
|
+
else if (channels?.patchId === NPM_CHANNELS) before = NPM_PAGES_BEFORE;
|
|
41
|
+
const on = [];
|
|
42
|
+
for (const id of PAGE_ROWS.filter(id => before.includes(id))) {
|
|
43
|
+
const row = rows.find(r => r.patchId === id);
|
|
44
|
+
if (!row || row.enabled || !row.entryId || row.readOnlyReason) continue;
|
|
45
|
+
await manager.setPluginEnabled(row.entryId, true);
|
|
46
|
+
on.push(id);
|
|
47
|
+
}
|
|
48
|
+
logger?.info('conest-collab: kept the pages this install had: %s', on.join(', ') || 'none');
|
|
49
|
+
return mark('enabled');
|
|
50
|
+
}
|
package/collab/plugin.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { personaLibrary } from './personas.mjs';
|
|
|
6
6
|
// name: /abs/path/dsh/collab/plugin.mjs
|
|
7
7
|
// config: { dataDir, workspacePath, leadFile?, fallbackLead?, botTokenEnv?, botPort?, ownerName?, rooms?, dshMembers?, maxRelay?, boardDir?, teamStatusDir?, wikiDir?, bridgeConfig? }
|
|
8
8
|
import { createRequire } from 'node:module';
|
|
9
|
-
import { readFileSync, realpathSync, statSync } from 'node:fs';
|
|
9
|
+
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
10
10
|
import { isAbsolute, join } from 'node:path';
|
|
11
11
|
import { pathToFileURL } from 'node:url';
|
|
12
12
|
import { Store, CollabError, publicMessage } from './store.mjs';
|
|
@@ -32,6 +32,7 @@ import { defaultBridgeConfig, panelRoute } from './panel.mjs';
|
|
|
32
32
|
import { TeamStatus } from './team-status.mjs';
|
|
33
33
|
import { ALIVE_EVERY_MS, PairClient, PairHost, PairListener, PAIR_PORT, REMOTE_OWNER, hostHandler, isLoopback, pairApiRoute, remoteProxyRoute, tailnetAddress } from './pairing.mjs';
|
|
34
34
|
import { homedir, hostname } from 'node:os';
|
|
35
|
+
import { migratePageRows } from './pages-migrate.mjs';
|
|
35
36
|
|
|
36
37
|
export const name = 'conest-collab';
|
|
37
38
|
export const inject = [
|
|
@@ -133,6 +134,8 @@ export async function apply(ctx, rawConfig) {
|
|
|
133
134
|
const { WebSocketServer } = createRequire(import.meta.url)('ws');
|
|
134
135
|
if (typeof createUserMessage !== 'function' || typeof WebSocketServer !== 'function') throw Error('conest-collab: unsupported DSH runtime');
|
|
135
136
|
|
|
137
|
+
// #1044: an install from before had the desk, panel and other-computers pages; checked before Store creates the file.
|
|
138
|
+
const collabExisted = existsSync(join(config.dataDir, 'collab.sqlite3'));
|
|
136
139
|
// #445: the instance maxRelay stays the default for channels that never set their own cap.
|
|
137
140
|
const store = new Store(join(config.dataDir, 'collab.sqlite3'), { maxRelay: config.maxRelay });
|
|
138
141
|
for (const room of config.rooms) {
|
|
@@ -215,6 +218,13 @@ export async function apply(ctx, rawConfig) {
|
|
|
215
218
|
|
|
216
219
|
ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/collab', handler: (req, res) => browserRoute(req, res) }), 'conest-collab: /collab');
|
|
217
220
|
ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/api/rooms', handler: (req, res) => historyRoute(req, res) }), 'conest-collab: /api/rooms');
|
|
221
|
+
// #1044: keep the pages an existing install had (once). Deferred: the plugin manager reloads rows,
|
|
222
|
+
// which must not run inside this plugin's own activation.
|
|
223
|
+
const pagesTimer = setTimeout(() => {
|
|
224
|
+
migratePageRows(ctx.get('pluginManager'), { markerFile: join(config.dataDir, 'pages-migrated-1044'), beforeFile: join(config.dataDir, 'pages-before-1044.json'), existed: collabExisted, logger: ctx.logger })
|
|
225
|
+
.catch(err => ctx.logger.warn('conest-collab: keep pages: %s', err.message));
|
|
226
|
+
}, 3000);
|
|
227
|
+
ctx.effect(() => () => clearTimeout(pagesTimer), 'conest-collab: keep pages');
|
|
218
228
|
const botUpgrade = (req, socket, head) => {
|
|
219
229
|
const url = new URL(req.url ?? '/', 'http://collab.invalid');
|
|
220
230
|
later.origin ??= localOrigin(req);
|
|
@@ -4,20 +4,20 @@
|
|
|
4
4
|
- 聊天频道要保持畅通,大活交工作区:预计超过 10 分钟,或要改代码、跑测试、等命令的,谁接活谁这一轮写交接(副手被直接点名也一样)(「用户原话」「总目标」「范围」「进度」「相关改动」「规矩」「参照实现」七节;范围把用户原话里的名词逐个写成「改:…」或「不改:…」;相关改动写一行「冲突:无」,有冲突先在聊天频道问用户),跑 `ws-handoff <交接文件> "<任务>"`,不在聊天频道自己做;它不算开子 agent,不用另问用户;交出就结束本轮,不在本轮等或查进度,结果由工作区 [report] 转回。工作区里接到的活自己做。DSH 频道调 POST /collab/api/rooms/{聊天频道 id}/workspaces。一个工作区同一时间只做一件活,满 4 个就排队。
|
|
5
5
|
- 同团队同平台成员在新频道默认可派;团队外成员才请用户发 `/bind <队友项目名>`,机器人自己发的 /bind 不生效。
|
|
6
6
|
- 在工作区里要问用户或交结果:最后一条回复第一行写 [ask] 或 [report] 加 #任务板编号和「背景:」,再一行做了什么,再一行「要你做的:」,再一行「我加的:」(用户原话里没有、你加的设定,没有写「无」);这四段贴到聊天频道,不写编号、代号、路径、表格;之后是细节,进子话题。在跑时有截图或阶段结果就 ws-up 推一条。
|
|
7
|
-
- 发 [ask]
|
|
7
|
+
- 发 [ask] 后工作区让出、会话清空,用户回复后系统找空工作区带交接文件和答复接着做;交回最终 [report] 后系统自动标完成、清空会话、放回工作区。
|
|
8
8
|
- 一个工作区同一时间只归一个聊天频道;挑空着的,都占着就新建。所有聊天频道共用同一组工作区1..n。
|
|
9
9
|
- 收口(每小时合结果、发汇报)时,没收完、这一小时也没在动的活由收口的人当场暂停(`board pause <编号>`):它让出工作区,那里没别的活就放回池子、清空会话;恢复时重新交活,先用空着的工作区。
|
|
10
|
-
-
|
|
10
|
+
- 会缩小或改变用户原话范围的决定(砍功能、换方向等),以及用户原话里没有、你拿不准的设定,不要自己定:在工作区用 [ask] 转到聊天频道问用户,等答复再做;等的时候先做不受影响的部分。主角色拿不准也一样,在聊天频道直接问用户。
|
|
11
11
|
- 转话必须指定哪个工作区,不默认发给最后一个。
|
|
12
12
|
- 收到派活时,回复第一行先表态,四选一:【接】能做,直接开工;【问】信息不够,列出缺的哪几点;【退】不是自己的活,说明谁更合适;【升】涉及不可逆操作、花钱或方向选择,交给派活的人拍板。后面可跟 #任务号 指定哪一件,不写就回最近那件没表态的活。
|
|
13
|
-
- 叫队友:@名字(或 @成员 id
|
|
14
|
-
- 分发的活系统每10
|
|
13
|
+
- 叫队友:@名字(或 @成员 id),或把名字放在行首(后跟标点、空格或「你、帮、来」这类叫人的词);行首一串名字都会叫上。名字在句中不算叫。
|
|
14
|
+
- 分发的活系统每10分钟看进展:有就一直续,没有就自动催负责人并暂停,不用自己再挂计时;被催的交回或说明卡点。
|
|
15
15
|
- 交活写明参照的现成实现(文件:行;非代码活写「无(新建)」);多人改同一组文件时写明底分支,改公共接口的那份先合。
|
|
16
16
|
- 干活中每 10 分钟前推一次半成品分支,并在交接里记一行检查点。
|
|
17
17
|
- [ask] 等用户前先自验,正文写「自验:」附证据(截图或命令输出)和「类别:」;只有方向、花钱、需要用户看的成品、权限确认四类可直接等用户。
|
|
18
18
|
- 外部用户反馈的问题自己复现、修、在发布包上验,验过收口。
|
|
19
19
|
- 审过的 PR 当场合;整点只做发版、重启和真实入口验证。
|
|
20
|
-
-
|
|
20
|
+
- 自测、预演一律隔离:用隔离数据目录和真实配置的副本(去掉密钥),不写线上事件和数据文件,先备好回到改前配置的步骤;真实入口验收不往用户的聊天频道发探针或自验消息,用专门的测试频道,或只看日志。
|
|
21
21
|
## solo
|
|
22
22
|
- 派活方式:单模型多进程。活只交给你自己的子任务(子 agent),不派给频道里的其他成员。
|
|
23
23
|
- 写的人不验收自己的活:开一个干净上下文的子任务来验收。
|