@chenacc/agentbridge 0.0.0-stage → 0.1.1

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.
Binary file
@@ -0,0 +1,16 @@
1
+ export function createCommandId(cryptoApi = globalThis.crypto) {
2
+ if (typeof cryptoApi?.randomUUID === "function") return cryptoApi.randomUUID();
3
+
4
+ const bytes = new Uint8Array(16);
5
+ if (typeof cryptoApi?.getRandomValues === "function") {
6
+ cryptoApi.getRandomValues(bytes);
7
+ } else {
8
+ for (let index = 0; index < bytes.length; index += 1) {
9
+ bytes[index] = Math.floor(Math.random() * 256);
10
+ }
11
+ }
12
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
13
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
14
+ const hex = [...bytes].map((value) => value.toString(16).padStart(2, "0")).join("");
15
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
16
+ }
@@ -0,0 +1,125 @@
1
+ // A deliberately small syntax highlighter for fenced code.
2
+ //
3
+ // It is not a parser: one ordered regex per language family colours comments,
4
+ // strings, numbers and keywords, and everything else is escaped verbatim. That is
5
+ // enough to make a code block readable on a phone without shipping a 300 KB
6
+ // language grammar, and it keeps one hard rule: the output is always escaped
7
+ // first, so model output can never inject markup through a code fence.
8
+
9
+ function escapeHtml(value) {
10
+ return String(value ?? "").replace(/[&<>'"]/g, (character) => ({
11
+ "&": "&amp;",
12
+ "<": "&lt;",
13
+ ">": "&gt;",
14
+ "'": "&#39;",
15
+ '"': "&quot;",
16
+ })[character]);
17
+ }
18
+
19
+ const JS_KEYWORDS = "const|let|var|function|return|if|else|for|while|do|switch|case|break|continue|default|class|extends|super|new|this|typeof|instanceof|in|of|delete|void|yield|await|async|import|export|from|as|try|catch|finally|throw|null|undefined|true|false|static|get|set|interface|type|enum|implements|readonly|public|private|protected";
20
+ const PYTHON_KEYWORDS = "def|class|return|if|elif|else|for|while|import|from|as|with|try|except|finally|raise|yield|lambda|pass|break|continue|and|or|not|in|is|None|True|False|async|await|global|nonlocal|assert|del";
21
+ const SHELL_KEYWORDS = "if|then|else|elif|fi|for|while|until|do|done|case|esac|function|return|export|local|echo|cd|set|source|exit|sudo|git|npm|node";
22
+ const SQL_KEYWORDS = "select|from|where|insert|into|values|update|set|delete|create|table|alter|drop|index|join|left|right|inner|outer|group|order|by|having|limit|offset|and|or|not|null|as|on|distinct|union|count|sum|avg";
23
+ const GO_KEYWORDS = "package|import|func|return|if|else|for|range|switch|case|default|type|struct|interface|map|chan|go|defer|select|var|const|nil|true|false|break|continue|fallthrough|goto";
24
+ const RUST_KEYWORDS = "fn|let|mut|pub|use|mod|struct|enum|impl|trait|match|if|else|for|while|loop|return|self|Self|super|crate|as|in|ref|move|where|const|static|unsafe|async|await|dyn|true|false|Some|None|Ok|Err";
25
+ const C_KEYWORDS = "int|char|float|double|void|long|short|unsigned|signed|struct|union|enum|typedef|const|static|extern|return|if|else|for|while|do|switch|case|break|continue|default|sizeof|goto|class|public|private|protected|namespace|using|template|virtual|override|nullptr|true|false|new|delete|throw|try|catch";
26
+
27
+ /**
28
+ * How each language is recognised. `pattern` is a named-group alternation, so the
29
+ * token kind comes straight from whichever group matched.
30
+ */
31
+ const LANGUAGES = {
32
+ javascript: {
33
+ // The backtick form is written as \x60 so the whole pattern can stay inside a
34
+ // single String.raw chunk: mixing raw and plain template chunks silently ate
35
+ // the \d and \b escapes and made this family never match anything.
36
+ pattern: String.raw`(?<comment>//[^\n]*|/\*[\s\S]*?\*/)|(?<string>"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|\x60(?:\\\\.|[^\x60\\\\])*\x60)|(?<number>\b0[xX][\da-fA-F]+\b|\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b)|(?<keyword>\b(?:${JS_KEYWORDS})\b)`,
37
+ },
38
+ python: {
39
+ pattern: String.raw`(?<comment>#[^\n]*)|(?<string>"""[\s\S]*?"""|'''[\s\S]*?'''|"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*')|(?<number>\b0[xX][\da-fA-F]+\b|\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b)|(?<keyword>\b(?:${PYTHON_KEYWORDS})\b)`,
40
+ },
41
+ shell: {
42
+ pattern: String.raw`(?<comment>#[^\n]*)|(?<string>"(?:\\.|[^"\\])*"|'[^']*')|(?<number>\b\d+(?:\.\d+)?\b)|(?<keyword>\b(?:${SHELL_KEYWORDS})\b)`,
43
+ },
44
+ json: {
45
+ pattern: String.raw`(?<comment>//[^\n]*)|(?<string>"(?:\\.|[^"\\\n])*")|(?<number>-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b)|(?<keyword>\b(?:true|false|null)\b)`,
46
+ },
47
+ css: {
48
+ pattern: String.raw`(?<comment>/\*[\s\S]*?\*/)|(?<string>"[^"]*"|'[^']*')|(?<number>#[\da-fA-F]{3,8}\b|\b\d+(?:\.\d+)?(?:px|em|rem|%|vh|vw|s|ms|deg|fr)?)|(?<keyword>@[a-zA-Z-]+|:root|!important)`,
49
+ },
50
+ html: {
51
+ pattern: String.raw`(?<comment><!--[\s\S]*?-->)|(?<string>"[^"]*"|'[^']*')|(?<keyword></?[a-zA-Z][\w:-]*|/?>)`,
52
+ },
53
+ sql: {
54
+ pattern: String.raw`(?<comment>--[^\n]*|/\*[\s\S]*?\*/)|(?<string>'(?:''|[^'])*')|(?<number>\b\d+(?:\.\d+)?\b)|(?<keyword>\b(?:${SQL_KEYWORDS})\b)`,
55
+ },
56
+ yaml: {
57
+ pattern: String.raw`(?<comment>#[^\n]*)|(?<string>"[^"\n]*"|'[^'\n]*')|(?<number>\b-?\d+(?:\.\d+)?\b)|(?<keyword>\b[\w.-]+:(?=\s|$)|^\s*-(?=\s))`,
58
+ },
59
+ go: {
60
+ pattern: String.raw`(?<comment>//[^\n]*|/\*[\s\S]*?\*/)|(?<string>"(?:\\.|[^"\\\n])*"|\x60[^\x60]*\x60)|(?<number>\b0[xX][\da-fA-F]+\b|\b\d+(?:\.\d+)?\b)|(?<keyword>\b(?:${GO_KEYWORDS})\b)`,
61
+ },
62
+ rust: {
63
+ pattern: String.raw`(?<comment>//[^\n]*|/\*[\s\S]*?\*/)|(?<string>"(?:\\.|[^"\\\n])*")|(?<number>\b\d+(?:\.\d+)?(?:[a-z]\w*)?\b)|(?<keyword>\b(?:${RUST_KEYWORDS})\b)`,
64
+ },
65
+ c: {
66
+ pattern: String.raw`(?<comment>//[^\n]*|/\*[\s\S]*?\*/)|(?<string>"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\n])')|(?<number>\b0[xX][\da-fA-F]+\b|\b\d+(?:\.\d+)?[fFuUlL]?\b)|(?<keyword>\b(?:${C_KEYWORDS})\b)`,
67
+ },
68
+ };
69
+
70
+ const ALIASES = {
71
+ js: "javascript", jsx: "javascript", ts: "javascript", tsx: "javascript", mjs: "javascript", cjs: "javascript", node: "javascript",
72
+ py: "python", python3: "python",
73
+ sh: "shell", shell: "shell", bash: "shell", zsh: "shell", console: "shell", ps1: "shell", powershell: "shell", cmd: "shell",
74
+ json5: "json", jsonc: "json",
75
+ scss: "css", less: "css",
76
+ xml: "html", svg: "html", vue: "html", markup: "html",
77
+ postgres: "sql", postgresql: "sql", mysql: "sql", sqlite: "sql", tsql: "sql",
78
+ yml: "yaml",
79
+ golang: "go",
80
+ rs: "rust",
81
+ cpp: "c", "c++": "c", cc: "c", h: "c", hpp: "c", cs: "c", java: "c", kotlin: "c", swift: "c", php: "c", objectivec: "c",
82
+ };
83
+
84
+ export function normalizeLanguage(language) {
85
+ const key = String(language ?? "").trim().toLowerCase();
86
+ if (!key) return null;
87
+ if (LANGUAGES[key]) return key;
88
+ return ALIASES[key] ?? null;
89
+ }
90
+
91
+ /** Families this highlighter can colour; used by the tests to cover all of them. */
92
+ export function supportedLanguages() {
93
+ return Object.keys(LANGUAGES);
94
+ }
95
+
96
+ const COMPILED = new Map();
97
+
98
+ function patternFor(language) {
99
+ const family = normalizeLanguage(language);
100
+ if (!family) return null;
101
+ if (!COMPILED.has(family)) COMPILED.set(family, new RegExp(LANGUAGES[family].pattern, "g"));
102
+ return COMPILED.get(family);
103
+ }
104
+
105
+ /**
106
+ * Highlight `code` for `language`, or escape it unchanged when the language is not
107
+ * one we colour. Either way the output is HTML-safe.
108
+ */
109
+ export function highlightCode(code, language) {
110
+ const source = String(code ?? "");
111
+ const pattern = patternFor(language);
112
+ if (!pattern) return escapeHtml(source);
113
+ pattern.lastIndex = 0;
114
+ let html = "";
115
+ let cursor = 0;
116
+ for (const match of source.matchAll(pattern)) {
117
+ const kind = Object.keys(match.groups ?? {}).find((group) => match.groups[group] !== undefined);
118
+ if (!kind) continue;
119
+ html += escapeHtml(source.slice(cursor, match.index));
120
+ html += `<span class="tok-${kind}">${escapeHtml(match[0])}</span>`;
121
+ cursor = match.index + match[0].length;
122
+ }
123
+ html += escapeHtml(source.slice(cursor));
124
+ return html;
125
+ }
@@ -0,0 +1,339 @@
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, viewport-fit=cover, interactive-widget=resizes-content">
6
+ <meta name="theme-color" content="#cfcfcf">
7
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='.15' stop-color='%23ffffff'/%3E%3Cstop offset='.9' stop-color='%23808080'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='64' height='64' rx='18' fill='url(%23g)'/%3E%3Ctext x='32' y='42' font-family='system-ui,sans-serif' font-size='26' font-weight='800' font-style='italic' text-anchor='middle' fill='%232a2a3a'%3EAB%3C/text%3E%3C/svg%3E">
8
+ <title>AgentBridge</title>
9
+ <link rel="stylesheet" href="/styles.css">
10
+ </head>
11
+ <body>
12
+ <svg class="icon-sprite" aria-hidden="true">
13
+ <symbol id="icon-refresh" viewBox="0 0 24 24"><path d="M20 6v5h-5M4 18v-5h5M18.6 9A7 7 0 0 0 6.4 6.4L4 9m16 6-2.4 2.6A7 7 0 0 1 5.4 15"/></symbol>
14
+ <symbol id="icon-history" viewBox="0 0 24 24"><path d="M3.5 12a8.5 8.5 0 1 0 2.6-6.1L3 9"/><path d="M3 4.5V9h4.5"/><path d="M12 8v4.5l3 1.8"/></symbol>
15
+ <symbol id="icon-send" viewBox="0 0 24 24"><path d="m4 4 17 8-17 8 3-8-3-8Zm3 8h14"/></symbol>
16
+ <symbol id="icon-stop" viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="1"/></symbol>
17
+ <symbol id="icon-control" viewBox="0 0 24 24"><path d="M8 11V7a2 2 0 1 1 4 0v4-2a2 2 0 1 1 4 0v3-1a2 2 0 1 1 4 0v4c0 4-3 7-7 7h-1c-2 0-3-1-4-2l-4-5a2 2 0 0 1 3-3l1 1"/></symbol>
18
+ <symbol id="icon-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
19
+ <symbol id="icon-menu" viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h10"/></symbol>
20
+ <symbol id="icon-chevron" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></symbol>
21
+ <symbol id="icon-close" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></symbol>
22
+ <symbol id="icon-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.6-3.6"/></symbol>
23
+ <symbol id="icon-link" viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1"/></symbol>
24
+ <symbol id="icon-up" viewBox="0 0 24 24"><path d="M5 14.5 12 7l7 7.5"/><path d="M5 19.5 12 12l7 7.5"/></symbol>
25
+ <symbol id="icon-down" viewBox="0 0 24 24"><path d="M5 9.5 12 17l7-7.5"/><path d="M5 4.5 12 12l7-7.5"/></symbol>
26
+ </svg>
27
+
28
+ <div id="pairing-screen" class="pairing-screen" hidden>
29
+ <div class="pairing-mark" aria-hidden="true">AB</div>
30
+ <h1>正在连接电脑</h1>
31
+ <p id="pairing-message">验证一次性配对码,请稍候。</p>
32
+ <div class="loader" aria-hidden="true"></div>
33
+ </div>
34
+
35
+ <div id="access-screen" class="pairing-screen" hidden>
36
+ <div class="pairing-mark" aria-hidden="true">AB</div>
37
+ <h1>需要访问密钥</h1>
38
+ <p id="access-message">此实例已开启公网保护。请重新扫描电脑上的二维码,或输入启动器窗口中显示的访问密钥。</p>
39
+ <form id="access-form" class="access-form">
40
+ <label class="sr-only" for="access-key-input">访问密钥</label>
41
+ <input id="access-key-input" type="password" autocomplete="off" placeholder="访问密钥" required>
42
+ <button class="primary-button" type="submit">解锁</button>
43
+ </form>
44
+ </div>
45
+
46
+ <div id="app" class="app-shell" hidden>
47
+ <header class="topbar">
48
+ <a class="brand" href="/" aria-label="AgentBridge 首页">
49
+ <span class="brand-mark">AB</span>
50
+ <span class="brand-text">AgentBridge</span>
51
+ </a>
52
+ <div class="connection" id="connection-status" role="status">
53
+ <span class="connection-dot"></span>
54
+ <span>正在连接</span>
55
+ </div>
56
+ <div class="machine-name" id="machine-name">本机</div>
57
+ </header>
58
+
59
+ <!-- 抽屉开关在顶栏之外:顶栏的 backdrop-filter 会成为 fixed 子元素的包含块,
60
+ 放进顶栏里它就无法真正固定在视口左上角。 -->
61
+ <button id="drawer-toggle" class="drawer-toggle" type="button" aria-label="打开会话抽屉" aria-expanded="false" aria-controls="agent-drawer">
62
+ <svg><use href="#icon-menu"/></svg>
63
+ </button>
64
+
65
+ <section id="desktop-pairing" class="pairing-strip" hidden>
66
+ <div class="pairing-copy">
67
+ <h2>用微信扫一扫接管</h2>
68
+ <p>二维码五分钟内有效且只能使用一次。Agent 与凭据始终留在这台电脑。</p>
69
+ <code id="pairing-url"></code>
70
+ <button id="rotate-pairing" class="text-button" type="button">
71
+ <svg><use href="#icon-refresh"/></svg>刷新二维码
72
+ </button>
73
+ </div>
74
+ <img id="pairing-qr" src="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=" alt="AgentBridge 一次性配对二维码" width="176" height="176">
75
+ <div class="return-qr-copy">
76
+ <h3>手机常用入口</h3>
77
+ <p>首次配对后扫描此码,可回到这台手机最近使用的会话。</p>
78
+ <code id="desktop-return-url"></code>
79
+ </div>
80
+ <img id="return-qr" src="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=" alt="AgentBridge 固定返回二维码" width="144" height="144">
81
+ <div class="device-manager">
82
+ <h3>已配对设备</h3>
83
+ <p>手机丢失或不再使用时,在这里撤销它的访问权限。</p>
84
+ <ul id="device-list" class="device-list"></ul>
85
+ <div class="notify-row" id="integration-row" hidden>
86
+ <div>
87
+ <h3>桌面端集成</h3>
88
+ <p id="integration-note" role="status"></p>
89
+ </div>
90
+ </div>
91
+ <div class="notify-row">
92
+ <div>
93
+ <h3>手机通知</h3>
94
+ <p id="notify-note" role="status">未配置。设置 AGENTBRIDGE_NOTIFY_WEBHOOK 后,Agent 需要批准或跑完时会推送到你的聊天工具。</p>
95
+ </div>
96
+ <button id="notify-test" class="secondary-button" type="button" hidden>发送测试</button>
97
+ </div>
98
+ <div class="bridge-shutdown">
99
+ <div>
100
+ <h3>关闭本机 Bridge</h3>
101
+ <p id="shutdown-note" role="status">仅关闭 AgentBridge Node 服务,不会关闭 Cloudflare 或 ZeroTier。运行中的任务会阻止关闭。</p>
102
+ </div>
103
+ <button id="safe-shutdown" class="danger-button" type="button">
104
+ <svg><use href="#icon-stop"/></svg><span>安全停止</span>
105
+ </button>
106
+ </div>
107
+ </div>
108
+ </section>
109
+
110
+ <section id="return-entry" class="return-entry" hidden>
111
+ <div>
112
+ <strong>下次从这里继续</strong>
113
+ <div id="return-details">
114
+ <p>在微信右上角“…”中收藏本页;重新打开会自动回到这台手机最近使用的对话。</p>
115
+ <code id="return-url"></code>
116
+ </div>
117
+ </div>
118
+ <div class="return-actions">
119
+ <button id="toggle-return-details" class="text-button" type="button" aria-expanded="false">详情</button>
120
+ <button id="copy-return-url" class="secondary-button" type="button">复制入口</button>
121
+ </div>
122
+ </section>
123
+
124
+ <main class="workspace">
125
+ <section class="conversation" aria-label="当前会话">
126
+ <div id="empty-state" class="empty-state">
127
+ <div class="empty-rule"></div>
128
+ <h2 id="empty-title">选择一个会话</h2>
129
+ <p id="empty-description">点左上角 ☰ 按 Agent 与项目挑选会话,或新建一个。</p>
130
+ </div>
131
+
132
+ <div id="session-view" class="session-view" hidden>
133
+ <header class="session-header">
134
+ <div>
135
+ <div class="session-meta"><span id="session-adapter"></span><span id="session-project"></span></div>
136
+ <h2 id="session-title-heading"></h2>
137
+ <p id="control-status"></p>
138
+ <p id="usage-line" class="usage-line" hidden></p>
139
+ <p id="session-error" class="session-error" role="alert" hidden></p>
140
+ </div>
141
+ <div class="session-actions">
142
+ <div id="model-control" class="model-control" hidden>
143
+ <div class="picker" id="model-picker">
144
+ <span class="field-label" id="model-label">会话模型</span>
145
+ <button id="model-trigger" class="picker-trigger" type="button" aria-haspopup="listbox" aria-expanded="false" aria-controls="model-panel">
146
+ <span class="picker-value" id="model-trigger-value">—</span>
147
+ <svg class="picker-chevron"><use href="#icon-chevron"/></svg>
148
+ </button>
149
+ <select id="model-select" class="picker-native" aria-label="会话模型" tabindex="-1"></select>
150
+ <div id="model-panel" class="glass-popover picker-panel" hidden>
151
+ <input id="model-search" class="picker-search" type="search" placeholder="搜索模型" aria-label="搜索模型">
152
+ <div id="model-options" class="picker-options" role="listbox" aria-label="模型列表"></div>
153
+ </div>
154
+ </div>
155
+ <div class="picker" id="reasoning-control" hidden>
156
+ <span class="field-label">推理强度</span>
157
+ <button id="reasoning-trigger" class="picker-trigger" type="button" aria-haspopup="listbox" aria-expanded="false" aria-controls="reasoning-panel">
158
+ <span class="picker-value" id="reasoning-trigger-value">—</span>
159
+ <svg class="picker-chevron"><use href="#icon-chevron"/></svg>
160
+ </button>
161
+ <select id="reasoning-select" class="picker-native" aria-label="推理强度" tabindex="-1"></select>
162
+ <div id="reasoning-panel" class="glass-popover picker-panel" hidden>
163
+ <div id="reasoning-options" class="picker-options" role="listbox" aria-label="推理强度列表"></div>
164
+ </div>
165
+ </div>
166
+ <button id="apply-model" class="secondary-button" type="button">切换模型</button>
167
+ <small id="model-note"></small>
168
+ </div>
169
+ <div class="auto-approve">
170
+ <button id="auto-approve-toggle" class="secondary-button" type="button" aria-expanded="false" aria-controls="auto-approve-panel">
171
+ <svg><use href="#icon-control"/></svg>自动放行
172
+ </button>
173
+ <div id="auto-approve-panel" class="glass-popover auto-approve-panel" role="dialog" aria-label="自动放行设置" hidden>
174
+ <p class="auto-approve-lede">在限定时间内自动批准这个会话的工具调用,省掉一次次确认。</p>
175
+ <div class="field">
176
+ <span class="field-label">放行时长</span>
177
+ <div class="segmented" role="radiogroup" aria-label="放行时长">
178
+ <label class="segment"><input type="radio" name="auto-approve-minutes" value="5"><span>5 分钟</span></label>
179
+ <label class="segment"><input type="radio" name="auto-approve-minutes" value="10" checked><span>10 分钟</span></label>
180
+ <label class="segment"><input type="radio" name="auto-approve-minutes" value="30"><span>30 分钟</span></label>
181
+ </div>
182
+ </div>
183
+ <div class="field">
184
+ <span class="field-label">放行范围</span>
185
+ <label class="switch">
186
+ <input id="auto-approve-all" type="checkbox">
187
+ <span class="switch-track"><span class="switch-knob"></span></span>
188
+ <span class="switch-text">包含全部工具(不勾只放行 shell)</span>
189
+ </label>
190
+ </div>
191
+ <p class="auto-approve-note">到期自动失效,最长 30 分钟。批准记录会留在对话里。</p>
192
+ <button id="auto-approve-arm" class="primary-button" type="button">开始放行</button>
193
+ </div>
194
+ <button id="auto-approve-off" class="auto-approve-pill" type="button" hidden></button>
195
+ </div>
196
+ <button id="take-control" class="secondary-button" type="button">
197
+ <svg><use href="#icon-control"/></svg>接管输入
198
+ </button>
199
+ <button id="stop-session" class="danger-button" type="button">
200
+ <svg><use href="#icon-stop"/></svg><span>停止</span>
201
+ </button>
202
+ </div>
203
+ </header>
204
+
205
+ <div id="history-more" class="history-more" hidden>
206
+ <button id="load-earlier" class="secondary-button" type="button">
207
+ <svg><use href="#icon-history"/></svg><span>加载更早</span>
208
+ </button>
209
+ </div>
210
+
211
+ <div id="event-log" class="event-log" role="log" aria-live="off"></div>
212
+ <div id="event-announcer" class="sr-only" aria-live="polite" aria-atomic="true"></div>
213
+
214
+ <!-- 悬浮在视口右侧的固定快捷导航:置顶 ⇧ / 置底 ⇩。
215
+ 只在会话视图(#session-view)打开时显示,滚动到顶/底时对应按钮自动淡出。 -->
216
+ <div id="quick-nav" class="quick-nav" role="group" aria-label="对话快捷导航" hidden>
217
+ <button id="quick-nav-top" class="quick-nav-btn" type="button" aria-label="到顶部">
218
+ <svg aria-hidden="true"><use href="#icon-up"/></svg>
219
+ </button>
220
+ <button id="quick-nav-bottom" class="quick-nav-btn" type="button" aria-label="到底部">
221
+ <svg aria-hidden="true"><use href="#icon-down"/></svg>
222
+ </button>
223
+ </div>
224
+
225
+ <form id="composer" class="composer">
226
+ <label class="sr-only" for="message-input">给 Agent 的消息</label>
227
+ <div id="composer-attachments" class="composer-attachments" hidden></div>
228
+ <div id="composer-queue" class="composer-queue" hidden></div>
229
+ <div class="composer-row">
230
+ <button id="attach-button" class="attach-button" type="button" aria-label="添加图片或文件">
231
+ <svg><use href="#icon-link"/></svg>
232
+ </button>
233
+ <button id="voice-button" class="attach-button voice-button" type="button" aria-label="语音输入" aria-pressed="false" hidden>
234
+ <svg><use href="#icon-control"/></svg>
235
+ </button>
236
+ <input id="attach-input" class="attach-input" type="file" accept="image/png,image/jpeg,image/webp,image/gif,application/pdf,text/plain,text/markdown,application/json,application/zip" multiple>
237
+ <textarea id="message-input" rows="1" maxlength="20000" placeholder="继续给 Agent 指令…"></textarea>
238
+ <button class="send-button" type="submit" aria-label="发送消息">
239
+ <svg><use href="#icon-send"/></svg>
240
+ </button>
241
+ </div>
242
+ </form>
243
+ </div>
244
+ </section>
245
+ </main>
246
+ </div>
247
+
248
+ <!-- 抽屉:Agent → 项目 → 会话。手机上是覆盖层,桌面隐藏(沿用左侧常驻栏)。 -->
249
+ <div id="drawer-mask" class="drawer-mask" hidden></div>
250
+ <aside id="agent-drawer" class="agent-drawer" aria-labelledby="drawer-title" inert>
251
+ <header class="drawer-head">
252
+ <button id="drawer-close" class="icon-button drawer-close" type="button" aria-label="关闭抽屉">
253
+ <svg><use href="#icon-close"/></svg>
254
+ </button>
255
+ <div class="drawer-heading">
256
+ <h2 id="drawer-title">会话</h2>
257
+ <p id="drawer-subtitle">按 Agent 与项目浏览</p>
258
+ </div>
259
+ <div class="connection drawer-connection" id="drawer-connection">
260
+ <span class="connection-dot"></span>
261
+ <span>本地</span>
262
+ </div>
263
+ <button id="drawer-create" class="icon-button drawer-create" type="button" aria-label="新建会话" aria-haspopup="dialog" aria-controls="create-sheet">
264
+ <svg><use href="#icon-plus"/></svg>
265
+ </button>
266
+ </header>
267
+
268
+ <div class="drawer-search">
269
+ <label class="sr-only" for="drawer-query">搜索会话、项目或 Agent</label>
270
+ <svg class="drawer-search-icon" aria-hidden="true"><use href="#icon-search"/></svg>
271
+ <input id="drawer-query" type="search" placeholder="搜索会话 / 项目 / Agent" autocomplete="off" enterkeyhint="search">
272
+ </div>
273
+
274
+ <nav id="drawer-tree" class="drawer-tree" aria-label="Agent 与项目会话"></nav>
275
+ <section id="drawer-deep-search" class="drawer-deep" aria-label="在所有会话中搜索" hidden></section>
276
+
277
+ <section class="drawer-card" aria-label="手机通知" hidden>
278
+ <div class="drawer-card-body">
279
+ <strong id="drawer-notify-title" class="drawer-card-title">手机通知</strong>
280
+ <span id="drawer-notify-note" class="drawer-card-note">未配置</span>
281
+ </div>
282
+ <button id="drawer-notify-toggle" class="secondary-button notify-switch" type="button" aria-pressed="false">开启</button>
283
+ </section>
284
+
285
+ <footer class="drawer-foot">
286
+ <button id="drawer-refresh" class="secondary-button" type="button">
287
+ <svg><use href="#icon-refresh"/></svg><span>刷新状态</span>
288
+ </button>
289
+ <button id="drawer-discover" class="secondary-button" type="button" hidden>
290
+ <svg><use href="#icon-history"/></svg><span>同步历史</span>
291
+ </button>
292
+ </footer>
293
+ </aside>
294
+
295
+ <!-- 新建会话:抽屉头部 + 打开。只列出真正能新建的 Agent,不能新建的也保留并说明原因。 -->
296
+ <div id="create-mask" class="create-mask" hidden></div>
297
+ <section id="create-sheet" class="create-sheet" role="dialog" aria-modal="true" aria-labelledby="create-title" inert>
298
+ <header class="create-head">
299
+ <div>
300
+ <h2 id="create-title">新建会话</h2>
301
+ <p id="create-note">手机只能使用电脑端已登记的项目。</p>
302
+ </div>
303
+ <button id="create-close" class="icon-button" type="button" aria-label="关闭新建会话">
304
+ <svg><use href="#icon-close"/></svg>
305
+ </button>
306
+ </header>
307
+ <form id="create-form" class="create-form">
308
+ <label>Agent<select id="create-adapter" name="adapterId" required></select></label>
309
+ <label>项目<select id="create-project" name="projectAlias" required></select></label>
310
+ <label>标题<input id="create-title-input" name="title" maxlength="100" placeholder="例如:修复登录问题"></label>
311
+ <p id="create-adapter-note" class="form-note"></p>
312
+ <button class="primary-button" type="submit">创建会话</button>
313
+ </form>
314
+ </section>
315
+
316
+ <!-- 同步历史:显式选择哪个 Agent 去读哪个项目,而不是替用户猜一个。 -->
317
+ <div id="sync-mask" class="create-mask" hidden></div>
318
+ <section id="sync-sheet" class="create-sheet" role="dialog" aria-modal="true" aria-labelledby="sync-title" inert>
319
+ <header class="create-head">
320
+ <div>
321
+ <h2 id="sync-title">同步历史</h2>
322
+ <p id="sync-note">只读取所选 Agent 的公开会话列表,不会启动任何对话。</p>
323
+ </div>
324
+ <button id="sync-close" class="icon-button" type="button" aria-label="关闭同步历史">
325
+ <svg><use href="#icon-close"/></svg>
326
+ </button>
327
+ </header>
328
+ <form id="sync-form" class="create-form">
329
+ <label>Agent<select id="sync-adapter" name="adapterId" required></select></label>
330
+ <label>项目<select id="sync-project" name="workspaceId" required></select></label>
331
+ <p id="sync-adapter-note" class="form-note"></p>
332
+ <button class="primary-button" type="submit">开始同步</button>
333
+ </form>
334
+ </section>
335
+
336
+ <div id="toast" class="toast" role="alert" hidden></div>
337
+ <script type="module" src="/app.js"></script>
338
+ </body>
339
+ </html>