feat: remote control 支持 auto bind 功能 (#300)

* feat: acp-link 支持 --group 参数指定 channel group

- 添加 --group CLI flag,校验格式 [a-zA-Z0-9_-]+
- 支持 ACP_RCS_GROUP 环境变量 fallback
- 传递 channelGroupId 到 RcsUpstreamClient
- 更新 README 文档说明 --group 和相关环境变量

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

* fix: RCS 后端 session 复用与 group 绑定

- storeFindEnvironmentByMachineName 匹配 offline 状态,防止重连创建重复 session
- registerEnvironment 复用已有 session 而非每次新建
- EnvironmentResponse 返回 channel_group_id 字段
- 注册时将 session 绑定到 group ID,支持 web UI 按 group 查询
- apiKeyAuth 不再设置 uuid,由 uuidAuth 统一处理

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

* feat: Web UI Token Manager — 多 token 切换与 session 隔离

- 新增 useTokens hook 管理 localStorage token CRUD
- 新增 TokenManagerDialog 弹窗组件(添加/编辑/删除/切换 token)
- api client 支持Bearer token 认证,UUID 跟随 token 变化
- Navbar 添加 token 切换按钮
- 切换 token 时自动 reload,实现 session 数据隔离

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

* fix: 修复 useTokens useState 初始化函数签名错误

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-19 13:04:09 +08:00
committed by GitHub
parent 673ccd1800
commit c7bc8c8636
15 changed files with 508 additions and 22 deletions

View File

@@ -41,6 +41,9 @@ acp-link --https /path/to/agent
# Disable authentication (dangerous)
acp-link --no-auth /path/to/agent
# Register to RCS with a specific channel group
acp-link --group my-team /path/to/agent
# Pass arguments to the agent (use -- to separate)
acp-link /path/to/agent -- --verbose --model gpt-4
```
@@ -49,7 +52,7 @@ acp-link /path/to/agent -- --verbose --model gpt-4
```
USAGE
acp-link [--port value] [--host value] [--debug] [--no-auth] [--https] <command>...
acp-link [--port value] [--host value] [--debug] [--no-auth] [--https] [--group value] <command>...
acp-link --help
acp-link --version
@@ -59,6 +62,7 @@ FLAGS
[--debug] Enable debug logging to file
[--no-auth] Disable authentication (dangerous)
[--https] Enable HTTPS with self-signed cert
[--group] Channel group ID for RCS registration (letters, digits, hyphens, underscores only)
-h --help Print help information and exit
-v --version Print version information and exit
@@ -84,6 +88,18 @@ ws://localhost:9315/ws?token=<your-token>
Set `ACP_AUTH_TOKEN` env var to use a fixed token, or use `--no-auth` to disable (not recommended).
## RCS Upstream
acp-link can register to a Remote Control Server (RCS) for remote access. Set the following environment variables:
| Variable | Description |
|----------|-------------|
| `ACP_RCS_URL` | RCS server URL (e.g. `http://rcs.example.com:3000`) |
| `ACP_RCS_TOKEN` | API token for RCS authentication |
| `ACP_RCS_GROUP` | Channel group ID to lock the agent into (letters, digits, `-`, `_` only) |
You can also use `--group <id>` on the CLI. The CLI flag takes priority over the env var.
## License
MIT

View File

@@ -1,6 +1,6 @@
{
"name": "acp-link",
"version": "1.0.1",
"version": "1.1.0",
"description": "ACP proxy server that bridges WebSocket clients to ACP agents",
"author": "claude-code-best",
"type": "module",
@@ -14,7 +14,7 @@
],
"scripts": {
"build": "tsc",
"dev": "bun run src/cli/bin.ts",
"dev": "ACP_RCS_URL=http://localhost:3000 ACP_RCS_TOKEN=test-my-key bun run src/cli/bin.ts ccb-bun -- --acp",
"prepublishOnly": "bun run build"
},
"devDependencies": {

View File

@@ -40,6 +40,17 @@ export const command = buildCommand({
brief: "Enable HTTPS with auto-generated self-signed certificate",
default: false,
},
group: {
kind: "parsed",
parse: (value: string) => {
if (!/^[a-zA-Z0-9_-]+$/.test(value)) {
throw new Error(`Invalid group "${value}": only letters, digits, hyphens, and underscores are allowed`);
}
return value;
},
brief: "Channel group ID for RCS registration (env: ACP_RCS_GROUP)",
optional: true,
},
},
positional: {
kind: "array",
@@ -53,7 +64,7 @@ export const command = buildCommand({
},
func: async function (
this: LocalContext,
flags: { port: number; host: string; debug: boolean; "no-auth": boolean; https: boolean },
flags: { port: number; host: string; debug: boolean; "no-auth": boolean; https: boolean; group: string | undefined },
...args: readonly string[]
) {
const port = flags.port;
@@ -61,6 +72,7 @@ export const command = buildCommand({
const debug = flags.debug;
const noAuth = flags["no-auth"];
const https = flags.https;
const group = flags.group;
const [command, ...agentArgs] = args;
const cwd = process.cwd();
@@ -85,6 +97,6 @@ export const command = buildCommand({
// Import and run the server
const { startServer } = await import("../server.js");
await startServer({ port, host, command: command!, args: [...agentArgs], cwd, debug, token, https });
await startServer({ port, host, command: command!, args: [...agentArgs], cwd, debug, token, https, group });
},
});

View File

@@ -22,6 +22,8 @@ export interface ServerConfig {
https?: boolean;
/** Default permission mode for new sessions (e.g. "auto", "default", "bypassPermissions") */
permissionMode?: string;
/** Channel group ID for RCS registration */
group?: string;
}
// Pending permission request
@@ -608,11 +610,16 @@ export async function startServer(config: ServerConfig): Promise<void> {
// Initialize RCS upstream client if configured
const rcsUrl = process.env.ACP_RCS_URL;
const rcsToken = process.env.ACP_RCS_TOKEN;
const rcsGroup = config.group || process.env.ACP_RCS_GROUP;
if (rcsGroup && !/^[a-zA-Z0-9_-]+$/.test(rcsGroup)) {
throw new Error(`Invalid ACP_RCS_GROUP "${rcsGroup}": only letters, digits, hyphens, and underscores are allowed`);
}
if (rcsUrl) {
rcsUpstream = new RcsUpstreamClient({
rcsUrl,
apiToken: rcsToken || "",
agentName: command,
channelGroupId: rcsGroup || undefined,
maxSessions: 1,
});

View File

@@ -90,9 +90,20 @@ export function getUuidFromRequest(c: Context): string | undefined {
/**
* UUID-based auth for Web UI routes (no-login mode).
* Requires a UUID in query param or header, injects it into context as c.set("uuid").
* Accepts UUID in query param/header, OR a valid API key via Authorization header.
*/
export async function uuidAuth(c: Context, next: Next) {
// Try API key auth via Authorization header
const bearer = extractBearerToken(c);
if (bearer && validateApiKey(bearer)) {
// Valid API key — generate a stable UUID from the key for downstream use
const uuid = getUuidFromRequest(c);
c.set("uuid", uuid || bearer);
await next();
return;
}
// Fall back to UUID auth
const uuid = getUuidFromRequest(c);
if (!uuid) {
return c.json({ error: { type: "unauthorized", message: "Missing UUID" } }, 401);

View File

@@ -1,6 +1,7 @@
import { Hono } from "hono";
import { registerEnvironment, deregisterEnvironment, reconnectEnvironment } from "../../services/environment";
import { apiKeyAuth, acceptCliHeaders } from "../../auth/middleware";
import { storeBindSession } from "../../store";
const app = new Hono();
@@ -9,6 +10,13 @@ app.post("/bridge", acceptCliHeaders, apiKeyAuth, async (c) => {
const body = await c.req.json();
const username = c.get("username");
const result = registerEnvironment({ ...body, username });
// Bind ACP session to the group ID so the web UI can find it by group
if (result.session_id) {
const groupId = body.bridge_id as string | undefined;
if (groupId) {
storeBindSession(result.session_id, groupId);
}
}
return c.json(result, 200);
});

View File

@@ -6,6 +6,7 @@ import {
storeUpdateEnvironment,
storeListActiveEnvironments,
storeListActiveEnvironmentsByUsername,
storeListSessionsByEnvironment,
} from "../store";
import type { RegisterEnvironmentRequest, EnvironmentResponse } from "../types/api";
import type { EnvironmentRecord } from "../store";
@@ -20,6 +21,7 @@ function toResponse(row: EnvironmentRecord): EnvironmentResponse {
username: row.username,
last_poll_at: row.lastPollAt ? row.lastPollAt.getTime() / 1000 : null,
worker_type: row.workerType,
channel_group_id: row.bridgeId,
capabilities: row.capabilities,
};
}
@@ -41,14 +43,19 @@ export function registerEnvironment(req: RegisterEnvironmentRequest & { metadata
});
let sessionId: string | undefined;
// ACP agents: auto-create a session so they appear in the dashboard sessions list
// ACP agents: reuse existing session or create one
if (workerType === "acp") {
const session = storeCreateSession({
environmentId: record.id,
title: req.machine_name || "ACP Agent",
source: "acp",
});
sessionId = session.id;
const existing = storeListSessionsByEnvironment(record.id);
if (existing.length > 0) {
sessionId = existing[0].id;
} else {
const session = storeCreateSession({
environmentId: record.id,
title: req.machine_name || "ACP Agent",
source: "acp",
});
sessionId = session.id;
}
}
return { environment_id: record.id, environment_secret: record.secret, status: record.status as "active", session_id: sessionId };

View File

@@ -98,13 +98,14 @@ export function storeDeleteToken(token: string): boolean {
// ---------- Environment ----------
/** Find an active environment by machineName (optionally filtered by workerType) */
/** Find an active or offline environment by machineName (optionally filtered by workerType).
* Includes "offline" so ACP agents can be reused on reconnect. */
export function storeFindEnvironmentByMachineName(
machineName: string,
workerType?: string,
): EnvironmentRecord | undefined {
for (const rec of environments.values()) {
if (rec.machineName === machineName && rec.status === "active") {
if (rec.machineName === machineName && (rec.status === "active" || rec.status === "offline")) {
if (!workerType || rec.workerType === workerType) {
return rec;
}
@@ -313,12 +314,32 @@ export function storeGetSessionOwners(sessionId: string): Set<string> | undefine
export function storeListSessionsByOwnerUuid(uuid: string): SessionRecord[] {
const result: SessionRecord[] = [];
const resultIds = new Set<string>();
// Collect sessions already owned by this UUID
for (const [sessionId, owners] of sessionOwners) {
if (owners.has(uuid)) {
const session = sessions.get(sessionId);
if (session) result.push(session);
if (session) {
result.push(session);
resultIds.add(sessionId);
}
}
}
// Auto-bind orphaned sessions (no owner — typically ACP agent sessions created via REST registration)
for (const [sessionId, session] of sessions) {
if (resultIds.has(sessionId)) continue;
const owners = sessionOwners.get(sessionId);
// No owners map entry at all, or empty owners set
const isOrphaned = !owners || owners.size === 0;
if (isOrphaned) {
storeBindSession(sessionId, uuid);
result.push(session);
resultIds.add(sessionId);
}
}
return result;
}

View File

@@ -107,6 +107,7 @@ export interface EnvironmentResponse {
username: string | null;
last_poll_at: number | null;
worker_type?: string;
channel_group_id?: string | null;
capabilities?: Record<string, unknown> | null;
}

View File

@@ -1,9 +1,11 @@
import { useState, useEffect, useCallback, lazy, Suspense } from "react";
import { Navbar } from "./components/Navbar";
import { IdentityPanel } from "./components/IdentityPanel";
import { TokenManagerDialog } from "./components/TokenManagerDialog";
import { ThemeProvider } from "./lib/theme";
import { getUuid, setUuid, apiBind } from "./api/client";
import { getUuid, setUuid, apiBind, setActiveApiToken } from "./api/client";
import { ACPDirectView } from "./components/ACPDirectView";
import { useTokens } from "./hooks/useTokens";
const Dashboard = lazy(() => import("./pages/Dashboard").then((m) => ({ default: m.Dashboard })));
const SessionDetail = lazy(() => import("./pages/SessionDetail").then((m) => ({ default: m.SessionDetail })));
@@ -11,7 +13,18 @@ const SessionDetail = lazy(() => import("./pages/SessionDetail").then((m) => ({
export default function App() {
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [identityOpen, setIdentityOpen] = useState(false);
const [tokenDialogOpen, setTokenDialogOpen] = useState(false);
const [acpDirect, setAcpDirect] = useState<{ url: string; token: string } | null>(null);
const { tokens, activeTokenId, activeLabel, activeTokenValue, setActiveTokenId, addToken, removeToken, updateToken } = useTokens();
// Sync active token to API client
useEffect(() => {
setActiveApiToken(activeTokenValue);
}, [activeTokenValue]);
const handleSetActiveToken = useCallback((id: string) => {
setActiveTokenId(id);
}, [setActiveTokenId]);
// Simple hash-based router
const parseRoute = useCallback(() => {
@@ -97,6 +110,8 @@ export default function App() {
<div className="flex h-screen flex-col bg-surface-0 text-text-primary">
<Navbar
onIdentityClick={() => setIdentityOpen(true)}
onTokenClick={() => setTokenDialogOpen(true)}
activeTokenLabel={currentSessionId ? undefined : activeLabel}
sessionTitle={currentSessionId || (acpDirect ? "ACP" : undefined)}
onBack={(currentSessionId || acpDirect) ? navigateToDashboard : undefined}
/>
@@ -114,6 +129,17 @@ export default function App() {
</Suspense>
<IdentityPanel open={identityOpen} onClose={() => setIdentityOpen(false)} />
<TokenManagerDialog
open={tokenDialogOpen}
onClose={() => setTokenDialogOpen(false)}
tokens={tokens}
activeTokenId={activeTokenId}
onSetActive={handleSetActiveToken}
onAdd={addToken}
onRemove={removeToken}
onUpdate={updateToken}
/>
</div>
</ThemeProvider>
);

View File

@@ -24,11 +24,35 @@ export function setUuid(uuid: string): void {
localStorage.setItem("rcs_uuid", uuid);
}
/** Active API token for Authorization header (set by useTokens) */
let _activeToken: string | null = null;
export function setActiveApiToken(token: string | null): void {
_activeToken = token;
}
export function getActiveApiToken(): string | null {
return _activeToken;
}
async function api<T>(method: string, path: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = { "Content-Type": "application/json" };
const uuid = getUuid();
const sep = path.includes("?") ? "&" : "?";
const url = `${BASE}${path}${sep}uuid=${encodeURIComponent(uuid)}`;
if (_activeToken) {
headers["Authorization"] = `Bearer ${_activeToken}`;
}
// When using Bearer token auth, backend derives UUID from the token — no need to send query param.
// Otherwise fall back to UUID auth via query param.
let url: string;
if (_activeToken) {
const sep = path.includes("?") ? "&" : "?";
url = `${BASE}${path}${sep}uuid=${encodeURIComponent(_activeToken)}`;
} else {
const uuid = getUuid();
const sep = path.includes("?") ? "&" : "?";
url = `${BASE}${path}${sep}uuid=${encodeURIComponent(uuid)}`;
}
const opts: RequestInit = { method, headers };
if (body !== undefined) opts.body = JSON.stringify(body);

View File

@@ -1,14 +1,16 @@
import { cn } from "../lib/utils";
import { ThemeToggle } from "../../components/ui/theme-toggle";
import { ChevronLeft, LayoutGrid, UserPlus } from "lucide-react";
import { ChevronLeft, LayoutGrid, UserPlus, KeyRound } from "lucide-react";
interface NavbarProps {
onIdentityClick: () => void;
onTokenClick: () => void;
activeTokenLabel?: string | null;
sessionTitle?: string;
onBack?: () => void;
}
export function Navbar({ onIdentityClick, sessionTitle, onBack }: NavbarProps) {
export function Navbar({ onIdentityClick, onTokenClick, activeTokenLabel, sessionTitle, onBack }: NavbarProps) {
return (
<nav className="sticky top-0 z-40 border-b border-border bg-surface-1/80 backdrop-blur-md">
<div className="mx-auto flex h-11 sm:h-12 max-w-5xl items-center justify-between px-3 sm:px-4">
@@ -51,6 +53,19 @@ export function Navbar({ onIdentityClick, sessionTitle, onBack }: NavbarProps) {
</a>
)}
<ThemeToggle />
<button
onClick={onTokenClick}
className={cn(
"flex items-center gap-1 rounded-md px-2 sm:px-3 py-1.5 text-sm transition-colors",
activeTokenLabel
? "bg-brand/10 text-brand hover:bg-brand/20"
: "text-text-secondary hover:bg-surface-2 hover:text-text-primary"
)}
title="Token Manager"
>
<KeyRound className="h-4 w-4" />
<span className="hidden sm:inline max-w-24 truncate">{activeTokenLabel || "No Token"}</span>
</button>
<button
onClick={onIdentityClick}
className="flex items-center gap-1 rounded-md px-2 sm:px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-2 hover:text-text-primary transition-colors"

View File

@@ -0,0 +1,217 @@
import { useState } from "react";
import type { TokenEntry } from "../hooks/useTokens";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "../../components/ui/dialog";
import { Check, Copy, Eye, EyeOff, Pencil, Plus, Trash2, X } from "lucide-react";
interface TokenManagerDialogProps {
open: boolean;
onClose: () => void;
tokens: TokenEntry[];
activeTokenId: string | null;
onSetActive: (id: string) => void;
onAdd: (token: string, label: string) => string | null;
onRemove: (id: string) => void;
onUpdate: (id: string, label: string) => void;
}
export function TokenManagerDialog({
open,
onClose,
tokens,
activeTokenId,
onSetActive,
onAdd,
onRemove,
onUpdate,
}: TokenManagerDialogProps) {
const [newToken, setNewToken] = useState("");
const [newLabel, setNewLabel] = useState("");
const [addError, setAddError] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [editLabel, setEditLabel] = useState("");
const [visibleTokenId, setVisibleTokenId] = useState<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
const handleCopy = (id: string, token: string) => {
navigator.clipboard.writeText(token).then(() => {
setCopiedId(id);
setTimeout(() => setCopiedId(null), 1500);
});
};
const handleAdd = () => {
const error = onAdd(newToken, newLabel);
if (error) {
setAddError(error);
return;
}
setNewToken("");
setNewLabel("");
setAddError("");
};
const handleStartEdit = (entry: TokenEntry) => {
setEditingId(entry.id);
setEditLabel(entry.label);
};
const handleSaveEdit = (id: string) => {
onUpdate(id, editLabel.trim() || "Unnamed");
setEditingId(null);
};
const handleSwitch = (id: string) => {
onSetActive(id);
onClose();
};
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-md rounded-2xl border-border bg-surface-1 p-6 shadow-2xl">
<DialogHeader>
<DialogTitle className="font-display text-lg font-semibold text-text-primary">
Token Manager
</DialogTitle>
<DialogDescription className="text-sm text-text-muted">
Manage API tokens for RCS authentication.
</DialogDescription>
</DialogHeader>
{/* Token list */}
<div className="space-y-1 max-h-64 overflow-y-auto">
{tokens.map((entry) => (
<div key={entry.id} className="group flex items-center gap-1">
{editingId === entry.id ? (
<div className="flex flex-1 items-center gap-2 rounded-lg bg-surface-2 px-3 py-1.5">
<input
value={editLabel}
onChange={(e) => setEditLabel(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSaveEdit(entry.id);
if (e.key === "Escape") setEditingId(null);
}}
className="flex-1 rounded border border-border bg-surface-1 px-2 py-1 text-sm text-text-primary focus:border-brand focus:outline-none"
autoFocus
/>
<button
onClick={() => handleSaveEdit(entry.id)}
className="text-brand hover:text-brand-light transition-colors"
>
<Check className="h-4 w-4" />
</button>
<button
onClick={() => setEditingId(null)}
className="text-text-muted hover:text-text-primary transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
) : (
<>
<button
onClick={() => handleSwitch(entry.id)}
className={`flex flex-1 items-center justify-between rounded-lg px-3 py-2 text-sm transition-colors ${
activeTokenId === entry.id
? "bg-brand/10 text-brand"
: "text-text-secondary hover:bg-surface-2"
}`}
>
<div className="flex flex-col items-start min-w-0">
<span className="font-medium truncate w-full">{entry.label}</span>
<span className="text-xs text-text-muted font-mono">
{visibleTokenId === entry.id
? entry.token
: `${entry.token.slice(0, 6)}${"\u2022".repeat(6)}`}
</span>
</div>
{activeTokenId === entry.id && <Check className="h-4 w-4 flex-shrink-0" />}
</button>
<button
onClick={() => setVisibleTokenId(visibleTokenId === entry.id ? null : entry.id)}
className="rounded p-1 text-text-muted opacity-0 group-hover:opacity-100 hover:text-text-primary transition-all"
title="Toggle token visibility"
>
{visibleTokenId === entry.id ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</button>
<button
onClick={() => handleCopy(entry.id, entry.token)}
className="rounded p-1 text-text-muted opacity-0 group-hover:opacity-100 hover:text-text-primary transition-all"
title="Copy token"
>
{copiedId === entry.id ? <Check className="h-3.5 w-3.5 text-status-active" /> : <Copy className="h-3.5 w-3.5" />}
</button>
<button
onClick={() => handleStartEdit(entry)}
className="rounded p-1 text-text-muted opacity-0 group-hover:opacity-100 hover:text-text-primary transition-all"
title="Edit label"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => onRemove(entry.id)}
className="rounded p-1 text-text-muted opacity-0 group-hover:opacity-100 hover:text-status-error transition-all"
title="Delete token"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
))}
{tokens.length === 0 && (
<div className="py-4 text-center text-sm text-text-muted">
No tokens saved yet. Add one below.
</div>
)}
</div>
{/* Add form */}
<div className="border-t border-border pt-4 space-y-3">
<div className="text-sm font-medium text-text-secondary">Add Token</div>
<div className="space-y-2">
<input
type="text"
value={newToken}
onChange={(e) => {
setNewToken(e.target.value);
setAddError("");
}}
placeholder="API Token"
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-brand focus:outline-none font-mono"
onKeyDown={(e) => {
if (e.key === "Enter") handleAdd();
}}
/>
<div className="flex gap-2">
<input
type="text"
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
placeholder="Label (optional)"
className="flex-1 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-brand focus:outline-none"
onKeyDown={(e) => {
if (e.key === "Enter") handleAdd();
}}
/>
<button
onClick={handleAdd}
disabled={!newToken.trim()}
className="rounded-lg bg-brand px-3 py-2 text-white hover:bg-brand-light disabled:opacity-50 transition-colors"
>
<Plus className="h-4 w-4" />
</button>
</div>
</div>
{addError && <div className="text-xs text-status-error">{addError}</div>}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,120 @@
import { useState, useCallback } from "react";
export interface TokenEntry {
id: string;
token: string;
label: string;
}
const TOKENS_KEY = "rcs_tokens";
const ACTIVE_TOKEN_KEY = "rcs_uuid";
const DEFAULT_ID = "__default__";
function generateId(): string {
return `tk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
/** Ensure the existing rcs_uuid is present as the default token entry */
function ensureDefault(tokens: TokenEntry[]): TokenEntry[] {
if (tokens.some((t) => t.id === DEFAULT_ID)) return tokens;
let uuid: string | null = null;
try {
uuid = localStorage.getItem("rcs_uuid");
} catch {
// ignore
}
if (!uuid) return tokens;
return [{ id: DEFAULT_ID, token: uuid, label: "Default" }, ...tokens];
}
function loadTokens(): TokenEntry[] {
let tokens: TokenEntry[] = [];
try {
const raw = localStorage.getItem(TOKENS_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) tokens = parsed;
}
} catch {
// ignore
}
return ensureDefault(tokens);
}
function loadActiveTokenId(tokens: TokenEntry[]): string {
// Try saved active token
try {
const saved = localStorage.getItem(ACTIVE_TOKEN_KEY);
if (saved && tokens.some((t) => t.id === saved)) return saved;
} catch {
// ignore
}
// Fall back to default (rcs_uuid) entry
const defaultEntry = tokens.find((t) => t.id === DEFAULT_ID);
if (defaultEntry) return defaultEntry.id;
// Fall back to first entry
return tokens[0]?.id ?? DEFAULT_ID;
}
export function useTokens() {
const [tokens, setTokens] = useState<TokenEntry[]>(loadTokens);
const [activeTokenId, setActiveTokenIdState] = useState<string>(() => loadActiveTokenId(loadTokens()));
const persistTokens = useCallback((next: TokenEntry[]) => {
setTokens(next);
try {
localStorage.setItem(TOKENS_KEY, JSON.stringify(next));
} catch {
// ignore
}
}, []);
const setActiveTokenId = useCallback((id: string) => {
setActiveTokenIdState(id);
try {
localStorage.setItem(ACTIVE_TOKEN_KEY, id);
location.reload(); // Reload to ensure api client picks up new token from localStorage
} catch {
// ignore
}
}, []);
const addToken = useCallback((token: string, label: string): string | null => {
const trimmed = token.trim();
if (!trimmed) return "Token is required";
const entry: TokenEntry = { id: generateId(), token: trimmed, label: label.trim() || trimmed.slice(0, 8) };
const next = [...tokens, entry];
persistTokens(next);
return null;
}, [tokens, persistTokens]);
const removeToken = useCallback((id: string) => {
if (id === DEFAULT_ID) return; // Cannot remove default
const next = tokens.filter((t) => t.id !== id);
persistTokens(next);
if (activeTokenId === id) {
setActiveTokenId(DEFAULT_ID);
}
}, [tokens, persistTokens, activeTokenId, setActiveTokenId]);
const updateToken = useCallback((id: string, label: string) => {
const next = tokens.map((t) => t.id === id ? { ...t, label } : t);
persistTokens(next);
}, [tokens, persistTokens]);
const activeToken = tokens.find((t) => t.id === activeTokenId) ?? tokens[0] ?? null;
const activeLabel = activeToken?.label ?? "Default";
const activeTokenValue = activeToken?.token ?? null;
return {
tokens,
activeTokenId,
activeToken,
activeLabel,
activeTokenValue,
setActiveTokenId,
addToken,
removeToken,
updateToken,
};
}

View File

@@ -5,6 +5,7 @@ export interface Environment {
status: string;
branch?: string;
worker_type?: string;
channel_group_id?: string | null;
capabilities?: Record<string, unknown> | null;
}