c946b73d11
Remote clients (docs/plans/remote-clients/README.md, B1–B3 for the Claude pool): - server/hub/ holds the hub-only code (devices, remote sessions, upload ingest, LAN gateway), required only in server mode; hub-boundary.test.js keeps local code from importing it. - A local install enrolls with `agenthub remote login` or Settings → AI tools → Company hub; the "Company account" AI Proxy (reserved name agenthub.company) sends a session through the hub's gateway, which adds a pool account's token — no credential reaches the machine. Only those sessions are uploaded, hourly and at their end; secret-shaped strings are redacted on the client. - Gateway refuses: unknown device (401), a message without a session id, an unregistered session under AGENTHUB_REMOTE_GATE=block, a user over quota (EQUOTA), a purged session (410). Device tokens 90 days, conversations 30. - New alert rule type remote_unmonitored: a company-account session its device never registered; a late registration clears the flag instead. - The hub imports a session once per upload round (chunks carry `more`), not once per chunk. Artifacts: - Share an artifact with chosen people (artifact_shares, PATCH sharedWith, GET /api/artifacts/people) besides everyone; a "Shared with you" tab. Only the owner or an admin sees or edits who it is shared with; audited. - Folders: each account's own arrangement (agenthub.artifacts.folders pref). - A claude.ai page keeps a copy viewable without the publishing account: its HTML file (and supporting files) snapshotted at publish, or the copy Claude saves for a Doc / Slides / Design in artifacts-export/ (a synchronous PostToolUse hint hook, scripts/artifact-export-hint.js, admin switch), attached to the page at Stop. Viewed in HtmlFrame's sandbox, Markdown, or pdf.js (pdfjs-dist 6.4.299, legacy build, loaded only for a PDF). Fixes: - tmux: a cached /proc/<pid>/exe of an exited server falls back to PATH's tmux (stop the last pane, start again within 10 s failed with ENOENT). - shelves: a shelf restores beside changes in other files. - chat: a finished reply ends the turn even with no hook at all. - Settings → AI tools: profile list display tweaks.
131 lines
4.3 KiB
TypeScript
131 lines
4.3 KiB
TypeScript
/**
|
|
* @file ProxyPicker.tsx
|
|
* @description The AI Proxy a NEW session starts on — a session's choice, not
|
|
* its workspace's (`proxy` on `POST /api/lanes/:id/start`, `server/lib/proxy-apply.js`):
|
|
* Native or one of the saved profiles (`useAiProxies`, which the caller also
|
|
* reads to list the proxy's models). A tool that cannot use a proxy (only
|
|
* Claude and Codex can) shows Native, disabled. The last pick is remembered
|
|
* for the next session (`agenthub.session.proxy`; the pre-rename
|
|
* `agenthub.session.router` is still read when that is unset). On a local
|
|
* machine enrolled with the company hub (`/api/remote-client`) the list ends
|
|
* with the Company account (`agenthub.company`): a Claude session there runs through
|
|
* the hub's gateway, and only such sessions are reported to the hub.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { api, type AiProxy } from "../../lib/api";
|
|
import { useAiTools } from "../../hooks/useAiTools";
|
|
import { Select } from "../Select";
|
|
|
|
const KEY = "agenthub.session.proxy";
|
|
const LEGACY_KEY = "agenthub.session.router";
|
|
|
|
/** The remembered pick, `"native"` by default. */
|
|
export function useSessionProxy(): [string, (v: string) => void] {
|
|
const [value, setValue] = useState(() => {
|
|
try {
|
|
return localStorage.getItem(KEY) || localStorage.getItem(LEGACY_KEY) || "native";
|
|
} catch {
|
|
return "native";
|
|
}
|
|
});
|
|
const set = (v: string) => {
|
|
setValue(v);
|
|
try {
|
|
localStorage.setItem(KEY, v);
|
|
} catch {
|
|
/* storage off — the pick still holds for this page */
|
|
}
|
|
};
|
|
return [value, set];
|
|
}
|
|
|
|
/** The remote client's Company account, as the list shows it. */
|
|
const COMPANY: AiProxy = {
|
|
name: "agenthub.company",
|
|
label: "Company account",
|
|
builtin: false,
|
|
company: true,
|
|
baseUrl: "",
|
|
hasKey: false,
|
|
model: "",
|
|
models: {},
|
|
};
|
|
|
|
/** The saved profiles (Native included, the Company account last when enrolled), null until they arrive. */
|
|
export function useAiProxies(): AiProxy[] | null {
|
|
const [proxies, setProxies] = useState<AiProxy[] | null>(null);
|
|
useEffect(() => {
|
|
let alive = true;
|
|
// A hub (server mode) answers 404 here, and a failure only hides the entry.
|
|
const enrolled = Promise.resolve()
|
|
.then(() => api.remoteClient.status())
|
|
.then((r) => r.enrolled)
|
|
.catch(() => false);
|
|
Promise.all([api.aiProxies.list().then((r) => r.items), enrolled])
|
|
.then(([items, company]) => alive && setProxies(company ? [...items, COMPANY] : items))
|
|
.catch(() => alive && setProxies([]));
|
|
return () => {
|
|
alive = false;
|
|
};
|
|
}, []);
|
|
return proxies;
|
|
}
|
|
|
|
/** What to send as `proxy` for `tool`: nothing for native or a tool that has none. */
|
|
export function proxyArg(proxyCapable: boolean | undefined, proxy: string): { proxy?: string } {
|
|
return proxyCapable && proxy && proxy !== "native" ? { proxy } : {};
|
|
}
|
|
|
|
export function ProxyPicker({
|
|
tool,
|
|
proxies,
|
|
value,
|
|
onChange,
|
|
variant,
|
|
}: {
|
|
variant?: "field" | "ghost";
|
|
tool: string;
|
|
/** From `useAiProxies`. */
|
|
proxies: AiProxy[] | null;
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
}) {
|
|
const { t } = useTranslation(["lanes"]);
|
|
const aiTools = useAiTools();
|
|
const capable = aiTools.byId(tool)?.proxyCapable ?? (tool === "claude" || tool === "codex");
|
|
const native = t("proxy.native", { tool: aiTools.label(tool) });
|
|
|
|
// A remembered profile that has since been deleted falls back to native,
|
|
// or the start would be refused for a proxy the user cannot see.
|
|
useEffect(() => {
|
|
if (!proxies || value === "native") return;
|
|
const pick = proxies.find((r) => r.name === value);
|
|
if (!pick || (pick.company && tool !== "claude")) onChange("native");
|
|
}, [proxies, value, onChange, tool]);
|
|
|
|
const options =
|
|
capable && proxies?.length
|
|
? proxies
|
|
.filter((r) => !r.company || tool === "claude")
|
|
.map((r) => ({
|
|
value: r.name,
|
|
label: r.builtin ? native : r.company ? t("proxy.company") : r.label,
|
|
}))
|
|
: [{ value: "native", label: native }];
|
|
return (
|
|
<div data-testid="proxy-picker">
|
|
<Select<string>
|
|
value={capable ? value : "native"}
|
|
onChange={onChange}
|
|
options={options}
|
|
disabled={!capable}
|
|
variant={variant}
|
|
label={t("proxy.label")}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|