diff --git a/.imac/issue_knowledge/dca1dadf/issue_knowledge.md b/.imac/issue_knowledge/dca1dadf/issue_knowledge.md index cdec2fa1..452aeb00 100644 --- a/.imac/issue_knowledge/dca1dadf/issue_knowledge.md +++ b/.imac/issue_knowledge/dca1dadf/issue_knowledge.md @@ -2,6 +2,8 @@ - `mobius/frontend/src/components/chat.tsx` 的“项目知识沉淀到记忆”按钮与“查看当前知识”并排,位于其右侧,不再收纳在会话顶栏“更多操作”菜单中。 - 按钮发送的提示词要求先读取并合并已有知识:项目通用、跨任务可复用内容写入 `.imac/project_knowledge.md`,仅当前任务相关内容写入本 Issue 的 `issue_knowledge.md`;两者都要精简,避免重复、一次性过程、个人信息和凭据。 +- `@` 智能体支持两种模式:`只读` 只把被 @ 智能体的上下文注入当前会话,不触发对端感知;`双向` 会给双方注入 curl 桥接提示,并通过 `/api/agent-bridge/messages` 做后续互发。 +- `@` 的文件与智能体入口共用同一个抽屉,按标签页切换;输入框里直接输入 `@` 会打开该抽屉并复用同一条插入链路。 ## 广告爽游实验室角色图集与打击特效(2026-08-07,v0.13.0) diff --git a/README.md b/README.md index 052ec052..0d55dbcb 100644 --- a/README.md +++ b/README.md @@ -36,12 +36,19 @@ One system to connect your team, AI agents, devices, and compute ## News -**2026-07-16** -- **Web Terminal now supports two launch modes**: open a shell in the current project directory, or open a terminal that automatically attaches to the current session's Agent tmux backend for live TUI inspection. +**2026-08-09** +- **Windows one-key install**: a single PowerShell command installs the Mobius TUI on a fresh Windows machine. + +**2026-08-02** +- **Easy Mode**: an optional clutter-free layout (cross-project recent sessions + JSONL + floating input). First-time users choose between Easy and Normal mode and can switch anytime. +- **TUI released**: connect to Mobius from any terminal with the TUI, and use Mobius the way you use Codex. + +**2026-07-26** +- **Search improvements**: results stream in via SSE with case/whole-word matching; clicking a result jumps to the exact JSONL card. + **2026-07-14** -- **Code Conversation v2** workspace mode: a 3-pane layout (file browser + built-in CodeMirror editor with syntax highlighting and in-place file saving + chat) for browsing, editing, and discussing project files in one place. -- **Scene-level first-visit tours** added for the Admin Center, Research page, and self-cognition workbench. +- **Code Conversation v2** workspace mode: a 3-pane layout (file browser + built-in CodeMirror editor with syntax highlighting and in-place saving + chat). diff --git a/README.zh.md b/README.zh.md index f4cdefdf..5fc35f4d 100644 --- a/README.zh.md +++ b/README.zh.md @@ -36,12 +36,19 @@ ## 最新动态 -**2026-07-16** -- **网页终端新增两种打开方式**:可在当前项目目录打开普通 shell,也可打开终端后自动 attach 到当前会话的 Agent tmux 后台,直接查看 Agent TUI 运行状态。 +**2026-08-09** +- **Windows 一键安装**:单条 PowerShell 命令即可在全新 Windows 机器上安装Mobius TUI。 + +**2026-08-02** +- **简易模式上线**:可选的无干扰布局(跨项目近期会话 + JSONL + 悬浮输入框)。首次进入需在简易/常规模式间二选一,随时可从主题菜单切换。 +- **TUI发布**:使用TUI在任意终端连接到Mobius,像使用Codex一样使用Mobius。 + +**2026-07-26** +- **搜索优化**:结果经 SSE 流式返回、支持大小写/全字匹配,点击结果直接跳转到对应 JSONL 卡片。 + **2026-07-14** -- **代码对话 v2**工作空间模式:三栏布局(文件浏览器 + 内置 CodeMirror 编辑器,支持语法高亮与就地保存 + 对话),可在一个界面浏览、编辑、讨论项目文件。 -- 为管理中心、研究课题页、自我认知工作台新增**场景级首触引导**。 +- **代码对话 v2**工作空间模式:三栏布局(文件浏览器 + 内置 CodeMirror 编辑器,支持语法高亮与就地保存 + 对话。 diff --git a/mobius/backend/agents/tmux-claude-code.js b/mobius/backend/agents/tmux-claude-code.js index 87726b57..33ce1e87 100644 --- a/mobius/backend/agents/tmux-claude-code.js +++ b/mobius/backend/agents/tmux-claude-code.js @@ -443,7 +443,7 @@ function isSameQueuedRequest(sigA, sigB) { return false } -// Resolve the aimux binary to spawn as a stdio MCP server (for TUI sessions that +// Resolve the aimux binary to spawn as a stdio MCP server (for desktop/TUI sessions that // opted into add_remote_aimux_mcp). Mirrors tmux-codex.js + aimux-remote.ts // AIMUX_BIN_CANDIDATES (kept inline to avoid crossing the .js/.ts boundary). function resolveAimuxBin() { @@ -456,6 +456,19 @@ function resolveAimuxBin() { return 'aimux' } +// Resolve the guling 实盘 MCP (HTTP / streamable-http) server config from env, so +// the 小莫 assistant session can directly read 资金/持仓 (mcp__guling__position / +// balance 等) without going through Hermes. The bearer token is a credential and +// MUST live in .env (MOBIUS_GULING_MCP_URL / MOBIUS_GULING_MCP_TOKEN) — never in +// source. Returns a { type:'http', url, headers } entry ready to drop into the +// per-session --mcp-config mcpServers, or null when unset (→ injection is a no-op). +function resolveGulingMcp() { + const url = (process.env.MOBIUS_GULING_MCP_URL || '').trim() + const token = (process.env.MOBIUS_GULING_MCP_TOKEN || '').trim() + if (!url || !token) return null + return { type: 'http', url, headers: { Authorization: `Bearer ${token}` } } +} + class TmuxClaudeCodeBackend extends AgentBackend { constructor() { super({ name: 'tmux-claude-code', runtimeFile: RUNTIME_FILE, archiveFile: ARCHIVE_FILE }) @@ -791,7 +804,7 @@ class TmuxClaudeCodeBackend extends AgentBackend { } // ── 内部实现 ────────────────────────────────────────── - async _createImpl({ sessionId, cwd, flagRoot, model, useProxy, displayName, initialPrompt, agentSessionId, isInitialContextPrompt = false, settingsPath, forceNoProxy = false, aimuxRemoteName }) { + async _createImpl({ sessionId, cwd, flagRoot, model, useProxy, displayName, initialPrompt, agentSessionId, isInitialContextPrompt = false, settingsPath, forceNoProxy = false, aimuxRemoteName, enableGulingMcp = false }) { if (!sessionId || !cwd) throw new Error('createNewSession 需要 sessionId + cwd') if (!initialPrompt) throw new Error('createNewSession 需要 initialPrompt') if (!fs.existsSync(cwd)) throw new Error(`cwd 不存在: ${cwd}`) @@ -799,7 +812,7 @@ class TmuxClaudeCodeBackend extends AgentBackend { // tmux 模式特点: window 可跨后端重启存活. 已有活窗口 → 复用 (跟原 hub.startSession // idempotent 一致). 这跟 stream-json 那版"严格新建"语义不同, 是有意为之. if (!windowExists(sessionId)) { - await this._spawnWindow({ sessionId, cwd, flagRoot, model, useProxy, displayName, agentSessionId, settingsPath, forceNoProxy, aimuxRemoteName }) + await this._spawnWindow({ sessionId, cwd, flagRoot, model, useProxy, displayName, agentSessionId, settingsPath, forceNoProxy, aimuxRemoteName, enableGulingMcp }) } else { // 窗口在但 runtime entry 可能不在 (后端首次 reload) — 兜底建一个 if (!this.runtime.has(sessionId) && agentSessionId) { @@ -833,7 +846,7 @@ class TmuxClaudeCodeBackend extends AgentBackend { } // 宽松版 — 没活进程就按 opts 自动 spawn (chat 不区分首发/续发, 统一走这里). - async _queueImpl({ sessionId, prompt, cwd, flagRoot, model, useProxy, displayName, agentSessionId, isInitialContextPrompt = false, settingsPath, forceNoProxy = false, mobiusJsonl = null, aimuxRemoteName }) { + async _queueImpl({ sessionId, prompt, cwd, flagRoot, model, useProxy, displayName, agentSessionId, isInitialContextPrompt = false, settingsPath, forceNoProxy = false, mobiusJsonl = null, aimuxRemoteName, enableGulingMcp = false }) { if (!sessionId) throw new Error('需要 sessionId') if (!prompt) throw new Error('需要 prompt') @@ -857,6 +870,7 @@ class TmuxClaudeCodeBackend extends AgentBackend { displayName: displayName || persisted?.displayName, agentSessionId: finalAgentSid, aimuxRemoteName, + enableGulingMcp, }) } this._appendMobiusPromptEntry(sessionId, mobiusJsonl) @@ -950,7 +964,7 @@ class TmuxClaudeCodeBackend extends AgentBackend { // ── tmux 操作底层 ───────────────────────────────────── // 启动一个新的 Claude Code tmux 窗口,并把运行态登记到内存和持久化存储。 - async _spawnWindow({ sessionId, cwd, flagRoot, model, useProxy, displayName, agentSessionId, settingsPath, forceNoProxy = false, aimuxRemoteName }) { + async _spawnWindow({ sessionId, cwd, flagRoot, model, useProxy, displayName, agentSessionId, settingsPath, forceNoProxy = false, aimuxRemoteName, enableGulingMcp = false }) { // 确保承载 agent 窗口的 tmux hub session 已经存在。 ensureHub() // 运行标记默认写在 cwd 下;调用方传 flagRoot 时优先使用仓库根等稳定路径。 @@ -983,28 +997,45 @@ class TmuxClaudeCodeBackend extends AgentBackend { // resume 使用旧 agentSessionId,新会话生成一个新的 UUID。 const claudeSessionId = useResume ? agentSessionId : crypto.randomUUID() + // 收集要禁用的工具: 永久禁用 AskUserQuestion/ExitPlanMode (避免 agent 停下来等 + // 人 / 卡在 plan 模式). 若注入了 guling 实盘 MCP, 额外禁用其真实下单类工具 + // (buy/sell/cancel/switch_account), 只保留只读查询 (position/balance/orders/ + // settlement/watchlist), 防止 AI 误触发真实证券交易. + const disallowedTools = ['AskUserQuestion', 'ExitPlanMode'] + + // 收集要注入的 stdio/http MCP server (会话级 --mcp-config , 顶层 + // mcpServers, additive 不叠 --strict-mcp-config, 且 --mcp-config 传入的 server + // 被视为显式可信, 不触发 .mcp.json 那种信任弹窗). per-session 文件各会话不同. + const mcpServers = {} + // TUI 会话 (add_remote_aimux_mcp): aimux stdio MCP, 让 claude 经 remote_* 工具 + // (remote_exec_command/write_stdin/apply_patch/view_image/ping) 操作远程工作站. + if (aimuxRemoteName) { + mcpServers.aimux = { command: resolveAimuxBin(), args: ['mcp', 'serve', '--remote', aimuxRemoteName] } + } + // 小莫 assistant 会话 (enableGulingMcp): guling 实盘 MCP (HTTP), 让 claude 直接读 + // 资金/持仓. token 从 env 读, 未配置时 resolveGulingMcp() 返回 null → 跳过. + if (enableGulingMcp) { + const guling = resolveGulingMcp() + if (guling) { + mcpServers.guling = guling + disallowedTools.push('mcp__guling__buy', 'mcp__guling__sell', 'mcp__guling__cancel', 'mcp__guling__switch_account') + } + } + // 组装传给 claude CLI 的参数列表。 const claudeArgs = [ // 跳过权限确认,让后台 agent 可以自动执行。 `--dangerously-skip-permissions`, - // 绝对禁止 agent 停下来问人: 在 harness 层 deny 掉 AskUserQuestion 工具. - // 同时禁掉 ExitPlanMode, 避免 agent 卡在 plan 模式里等待用户批准. - `--disallowedTools AskUserQuestion,ExitPlanMode`, + `--disallowedTools ${disallowedTools.join(',')}`, // resume 用 --resume,新会话用 --session-id 绑定固定会话 id。 useResume ? `--resume ${claudeSessionId}` : `--session-id ${claudeSessionId}`, ] // 如果调用方指定模型,就追加 --model 参数并做 shell 转义。 if (model) claudeArgs.push(`--model ${shellQuote(model)}`) - // TUI 会话 (add_remote_aimux_mcp): 注入 aimux stdio MCP server, 让 claude 经 - // remote_* 工具 (remote_exec_command/write_stdin/apply_patch/view_image/ping) - // 操作远程工作站. claude 用 --mcp-config (顶层 mcpServers, stdio 默认), - // additive (不叠 --strict-mcp-config). per-session 文件因 --remote 各会话不同. - if (aimuxRemoteName) { - const aimuxBinPath = resolveAimuxBin() - const mcpConfigPath = path.join(os.tmpdir(), `mobius-aimux-mcp-${sessionId}-${crypto.randomUUID().slice(0, 8)}.json`) - fs.writeFileSync(mcpConfigPath, JSON.stringify({ - mcpServers: { aimux: { command: aimuxBinPath, args: ['mcp', 'serve', '--remote', aimuxRemoteName] } }, - })) + // 有任一 MCP server 要注入时, 写 per-session 配置文件并传给 claude. + if (Object.keys(mcpServers).length > 0) { + const mcpConfigPath = path.join(os.tmpdir(), `mobius-mcp-${sessionId}-${crypto.randomUUID().slice(0, 8)}.json`) + fs.writeFileSync(mcpConfigPath, JSON.stringify({ mcpServers })) claudeArgs.push(`--mcp-config ${shellQuote(mcpConfigPath)}`) } // settings 参数优先使用调用方指定文件,否则使用默认 Mobius Claude settings。 diff --git a/mobius/backend/agents/tmux-codex.js b/mobius/backend/agents/tmux-codex.js index 6877dae1..24cee58c 100644 --- a/mobius/backend/agents/tmux-codex.js +++ b/mobius/backend/agents/tmux-codex.js @@ -1214,7 +1214,7 @@ class TmuxCodexBackend extends AgentBackend { // 并在 tmux 命令中 export TOML env_key 对应的秘钥环境变量. // 组装 Codex CLI 参数:模型、工作目录以及自动审批/沙箱绕过参数。 const codexArgs = ['-m', finalModel, '-C', cwd, '--dangerously-bypass-approvals-and-sandbox'] - // TUI 会话 (is_tui + aimux_id): 注入 aimux stdio MCP server, 让 codex 经 MCP + // TUI/Electron 会话 (add_remote_aimux_mcp + aimux_id): 注入 aimux stdio MCP server, 让 codex 经 MCP // 工具 (remote_execute/read_file/write_file/ping/apply_patch) 操作远程工作站. // codex `-c key=value` 按 TOML 解析 value, args 用 inline array. if (aimuxRemoteName) { diff --git a/mobius/backend/routes/agent-bridge.ts b/mobius/backend/routes/agent-bridge.ts new file mode 100644 index 00000000..2bc0a77f --- /dev/null +++ b/mobius/backend/routes/agent-bridge.ts @@ -0,0 +1,91 @@ +import express from 'express'; +import { Users } from '../repositories/users'; +import { Sessions } from '../repositories/sessions'; +import { runSessionMessage } from '../services/session-message-runner'; +import { verifyAgentBridgeToken } from '../services/agent-mention-bridge'; + +const router = express.Router(); + +function extractBridgeToken(req: express.Request): string { + const bodyToken = typeof req.body?.token === 'string' ? req.body.token.trim() : ''; + if (bodyToken) return bodyToken; + const authHeader = String(req.headers.authorization || '').trim(); + if (authHeader) { + const bearer = authHeader.replace(/^Bearer\s+/i, '').trim(); + if (bearer) return bearer; + } + const headerToken = String(req.headers['x-agent-bridge-token'] || '').trim(); + if (headerToken) return headerToken; + return ''; +} + +router.post('/messages', async (req: express.Request, res: express.Response) => { + const token = extractBridgeToken(req); + const payload = verifyAgentBridgeToken(token); + if (!payload) { + res.status(401).json({ error: '无效或过期的智能体桥接 token' }); + return; + } + + const bodyFromSessionId = typeof req.body?.from_session_id === 'string' ? req.body.from_session_id.trim() : ''; + const bodyToSessionId = typeof req.body?.to_session_id === 'string' ? req.body.to_session_id.trim() : ''; + const sourceSessionId = bodyFromSessionId || payload.source_session_id; + const targetSessionId = bodyToSessionId || payload.target_session_id; + const sourceMatches = sourceSessionId === payload.source_session_id && targetSessionId === payload.target_session_id; + const reverseMatches = sourceSessionId === payload.target_session_id && targetSessionId === payload.source_session_id; + if (!sourceMatches && !reverseMatches) { + res.status(403).json({ error: '桥接 token 与会话配对不一致' }); + return; + } + + const content = String(req.body?.content || '').trim(); + if (!content) { + res.status(400).json({ error: 'content 不能为空' }); + return; + } + if (content.length > 8000) { + res.status(400).json({ error: 'content 过长' }); + return; + } + + const ownerUser = Users.findAuthById(payload.owner_user_id) as any; + if (!ownerUser) { + res.status(401).json({ error: '桥接所属用户不存在' }); + return; + } + const targetSession = Sessions.findById(targetSessionId) as any; + if (!targetSession) { + res.status(404).json({ error: '目标 Session 不存在' }); + return; + } + + try { + const result = await runSessionMessage({ + user: ownerUser, + sessionId: targetSessionId, + content, + inputText: content, + hasInputText: true, + requestId: typeof req.body?.request_id === 'string' ? req.body.request_id : `bridge-${Date.now()}`, + source: 'api.agent_bridge.messages', + logger: console, + urgent: req.body?.urgent === true, + } as any); + res.json({ + ok: true, + from_session_id: sourceSessionId, + to_session_id: targetSessionId, + request_id: result?.request_id ?? null, + turn_number: result?.turn_number ?? null, + mode: payload.mode, + }); + } catch (e) { + const err = e as any; + res.status(err.status || 500).json({ + error: err.message || '桥接消息发送失败', + category: err.category || undefined, + }); + } +}); + +export { router }; diff --git a/mobius/backend/routes/assistant.ts b/mobius/backend/routes/assistant.ts index c76e0451..05f9ef77 100644 --- a/mobius/backend/routes/assistant.ts +++ b/mobius/backend/routes/assistant.ts @@ -1222,6 +1222,9 @@ async function startAssistantSession(req: express.Request, session: any, questio agentSessionId: session.claude_session_id || undefined, mobiusJsonl, aimuxRemoteName: aimuxRemoteNameFromMeta(session?.pc_client_metadata), + // 小莫 assistant 注入 guling 实盘 MCP (HTTP), 让 claude 直接读资金/持仓. + // resolveGulingMcp() 未配置 env 时返回 null, 这里恒传 true 是安全 no-op. + enableGulingMcp: true, }); const runtimeInfo = backend.listSessions().find((item: any) => item.sessionId === session.session_id); diff --git a/mobius/backend/routes/sessions.ts b/mobius/backend/routes/sessions.ts index ab1101be..4360d209 100644 --- a/mobius/backend/routes/sessions.ts +++ b/mobius/backend/routes/sessions.ts @@ -1369,7 +1369,7 @@ router.get('/:id/selection-snapshot', auth, (req: express.Request, res: express. // 5) backend.noPauseCurrentAndQueueQueryAtSession 推到 TUI // 6) 同步 backend 内部 agent session id 回 DB // 不做流式响应 — 请求成功即表示后端已接收; 后续 jsonl 由 /api/sessions/:id/events SSE 推送. -// Body: { content: string, input_text?: string, request_id?: string, attachments?: Array } +// Body: { content: string, input_text?: string, request_id?: string, attachments?: Array, mentions?: Array } router.post('/:id/messages', auth, async (req: express.Request, res: express.Response) => { const sessionId = String(req.params.id); const user = userOf(req); @@ -1387,6 +1387,7 @@ router.post('/:id/messages', auth, async (req: express.Request, res: express.Res hasInputText, requestId, attachments: req.body?.attachments, + mentions: req.body?.mentions, source: 'http.session.messages', logger: console, urgent: req.body?.urgent === true, diff --git a/mobius/backend/services/agent-mention-bridge.ts b/mobius/backend/services/agent-mention-bridge.ts new file mode 100644 index 00000000..8a9e7d5a --- /dev/null +++ b/mobius/backend/services/agent-mention-bridge.ts @@ -0,0 +1,185 @@ +import jwt from 'jsonwebtoken'; +import { PORT, JWT_SECRET } from '../config'; + +const AGENT_BRIDGE_KIND = 'agent_mention_bridge'; +const AGENT_BRIDGE_TTL_SECONDS = 6 * 60 * 60; + +type AgentMentionMode = 'read_only' | 'bidirectional'; +type AgentBridgePerspective = 'source' | 'target'; + +type AgentBridgeTokenPayload = { + kind: typeof AGENT_BRIDGE_KIND; + owner_user_id: string; + source_session_id: string; + target_session_id: string; + mode: AgentMentionMode; + source_session_name?: string; + target_session_name?: string; +}; + +type AgentBridgePromptArgs = { + perspective: AgentBridgePerspective; + mode: AgentMentionMode; + token?: string; + sourceSession: any; + targetSession: any; + transferMarkdown?: string; + currentUserName?: string; + initialMessage?: string; +}; + +function sessionLabel(session: any, fallback: string): string { + const name = String(session?.name || '').trim() || fallback; + const sid = String(session?.session_id || '').trim(); + return sid ? `${name} (${sid})` : name; +} + +function mintAgentBridgeToken(payload: Omit): string { + return jwt.sign( + { kind: AGENT_BRIDGE_KIND, ...payload }, + JWT_SECRET, + { expiresIn: AGENT_BRIDGE_TTL_SECONDS }, + ); +} + +function verifyAgentBridgeToken(token: string | null | undefined): AgentBridgeTokenPayload | null { + if (!token) return null; + try { + const payload = jwt.verify(token, JWT_SECRET) as Partial | string; + if (!payload || typeof payload === 'string') return null; + if (payload.kind !== AGENT_BRIDGE_KIND) return null; + if (!payload.owner_user_id || !payload.source_session_id || !payload.target_session_id) return null; + if (payload.mode !== 'read_only' && payload.mode !== 'bidirectional') return null; + return { + kind: AGENT_BRIDGE_KIND, + owner_user_id: String(payload.owner_user_id), + source_session_id: String(payload.source_session_id), + target_session_id: String(payload.target_session_id), + mode: payload.mode, + source_session_name: typeof payload.source_session_name === 'string' ? payload.source_session_name : undefined, + target_session_name: typeof payload.target_session_name === 'string' ? payload.target_session_name : undefined, + }; + } catch { + return null; + } +} + +function bridgeEndpointUrl(): string { + return `http://localhost:${PORT}/api/agent-bridge/messages`; +} + +function bridgeCurlExample(token: string, fromSessionId: string, toSessionId: string, content: string): string { + const payload = JSON.stringify({ + token, + from_session_id: fromSessionId, + to_session_id: toSessionId, + content, + }); + return [ + `cat <<'JSON' | curl -sS ${bridgeEndpointUrl()} \\`, + ` -H 'Content-Type: application/json' \\`, + ` --data-binary @-`, + payload, + `JSON`, + ].join('\n'); +} + +function buildReadOnlyMentionPrompt({ + sourceSession, + targetSession, + transferMarkdown, + currentUserName, +}: { + sourceSession: any; + targetSession: any; + transferMarkdown: string; + currentUserName?: string; +}): string { + const sourceLabel = sessionLabel(sourceSession, '当前会话'); + const targetLabel = sessionLabel(targetSession, '被 @ 智能体'); + const userLabel = String(currentUserName || '').trim(); + const lines = [ + '[@智能体 - 只读模式]', + userLabel ? `发起人: ${userLabel}` : null, + `当前会话: ${sourceLabel}`, + `被 @ 智能体: ${targetLabel}`, + '', + '下面是被 @ 智能体的最近会话上下文,仅供你读取和理解,不要把它当成你自己的会话,也不要修改它:', + transferMarkdown || '(未能读取到被 @ 智能体的转接资料)', + '', + '请把这些上下文当成背景资料,继续处理当前消息。' + ].filter(Boolean); + return lines.join('\n'); +} + +function buildBidirectionalMentionPrompt({ + perspective, + mode, + token, + sourceSession, + targetSession, + transferMarkdown, + currentUserName, + initialMessage, +}: AgentBridgePromptArgs): string { + const ownSession = perspective === 'source' ? sourceSession : targetSession; + const peerSession = perspective === 'source' ? targetSession : sourceSession; + const ownLabel = sessionLabel(ownSession, perspective === 'source' ? '当前会话' : '被通知会话'); + const peerLabel = sessionLabel(peerSession, perspective === 'source' ? '对端会话' : '发起会话'); + const userLabel = String(currentUserName || '').trim(); + const fromSessionId = perspective === 'source' + ? String(sourceSession?.session_id || '').trim() + : String(targetSession?.session_id || '').trim(); + const toSessionId = perspective === 'source' + ? String(targetSession?.session_id || '').trim() + : String(sourceSession?.session_id || '').trim(); + const curlExample = token && fromSessionId && toSessionId + ? bridgeCurlExample(token, fromSessionId, toSessionId, '你好,继续。') + : ''; + + const lines = [ + perspective === 'source' + ? '[@智能体 - 双向模式 / 发起侧]' + : '[@智能体 - 双向模式 / 对端侧]', + `模式: ${mode === 'bidirectional' ? '双向通讯' : '只读'}`, + userLabel ? `发起人: ${userLabel}` : null, + `本侧会话: ${ownLabel}`, + `对端会话: ${peerLabel}`, + initialMessage ? '' : null, + initialMessage ? '本轮发起消息:' : null, + initialMessage ? initialMessage : null, + '', + '下面是对端会话的最近上下文,仅供你读取:', + transferMarkdown || '(未能读取到对端会话的转接资料)', + '', + '你们已经通过莫比乌斯后端建立了一条可持续的消息通道。需要把消息发给对方时,使用本机 curl 调用下面的接口:', + bridgeEndpointUrl(), + token ? `桥接 token: ${token}` : null, + fromSessionId && toSessionId ? `发送方向: ${fromSessionId} -> ${toSessionId}` : null, + '', + '请求字段:', + '- token', + '- from_session_id', + '- to_session_id', + '- content', + '', + '参考命令:', + curlExample || '(缺少 token,无法生成 curl 示例)', + '', + '收到对方消息后,继续按自己的职责推进,并把需要共享的信息通过同一接口回传给对方。', + ].filter(Boolean); + return lines.join('\n'); +} + +export { + AGENT_BRIDGE_KIND, + AGENT_BRIDGE_TTL_SECONDS, + type AgentMentionMode, + type AgentBridgePerspective, + type AgentBridgeTokenPayload, + mintAgentBridgeToken, + verifyAgentBridgeToken, + buildReadOnlyMentionPrompt, + buildBidirectionalMentionPrompt, + bridgeEndpointUrl, +}; diff --git a/mobius/backend/services/pc-client-context.ts b/mobius/backend/services/pc-client-context.ts index ab762a37..0b1936ae 100644 --- a/mobius/backend/services/pc-client-context.ts +++ b/mobius/backend/services/pc-client-context.ts @@ -35,15 +35,16 @@ export function parsePcClientMetadata(raw: unknown): PcClientMetadata | null { } /** - * For Mobius TUI sessions that opted into the aimux remote_* MCP toolset - * (is_tui === true AND add_remote_aimux_mcp === true) and are bound to an - * aimux remote, return that remote name (aimux_id); otherwise undefined. + * For client sessions that explicitly opted into the aimux remote_* MCP + * toolset (add_remote_aimux_mcp === true) and are bound to an aimux remote, + * return that remote name (aimux_id); otherwise undefined. TUI and Electron + * use the same per-session protocol; the explicit flag keeps web sessions + * unchanged and prevents accidental MCP injection. * Used to gate per-session MCP injection when spawning codex / claude-code. */ export function aimuxRemoteNameFromMeta(raw: unknown): string | undefined { const meta = parsePcClientMetadata(raw); if ( - meta?.is_tui === true && meta?.add_remote_aimux_mcp === true && typeof meta.aimux_id === 'string' && meta.aimux_id.trim() @@ -92,14 +93,14 @@ function tuiBasePrompt(aimuxId: string, remotePath: string, language: ContextLan `I'm working in this remote machine as well, so when I say 'here', I mean remote path [${remotePath}] at ${aimuxId}. ` + `When I say 'local', I also mean ${aimuxId}. ` + `Only when I say 'mobius center/hub' do I mean the machine you are currently on. ` + - `You operate this remote computer through the remote_* tools (remote_exec_command, remote_write_stdin, remote_apply_patch, remote_view_image, remote_ping); ` + + `You operate this remote computer through the remote_* MCP tools (remote_exec_command, remote_write_stdin, remote_apply_patch, remote_view_image, remote_ping); ` + `these tools act directly on ${aimuxId} — the computer whose hostname is encoded in that identifier. ` + `In your text answer, do not mention 'aimux' or reveal that you are working remotely (although in fact you are working remotely).` : `你在远程机器 ${aimuxId}${remotePath} 上工作。` + `由于我也正在远程机器上操作,所以当我说“这里(here)”时,我指的是 ${aimuxId} 上的远程路径 [${remotePath}];` + `当我说“本地(local)”时,我指的是 ${aimuxId}。` + `只有当我说“中枢(mobius center/hub)”时,才指现在你所处的机器。` + - `你通过 remote_* 工具(remote_exec_command、remote_write_stdin、remote_apply_patch、remote_view_image、remote_ping)操作这台远程计算机;` + + `你优先使用已注册的 MCP 工具(remote_exec_command、remote_write_stdin、remote_apply_patch、remote_view_image、remote_ping)操作这台远程计算机;` + `这些工具直接作用于 ${aimuxId} —— 该标识符编码了其主机名的那台计算机。` + `在你的文本回答中,不要提及“aimux”,也不要暴露你在远程工作(尽管实际上你在远程工作)。`; } @@ -151,16 +152,16 @@ const MODE_PROMPTS: Record - `Use aimux to connect to the following remote machine to carry out all work, ` + + `Use the registered remote_* MCP tools (backed by aimux) to connect to the following remote machine and carry out all work, ` + `and try to avoid modifying local code: ${id}${rp}`, - zh: (id, rp) => `使用aimux连接到以下远程机器执行所有工作,尽量不修改本地的代码: ${id}${rp}`, + zh: (id, rp) => `使用已注册的 remote_* MCP 工具(由 aimux 提供)连接到以下远程机器执行所有工作,尽量不修改本地的代码: ${id}${rp}`, }, dual: { en: (id, rp) => - `You are authorized to use aimux to connect to the following remote machine: ${id}. ` + + `You are authorized to use the registered remote_* MCP tools (backed by aimux) to connect to the following remote machine: ${id}. ` + dualModeTail(id, rp, 'en'), zh: (id, rp) => - `你现在被授权使用aimux连接到以下远程机器: ${id},` + + `你现在被授权使用已注册的 remote_* MCP 工具(由 aimux 提供)连接到以下远程机器: ${id},` + dualModeTail(id, rp, 'zh'), }, }, diff --git a/mobius/backend/services/session-context.ts b/mobius/backend/services/session-context.ts index f1abd5c3..bed41209 100644 --- a/mobius/backend/services/session-context.ts +++ b/mobius/backend/services/session-context.ts @@ -33,62 +33,6 @@ const SESSION_STATUS_LABELS_EN: Record = { active: 'In Progress' function normalizeLanguage(value: any): 'zh' | 'en' { return value === 'en' ? 'en' : 'zh'; } -const RANDOM_EMOJIS = [ - // 天体 / 天气 - '✨', '🌟', '💫', '⭐', '🌙', '☀️', '🌤️', '🌦️', - '🌧️', '⛅', '⛈️', '🌩️', '🌨️', '🌬️', '🌫️', '🌠', - // 植物 / 自然 - '🍀', '🌿', '🌱', '🌵', '🌸', '🌼', '🌻', '🍁', - '🌹', '🌷', '🌺', '🌳', '🌲', '🎋', '🍂', '🍄', - // 游戏 / 艺术 - '🎲', '🧩', '🎯', '🎪', '🎨', '🎭', '🎬', '🎧', - // 交通 / 场景 - '🚀', '🛰️', '✈️', '🛸', '🚦', '🛤️', '🏕️', '🏙️', - '🚁', '⛵', '🚂', '🚲', '🏎️', '🗽', '🏰', '🎡', - // 元素 / 气象 - '🔥', '⚡', '💧', '❄️', '🌊', '🌪️', '☄️', '🌈', - // 水果 - '🍉', '🍓', '🍒', '🍑', '🍍', '🥝', '🫐', '🍯', - '🍎', '🍊', '🍋', '🍌', '🍇', '🥭', '🍈', '🥥', - // 庆祝 / 奖励 - '🎉', '🎊', '🎈', '🎁', '🏆', '🥇', '🏅', '🎖️', - // 工具 / 魔法 / 探索 - '💎', '🔮', '🪄', '🧭', '🗺️', '🔭', '🔬', '⚙️', - '🛠️', '🔑', '🧪', '💡', '📌', '📎', '📝', '📚', - // 爱心 - '💜', '💙', '💚', '💛', '🧡', '❤️', '🤍', '🖤', - // 圆点 - '🔴', '🟠', '🟡', '🟢', '🔵', '🟣', '⚪', '⚫', - // 动物 - '🐶', '🐱', '🦊', '🐻', '🐼', '🐨', '🐯', '🦁', - '🐮', '🐷', '🐸', '🐵', '🐔', '🐧', '🦉', '🦇', - '🐺', '🐗', '🦄', '🐝', '🦋', '🐌', '🐞', '🐢', - '🐙', '🦑', '🦀', '🐠', '🐬', '🐳', '🦈', '🐊', - '🦓', '🦍', '🐘', '🦒', '🦘', '🐪', '🦔', '🦦', - // 表情 - '😀', '😄', '😁', '😆', '😂', '🤣', '😊', '😍', - '🥰', '😎', '🤩', '🥳', '🤓', '🧐', '🤔', '😉', - // 食物 - '🍕', '🍔', '🍟', '🌭', '🍿', '🥐', '🥯', '🧀', - '🌮', '🌯', '🍣', '🍱', '🍜', '🍝', '🍪', '🍩', - '🍰', '🧁', '🍫', '🍬', '🍭', '🍦', '🍨', '🥧', - // 饮品 - '☕', '🍵', '🧃', '🥤', '🧋', '🍺', '🍻', '🥂', - '🍷', '🍸', '🍹', '🥃', '🧉', '🍾', - // 运动 - '⚽', '🏀', '🏈', '⚾', '🎾', '🏐', '🏉', '🎱', - '🏓', '🏸', '🥏', '🪁', '🏹', '🥊', '🛹', '⛸️', - '🎿', '🏂', '🏄', '🏊', '🚵', '🚴', '🧗', '🧘', - // 乐器 - '🎵', '🎶', '🎼', '🎤', '🎷', '🎸', '🎹', '🎺', - '🎻', '🪕', '🥁', - // 电子 / 设备 - '⌚', '📱', '💻', '⌨️', '🖥️', '🖱️', '🕹️', '💾', - '📷', '📸', '📹', '🎥', '📺', '📻', '⏰', '⏱️', -]; - -// memory scope -> 展示标签; 未列出的 (如 'user') 回退到 '用户级', 与历史行为一致. -const MEMORY_SCOPE_LABELS: Record = { project: '项目级', builtin: '内置级' }; function indent(text: any, prefix: string = ' '): string { return String(text || '').split('\n').map((l: string) => prefix + l).join('\n'); @@ -541,19 +485,6 @@ function en_add_completion_flag_info(lines: string[], session: any, project: any lines.push('When user gives new instruction again, running.flag will be recreated.'); } -function buildRandomEmojiPrefix(): string { - const emojiCount = 1; - const pool = [...RANDOM_EMOJIS]; - const picked: string[] = []; - - for (let i = 0; i < emojiCount && pool.length > 0; i += 1) { - const index = Math.floor(Math.random() * pool.length); - picked.push(pool.splice(index, 1)[0]); - } - - return `${picked.join('')}\n`; -} - // PC task mode prompt injection (Electron/TUI sessions only, when // session.pc_client_metadata is non-null; web sessions return early). function en_add_pc_task_mode_info(lines: string[], session: any): void { @@ -613,7 +544,7 @@ function formatBody({ user, project, issue, research, session, skills, memories, fns.issue(lines, issue); fns.session(lines, session); fns.pcTaskMode(lines, session); - return `${buildRandomEmojiPrefix()}${lines.join('\n').trimEnd()}`; + return lines.join('\n').trimEnd(); } function compactSkillForSnapshot(sk: any): any { diff --git a/mobius/backend/services/session-message-runner.ts b/mobius/backend/services/session-message-runner.ts index 5a71080f..5c782114 100644 --- a/mobius/backend/services/session-message-runner.ts +++ b/mobius/backend/services/session-message-runner.ts @@ -7,9 +7,15 @@ import { resolveSessionWorkspace } from './workspace'; import { appendSessionInput } from './session-inputs'; import { syncSkillsToWorkspace } from './session-skills-sync'; import { formatBackendSendFailure } from './session-errors'; -import { transferReferencePrompt } from './session-transfer'; +import { buildSessionTransferMarkdown, transferReferencePrompt } from './session-transfer'; import { canOperateSession } from './access-control'; import { aimuxRemoteNameFromMeta } from './pc-client-context'; +import { + buildBidirectionalMentionPrompt, + buildReadOnlyMentionPrompt, + mintAgentBridgeToken, + type AgentMentionMode, +} from './agent-mention-bridge'; import { normalizeSessionAttachments, sessionContentWithAttachments, @@ -42,6 +48,11 @@ interface PendingTransferPaths { metadata: string | null; } +type NormalizedAgentMention = { + sessionId: string; + mode: AgentMentionMode; +}; + function readPendingTransferPaths(sessionId: any): PendingTransferPaths | null { try { const row = db.prepare(` @@ -72,6 +83,64 @@ function readPendingTransferPaths(sessionId: any): PendingTransferPaths | null { } } +function resolveSessionJsonlPath(session: any, sessionId: string): string | null { + try { + const launch = modelRegistry.launchOptionsForSession(session); + const backend = agents.get(launch.backend); + return typeof backend?._resolveJsonlPath === 'function' + ? backend._resolveJsonlPath(sessionId) + : null; + } catch { + return null; + } +} + +function normalizeAgentMentions(mentions: any): NormalizedAgentMention[] { + if (!Array.isArray(mentions)) return []; + const seen = new Set(); + const output: NormalizedAgentMention[] = []; + for (const raw of mentions) { + if (!raw || typeof raw !== 'object') continue; + const kind = String(raw.kind || raw.type || '').trim(); + if (kind !== 'agent') continue; + const sessionId = String(raw.session_id || raw.sessionId || raw.id || '').trim(); + if (!sessionId) continue; + const mode = String(raw.mode || raw.mention_mode || raw.agent_mode || '').trim() === 'bidirectional' + ? 'bidirectional' + : 'read_only'; + const key = `${sessionId}:${mode}`; + if (seen.has(key)) continue; + seen.add(key); + output.push({ sessionId, mode }); + } + return output; +} + +function buildMentionTransferMarkdown(user: any, sourceSession: any, targetSessionId: string, logger: any): string { + const jsonlPath = resolveSessionJsonlPath(sourceSession, sourceSession.session_id); + if (jsonlPath) { + try { + const transfer = buildSessionTransferMarkdown({ + sourceSession, + targetSessionId, + jsonlPath, + maxTextChars: 12_000, + maxTotalChars: 120_000, + }); + if (transfer?.markdown) return String(transfer.markdown || '').trimEnd(); + } catch (e) { + logger?.warn?.(`[sessions/messages] build mention transfer failed (${sourceSession.session_id}): ${e.message}`); + } + } + + try { + const ctx = buildSessionContext(user, sourceSession.session_id); + return String(ctx?.body || '').trimEnd(); + } catch { + return ''; + } +} + async function runSessionMessage({ user, sessionId, @@ -80,6 +149,7 @@ async function runSessionMessage({ hasInputText = false, requestId = null, attachments = [], + mentions = [], source = 'service.session.messages', logger = console, urgent = false, @@ -91,6 +161,7 @@ async function runSessionMessage({ hasInputText?: boolean; requestId?: any; attachments?: any[]; + mentions?: any[]; source?: string; logger?: any; urgent?: boolean; @@ -118,6 +189,7 @@ async function runSessionMessage({ user, [workspace.projectRoot, workspace.workDir], ); + const normalizedMentions = normalizeAgentMentions(mentions); if (!normalizedContent.trim() && normalizedAttachments.length === 0) { throw httpError('content 不能为空', 400); } @@ -165,10 +237,18 @@ async function runSessionMessage({ turnNumber: turnNum, userId: user?.id || null, attachments: normalizedAttachments, + mentions: normalizedMentions, timestamp: new Date().toISOString(), }; let finalContent = sessionContentWithAttachments(normalizedContent, normalizedAttachments); + const bridgeKickoffs: Array<{ + targetSession: any; + token: string; + sourceTransferMarkdown: string; + targetTransferMarkdown: string; + mode: AgentMentionMode; + }> = []; if (Messages.countUserMessagesFor(normalizedSessionId) <= 1) { const ctx = buildSessionContext(user, normalizedSessionId); if (workDir && ctx.sources?.skills?.length > 0) { @@ -197,6 +277,58 @@ async function runSessionMessage({ } } + if (normalizedMentions.length > 0) { + for (const mention of normalizedMentions) { + const targetSession = Sessions.findById(mention.sessionId) as any; + if (!targetSession) continue; + if (targetSession.session_id === normalizedSessionId) continue; + if (!canOperateSession(user, targetSession)) continue; + const sourceTransferMarkdown = buildMentionTransferMarkdown(user, targetSession, normalizedSessionId, logger); + if (!sourceTransferMarkdown) continue; + if (mention.mode === 'read_only') { + finalContent = [ + finalContent, + buildReadOnlyMentionPrompt({ + sourceSession: sess, + targetSession, + transferMarkdown: sourceTransferMarkdown, + currentUserName: user?.display_name || user?.id, + }), + ].filter(Boolean).join('\n\n'); + continue; + } + + const targetTransferMarkdown = buildMentionTransferMarkdown(user, sess, targetSession.session_id, logger); + const token = mintAgentBridgeToken({ + owner_user_id: user.id, + source_session_id: normalizedSessionId, + target_session_id: targetSession.session_id, + mode: mention.mode, + source_session_name: String(sess?.name || '').trim() || normalizedSessionId, + target_session_name: String(targetSession?.name || '').trim() || targetSession.session_id, + }); + finalContent = [ + finalContent, + buildBidirectionalMentionPrompt({ + perspective: 'source', + mode: mention.mode, + token, + sourceSession: sess, + targetSession, + transferMarkdown: sourceTransferMarkdown, + currentUserName: user?.display_name || user?.id, + }), + ].filter(Boolean).join('\n\n'); + bridgeKickoffs.push({ + targetSession, + token, + sourceTransferMarkdown, + targetTransferMarkdown, + mode: mention.mode, + }); + } + } + try { const dispatchOpts = { sessionId: normalizedSessionId, @@ -234,6 +366,30 @@ async function runSessionMessage({ logger?.warn?.(`[sessions/messages] save agent session id: ${e.message}`); } } + if (bridgeKickoffs.length > 0) { + void Promise.allSettled(bridgeKickoffs.map(async (kickoff) => { + const kickoffContent = buildBidirectionalMentionPrompt({ + perspective: 'target', + mode: kickoff.mode, + token: kickoff.token, + sourceSession: sess, + targetSession: kickoff.targetSession, + transferMarkdown: kickoff.targetTransferMarkdown || kickoff.sourceTransferMarkdown, + currentUserName: user?.display_name || user?.id, + initialMessage: normalizedContent.trim() || displayContent, + }); + await runSessionMessage({ + user, + sessionId: kickoff.targetSession.session_id, + content: kickoffContent, + inputText: kickoffContent, + hasInputText: true, + requestId: `agent-bridge-kickoff-${normalizedSessionId}-${kickoff.targetSession.session_id}-${Date.now()}` as any, + source: 'service.session.agent_bridge', + logger, + } as any); + })); + } return { ok: true, session_id: normalizedSessionId, diff --git a/mobius/desktop/electron/lib/aimux-supervisor.ts b/mobius/desktop/electron/lib/aimux-supervisor.ts index a218a43a..cd64a018 100644 --- a/mobius/desktop/electron/lib/aimux-supervisor.ts +++ b/mobius/desktop/electron/lib/aimux-supervisor.ts @@ -70,7 +70,11 @@ export class AimuxSupervisor { onStatus({ state: "starting", detail: "正在连接 mobius…", identifier }); appendAimuxLog(`\n==== [${new Date().toISOString()}] spawn reverse connect identifier=${identifier} ====\n`); - const child = spawn(aimuxExe, ["reverse", "connect", bridgeUrl, "--identifier", identifier, "--token", token, "--replace"]); + const args = ["reverse", "connect", bridgeUrl, "--identifier", identifier, "--token", token, "--replace"]; + // Windows otherwise opens a console window for every reverse-connect + // child and for its shell helpers. TUI and Electron now share this flag. + if (process.platform === "win32") args.push("--silent-shell"); + const child = spawn(aimuxExe, args, { windowsHide: true }); this.child = child; this.startConnectionProbe(); diff --git a/mobius/desktop/electron/lib/project-paths.ts b/mobius/desktop/electron/lib/project-paths.ts index 867074c5..fd75985b 100644 --- a/mobius/desktop/electron/lib/project-paths.ts +++ b/mobius/desktop/electron/lib/project-paths.ts @@ -4,6 +4,7 @@ import { app } from "electron"; import * as fs from "node:fs"; import * as path from "node:path"; +import * as os from "node:os"; const FILE = (): string => path.join(app.getPath("userData"), "project-paths.json"); @@ -11,6 +12,50 @@ interface Store { [k: string]: { path?: string; workMode?: string; updatedAt: string }; } +/** TUI-compatible mapping stored in the user's shared ~/.mobius directory. */ +export function sharedMobiusHome(): string { + return path.join(os.homedir(), ".mobius"); +} + +export function readSharedDir2Project(): Record { + try { + const file = path.join(sharedMobiusHome(), "dir2project.json"); + const value = JSON.parse(fs.readFileSync(file, "utf8")); + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; + } catch { + return {}; + } +} + +/** Exact path first, then the nearest bound ancestor (useful for subfolders). */ +export function findSharedProjectForPath(rawPath: string): { projectId: string; root: string } | null { + const target = path.resolve(rawPath); + const map = readSharedDir2Project(); + let best: { projectId: string; root: string } | null = null; + for (const [rawRoot, rawId] of Object.entries(map)) { + if (typeof rawId !== "string" || !rawId.trim()) continue; + const root = path.resolve(rawRoot); + const rel = path.relative(root, target); + if (rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))) { + if (!best || root.length > best.root.length) best = { projectId: rawId.trim(), root }; + } + } + return best; +} + +export function bindSharedProjectPath(rawPath: string, projectId: string): void { + const file = path.join(sharedMobiusHome(), "dir2project.json"); + const target = path.resolve(rawPath); + try { + const map = readSharedDir2Project(); + map[target] = projectId; + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(map, null, 2), { mode: 0o600 }); + } catch (e) { + console.error("[project-paths] 写入共享 .mobius 映射失败:", e); + } +} + function read(): Store { try { return JSON.parse(fs.readFileSync(FILE(), "utf8")); diff --git a/mobius/desktop/electron/lib/windows-context-menu.ts b/mobius/desktop/electron/lib/windows-context-menu.ts new file mode 100644 index 00000000..c3916115 --- /dev/null +++ b/mobius/desktop/electron/lib/windows-context-menu.ts @@ -0,0 +1,31 @@ +import { spawnSync } from "node:child_process"; + +const MENU_KEY = "HKCU\\Software\\Classes\\Directory\\shell\\MobiusDesktop"; +const BACKGROUND_KEY = "HKCU\\Software\\Classes\\Directory\\Background\\shell\\MobiusDesktop"; +const LABEL = "在 Mobius 桌面端打开"; + +function reg(args: string[]): boolean { + try { + return spawnSync("reg.exe", args, { windowsHide: true, encoding: "utf8", timeout: 5000 }).status === 0; + } catch { + return false; + } +} + +export function ensureWindowsContextMenu(): void { + if (process.platform !== "win32") return; + const exe = process.execPath; + const command = `"${exe.replace(/"/g, '""')}" --open-path "%1"`; + for (const key of [MENU_KEY, BACKGROUND_KEY]) { + // Respect an existing user-installed entry; only create missing commands. + if (reg(["QUERY", `${key}\\command`])) continue; + reg(["ADD", key, "/ve", "/d", LABEL, "/f"]); + reg(["ADD", `${key}\\command`, "/ve", "/d", command, "/f"]); + } +} + +export function openPathArgument(argv: string[] = process.argv): string | null { + const index = argv.findIndex((value) => value === "--open-path"); + const candidate = index >= 0 ? argv[index + 1] : null; + return candidate && !candidate.startsWith("--") ? candidate : null; +} diff --git a/mobius/desktop/electron/main.ts b/mobius/desktop/electron/main.ts index a55c2639..0a3ed3ba 100644 --- a/mobius/desktop/electron/main.ts +++ b/mobius/desktop/electron/main.ts @@ -11,7 +11,8 @@ import { loadCreds, saveCreds, clearCreds, loadServerUrl, saveServerUrl, loadSer import { gatherHostInfo, type BootData } from "./lib/host-info"; import { ensureAimux, upgradeAimux, getAimuxVersion, checkAimuxUpdate, aimuxExe, venvDir, hasBundledPython, type InstallProgress } from "./lib/python-runtime"; import { AimuxSupervisor, aimuxLogPath, appendAimuxLog, type AimuxStatus } from "./lib/aimux-supervisor"; -import { getProjectLocalPath, setProjectLocalPath, getProjectWorkMode, setProjectWorkMode, sanitizeName } from "./lib/project-paths"; +import { getProjectLocalPath, setProjectLocalPath, getProjectWorkMode, setProjectWorkMode, sanitizeName, findSharedProjectForPath, bindSharedProjectPath } from "./lib/project-paths"; +import { ensureWindowsContextMenu, openPathArgument } from "./lib/windows-context-menu"; import { FileOpError, validateNewName, assertNoSymlink, isDirEqualOrChild, copyEntryRecursive } from "./lib/project-file-ops"; import { getAimuxEnabled, setAimuxEnabled, getLastRoute } from "./lib/desktop-settings"; import { createStatusWindow } from "./status-window"; @@ -33,6 +34,7 @@ let windowDragTimer: NodeJS.Timeout | null = null; // aimux 反向连接开关(持久化于 userData/desktop-settings.json,默认开)。 // 关闭后本机不再作为可调度节点连入 mobius,桌面端其他功能不受影响。 let aimuxEnabled = true; +let pendingOpenPath: string | null = openPathArgument(); // 多 tab 编排(实验版 0.0.12)。每个 tab = 一个独立 WebContentsView,挂 mainWindow.contentView。 let tabManager: TabManager | null = null; @@ -188,6 +190,29 @@ async function fetchProjectName(server: string, projectId: string): Promise { + const candidate = String(rawPath || "").trim(); + if (!candidate || !tabManager || !creds) return; + const target = resolve(candidate); + let route = `/welcome?path=${encodeURIComponent(target)}`; + const match = findSharedProjectForPath(target); + if (match) { + try { + const res = await fetch(`${serverOrigin()}/api/projects`, { headers: { Authorization: `Bearer ${creds.jwt}` } }); + const data = await res.json().catch(() => ({})); + const projects: any[] = Array.isArray(data) ? data : (data?.projects || []); + const project = projects.find((p) => String(p?.id || "") === match.projectId); + if (project) { + // Import the TUI mapping into the legacy Electron store on first use. + setProjectLocalPath(serverOrigin(), match.projectId, match.root); + route = `/u/${encodeURIComponent(creds.username)}/p/${encodeURIComponent(match.projectId)}`; + } + } catch { /* server unavailable: leave the welcome flow with the path */ } + } + tabManager.createTab(route, { activate: true }); +} + // ——— 状态分发:推给 web UI(前端 AimuxStatusBadge 通过 IPC 接收,不再由主进程注入徽标)——— function emitStatus(s: AimuxStatus): void { lastStatus = s; @@ -494,6 +519,11 @@ async function bootDesktop(): Promise { ensureTabManager(); void mainWindow.loadURL(ABOUT_BLANK); tabManager!.restore(); + if (pendingOpenPath) { + const requested = pendingOpenPath; + pendingOpenPath = null; + void openRequestedPath(requested); + } scheduleAutoAimuxUpdateCheck(); } @@ -951,6 +981,9 @@ ipcMain.handle("project:confirm-path", async (_e, projectId: string, pathRaw: st return { ok: false, error: (e as Error).message }; } setProjectLocalPath(serverOrigin(), projectId, p); + // Keep Electron and TUI on the same cwd -> project protocol. The old + // userData mapping remains as a machine/server-local cache for compatibility. + bindSharedProjectPath(p, projectId); return { ok: true, path: p }; }); // 进入项目页时前端拉取:已绑则(必要时补建目录)返回 bound:true;未绑返回默认路径供弹窗预填。 @@ -1269,14 +1302,18 @@ const gotLock = app.requestSingleInstanceLock(); if (!gotLock) { app.quit(); } else { - app.on("second-instance", () => { + app.on("second-instance", (_event, argv) => { + const requested = openPathArgument(argv); + if (requested) pendingOpenPath = requested; if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.focus(); + if (requested && creds) void openRequestedPath(requested); } }); app.whenReady().then(async () => { + ensureWindowsContextMenu(); aimuxEnabled = getAimuxEnabled(); buildMenu(); createWindow(); diff --git a/mobius/ecosystem.config.js b/mobius/ecosystem.config.js index 531c4d1b..c65b0ed3 100644 --- a/mobius/ecosystem.config.js +++ b/mobius/ecosystem.config.js @@ -64,6 +64,8 @@ const envKeys = [ 'MOBIUS_LOG_DIR', 'MOBIUS_TOKEN_PROXY_HOST', 'MOBIUS_TOKEN_PROXY_PORT', + 'MOBIUS_GULING_MCP_URL', + 'MOBIUS_GULING_MCP_TOKEN', ]; const inheritedEnv = {}; diff --git a/mobius/frontend/src/components/chat.tsx b/mobius/frontend/src/components/chat.tsx index 7335247e..b6698ed9 100644 --- a/mobius/frontend/src/components/chat.tsx +++ b/mobius/frontend/src/components/chat.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } fr import type { ButtonHTMLAttributes, ReactNode } from 'react' import { useNavigate, useSearchParams } from 'react-router-dom' import ReactMarkdown from 'react-markdown' -import { Bot, Bookmark, Wrench, MoreHorizontal, History, Copy, Check, Replace, Archive, Maximize2, Minimize2, X, ZoomIn, FileDiff, Terminal, GitCompare, Loader2, Mic, RefreshCw, SendHorizontal, Zap, Square, Plus, Paperclip, ExternalLink, Server, FolderOpen, ChevronRight, FileText } from 'lucide-react' +import { Bot, Bookmark, Wrench, MoreHorizontal, History, Copy, Check, Replace, Archive, Maximize2, Minimize2, X, ZoomIn, FileDiff, Terminal, GitCompare, Loader2, Mic, RefreshCw, SendHorizontal, Zap, Square, Plus, Paperclip, ExternalLink, Server, FolderOpen, ChevronRight, FileText, AtSign, ArrowLeftRight, Search } from 'lucide-react' import { useStore, api, HIDDEN_FOLDER_NAME } from '../store' import { timeAgo, isRecentlyActive } from './shell' import { AgentStatusDot } from './AgentStatusDot' @@ -1385,10 +1385,11 @@ function runtimeStatusForSessionList(r: any) { return 'idle' } -export function SessionRow({ session, isSelected, onSelect, onEdit, onDelete, pinnedIds, onTogglePinned }: { +export function SessionRow({ session, isSelected, onSelect, onEdit, onDelete, pinnedIds, onTogglePinned, dataTour }: { session: any; isSelected: boolean; onSelect: (s: any) => void; onEdit?: (s: any) => void; onDelete?: (s: any) => void; - pinnedIds?: Set; onTogglePinned?: (s: any) => void + pinnedIds?: Set; onTogglePinned?: (s: any) => void; + dataTour?: string }) { const { theme } = useStore() const textPrimary = theme !== 'light' ? '#f1f5f9' : '#1e293b' @@ -1399,6 +1400,7 @@ export function SessionRow({ session, isSelected, onSelect, onEdit, onDelete, pi return (
onSelect(session)} + data-tour={dataTour} className={`group flex h-[54px] items-center gap-1.5 overflow-hidden px-2 py-1.5 rounded-lg cursor-pointer mb-0.5 transition-colors ${ isSelected ? 'bg-blue-500/10 border border-blue-500/20' : 'hover:bg-[var(--bg-card-hover)] border border-transparent' } ${nameMuted ? 'opacity-75' : ''}`}> @@ -1458,19 +1460,95 @@ type RemoteFileSource = { hardware?: string } -function RemoteFileMentionDrawer({ projectId, open, onClose, onPickPath }: { +type MentionFileSource = { + key: string + kind: 'hub' | 'local' | 'remote' + name: string + status?: string + remote_path?: string +} + +type AgentMentionMode = 'read_only' | 'bidirectional' + +type MentionAgentSession = { + session_id: string + name: string + description?: string + model?: string + model_label?: string + agent_status?: string + research_role?: string | null + scope_type?: 'issue' | 'research' + last_active?: string + message_count?: number +} + +type ChatDesktopFileBridge = { + isDesktop?: boolean + listProjectLocalFiles?: (projectId: string, path: string) => Promise<{ + ok?: boolean + error?: string + bind_path?: string + entries?: Entry[] + }> +} + +function getChatDesktopFileBridge(): ChatDesktopFileBridge | undefined { + if (typeof window === 'undefined') return undefined + return (window as { mobiusDesktop?: ChatDesktopFileBridge }).mobiusDesktop +} + +function RemoteFileMentionDrawer({ + projectId, + issueId, + researchId, + currentSessionId, + open, + query, + onClose, + onPickPath, + onPickAgent, +}: { projectId: string + issueId?: string + researchId?: string + currentSessionId?: string open: boolean + query?: string onClose: () => void onPickPath: (path: string) => void + onPickAgent?: (agent: MentionAgentSession, mode: AgentMentionMode) => void }) { + const [activeTab, setActiveTab] = useState<'files' | 'agents'>(issueId || researchId ? 'agents' : 'files') const [sources, setSources] = useState([]) - const [selectedRemote, setSelectedRemote] = useState('') + const [selectedSourceKey, setSelectedSourceKey] = useState('hub') const [sourcesLoading, setSourcesLoading] = useState(false) const [sourcesError, setSourcesError] = useState('') + const [agentSessions, setAgentSessions] = useState([]) + const [agentLoading, setAgentLoading] = useState(false) + const [agentError, setAgentError] = useState('') + const [agentMode, setAgentMode] = useState('read_only') const [dirs, setDirs] = useState>({}) const [expanded, setExpanded] = useState>(new Set(['/'])) + const sourceOptions = useMemo(() => { + const options: MentionFileSource[] = [{ key: 'hub', kind: 'hub', name: '中枢(local)' }] + const desktop = getChatDesktopFileBridge() + if (desktop?.isDesktop && desktop.listProjectLocalFiles) { + options.push({ key: 'local', kind: 'local', name: '本机(local)' }) + } + for (const source of sources) { + options.push({ + key: `remote:${source.name}`, + kind: 'remote', + name: source.name, + status: source.status, + remote_path: source.remote_path, + }) + } + return options + }, [sources]) + const loadSources = useCallback(async () => { if (!projectId) return setSourcesLoading(true) @@ -1479,21 +1557,62 @@ function RemoteFileMentionDrawer({ projectId, open, onClose, onPickPath }: { const data = await api(`/api/projects/${projectId}/remote-file-sources`) const next = Array.isArray(data?.remotes) ? data.remotes as RemoteFileSource[] : [] setSources(next) - setSelectedRemote(current => current && next.some(source => source.name === current) ? current : (next[0]?.name || '')) + const desktop = getChatDesktopFileBridge() + setSelectedSourceKey(current => { + if (current === 'hub') return current + if (current === 'local' && desktop?.isDesktop && desktop.listProjectLocalFiles) return current + return next.some(source => `remote:${source.name}` === current) ? current : 'hub' + }) } catch (error: any) { setSources([]) - setSelectedRemote('') - setSourcesError(error?.message || '加载远程服务器失败') + setSelectedSourceKey('hub') + setSourcesError(error?.message || '加载远程文件来源失败') } finally { setSourcesLoading(false) } }, [projectId]) + const agentScopeUrl = useMemo(() => { + if (researchId) return `/api/researches/${researchId}/sessions` + if (issueId) return `/api/issues/${issueId}/sessions` + return '' + }, [issueId, researchId]) + + useEffect(() => { + if (!open) return + setActiveTab(issueId || researchId ? 'agents' : 'files') + setAgentMode('read_only') + }, [issueId, open, researchId]) + + const loadAgentSessions = useCallback(async () => { + if (!agentScopeUrl) { + setAgentSessions([]) + return + } + setAgentLoading(true) + setAgentError('') + try { + const data = await api(agentScopeUrl) + const list = Array.isArray(data) ? data as MentionAgentSession[] : [] + setAgentSessions(list.filter(item => item.session_id !== currentSessionId)) + } catch (error: any) { + setAgentSessions([]) + setAgentError(error?.message || '加载智能体列表失败') + } finally { + setAgentLoading(false) + } + }, [agentScopeUrl, currentSessionId]) + useEffect(() => { if (!open) return void loadSources() }, [open, loadSources]) + useEffect(() => { + if (!open || activeTab !== 'agents') return + void loadAgentSessions() + }, [open, activeTab, loadAgentSessions]) + useEffect(() => { if (!open) return const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose() } @@ -1502,21 +1621,31 @@ function RemoteFileMentionDrawer({ projectId, open, onClose, onPickPath }: { }, [open, onClose]) const loadDir = useCallback(async (relPath: string) => { - if (!projectId || !selectedRemote) return + if (!projectId || !selectedSourceKey) return + const selectedSource = sourceOptions.find(source => source.key === selectedSourceKey) + if (!selectedSource) return setDirs(previous => ({ ...previous, [relPath]: { ...previous[relPath], loading: true, error: undefined } })) try { - const data = await api(`/api/projects/${projectId}/remote-files?remote=${encodeURIComponent(selectedRemote)}&path=${encodeURIComponent(relPath)}`) + const desktop = getChatDesktopFileBridge() + const data = selectedSource.kind === 'hub' + ? await api(`/api/projects/${projectId}/files?path=${encodeURIComponent(relPath)}`) + : selectedSource.kind === 'local' + ? await desktop?.listProjectLocalFiles?.(projectId, relPath) + : await api(`/api/projects/${projectId}/remote-files?remote=${encodeURIComponent(selectedSource.name)}&path=${encodeURIComponent(relPath)}`) + if (selectedSource.kind === 'local' && !data?.ok) throw new Error(data?.error || '加载本机文件失败') setDirs(previous => ({ ...previous, [relPath]: { loading: false, entries: Array.isArray(data?.entries) ? data.entries : [] } })) } catch (error: any) { - setDirs(previous => ({ ...previous, [relPath]: { loading: false, error: error?.message || '加载远程目录失败' } })) + setDirs(previous => ({ ...previous, [relPath]: { loading: false, error: error?.message || '加载文件目录失败' } })) } - }, [projectId, selectedRemote]) + }, [projectId, selectedSourceKey, sourceOptions]) useEffect(() => { + if (!open) return + if (activeTab !== 'files') return setDirs({}) setExpanded(new Set(['/'])) - if (open && selectedRemote) void loadDir('/') - }, [open, selectedRemote, loadDir]) + if (selectedSourceKey) void loadDir('/') + }, [open, activeTab, selectedSourceKey, loadDir]) const toggleDir = useCallback((relPath: string) => { setExpanded(previous => { @@ -1534,30 +1663,59 @@ function RemoteFileMentionDrawer({ projectId, open, onClose, onPickPath }: { if (entry.abs_path) onPickPath(entry.abs_path) }, [onPickPath]) + const pickAgent = useCallback((agent: MentionAgentSession) => { + if (!onPickAgent) return + onPickAgent(agent, agentMode) + onClose() + }, [agentMode, onClose, onPickAgent]) + + const filteredAgents = useMemo(() => { + const q = String(query || '').trim().toLowerCase() + const list = [...agentSessions].sort((a, b) => { + const ar = a.agent_status === 'running' ? 0 : 1 + const br = b.agent_status === 'running' ? 0 : 1 + if (ar !== br) return ar - br + return new Date(b.last_active || 0).getTime() - new Date(a.last_active || 0).getTime() + }) + if (!q) return list + return list.filter((agent) => [ + agent.session_id, + agent.name, + agent.description, + agent.model, + agent.model_label, + agent.research_role, + ].some(value => String(value || '').toLowerCase().includes(q))) + }, [agentSessions, query]) + if (!open) return null - const selectedSource = sources.find(source => source.name === selectedRemote) + const selectedSource = sourceOptions.find(source => source.key === selectedSourceKey) const rootState = dirs['/'] + const activeLabel = activeTab === 'agents' ? (researchId ? 'Research 智能体' : 'Issue 智能体') : '项目文件' + const activeHint = activeTab === 'agents' + ? '选择一个其他智能体,把它的上下文或双向通道插入当前输入框' + : '选择文件,把绝对路径插入输入框' return ( -
+
-
- 远程服务器 +
+
- {sourcesLoading && sources.length === 0 ? ( -
- 加载远程服务器… -
- ) : sourcesError ? ( -
{sourcesError}
- ) : sources.length === 0 ? ( -
- 当前项目还没有可用的远程服务器。请先在项目设置中同步 AIMUX 远程算力清单。 -
+ {activeTab === 'files' ? ( + <> +
+ 文件来源 + +
+ {sourcesLoading && sources.length === 0 ? ( +
+ 加载文件来源… +
+ ) : ( + <> + {sourcesError &&
远程来源加载失败:{sourcesError}
} +
+ {sourceOptions.map(source => { + const active = source.key === selectedSourceKey + return ( + + ) + })} +
+ + )} + ) : ( -
- {sources.map(source => { - const active = source.name === selectedRemote - return ( + <> +
+ + {agentScopeUrl ? (researchId ? 'Research 会话' : 'Issue 会话') : '无可用范围'} + +
+ +
+
+
+ 候选智能体 + +
+ {agentLoading && agentSessions.length === 0 ? ( +
+ 加载智能体… +
+ ) : ( + <> + {agentError &&
智能体加载失败:{agentError}
} + {!agentScopeUrl ? ( +
+ 当前会话没有 issue / research 范围,无法 @ 其他智能体。
-
- {source.remote_path || '默认登录目录'} + ) : filteredAgents.length === 0 ? ( +
+ 没有找到可 @ 的智能体。
- - ) - })} -
+ ) : ( +
+ {filteredAgents.map(agent => { + const active = agent.agent_status === 'running' + const modelLabel = sessionModelLabel(agent.model, agent.model_label) + return ( + + ) + })} +
+ )} + + )} + )}
-
-
- - {selectedSource?.name || '未选择服务器'} - {selectedSource && } - {selectedSource?.remote_path || (selectedSource ? '默认登录目录' : '')} -
-
- {!selectedRemote || sources.length === 0 ? null : !rootState ? ( -
- 加载文件… + {activeTab === 'files' ? ( + <> +
+
+ + {selectedSource?.name || '未选择来源'} + {selectedSource && } + {selectedSource?.kind === 'hub' ? '项目绑定路径' : selectedSource?.kind === 'local' ? 'Electron 本机路径' : (selectedSource?.remote_path || (selectedSource ? '默认登录目录' : ''))}
- ) : ( - - )} +
+ {!selectedSource ? null : !rootState ? ( +
+ 加载文件… +
+ ) : ( + + )} +
+
+
+ + 点击文件后会替换当前的 @ 并回到输入框 +
+ + ) : ( +
+ + 选择智能体后会插入当前输入框,并把其上下文或双向桥接语义一起发送给后端
-
-
- - 点击文件后会替换当前的 @ 并回到输入框 -
+ )}
) @@ -2450,6 +2747,12 @@ export function ChatArea({ layout = 'default', onNewSession }: { const inputRef = useRef(null) const [remoteFileDrawerOpen, setRemoteFileDrawerOpen] = useState(false) const remoteMentionRangeRef = useRef<{ start: number; end: number } | null>(null) + const [mentionQuery, setMentionQuery] = useState('') + const [selectedAgentMention, setSelectedAgentMention] = useState<{ + sessionId: string + name: string + mode: AgentMentionMode + } | null>(null) // IME 合成状态守卫: macOS 系统拼音输入法打字母时(合成进行中)按回车, 本意是确认候选字/上屏 // 字母, 不应触发发送. Chromium on macOS 合成中的 keydown(Enter) 其 isComposing===true, // 但原代码 onKeyDown 没检查 isComposing, 直接 preventDefault+send() 抢在 IME 前面发送了 @@ -2476,13 +2779,15 @@ export function ChatArea({ layout = 'default', onNewSession }: { setInput(nextValue) const beforeCaret = nextValue.slice(0, caret) const mentionMatch = beforeCaret.match(/@([^\s@]*)$/) - if (!mentionMatch || !currentProjectId) { + if (!mentionMatch || (!currentProjectId && !currentIssueId && !currentResearchId)) { remoteMentionRangeRef.current = null + setMentionQuery('') setRemoteFileDrawerOpen(false) return } const start = beforeCaret.lastIndexOf('@') remoteMentionRangeRef.current = { start, end: caret } + setMentionQuery(mentionMatch[1] || '') setRemoteFileDrawerOpen(true) } @@ -2496,7 +2801,34 @@ export function ChatArea({ layout = 'default', onNewSession }: { const nextValue = `${currentValue.slice(0, start)}${absolutePath}${trailingSpace}${suffix}` const caret = start + absolutePath.length + trailingSpace.length setInput(nextValue) + setSelectedAgentMention(null) + remoteMentionRangeRef.current = null + setMentionQuery('') + setRemoteFileDrawerOpen(false) + requestAnimationFrame(() => { + const textarea = inputRef.current + if (!textarea) return + textarea.focus() + try { textarea.setSelectionRange(caret, caret) } catch {} + }) + // setInput is session-scoped and intentionally recreated with the active draft. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [input, sessionId]) + + const insertAgentMention = useCallback((agent: MentionAgentSession, mode: AgentMentionMode) => { + const range = remoteMentionRangeRef.current + const currentValue = inputRef.current?.value ?? input + const start = range?.start ?? (inputRef.current?.selectionStart ?? currentValue.length) + const end = range?.end ?? start + const label = `@${agent.name || agent.session_id}` + const suffix = currentValue.slice(end) + const trailingSpace = suffix && !/^\s/.test(suffix) ? ' ' : '' + const nextValue = `${currentValue.slice(0, start)}${label}${trailingSpace}${suffix}` + const caret = start + label.length + trailingSpace.length + setInput(nextValue) + setSelectedAgentMention({ sessionId: agent.session_id, name: agent.name || agent.session_id, mode }) remoteMentionRangeRef.current = null + setMentionQuery('') setRemoteFileDrawerOpen(false) requestAnimationFrame(() => { const textarea = inputRef.current @@ -2510,6 +2842,8 @@ export function ChatArea({ layout = 'default', onNewSession }: { useEffect(() => { remoteMentionRangeRef.current = null + setMentionQuery('') + setSelectedAgentMention(null) setRemoteFileDrawerOpen(false) }, [sessionId]) const loadHistoryRef = useRef<() => void>(() => {}) @@ -2518,16 +2852,19 @@ export function ChatArea({ layout = 'default', onNewSession }: { inputText, requestId, urgent = false, + mentions, }: { content: string inputText?: string requestId: string urgent?: boolean + mentions?: any[] }) => { if (!sessionId) throw new Error('当前没有可发送消息的会话') const payload: Record = { content, request_id: requestId } if (typeof inputText === 'string') payload.input_text = inputText if (urgent) payload.urgent = true + if (Array.isArray(mentions) && mentions.length > 0) payload.mentions = mentions try { const resp = await api(`/api/sessions/${sessionId}/messages`, { method: 'POST', @@ -3177,6 +3514,14 @@ export function ChatArea({ layout = 'default', onNewSession }: { const sentSessionId = sessionId const sentInput = input const requestId = makeSendRequestId() + const mentionPayload = selectedAgentMention + ? [{ + kind: 'agent', + session_id: selectedAgentMention.sessionId, + mode: selectedAgentMention.mode, + name: selectedAgentMention.name, + }] + : [] setLastSendError('') addMessage({ role: 'user', content }) pendingUrgentRef.current = urgent @@ -3186,16 +3531,17 @@ export function ChatArea({ layout = 'default', onNewSession }: { // 发送瞬间立即清空输入框, 给用户即时反馈. 原来放在 .then() 里, // 要等后端 POST /messages 返回才清空, 体感是"字过了一会儿才消失". clearSessionInputDraft(sentSessionId, sentInput) - postSessionMessage({ content, inputText: text, requestId, urgent }) + postSessionMessage({ content, inputText: text, requestId, urgent, mentions: mentionPayload }) .then(() => { setEditingMsg(null) clearAttachments() + setSelectedAgentMention(null) inputRef.current?.focus() setTimeout(() => loadHistoryRef.current(), 500) }) .catch(() => { inputRef.current?.focus() }) .finally(() => setMessageSubmitting(false)) - }, [input, replyTo, sessionId, addMessage, attachments, anyUploading, messageSubmitting, clearAttachments, postSessionMessage, clearSessionInputDraft, voiceState]) + }, [input, replyTo, sessionId, addMessage, attachments, anyUploading, messageSubmitting, clearAttachments, postSessionMessage, clearSessionInputDraft, voiceState, selectedAgentMention]) const sendProjectKnowledgePrompt = useCallback(async () => { if (!sessionId || projectKnowledgeSending) return @@ -3427,9 +3773,14 @@ export function ChatArea({ layout = 'default', onNewSession }: {
setRemoteFileDrawerOpen(false)} onPickPath={insertRemoteFilePath} + onPickAgent={insertAgentMention} /> {attachmentImagePreview && ( @@ -3799,6 +4150,26 @@ export function ChatArea({ layout = 'default', onNewSession }: { ))}
)} + {selectedAgentMention && ( +
+ + + @{selectedAgentMention.name} + + + {selectedAgentMention.mode === 'bidirectional' ? '双向' : '只读'} + + +
+ )}