agent-loft 0.2.34 → 0.2.35
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 +10 -3
- package/package.json +7 -1
- package/src/channels/web/api.ts +27 -1
- package/src/channels/web/desktop.ts +183 -0
- package/src/channels/web/server.ts +64 -2
- package/src/channels/web/terminal.ts +154 -0
- package/src/core/config.ts +27 -0
- package/src/core/desktop.ts +281 -0
- package/src/core/terminals.ts +308 -0
- package/src/core/users.ts +16 -1
- package/web/css/remote.css +350 -0
- package/web/css/terminal.css +243 -0
- package/web/index.html +2 -0
- package/web/lib/dom.js +13 -0
- package/web/lib/ws.js +6 -1
- package/web/settings/general.js +96 -0
- package/web/settings/users.js +23 -1
- package/web/state.js +12 -1
- package/web/views/desktop-touch.js +344 -0
- package/web/views/desktop.js +507 -0
- package/web/views/layout.js +57 -1
- package/web/views/messages.js +3 -1
- package/web/views/remote.js +136 -0
- package/web/views/sidebar.js +6 -1
- package/web/views/tasks.js +2 -0
- package/web/views/terminal-touch.js +207 -0
- package/web/views/terminal.js +540 -0
- package/web/views/topbar.js +4 -3
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { fill, h, icon, isTouch, toast } from "../lib/dom.js";
|
|
2
|
+
import { remoteTabs, S, ui } from "../state.js";
|
|
3
|
+
import { WS } from "../lib/ws.js";
|
|
4
|
+
import { closeSide, renderSidebar } from "./sidebar.js";
|
|
5
|
+
import { renderTopbar } from "./topbar.js";
|
|
6
|
+
import { openTasksView } from "./layout.js";
|
|
7
|
+
|
|
8
|
+
// ---------- 远程控制 ----------
|
|
9
|
+
// 侧栏「定时任务」下面的入口:上面切「桌面 / 终端」,下面是对应的那一块。
|
|
10
|
+
// 切回对话时这一页只是藏起来:桌面、终端的连接都在后台留着,再进来接着用。
|
|
11
|
+
// 这台机器没有桌面时只有终端(不显示切换)。
|
|
12
|
+
|
|
13
|
+
const TABS = { desktop: { label: "桌面", icon: "monitor" }, terminal: { label: "终端", icon: "terminal" } };
|
|
14
|
+
|
|
15
|
+
const R = {
|
|
16
|
+
tab: localStorage.getItem("aim.remoteTab") || "desktop",
|
|
17
|
+
/** 进来之前在看什么:「放到对话下面」时回去 */
|
|
18
|
+
before: null,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function ensurePane() {
|
|
22
|
+
if (ui.remote) return;
|
|
23
|
+
ui.remoteSwitch = h("div", { class: "remote-switch", role: "tablist" });
|
|
24
|
+
ui.remoteTools = h("div", { class: "remote-tools" });
|
|
25
|
+
ui.remoteDesk = h("div", { class: "remote-body", hidden: true });
|
|
26
|
+
ui.remoteTerm = h("div", { class: "remote-body", hidden: true });
|
|
27
|
+
ui.remote = h(
|
|
28
|
+
"section",
|
|
29
|
+
{ class: "remote-pane", hidden: true, "aria-label": "远程控制" },
|
|
30
|
+
h("div", { class: "remote-head" }, ui.remoteSwitch, h("span", { class: "grow" }), ui.remoteTools),
|
|
31
|
+
ui.remoteDesk,
|
|
32
|
+
ui.remoteTerm,
|
|
33
|
+
);
|
|
34
|
+
ui.main.append(ui.remote);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function renderSwitch() {
|
|
38
|
+
const tabs = remoteTabs();
|
|
39
|
+
fill(
|
|
40
|
+
ui.remoteSwitch,
|
|
41
|
+
tabs.length > 1
|
|
42
|
+
? tabs.map((t) =>
|
|
43
|
+
h(
|
|
44
|
+
"button",
|
|
45
|
+
{ class: `remote-tab${t === R.tab ? " on" : ""}`, role: "tab", title: TABS[t].label, "aria-selected": String(t === R.tab), onclick: () => void select(t) },
|
|
46
|
+
icon(TABS[t].icon),
|
|
47
|
+
h("span", {}, TABS[t].label),
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
: null,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 打开远程控制;tab 不给就是上次看的那页 */
|
|
55
|
+
export async function openRemote(tab) {
|
|
56
|
+
const tabs = remoteTabs();
|
|
57
|
+
if (!tabs.length) return toast("没有可用的远程控制");
|
|
58
|
+
ensurePane();
|
|
59
|
+
if (S.view !== "remote") {
|
|
60
|
+
R.before = S.view;
|
|
61
|
+
R.scroll = ui.messages.scrollTop;
|
|
62
|
+
}
|
|
63
|
+
S.view = "remote";
|
|
64
|
+
ui.main.classList.add("remote-view");
|
|
65
|
+
ui.remote.hidden = false;
|
|
66
|
+
closeSide();
|
|
67
|
+
renderTopbar();
|
|
68
|
+
renderSidebar();
|
|
69
|
+
WS.presence();
|
|
70
|
+
await select(tabs.includes(tab) ? tab : tabs.includes(R.tab) ? R.tab : tabs[0]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function select(tab) {
|
|
74
|
+
R.tab = tab;
|
|
75
|
+
localStorage.setItem("aim.remoteTab", tab);
|
|
76
|
+
history.replaceState(null, "", `#remote/${tab}`);
|
|
77
|
+
renderSwitch();
|
|
78
|
+
ui.remoteDesk.hidden = tab !== "desktop";
|
|
79
|
+
ui.remoteTerm.hidden = tab !== "terminal";
|
|
80
|
+
fill(ui.remoteTools);
|
|
81
|
+
if (tab === "terminal") (await import("./terminal.js")).embedIn(ui.remoteTerm);
|
|
82
|
+
else (await import("./desktop.js")).showDesktop(ui.remoteDesk, ui.remoteTools);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 藏起这一页(连接不断) */
|
|
86
|
+
function hidePane() {
|
|
87
|
+
if (!ui.remote) return;
|
|
88
|
+
ui.remote.hidden = true;
|
|
89
|
+
ui.main.classList.remove("remote-view");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 回到进来之前在看的(对话没动过,直接露出来) */
|
|
93
|
+
export function leaveRemote() {
|
|
94
|
+
if (S.view !== "remote") return;
|
|
95
|
+
S.view = null;
|
|
96
|
+
hidePane();
|
|
97
|
+
const before = R.before;
|
|
98
|
+
R.before = null;
|
|
99
|
+
if (before === "tasks") return openTasksView();
|
|
100
|
+
history.replaceState(null, "", S.current ? `#${encodeURIComponent(S.current)}` : location.pathname);
|
|
101
|
+
// 对话区藏着的时候滚动位置没了:放回原处
|
|
102
|
+
ui.messages.scrollTop = R.scroll ?? ui.messages.scrollTop;
|
|
103
|
+
renderTopbar();
|
|
104
|
+
renderSidebar();
|
|
105
|
+
WS.presence();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// iOS 弹出软键盘时页面不会变矮(不认 interactive-widget),键盘会盖住下面那排快捷键:
|
|
109
|
+
// 量出被盖住的高度,远程控制页和对话下面的终端面板垫高这么多(css/remote.css 的 --kb-inset)
|
|
110
|
+
// 只在触屏设备上算:电脑上双指缩放网页、出了滚动条时 visualViewport 也会变小,不是键盘
|
|
111
|
+
const vv = window.visualViewport;
|
|
112
|
+
if (vv && isTouch) {
|
|
113
|
+
const inset = () => {
|
|
114
|
+
const covered = Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop));
|
|
115
|
+
document.documentElement.style.setProperty("--kb-inset", `${covered}px`);
|
|
116
|
+
};
|
|
117
|
+
vv.addEventListener("resize", inset);
|
|
118
|
+
vv.addEventListener("scroll", inset);
|
|
119
|
+
inset();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 打开了对话、新聊天、定时任务:这一页让开,连接留着
|
|
123
|
+
document.addEventListener("aim:leave-view", () => {
|
|
124
|
+
hidePane();
|
|
125
|
+
R.before = null;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
/** 权限、设置变了:能切的页变了就重画;一页都没了就退出 */
|
|
129
|
+
export function syncRemote() {
|
|
130
|
+
if (!ui.remote) return;
|
|
131
|
+
const tabs = remoteTabs();
|
|
132
|
+
if (S.view !== "remote") return;
|
|
133
|
+
if (!tabs.length) return leaveRemote();
|
|
134
|
+
if (!tabs.includes(R.tab)) return void select(tabs[0]);
|
|
135
|
+
renderSwitch();
|
|
136
|
+
}
|
package/web/views/sidebar.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { fill, h, icon, ICON, toast } from "../lib/dom.js";
|
|
2
2
|
import { api } from "../lib/api.js";
|
|
3
|
-
import { desktop, S, ui } from "../state.js";
|
|
3
|
+
import { desktop, remoteTabs, S, ui } from "../state.js";
|
|
4
4
|
import { baseName } from "./topbar.js";
|
|
5
5
|
import { newDraft, openConversation } from "./messages.js";
|
|
6
6
|
|
|
@@ -358,6 +358,11 @@ export function renderSidebar() {
|
|
|
358
358
|
}
|
|
359
359
|
sidebarStale = false;
|
|
360
360
|
ui.tasksNav?.classList.toggle("active", S.view === "tasks");
|
|
361
|
+
if (ui.remoteNav) {
|
|
362
|
+
ui.remoteNav.hidden = !remoteTabs().length;
|
|
363
|
+
ui.remoteNav.classList.toggle("active", S.view === "remote");
|
|
364
|
+
ui.remoteNav.classList.toggle("live", S.remoteLive.desktop || S.remoteLive.terminals > 0);
|
|
365
|
+
}
|
|
361
366
|
const askKeys = new Set([...S.pending.values()].filter((i) => !i.settled).map((i) => i.convKey));
|
|
362
367
|
const visible = [...S.convs.values()].filter((c) => !c.hidden).sort((a, b) => b.activeAt - a.activeAt);
|
|
363
368
|
const cur = S.current ? S.convs.get(S.current) : null;
|
package/web/views/tasks.js
CHANGED
|
@@ -82,6 +82,8 @@ function toLocalInput(ms) {
|
|
|
82
82
|
export async function openTasks() {
|
|
83
83
|
if (S.current) WS.raw({ t: "unsub", key: S.current });
|
|
84
84
|
S.current = null;
|
|
85
|
+
// 远程控制页藏起来,连接留着(views/remote.js)
|
|
86
|
+
document.dispatchEvent(new Event("aim:leave-view"));
|
|
85
87
|
S.view = "tasks";
|
|
86
88
|
S.items = [];
|
|
87
89
|
S.turnAi = null;
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { h } from "../lib/dom.js";
|
|
2
|
+
import { copyText } from "../lib/markdown.js";
|
|
3
|
+
|
|
4
|
+
// ---------- 终端:手机上的手势 ----------
|
|
5
|
+
// xterm.js 在手机上既不能用手指翻历史,也不能长按选字。这里补上:
|
|
6
|
+
// 单指上下滑:翻看历史(带惯性);在 less、vim 这类占满整屏的程序里换成发 ↑↓(它们自己没有历史可翻)
|
|
7
|
+
// 长按:选中按住的那个词,接着拖动能扩大选区;松手后上面出一条「复制 / 全选 / 取消」
|
|
8
|
+
// 轻点:照旧交给 xterm(弹出键盘)
|
|
9
|
+
|
|
10
|
+
const SLOP = 8;
|
|
11
|
+
const LONG_PRESS_MS = 450;
|
|
12
|
+
|
|
13
|
+
/** tab:{ term, el };sendInput(data):发给 shell(↑↓ 用) */
|
|
14
|
+
export function attachTermTouch(tab, sendInput) {
|
|
15
|
+
const { term, el } = tab;
|
|
16
|
+
let g = null;
|
|
17
|
+
let fling = 0;
|
|
18
|
+
let selecting = null;
|
|
19
|
+
|
|
20
|
+
const bar = h(
|
|
21
|
+
"div",
|
|
22
|
+
{ class: "term-selbar", hidden: true },
|
|
23
|
+
h("button", { onpointerdown: (e) => e.preventDefault(), onclick: (e) => void copySel(e.currentTarget) }, "复制"),
|
|
24
|
+
h("button", { onpointerdown: (e) => e.preventDefault(), onclick: () => (term.selectAll(), showBar()) }, "全选"),
|
|
25
|
+
h("button", { onpointerdown: (e) => e.preventDefault(), onclick: () => clearSel() }, "取消"),
|
|
26
|
+
);
|
|
27
|
+
el.append(bar);
|
|
28
|
+
|
|
29
|
+
const screenEl = () => el.querySelector(".xterm-screen");
|
|
30
|
+
/** 屏幕上的点 → 缓冲区里的 (列, 行) */
|
|
31
|
+
function cellAt(x, y) {
|
|
32
|
+
const r = screenEl()?.getBoundingClientRect();
|
|
33
|
+
if (!r || !r.width) return null;
|
|
34
|
+
const col = Math.max(0, Math.min(term.cols - 1, Math.floor(((x - r.left) / r.width) * term.cols)));
|
|
35
|
+
const row = Math.max(0, Math.min(term.rows - 1, Math.floor(((y - r.top) / r.height) * term.rows)));
|
|
36
|
+
return { col, row: term.buffer.active.viewportY + row };
|
|
37
|
+
}
|
|
38
|
+
const lineHeight = () => (screenEl()?.getBoundingClientRect().height || 0) / term.rows || 16;
|
|
39
|
+
|
|
40
|
+
/** 按住的那个词(连续的非空格格子;中文一个字占两格,第二格是空的) */
|
|
41
|
+
function wordAt(cell) {
|
|
42
|
+
const line = term.buffer.active.getLine(cell.row);
|
|
43
|
+
if (!line) return { start: cell.col, len: 1 };
|
|
44
|
+
const filled = (i) => {
|
|
45
|
+
const c = line.getCell(i);
|
|
46
|
+
if (!c) return false;
|
|
47
|
+
const ch = c.getChars();
|
|
48
|
+
return ch === "" ? c.getWidth() === 0 : ch.trim() !== "";
|
|
49
|
+
};
|
|
50
|
+
if (!filled(cell.col)) return { start: cell.col, len: 1 };
|
|
51
|
+
let a = cell.col;
|
|
52
|
+
let b = cell.col;
|
|
53
|
+
while (a > 0 && filled(a - 1)) a--;
|
|
54
|
+
while (b < term.cols - 1 && filled(b + 1)) b++;
|
|
55
|
+
return { start: a, len: b - a + 1 };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function selectRange(from, to) {
|
|
59
|
+
const [a, b] = from.row < to.row || (from.row === to.row && from.col <= to.col) ? [from, to] : [to, from];
|
|
60
|
+
term.select(a.col, a.row, (b.row - a.row) * term.cols + (b.col - a.col) + 1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function showBar() {
|
|
64
|
+
bar.hidden = !term.hasSelection();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function clearSel() {
|
|
68
|
+
term.clearSelection();
|
|
69
|
+
selecting = null;
|
|
70
|
+
bar.hidden = true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function copySel(btn) {
|
|
74
|
+
const text = term.getSelection();
|
|
75
|
+
if (!text) return;
|
|
76
|
+
await copyText(text, btn);
|
|
77
|
+
setTimeout(clearSel, 600);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------- 滚动 ----------
|
|
81
|
+
|
|
82
|
+
let acc = 0;
|
|
83
|
+
/** 往下翻(看更新的)为正 */
|
|
84
|
+
function scrollBy(lines) {
|
|
85
|
+
if (!lines) return;
|
|
86
|
+
if (term.buffer.active.type === "alternate") {
|
|
87
|
+
// 占满整屏的程序:发 ↑↓ 让它自己翻
|
|
88
|
+
const app = term.modes.applicationCursorKeysMode;
|
|
89
|
+
const seq = lines > 0 ? (app ? "\x1bOB" : "\x1b[B") : app ? "\x1bOA" : "\x1b[A";
|
|
90
|
+
sendInput(seq.repeat(Math.min(Math.abs(lines), 20)));
|
|
91
|
+
} else term.scrollLines(lines);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function dragScroll(dy) {
|
|
95
|
+
// 手指往上推,内容往上走:看下面(更新)的内容
|
|
96
|
+
acc -= dy;
|
|
97
|
+
const lh = lineHeight();
|
|
98
|
+
const n = Math.trunc(acc / lh);
|
|
99
|
+
if (n) {
|
|
100
|
+
scrollBy(n);
|
|
101
|
+
acc -= n * lh;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function startFling(v) {
|
|
106
|
+
cancelAnimationFrame(fling);
|
|
107
|
+
let last = performance.now();
|
|
108
|
+
const step = (now) => {
|
|
109
|
+
// 标签关掉了
|
|
110
|
+
if (!el.isConnected) return;
|
|
111
|
+
const dt = now - last;
|
|
112
|
+
last = now;
|
|
113
|
+
v *= Math.pow(0.95, dt / 16);
|
|
114
|
+
if (Math.abs(v) < 0.03) return;
|
|
115
|
+
dragScroll(v * dt);
|
|
116
|
+
fling = requestAnimationFrame(step);
|
|
117
|
+
};
|
|
118
|
+
fling = requestAnimationFrame(step);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------- 手势 ----------
|
|
122
|
+
|
|
123
|
+
function onLong() {
|
|
124
|
+
if (!g || g.moved) return;
|
|
125
|
+
const cell = cellAt(g.x0, g.y0);
|
|
126
|
+
if (!cell) return;
|
|
127
|
+
navigator.vibrate?.(12);
|
|
128
|
+
const w = wordAt(cell);
|
|
129
|
+
selecting = { anchor: { col: w.start, row: cell.row }, end: { col: w.start + w.len - 1, row: cell.row } };
|
|
130
|
+
term.select(w.start, cell.row, w.len);
|
|
131
|
+
g.selecting = true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const opts = { capture: true, passive: false };
|
|
135
|
+
el.addEventListener(
|
|
136
|
+
"touchstart",
|
|
137
|
+
(e) => {
|
|
138
|
+
if (e.target.closest(".term-selbar")) return;
|
|
139
|
+
cancelAnimationFrame(fling);
|
|
140
|
+
if (e.touches.length !== 1) {
|
|
141
|
+
clearTimeout(g?.timer);
|
|
142
|
+
g = null;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const t = e.touches[0];
|
|
146
|
+
g = { x0: t.clientX, y0: t.clientY, last: t.clientY, t: performance.now(), v: 0, moved: false, selecting: false };
|
|
147
|
+
g.timer = setTimeout(onLong, LONG_PRESS_MS);
|
|
148
|
+
acc = 0;
|
|
149
|
+
},
|
|
150
|
+
opts,
|
|
151
|
+
);
|
|
152
|
+
el.addEventListener(
|
|
153
|
+
"touchmove",
|
|
154
|
+
(e) => {
|
|
155
|
+
if (!g || e.touches.length !== 1) return;
|
|
156
|
+
const t = e.touches[0];
|
|
157
|
+
if (!g.moved && Math.hypot(t.clientX - g.x0, t.clientY - g.y0) < SLOP) return;
|
|
158
|
+
g.moved = true;
|
|
159
|
+
clearTimeout(g.timer);
|
|
160
|
+
e.preventDefault();
|
|
161
|
+
e.stopPropagation();
|
|
162
|
+
if (g.selecting) {
|
|
163
|
+
// 长按选中后接着拖:扩大选区
|
|
164
|
+
const cell = cellAt(t.clientX, t.clientY);
|
|
165
|
+
if (cell) {
|
|
166
|
+
selecting.end = cell;
|
|
167
|
+
selectRange(selecting.anchor, cell);
|
|
168
|
+
}
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const now = performance.now();
|
|
172
|
+
const dy = t.clientY - g.last;
|
|
173
|
+
g.v = dy / Math.max(1, now - g.t);
|
|
174
|
+
g.last = t.clientY;
|
|
175
|
+
g.t = now;
|
|
176
|
+
dragScroll(dy);
|
|
177
|
+
},
|
|
178
|
+
opts,
|
|
179
|
+
);
|
|
180
|
+
const end = (e) => {
|
|
181
|
+
if (!g) return;
|
|
182
|
+
clearTimeout(g.timer);
|
|
183
|
+
const gest = g;
|
|
184
|
+
g = null;
|
|
185
|
+
if (gest.selecting) {
|
|
186
|
+
// 别让这次松手变成一次点击(会弹出键盘、清掉选区)
|
|
187
|
+
if (e.cancelable) e.preventDefault();
|
|
188
|
+
e.stopPropagation();
|
|
189
|
+
showBar();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (gest.moved) {
|
|
193
|
+
if (e.cancelable) e.preventDefault();
|
|
194
|
+
e.stopPropagation();
|
|
195
|
+
// 手指停住一会儿才松开的不甩
|
|
196
|
+
if (Math.abs(gest.v) > 0.3 && performance.now() - gest.t < 80) startFling(gest.v);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
// 轻点:有选区就先取消,否则照常交给 xterm
|
|
200
|
+
if (term.hasSelection()) clearSel();
|
|
201
|
+
};
|
|
202
|
+
el.addEventListener("touchend", end, opts);
|
|
203
|
+
el.addEventListener("touchcancel", end, opts);
|
|
204
|
+
term.onSelectionChange(() => {
|
|
205
|
+
if (!term.hasSelection()) bar.hidden = true;
|
|
206
|
+
});
|
|
207
|
+
}
|