@bolloon/bolloon-agent 0.4.9 → 0.4.10

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.
@@ -34,6 +34,84 @@ export const SIDE_EFFECT_TOOLS = new Set([
34
34
  'write_file', 'edit_file', 'shell_exec', 'git_commit', 'git_push', 'git_branch',
35
35
  'create_task', 'update_task', 'terminal', 'process',
36
36
  ]);
37
+ /** 2026-08-12 (TaskA): 探测可用的命令 (跨平台: Windows 无 python3, 有 python). 返回 [cmd, ...] 或 null. */
38
+ async function detectRunner(candidates) {
39
+ const { spawn } = await import('child_process');
40
+ for (const c of candidates) {
41
+ try {
42
+ const ok = await new Promise((resolve) => {
43
+ const p = spawn(c, ['--version'], { stdio: 'ignore', windowsHide: true });
44
+ p.on('close', (code) => resolve(code === 0));
45
+ p.on('error', () => resolve(false));
46
+ });
47
+ if (ok)
48
+ return [c];
49
+ }
50
+ catch { /* 下一个 */ }
51
+ }
52
+ return null;
53
+ }
54
+ export async function runCodeSnippet(opts) {
55
+ const code = String(opts.code ?? '').trim();
56
+ if (!code)
57
+ return { success: false, error: 'code 必填' };
58
+ const lang = String(opts.language || '').trim().toLowerCase();
59
+ // 语言 → (扩展名, 解释器命令)
60
+ const ext = lang === 'python' || lang === 'py' ? 'py'
61
+ : lang === 'javascript' || lang === 'js' || lang === 'node' ? 'js'
62
+ : lang === 'typescript' || lang === 'ts' ? 'ts'
63
+ : lang === 'shell' || lang === 'bash' || lang === 'sh' ? 'sh'
64
+ : lang === 'html' ? 'html'
65
+ : 'txt';
66
+ const runner = lang === 'python' || lang === 'py' ? await detectRunner(['python3', 'python'])
67
+ : lang === 'javascript' || lang === 'js' || lang === 'node' ? await detectRunner(['node'])
68
+ : lang === 'typescript' || lang === 'ts' ? ['npx', 'tsx']
69
+ : lang === 'shell' || lang === 'bash' || lang === 'sh' ? ['bash']
70
+ : null;
71
+ if (!runner) {
72
+ if (['python', 'py', 'js', 'javascript', 'node', 'ts', 'typescript', 'shell', 'bash', 'sh'].includes(lang)) {
73
+ return { success: false, error: `解释器未找到 (${lang}), 请装对应运行时或改用 command` };
74
+ }
75
+ return { success: false, error: `不支持的语言 '${lang}', 支持: python/js/ts/shell/html` };
76
+ }
77
+ const { tmpdir } = await import('os');
78
+ const tmpDir = opts.tmpDir ?? tmpdir();
79
+ const file = `${tmpDir}/bolloon-code-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
80
+ const fsMod = await import('fs/promises');
81
+ await fsMod.writeFile(file, code, 'utf-8');
82
+ try {
83
+ const { spawn } = await import('child_process');
84
+ const timeoutMs = opts.timeoutMs ?? 30000;
85
+ const result = await new Promise((resolve) => {
86
+ const proc = spawn(runner[0], [...runner.slice(1), file], {
87
+ cwd: opts.cwd ?? process.cwd(),
88
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
89
+ windowsHide: true,
90
+ });
91
+ let stdout = '';
92
+ let stderr = '';
93
+ const timer = setTimeout(() => {
94
+ try {
95
+ proc.kill('SIGKILL');
96
+ }
97
+ catch { /* 忽略 */ }
98
+ resolve({ success: false, error: `代码执行超时 (>${timeoutMs}ms)`, output: (stdout + (stderr ? `\n[stderr]\n${stderr}` : '')).trim().slice(0, 8000) });
99
+ }, timeoutMs);
100
+ proc.stdout.on('data', (d) => { stdout += d.toString(); });
101
+ proc.stderr.on('data', (d) => { stderr += d.toString(); });
102
+ proc.on('error', (e) => { clearTimeout(timer); resolve({ success: false, error: `启动失败: ${e.message}` }); });
103
+ proc.on('close', (code) => {
104
+ clearTimeout(timer);
105
+ const output = (stdout + (stderr ? `\n[stderr]\n${stderr}` : '')).trim().slice(0, 8000) || '(无输出)';
106
+ resolve({ success: code === 0, output, exitCode: code, language: lang, script: file });
107
+ });
108
+ });
109
+ return result;
110
+ }
111
+ finally {
112
+ await fsMod.rm(file, { force: true }).catch(() => { });
113
+ }
114
+ }
37
115
  export async function runTerminalCommand(raw, opts = {}) {
38
116
  const { checkTerminalCommand } = await import('./shell-guard.js');
39
117
  const timeoutMs = opts.timeoutMs ?? 30000;
@@ -716,14 +794,20 @@ export function registerBuiltinTools(ctx) {
716
794
  // 与 shell_exec 的区别: 直接接受完整 shell 命令字符串, 更适合模型自主写命令.
717
795
  ctx.tools.set('terminal', {
718
796
  name: 'terminal',
719
- description: '执行完整 shell 命令 (支持管道/重定向/写文件/跑脚本). 护栏只挡高危破坏操作 (sudo/格式化/rm -rf 根目录/写 ~/.bolloon 数据), 其余灵活放行. 适合: 写 HTML 文件、跑 python/node 脚本、查系统状态、装依赖. 多条命令用 commands 数组并行执行. 长命令 (服务器/构建/后台任务) 设 background=true 后台执行不阻塞对话.',
720
- parameters: { command: '完整 shell 命令 (必填, 如: echo "<html>" > /tmp/site/index.html && ls /tmp/site)', commands: '可选: 多条命令数组 (并行执行), 每条独立字符串', timeoutMs: '超时毫秒, 默认 30000', background: '可选: true 后台执行, 立即返回 session_id (用 process 工具 poll/wait/kill)' },
797
+ description: '执行完整 shell 命令 (支持管道/重定向/写文件/跑脚本). 护栏只挡高危破坏操作 (sudo/格式化/rm -rf 根目录/写 ~/.bolloon 数据), 其余灵活放行. 适合: 写 HTML 文件、跑 python/node 脚本、查系统状态、装依赖. 多条命令用 commands 数组并行执行. 长命令 (服务器/构建/后台任务) 设 background=true 后台执行不阻塞对话. 也可直接传 code+language 自动写脚本执行 (便捷代码运行: python/js/ts/shell/html).',
798
+ parameters: { command: '完整 shell 命令 (可选, 如: echo "<html>" > /tmp/site/index.html && ls /tmp/site)', commands: '可选: 多条命令数组 (并行执行), 每条独立字符串', code: '可选: 一段代码, 传 code+language 时自动写脚本执行 (便捷代码运行)', language: '可选: code 的语言 (python/js/ts/shell/html), 默认自动', timeoutMs: '超时毫秒, 默认 30000', background: '可选: true 后台执行, 立即返回 session_id (用 process 工具 poll/wait/kill)' },
721
799
  execute: async (args) => {
800
+ const timeoutMs = Number(args.timeoutMs) || 30000;
801
+ // 便捷代码运行: 传 code → 自动写脚本执行
802
+ const code = String(args.code ?? '').trim();
803
+ if (code) {
804
+ const lang = String(args.language || '').trim().toLowerCase();
805
+ return await runCodeSnippet({ code, language: lang, timeoutMs, cwd: ctx.cwd });
806
+ }
722
807
  const raw = String(args.command || '').trim();
723
808
  const commands = Array.isArray(args.commands) ? args.commands.map((c) => String(c || '').trim()).filter(Boolean) : [];
724
809
  if (!raw && commands.length === 0)
725
- return { success: false, error: 'commandcommands 必填' };
726
- const timeoutMs = Number(args.timeoutMs) || 30000;
810
+ return { success: false, error: 'command/code/commands 至少一个必填' };
727
811
  return await runTerminalCommand(raw, { timeoutMs, cwd: ctx.cwd, commands: commands.length > 0 ? commands : undefined, background: String(args.background).toLowerCase() === 'true' });
728
812
  }
729
813
  });
@@ -2131,6 +2215,34 @@ export function registerBuiltinTools(ctx) {
2131
2215
  }
2132
2216
  }
2133
2217
  });
2218
+ // 2026-08-12: MCP 驱动前端 UI 工具 — agent 理解用户意图后调用, 通过 SSE 广播驱动前端组件.
2219
+ // 复用 ui-tools (dispatchUiAction → broadcast {type:'ui'}), 前端订阅 /events 执行.
2220
+ const registerUiAgentTools = async () => {
2221
+ const ui = await import('../pi-ecosystem-mcp/ui-tools.js');
2222
+ // 注册到 MCP 系统 (供 mcp_list_tools 可见) + 注册为 agent 工具
2223
+ ui.registerUiControlTools();
2224
+ const uiTools = [
2225
+ { name: 'ui_switch_tab', description: '驱动前端切换底部 tab (微信/通讯录/发现/我). 用户想"去通讯录/去设置/去我的"时调用.', params: { tab: 'wechat|contacts|discover|me (必填)' }, map: (a) => ({ action: 'switchTab', data: { tab: String(a.tab || '') } }) },
2226
+ { name: 'ui_open_chat', description: '驱动前端打开某个智能体聊天页. 用户想"打开和 X 的聊天"时调用.', params: { channelId: '目标 channel id (必填)' }, map: (a) => ({ action: 'openChat', data: { channelId: String(a.channelId || '') } }) },
2227
+ { name: 'ui_open_settings', description: '驱动前端打开设置页.', params: {}, map: () => ({ action: 'openSettings', data: {} }) },
2228
+ { name: 'ui_open_wallet', description: '驱动前端打开钱包.', params: {}, map: () => ({ action: 'openWallet', data: {} }) },
2229
+ { name: 'ui_open_add_friend', description: '驱动前端打开添加好友.', params: {}, map: () => ({ action: 'openAddFriend', data: {} }) },
2230
+ { name: 'ui_show_toast', description: '驱动前端顶部显示提示 (toast).', params: { message: '提示内容 (必填)' }, map: (a) => ({ action: 'showToast', data: { message: String(a.message || '') } }) },
2231
+ { name: 'ui_go_back', description: '驱动前端返回上一页.', params: {}, map: () => ({ action: 'goBack', data: {} }) },
2232
+ ];
2233
+ for (const t of uiTools) {
2234
+ ctx.tools.set(t.name, {
2235
+ name: t.name,
2236
+ description: t.description,
2237
+ parameters: t.params,
2238
+ execute: async (args) => {
2239
+ const r = ui.dispatchUiAction(t.map(args));
2240
+ return r.success ? { success: true, output: r.output } : { success: false, error: r.output };
2241
+ },
2242
+ });
2243
+ }
2244
+ };
2245
+ registerUiAgentTools().catch(() => { });
2134
2246
  // ============================================================
2135
2247
  // publish_did (2026-08-03) — 把当前 agent 的 DID 发布到 IPFS + IPNS
2136
2248
  // 全自动: 自动安装/启动本地 Kubo → 上传 DID 文档 → 发布 IPNS name
@@ -0,0 +1,115 @@
1
+ /**
2
+ * python-exec.ts — Python 代码执行引擎 (2026-08-12, TaskP)
3
+ *
4
+ * 借鉴 hermes code_execution_tool: 让 agent 输入 Python 代码运行操作, 而非逐条 shell 命令.
5
+ * 设计:
6
+ * - 把 LLM 给的 Python 代码写到临时文件, 用 python3 子进程执行 (隔离, 不污染主进程)
7
+ * - 带超时 + kill (子进程跑太久强制终止)
8
+ * - stdout/stderr 合并捕获 + 截断 (head+tail, 显式 truncation 标记 — hermes 模式)
9
+ * - 依赖检测: 找不到 python3 返回明确错误
10
+ *
11
+ * 安全: 不 eval (子进程隔离); 代码里若含高危 shell (os.system("rm -rf /") 等) 走 checkTerminalCommand 预检 (最佳努力).
12
+ */
13
+ import { spawn } from 'child_process';
14
+ import * as fs from 'fs/promises';
15
+ import * as os from 'os';
16
+ import * as path from 'path';
17
+ const MAX_OUTPUT = 16000; // 捕获输出上限 (head+tail)
18
+ /** 探测可用的 python 解释器 (python3 / python) */
19
+ async function detectPython() {
20
+ const candidates = process.platform === 'win32' ? ['python', 'py'] : ['python3', 'python'];
21
+ for (const c of candidates) {
22
+ try {
23
+ await new Promise((resolve, reject) => {
24
+ const p = spawn(c, ['--version'], { stdio: 'ignore', windowsHide: true });
25
+ p.on('close', (code) => (code === 0 ? resolve() : reject(new Error('exit ' + code))));
26
+ p.on('error', reject);
27
+ });
28
+ return c;
29
+ }
30
+ catch { /* 下一个 */ }
31
+ }
32
+ return null;
33
+ }
34
+ /**
35
+ * 截断输出: 保留 head + tail, 中间省略, 返回截断标记 (hermes 模式).
36
+ */
37
+ export function truncateOutput(text) {
38
+ if (text.length <= MAX_OUTPUT)
39
+ return { text, truncated: false };
40
+ const head = text.slice(0, 4000);
41
+ const tail = text.slice(-4000);
42
+ return { text: `${head}\n…[输出截断 ${text.length - 8000} 字节]…\n${tail}`, truncated: true };
43
+ }
44
+ /** 最佳努力: 代码里明显的高危 shell 调用预检 (防 os.system("rm -rf /") 等) */
45
+ function checkDangerousCode(code) {
46
+ const dangerous = [
47
+ { re: /os\.system\s*\(\s*["']\s*rm\s+(-[a-z]*f[a-z]*\s+)?-[a-z]*r[a-z]*\s+\//, reason: '禁止递归删除根目录' },
48
+ { re: /shutil\.rmtree\s*\(\s*["']\s*\//, reason: '禁止删除根目录' },
49
+ { re: /os\.system\s*\(\s*["']\s*(sudo|mkfs|dd\s+if=.*of=\/dev)/, reason: '禁止高危系统命令' },
50
+ ];
51
+ for (const { re, reason } of dangerous) {
52
+ if (re.test(code))
53
+ return { allowed: false, reason };
54
+ }
55
+ return { allowed: true };
56
+ }
57
+ /**
58
+ * 执行一段 Python 代码 (隔离子进程 + 超时 + 输出截断).
59
+ */
60
+ export async function executePython(opts) {
61
+ const code = String(opts.code ?? '').trim();
62
+ if (!code)
63
+ return { success: false, error: 'code 必填', output: '' };
64
+ // 高危代码预检 (最佳努力)
65
+ const d = checkDangerousCode(code);
66
+ if (!d.allowed)
67
+ return { success: false, error: `[python-guard] ${d.reason}`, deniedByGuard: true, output: '' };
68
+ // 探测 python
69
+ const python = await detectPython();
70
+ if (!python) {
71
+ return { success: false, error: '未找到 python3/python 解释器, 无法执行 Python 代码', output: '' };
72
+ }
73
+ // 写临时文件
74
+ const tmpFile = path.join(os.tmpdir(), `bolloon-py-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.py`);
75
+ await fs.writeFile(tmpFile, code, 'utf-8');
76
+ const timeoutMs = opts.timeoutMs ?? 30000;
77
+ try {
78
+ const result = await new Promise((resolve) => {
79
+ const proc = spawn(python, [tmpFile], {
80
+ cwd: opts.cwd ?? process.cwd(),
81
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', PYTHONIOENCODING: 'utf-8' },
82
+ windowsHide: true,
83
+ });
84
+ let stdout = '';
85
+ let stderr = '';
86
+ const timer = setTimeout(() => {
87
+ try {
88
+ proc.kill('SIGKILL');
89
+ }
90
+ catch { /* 忽略 */ }
91
+ resolve({ success: false, output: '', error: `Python 执行超时 (>${timeoutMs}ms), 已终止`, exitCode: -1, python });
92
+ }, timeoutMs);
93
+ proc.stdout.on('data', (d) => { stdout += d.toString(); });
94
+ proc.stderr.on('data', (d) => { stderr += d.toString(); });
95
+ proc.on('error', (e) => { clearTimeout(timer); resolve({ success: false, output: '', error: `启动失败: ${e.message}`, python }); });
96
+ proc.on('close', (code) => {
97
+ clearTimeout(timer);
98
+ const raw = (stdout + (stderr ? `\n[stderr]\n${stderr}` : '')).trim();
99
+ const { text, truncated } = truncateOutput(raw || '(无输出)');
100
+ resolve({
101
+ success: code === 0,
102
+ output: text,
103
+ stdoutTruncated: truncated,
104
+ exitCode: code,
105
+ error: code === 0 ? undefined : `exit code ${code}`,
106
+ python,
107
+ });
108
+ });
109
+ });
110
+ return result;
111
+ }
112
+ finally {
113
+ await fs.rm(tmpFile, { force: true }).catch(() => { });
114
+ }
115
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * ui-tools.ts — MCP 驱动的前端 UI 控制工具 (2026-08-12)
3
+ *
4
+ * 目标: bolloon 作为 MCP server 暴露 UI 控制工具, agent 理解用户意图后
5
+ * 通过 MCP 调用这些工具, 驱动前端 (web / 手机端 Capacitor) 的 UI 组件.
6
+ *
7
+ * 机制:
8
+ * - 注册一组 UI 控制工具到 MCP 系统 (registerTool), 供 agent 用 mcp_tool 调用
9
+ * - 工具 execute 时通过注入的 broadcast 回调, 广播 { type: 'ui', action, data } 给前端 (SSE /events)
10
+ * - 前端订阅 /events, 收到 ui 指令执行对应组件 (switchTab/openChat/openSettings 等)
11
+ *
12
+ * 设计: UI 工具不走外部 MCP server (executeTool 硬依赖 server), 而是本地 handler + broadcast.
13
+ */
14
+ import { registerTool, listTools } from './index.js';
15
+ /** broadcast 注入点 (由 server 调用 setUiBroadcast 注入, 广播给前端 SSE) */
16
+ let uiBroadcast = null;
17
+ /** 注入广播函数 (server 启动时调用, 关联到 SSE /events) */
18
+ export function setUiBroadcast(fn) {
19
+ uiBroadcast = fn;
20
+ }
21
+ /** 广播一条 UI 控制指令给所有前端 */
22
+ export function broadcastUiAction(action, data) {
23
+ if (!uiBroadcast)
24
+ return false;
25
+ uiBroadcast({ type: 'ui', action, data: data ?? {} });
26
+ return true;
27
+ }
28
+ /** 把一次 UI 工具调用 (agent 意图理解后发起) 广播给前端 */
29
+ export function dispatchUiAction(call) {
30
+ const { action, data } = call || {};
31
+ if (!action)
32
+ return { success: false, output: 'action 必填 (switchTab/openChat/openSettings 等)' };
33
+ const ok = broadcastUiAction(action, data);
34
+ return { success: ok, output: ok ? `已驱动前端: ${action}` : 'UI 广播未连接' };
35
+ }
36
+ /** 注册 UI 控制工具到 MCP 系统 (供 agent mcp_tool 调用) */
37
+ export function registerUiControlTools() {
38
+ const toolDefs = [
39
+ {
40
+ name: 'ui_switch_tab',
41
+ description: '驱动前端切换底部 tab. 用户想"去通讯录/去我的/去设置"时调用. tab: wechat|contacts|discover|me',
42
+ inputSchema: { type: 'object', properties: { tab: { type: 'string' } }, required: ['tab'] },
43
+ },
44
+ {
45
+ name: 'ui_open_chat',
46
+ description: '驱动前端打开某个智能体聊天页. 用户想"打开和 X 的聊天"时调用. channelId: 目标 channel',
47
+ inputSchema: { type: 'object', properties: { channelId: { type: 'string' } }, required: ['channelId'] },
48
+ },
49
+ {
50
+ name: 'ui_open_settings',
51
+ description: '驱动前端打开设置页. 用户想"打开设置/配置"时调用.',
52
+ inputSchema: { type: 'object', properties: {} },
53
+ },
54
+ {
55
+ name: 'ui_open_wallet',
56
+ description: '驱动前端打开钱包. 用户想"看钱包/支付"时调用.',
57
+ inputSchema: { type: 'object', properties: {} },
58
+ },
59
+ {
60
+ name: 'ui_open_add_friend',
61
+ description: '驱动前端打开添加好友. 用户想"添加好友"时调用.',
62
+ inputSchema: { type: 'object', properties: {} },
63
+ },
64
+ {
65
+ name: 'ui_send_message',
66
+ description: '驱动前端在当前聊天发送消息. channelId: 目标 channel, text: 要发的消息文本.',
67
+ inputSchema: { type: 'object', properties: { channelId: { type: 'string' }, text: { type: 'string' } }, required: ['channelId', 'text'] },
68
+ },
69
+ {
70
+ name: 'ui_show_toast',
71
+ description: '在前端顶部显示一条提示 (toast). message: 提示内容.',
72
+ inputSchema: { type: 'object', properties: { message: { type: 'string' } }, required: ['message'] },
73
+ },
74
+ {
75
+ name: 'ui_go_back',
76
+ description: '驱动前端返回上一页. 用户想"返回/回去"时调用.',
77
+ inputSchema: { type: 'object', properties: {} },
78
+ },
79
+ ];
80
+ let count = 0;
81
+ for (const t of toolDefs) {
82
+ // 已注册则跳过 (幂等)
83
+ if (listTools().some((x) => x.name === t.name))
84
+ continue;
85
+ registerTool({
86
+ name: t.name,
87
+ description: t.description,
88
+ inputSchema: t.inputSchema,
89
+ serverName: 'ui', // 本地 UI 工具 (不走外部 server; 由 agent 调用方 dispatch)
90
+ });
91
+ count++;
92
+ }
93
+ return count;
94
+ }
95
+ /** agent 工具 (pi-sdk-tools 注册) 的 execute 适配: name → UiAction */
96
+ export function uiToolNameToAction(name) {
97
+ switch (name) {
98
+ case 'ui_switch_tab': return 'switchTab';
99
+ case 'ui_open_chat': return 'openChat';
100
+ case 'ui_open_settings': return 'openSettings';
101
+ case 'ui_open_wallet': return 'openWallet';
102
+ case 'ui_open_add_friend': return 'openAddFriend';
103
+ case 'ui_send_message': return 'sendMessage';
104
+ case 'ui_show_toast': return 'showToast';
105
+ case 'ui_go_back': return 'goBack';
106
+ default: return null;
107
+ }
108
+ }
@@ -5,6 +5,19 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>Bolloon Agent</title>
7
7
 
8
+ <!-- 2026-08-12: 手机端 (Capacitor webview / 移动浏览器) 跳转专门的手机端 UI (mobile.html, 微信风格) -->
9
+ <script>
10
+ (function () {
11
+ try {
12
+ var isCapacitor = window.Capacitor && window.Capacitor.isNativePlatform && window.Capacitor.isNativePlatform();
13
+ var isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
14
+ if ((isCapacitor || isMobile) && !location.pathname.endsWith('/mobile.html')) {
15
+ location.replace('./mobile.html');
16
+ }
17
+ } catch (e) { /* 跳转失败静默, 继续桌面 UI */ }
18
+ })();
19
+ </script>
20
+
8
21
  <!-- Favicon -->
9
22
  <link rel="icon" type="image/x-icon" href="./icons/favicon.ico">
10
23
  <link rel="icon" type="image/png" sizes="32x32" href="./icons/favicon-32x32.png">
@@ -0,0 +1,173 @@
1
+ /* 手机端 UI — 微信风格 + bolloon WebUI 主题 (配色不变) */
2
+ :root {
3
+ --bg: #1a1a18;
4
+ --bg-card: #222220;
5
+ --bg-hover: #2a2a26;
6
+ --text: #d8d8c8;
7
+ --text-secondary: #909088;
8
+ --text-muted: #606058;
9
+ --accent: #c4d640;
10
+ --accent-hover: #d4e650;
11
+ --border: #3a3a36;
12
+ --radius: 12px;
13
+ --tabbar-h: 60px;
14
+ --topbar-h: 56px;
15
+ }
16
+
17
+ * { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
18
+ html, body { height: 100%; }
19
+ body {
20
+ background: var(--bg);
21
+ color: var(--text);
22
+ font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
23
+ overflow: hidden;
24
+ touch-action: manipulation;
25
+ }
26
+
27
+ .app {
28
+ display: flex;
29
+ flex-direction: column;
30
+ height: 100%;
31
+ }
32
+
33
+ /* 顶部栏 */
34
+ .topbar {
35
+ height: var(--topbar-h);
36
+ display: flex;
37
+ align-items: center;
38
+ justify-content: space-between;
39
+ padding: 0 16px;
40
+ padding-top: env(safe-area-inset-top);
41
+ background: var(--bg-card);
42
+ border-bottom: 1px solid var(--border);
43
+ }
44
+ .topbar-title { font-size: 18px; font-weight: 600; color: var(--text); }
45
+ .topbar-actions { display: flex; gap: 12px; }
46
+ .icon-btn {
47
+ width: 36px; height: 36px;
48
+ border: none; border-radius: 8px;
49
+ background: var(--bg-hover);
50
+ color: var(--accent);
51
+ font-size: 22px;
52
+ display: flex; align-items: center; justify-content: center;
53
+ }
54
+
55
+ /* 页面容器 */
56
+ .page-container { flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch; }
57
+ .page { min-height: 100%; }
58
+
59
+ /* 列表 */
60
+ .list { padding: 8px 0; }
61
+ .list-item {
62
+ display: flex; align-items: center;
63
+ gap: 12px;
64
+ padding: 14px 16px;
65
+ background: var(--bg-card);
66
+ border-bottom: 1px solid var(--border);
67
+ cursor: pointer;
68
+ }
69
+ .list-item:active { background: var(--bg-hover); }
70
+ .list-icon { font-size: 20px; width: 32px; text-align: center; }
71
+ .list-item span:nth-child(2) { flex: 1; }
72
+ .list-arrow { color: var(--text-muted); }
73
+
74
+ /* 分组标签 */
75
+ .section-label {
76
+ padding: 12px 16px 4px;
77
+ font-size: 12px;
78
+ color: var(--text-muted);
79
+ }
80
+
81
+ /* 会话 / 联系人条目 */
82
+ .conv-item {
83
+ display: flex; align-items: center; gap: 12px;
84
+ padding: 12px 16px;
85
+ background: var(--bg-card);
86
+ border-bottom: 1px solid var(--border);
87
+ cursor: pointer;
88
+ }
89
+ .conv-item:active { background: var(--bg-hover); }
90
+ .conv-avatar {
91
+ width: 44px; height: 44px; border-radius: 8px;
92
+ background: var(--accent);
93
+ color: var(--bg);
94
+ display: flex; align-items: center; justify-content: center;
95
+ font-size: 18px; font-weight: 600;
96
+ }
97
+ .conv-body { flex: 1; min-width: 0; }
98
+ .conv-name { font-size: 16px; color: var(--text); }
99
+ .conv-preview { font-size: 13px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
100
+
101
+ /* 我 tab 卡片 */
102
+ .profile-card {
103
+ display: flex; align-items: center; gap: 14px;
104
+ padding: 20px 16px;
105
+ background: var(--bg-card);
106
+ border-bottom: 1px solid var(--border);
107
+ }
108
+ .avatar {
109
+ width: 56px; height: 56px; border-radius: 12px;
110
+ background: var(--accent); color: var(--bg);
111
+ display: flex; align-items: center; justify-content: center;
112
+ font-size: 24px; font-weight: 700;
113
+ }
114
+ .profile-name { font-size: 18px; font-weight: 600; }
115
+ .profile-did { font-size: 12px; color: var(--text-muted); word-break: break-all; margin-top: 4px; }
116
+
117
+ /* 底部 tab */
118
+ .tabbar {
119
+ height: var(--tabbar-h);
120
+ display: flex;
121
+ padding-bottom: env(safe-area-inset-bottom);
122
+ background: var(--bg-card);
123
+ border-top: 1px solid var(--border);
124
+ }
125
+ .tab {
126
+ flex: 1;
127
+ display: flex; flex-direction: column;
128
+ align-items: center; justify-content: center;
129
+ gap: 2px;
130
+ border: none; background: transparent;
131
+ color: var(--text-secondary);
132
+ font-size: 11px;
133
+ }
134
+ .tab.active { color: var(--accent); }
135
+ .tab-icon { font-size: 20px; }
136
+
137
+ /* 聊天页 */
138
+ .chat-page {
139
+ position: fixed; inset: 0;
140
+ display: flex; flex-direction: column;
141
+ background: var(--bg);
142
+ z-index: 10;
143
+ }
144
+ .chat-topbar {
145
+ height: var(--topbar-h);
146
+ display: flex; align-items: center;
147
+ padding: 0 12px; padding-top: env(safe-area-inset-top);
148
+ background: var(--bg-card);
149
+ border-bottom: 1px solid var(--border);
150
+ gap: 8px;
151
+ }
152
+ .chat-messages { flex: 1; overflow-y: auto; padding: 12px; }
153
+ .chat-input-bar {
154
+ display: flex; gap: 8px; padding: 8px 12px;
155
+ padding-bottom: calc(8px + env(safe-area-inset-bottom));
156
+ background: var(--bg-card);
157
+ border-top: 1px solid var(--border);
158
+ }
159
+ .chat-input-bar input {
160
+ flex: 1; height: 38px; padding: 0 12px;
161
+ border: 1px solid var(--border); border-radius: 8px;
162
+ background: var(--bg-hover); color: var(--text);
163
+ outline: none;
164
+ }
165
+ .chat-input-bar button {
166
+ height: 38px; padding: 0 16px;
167
+ border: none; border-radius: 8px;
168
+ background: var(--accent); color: var(--bg);
169
+ font-weight: 600;
170
+ }
171
+ .bubble { max-width: 75%; padding: 8px 12px; border-radius: 10px; margin: 4px 0; word-break: break-word; }
172
+ .bubble.user { margin-left: auto; background: var(--accent); color: var(--bg); }
173
+ .bubble.ai { background: var(--bg-card); color: var(--text); }
@@ -0,0 +1,72 @@
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.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
6
+ <title>Bolloon 手机端</title>
7
+ <link rel="stylesheet" href="./mobile.css">
8
+ </head>
9
+ <body>
10
+ <div id="app" class="app">
11
+ <!-- 顶部导航 -->
12
+ <header class="topbar">
13
+ <div class="topbar-title" id="topbar-title">微信</div>
14
+ <div class="topbar-actions">
15
+ <button class="icon-btn" id="btn-add" title="添加好友">+</button>
16
+ </div>
17
+ </header>
18
+
19
+ <!-- 页面容器 (切换 4 个 tab) -->
20
+ <main class="page-container">
21
+ <!-- 微信 tab: 会话列表 -->
22
+ <section class="page" id="page-wechat" data-tab="wechat">
23
+ <div class="list" id="conversation-list"></div>
24
+ </section>
25
+
26
+ <!-- 通讯录 tab -->
27
+ <section class="page" id="page-contacts" data-tab="contacts" hidden>
28
+ <div class="list" id="contacts-list"></div>
29
+ </section>
30
+
31
+ <!-- 发现 tab -->
32
+ <section class="page" id="page-discover" data-tab="discover" hidden>
33
+ <div class="list">
34
+ <div class="list-item" id="item-p2p"><span class="list-icon">🌐</span><span>P2P 好友</span></div>
35
+ <div class="list-item" id="item-p2p-id"><span class="list-icon">🪪</span><span>我的 P2P ID</span></div>
36
+ </div>
37
+ <div class="section-label">MCP 工具 (触控调用)</div>
38
+ <div class="list" id="mcp-tools"></div>
39
+ </section>
40
+
41
+ <!-- 我 tab -->
42
+ <section class="page" id="page-me" data-tab="me" hidden>
43
+ <div class="profile-card" id="profile-card">
44
+ <div class="avatar" id="me-avatar">?</div>
45
+ <div class="profile-info">
46
+ <div class="profile-name" id="me-name">未登录</div>
47
+ <div class="profile-did" id="me-did"></div>
48
+ </div>
49
+ </div>
50
+ <div class="list">
51
+ <div class="list-item" id="item-settings"><span class="list-icon">⚙️</span><span>设置</span><span class="list-arrow">›</span></div>
52
+ <div class="list-item" id="item-wallet"><span class="list-icon">👛</span><span>钱包</span><span class="list-arrow">›</span></div>
53
+ <div class="list-item" id="item-judgments"><span class="list-icon">🧠</span><span>判断力 API</span><span class="list-arrow">›</span></div>
54
+ <div class="list-item" id="item-did"><span class="list-icon">🪪</span><span>DID 身份</span><span class="list-arrow">›</span></div>
55
+ <div class="list-item" id="item-login"><span class="list-icon">🔐</span><span id="login-label">登录</span><span class="list-arrow">›</span></div>
56
+ <div class="list-item" id="item-logout"><span class="list-icon">🚪</span><span>注销</span></div>
57
+ </div>
58
+ </section>
59
+ </main>
60
+
61
+ <!-- 底部 tab -->
62
+ <nav class="tabbar">
63
+ <button class="tab active" data-tab="wechat"><span class="tab-icon">💬</span><span>微信</span></button>
64
+ <button class="tab" data-tab="contacts"><span class="tab-icon">📇</span><span>通讯录</span></button>
65
+ <button class="tab" data-tab="discover"><span class="tab-icon">🧭</span><span>发现</span></button>
66
+ <button class="tab" data-tab="me"><span class="tab-icon">🙂</span><span>我</span></button>
67
+ </nav>
68
+ </div>
69
+
70
+ <script src="./mobile.js"></script>
71
+ </body>
72
+ </html>
@@ -0,0 +1,346 @@
1
+ /**
2
+ * mobile.js — 手机端 UI 交互 (微信风格, Capacitor webview)
3
+ * bolloon WebUI 主题配色. 数据来自 bolloon server HTTP API + SSE 流式聊天.
4
+ * 触控组件: tab 切换 / 列表点击 / 聊天输入 / 设置 / MCP 工具调用.
5
+ */
6
+ (function () {
7
+ const $ = (sel) => document.querySelector(sel);
8
+ const $$ = (sel) => Array.from(document.querySelectorAll(sel));
9
+
10
+ const api = {
11
+ async get(path) {
12
+ const r = await fetch(path);
13
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
14
+ return r.json();
15
+ },
16
+ async post(path, body) {
17
+ const r = await fetch(path, {
18
+ method: 'POST',
19
+ headers: { 'Content-Type': 'application/json' },
20
+ body: body ? JSON.stringify(body) : undefined,
21
+ });
22
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
23
+ return r.json();
24
+ },
25
+ };
26
+
27
+ // === 主题 (外观切换, 复用 WebUI 主题变量) ===
28
+ const THEMES = {
29
+ dark: { '--bg': '#1a1a18', '--bg-card': '#222220', '--bg-hover': '#2a2a26', '--text': '#d8d8c8', '--text-secondary': '#909088', '--accent': '#c4d640', '--border': '#3a3a36' },
30
+ light: { '--bg': '#f5f5f0', '--bg-card': '#ffffff', '--bg-hover': '#eeeeea', '--text': '#1a1a18', '--text-secondary': '#606058', '--accent': '#8a9430', '--border': '#d0d0c8' },
31
+ };
32
+ function applyTheme(name) {
33
+ const t = THEMES[name] || THEMES.dark;
34
+ const root = document.documentElement;
35
+ Object.entries(t).forEach(([k, v]) => root.style.setProperty(k, v));
36
+ localStorage.setItem('bolloon_theme', name);
37
+ const btn = $('#theme-toggle');
38
+ if (btn) btn.textContent = name === 'dark' ? '🌙 深色' : '☀️ 浅色';
39
+ }
40
+
41
+ // === tab 切换 ===
42
+ const TITLES = { wechat: '微信', contacts: '通讯录', discover: '发现', me: '我' };
43
+ function switchTab(tab) {
44
+ $$('.page').forEach((p) => { p.hidden = p.dataset.tab !== tab; });
45
+ $$('.tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === tab));
46
+ $('#topbar-title').textContent = TITLES[tab] || '微信';
47
+ if (tab === 'discover') loadMcpTools();
48
+ window.__mobileTouch?.('tab', tab);
49
+ }
50
+ $$('.tab').forEach((t) => t.addEventListener('click', () => switchTab(t.dataset.tab)));
51
+
52
+ // === 会话列表 ===
53
+ async function loadConversations() {
54
+ try {
55
+ const channels = await api.get('/channels');
56
+ const list = $('#conversation-list');
57
+ list.innerHTML = '';
58
+ if (!Array.isArray(channels) || channels.length === 0) {
59
+ list.innerHTML = '<div style="padding:20px;text-align:center;color:var(--text-muted)">暂无会话, 点右上角 + 添加</div>';
60
+ return;
61
+ }
62
+ channels.forEach((ch) => {
63
+ const name = ch.persona?.name || ch.name || ch.agentId || '智能体';
64
+ const el = document.createElement('div');
65
+ el.className = 'conv-item';
66
+ el.innerHTML = `
67
+ <div class="conv-avatar">${escapeHtml(name.charAt(0))}</div>
68
+ <div class="conv-body">
69
+ <div class="conv-name">${escapeHtml(name)}</div>
70
+ <div class="conv-preview">${escapeHtml(ch.preview || '开始对话')}</div>
71
+ </div>`;
72
+ el.addEventListener('click', () => openChat(ch));
73
+ list.appendChild(el);
74
+ });
75
+ } catch (e) {
76
+ $('#conversation-list').innerHTML = `<div style="padding:20px;color:var(--error)">加载失败: ${escapeHtml(e.message)}</div>`;
77
+ }
78
+ }
79
+
80
+ // === 通讯录 ===
81
+ async function loadContacts() {
82
+ try {
83
+ let peers = [];
84
+ try { peers = await api.get('/api/peers'); } catch { peers = []; }
85
+ const list = $('#contacts-list');
86
+ list.innerHTML = '';
87
+ if (!Array.isArray(peers) || peers.length === 0) {
88
+ list.innerHTML = '<div style="padding:20px;text-align:center;color:var(--text-muted)">暂无好友</div>';
89
+ return;
90
+ }
91
+ peers.forEach((p) => {
92
+ const name = p.name || p.publicKey?.slice(0, 12) || '好友';
93
+ const el = document.createElement('div');
94
+ el.className = 'conv-item';
95
+ el.innerHTML = `<div class="conv-avatar">${escapeHtml(name.charAt(0))}</div>
96
+ <div class="conv-body"><div class="conv-name">${escapeHtml(name)}</div></div>`;
97
+ list.appendChild(el);
98
+ });
99
+ } catch (e) { /* 忽略 */ }
100
+ }
101
+
102
+ // === 发现: MCP 工具列表 (MCP 前端支持) ===
103
+ async function loadMcpTools() {
104
+ const box = $('#mcp-tools');
105
+ if (!box) return;
106
+ box.innerHTML = '<div style="padding:12px 16px;color:var(--text-muted)">加载 MCP 工具...</div>';
107
+ try {
108
+ const r = await api.get('/api/mcp/tools').catch(() => null);
109
+ const tools = r?.tools || r || [];
110
+ if (!Array.isArray(tools) || tools.length === 0) {
111
+ box.innerHTML = '<div style="padding:12px 16px;color:var(--text-muted)">暂无可用 MCP 工具</div>';
112
+ return;
113
+ }
114
+ box.innerHTML = '';
115
+ tools.forEach((t) => {
116
+ const name = t.name || t.function?.name || '工具';
117
+ const desc = t.description || t.function?.description || '';
118
+ const el = document.createElement('div');
119
+ el.className = 'conv-item';
120
+ el.innerHTML = `<div class="conv-avatar">🔌</div>
121
+ <div class="conv-body"><div class="conv-name">${escapeHtml(name)}</div>
122
+ <div class="conv-preview">${escapeHtml(desc)}</div></div>`;
123
+ el.addEventListener('click', () => { window.__mobileTouch?.('mcp', name); alert('MCP 工具: ' + name + '\n可在对话中让智能体调用'); });
124
+ box.appendChild(el);
125
+ });
126
+ } catch (e) { box.innerHTML = '<div style="padding:12px 16px;color:var(--text-muted)">MCP 工具加载失败</div>'; }
127
+ }
128
+
129
+ // === 我的页 ===
130
+ async function loadMe() {
131
+ try {
132
+ const s = await api.get('/api/auth/status');
133
+ $('#me-avatar').textContent = (s.name || 'U').charAt(0);
134
+ $('#me-name').textContent = s.name || '未登录';
135
+ $('#me-did').textContent = s.didShort || s.did || '';
136
+ const hasAccount = (s.accounts && s.accounts.length > 0);
137
+ $('#login-label').textContent = hasAccount ? '已登录账号' : '登录';
138
+ $('#settings-did').textContent = 'DID: ' + (s.did || '未生成');
139
+ $('#settings-login-state').textContent = hasAccount ? '已登录 (' + (s.accounts.map(a => a.provider).join(',')) + ')' : '未登录';
140
+ } catch (e) { /* 默认 */ }
141
+ }
142
+
143
+ // === 聊天页 (SSE 流式) ===
144
+ let activeChannel = null;
145
+ let chatEventSource = null;
146
+ let streamingBubble = null;
147
+
148
+ function openChat(ch) {
149
+ activeChannel = ch;
150
+ const page = document.createElement('div');
151
+ page.className = 'chat-page';
152
+ page.id = 'chat-page';
153
+ const name = ch.persona?.name || ch.name || ch.agentId || '智能体';
154
+ page.innerHTML = `
155
+ <div class="chat-topbar">
156
+ <button class="icon-btn" id="chat-back">‹</button>
157
+ <div style="flex:1;font-weight:600">${escapeHtml(name)}</div>
158
+ </div>
159
+ <div class="chat-messages" id="chat-messages"></div>
160
+ <div class="chat-input-bar">
161
+ <input id="chat-input" placeholder="输入消息...">
162
+ <button id="chat-send">发送</button>
163
+ </div>`;
164
+ document.body.appendChild(page);
165
+ $('#chat-back').addEventListener('click', closeChat);
166
+ $('#chat-send').addEventListener('click', sendChat);
167
+ $('#chat-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') sendChat(); });
168
+ loadMessages();
169
+ openChatSse();
170
+ window.__mobileTouch?.('chat', ch.id);
171
+ }
172
+
173
+ function closeChat() {
174
+ if (chatEventSource) { chatEventSource.close(); chatEventSource = null; }
175
+ const p = $('#chat-page');
176
+ if (p) p.remove();
177
+ activeChannel = null;
178
+ loadConversations();
179
+ }
180
+
181
+ function openChatSse() {
182
+ if (chatEventSource) chatEventSource.close();
183
+ if (!activeChannel) return;
184
+ // 订阅全局事件流, 过滤当前 channel 的 AI 流式回复 (step/token/ai)
185
+ try {
186
+ chatEventSource = new EventSource('/events');
187
+ chatEventSource.onmessage = (e) => {
188
+ if (!activeChannel || !e.data) return;
189
+ let msg;
190
+ try { msg = JSON.parse(e.data); } catch { return; }
191
+ const box = $('#chat-messages');
192
+ if (!box) return;
193
+ // AI 消息 / token 流 → 流式气泡
194
+ if (msg.type === 'ai' || msg.type === 'token' || msg.role === 'ai') {
195
+ const content = msg.content || msg.text || '';
196
+ if (msg.channelId && msg.channelId !== activeChannel.id) return;
197
+ if (!streamingBubble || !streamingBubble.isConnected) {
198
+ streamingBubble = document.createElement('div');
199
+ streamingBubble.className = 'bubble ai';
200
+ box.appendChild(streamingBubble);
201
+ }
202
+ streamingBubble.textContent += content;
203
+ box.scrollTop = box.scrollHeight;
204
+ } else if (msg.type === 'done') {
205
+ streamingBubble = null;
206
+ setTimeout(loadMessages, 300);
207
+ }
208
+ };
209
+ chatEventSource.onerror = () => { /* SSE 断线静默重连 */ };
210
+ } catch (e) { /* SSE 不支持则退回轮询 */ }
211
+ }
212
+
213
+ async function loadMessages() {
214
+ if (!activeChannel) return;
215
+ const box = $('#chat-messages');
216
+ try {
217
+ const data = await api.get(`/sessions/${encodeURIComponent(activeChannel.id)}`);
218
+ const msgs = data?.messages || [];
219
+ box.innerHTML = '';
220
+ msgs.slice(-50).forEach((m) => {
221
+ const role = (m.role || m.type || '') === 'user' ? 'user' : 'ai';
222
+ const d = document.createElement('div');
223
+ d.className = 'bubble ' + role;
224
+ d.textContent = m.content || '';
225
+ box.appendChild(d);
226
+ });
227
+ box.scrollTop = box.scrollHeight;
228
+ } catch (e) { box.innerHTML = '<div style="color:var(--text-muted)">暂无历史消息</div>'; }
229
+ }
230
+
231
+ async function sendChat() {
232
+ const input = $('#chat-input');
233
+ const text = input.value.trim();
234
+ if (!text || !activeChannel) return;
235
+ input.value = '';
236
+ const box = $('#chat-messages');
237
+ const userBubble = document.createElement('div');
238
+ userBubble.className = 'bubble user';
239
+ userBubble.textContent = text;
240
+ box.appendChild(userBubble);
241
+ box.scrollTop = box.scrollHeight;
242
+ try {
243
+ await api.post('/message', { text, channelId: activeChannel.id });
244
+ // AI 回复走 SSE 流式 (openChatSse)
245
+ setTimeout(() => { if (!streamingBubble) loadMessages(); }, 1500);
246
+ } catch (e) {
247
+ const ai = document.createElement('div');
248
+ ai.className = 'bubble ai';
249
+ ai.textContent = '发送失败: ' + (e.message || '');
250
+ box.appendChild(ai);
251
+ }
252
+ }
253
+
254
+ // === 设置页 (弹层) ===
255
+ function openSettings() {
256
+ const page = document.createElement('div');
257
+ page.className = 'chat-page';
258
+ page.id = 'settings-page';
259
+ page.innerHTML = `
260
+ <div class="chat-topbar">
261
+ <button class="icon-btn" id="settings-back">‹</button>
262
+ <div style="flex:1;font-weight:600">设置</div>
263
+ </div>
264
+ <div style="padding:12px">
265
+ <div class="conv-item" id="theme-toggle">🌙 深色</div>
266
+ <div class="conv-item" id="settings-api"><span class="list-icon">🔧</span><span>API 配置</span><span class="list-arrow">›</span></div>
267
+ <div class="conv-item" id="settings-wallet"><span class="list-icon">👛</span><span>钱包</span><span class="list-arrow">›</span></div>
268
+ <div class="conv-item" id="settings-judgments"><span class="list-icon">🧠</span><span>判断力 API</span><span class="list-arrow">›</span></div>
269
+ <div class="conv-item" id="settings-did">🪪 DID</div>
270
+ <div class="conv-item" id="settings-login-state">🔐 登录状态</div>
271
+ </div>`;
272
+ document.body.appendChild(page);
273
+ const theme = localStorage.getItem('bolloon_theme') || 'dark';
274
+ applyTheme(theme);
275
+ $('#settings-back').addEventListener('click', () => page.remove());
276
+ $('#theme-toggle').addEventListener('click', () => applyTheme(localStorage.getItem('bolloon_theme') === 'dark' ? 'light' : 'dark'));
277
+ $('#settings-api').addEventListener('click', () => openUrl('/api-config'));
278
+ $('#settings-wallet').addEventListener('click', () => alert('钱包管理 (桌面 Web UI 提供)'));
279
+ $('#settings-judgments').addEventListener('click', () => alert('判断力 API (桌面 Web UI 提供)'));
280
+ $('#settings-did').addEventListener('click', () => { api.get('/api/auth/status').then((s) => alert('DID: ' + (s.did || '未生成'))); });
281
+ }
282
+
283
+ // === 菜单绑定 ===
284
+ function bindMenu() {
285
+ $('#item-settings').addEventListener('click', openSettings);
286
+ $('#item-wallet').addEventListener('click', () => { alert('钱包管理 (桌面 Web UI 提供)'); });
287
+ $('#item-judgments').addEventListener('click', () => { alert('判断力 API (桌面 Web UI 提供)'); });
288
+ $('#item-did').addEventListener('click', () => { api.get('/api/auth/status').then((s) => alert('DID: ' + (s.did || '未生成'))); });
289
+ $('#item-login').addEventListener('click', () => { openUrl('/api-config'); });
290
+ $('#item-logout').addEventListener('click', () => { api.post('/api/auth/logout', { provider: 'github' }).then(() => loadMe()); });
291
+ $('#btn-add').addEventListener('click', () => { alert('添加好友: 在桌面 Web UI 的 P2P 好友中添加'); });
292
+ $('#item-p2p').addEventListener('click', () => { switchTab('contacts'); });
293
+ $('#item-p2p-id').addEventListener('click', () => { alert('我的 P2P ID 见桌面 Web UI'); });
294
+ }
295
+
296
+ function openUrl(url) {
297
+ if (window.Capacitor && window.Capacitor.isNativePlatform?.()) location.href = url;
298
+ else window.open(url, '_blank');
299
+ }
300
+
301
+ function escapeHtml(s) {
302
+ return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
303
+ }
304
+
305
+ // === MCP 触控钩子 ===
306
+ window.__mobileTouch = (type, data) => {
307
+ // 预留: 触控事件上报/控制
308
+ };
309
+
310
+ // === MCP 驱动前端 UI (2026-08-12): 订阅 /events, 收到 {type:'ui'} 指令执行组件动作 ===
311
+ function setupUiControl() {
312
+ try {
313
+ const es = new EventSource('/events');
314
+ es.onmessage = (e) => {
315
+ if (!e.data) return;
316
+ let msg;
317
+ try { msg = JSON.parse(e.data); } catch { return; }
318
+ if (msg.type !== 'ui' || !msg.action) return;
319
+ const d = msg.data || {};
320
+ switch (msg.action) {
321
+ case 'switchTab': if (d.tab && ['wechat', 'contacts', 'discover', 'me'].includes(d.tab)) switchTab(d.tab); break;
322
+ case 'openSettings': openSettings(); break;
323
+ case 'openWallet': alert('钱包管理 (桌面 Web UI 提供)'); break;
324
+ case 'openAddFriend': alert('添加好友: 在桌面 Web UI 的 P2P 好友中添加'); break;
325
+ case 'showToast': alert(d.message || ''); break;
326
+ case 'goBack': { const p = $('#chat-page'); if (p) closeChat(); else openSettings(); break; }
327
+ default: break;
328
+ }
329
+ window.__mobileTouch?.('ui', msg.action);
330
+ };
331
+ es.onerror = () => { /* SSE 断线静默重连 */ };
332
+ } catch (e) { /* 忽略 */ }
333
+ }
334
+
335
+ function init() {
336
+ bindMenu();
337
+ applyTheme(localStorage.getItem('bolloon_theme') || 'dark');
338
+ switchTab('wechat');
339
+ setupUiControl();
340
+ loadConversations();
341
+ loadContacts();
342
+ loadMe();
343
+ }
344
+ document.addEventListener('DOMContentLoaded', init);
345
+ if (document.readyState !== 'loading') init();
346
+ })();
@@ -2785,6 +2785,29 @@ ${goalDesc}
2785
2785
  res.status(400).json({ ok: false, severity: 'block', reason: e?.message ?? 'parse error' });
2786
2786
  }
2787
2787
  });
2788
+ // 2026-08-12: MCP 工具列表 (MCP 前端支持 — 手机端/桌面 UI 展示可用 MCP 工具)
2789
+ app.get('/api/mcp/tools', async (_req, res) => {
2790
+ try {
2791
+ const mcp = await import('../pi-ecosystem-mcp/index.js');
2792
+ await mcp.initializeMcpAdapter().catch(() => { });
2793
+ const tools = mcp.listTools().map((t) => ({ name: t.name, description: t.description || '' }));
2794
+ res.json({ tools });
2795
+ }
2796
+ catch (e) {
2797
+ res.status(500).json({ error: e?.message });
2798
+ }
2799
+ });
2800
+ // 2026-08-12: MCP 驱动前端 UI — 把 broadcast 注入 ui-tools, agent 调 UI 工具时广播 {type:'ui'} 给前端 SSE
2801
+ (async () => {
2802
+ try {
2803
+ const { setUiBroadcast, registerUiControlTools } = await import('../pi-ecosystem-mcp/ui-tools.js');
2804
+ setUiBroadcast(broadcast);
2805
+ registerUiControlTools();
2806
+ }
2807
+ catch (e) {
2808
+ console.warn('[ui-tools] UI 广播注入失败 (非致命):', e?.message?.slice(0, 120));
2809
+ }
2810
+ })();
2788
2811
  app.get('/api/health', (_req, res) => {
2789
2812
  res.json(healthCheck(getPackageVersion()));
2790
2813
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bolloon/bolloon-agent",
3
- "version": "0.4.9",
3
+ "version": "0.4.10",
4
4
  "type": "module",
5
5
  "description": "P2P AI Document Agent - 全局安装后执行 `bolloon` 启动产品",
6
6
  "main": "dist/cli-entry.js",
@@ -14,7 +14,7 @@ async function main() {
14
14
 
15
15
  // 重要: 不能 rm -rf 整个 dist/web/, 否则会删掉 build:main 编译出来的
16
16
  // dist/web/server.js. 只清理 web 静态资源, 保留 server.js.
17
- for (const f of ['index.html', 'api-config.html', 'style.css', 'client.js', 'components']) {
17
+ for (const f of ['index.html', 'api-config.html', 'style.css', 'client.js', 'mobile.html', 'mobile.css', 'mobile.js', 'components']) {
18
18
  await fs.rm(path.join(DIST_WEB, f), { recursive: true, force: true });
19
19
  }
20
20
  await fs.mkdir(DIST_WEB, { recursive: true });
@@ -96,6 +96,10 @@ async function main() {
96
96
  await fs.copyFile(path.join(ROOT, 'src/web/index.html'), path.join(DIST_WEB, 'index.html'));
97
97
  await fs.copyFile(path.join(ROOT, 'src/web/api-config.html'), path.join(DIST_WEB, 'api-config.html'));
98
98
  await fs.copyFile(path.join(ROOT, 'src/web/style.css'), path.join(DIST_WEB, 'style.css'));
99
+ // 2026-08-12: 手机端 UI (微信风格, Capacitor webview 加载) — 纯静态复制
100
+ await fs.copyFile(path.join(ROOT, 'src/web/mobile.html'), path.join(DIST_WEB, 'mobile.html'));
101
+ await fs.copyFile(path.join(ROOT, 'src/web/mobile.css'), path.join(DIST_WEB, 'mobile.css'));
102
+ await fs.copyFile(path.join(ROOT, 'src/web/mobile.js'), path.join(DIST_WEB, 'mobile.js'));
99
103
  // 复制 PWA manifest (index.html 里有 <link rel="manifest">, 否则浏览器会 404)
100
104
  await fs.copyFile(path.join(ROOT, 'src/web/manifest.json'), path.join(DIST_WEB, 'manifest.json'));
101
105
  // 复制 icons 目录 (manifest.json 里引用了 favicon 等)