feat: 支持 acp-link 包进行 acp 通用的 remote-control (#292)

* fix: 修复超时问题

* feat: 添加 acp-link 代码

* refactor: 样式重构完成

* feat: RCS 添加 ACP 后端支持

- 新增 ACP WebSocket handler (agent 注册、EventBus 订阅)
- 新增 relay handler (前端 WS → acp-link 透传 + EventBus inbound 转发)
- 新增 SSE event stream 供外部消费者订阅 channel group 事件
- ACP REST 接口无鉴权 (agents、channel-groups)
- WebSocket 端点保留 token 鉴权
- SPA 路由 /acp/ 指向 acp.html

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: 添加 ACP 专属前端界面

- 新增 /acp/ SPA 页面 (agent 列表 + 实时交互)
- Agent 列表按 channel group 分组,显示在线状态
- 通过 RCS WebSocket relay 与 agent 通信
- Vite multi-page 构建 (index.html + acp.html)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: acp-link 支持 RCS relay 双向通信

- rcs-upstream 新增 messageHandler 转发非控制消息
- server.ts 新增虚拟 WS + relay client state 处理 relay ACP 消息
- newSession/loadSession 补充 mcpServers 参数
- 连接成功后显示 ACP Dashboard URL

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 移除 FileExplorer 及文件操作相关代码

- 删除 FileExplorer 组件
- ACPMain 移除 Files tab,仅保留 Chat 和 History
- client.ts 移除 listDir/readFile/onFileChanges 等方法
- types.ts 移除 FileItem/FileContent/FileChange 等类型

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 修复类型问题

* feat: RCS 后端统一 ACP/Bridge 注册逻辑

- store: EnvironmentRecord 增加 capabilities 字段、storeFindEnvironmentByMachineName 复用逻辑
- store: 新增 storeGetSessionOwners,支持未绑定 session 自动 claim
- environment: registerEnvironment 支持 ACP 复用已有记录,返回 session_id
- session: resolveOwnedWebSessionId 支持无 owner session 自动绑定
- acp-ws-handler: 新增 handleIdentify 支持 REST+WS 两步注册
- acp routes: /acp/relay 和 /acp/agents 支持 UUID 认证
- event-bus: 增加 error 类型 payload 日志

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: acp-link 改 REST 注册 + WS identify 两步流程

- rcs-upstream: 新增 registerViaRest() 通过 POST /v1/environments/bridge 注册
- rcs-upstream: WS 连接后发送 identify 替代 register,携带 agentId
- rcs-upstream: 入口链接改为 /code/?sid=${sessionId} 实现用户绑定
- server: 修复心跳跳过 relay 虚拟连接的 bug
- server: maxSessions 配置传入 RCS upstream

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: 前端统一 Chat 组件 + ACP 聊天界面重构

- 新增 chat/ 组件: ChatView, ChatInput, MessageBubble, ToolCallGroup, PermissionPanel, SessionSidebar, CommandMenu
- ACPMain: 重构支持完整 ACP 协议交互(session/prompt/permission)
- rcs-chat-adapter: 统一 bridge session SSE 适配器
- ACPClient: 增强 session 管理、permission 流程、streaming 支持
- index.css: 新增 chat 相关样式、动画、布局
- useCommands: 新增快捷命令 hook

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 删除 /acp/ 独立页面,ACP 聊天统一到 /code/:sessionId

- 删除 acp.html、acp-main.tsx 入口文件和 pages/acp/ 目录
- SessionDetail: ACP session 在同一页面渲染 ACPSessionDetail 组件
- App.tsx: ?sid= 参数自动调用 apiBind 绑定用户 UUID
- Dashboard: 统一 session 列表导航,ACP 显示紫色标签
- relay-client: 改用 UUID 认证替代 API token
- EnvironmentList: 显示 workerType 标签(ACP Agent / Claude Code)
- index.ts: 移除 /acp/ SPA 路由,vite.config 移除 acp 入口

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* build: 更新构建及测试修复

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-18 17:59:29 +08:00
committed by GitHub
parent 29cc74a170
commit 34154ee3f5
142 changed files with 17847 additions and 5577 deletions

View File

@@ -0,0 +1,82 @@
import { useState, useEffect, useCallback } from "react";
import { apiFetchAllSessions, apiFetchEnvironments } from "../api/client";
import type { Session, Environment } from "../types";
import { EnvironmentList } from "../components/EnvironmentList";
import { SessionList } from "../components/SessionList";
import { NewSessionDialog } from "../components/NewSessionDialog";
interface DashboardProps {
onNavigateSession: (sessionId: string) => void;
}
export function Dashboard({ onNavigateSession }: DashboardProps) {
const [sessions, setSessions] = useState<Session[]>([]);
const [environments, setEnvironments] = useState<Environment[]>([]);
const [dialogOpen, setDialogOpen] = useState(false);
const loadDashboard = useCallback(async () => {
try {
const [sess, envs] = await Promise.all([apiFetchAllSessions(), apiFetchEnvironments()]);
setSessions(sess || []);
setEnvironments(envs || []);
} catch (err) {
console.error("Dashboard render error:", err);
}
}, []);
useEffect(() => {
loadDashboard();
const interval = setInterval(loadDashboard, 10000);
return () => clearInterval(interval);
}, [loadDashboard]);
const handleSessionCreated = (session: Session) => {
setDialogOpen(false);
onNavigateSession(session.id);
};
const handleSelectEnvironment = useCallback((env: Environment) => {
if (env.worker_type === "acp") {
// Navigate to ACP agent detail page (same origin, shares UUID auth)
window.history.pushState(null, "", `/acp/agent/${env.id}`);
// Force page reload to load ACP app
window.location.href = `/acp/agent/${env.id}`;
}
// Bridge environments: no direct navigation (sessions are listed below)
}, []);
const handleSelectSession = useCallback((sessionId: string) => {
onNavigateSession(sessionId);
}, [onNavigateSession]);
return (
<div className="mx-auto max-w-5xl px-4 py-8">
{/* Environments */}
<section className="mb-10">
<h2 className="mb-4 font-display text-lg font-semibold text-text-primary">Environments</h2>
<EnvironmentList environments={environments} onSelectEnvironment={handleSelectEnvironment} />
</section>
{/* Sessions */}
<section>
<div className="mb-4 flex items-center justify-between">
<h2 className="font-display text-lg font-semibold text-text-primary">Sessions</h2>
<button
onClick={() => setDialogOpen(true)}
className="rounded-lg bg-brand px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-light transition-colors"
>
+ New Session
</button>
</div>
<SessionList sessions={sessions} onSelect={handleSelectSession} />
</section>
<NewSessionDialog
open={dialogOpen}
environments={environments}
onClose={() => setDialogOpen(false)}
onCreated={handleSessionCreated}
/>
</div>
);
}

View File

@@ -0,0 +1,487 @@
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import {
apiFetchSession,
apiSendControl,
apiInterrupt,
} from "../api/client";
import type { Session, SessionEvent } from "../types";
import { isClosedSessionStatus, formatTime, cn } from "../lib/utils";
import { RCSChatAdapter } from "../lib/rcs-chat-adapter";
import type { ThreadEntry, PendingPermission } from "../lib/types";
import { StatusBadge } from "../components/Navbar";
import { TaskPanel } from "../components/TaskPanel";
import {
PermissionPromptView,
AskUserPanelView,
PlanPanelView,
} from "../components/PermissionViews";
// Unified chat components
import { ChatView } from "../../components/chat/ChatView";
import { ChatInput } from "../../components/chat/ChatInput";
import { TooltipProvider } from "../../components/ui/tooltip";
// ACP chat components
import { ACPClient, DisconnectRequestedError } from "../acp/client";
import { createRelayClient } from "../acp/relay-client";
import { ACPMain } from "../../components/ACPMain";
import { StatusDot } from "../../components/ui/connection-status";
interface SessionDetailProps {
sessionId: string;
}
export function SessionDetail({ sessionId }: SessionDetailProps) {
const [session, setSession] = useState<Session | null>(null);
const [sessionStatus, setSessionStatus] = useState<string | null>(null);
const [error, setError] = useState("");
const [taskPanelOpen, setTaskPanelOpen] = useState(false);
const [showMeta, setShowMeta] = useState(false);
const [entries, setEntries] = useState<ThreadEntry[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [pendingPermissions, setPendingPermissions] = useState<PendingPermission[]>([]);
const adapterRef = useRef<RCSChatAdapter | null>(null);
// Create RCSChatAdapter
const adapter = useMemo(
() =>
new RCSChatAdapter(sessionId, setEntries, {
onStatusChange: (status) => {
setSessionStatus(status);
},
onError: (err) => {
console.error("[RCSChatAdapter] error:", err);
},
onPermissionRequest: (permission) => {
setPendingPermissions((prev) => {
if (prev.some((p) => p.requestId === permission.requestId)) return prev;
return [...prev, permission];
});
},
}),
[sessionId],
);
useEffect(() => {
adapterRef.current = adapter;
return () => {
adapter.disconnect();
};
}, [adapter]);
// Load session data and initialize adapter
useEffect(() => {
let cancelled = false;
async function load() {
setError("");
try {
const sess = await apiFetchSession(sessionId);
if (cancelled) return;
setSession(sess);
setSessionStatus(sess.status);
} catch (err) {
if (cancelled) return;
setError(err instanceof Error ? err.message : "Failed to load session");
return;
}
try {
await adapter.init();
} catch (err) {
console.warn("Failed to init adapter:", err);
}
}
load();
return () => {
cancelled = true;
};
}, [sessionId, adapter]);
const closed = isClosedSessionStatus(sessionStatus);
// Send message via ChatInput
const handleSubmit = useCallback(
async (message: import("../../src/lib/types").ChatInputMessage) => {
const text = message.text.trim();
if (!text || closed) return;
setIsLoading(true);
try {
await adapter.sendMessage(text, message.images);
} catch (err) {
console.error("Send failed:", err);
}
},
[adapter, closed],
);
// Interrupt
const handleInterrupt = useCallback(async () => {
try {
await adapter.interrupt();
} catch (err) {
console.error("Interrupt failed:", err);
} finally {
setIsLoading(false);
}
}, [adapter]);
// Mark loading done when last assistant message stops streaming
useEffect(() => {
if (entries.length === 0) return;
const last = entries[entries.length - 1];
if (last?.type === "assistant_message" || last?.type === "tool_call") {
// If the last entry is no longer a streaming tool, consider loading done
if (last.type === "tool_call" && last.toolCall.status === "running") return;
setIsLoading(false);
}
}, [entries]);
// Permission actions
const handleApprovePermission = useCallback(
async (requestId: string) => {
try {
await adapter.respondPermission(requestId, true);
} catch (err) {
console.error("Failed to approve:", err);
}
setPendingPermissions((prev) => prev.filter((p) => p.requestId !== requestId));
},
[adapter],
);
const handleRejectPermission = useCallback(
async (requestId: string) => {
try {
await adapter.respondPermission(requestId, false);
} catch (err) {
console.error("Failed to reject:", err);
}
setPendingPermissions((prev) => prev.filter((p) => p.requestId !== requestId));
},
[adapter],
);
const handleSubmitAnswers = useCallback(
async (
requestId: string,
answers: Record<string, unknown>,
questions: import("../types").Question[],
) => {
try {
await apiSendControl(sessionId, {
type: "permission_response",
approved: true,
request_id: requestId,
updated_input: { questions, answers },
});
} catch (err) {
console.error("Failed to submit answers:", err);
}
setPendingPermissions((prev) => prev.filter((p) => p.requestId !== requestId));
},
[sessionId],
);
const handleSubmitPlanResponse = useCallback(
async (requestId: string, value: string, feedback?: string) => {
try {
if (value === "no") {
await apiSendControl(sessionId, {
type: "permission_response",
approved: false,
request_id: requestId,
...(feedback ? { message: feedback } : {}),
});
} else {
const modeMap: Record<string, string> = {
"yes-accept-edits": "acceptEdits",
"yes-default": "default",
};
await apiSendControl(sessionId, {
type: "permission_response",
approved: true,
request_id: requestId,
updated_permissions: [
{ type: "setMode", mode: modeMap[value] || "default", destination: "session" },
],
});
}
} catch (err) {
console.error("Failed to submit plan response:", err);
}
setPendingPermissions((prev) => prev.filter((p) => p.requestId !== requestId));
},
[sessionId],
);
if (error) {
return (
<div className="flex flex-1 items-center justify-center">
<div className="text-center">
<p className="text-status-error">{error}</p>
<a href="/code/" className="mt-4 inline-block text-brand hover:underline">
&larr; Back to Dashboard
</a>
</div>
</div>
);
}
if (!session) {
return (
<div className="flex flex-1 items-center justify-center">
<div className="text-text-muted">Loading session...</div>
</div>
);
}
// ACP session — render ACP relay chat
if (session.source === "acp" && session.environment_id) {
return <ACPSessionDetail sessionId={sessionId} agentId={session.environment_id} />;
}
return (
<TooltipProvider>
<div className="flex flex-1 flex-col overflow-hidden">
{/* Session Header */}
<div className="border-b bg-surface-1 px-4 py-3">
<div className="mx-auto max-w-5xl">
<div className="mb-1">
<a
href="/code/"
className="text-sm text-text-muted hover:text-text-secondary transition-colors no-underline"
>
&larr; Dashboard
</a>
</div>
<div className="flex items-start justify-between">
<div className="min-w-0">
<h2 className="font-display text-lg font-semibold text-text-primary">
{session.title || session.id}
</h2>
<div className="mt-1 flex flex-wrap items-center gap-2">
{sessionStatus && <StatusBadge status={sessionStatus} />}
<span className="text-xs text-text-muted">
{formatTime(session.created_at)}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowMeta(!showMeta)}
className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-text-muted hover:bg-surface-2 hover:text-text-secondary transition-colors"
title="Session info"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
</button>
<button
onClick={() => setTaskPanelOpen(!taskPanelOpen)}
className="flex items-center gap-1 rounded-md px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-2 transition-colors"
>
Tasks
</button>
</div>
</div>
{showMeta && (
<div className="mt-2 rounded-md bg-surface-2 px-3 py-2 text-xs text-text-muted space-y-1 font-mono">
<div><span className="text-text-secondary font-sans font-medium">Session</span> {session.id}</div>
{session.environment_id && (
<div><span className="text-text-secondary font-sans font-medium">Environment</span> {session.environment_id}</div>
)}
</div>
)}
</div>
</div>
{/* Chat messages — unified ChatView */}
<ChatView
entries={entries}
isLoading={isLoading}
emptyTitle="开始对话"
emptyDescription="输入消息开始聊天"
/>
{/* Unified Permission Panel — above input */}
{pendingPermissions.length > 0 && (
<div className="border-t bg-surface-1 px-4 py-3">
<div className="mx-auto max-w-3xl space-y-3">
{pendingPermissions.map((req) => (
<PermissionEventView
key={req.requestId}
request={req}
onApprove={() => handleApprovePermission(req.requestId)}
onReject={() => handleRejectPermission(req.requestId)}
onSubmitAnswers={handleSubmitAnswers}
onSubmitPlan={handleSubmitPlanResponse}
/>
))}
</div>
</div>
)}
{/* Unified ChatInput — claude.ai style */}
<ChatInput
onSubmit={handleSubmit}
isLoading={isLoading}
onInterrupt={handleInterrupt}
disabled={closed}
placeholder={closed ? "会话已关闭" : "输入消息..."}
/>
{/* Task Panel */}
{taskPanelOpen && <TaskPanel onClose={() => setTaskPanelOpen(false)} />}
</div>
</TooltipProvider>
);
}
// ============================================================
// Permission Event View — routes to correct UI
// ============================================================
function PermissionEventView({
request,
onApprove,
onReject,
onSubmitAnswers,
onSubmitPlan,
}: {
request: PendingPermission;
onApprove: () => void;
onReject: () => void;
onSubmitAnswers: (requestId: string, answers: Record<string, unknown>, questions: import("../types").Question[]) => void;
onSubmitPlan: (requestId: string, value: string, feedback?: string) => void;
}) {
const toolName = request.toolName;
const toolInput = request.toolInput;
const description = request.description || "";
if (toolName === "AskUserQuestion") {
const questions = (toolInput.questions as import("../types").Question[]) || [];
return (
<AskUserPanelView
requestId={request.requestId}
questions={questions}
description={description}
onSubmit={(answers) => onSubmitAnswers(request.requestId, answers, questions)}
onSkip={onReject}
/>
);
}
if (toolName === "ExitPlanMode") {
const planContent = (toolInput.plan as string) || "";
return (
<PlanPanelView
requestId={request.requestId}
planContent={planContent}
description={description}
onSubmit={(value, feedback) => onSubmitPlan(request.requestId, value, feedback)}
/>
);
}
return (
<PermissionPromptView
requestId={request.requestId}
toolName={toolName}
toolInput={toolInput}
description={description}
onApprove={onApprove}
onReject={onReject}
/>
);
}
// ============================================================
// ACP Session Detail — renders ACP relay chat in session page
// ============================================================
function ACPSessionDetail({ sessionId, agentId }: { sessionId: string; agentId: string }) {
const [client, setClient] = useState<ACPClient | null>(null);
const [connectionState, setConnectionState] = useState<"disconnected" | "connecting" | "connected" | "error">("disconnected");
const [error, setError] = useState<string | null>(null);
const clientRef = useRef<ACPClient | null>(null);
useEffect(() => {
const relayClient = createRelayClient(agentId);
relayClient.setConnectionStateHandler((state, err) => {
setConnectionState(state);
setError(err || null);
});
clientRef.current = relayClient;
setClient(relayClient);
relayClient.connect().catch((e) => {
if (e instanceof DisconnectRequestedError) return;
setError((e as Error).message);
setConnectionState("error");
});
return () => {
relayClient.disconnect();
clientRef.current = null;
setClient(null);
setConnectionState("disconnected");
};
}, [agentId]);
return (
<TooltipProvider>
<div className="flex flex-1 flex-col overflow-hidden">
{/* Header */}
<div className="border-b bg-surface-1 px-4 py-3">
<div className="mx-auto max-w-5xl">
<div className="mb-1">
<a href="/code/" className="text-sm text-text-muted hover:text-text-secondary transition-colors no-underline">
&larr; Dashboard
</a>
</div>
<div className="flex items-center gap-3">
<StatusDot state={connectionState} />
<h2 className="font-display text-lg font-semibold text-text-primary">
{agentId}
</h2>
<span className="rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-700">ACP</span>
</div>
</div>
</div>
{error && connectionState === "error" && (
<div className="px-4 py-2 bg-destructive/10 text-destructive text-sm border-b">
{error}
</div>
)}
{connectionState === "connecting" && (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin h-8 w-8 border-2 border-brand border-t-transparent rounded-full mx-auto mb-3" />
<p className="text-text-muted text-sm">Connecting to agent...</p>
</div>
</div>
)}
{connectionState === "error" && !client && (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<p className="font-medium mb-1">Connection Failed</p>
<p className="text-text-muted text-sm">{error}</p>
</div>
</div>
)}
{client && connectionState === "connected" && (
<div className="flex-1 min-h-0">
<ACPMain client={client} />
</div>
)}
</div>
</TooltipProvider>
);
}