feat: ACP 协议版本 remote control (#293)

* fix: 添加 usage 字段缺失时的防御性防护

第三方 API(如智谱 GLM)在某些流式响应中不返回 usage 字段,
导致 usage.input_tokens 访问 undefined 崩溃并连锁影响后续所有请求。

- claude.ts: content_block_stop 创建消息时 fallback 到 EMPTY_USAGE
- LocalAgentTask.tsx: usage 为 undefined 时提前返回
- tokens.ts: getTokenCountFromUsage 加 null guard 和 ?? 0
- cost-tracker.ts: input_tokens/output_tokens 加 ?? 0

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

* feat: ACP Plan 展示 — 支持 session/update plan 类型的可视化

补全 PlanUpdate 类型定义(PlanEntry/Priority/Status),新建 PlanView 组件
渲染进度条、状态图标和优先级标签,在 ChatInterface 中处理 plan 更新逻辑。

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

* feat: 穷鬼模式下跳过 verification agent 以节省 token

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

* test: 补充 RCS 后端 + 前端测试覆盖 (+116 tests)

后端新增 3 个测试文件 (70 tests):
- automationState: normalize/snapshot/equals 纯函数
- client-payload: toClientPayload 协议转换
- transport-normalize: normalizePayload + extractContent

前端新增 2 个测试文件 (46 tests):
- utils: formatTime/statusClass/truncate/extractEventText 等
- api-client: getUuid/setUuid/api GET/POST 错误处理

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

* feat: RCS ACP 页面添加权限模式选择器 + 权限响应修复

- 新增权限模式选择器 UI(6种模式:默认/自动接受编辑/跳过权限/规划/不询问/自动判断)
- 权限模式通过 ACP _meta 从 web → acp-link → agent 全链路传递
- 修复 PermissionPanel 点击"允许"发送 cancelled 而非 selected 的 bug
- 权限模式和模型选择持久化到 localStorage
- acp-link 直接连接路径同步支持 permissionMode 透传

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

* feat: RCS Web UI 重构 + QR 修复 + ACP 扫描自动跳转

- RCS Web UI 组件全面重构: Dialog 迁移 Radix UI, lazy loading,
  主题系统改进, 组件样式优化
- IdentityPanel QR 码显示修复: requestAnimationFrame 延迟绘制
  解决 Radix Dialog Portal 挂载时序问题
- ACP QR 扫描自动跳转: IdentityPanel 扫描 ACP 格式 { url, token }
  后存储 sessionStorage 并跳转 /code/?acp=1
- 新增 ACPDirectView 组件: ACP 直连视图, 用 ACPClient 连接并
  渲染 ACPMain 聊天界面

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

* feat: ACP 权限管道改进 — 模式同步 + bypass 检测 + 统一权限流水线

- agent.ts: applySessionMode 同步 appState.toolPermissionContext.mode
- agent.ts: bypassPermissions 可用性检测 (非 root 或 sandbox 环境)
- permissions.ts: createAcpCanUseTool 接入 hasPermissionsToUseTool
  统一权限流水线, 替代原来分散的处理逻辑
- permissions.ts: 支持 onModeChange 回调, 模式变更时实时同步

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

* fix: acp-link 支持 permissionMode 默认值传递给 agent

客户端 (Zed/VS Code 等) 的 new_session 不一定携带 permissionMode,
导致 agent 收到 _meta: undefined, permission 回退到 default。

修复: handleNewSession 使用 fallback 链:
  客户端传值 > config.permissionMode > ACP_PERMISSION_MODE 环境变量

使用: ACP_PERMISSION_MODE=auto acp-link claude

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

* docs: 更新文档及说明

* fix: 修复类型错误

* chore: 提交脚本

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-18 21:54:22 +08:00
committed by GitHub
parent 34154ee3f5
commit 2e9aaf4993
54 changed files with 2400 additions and 435 deletions

View File

@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef } from "react";
import imageCompression from "browser-image-compression";
import type { ACPClient } from "../src/acp/client";
import type { SessionUpdate, PermissionRequestPayload, PermissionOption, ContentBlock, ImageContent } from "../src/acp/types";
import type { ThreadEntry, ToolCallStatus, ToolCallData, UserMessageImage, UserMessageEntry, AssistantMessageEntry, ToolCallEntry, ChatInputMessage, PendingPermission } from "../src/lib/types";
import type { ThreadEntry, ToolCallStatus, ToolCallData, UserMessageImage, UserMessageEntry, AssistantMessageEntry, ToolCallEntry, ChatInputMessage, PendingPermission, PlanDisplayEntry } from "../src/lib/types";
import { ChatView } from "./chat/ChatView";
import { ChatInput } from "./chat/ChatInput";
import { PermissionPanel } from "./chat/PermissionPanel";
@@ -44,13 +44,14 @@ function dataUrlToBlob(dataUrl: string): Blob {
return new Blob([bytes], { type: mimeType });
}
import { Plus } from "lucide-react";
import { Plus, Shield, ChevronDown, ChevronUp, Check } from "lucide-react";
import { Button } from "./ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "./ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
// =============================================================================
// Type Definitions - imported from shared types module
@@ -58,6 +59,72 @@ import {
interface ChatInterfaceProps {
client: ACPClient;
agentId?: string;
}
// =============================================================================
// Permission Mode Selector
// =============================================================================
const PERMISSION_MODES = [
{ value: "default", label: "默认", description: "手动审批权限请求" },
{ value: "acceptEdits", label: "自动接受编辑", description: "自动允许文件编辑操作" },
{ value: "bypassPermissions", label: "跳过权限", description: "跳过所有权限检查" },
{ value: "plan", label: "规划模式", description: "仅规划,不执行工具" },
{ value: "dontAsk", label: "不询问", description: "不弹出询问,自动拒绝" },
{ value: "auto", label: "自动判断", description: "AI 自动判断是否批准" },
] as const;
function PermissionModeSelector({
mode,
onModeChange,
}: {
mode: string;
onModeChange: (mode: string) => void;
}) {
const [open, setOpen] = useState(false);
const current = PERMISSION_MODES.find((m) => m.value === mode) ?? PERMISSION_MODES[0];
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="gap-1.5 text-muted-foreground hover:text-foreground h-7 px-2"
>
<Shield className="h-3 w-3" />
<span className="max-w-24 truncate">{current.label}</span>
{open ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-1" align="start">
{PERMISSION_MODES.map((m) => (
<button
key={m.value}
type="button"
onClick={() => {
onModeChange(m.value);
setOpen(false);
}}
className="flex w-full items-start gap-2 rounded-md px-2.5 py-2 text-left hover:bg-surface-2 transition-colors"
>
<span className="mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center">
{mode === m.value && <Check className="h-3.5 w-3.5 text-brand" />}
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-text-primary">{m.label}</div>
<div className="text-xs text-text-muted">{m.description}</div>
</div>
</button>
))}
</PopoverContent>
</Popover>
);
}
// =============================================================================
@@ -86,7 +153,7 @@ function findToolCallIndex(entries: ThreadEntry[], toolCallId: string): number {
// ChatInterface Component
// =============================================================================
export function ChatInterface({ client }: ChatInterfaceProps) {
export function ChatInterface({ client, agentId }: ChatInterfaceProps) {
// Flat list of entries (like Zed's entries: Vec<AgentThreadEntry>)
const [entries, setEntries] = useState<ThreadEntry[]>([]);
const [isLoading, setIsLoading] = useState(false);
@@ -95,6 +162,7 @@ export function ChatInterface({ client }: ChatInterfaceProps) {
const activeSessionIdRef = useRef<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const errorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [permissionMode, setPermissionMode] = useState(() => localStorage.getItem("acp_permission_mode") || "default");
// Reference: Zed's supports_images() checks prompt_capabilities.image
const [supportsImages, setSupportsImages] = useState(false);
const { commands: availableCommands } = useCommands(client);
@@ -109,6 +177,8 @@ export function ChatInterface({ client }: ChatInterfaceProps) {
setSessionReady(false);
}, []);
const storageKey = agentId ? `acp_last_session_${agentId}` : null;
const activateSession = useCallback((sessionId: string, options?: { resetEntries?: boolean }) => {
const shouldResetEntries = options?.resetEntries ?? true;
if (shouldResetEntries) {
@@ -118,8 +188,12 @@ export function ChatInterface({ client }: ChatInterfaceProps) {
setActiveSessionId(sessionId);
setSessionReady(true);
setSupportsImages(client.supportsImages);
// Persist session ID for restoration on remount
if (storageKey) {
try { localStorage.setItem(storageKey, sessionId); } catch {}
}
console.log("[ChatInterface] Active session:", sessionId, "supportsImages:", client.supportsImages);
}, [client]);
}, [client, storageKey]);
// =============================================================================
// Permission Request Handler
@@ -384,6 +458,38 @@ export function ChatInterface({ client }: ChatInterfaceProps) {
});
});
}
// Handle plan update (replace entire plan)
else if (update.sessionUpdate === "plan") {
setEntries((prev) => {
// Empty entries → remove existing plan
if (update.entries.length === 0) {
return prev.filter((e) => e.type !== "plan");
}
// Find last plan entry
const lastPlanIndex = prev.reduce(
(acc, entry, i) => (entry.type === "plan" ? i : acc),
-1,
);
if (lastPlanIndex >= 0) {
// Update existing plan in place
return prev.map((entry, index) =>
index === lastPlanIndex
? { ...entry, entries: update.entries }
: entry,
);
}
// Create new plan entry
const newPlanEntry: PlanDisplayEntry = {
type: "plan",
id: `plan-${Date.now()}`,
entries: update.entries,
};
return [...prev, newPlanEntry];
});
}
}, []);
// =============================================================================
@@ -429,8 +535,26 @@ export function ChatInterface({ client }: ChatInterfaceProps) {
errorTimerRef.current = setTimeout(() => setErrorMessage(null), 5000);
});
// Create session
client.createSession();
// Restore last session or create a new one
const lastSessionId = storageKey ? localStorage.getItem(storageKey) : null;
if (lastSessionId && (client.supportsLoadSession || client.supportsResumeSession)) {
console.log("[ChatInterface] Restoring session:", lastSessionId);
const restore = async () => {
try {
if (client.supportsLoadSession) {
await client.loadSession({ sessionId: lastSessionId });
} else {
await client.resumeSession({ sessionId: lastSessionId });
}
} catch (err) {
console.warn("[ChatInterface] Failed to restore session, creating new one:", err);
client.createSession(undefined, permissionMode);
}
};
restore();
} else {
client.createSession(undefined, permissionMode);
}
return () => {
if (errorTimerRef.current) clearTimeout(errorTimerRef.current);
client.setSessionCreatedHandler(() => {});
@@ -465,8 +589,8 @@ export function ChatInterface({ client }: ChatInterfaceProps) {
// 3. Create new session (like Zed's initial_state -> connection.new_session())
// The session_created handler will set sessionReady=true when ready
client.createSession();
}, [client, isLoading, resetThreadState]);
client.createSession(undefined, permissionMode);
}, [client, isLoading, resetThreadState, permissionMode]);
// Cancel handler - matches Zed's cancel() logic in acp_thread.rs
// 1. Mark all pending/running/waiting_for_confirmation tool calls as canceled
@@ -559,9 +683,36 @@ export function ChatInterface({ client }: ChatInterfaceProps) {
// Handle permission respond for unified PermissionPanel
const handlePermissionPanelRespond = useCallback((requestId: string, approved: boolean) => {
const kind = approved ? "accept_once" : "reject_once";
handlePermissionResponse(requestId, null, kind as PermissionOption["kind"] | null);
}, [handlePermissionResponse]);
// Find the matching permission request to get the real optionId
const perm = pendingPermissions.find((p) => p.requestId === requestId);
let optionId: string | null = null;
let optionKind: PermissionOption["kind"] | null = null;
if (perm?.options && perm.options.length > 0) {
if (approved) {
// Pick the first allow option (prefer allow_once, then allow_always)
const allowOpt = perm.options.find((o) => o.kind === "allow_once") ?? perm.options.find((o) => o.kind === "allow_always");
if (allowOpt) {
optionId = allowOpt.optionId;
optionKind = allowOpt.kind;
}
} else {
// Pick the first reject option
const rejectOpt = perm.options.find((o) => o.kind === "reject_once") ?? perm.options.find((o) => o.kind === "reject_always");
if (rejectOpt) {
optionId = rejectOpt.optionId;
optionKind = rejectOpt.kind;
}
}
}
// Fallback: if no matching option found, use null (cancelled)
if (!optionId) {
optionKind = approved ? "allow_once" : "reject_once";
}
handlePermissionResponse(requestId, optionId, optionKind);
}, [handlePermissionResponse, pendingPermissions]);
// Handle ChatInput submit — convert ChatInputMessage to ContentBlock[]
const handleChatInputSubmit = useCallback(async (message: ChatInputMessage) => {
@@ -667,7 +818,7 @@ export function ChatInterface({ client }: ChatInterfaceProps) {
{/* Error banner */}
{errorMessage && (
<div className="mx-auto max-w-3xl w-full px-4 pb-1">
<div className="mx-auto max-w-3xl w-full px-4 sm:px-8 pb-1">
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 px-3 py-2 text-sm text-red-700 dark:text-red-300 flex items-center justify-between">
<span>{errorMessage}</span>
<button
@@ -683,8 +834,11 @@ export function ChatInterface({ client }: ChatInterfaceProps) {
{/* Model selector + New thread + ChatInput */}
<div className="flex-shrink-0">
<div className="max-w-3xl mx-auto w-full px-3 sm:px-4 pb-1 flex items-center justify-between">
<ModelSelectorPopover client={client} />
<div className="max-w-3xl mx-auto w-full px-4 sm:px-8 pb-1 flex items-center justify-between">
<div className="flex items-center gap-1">
<PermissionModeSelector mode={permissionMode} onModeChange={(m: string) => { setPermissionMode(m); localStorage.setItem("acp_permission_mode", m); }} />
<ModelSelectorPopover client={client} />
</div>
{entries.length > 0 && (
<Tooltip>
<TooltipTrigger asChild>