mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 14:25:51 +00:00
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:
3
packages/remote-control-server/web/src/hooks/index.ts
Normal file
3
packages/remote-control-server/web/src/hooks/index.ts
Normal 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";
|
||||
12
packages/remote-control-server/web/src/hooks/useAuth.ts
Normal file
12
packages/remote-control-server/web/src/hooks/useAuth.ts
Normal 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 };
|
||||
}
|
||||
39
packages/remote-control-server/web/src/hooks/useCommands.ts
Normal file
39
packages/remote-control-server/web/src/hooks/useCommands.ts
Normal 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 };
|
||||
}
|
||||
103
packages/remote-control-server/web/src/hooks/useModels.ts
Normal file
103
packages/remote-control-server/web/src/hooks/useModels.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
163
packages/remote-control-server/web/src/hooks/useQRScanner.ts
Normal file
163
packages/remote-control-server/web/src/hooks/useQRScanner.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
25
packages/remote-control-server/web/src/hooks/useSSE.ts
Normal file
25
packages/remote-control-server/web/src/hooks/useSSE.ts
Normal 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]);
|
||||
}
|
||||
Reference in New Issue
Block a user