From b94e51ef38a56270f8bb772776342316bd2832cb Mon Sep 17 00:00:00 2001 From: Mobius OS Date: Sun, 9 Aug 2026 03:00:50 +0000 Subject: [PATCH 01/16] =?UTF-8?q?Clarify=20top=20navigation=20home=20link?= =?UTF-8?q?=20behavior=20(=E6=98=8E=E7=A1=AE=E9=A1=B6=E6=A0=8F=E4=B8=BB?= =?UTF-8?q?=E9=A1=B5=E5=AF=BC=E8=88=AA=E8=A1=8C=E4=B8=BA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mobius/frontend/src/components/shell.tsx | 6 ++++- mobius/frontend/tests/user-home-link.test.js | 27 ++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 mobius/frontend/tests/user-home-link.test.js diff --git a/mobius/frontend/src/components/shell.tsx b/mobius/frontend/src/components/shell.tsx index 67ad8c96..791766a3 100644 --- a/mobius/frontend/src/components/shell.tsx +++ b/mobius/frontend/src/components/shell.tsx @@ -1013,7 +1013,11 @@ export function TopNav({ rightExtra }: { rightExtra?: React.ReactNode } = {}) { )} / - {userParam} diff --git a/mobius/frontend/tests/user-home-link.test.js b/mobius/frontend/tests/user-home-link.test.js new file mode 100644 index 00000000..1f536a1f --- /dev/null +++ b/mobius/frontend/tests/user-home-link.test.js @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const shellSource = fs.readFileSync(path.join(here, '../src/components/shell.tsx'), 'utf8') + +const classIndex = shellSource.indexOf('className="mobius-topnav-userlink') +const buttonStart = shellSource.lastIndexOf('', classIndex) + +assert(classIndex >= 0 && buttonStart >= 0 && buttonEnd >= 0, '顶栏用户名按钮必须由 shell.tsx 的 mobius-topnav-userlink 渲染') + +const buttonMarkup = shellSource.slice(buttonStart, buttonEnd + 1) +const userHomeTarget = buttonMarkup.match(/to=\{(userParam\s*\?\s*`\/u\/\$\{userParam\}`\s*:\s*'\/')\}/) +assert(userHomeTarget, '顶栏用户名按钮必须声明主页导航目标') +assert.equal( + userHomeTarget[1].replace(/\s+/g, ''), + "userParam?`/u/${userParam}`:'/'", + '顶栏用户名按钮必须导航到当前用户主页,并在用户参数缺失时回退根路径', +) + +assert.match(buttonMarkup, /aria-label="回到主页"/, '顶栏用户名按钮必须提供回到主页的可访问名称') +assert.match(buttonMarkup, /title="回到主页"/, '顶栏用户名按钮必须提供回到主页的悬浮提示') + +console.log('user home link contract test passed') From 7c3e36c3deebe439ee8847ad69c674b13156b14a Mon Sep 17 00:00:00 2001 From: Mobius OS Date: Sun, 9 Aug 2026 08:31:17 +0000 Subject: [PATCH 02/16] =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E6=94=B9=E4=B8=BA=E8=87=AA=E9=80=82=E5=BA=94?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E7=BD=91=E6=A0=BC,=20=E6=B6=88=E9=99=A4?= =?UTF-8?q?=E5=8D=95=E5=88=97=E6=92=91=E6=BB=A1=E5=AF=BC=E8=87=B4=E7=9A=84?= =?UTF-8?q?=E7=95=99=E7=99=BD=20(project=20task=20list=20to=20responsive?= =?UTF-8?q?=20card=20grid,=20eliminate=20single-column=20whitespace)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/project-page/ProjectItemsPanel.tsx | 4 ++-- mobius/frontend/src/pages/ProjectPage.tsx | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/mobius/frontend/src/components/project-page/ProjectItemsPanel.tsx b/mobius/frontend/src/components/project-page/ProjectItemsPanel.tsx index 1d16f495..04ce5558 100644 --- a/mobius/frontend/src/components/project-page/ProjectItemsPanel.tsx +++ b/mobius/frontend/src/components/project-page/ProjectItemsPanel.tsx @@ -369,7 +369,7 @@ function IssueList({ return (
-
+
{issues.map((issue: any) => ( -
+
{researches.map((research: any) => ( -
+ {/* 任务/研究列表是卡片网格, 在大屏放宽最大宽度让网格铺开更多列, 减少右侧留白; + 项目设置表单仍保持 max-w-7xl 保证长表单可读性. */} +
{(() => { const settingsPanel = ( Date: Sun, 9 Aug 2026 08:41:17 +0000 Subject: [PATCH 03/16] =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E6=A6=82=E8=A7=88?= =?UTF-8?q?=E9=A1=B5=E5=8F=B3=E4=BE=A7=E6=94=B9=E4=B8=BA=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E9=9D=A2=E6=9D=BF,=20=E6=B6=88=E9=99=A4?= =?UTF-8?q?=E4=B8=8E=E5=B7=A6=E4=BE=A7=E4=BC=9A=E8=AF=9D=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E7=9A=84=E5=8D=A1=E7=89=87=E5=86=97=E4=BD=99;=20guided-demo/lo?= =?UTF-8?q?go-review=20=E7=9A=84=20tour=20=E9=94=9A=E7=82=B9=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E5=88=B0=E5=B7=A6=E4=BE=A7=20SessionRow=20(session=20?= =?UTF-8?q?overview:=20replace=20redundant=20card=20grid=20with=20task=20s?= =?UTF-8?q?ummary=20panel;=20move=20guided-demo/logo-review=20tour=20ancho?= =?UTF-8?q?rs=20onto=20left=20SessionRow)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mobius/frontend/src/components/chat.tsx | 6 +- mobius/frontend/src/pages/IssuePage.tsx | 251 ++++++++---------------- 2 files changed, 82 insertions(+), 175 deletions(-) diff --git a/mobius/frontend/src/components/chat.tsx b/mobius/frontend/src/components/chat.tsx index 7335247e..a1fafcc2 100644 --- a/mobius/frontend/src/components/chat.tsx +++ b/mobius/frontend/src/components/chat.tsx @@ -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' : ''}`}> diff --git a/mobius/frontend/src/pages/IssuePage.tsx b/mobius/frontend/src/pages/IssuePage.tsx index 39a93a5a..4e0eee39 100644 --- a/mobius/frontend/src/pages/IssuePage.tsx +++ b/mobius/frontend/src/pages/IssuePage.tsx @@ -1,6 +1,6 @@ import { lazy, Suspense, useState, useEffect, useMemo, useRef, useCallback } from 'react' import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom' -import { CircleDot, ChevronDown, ChevronLeft, ChevronRight, FlaskConical, MessageSquare, MessageSquarePlus, Plus } from 'lucide-react' +import { CircleDot, ChevronDown, FlaskConical, MessageSquare, MessageSquarePlus, Plus } from 'lucide-react' import { useStore, api } from '../store' import { TopNav, timeAgo, timeAgoPrecise } from '../components/shell' import { ResizablePanel, useIsMobile } from '../components/resizable-panel' @@ -8,7 +8,7 @@ import { usePagination, PaginationControls } from '../components/pagination' import { NewSessionModal, RenameSessionModal, RenameIssueModal, ConfirmModal, } from '../components/modals' -import { ChatArea, SessionRow, isSessionNameMuted } from '../components/chat' +import { ChatArea, SessionRow } from '../components/chat' import { AgentStatusDot } from '../components/AgentStatusDot' import { ProjectFilesCard } from '../components/project-files' import { Loading } from '../components/shell' @@ -21,7 +21,6 @@ const EditorPane = lazy(() => import('../components/workspace/editor-pane').then const CodeConversationPane = lazy(() => import('../components/workspace/code-conversation-pane').then(m => ({ default: m.CodeConversationPane }))) const GUIDED_DEMO_TOUR_EVENT = 'imac:guided-demo-tour:start' -const SESSION_OVERVIEW_PAGE_SIZE = 15 const SESSION_SIDEBAR_PAGE_SIZE = 16 // sidebar 会话列表每页 16, 超过即分页 const RECENT_SESSION_LIMIT = 50 @@ -491,15 +490,22 @@ export default function IssuePage() { 为当前 Issue 开启一次智能体执行
- ) : sidebarPagination.pagedItems.map((s: any) => ( - setEditingSession(s)} - onDelete={(s) => setDeletingSession(s)} - /> - )) + ) : sidebarPagination.pagedItems.map((s: any) => { + // guided-demo / logo-review 的 tour 锚点 (session-card / logo-review-session-card) + // 从原右侧会话卡片网格迁移到左侧 SessionRow: 会话导航统一在左侧列表, demo 流程不破坏. + const isLogoReviewSessionCard = projectId === LOGO_REVIEW_PROJECT_ID + && String(s.name || '').includes(LOGO_REVIEW_SESSION_NAME) + return ( + setEditingSession(s)} + onDelete={(s) => setDeletingSession(s)} + dataTour={isGuidedDemoSession(s.session_id) ? 'session-card' : isLogoReviewSessionCard ? 'logo-review-session-card' : undefined} + /> + ) + }) ) : recentSessionsLoading ? (
加载中...
) : recentSessionsError ? ( @@ -645,11 +651,7 @@ export default function IssuePage() { ) : ( setShowNewSession(true)} - onEdit={(s) => setEditingSession(s)} - onDelete={(s) => setDeletingSession(s)} projectId={projectId} /> )} @@ -699,52 +701,42 @@ function WorkspacePaneLoading({ label }: { label: string }) { // ===================================================================== // SessionOverview — 没有选中 session 时的右侧主区 -// 展示 session 卡片网格 + 新建会话按钮 +// 会话导航统一收敛到左侧 SessionRow 列表 (master-detail 的 master), 这里只做 +// 任务概览: 统计摘要 + 状态统计卡 + 新建会话入口. 不再与左侧列表并列展示同一批 +// 会话卡片 (消除冗余). guided-demo / logo-review 的 tour 锚点已迁移到左侧 +// SessionRow (见 IssuePage 渲染处), demo 流程不破坏. // ===================================================================== -function SessionOverview({ sessions, issueId, onOpenSession, onNewSession, onEdit, onDelete, projectId }: { +function SessionOverview({ sessions, onNewSession, projectId }: { sessions: any[] - issueId: string - onOpenSession: (sid: string) => void onNewSession: () => void - onEdit: (s: any) => void - onDelete: (s: any) => void projectId: string }) { - const [page, setPage] = useState(1) - const totalPages = Math.max(1, Math.ceil(sessions.length / SESSION_OVERVIEW_PAGE_SIZE)) - const currentPage = Math.min(page, totalPages) - const showPagination = sessions.length > SESSION_OVERVIEW_PAGE_SIZE - const pageStart = sessions.length === 0 ? 0 : (currentPage - 1) * SESSION_OVERVIEW_PAGE_SIZE + 1 - const pageEnd = Math.min(currentPage * SESSION_OVERVIEW_PAGE_SIZE, sessions.length) - const pagedSessions = useMemo(() => { - const start = (currentPage - 1) * SESSION_OVERVIEW_PAGE_SIZE - return sessions.slice(start, start + SESSION_OVERVIEW_PAGE_SIZE) - }, [sessions, currentPage]) - - useEffect(() => { - setPage(1) - }, [issueId]) - - useEffect(() => { - if (page > totalPages) setPage(totalPages) - }, [page, totalPages]) - - const goToPage = (nextPage: number) => { - setPage(Math.min(Math.max(nextPage, 1), totalPages)) - } + // 状态分类复用 agent_status 单一真相源 (与 AgentStatusDot 同源), 颜色语义一致. + const stats = useMemo(() => { + let running = 0, completed = 0, failed = 0 + sessions.forEach((s: any) => { + const st = s.agent_status || 'idle' + if (st === 'running') running++ + else if (st === 'completed') completed++ + else if (st === 'failed') failed++ + }) + return { + total: sessions.length, + running, completed, failed, + idle: sessions.length - running - completed - failed, + } + }, [sessions]) return (
-
-
-

所有会话

-

- {showPagination - ? `共 ${sessions.length} 个会话 · 当前显示 ${pageStart}-${pageEnd} 个` - : `共 ${sessions.length} 个会话 · 点击进入对话或新建会话`} -

-
+
+

任务概览

+

+ {sessions.length === 0 + ? '当前任务还没有会话,新建一个开始执行' + : `共 ${stats.total} 个会话${stats.running ? ` · ${stats.running} 个执行中` : ''}${stats.completed ? ` · ${stats.completed} 个已完成` : ''} · 从左侧选择会话进入对话`} +

{sessions.length === 0 ? ( @@ -758,86 +750,30 @@ function SessionOverview({ sessions, issueId, onOpenSession, onNewSession, onEdi
) : ( -
- {showPagination && ( - - )} -
- {pagedSessions.map((s: any) => { - // session 状态完全由 agent_status 决定 (单一真相源: 后端 agent-status-syncer - // 周期重算写入, 与 GET /api/sessions/:id/status 共用判定). 前端只读 agent_status, - // 不再二次判定 job_failed / job_accomplished / sessions_v2.status. - const _st = s.agent_status || 'idle' - const isFailed = _st === 'failed' - const isRunning = _st === 'running' - const isCompleted = _st === 'completed' - const nameMuted = isSessionNameMuted(_st) - const isLogoReviewSessionCard = projectId === LOGO_REVIEW_PROJECT_ID - && String(s.name || '').includes(LOGO_REVIEW_SESSION_NAME) - const isGuidedOrReviewSession = isGuidedDemoSession(s.session_id) || isLogoReviewSessionCard - return ( -
onOpenSession(s.session_id)} - className="rounded-xl border overflow-hidden flex flex-col group cursor-pointer transition-all hover:border-blue-500/30" - style={{ background: 'var(--bg-primary)', borderColor: 'var(--border-color)' }}> -
-
- -
-
-
{s.name}
-
- {isFailed && ● 任务失败} - {!isFailed && isRunning && ● 执行中} - {!isFailed && !isRunning && isCompleted && 已完成} - {!isFailed && !isRunning && !isCompleted && {s.status === 'active' ? '活跃' : s.status}} -
-
-
- - -
-
- - {s.description && ( -
- {s.description} -
- )} - -
- {s.message_count || 0} 消息 · {s.raw_entry_count || 0} 条原始数据 - 活跃 {timeAgo(s.last_active)} -
-
- ) - })} + <> + {/* 状态统计卡 — agent_status 单一真相源, 圆点颜色复用 AgentStatusDot 语义 */} +
+ + + +
- {showPagination && ( - - )} -
+ + {/* 新建会话入口 */} +
+
+
开启一次智能体执行
+
从左侧选择已有会话进入对话,或新建会话
+
+ +
+ )} {projectId && ( @@ -850,50 +786,19 @@ function SessionOverview({ sessions, issueId, onOpenSession, onNewSession, onEdi ) } -function SessionOverviewPagination({ - page, - totalPages, - pageStart, - pageEnd, - totalItems, - onPageChange, - compact = false, -}: { - page: number - totalPages: number - pageStart: number - pageEnd: number - totalItems: number - onPageChange: (page: number) => void - compact?: boolean +// OverviewStatCard — 任务概览的状态统计小卡: 标签 + 计数, 可选状态圆点 (颜色复用 AgentStatusDot). +function OverviewStatCard({ label, value, agentStatus }: { + label: string + value: number + agentStatus?: string }) { return ( -
- - 显示 {pageStart}-{pageEnd} / {totalItems} 个Session · 第 {page} / {totalPages} 页 - -
- - +
+
+ {agentStatus && } + {label}
+
{value}
) } From 62d0dad40de0f0f48dab62a03aed5d415b5d3f3c Mon Sep 17 00:00:00 2001 From: Mobius OS Date: Sun, 9 Aug 2026 08:54:24 +0000 Subject: [PATCH 04/16] =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E5=9B=BA=E5=AE=9A=E7=AD=89=E9=AB=98=E5=B0=BA?= =?UTF-8?q?=E5=AF=B8,=20=E7=9C=81=E7=95=A5=E5=86=97=E4=BD=99=E6=8F=8F?= =?UTF-8?q?=E8=BF=B0=E4=B8=8E=E4=BC=9A=E8=AF=9D=E5=B0=BE=E8=A1=8C=20(proje?= =?UTF-8?q?ct=20cards=20to=20fixed=20uniform=20height,=20omit=20redundant?= =?UTF-8?q?=20description=20and=20session=20trailing=20line)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/project-page/IssueCard.tsx | 14 ++++---------- .../src/components/project-page/ResearchCard.tsx | 14 ++++---------- .../frontend/src/services/project-session-order.ts | 2 +- 3 files changed, 9 insertions(+), 21 deletions(-) diff --git a/mobius/frontend/src/components/project-page/IssueCard.tsx b/mobius/frontend/src/components/project-page/IssueCard.tsx index 385b0bd9..dec7f15c 100644 --- a/mobius/frontend/src/components/project-page/IssueCard.tsx +++ b/mobius/frontend/src/components/project-page/IssueCard.tsx @@ -43,7 +43,6 @@ export function IssueCard({ const showingSessionMatches = !!searchQuery.trim() && searchMatches.length > 0 const displayedSessions = sortProjectSessions(showingSessionMatches ? searchMatches : sessions) const previewSessions = projectSessionPreview(displayedSessions, compact, showingSessionMatches) - const hiddenSessionCount = Math.max(0, (showingSessionMatches ? searchMatches.length : sessionTotal) - previewSessions.length) const description = typeof issue.description === 'string' ? issue.description.trim() : '' const normalizedTitle = String(issue.title || '').trim().replace(/\s+/g, ' ') const normalizedDescription = description.replace(/\s+/g, ' ') @@ -57,7 +56,7 @@ export function IssueCard({ return (
- {hasDistinctDescription && ( -
+ {hasDistinctDescription && !compact && ( +
{description}
)} @@ -106,7 +105,7 @@ export function IssueCard({ 活跃 {timeAgo(issue.last_active)}
-
+
{showingSessionMatches && (
@@ -141,11 +140,6 @@ export function IssueCard({ ) })} - {hiddenSessionCount > 0 && ( -
- 还有 {hiddenSessionCount} 个{showingSessionMatches ? '匹配' : ''}会话... -
- )}
)}
diff --git a/mobius/frontend/src/components/project-page/ResearchCard.tsx b/mobius/frontend/src/components/project-page/ResearchCard.tsx index 781bae05..084263f5 100644 --- a/mobius/frontend/src/components/project-page/ResearchCard.tsx +++ b/mobius/frontend/src/components/project-page/ResearchCard.tsx @@ -34,12 +34,11 @@ export function ResearchCard({ const showingSessionMatches = !!searchQuery.trim() && searchMatches.length > 0 const displayedSessions = sortProjectSessions(showingSessionMatches ? searchMatches : sessions) const previewSessions = projectSessionPreview(displayedSessions, compact, showingSessionMatches) - const hiddenSessionCount = Math.max(0, (showingSessionMatches ? searchMatches.length : sessionTotal) - previewSessions.length) const chief = sessions.find((s: any) => s.research_role === 'chief_researcher') const hasChief = !!chief || Number(research.chief_count || 0) > 0 return ( -
- {research.description && ( -
+ {research.description && !compact && ( +
{research.description}
)} @@ -76,7 +75,7 @@ export function ResearchCard({ 活跃 {timeAgo(research.last_active)}
-
+
{showingSessionMatches ? `匹配智能体 ${searchMatches.length}` : '研究智能体'} @@ -108,11 +107,6 @@ export function ResearchCard({ ))} - {hiddenSessionCount > 0 && ( -
- 还有 {hiddenSessionCount} 个{showingSessionMatches ? '匹配' : ''}研究智能体... -
- )}
)}
diff --git a/mobius/frontend/src/services/project-session-order.ts b/mobius/frontend/src/services/project-session-order.ts index 774c655d..8f7f0630 100644 --- a/mobius/frontend/src/services/project-session-order.ts +++ b/mobius/frontend/src/services/project-session-order.ts @@ -38,7 +38,7 @@ export function projectSessionPreview session?.agent_status === 'running') return running.length > 0 ? running.slice(0, 2) : ordered.slice(0, 1) From 3be2dc9a39df0b9785a5327f0b5fb8ceec67a4a2 Mon Sep 17 00:00:00 2001 From: Mobius OS Date: Sun, 9 Aug 2026 09:16:47 +0000 Subject: [PATCH 05/16] =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E5=9B=BA=E5=AE=9A3=E5=88=97=E5=B8=83?= =?UTF-8?q?=E5=B1=80=E5=B9=B6=E5=A2=9E=E9=AB=98=E5=8D=A1=E7=89=87=20(proje?= =?UTF-8?q?ct=20cards=20to=20fixed=203-column=20layout=20with=20taller=20h?= =?UTF-8?q?eight)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mobius/frontend/src/components/project-page/IssueCard.tsx | 2 +- .../src/components/project-page/ProjectItemsPanel.tsx | 4 ++-- mobius/frontend/src/components/project-page/ResearchCard.tsx | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/mobius/frontend/src/components/project-page/IssueCard.tsx b/mobius/frontend/src/components/project-page/IssueCard.tsx index dec7f15c..f8e303c5 100644 --- a/mobius/frontend/src/components/project-page/IssueCard.tsx +++ b/mobius/frontend/src/components/project-page/IssueCard.tsx @@ -56,7 +56,7 @@ export function IssueCard({ return (
-
+
{issues.map((issue: any) => ( -
+
{researches.map((research: any) => ( 0 return ( -
Date: Sun, 9 Aug 2026 09:56:26 +0000 Subject: [PATCH 06/16] =?UTF-8?q?Inject=20guling=20broker=20MCP=20into=20a?= =?UTF-8?q?ssistant=20session=20(=E7=BB=99=E5=B0=8F=E8=8E=AB=E5=8A=A9?= =?UTF-8?q?=E7=90=86=E6=B3=A8=E5=85=A5=20guling=20=E5=AE=9E=E7=9B=98=20MCP?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add resolveGulingMcp() reading MOBIUS_GULING_MCP_URL/_TOKEN from env (credential stays in gitignored .env, never in source); returns {type:http,url,headers} or null. - Generalize the per-session --mcp-config builder in tmux-claude-code.js so it merges multiple MCP servers (existing aimux stdio + new guling http) instead of aimux-only. - Thread enableGulingMcp opt through _createImpl/_queueImpl/_spawnWindow and set it on the assistant dispatch in routes/assistant.ts, so the Xiao Mo assistant can directly call mcp__guling__position / balance to read real funds and holdings. - Default-deny guling real-money write tools (buy/sell/cancel/switch_account) via --disallowedTools; only read tools (position/balance/orders/settlement/watchlist). - Register MOBIUS_GULING_MCP_URL/_TOKEN in ecosystem.config.js envKeys so pm2 injects them. --- mobius/backend/agents/tmux-claude-code.js | 65 +++++++++++++++++------ mobius/backend/routes/assistant.ts | 3 ++ mobius/ecosystem.config.js | 2 + 3 files changed, 53 insertions(+), 17 deletions(-) diff --git a/mobius/backend/agents/tmux-claude-code.js b/mobius/backend/agents/tmux-claude-code.js index 87726b57..0b15fd29 100644 --- a/mobius/backend/agents/tmux-claude-code.js +++ b/mobius/backend/agents/tmux-claude-code.js @@ -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/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/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 = {}; From 6b3bd26eb0b79fa160a4d447b692345ab9bbef9e Mon Sep 17 00:00:00 2001 From: Mobius OS Date: Sun, 9 Aug 2026 14:55:52 +0000 Subject: [PATCH 07/16] =?UTF-8?q?Preserve=20transcript=20colors=20for=20cl?= =?UTF-8?q?ipped=20rows=20(=E4=BF=9D=E7=95=99=E6=88=AA=E6=96=AD=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E9=A2=9C=E8=89=B2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mobius/backend/services/pc-client-context.ts | 4 +- mobius/backend/services/session-context.ts | 71 +------------------- mobius/tui/package-lock.json | 4 +- mobius/tui/package.json | 4 +- mobius/tui/src/components/Chat.tsx | 30 +++++---- mobius/tui/tests/screen.test.tsx | 14 ++++ 6 files changed, 38 insertions(+), 89 deletions(-) diff --git a/mobius/backend/services/pc-client-context.ts b/mobius/backend/services/pc-client-context.ts index ab762a37..89810559 100644 --- a/mobius/backend/services/pc-client-context.ts +++ b/mobius/backend/services/pc-client-context.ts @@ -92,14 +92,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”,也不要暴露你在远程工作(尽管实际上你在远程工作)。`; } 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/tui/package-lock.json b/mobius/tui/package-lock.json index 420dc21f..f63b6243 100644 --- a/mobius/tui/package-lock.json +++ b/mobius/tui/package-lock.json @@ -1,12 +1,12 @@ { "name": "@mobius-os/mobius", - "version": "0.3.30", + "version": "0.3.31", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mobius-os/mobius", - "version": "0.3.30", + "version": "0.3.31", "dependencies": { "chalk": "^5.3.0", "cli-highlight": "2.1.11", diff --git a/mobius/tui/package.json b/mobius/tui/package.json index 79b096c2..9a04d153 100644 --- a/mobius/tui/package.json +++ b/mobius/tui/package.json @@ -1,6 +1,6 @@ { "name": "@mobius-os/mobius", - "version": "0.3.30", + "version": "0.3.31", "type": "module", "description": "Mobius terminal client. Reuses Mobius frontend TypeScript types and jsonl entry shapes.", "bin": { @@ -17,7 +17,7 @@ "test:resume": "tsx tests/resume.test.tsx", "test:aimux": "tsx tests/aimux.test.tsx", "test:reconnect": "tsx tests/reconnect.test.tsx", - "test:screen": "tsx tests/screen.test.tsx", + "test:screen": "FORCE_COLOR=1 tsx tests/screen.test.tsx", "test:scroll": "tsx tests/scroll.test.tsx", "test:selection": "FORCE_COLOR=1 tsx tests/selection.test.tsx", "test": "npm run typecheck && npm run test:ui && npm run test:integration" diff --git a/mobius/tui/src/components/Chat.tsx b/mobius/tui/src/components/Chat.tsx index 9cd93788..25b58806 100644 --- a/mobius/tui/src/components/Chat.tsx +++ b/mobius/tui/src/components/Chat.tsx @@ -333,10 +333,14 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear, : null} 0 ? 'flex-start' : 'flex-end'} overflowY="hidden"> - {fitted.peekLines.length > 0 + {fitted.peekRows.length > 0 ? - {fitted.peekLines.map((line, index) => ( - {index === 0 ? ' ⋯ ' : ' '}{line} + {fitted.peekRows.map((row, index) => ( + ))} : null} @@ -1101,17 +1105,17 @@ function clickableUrl(url: string, maxLen?: number): string { // displayWidth is imported from src/lib/screen-text.ts (CJK/emoji-aware), used // here to size the AIMUX status block so the web URL truncates exactly. -function fitTranscript(entries: AnyEntry[], rowBudget: number, columns: number, scrollBack = 0): { +export function fitTranscript(entries: AnyEntry[], rowBudget: number, columns: number, scrollBack = 0): { entries: AnyEntry[] /** Tail rows of the next older entry, used to fill spare space above the viewport. */ - peekLines: string[] + peekRows: ScreenRow[] hiddenOlder: number hiddenRecent: number startIndex: number } { const tail = Math.max(0, entries.length - scrollBack) const available = tail === 0 ? [] : entries.slice(0, tail) - const renderedRows = available.map((entry) => entryScreenLines(viewsForEntry(entry), columns)) + const renderedRows = available.map((entry) => entryScreenRows(viewsForEntry(entry), columns)) const fit = (budget: number) => { let rows = 0 let first = available.length @@ -1127,7 +1131,7 @@ function fitTranscript(entries: AnyEntry[], rowBudget: number, columns: number, const base = fit(rowBudget) let fitted = base let first = fitted.first - let peekLines: string[] = [] + let peekRows: ScreenRow[] = [] // When older history exists, guarantee at least one row for the tail of the // next older message. If complete entries exactly consume the budget, refit // them with one fewer row; only the oldest complete entry can drop out, while @@ -1141,17 +1145,17 @@ function fitTranscript(entries: AnyEntry[], rowBudget: number, columns: number, first = reduced.first } } - const olderLines = renderedRows[first - 1].slice() - while (olderLines.length > 0 && !olderLines[0].trim()) olderLines.shift() - while (olderLines.length > 0 && !olderLines[olderLines.length - 1].trim()) olderLines.pop() + const olderRows = renderedRows[first - 1].slice() + while (olderRows.length > 0 && !olderRows[0].plain.trim()) olderRows.shift() + while (olderRows.length > 0 && !olderRows[olderRows.length - 1].plain.trim()) olderRows.pop() const spare = rowBudget - fitted.rows - if (spare > 0 && olderLines.length > 0) { - peekLines = olderLines.slice(-spare) + if (spare > 0 && olderRows.length > 0) { + peekRows = olderRows.slice(-spare) } } return { entries: available.slice(first), - peekLines, + peekRows, hiddenOlder: first, hiddenRecent: entries.length - tail, startIndex: first, diff --git a/mobius/tui/tests/screen.test.tsx b/mobius/tui/tests/screen.test.tsx index 655fbcc4..74def304 100644 --- a/mobius/tui/tests/screen.test.tsx +++ b/mobius/tui/tests/screen.test.tsx @@ -20,6 +20,8 @@ import { Box, Text } from 'ink' import { render } from 'ink-testing-library' import { Screen } from '../src/components/Screen.js' import { Select } from '../src/components/primitives.js' +import { fitTranscript } from '../src/components/Chat.js' +import type { AnyEntry } from '../src/types.js' const delay = (ms: number) => new Promise(r => setTimeout(r, ms)) const strip = (s: string) => s.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '') @@ -34,6 +36,18 @@ const ROWS = 24 async function main() { console.log('\n[SCREEN] no-residue picker transitions\n') + // A hidden older entry may contribute only its tail rows when the viewport + // has one spare row. That preview must retain the entry's foreground style; + // rendering it as a bare dimColor string makes a clipped cyan Markdown link + // look gray even though the rest of the message is colored. + const styledEntries: AnyEntry[] = [ + { type: 'assistant', uuid: 'styled-old', message: { role: 'assistant', content: [{ type: 'text', text: '[彩色链接](https://example.com)' }] } }, + { type: 'assistant', uuid: 'styled-new', message: { role: 'assistant', content: [{ type: 'text', text: '最新消息' }] } }, + ] + const fitted = fitTranscript(styledEntries, 3, 80) + ok(fitted.peekRows.length === 1, 'small viewport exposes one tail row from the hidden message') + ok(fitted.peekRows[0]?.styled.includes('\x1b[') && fitted.peekRows[0]?.styled.includes('彩色链接'), 'partial older row keeps its ANSI foreground styling') + // ── 1. Without Screen, a tall frame overflows the terminal (the bug). ─────── const tall = render( From 8a2d1e5b96a726f2e772e002d07b5d819d7c7aa9 Mon Sep 17 00:00:00 2001 From: Mobius OS Date: Sun, 9 Aug 2026 17:51:10 +0000 Subject: [PATCH 08/16] Update News section with weekly highlights (2026-07-16 ~ 2026-08-09): Windows one-key install, pure-membership project access, Easy Mode, TUI npm release, remote-compute MCP, editor/JSONL/search upgrades, desktop multi-tab & cluster overview --- README.md | 19 +++++++++++++++++++ README.zh.md | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/README.md b/README.md index 052ec052..3a8a8c0f 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,25 @@ One system to connect your team, AI agents, devices, and compute ## News +**2026-08-09** +- **Windows one-key installation**: a single PowerShell command installs Node, Python, coding agents, and the TUI on a fresh Windows machine — with npm mirror fallback, built-in AIMUX runtime diagnostics, and a fully verified install pipeline. +- **Pure-membership project access**: projects are now invisible to non-members; the member panel is redesigned Aone-style (role filter tabs + member table + search), and the create-project flow shares the same member-invite experience. +- **Desktop CI auto-build + webhook**: every release is auto-built and synced to the download menu. + +**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 published to npm as `@mobius-os/mobius`**: multiline/paste-aware composer, automatic SSE reconnect, and reasoning/thinking display. +- **Remote compute inside agent sessions**: aimux remote MCP tools are injected into claude-code / codex sessions so agents can call remote machines directly from the chat; the code-conversation file browser gains project-scoped remote browsing. + +**2026-07-26** +- **Native editor upgrades**: Markdown WYSIWYG editing, drag-to-move files/folders in the file tree, and source/rich-text mode switching. +- **JSONL viewer de-noising**: six classes of metadata noise cards are hidden, Cursor-style tool-call status icons and explore-tool aggregation added, plus animated card expansion. +- **Search**: results stream in via SSE with case/whole-word matching; clicking a result jumps to the exact JSONL card. + +**2026-07-19** +- **Desktop multi-tab & extension host bar** (experimental): tabbed pages in the desktop shell plus an embedded host bar for running extension apps. +- **Cluster overview page**: a hex-lattice session overview that clusters sessions by project and creator with physics-based layout. + **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. diff --git a/README.zh.md b/README.zh.md index f4cdefdf..e4db5c9d 100644 --- a/README.zh.md +++ b/README.zh.md @@ -36,6 +36,25 @@ ## 最新动态 +**2026-08-09** +- **Windows 一键安装**:单条 PowerShell 命令即可在全新 Windows 机器上装齐 Node、Python、编码 Agent 与 TUI——内置 npm 镜像超时回退、AIMUX 运行时诊断,安装流程经公网与真机双重验证。 +- **项目权限改纯成员制**:非成员不再可见项目;成员管理按 Aone 权限页风格重做(角色筛选 Tab + 成员表格 + 搜索),创建项目时的成员邀请与成员设置页完全统一。 +- **桌面端 CI 自动构建 + webhook**:每次发布自动构建并同步到下载菜单。 + +**2026-08-02** +- **简易模式上线**:可选的无干扰布局(跨项目近期会话 + JSONL + 悬浮输入框)。首次进入需在简易/常规模式间二选一,随时可从主题菜单切换。 +- **TUI 以 `@mobius-os/mobius` 发布到 npm**:输入框支持多行/粘贴自适应,SSE 断线自动重连,并展示模型推理/思考过程。 +- **远程算力进入 Agent 会话**:向 claude-code / codex 会话注入 aimux 远程 MCP 工具,对话中即可直接调用远程机器;代码对话文件浏览器新增项目级远程文件浏览。 + +**2026-07-26** +- **原生编辑器升级**:Markdown 富文本所见即所得(Tiptap)、文件树拖拽移动文件/目录、源码/富文本模式切换。 +- **JSONL 视图降噪**:隐藏 6 类元数据噪声卡片,新增 Cursor 式工具调用状态图标与探索类工具聚合,卡片展开收起带过渡动画。 +- **搜索优化**:结果经 SSE 流式返回、支持大小写/全字匹配,点击结果直接跳转到对应 JSONL 卡片。 + +**2026-07-19** +- **桌面端多标签与拓展宿主栏**(实验版):桌面壳支持多页签,并可在内嵌宿主栏中运行拓展应用。 +- **点阵会话概览页**:新增按项目/创建者聚类的六边形点阵概览,带物理力导向布局。 + **2026-07-16** - **网页终端新增两种打开方式**:可在当前项目目录打开普通 shell,也可打开终端后自动 attach 到当前会话的 Agent tmux 后台,直接查看 Agent TUI 运行状态。 From e33004dfd077aeb0879a75ab4ac5f67f1584bd5d Mon Sep 17 00:00:00 2001 From: Mobius OS Date: Sun, 9 Aug 2026 18:04:38 +0000 Subject: [PATCH 09/16] =?UTF-8?q?Support=20hub=20and=20local=20files=20in?= =?UTF-8?q?=20mention=20drawer=20(=E6=94=AF=E6=8C=81@=E6=8A=BD=E5=B1=89?= =?UTF-8?q?=E6=B5=8F=E8=A7=88=E4=B8=AD=E6=9E=A2=E5=92=8C=E6=9C=AC=E6=9C=BA?= =?UTF-8?q?=E6=96=87=E4=BB=B6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mobius/frontend/src/components/chat.tsx | 143 ++++++++++++++++-------- 1 file changed, 97 insertions(+), 46 deletions(-) diff --git a/mobius/frontend/src/components/chat.tsx b/mobius/frontend/src/components/chat.tsx index a1fafcc2..7f369574 100644 --- a/mobius/frontend/src/components/chat.tsx +++ b/mobius/frontend/src/components/chat.tsx @@ -1460,6 +1460,29 @@ type RemoteFileSource = { hardware?: string } +type MentionFileSource = { + key: string + kind: 'hub' | 'local' | 'remote' + name: string + status?: string + remote_path?: string +} + +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, open, onClose, onPickPath }: { projectId: string open: boolean @@ -1467,12 +1490,30 @@ function RemoteFileMentionDrawer({ projectId, open, onClose, onPickPath }: { onPickPath: (path: string) => void }) { const [sources, setSources] = useState([]) - const [selectedRemote, setSelectedRemote] = useState('') + const [selectedSourceKey, setSelectedSourceKey] = useState('hub') const [sourcesLoading, setSourcesLoading] = useState(false) const [sourcesError, setSourcesError] = useState('') 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) @@ -1481,11 +1522,16 @@ 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) } @@ -1504,21 +1550,29 @@ 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(() => { setDirs({}) setExpanded(new Set(['/'])) - if (open && selectedRemote) void loadDir('/') - }, [open, selectedRemote, loadDir]) + if (open && selectedSourceKey) void loadDir('/') + }, [open, selectedSourceKey, loadDir]) const toggleDir = useCallback((relPath: string) => { setExpanded(previous => { @@ -1537,7 +1591,7 @@ function RemoteFileMentionDrawer({ projectId, open, onClose, onPickPath }: { }, [onPickPath]) if (!open) return null - const selectedSource = sources.find(source => source.name === selectedRemote) + const selectedSource = sourceOptions.find(source => source.key === selectedSourceKey) const rootState = dirs['/'] return ( @@ -1558,7 +1612,7 @@ function RemoteFileMentionDrawer({ projectId, open, onClose, onPickPath }: {
-
远程服务器 · 文件
+
项目文件
选择文件,把绝对路径插入输入框
{sourcesLoading && sources.length === 0 ? (
- 加载远程服务器… -
- ) : sourcesError ? ( -
{sourcesError}
- ) : sources.length === 0 ? ( -
- 当前项目还没有可用的远程服务器。请先在项目设置中同步 AIMUX 远程算力清单。 + 加载文件来源…
) : ( -
- {sources.map(source => { - const active = source.name === selectedRemote - return ( - - ) - })} -
+ <> + {sourcesError &&
远程来源加载失败:{sourcesError}
} +
+ {sourceOptions.map(source => { + const active = source.key === selectedSourceKey + return ( + + ) + })} +
+ )}
- {selectedSource?.name || '未选择服务器'} + {selectedSource?.name || '未选择来源'} {selectedSource && } - {selectedSource?.remote_path || (selectedSource ? '默认登录目录' : '')} + {selectedSource?.kind === 'hub' ? '项目绑定路径' : selectedSource?.kind === 'local' ? 'Electron 本机路径' : (selectedSource?.remote_path || (selectedSource ? '默认登录目录' : ''))}
- {!selectedRemote || sources.length === 0 ? null : !rootState ? ( + {!selectedSource ? null : !rootState ? (
加载文件…
From c3da1c96394727b898ff506e31118f385c7bd500 Mon Sep 17 00:00:00 2001 From: Mobius OS Date: Sun, 9 Aug 2026 18:19:24 +0000 Subject: [PATCH 10/16] =?UTF-8?q?Improve=20desktop=20AIMUX=20MCP=20and=20p?= =?UTF-8?q?ath=20integration=20(=E4=BC=98=E5=8C=96=E6=A1=8C=E9=9D=A2?= =?UTF-8?q?=E7=AB=AF=20AIMUX=20MCP=20=E4=B8=8E=E8=B7=AF=E5=BE=84=E9=9B=86?= =?UTF-8?q?=E6=88=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mobius/backend/agents/tmux-claude-code.js | 2 +- mobius/backend/agents/tmux-codex.js | 2 +- mobius/backend/services/pc-client-context.ts | 17 +++---- .../desktop/electron/lib/aimux-supervisor.ts | 6 ++- mobius/desktop/electron/lib/project-paths.ts | 45 +++++++++++++++++++ .../electron/lib/windows-context-menu.ts | 31 +++++++++++++ mobius/desktop/electron/main.ts | 41 ++++++++++++++++- .../frontend/src/components/global-create.tsx | 2 +- mobius/frontend/src/components/modals.tsx | 4 +- mobius/frontend/src/pages/Welcome.tsx | 25 ++++++++--- mobius/tests/pc-client-context.js | 15 ++++--- 11 files changed, 161 insertions(+), 29 deletions(-) create mode 100644 mobius/desktop/electron/lib/windows-context-menu.ts diff --git a/mobius/backend/agents/tmux-claude-code.js b/mobius/backend/agents/tmux-claude-code.js index 0b15fd29..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() { 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/services/pc-client-context.ts b/mobius/backend/services/pc-client-context.ts index 89810559..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() @@ -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/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/frontend/src/components/global-create.tsx b/mobius/frontend/src/components/global-create.tsx index 134a7124..e558c998 100644 --- a/mobius/frontend/src/components/global-create.tsx +++ b/mobius/frontend/src/components/global-create.tsx @@ -1345,7 +1345,7 @@ export function CreateSessionForm({ onClose, onDone, onNavigate, defaultProjectI // 用户手填过名称 → 标记 name_touched, 后端置 name_human_edited=1, AI 标题生成器不再覆盖此名. name_touched: nameUserTouchedRef.current, // PC 任务模式 (仅桌面端): workMode 非空才附 pc_client_metadata; web 端恒 null → body 完全不变. - ...(workMode ? { pc_client_metadata: { work_mode: workMode, aimux_id: aimuxId, local_path: pcPath || undefined, is_tui: false } } : {}), + ...(workMode ? { pc_client_metadata: { work_mode: workMode, aimux_id: aimuxId, local_path: pcPath || undefined, is_tui: false, add_remote_aimux_mcp: true } } : {}), }) }) if (s?.error) { setErr(s.error); return } // 记录「恢复上次选择」快照 (项目/任务/语言/Skill·Memory), 下次新建可一键回填. 与工作草稿 (gc:new-session) 不同键, 提交清草稿不影响此快照. diff --git a/mobius/frontend/src/components/modals.tsx b/mobius/frontend/src/components/modals.tsx index 0d2a99c5..df7dd6a4 100644 --- a/mobius/frontend/src/components/modals.tsx +++ b/mobius/frontend/src/components/modals.tsx @@ -2464,7 +2464,7 @@ export function NewSessionModal({ ...(options.includeBody === false ? { include_body: false } : {}), ...(options.includeItemBodies === false ? { include_item_bodies: false } : {}), // PC 任务模式 (仅桌面端): 与 session 创建 body 同源, 让 preview 也注入 PC 提示词; web 端 workMode null 不传. - ...(workMode ? { pc_client_metadata: { work_mode: workMode, aimux_id: aimuxId, local_path: pcPath || undefined, is_tui: false } } : {}), + ...(workMode ? { pc_client_metadata: { work_mode: workMode, aimux_id: aimuxId, local_path: pcPath || undefined, is_tui: false, add_remote_aimux_mcp: true } } : {}), }), }) as WizardPreview }, [issueId, projectId, researchId, isResearch, isProjectPreset, presetContextPreviewEndpoint, name, submittedDescription, role, language, personality, workMode, aimuxId, pcPath]) @@ -2612,7 +2612,7 @@ export function NewSessionModal({ excluded_memory_ids: Array.from(excludedMemories), continue_from_session_id: continueFromSessionId || undefined, // PC 任务模式 (仅桌面端): workMode 非空才附带 pc_client_metadata; web 端 workMode 恒 null → body 完全不变. - ...(workMode ? { pc_client_metadata: { work_mode: workMode, aimux_id: aimuxId, local_path: pcPath || undefined, is_tui: false } } : {}), + ...(workMode ? { pc_client_metadata: { work_mode: workMode, aimux_id: aimuxId, local_path: pcPath || undefined, is_tui: false, add_remote_aimux_mcp: true } } : {}), }), }) draftClear(DRAFT_KEY) diff --git a/mobius/frontend/src/pages/Welcome.tsx b/mobius/frontend/src/pages/Welcome.tsx index 915ce898..306c0390 100644 --- a/mobius/frontend/src/pages/Welcome.tsx +++ b/mobius/frontend/src/pages/Welcome.tsx @@ -22,7 +22,7 @@ // 绑定路径 = mobius 中枢 agent 工作目录 (服务器侧 user.work_dir/<随机 slug>)。 // ===================================================================== import { useCallback, useEffect, useRef, useState } from 'react' -import { useNavigate } from 'react-router-dom' +import { useLocation, useNavigate } from 'react-router-dom' import { History, FolderInput, Plus, FileText, FolderOpen, ChevronLeft, ChevronDown, ChevronRight, FolderOpen as FolderBrowse, Dices, Loader2, Sparkles, Star, Search, @@ -145,6 +145,7 @@ type Step = 'menu' | 'project' | 'session' | 'projectList' export default function Welcome() { const { user, theme } = useStore() const navigate = useNavigate() + const location = useLocation() const dark = theme !== 'light' const md = getDesktopBridge() @@ -157,6 +158,9 @@ export default function Welcome() { const [sessionCtx, setSessionCtx] = useState(null) const [checking, setChecking] = useState(false) const [checkErr, setCheckErr] = useState('') + const requestedPath = (() => { + try { return new URLSearchParams(location.search).get('path') || '' } catch { return '' } + })() useEffect(() => { md?.getBootData?.().then(b => setBoot(b || null)).catch(() => {}) @@ -164,6 +168,14 @@ export default function Welcome() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + // Explorer's "在 Mobius 桌面端打开" fallback enters the same guided + // project flow as the welcome page, with the selected folder prefilled. + useEffect(() => { + if (!isDesktop || !requestedPath || flow || step !== 'menu') return + setFlow(FLOW.connect) + setStep('project') + }, [isDesktop, requestedPath, flow, step]) + if (!user) return null // ---- 菜单选项 ---- @@ -197,7 +209,7 @@ export default function Welcome() { if (step === 'project' && flow) { return ( { setStep('menu'); setCheckErr('') }} onIntoSession={(ctx) => { setSessionCtx(ctx); setStep('session') }} /> @@ -345,19 +357,20 @@ async function ensureIssue(projectId: string, title: string): Promise void onIntoSession: (ctx: SessionCtx) => void }) { const { user } = useStore() const [name, setName] = useState(flow.nameDefault) - const [localPath, setLocalPath] = useState('') - const [localPathTouched, setLocalPathTouched] = useState(false) + const [localPath, setLocalPath] = useState(initialLocalPath || '') + const [localPathTouched, setLocalPathTouched] = useState(!!initialLocalPath) const [advancedOpen, setAdvancedOpen] = useState(false) const [desc, setDesc] = useState('一个新项目') const [bindPath, setBindPath] = useState(() => randomBindPath(user?.work_dir)) @@ -677,7 +690,7 @@ function WelcomeSession({ flow, dark, isDesktop, ctx, onBack }: { name, description: finalDesc, model, language, excluded_skill_ids: excludedSkillIds, excluded_memory_ids: Array.from(excludedMemories), // PC 任务模式 (仅桌面端): workMode 非空才附 pc_client_metadata; web 端恒 null → body 完全不变. - ...(workMode ? { pc_client_metadata: { work_mode: workMode, aimux_id: aimuxId, local_path: pcPath || undefined, is_tui: false } } : {}), + ...(workMode ? { pc_client_metadata: { work_mode: workMode, aimux_id: aimuxId, local_path: pcPath || undefined, is_tui: false, add_remote_aimux_mcp: true } } : {}), }) }) if (s?.error) { window.clearInterval(timer); setSubmitting(false); setErr(s.error); return } // 等进度条走完 diff --git a/mobius/tests/pc-client-context.js b/mobius/tests/pc-client-context.js index fa0aa827..a8ff0de8 100644 --- a/mobius/tests/pc-client-context.js +++ b/mobius/tests/pc-client-context.js @@ -32,7 +32,7 @@ assert.match(tuiHubPrompt, /不要使用 remote_\* 工具操作.*在mobius中枢 'TUI hub prompt should select Mobius Hub work'); const tuiPcPrompt = pcTaskModePrompt({ work_mode: 'pc', aimux_id: device, local_path: localPath, is_tui: true }, 'zh'); -assert.match(tuiPcPrompt, /通过 remote_\* 工具在以下远程对象上执行所有工作/, +assert.match(tuiPcPrompt, /通过 remote_\* 工具在以下远程机器上执行所有工作/, 'TUI pc prompt should require remote execution via remote_* tools'); assert.match(tuiPcPrompt, /先将项目同步到mobius中枢.*每次修改后都立即同步回到 tui-workstation 指定路径/s, 'TUI pc prompt should describe hub sync and direct-aimux fallback'); @@ -40,10 +40,10 @@ assert.match(tuiPcPrompt, /先将项目同步到mobius中枢.*每次修改后都 const tuiDualPrompt = pcTaskModePrompt({ work_mode: 'dual', aimux_id: device, local_path: localPath, is_tui: true }, 'zh'); assert.match(tuiDualPrompt, /先修改本地的代码.*同步到tui-workstation上/s, 'TUI dual prompt should retain the synchronization rule'); -assert.strictEqual( +assert.match( pcTaskModePrompt({ work_mode: 'pc', aimux_id: device, local_path: localPath, is_tui: false }, 'zh'), - `使用aimux连接到以下远程对象执行所有工作,尽量不修改本地的代码: ${device}。该远程对象上的工作目录为:\`${localPath}\``, - 'Electron prompt should remain unchanged apart from explicit is_tui metadata', + /使用已注册的 remote_\* MCP 工具.*尽量不修改本地的代码/s, + 'Electron prompt should explain the shared remote MCP toolset', ); assert.match( pcTaskModePrompt({ work_mode: 'pc', aimux_id: device, local_path: localPath, is_tui: true }, 'en'), @@ -53,7 +53,8 @@ assert.match( assert.strictEqual(pcTaskModePrompt({ work_mode: 'invalid', aimux_id: device, is_tui: true }, 'zh'), '', 'invalid work modes should not produce a prompt'); -// aimuxRemoteNameFromMeta gate: requires is_tui === true AND add_remote_aimux_mcp === true AND aimux_id. +// aimuxRemoteNameFromMeta gate: requires explicit opt-in and aimux_id for both +// TUI and Electron desktop clients. assert.strictEqual( aimuxRemoteNameFromMeta({ work_mode: 'pc', aimux_id: device, is_tui: true, add_remote_aimux_mcp: true }), device, @@ -66,8 +67,8 @@ assert.strictEqual( ); assert.strictEqual( aimuxRemoteNameFromMeta({ work_mode: 'pc', aimux_id: device, is_tui: false, add_remote_aimux_mcp: true }), - undefined, - 'non-TUI sessions must not get MCP even with the flag', + device, + 'Electron sessions opt into the same MCP protocol with the flag', ); assert.strictEqual( aimuxRemoteNameFromMeta({ work_mode: 'pc', is_tui: true, add_remote_aimux_mcp: true }), From 7ec034f21e46af68c6807056294e842ec88fa37c Mon Sep 17 00:00:00 2001 From: Mobius Date: Sun, 9 Aug 2026 19:05:06 +0000 Subject: [PATCH 11/16] Add agent mention bridge --- mobius/backend/routes/agent-bridge.ts | 91 ++++ mobius/backend/routes/sessions.ts | 3 +- .../backend/services/agent-mention-bridge.ts | 185 +++++++ .../services/session-message-runner.ts | 158 +++++- mobius/frontend/src/components/chat.tsx | 472 +++++++++++++++--- mobius/server.js | 2 + 6 files changed, 832 insertions(+), 79 deletions(-) create mode 100644 mobius/backend/routes/agent-bridge.ts create mode 100644 mobius/backend/services/agent-mention-bridge.ts 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/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/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/frontend/src/components/chat.tsx b/mobius/frontend/src/components/chat.tsx index 7f369574..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' @@ -1468,6 +1468,21 @@ type MentionFileSource = { 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<{ @@ -1483,16 +1498,36 @@ function getChatDesktopFileBridge(): ChatDesktopFileBridge | undefined { return (window as { mobiusDesktop?: ChatDesktopFileBridge }).mobiusDesktop } -function RemoteFileMentionDrawer({ projectId, open, onClose, onPickPath }: { +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 [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(['/'])) @@ -1537,11 +1572,47 @@ function RemoteFileMentionDrawer({ projectId, open, onClose, onPickPath }: { } }, [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() } @@ -1569,10 +1640,12 @@ function RemoteFileMentionDrawer({ projectId, open, onClose, onPickPath }: { }, [projectId, selectedSourceKey, sourceOptions]) useEffect(() => { + if (!open) return + if (activeTab !== 'files') return setDirs({}) setExpanded(new Set(['/'])) - if (open && selectedSourceKey) void loadDir('/') - }, [open, selectedSourceKey, loadDir]) + if (selectedSourceKey) void loadDir('/') + }, [open, activeTab, selectedSourceKey, loadDir]) const toggleDir = useCallback((relPath: string) => { setExpanded(previous => { @@ -1590,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 = 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 ? ( -
- 加载文件来源… -
+ {activeTab === 'files' ? ( + <> +
+ 文件来源 + +
+ {sourcesLoading && sources.length === 0 ? ( +
+ 加载文件来源… +
+ ) : ( + <> + {sourcesError &&
远程来源加载失败:{sourcesError}
} +
+ {sourceOptions.map(source => { + const active = source.key === selectedSourceKey + return ( + + ) + })} +
+ + )} + ) : ( <> - {sourcesError &&
远程来源加载失败:{sourcesError}
} -
- {sourceOptions.map(source => { - const active = source.key === selectedSourceKey - return ( - - ) - })} +
+ + {agentScopeUrl ? (researchId ? 'Research 会话' : 'Issue 会话') : '无可用范围'} + +
+ + +
+
+
+ 候选智能体 +
+ {agentLoading && agentSessions.length === 0 ? ( +
+ 加载智能体… +
+ ) : ( + <> + {agentError &&
智能体加载失败:{agentError}
} + {!agentScopeUrl ? ( +
+ 当前会话没有 issue / research 范围,无法 @ 其他智能体。 +
+ ) : 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?.kind === 'hub' ? '项目绑定路径' : selectedSource?.kind === 'local' ? 'Electron 本机路径' : (selectedSource?.remote_path || (selectedSource ? '默认登录目录' : ''))} -
-
- {!selectedSource ? null : !rootState ? ( -
- 加载文件… + {activeTab === 'files' ? ( + <> +
+
+ + {selectedSource?.name || '未选择来源'} + {selectedSource && } + {selectedSource?.kind === 'hub' ? '项目绑定路径' : selectedSource?.kind === 'local' ? 'Electron 本机路径' : (selectedSource?.remote_path || (selectedSource ? '默认登录目录' : ''))}
- ) : ( - - )} +
+ {!selectedSource ? null : !rootState ? ( +
+ 加载文件… +
+ ) : ( + + )} +
+
+
+ + 点击文件后会替换当前的 @ 并回到输入框 +
+ + ) : ( +
+ + 选择智能体后会插入当前输入框,并把其上下文或双向桥接语义一起发送给后端
-
-
- - 点击文件后会替换当前的 @ 并回到输入框 -
+ )}
) @@ -2503,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 前面发送了 @@ -2529,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) } @@ -2549,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 @@ -2563,6 +2842,8 @@ export function ChatArea({ layout = 'default', onNewSession }: { useEffect(() => { remoteMentionRangeRef.current = null + setMentionQuery('') + setSelectedAgentMention(null) setRemoteFileDrawerOpen(false) }, [sessionId]) const loadHistoryRef = useRef<() => void>(() => {}) @@ -2571,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', @@ -3230,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 @@ -3239,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 @@ -3480,9 +3773,14 @@ export function ChatArea({ layout = 'default', onNewSession }: {
setRemoteFileDrawerOpen(false)} onPickPath={insertRemoteFilePath} + onPickAgent={insertAgentMention} /> {attachmentImagePreview && ( @@ -3852,6 +4150,26 @@ export function ChatArea({ layout = 'default', onNewSession }: { ))}
)} + {selectedAgentMention && ( +
+ + + @{selectedAgentMention.name} + + + {selectedAgentMention.mode === 'bidirectional' ? '双向' : '只读'} + + +
+ )}