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 }}