From ee7e7e36573ad4b8a151ec3a4ee91ec1fcf2a8e5 Mon Sep 17 00:00:00 2001 From: cfn Date: Thu, 5 Nov 2026 15:24:14 +0800 Subject: [PATCH] feat: integrate mobile MCP and improve device workflows Bundle the locked Mobile Next runtime and preserve built-in MCP settings. Improve USB discovery, selection, preview and disconnect behavior. Fix macOS layout, tighten chat spacing and show AI operation status. Keep observation screenshots internal unless requested and allow tasks to run until completion or cancellation. Update runtime packaging, ignore generated mobile assets and add regression coverage. Validation: 34 frontend tests, vue-tsc, and go test -race ./internal/... --- .gitignore | 1 + README.md | 7 +- build/windows/nsis/project.nsi | 2 + frontend/src/App.vue | 93 +- frontend/src/components/chat/ChatPane.vue | 28 +- .../src/components/chat/ConversationList.vue | 3 +- .../src/components/chat/MarkdownContent.vue | 10 +- frontend/src/components/device/DeviceBar.vue | 12 +- .../src/components/screen/CapturePane.vue | 88 +- .../src/components/settings/MCPSettings.vue | 24 +- .../src/components/settings/SettingsPane.vue | 30 +- frontend/src/lib/useConversations.ts | 18 +- frontend/tests/conversations.test.mjs | 53 + frontend/tests/device-connection.test.mjs | 147 ++ internal/agent/screenshots.go | 10 + internal/agent/screenshots_test.go | 89 +- internal/agent/service.go | 2 +- internal/agent/stream.go | 4 +- internal/agent/tools.go | 21 +- internal/agent/tools_test.go | 73 +- internal/device/adb.go | 4 +- internal/device/mcp.go | 27 + internal/device/service.go | 79 +- internal/device/stream.go | 6 + internal/device/usb_test.go | 124 ++ internal/mcpclient/agent.go | 5 +- internal/mcpclient/filesystem.go | 30 + internal/mcpclient/filesystem_test.go | 36 + internal/mcpclient/mobile.go | 77 + internal/mcpclient/mobile_test.go | 127 ++ internal/mcpclient/service.go | 32 +- internal/mcpclient/transport.go | 22 +- runtime/mobile/package-lock.json | 1792 +++++++++++++++++ runtime/mobile/package.json | 8 + runtime/mobile/server.cjs | 14 + scripts/package-windows-portable.ps1 | 2 +- scripts/stage-macos-runtime.sh | 7 +- scripts/stage-mobile-runtime.mjs | 26 + scripts/sync-macos-runtime.sh | 1 + scripts/sync-windows-runtime.ps1 | 6 +- third_party/RUNTIME-NOTICES.txt | 8 + 41 files changed, 3014 insertions(+), 134 deletions(-) create mode 100644 frontend/tests/device-connection.test.mjs create mode 100644 internal/device/mcp.go create mode 100644 internal/device/usb_test.go create mode 100644 internal/mcpclient/mobile.go create mode 100644 internal/mcpclient/mobile_test.go create mode 100644 runtime/mobile/package-lock.json create mode 100644 runtime/mobile/package.json create mode 100644 runtime/mobile/server.cjs create mode 100644 scripts/stage-mobile-runtime.mjs diff --git a/.gitignore b/.gitignore index a221ac7..e8376be 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,7 @@ __pycache__/ /third_party/ffmpeg/ /third_party/webview2/ /third_party/mcp-filesystem/ +/third_party/mcp-mobile/ /third_party/runtime-manifest.json /build/linux/appimage/build/ /build/windows/nsis/MicrosoftEdgeWebview2Setup.exe diff --git a/README.md b/README.md index ebb0fa6..6e75921 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ Windows 使用上面的 PowerShell 脚本和 Windows 打包任务;不要在 Wi ## 当前能力 -- USB / 无线 ADB 设备发现、配对、连接与离线重连。 +- USB / 无线 ADB 设备发现、配对、连接与离线重连。「设置 → 设备连接」提供 USB 数据线入口:开启手机 USB 调试并允许电脑授权后即可投屏与操作,无需无线配对;插拔或授权后点击「检测 USB 设备」或顶部「刷新」更新列表,设备选择器区分 USB 和无线连接。 - scrcpy 视频预览,支持适应窗口及 50%–200% 缩放。 - 预览中点按、长按及连续拖动:按下、移动、松开实时发送到 scrcpy 控制通道。 - 深色、浅色和跟随系统主题,连接配置与缩放偏好持久化。 @@ -95,10 +95,11 @@ Windows 使用上面的 PowerShell 脚本和 Windows 打包任务;不要在 Wi - 会话支持自动摘要记忆:历史问答超出设置的近期轮数或正文长度预算时,使用当前模型把较早内容增量整理为摘要,后续发送“摘要 + 近期问答 + 当前问题”。摘要包含需求、限制、决定、关键参数、工具结果及待办,原始消息保留。摘要与覆盖范围按会话保存到同一 JSON 文件,重启后恢复;前端旧快照不能覆盖后端新记忆,历史原文被修改时会失效并重新整理。记忆及整理过程在后台处理,不在页面展示;停止生成仍可取消整理。摘要失败或取消不会丢弃原记录,也不会推进覆盖范围。摘要会额外调用当前模型,上下文轮数为 0 时不使用或生成摘要。 - 对话支持文件选择、拖放和粘贴图片。附件可在发送前移除,也可仅发送附件;待发送附件随草稿保存,已发送附件显示在消息中,按会话隔离,重启后可继续引用。原文件与提取正文保存在用户配置目录的 `iTuoke/attachments/{会话ID}/{附件ID}/`,删除会话时同步清理。文件名只作为元数据,文件路径由程序生成,客户端无需安装额外解析工具。 - 支持 UTF-8、带 BOM 的 UTF-16、GB18030 文本与代码文件,以及文字型 PDF、DOCX、XLSX 和 PNG/JPEG/WebP。PDF 不做扫描件 OCR;Excel 使用单元格缓存值,不执行公式。每条最多 5 个附件,单个最多 20 MB、合计最多 30 MB;单文件提取正文最多 128 KB,超出会标记“部分正文”,每条附件正文合计最多 256 KB。文本内容用于对话和自动摘要,图片作为当前模型的图片输入,模型需支持识图。 -- Agent 支持 Chat Completions 工具调用:将已启用 MCP 的工具提供给模型,执行后回传结果并继续流式回复;支持手机操作及文件工具。投屏本身不上传模型;截图等工具返回的图片会作为当前轮图片输入发送给所配置模型(需要支持图片),同时保存到本机会话附件,记录中只存引用。图片卡片直接显示在聊天中,可点击放大,切换会话和重启后恢复。Ollama 选项连接已有服务,不随应用打包本地推理引擎或模型权重。 +- Agent 支持 Chat Completions 工具调用:将已启用 MCP 的工具提供给模型,执行后回传结果并继续流式回复;支持手机操作及文件工具。Agent 不设固定工具轮数、调用次数或整段任务时长上限,任务完成后结束,用户可随时停止;单次模型请求超时仍按配置执行。投屏本身不上传模型;截图等工具返回的图片会作为当前轮图片输入发送给所配置模型(需要支持图片)。手机操作所需的截图默认仅用于内部识别;只有用户本轮明确要求截图或展示画面时,才保存到本机会话附件并显示图片卡片,可点击放大,切换会话和重启后恢复。Ollama 选项连接已有服务,不随应用打包本地推理引擎或模型权重。 - “截图给我看”等单纯截图请求使用本地快捷路径:只识别唯一在线手机并调用截图工具,跳过模型规划、历史摘要、页面与目录查询,不再另存重试。包含导航、分析、指定保存路径等额外要求时仍由模型执行。普通任务优先使用已确定的设备和批量顺序动作,减少逐步播报和重复核验。Qwen3.8-Max 工具任务默认设置 `enable_thinking=false`,可在模型高级设置中开启“工具执行使用深度思考”。 +- 系统内置 Mobile Next MCP 1.0.16 与 Filesystem,默认启用,不允许删除或改成其他连接方式,可在 MCP 管理中停用、编辑或测试。Filesystem 保留已有允许目录,新安装默认开放应用用户配置目录中的 `iTuoke/files/`。固定依赖随应用交付,共用私有 Node 并使用内置 ADB 和 iTuoke 专用连接;无需填写 npx 命令或在客户机下载依赖。当前内置集成面向已连接的 Android 手机,使用上游 ADB 模式,关闭遥测;中文输入等上游功能可能需要手机侧 DeviceKit。构建机执行 `npm --prefix runtime/mobile run stage` 准备锁定资源。 - 「设置 → MCP 管理」支持添加、编辑、启停和删除多个 MCP 服务。支持本地 stdio 进程和远程 Streamable HTTP,连接测试真实发现工具(含分页),可展开查看工具说明和输入参数。测试使用当前表单,不自动保存或执行工具,完成后关闭连接。Agent 每次对话读取启用服务,保留连接直至本轮结束;停用、删除或修改目标会阻止旧会话继续调用该服务。 -- 工具名称按服务隔离,调用前校验 JSON Schema,执行结果(含错误)回传模型。工具进度、参数和结果摘要显示在聊天中并随会话保存;停止生成会取消请求和后续调用。每次任务最多 16 轮、40 次调用、10 分钟;同一应用中的 MCP 操作序列按任务排队,避免多个会话交错操作同一手机,纯文字对话仍可并行。 +- 工具名称按服务隔离,调用前校验 JSON Schema,执行结果(含错误)回传模型。工具进度、参数和结果摘要显示在聊天中并随会话保存;停止生成会取消请求和后续调用。同一应用中的 MCP 操作序列按任务排队,避免多个会话交错操作同一手机,纯文字对话仍可并行。 - MCP 普通配置保存在系统用户配置目录下的 `iTuoke/mcp.json`;环境变量与认证请求头保存到系统凭据库,不返回前端。编辑时可保留、替换或清除凭据,修改连接目标须重新填写或清除凭据。本地服务的 Node/Python 等运行环境需自行准备;命令与 JSON 启动参数分开填写,不经 shell 执行。远程服务目前支持自定义认证请求头,不包含 OAuth 登录流程或旧版 HTTP+SSE 传输。 - 「添加 Filesystem」使用官方 `@modelcontextprotocol/server-filesystem` 2.1.0,默认只开放系统用户配置目录的 `iTuoke/files/`。编辑中可指定多个已有目录;配置不保存随安装位置变化的启动路径。普通连接测试只发现工具,「测试文件读写」会实际创建临时目录、写入及读取中文文件、搜索、重命名并验证目录外读写被拒绝,随后清理测试文件。启用后可由对话 Agent 调用。 - Filesystem 的 Node 与锁定依赖随 Windows/macOS 包交付,客户端无需安装或联网下载。构建机需 Node >= 24,首次执行 `npm --prefix runtime/filesystem run stage`;Windows/macOS 运行资源准备脚本也会执行该步骤。`runtime/filesystem/package-lock.json` 固定依赖,目录搜索依赖覆盖为修复版本。Mac 使用官方自包含 Node,检查目标架构及系统 dylib;universal 构建需通过 `ITUOKE_NODE_UNIVERSAL_SOURCE` 提供同时含 arm64/x86_64 的 Node,并用 `ITUOKE_NODE_LICENSE` 指定其许可证(若不在当前 Node 相邻目录)。 diff --git a/build/windows/nsis/project.nsi b/build/windows/nsis/project.nsi index 303b364..f458595 100644 --- a/build/windows/nsis/project.nsi +++ b/build/windows/nsis/project.nsi @@ -103,6 +103,8 @@ Section File /r "..\..\..\bin\webview2\*" SetOutPath "$INSTDIR\mcp-filesystem" File /r "..\..\..\bin\mcp-filesystem\*" + SetOutPath "$INSTDIR\mcp-mobile" + File /r "..\..\..\bin\mcp-mobile\*" SetOutPath $INSTDIR File "..\..\..\bin\runtime-manifest.json" File "..\..\..\bin\RUNTIME-NOTICES.txt" diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 5a4ee63..0e33fc4 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -36,6 +36,8 @@ const tab = ref(prefs.value.lastTab || 'workspace') const splitRatio = ref(prefs.value.splitRatio) const devices = ref([]) +const devicesRefreshing = ref(false) +const deviceSelecting = ref(false) const activeId = ref('') const previewStarting = ref(false) const connectBusy = ref(false) @@ -58,6 +60,8 @@ const agentStatus = ref | null>(null) const workspaceRef = ref(null) let dragging = false let liveTimer: ReturnType | null = null +let deviceRefresh: Promise | null = null +let unmounted = false const activeDevice = computed( () => devices.value.find((d) => d.id === activeId.value) ?? null, @@ -114,37 +118,66 @@ async function refreshAdb() { } } -async function refreshDevices() { +function refreshDevices(): Promise { + if (deviceRefresh) return deviceRefresh + deviceRefresh = updateDevices().finally(() => { deviceRefresh = null }) + return deviceRefresh +} + +async function updateDevices() { + devicesRefreshing.value = true try { const list = (await DeviceService.ListDevices()) as DeviceInfo[] + if (unmounted) return + const previous = activeDevice.value devices.value = list ?? [] if (activeId.value && !devices.value.some((d) => d.id === activeId.value)) { activeId.value = '' } - if (!activeId.value) { - const initial = devices.value.find((d) => d.online) ?? devices.value[0] - if (initial) activeId.value = initial.id + if (!connectBusy.value && (!activeId.value || (!activeDevice.value?.online && devices.value.some(d => d.online)))) { + const initial = devices.value.find(d => d.online && d.mode === 'usb') + ?? devices.value.find(d => d.online) ?? devices.value[0] + if (initial) { + await DeviceService.SelectDevice(initial.id) + if (unmounted) return + activeId.value = initial.id + } + } + const current = activeDevice.value + if (live.value && (!current?.online || previous?.id !== current.id)) await stopStream() + if (current?.mode === 'usb' && current.online && (!previous?.online || previous.id !== current.id) + && !connectBusy.value && !previewStarting.value) { + frameError.value = '' + await startStream() } - if (live.value && !activeDevice.value?.online) await stopStream() log(`devices: ${devices.value.length}`) } catch (e) { log(`ListDevices failed: ${String(e)}`) + } finally { + devicesRefreshing.value = false } } async function selectDevice(id: string) { - if (!id) return + if (!id || deviceSelecting.value) return false + deviceSelecting.value = true try { await DeviceService.SelectDevice(id) + const selected = await DeviceService.GetActiveDevice() as DeviceInfo + if (selected.id === id) devices.value = devices.value.map(d => d.id === id ? selected : d) activeId.value = id frameError.value = '' log(`select device ${id}`) if (live.value) { - if (activeDevice.value?.online) void startStream() + if (activeDevice.value?.online) await startStream() else await stopStream() } + return true } catch (e) { log(`SelectDevice failed: ${String(e)}`) + return false + } finally { + deviceSelecting.value = false } } @@ -290,13 +323,13 @@ async function onConnect(hostPort: string) { content: res.ok ? `连接结果:${res.message}` : `连接失败:${res.message}`, createdAt: Date.now(), }) + const connected = res.ok ? await DeviceService.GetActiveDevice() as DeviceInfo : null await refreshDevices() if (!res.ok) { openConnectionSettings(`连接未成功,请检查手机无线调试是否开启,并更新下方连接 IP 和端口(端口可能已变化)。${res.message}`) } if (res.ok) { - const connected = await DeviceService.GetActiveDevice() as DeviceInfo - if (connected.online) { + if (connected?.online) { await selectDevice(connected.id) setTab('workspace') await startStream() @@ -313,16 +346,41 @@ async function onConnect(hostPort: string) { async function onDeviceConnect(id: string) { const device = devices.value.find((d) => d.id === id) if (!device) { - openConnectionSettings('尚未选择可连接的设备。请先在手机上开启无线调试,首次连接需完成配对;已配对的手机请核对下方连接 IP 和端口后,点击「连接并看画面」。') + openConnectionSettings('可以使用 USB 数据线连接并允许 USB 调试,或在下方配置无线连接。') return } if (device.mode !== 'lan') { - openConnectionSettings('请检查 USB 连接,并在手机上允许 USB 调试后点击右上角「刷新」。也可以在下方配置无线连接。') + if (device.online || device.state === 'disconnected') await onUSBConnect(id) + else openConnectionSettings(device.state === 'unauthorized' + ? 'USB 设备尚未授权。请解锁手机,在「允许 USB 调试」提示中点击「允许」,然后点击「检测 USB 设备」。' + : 'USB 设备未就绪。请使用支持数据传输的数据线,并开启手机「开发者选项 → USB 调试」。') return } await onConnect(device.id) } +async function onUSBConnect(id: string) { + if (connectBusy.value || previewStarting.value) return + connectBusy.value = true + try { + await refreshDevices() + const device = devices.value.find(d => d.id === id && d.mode === 'usb') + if (!device || (!device.online && device.state !== 'disconnected')) { + openConnectionSettings('USB 设备尚未就绪,请检查数据线,并在手机上允许 USB 调试。') + return + } + if (!await selectDevice(id)) { + openConnectionSettings('选择 USB 设备失败,请检查数据线后重新检测设备。') + return + } + connectionNotice.value = '' + setTab('workspace') + if (!live.value) await startStream() + } finally { + connectBusy.value = false + } +} + async function onDisconnect(id: string) { if (connectBusy.value) return connectBusy.value = true @@ -363,6 +421,7 @@ onMounted(async () => { }) onUnmounted(() => { + unmounted = true systemAppearance.removeEventListener('change', applyTheme) void stopStream() window.removeEventListener('pointermove', onSplitPointerMove) @@ -376,9 +435,10 @@ onUnmounted(() => { :devices="devices" :active-id="activeId" :tab="tab" - :busy="connectBusy" + :busy="connectBusy || previewStarting || devicesRefreshing || deviceSelecting" + :processing="connectBusy" @select="selectDevice" - @refresh="refreshDevices" + @refresh="refreshDevices()" @disconnect="onDisconnect" @connect="onDeviceConnect" @update:tab="setTab" @@ -393,6 +453,7 @@ onUnmounted(() => { :loading="previewStarting" :live="live" :manual-control="manualControl" + :agent-operating="conversations.agentControlledDevices.value.has(activeId)" :error="frameError" :control-error="controlError" @touch="onPhoneTouch" @@ -421,14 +482,18 @@ onUnmounted(() => { diff --git a/frontend/src/components/chat/ChatPane.vue b/frontend/src/components/chat/ChatPane.vue index 44d1aa7..e181534 100644 --- a/frontend/src/components/chat/ChatPane.vue +++ b/frontend/src/components/chat/ChatPane.vue @@ -299,11 +299,11 @@ function onSubmit() { flex: 1; min-height: 0; overflow: auto; - padding: 20px 24px; + padding: 16px 24px; background: var(--bg-elevated); display: flex; flex-direction: column; - gap: 24px; + gap: 16px; } .bubble { @@ -316,7 +316,7 @@ function onSubmit() { align-self: flex-end; max-width: 85%; } -.bubble.user .message-body { width: fit-content; max-width: 100%; margin-left: auto; padding: 12px 16px; border-radius: 18px 18px 5px 18px; background: var(--bg); } +.bubble.user .message-body { width: fit-content; max-width: 100%; margin-left: auto; padding: 8px 12px; border-radius: 18px 18px 5px 18px; background: var(--bg); } .bubble.user .message-footer { justify-content: flex-end; padding-right: 4px; } .bubble.assistant, @@ -324,16 +324,16 @@ function onSubmit() { align-self: flex-start; } -.message-footer { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 8px; min-height: 30px; margin-top: 6px; } -.message-time { color: var(--text-muted); font-size: 13px; line-height: 1.8; white-space: nowrap; font-variant-numeric: tabular-nums; } +.message-footer { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 8px; min-height: 24px; margin-top: 4px; } +.message-time { color: var(--text-muted); font-size: 12px; line-height: 1.5; white-space: nowrap; font-variant-numeric: tabular-nums; } .message-actions { display: flex; align-items: center; gap: 2px; } -.message-action { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; padding: 0; border: 0; border-radius: 7px; background: transparent; color: var(--text-muted); cursor: pointer; } +.message-action { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 7px; background: transparent; color: var(--text-muted); cursor: pointer; } .message-action:hover:not(:disabled) { color: var(--text); background: var(--bg); }.message-action:disabled { opacity: .4; cursor: default; }.message-action:focus-visible, .edit-context button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } .copy-feedback { color: var(--text-muted); font-size: 13px; } .copy-error { color: var(--danger); font-size: 13px; line-height: 1.6; margin: 4px 0 0; } .edit-context { display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; gap: 12px; padding: 8px 12px; background: var(--accent-soft); color: var(--accent); font-size: 13px; } .edit-context button { display: inline-flex; align-items: center; gap: 5px; flex-shrink: 0; padding: 3px 4px; background: transparent; border: 0; color: inherit; font: inherit; cursor: pointer; } -.reasoning { margin-bottom: 12px; color: var(--text-muted); font-size: 14px; line-height: 1.8; } +.reasoning { margin-bottom: 8px; color: var(--text-muted); font-size: 13px; line-height: 1.6; } .reasoning summary { display: flex; align-items: center; gap: 8px; width: fit-content; cursor: pointer; list-style: none; border-radius: 4px; } .reasoning summary::-webkit-details-marker { display: none; } .reasoning summary::before { content: ''; width: 6px; height: 6px; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: rotate(-45deg); transition: transform .15s; margin-right: 3px; } @@ -356,8 +356,8 @@ function onSubmit() { .tool-step pre { max-height: 200px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font: 13px/1.7 var(--mono); margin: 8px 0 0; } .content { - font-size: 16px; - line-height: 1.8; + font-size: 14px; + line-height: 1.6; white-space: normal; word-break: break-word; } @@ -389,8 +389,8 @@ textarea { background: transparent; padding: 0 2px 8px; outline: 0; - font-size: 15px; - line-height: 1.7; + font-size: 14px; + line-height: 1.6; overflow-y: auto; } textarea::placeholder { color: var(--text-muted); } @@ -432,13 +432,13 @@ textarea::placeholder { color: var(--text-muted); } color: var(--text-muted); cursor: default; } -.bubble.assistant { width: 100%; }.bubble.system { max-width: 100%; }.bubble.system .content { font-size: 14px; color: var(--text-muted); line-height: 1.8; } +.bubble.assistant { width: 100%; }.bubble.system { max-width: 100%; }.bubble.system .content { font-size: 13px; color: var(--text-muted); line-height: 1.6; } .empty-chat { max-width: 400px; padding: 32px 20px; font-size: 15px; }.empty-chat strong { display: block; font-size: 21px; font-weight: 600; }.empty-chat p { margin: 14px 0 22px; }.empty-chat small { font-size: 13px; }.empty-icon { display: grid; place-items: center; width: 68px; height: 68px; margin: 0 auto 24px; border-radius: 20px; background: var(--accent-soft); color: var(--accent); } .composer-box { display: flex; flex-direction: column; min-width: 0; border: 0; border-radius: 16px; padding: 12px 12px 8px; outline: 0; background: var(--bg); } .send:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } .session-error, .message-state { font-size: 13px; }.tool-step { font-size: 13px; padding: 10px 12px; background: var(--bg-panel); } -@media (max-width: 1000px) { .list { padding: 16px 18px; gap: 22px; }.composer { padding: 6px 10px 10px; } } +@media (max-width: 1000px) { .list { padding: 14px 18px; }.composer { padding: 6px 10px 10px; } } @media (max-width: 480px) { .actions { gap: 8px; } } -@media (max-width: 480px) { .list { padding: 14px 12px; gap: 20px; }.bubble.user { max-width: 94%; }.bubble.user .message-body { padding: 10px 13px; } } +@media (max-width: 480px) { .list { padding: 12px; gap: 14px; }.bubble.user { max-width: 94%; } } @media (max-width: 760px) { .empty-chat { flex-shrink: 0; padding: 16px 12px; font-size: 13px; line-height: 1.7; }.empty-chat strong { font-size: 18px; }.empty-chat p { margin: 8px 0 0; }.empty-chat small { display: none; }.empty-icon { width: 42px; height: 42px; margin-bottom: 12px; border-radius: 13px; }.empty-icon .ui-icon { width: 24px; height: 24px; } } diff --git a/frontend/src/components/chat/ConversationList.vue b/frontend/src/components/chat/ConversationList.vue index b7dea1e..58097e6 100644 --- a/frontend/src/components/chat/ConversationList.vue +++ b/frontend/src/components/chat/ConversationList.vue @@ -181,7 +181,8 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--accent); ou .drawer-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; min-height: 28px; } .drawer-heading h3 { margin: 0; font-size: 14px; } .drawer-heading h3 span { margin-left: 6px; font-size: 14px; color: var(--text-muted); font-weight: 400; } -:global(:root[data-platform="macos"]) .drawer-heading { padding-left: 80px; min-height: 36px; } +/* Scope the heading, not :root: Vue's :global() discards the trailing selector. */ +:root[data-platform="macos"] .drawer-heading { padding-left: 80px; min-height: 36px; } .drawer-enter-active, .drawer-leave-active { transition: opacity .18s ease; } .drawer-enter-active .session-panel, .drawer-leave-active .session-panel { transition: transform .18s ease; } .drawer-enter-from, .drawer-leave-to { opacity: 0; } diff --git a/frontend/src/components/chat/MarkdownContent.vue b/frontend/src/components/chat/MarkdownContent.vue index 28dfe1f..94eeae4 100644 --- a/frontend/src/components/chat/MarkdownContent.vue +++ b/frontend/src/components/chat/MarkdownContent.vue @@ -12,15 +12,15 @@ const html = computed(() => renderMarkdown(props.source)) diff --git a/frontend/src/components/screen/CapturePane.vue b/frontend/src/components/screen/CapturePane.vue index c44eb74..13607fa 100644 --- a/frontend/src/components/screen/CapturePane.vue +++ b/frontend/src/components/screen/CapturePane.vue @@ -18,6 +18,7 @@ const props = defineProps<{ loading?: boolean live?: boolean manualControl?: boolean + agentOperating?: boolean error?: string controlError?: string }>() @@ -81,6 +82,7 @@ watch(() => [props.device?.id, props.live, props.streamUrl], () => { const controlAvailable = computed(() => !!(props.live && props.device?.online && naturalSize.value.width > 0 && !props.loading)) const canControl = computed(() => controlAvailable.value && props.manualControl) +const showAgentMask = computed(() => !!(props.agentOperating && props.live && props.device?.online && !props.manualControl)) let pointer: { id: number; image: HTMLImageElement; rect: DOMRect; screen: Size; deviceId: string; point: Point } | null = null let moveFrame: number | null = null function sendTouch(action: TouchEvent['action'], gesture: NonNullable) { @@ -251,23 +253,29 @@ onUnmounted(() => {
- +
+ +
+
+ AI 正在操作 + 点击上方小手可切换手动控制 +
+
+

{{ snapError || '正在拉取画面…' }}

@@ -277,12 +285,12 @@ onUnmounted(() => {

{{ error ? '画面获取失败' : '等待设备画面' }}

{{ error }}

-

{{ device?.online ? '设备已连接,启动预览即可查看实时画面。' : '在顶部连接手机,即可查看画面并进行操作。' }}

+

{{ device?.online ? '设备已连接,启动预览即可查看实时画面。' : device?.mode === 'usb' && device.state === 'unauthorized' ? '请解锁手机,在「允许 USB 调试」提示中点击「允许」。' : '连接 USB 数据线并允许调试,或在设置中配置无线连接。' }}

-
{{ live ? '实时预览' : '预览未启动' }}{{ canControl ? '手动控制 · 点击或拖动操作' : live ? '仅查看 · 点击小手开启控制' : '连接设备,开始你的工作' }}
+
{{ live ? '实时预览' : '预览未启动' }}{{ canControl ? '手动控制 · 点击或拖动操作' : showAgentMask ? 'AI 正在操作 · 点击小手切换手动控制' : live ? '仅查看 · 点击小手开启控制' : '连接设备,开始你的工作' }}
@@ -438,7 +446,16 @@ h2 { place-items: center; } +.frame-shell { + position: relative; + border-radius: 12px; + overflow: hidden; + box-shadow: var(--preview-shadow); +} + .frame { + width: 100%; + height: 100%; touch-action: auto; user-select: none; -webkit-user-select: none; @@ -448,12 +465,41 @@ h2 { max-height: none; object-fit: contain; border-radius: 12px; - box-shadow: var(--preview-shadow); } +.agent-mask { + position: absolute; + inset: 0; + display: flex; + align-items: flex-end; + justify-content: center; + padding: 16px 10px; + border: 2px solid var(--accent); + border-radius: inherit; + background: linear-gradient(180deg, rgba(12, 24, 48, .16) 55%, rgba(8, 16, 32, .55)); + pointer-events: none; +} +.agent-indicator { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + max-width: 100%; + padding: 10px 14px; + border: 1px solid rgba(255, 255, 255, .2); + border-radius: 12px; + background: rgba(12, 22, 40, .88); + color: #fff; + text-align: center; + line-height: 1.5; +} +.agent-label { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; } +.agent-label .ui-icon { color: #8dbbff; } +.agent-hint { font-size: 11px; color: #c4d2e8; } + .frame { cursor: default; } .frame.controllable { - cursor: default; + cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 24 24'%3E%3Cpath d='M4 4 21 10 13 13 10 21Z' fill='white' stroke='%235c6f80' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") 3 3, default; touch-action: none; } .control-error { diff --git a/frontend/src/components/settings/MCPSettings.vue b/frontend/src/components/settings/MCPSettings.vue index 79034a9..af06f1c 100644 --- a/frontend/src/components/settings/MCPSettings.vue +++ b/frontend/src/components/settings/MCPSettings.vue @@ -44,17 +44,6 @@ async function load() { catch (error) { loadError.value = errorText(error) } finally { loading.value = false } } -async function addFilesystem() { - if (dirty.value && !window.confirm('放弃尚未保存的 MCP 修改?')) return - pending.value = 'add-filesystem' - try { - const view = await MCPService.AddFilesystem() - servers.value = await MCPService.ListServers() || [] - editing.value = false - report(`Filesystem 已添加,允许访问:${(view.config.args || []).join('、')}。可在编辑中调整目录。`, true) - } catch (error) { report(errorText(error)) } - finally { pending.value = '' } -} async function testFiles(server: ServerView) { pending.value = `files:${server.config.id}` notice.value = '' @@ -179,7 +168,7 @@ onBeforeUnmount(() => testCall?.cancel())

MCP 管理

管理本地与远程工具服务,测试连接并查看可用工具。

-
+

已启用服务的工具会提供给对话 Agent。执行过程显示在会话中;停止生成会取消后续调用。截图工具返回的图片会发送给当前模型,模型需支持图片输入。

@@ -191,16 +180,16 @@ onBeforeUnmount(() => testCall?.cancel())
-
{{ server.config.name }}{{ server.config.transport === 'filesystem' ? '内置 · Filesystem' : server.config.transport === 'stdio' ? '本地 · stdio' : '远程 · HTTP' }}{{ server.config.enabled ? '已启用' : '已停用' }}
+
{{ server.config.name }}{{ server.config.transport === 'mobile' ? '系统内置 · Mobile Next' : server.config.transport === 'filesystem' ? '系统内置 · Filesystem' : server.config.transport === 'stdio' ? '本地 · stdio' : '远程 · HTTP' }}{{ server.config.enabled ? '已启用' : '已停用' }}
- +
-

{{ server.config.transport === 'filesystem' ? `允许目录:${(server.config.args || []).join('、')}` : server.config.transport === 'http' ? server.config.url : server.config.command }}

+

{{ server.config.transport === 'mobile' ? '手机截图、界面识别、点击、滑动与文字输入 · 随应用提供' : server.config.transport === 'filesystem' ? `允许目录:${(server.config.args || []).join('、')}` : server.config.transport === 'http' ? server.config.url : server.config.command }}