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,3 @@
export { useModels, type UseModelsResult } from "./useModels";
export { useCommands, type UseCommandsResult } from "./useCommands";
export { useQRScanner, type QRCodeData, type UseQRScannerOptions, type UseQRScannerResult } from "./useQRScanner";

View File

@@ -0,0 +1,12 @@
import { useState, useCallback } from "react";
import { getUuid, setUuid } from "../api/client";
export function useAuth() {
const [uuid] = useState(() => getUuid());
const importUuid = useCallback((newUuid: string) => {
setUuid(newUuid);
}, []);
return { uuid, importUuid };
}

View File

@@ -0,0 +1,39 @@
import { useState, useEffect, useMemo } from "react";
import type { ACPClient } from "../acp/client";
import type { AvailableCommand } from "../acp/types";
export interface UseCommandsResult {
/** List of available slash commands from the agent */
commands: AvailableCommand[];
/** Whether any commands are available */
hasCommands: boolean;
}
/**
* Hook to manage available commands state.
* Follows the same pattern as useModels — event-driven with immediate callback.
*/
export function useCommands(client: ACPClient): UseCommandsResult {
const [commands, setCommands] = useState<AvailableCommand[]>(
client.availableCommands,
);
useEffect(() => {
const handleCommandsChanged = (newCommands: AvailableCommand[]) => {
setCommands(newCommands);
};
client.setAvailableCommandsChangedHandler(handleCommandsChanged);
return () => {
client.setAvailableCommandsChangedHandler(() => {});
};
}, [client]);
const hasCommands = useMemo(
() => commands.length > 0,
[commands],
);
return { commands, hasCommands };
}

View File

@@ -0,0 +1,103 @@
import { useState, useEffect, useMemo, useCallback } from "react";
import type { ACPClient } from "../acp/client";
import type { ModelInfo, SessionModelState } from "../acp/types";
export interface UseModelsResult {
/** Whether model selection is supported by the current agent */
supportsModelSelection: boolean;
/** List of available models */
availableModels: ModelInfo[];
/** The currently selected model ID */
currentModelId: string | null;
/** The currently selected model info */
currentModel: ModelInfo | null;
/** Set the model for the current session */
setModel: (modelId: string) => Promise<void>;
/** Whether a model change is in progress */
isLoading: boolean;
}
/**
* Hook to manage model selection state.
* Reference: Zed's AcpModelSelector reads from state.available_models and state.current_model_id
*
* Uses event-driven updates instead of polling:
* - setModelStateChangedHandler: called on session create/disconnect
* - setModelChangedHandler: called when model selection changes
*/
export function useModels(client: ACPClient): UseModelsResult {
const [modelState, setModelState] = useState<SessionModelState | null>(
client.modelState
);
const [isLoading, setIsLoading] = useState(false);
// Subscribe to model state changes (session created/destroyed)
// This replaces the previous 500ms polling approach
useEffect(() => {
// Handler for when model state changes (session created or disconnected)
const handleModelStateChanged = (state: SessionModelState | null) => {
setModelState(state);
};
// Handler for when current model changes within a session
const handleModelChanged = (modelId: string) => {
setModelState((prev) => {
if (!prev) return null;
return {
...prev,
currentModelId: modelId,
};
});
setIsLoading(false);
};
// Register handlers - setModelStateChangedHandler immediately calls with current state
client.setModelStateChangedHandler(handleModelStateChanged);
client.setModelChangedHandler(handleModelChanged);
return () => {
// Clear handlers on unmount
client.setModelStateChangedHandler(() => {});
client.setModelChangedHandler(() => {});
};
}, [client]);
const availableModels = useMemo(
() => modelState?.availableModels ?? [],
[modelState]
);
const currentModelId = modelState?.currentModelId ?? null;
const currentModel = useMemo(
() =>
availableModels.find((m) => m.modelId === currentModelId) ?? null,
[availableModels, currentModelId]
);
const setModel = useCallback(
async (modelId: string) => {
if (!modelState) {
throw new Error("Model selection not supported");
}
setIsLoading(true);
try {
await client.setSessionModel(modelId);
// The model_changed event will update the state
} catch (error) {
setIsLoading(false);
throw error;
}
},
[client, modelState]
);
return {
supportsModelSelection: modelState !== null && availableModels.length > 0,
availableModels,
currentModelId,
currentModel,
setModel,
isLoading,
};
}

View File

@@ -0,0 +1,163 @@
import { useState, useEffect, useRef, useCallback } from "react";
import QrScanner from "qr-scanner";
/** QR code data format for scanning */
export interface QRCodeData {
url: string;
token: string;
}
export interface UseQRScannerOptions {
/** Called when a valid QR code is scanned */
onScan: (data: QRCodeData) => void;
/** Called when an error occurs */
onError?: (error: string) => void;
}
export interface UseQRScannerResult {
/** Whether the scanner is currently active */
isScanning: boolean;
/** Ref to attach to the video element */
videoRef: React.RefObject<HTMLVideoElement | null>;
/** Start scanning */
startScanning: () => void;
/** Stop scanning */
stopScanning: () => void;
/** Scan QR code from a file (e.g., from photo album) */
scanFromFile: (file: File) => Promise<void>;
}
/**
* Hook for QR code scanning functionality.
* Manages QrScanner lifecycle and camera access.
*/
export function useQRScanner({
onScan,
onError,
}: UseQRScannerOptions): UseQRScannerResult {
const [isScanning, setIsScanning] = useState(false);
const videoRef = useRef<HTMLVideoElement | null>(null);
const qrScannerRef = useRef<QrScanner | null>(null);
// Store callbacks in refs to avoid re-creating scanner when callbacks change
// This allows callers to pass inline functions without causing re-renders
const onScanRef = useRef(onScan);
const onErrorRef = useRef(onError);
// Keep refs up to date
useEffect(() => {
onScanRef.current = onScan;
onErrorRef.current = onError;
}, [onScan, onError]);
const startScanning = useCallback(() => {
setIsScanning(true);
}, []);
const stopScanning = useCallback(() => {
if (qrScannerRef.current) {
qrScannerRef.current.stop();
qrScannerRef.current.destroy();
qrScannerRef.current = null;
}
setIsScanning(false);
}, []);
// Scan QR code from a file (photo album)
const scanFromFile = useCallback(async (file: File) => {
try {
const result = await QrScanner.scanImage(file, {
returnDetailedScanResult: true,
});
const data = JSON.parse(result.data) as QRCodeData;
if (data.url && data.token) {
onScanRef.current(data);
} else {
onErrorRef.current?.("Invalid QR code: missing url or token");
}
} catch (e) {
const message = e instanceof Error ? e.message : "No QR code found";
onErrorRef.current?.(message);
}
}, []);
// Initialize scanner when isScanning becomes true
useEffect(() => {
if (!isScanning || !videoRef.current) return;
let isCancelled = false;
let scanner: QrScanner | null = null;
const initScanner = async () => {
try {
const newScanner = new QrScanner(
videoRef.current!,
(result) => {
try {
const data = JSON.parse(result.data) as QRCodeData;
if (data.url && data.token) {
// Stop scanning and notify
newScanner.stop();
newScanner.destroy();
qrScannerRef.current = null;
setIsScanning(false);
onScanRef.current(data);
}
} catch {
// Not valid JSON, ignore
}
},
{
returnDetailedScanResult: true,
highlightScanRegion: true,
highlightCodeOutline: true,
}
);
if (isCancelled) {
newScanner.destroy();
return;
}
scanner = newScanner;
qrScannerRef.current = newScanner;
await newScanner.start();
if (isCancelled) {
newScanner.stop();
newScanner.destroy();
qrScannerRef.current = null;
}
} catch (e) {
if (!isCancelled) {
onErrorRef.current?.(`Camera error: ${(e as Error).message}`);
setIsScanning(false);
}
}
};
initScanner();
return () => {
isCancelled = true;
if (scanner) {
scanner.stop();
scanner.destroy();
}
if (qrScannerRef.current) {
qrScannerRef.current.stop();
qrScannerRef.current.destroy();
qrScannerRef.current = null;
}
};
}, [isScanning]); // Only depend on isScanning, callbacks are accessed via refs
return {
isScanning,
videoRef,
startScanning,
stopScanning,
scanFromFile,
};
}

View File

@@ -0,0 +1,25 @@
import { useEffect, useRef, useCallback } from "react";
import { connectSSE, disconnectSSE } from "../api/sse";
import type { SessionEvent } from "../types";
export function useSSE(
sessionId: string | null,
onEvent: (event: SessionEvent) => void,
) {
const onEventRef = useRef(onEvent);
onEventRef.current = onEvent;
const stableCallback = useCallback((event: SessionEvent) => {
onEventRef.current(event);
}, []);
useEffect(() => {
if (!sessionId) return;
connectSSE(sessionId, stableCallback);
return () => {
disconnectSSE();
};
}, [sessionId, stableCallback]);
}