feat(webui): add project workspaces and access controls (#4007)

* feat(webui): add project workspaces and access controls

* feat(webui): add project workspaces and access controls

* refactor(tools): centralize workspace access resolution

* refactor(webui): remove unused workspace host state

* fix(webui): hide estimated file edit label

* fix(webui): clarify file edit deletion feedback

* fix(webui): label deleted file activity

* fix(webui): flatten file edit activity rows

* fix(core): remove path-only patch deletion

* fix(core): keep apply patch non-destructive

* refactor(webui): trim workspace host plumbing

* fix(tools): register exec with tools config
This commit is contained in:
Xubin Ren
2026-05-29 03:42:53 +08:00
committed by GitHub
parent 84428136e6
commit 3a420136bb
111 changed files with 9972 additions and 1822 deletions
+80
View File
@@ -4,13 +4,17 @@ import type {
ImageGenerationSettingsUpdate,
McpPresetsPayload,
ModelConfigurationCreate,
ModelConfigurationUpdate,
NetworkSafetySettingsUpdate,
ProviderSettingsUpdate,
SettingsPayload,
SettingsUpdate,
SidebarStatePayload,
SlashCommand,
WebSearchSettingsUpdate,
WorkspacesPayload,
WebuiThreadPersistedPayload,
WorkspaceScopePayload,
} from "./types";
export class ApiError extends Error {
@@ -38,6 +42,17 @@ async function request<T>(
if (!res.ok) {
throw new ApiError(res.status, `HTTP ${res.status}`);
}
const contentType = res.headers?.get?.("content-type") ?? "";
if (contentType && !contentType.toLowerCase().includes("application/json")) {
const text = typeof res.text === "function" ? await res.text() : "";
const isHtml = text.trimStart().toLowerCase().startsWith("<!doctype");
throw new ApiError(
res.status,
isHtml
? "Gateway returned WebUI HTML instead of JSON. Restart nanobot gateway and try again."
: "Gateway returned a non-JSON response.",
);
}
return (await res.json()) as T;
}
@@ -73,6 +88,7 @@ export async function listSessions(
title?: string;
preview?: string;
run_started_at?: number | null;
workspace_scope?: WorkspaceScopePayload | null;
};
const body = await request<{ sessions: Row[] }>(
`${base}/api/sessions`,
@@ -86,6 +102,7 @@ export async function listSessions(
title: s.title ?? "",
preview: s.preview ?? "",
runStartedAt: s.run_started_at ?? null,
workspaceScope: s.workspace_scope ?? null,
}));
}
@@ -124,6 +141,13 @@ export async function fetchSettings(
return request<SettingsPayload>(`${base}/api/settings`, token);
}
export async function fetchWorkspaces(
token: string,
base: string = "",
): Promise<WorkspacesPayload> {
return request<WorkspacesPayload>(`${base}/api/workspaces`, token);
}
export async function fetchCliApps(
token: string,
base: string = "",
@@ -281,6 +305,22 @@ export async function createModelConfiguration(
);
}
export async function updateModelConfiguration(
token: string,
configuration: ModelConfigurationUpdate,
base: string = "",
): Promise<SettingsPayload> {
const query = new URLSearchParams();
query.set("name", configuration.name);
if (configuration.label !== undefined) query.set("label", configuration.label);
if (configuration.provider !== undefined) query.set("provider", configuration.provider);
if (configuration.model !== undefined) query.set("model", configuration.model);
return request<SettingsPayload>(
`${base}/api/settings/model-configurations/update?${query}`,
token,
);
}
export async function updateProviderSettings(
token: string,
update: ProviderSettingsUpdate,
@@ -297,6 +337,32 @@ export async function updateProviderSettings(
);
}
export async function loginProviderOAuth(
token: string,
provider: string,
base: string = "",
): Promise<SettingsPayload> {
const query = new URLSearchParams();
query.set("provider", provider);
return request<SettingsPayload>(
`${base}/api/settings/provider/oauth-login?${query}`,
token,
);
}
export async function logoutProviderOAuth(
token: string,
provider: string,
base: string = "",
): Promise<SettingsPayload> {
const query = new URLSearchParams();
query.set("provider", provider);
return request<SettingsPayload>(
`${base}/api/settings/provider/oauth-logout?${query}`,
token,
);
}
export async function updateWebSearchSettings(
token: string,
update: WebSearchSettingsUpdate,
@@ -317,6 +383,20 @@ export async function updateWebSearchSettings(
);
}
export async function updateNetworkSafetySettings(
token: string,
update: NetworkSafetySettingsUpdate,
base: string = "",
): Promise<SettingsPayload> {
const query = new URLSearchParams();
query.set("webui_allow_local_service_access", String(update.webuiAllowLocalServiceAccess));
query.set("webui_default_access_mode", update.webuiDefaultAccessMode);
return request<SettingsPayload>(
`${base}/api/settings/network-safety/update?${query}`,
token,
);
}
export async function updateImageGenerationSettings(
token: string,
update: ImageGenerationSettingsUpdate,
+16 -2
View File
@@ -64,12 +64,26 @@ export async function fetchBootstrap(
* matters because some WS servers dispatch handshakes based on the literal
* path, not a normalised form.
*/
export function deriveWsUrl(wsPath: string, token: string): string {
const path = wsPath && wsPath.startsWith("/") ? wsPath : `/${wsPath || ""}`;
export function deriveWsUrl(
wsPath: string,
token: string,
wsUrl?: string | null,
): string {
const query = `?token=${encodeURIComponent(token)}`;
if (wsUrl && /^(wss?|nanobot-host):\/\//i.test(wsUrl)) {
const join = wsUrl.includes("?") ? "&" : "?";
return `${wsUrl}${join}token=${encodeURIComponent(token)}`;
}
const path = wsPath && wsPath.startsWith("/") ? wsPath : `/${wsPath || ""}`;
if (typeof window === "undefined") {
return `ws://127.0.0.1:8765${path}${query}`;
}
if (window.location.port === "5173") {
const host = window.location.hostname.includes(":")
? `[${window.location.hostname}]`
: window.location.hostname;
return `ws://${host}:8765${path}${query}`;
}
const scheme = window.location.protocol === "https:" ? "wss" : "ws";
const host = window.location.host;
return `${scheme}://${host}${path}${query}`;
+372
View File
@@ -0,0 +1,372 @@
import { deriveTitle } from "@/lib/format";
import type { ChatSummary, SidebarSortMode } from "@/lib/types";
import { normalizeWorkspacePath, projectNameFromPath, sameWorkspacePath } from "@/lib/workspace";
export const COLLAPSED_CHATS_VISIBLE_COUNT = 8;
export interface SessionGroup {
id: string;
label: string;
sessions: ChatSummary[];
kind?: "project";
projectPath?: string;
projectKey?: string;
updatedAt?: string | null;
}
export interface ChatGroupLabels {
pinned: string;
all: string;
today: string;
yesterday: string;
earlier: string;
archived: string;
projects: string;
fallbackTitle: string;
}
export interface ChatGroupingOptions {
pinnedKeys: string[];
archivedKeys: string[];
titleOverrides: Record<string, string>;
projectNameOverrides: Record<string, string>;
showArchived: boolean;
sort: SidebarSortMode;
defaultWorkspacePath?: string | null;
}
export function groupSessions(
sessions: ChatSummary[],
labels: ChatGroupLabels,
options: ChatGroupingOptions,
): SessionGroup[] {
if (sessions.some((session) => session.workspaceScope?.project_path)) {
return groupSessionsByProject(sessions, labels, options);
}
const now = new Date();
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
const buckets = new Map<string, ChatSummary[]>();
const pinned = new Set(options.pinnedKeys);
const archived = new Set(options.archivedKeys);
const pinnedSessions: ChatSummary[] = [];
const archivedSessions: ChatSummary[] = [];
const normalSessions: ChatSummary[] = [];
for (const session of sessions) {
if (archived.has(session.key)) {
if (options.showArchived) archivedSessions.push(session);
continue;
}
if (pinned.has(session.key)) {
pinnedSessions.push(session);
continue;
}
if (options.sort === "title_asc") {
normalSessions.push(session);
continue;
}
const timestamp = Date.parse(session.updatedAt ?? session.createdAt ?? "");
const label = Number.isFinite(timestamp) && timestamp >= startOfToday
? labels.today
: Number.isFinite(timestamp) && timestamp >= startOfYesterday
? labels.yesterday
: labels.earlier;
const bucket = buckets.get(label) ?? [];
bucket.push(session);
buckets.set(label, bucket);
}
const groups: SessionGroup[] = [labels.today, labels.yesterday, labels.earlier]
.map((label) => ({
id: `date:${label}`,
label,
sessions: sortSessions(
buckets.get(label) ?? [],
options.sort,
options.titleOverrides,
),
}))
.filter((group) => group.sessions.length > 0);
if (options.sort === "title_asc" && normalSessions.length) {
groups.push({
id: "date:all",
label: labels.all,
sessions: sortSessions(
normalSessions,
options.sort,
options.titleOverrides,
),
});
}
if (pinnedSessions.length) {
groups.unshift({
id: "pinned",
label: labels.pinned,
sessions: sortSessions(
pinnedSessions,
options.sort,
options.titleOverrides,
),
});
}
if (archivedSessions.length) {
groups.push({
id: "archived",
label: labels.archived,
sessions: sortSessions(
archivedSessions,
options.sort,
options.titleOverrides,
),
});
}
return groups;
}
export function limitGroups(
groups: SessionGroup[],
limit: number,
activeKey: string | null,
collapsedGroups: Record<string, boolean>,
): SessionGroup[] {
let remaining = Math.max(0, limit);
let activeVisible = !activeKey;
const out: SessionGroup[] = [];
for (const group of groups) {
if (isCollapsedProject(group, collapsedGroups)) {
out.push({ ...group, sessions: [] });
continue;
}
const visible = remaining > 0
? group.sessions.slice(0, remaining)
: [];
remaining -= visible.length;
if (activeKey && visible.some((session) => session.key === activeKey)) {
activeVisible = true;
}
if (visible.length > 0) {
out.push({ ...group, sessions: visible });
}
}
if (activeVisible || !activeKey) return out;
for (const group of groups) {
if (isCollapsedProject(group, collapsedGroups)) continue;
const active = group.sessions.find((session) => session.key === activeKey);
if (!active) continue;
const existing = out.find((item) => item.id === group.id);
if (existing) {
existing.sessions = [...existing.sessions, active];
} else {
out.push({ ...group, sessions: [active] });
}
return out;
}
return out;
}
export function isCollapsedProject(
group: SessionGroup,
collapsedGroups: Record<string, boolean>,
): boolean {
return group.kind === "project" && Boolean(collapsedGroups[group.id]);
}
export function isFoldableChatsGroup(group: SessionGroup): boolean {
return group.id === "workspace:chats" || group.id === "date:all";
}
export function isFoldedChatsGroup(
group: SessionGroup,
collapsedGroups: Record<string, boolean>,
): boolean {
return (
isFoldableChatsGroup(group)
&& group.sessions.length > COLLAPSED_CHATS_VISIBLE_COUNT
&& collapsedGroups[group.id] !== false
);
}
export function visibleSessionsForGroup(
group: SessionGroup,
activeKey: string | null,
collapsedGroups: Record<string, boolean>,
): ChatSummary[] {
if (!isFoldedChatsGroup(group, collapsedGroups)) {
return group.sessions;
}
const visible = group.sessions.slice(0, COLLAPSED_CHATS_VISIBLE_COUNT);
if (!activeKey || visible.some((session) => session.key === activeKey)) {
return visible;
}
const active = group.sessions.find((session) => session.key === activeKey);
return active ? [...visible, active] : visible;
}
export function displayTitle(
session: ChatSummary,
titleOverrides: Record<string, string>,
fallbackTitle: string,
): string {
return (
titleOverrides[session.key]?.trim()
|| session.title?.trim()
|| deriveTitle(session.preview, fallbackTitle)
);
}
function groupSessionsByProject(
sessions: ChatSummary[],
labels: Pick<ChatGroupLabels, "all">,
options: ChatGroupingOptions,
): SessionGroup[] {
const archived = new Set(options.archivedKeys);
const conversations: ChatSummary[] = [];
const buckets = new Map<string, {
path?: string;
label: string;
sessions: ChatSummary[];
updatedAt: string | null;
}>();
for (const session of sessions) {
if (archived.has(session.key) && !options.showArchived) {
continue;
}
const scope = session.workspaceScope;
const path = scope?.project_path || "";
if (!path || sameWorkspacePath(path, options.defaultWorkspacePath)) {
conversations.push(session);
continue;
}
const key = normalizeWorkspacePath(path);
const label = options.projectNameOverrides[key]?.trim()
|| scope?.project_name?.trim()
|| projectNameFromPath(path);
const bucket = buckets.get(key) ?? {
path,
label,
sessions: [],
updatedAt: null,
};
bucket.sessions.push(session);
const candidate = session.updatedAt ?? session.createdAt ?? null;
if (isNewerDate(candidate, bucket.updatedAt)) {
bucket.updatedAt = candidate;
}
buckets.set(key, bucket);
}
const pinned = new Set(options.pinnedKeys);
const groups: SessionGroup[] = Array.from(buckets.entries()).map(([key, bucket]) => ({
id: `project:${key}`,
label: bucket.label,
kind: "project" as const,
projectPath: bucket.path,
projectKey: key,
updatedAt: bucket.updatedAt,
sessions: sortProjectSessions(
bucket.sessions,
options.sort,
options.titleOverrides,
pinned,
archived,
),
}));
groups.sort((a, b) => {
const timeOrder = dateToTime(b.updatedAt) - dateToTime(a.updatedAt);
if (timeOrder !== 0) return timeOrder;
return a.label.localeCompare(b.label, "en", {
numeric: true,
sensitivity: "base",
});
});
if (conversations.length) {
groups.push({
id: "workspace:chats",
label: labels.all,
sessions: sortProjectSessions(
conversations,
options.sort,
options.titleOverrides,
pinned,
archived,
),
});
}
return groups;
}
function sortProjectSessions(
sessions: ChatSummary[],
sort: SidebarSortMode,
titleOverrides: Record<string, string>,
pinned: Set<string>,
archived: Set<string>,
): ChatSummary[] {
return sortSessions(sessions, sort, titleOverrides).sort((a, b) => {
const pinOrder = Number(pinned.has(b.key)) - Number(pinned.has(a.key));
if (pinOrder !== 0) return pinOrder;
const archiveOrder = Number(archived.has(a.key)) - Number(archived.has(b.key));
if (archiveOrder !== 0) return archiveOrder;
return 0;
});
}
function sortSessions(
sessions: ChatSummary[],
sort: SidebarSortMode,
titleOverrides: Record<string, string>,
): ChatSummary[] {
const copy = [...sessions];
copy.sort((a, b) => {
if (sort === "title_asc") {
const titleOrder = titleForSort(a, titleOverrides).localeCompare(
titleForSort(b, titleOverrides),
"en",
{ numeric: true, sensitivity: "base" },
);
if (titleOrder !== 0) return titleOrder;
return sessionTime(b, "updatedAt") - sessionTime(a, "updatedAt");
}
const aTime = sessionTime(a, sort === "created_desc" ? "createdAt" : "updatedAt");
const bTime = sessionTime(b, sort === "created_desc" ? "createdAt" : "updatedAt");
return bTime - aTime;
});
return copy;
}
function isNewerDate(a: string | null, b: string | null): boolean {
return dateToTime(a) > dateToTime(b);
}
function dateToTime(value: string | null | undefined): number {
const ts = Date.parse(value ?? "");
return Number.isFinite(ts) ? ts : 0;
}
function titleForSort(
session: ChatSummary,
titleOverrides: Record<string, string>,
): string {
return (
titleOverrides[session.key]?.trim()
|| session.title?.trim()
|| deriveTitle(session.preview, "new chat")
).toLocaleLowerCase("en");
}
function sessionTime(session: ChatSummary, field: "createdAt" | "updatedAt"): number {
const ts = Date.parse(session[field] ?? "");
return Number.isFinite(ts) ? ts : 0;
}
+55 -13
View File
@@ -7,6 +7,7 @@ import type {
OutboundMcpPresetMention,
OutboundMedia,
GoalStateWsPayload,
WorkspaceScopePayload,
} from "./types";
/** WebSocket readyState constants, referenced by value to stay portable
@@ -57,22 +58,25 @@ type EventHandler = (ev: InboundEvent) => void;
type StatusHandler = (status: ConnectionStatus) => void;
type RuntimeModelHandler = (modelName: string | null, modelPreset?: string | null) => void;
type SessionUpdateScope = "metadata" | "thread" | string;
type SessionUpdateHandler = (chatId: string, scope?: SessionUpdateScope) => void;
type SessionUpdateHandler = (
chatId: string,
scope?: SessionUpdateScope,
workspaceScope?: WorkspaceScopePayload,
) => void;
type RunStatusHandler = (chatId: string, startedAt: number | null) => void;
/** Structured connection-level errors surfaced to the UI.
/** Structured errors surfaced to the UI.
*
* These are *not* InboundEvent errors from the server application layer —
* those arrive as ``{event: "error"}`` messages via ``onChat``. These are
* transport-level or protocol-level faults the UI should make visible so
* the user understands *why* their action failed (as opposed to silently
* reconnecting under the hood).
* Most entries are transport-level or protocol-level faults. Workspace scope
* rejections are server application errors promoted here because they affect
* controls outside the message stream and must be visible immediately.
*/
export type StreamError =
/** Server rejected the inbound frame as too large (WS close code 1009).
* Typically means the user attached images whose base64 size exceeded
* ``maxMessageBytes`` on the server. */
| { kind: "message_too_big" };
| { kind: "message_too_big" }
| { kind: "workspace_scope_rejected"; reason?: string; chatId?: string };
type ErrorHandler = (error: StreamError) => void;
@@ -206,6 +210,13 @@ export class NanobotClient {
}
private recordGoalStatusForRunStrip(chatId: string, ev: InboundEvent): void {
if (ev.event === "turn_end") {
if (this.runStartedAtByChatId.has(chatId)) {
this.runStartedAtByChatId.delete(chatId);
this.emitRunStatus(chatId, null);
}
return;
}
if (ev.event !== "goal_status") return;
if (ev.status === "running" && typeof ev.started_at === "number") {
const previous = this.runStartedAtByChatId.get(chatId);
@@ -281,7 +292,7 @@ export class NanobotClient {
}
/** Ask the server to provision a new chat_id; resolves with the assigned id. */
newChat(timeoutMs: number = 5_000): Promise<string> {
newChat(timeoutMs: number = 5_000, workspaceScope?: WorkspaceScopePayload | null): Promise<string> {
if (this.pendingNewChat) {
return Promise.reject(new Error("newChat already in flight"));
}
@@ -291,7 +302,10 @@ export class NanobotClient {
reject(new Error("newChat timed out"));
}, timeoutMs);
this.pendingNewChat = { resolve, reject, timer };
this.queueSend({ type: "new_chat" });
this.queueSend({
type: "new_chat",
...(workspaceScope ? { workspace_scope: workspaceScope } : {}),
});
});
}
@@ -310,6 +324,7 @@ export class NanobotClient {
imageGeneration?: OutboundImageGeneration;
cliApps?: OutboundCliAppMention[];
mcpPresets?: OutboundMcpPresetMention[];
workspaceScope?: WorkspaceScopePayload | null;
},
): void {
this.knownChats.add(chatId);
@@ -321,11 +336,21 @@ export class NanobotClient {
...(options?.imageGeneration ? { image_generation: options.imageGeneration } : {}),
...(options?.cliApps?.length ? { cli_apps: options.cliApps } : {}),
...(options?.mcpPresets?.length ? { mcp_presets: options.mcpPresets } : {}),
...(options?.workspaceScope ? { workspace_scope: options.workspaceScope } : {}),
webui: true,
};
this.queueSend(frame);
}
setWorkspaceScope(chatId: string, workspaceScope: WorkspaceScopePayload): void {
this.knownChats.add(chatId);
this.queueSend({
type: "set_workspace_scope",
chat_id: chatId,
workspace_scope: workspaceScope,
});
}
// -- internals ---------------------------------------------------------
private setStatus(status: ConnectionStatus): void {
@@ -388,10 +413,23 @@ export class NanobotClient {
}
if (parsed.event === "session_updated") {
this.emitSessionUpdate(parsed.chat_id, parsed.scope);
this.emitSessionUpdate(parsed.chat_id, parsed.scope, parsed.workspace_scope);
return;
}
if (parsed.event === "error" && parsed.detail === "workspace_scope_rejected") {
this.emitError({
kind: "workspace_scope_rejected",
reason: parsed.reason,
chatId: parsed.chat_id,
});
if (this.pendingNewChat) {
clearTimeout(this.pendingNewChat.timer);
this.pendingNewChat.reject(new Error(`workspace_scope_rejected:${parsed.reason || ""}`));
this.pendingNewChat = null;
}
}
const chatId = (parsed as { chat_id?: string }).chat_id;
if (chatId) {
this.recordGoalStatusForRunStrip(chatId, parsed);
@@ -406,9 +444,13 @@ export class NanobotClient {
}
}
private emitSessionUpdate(chatId: string, scope?: SessionUpdateScope): void {
private emitSessionUpdate(
chatId: string,
scope?: SessionUpdateScope,
workspaceScope?: WorkspaceScopePayload,
): void {
for (const handler of this.sessionUpdateHandlers) {
handler(chatId, scope);
handler(chatId, scope, workspaceScope);
}
}
+8 -2
View File
@@ -92,9 +92,11 @@ export function logoFallbackUrls(logoUrl: string | null | undefined): string[] {
export const PROVIDER_BRAND_ALIASES: Record<string, string> = {
brave_search: "brave",
byteplus_coding_plan: "byteplus",
mimo: "xiaomi_mimo",
minimaxAnthropic: "minimax",
minimax_anthropic: "minimax",
openai_codex: "openai",
xiaomi: "xiaomi_mimo",
volcengine_coding_plan: "volcengine",
};
@@ -127,7 +129,9 @@ const PROVIDER_BRANDS: Record<string, ProviderBrand> = {
jina: brand("jina.ai", "#7C3AED", "J"),
kagi: brand("kagi.com", "#FFB319", "K"),
lm_studio: brand("lmstudio.ai", "#111827", "LM"),
longcat: brand("longcat.chat", "#111827", "LC"),
longcat: brand("longcatai.org", "#4F8CFF", "LC", [
"https://www.longcatai.org/favicon.svg",
]),
minimax: brand("minimax.io", "#111827", "MM"),
mistral: brand("mistral.ai", "#FA520F", "M"),
moonshot: brand("moonshot.ai", "#111827", "MS"),
@@ -146,7 +150,9 @@ const PROVIDER_BRANDS: Record<string, ProviderBrand> = {
tavily: brand("tavily.com", "#111827", "T"),
volcengine: brand("volcengine.com", "#1664FF", "VE"),
vllm: brand("vllm.ai", "#2563EB", "VL"),
xiaomi_mimo: brand("xiaomimimo.com", "#FF6900", "MI"),
xiaomi_mimo: brand("mimo.xiaomi.com", "#FF6900", "MI", [
"https://mimo.xiaomi.com/mimo-v2-pro/assets/logo.svg",
]),
zhipu: brand("z.ai", "#155EEF", "Z", [
"https://z-cdn.chatglm.cn/z-ai/static/logo.svg",
"https://www.google.com/s2/favicons?domain=z.ai&sz=64",
+211
View File
@@ -0,0 +1,211 @@
import type { RuntimeCapabilities, RuntimeSurface } from "./types";
export interface RuntimeHost {
surface: RuntimeSurface;
capabilities: RuntimeCapabilities;
socketFactory?: (url: string) => WebSocket;
pickFolder?: () => Promise<string | null>;
restartEngine?: () => Promise<void>;
openLogs?: () => Promise<void>;
exportDiagnostics?: () => Promise<string>;
}
export interface HostRuntimeInfo {
surface: "native";
app_version: string;
engine_status: "starting" | "ready" | "restarting" | "stopped" | "crashed";
data_dir: string;
logs_dir: string;
config_path: string;
workspace_path: string;
python: string;
api_base?: string;
engine_transport?: "unix_socket";
}
export interface NanobotHostApi {
getRuntimeInfo(): Promise<HostRuntimeInfo>;
restartEngine(): Promise<void>;
pickFolder(): Promise<string | null>;
openLogs(): Promise<void>;
exportDiagnostics(): Promise<string>;
openSocket?(url: string): Promise<string>;
sendSocket?(id: string, data: string): Promise<void>;
closeSocket?(id: string): Promise<void>;
onSocketEvent?(
listener: (event: HostSocketEvent) => void,
): () => void;
onRuntimeStatus?(
listener: (status: HostRuntimeInfo["engine_status"]) => void,
): () => void;
}
export type HostSocketEvent =
| { id: string; type: "open" }
| { data: string; id: string; type: "message" }
| { id: string; message: string; type: "error" }
| { code?: number; id: string; reason?: string; type: "close" };
type HostSocketBridge = Required<Pick<
NanobotHostApi,
"closeSocket" | "onSocketEvent" | "openSocket" | "sendSocket"
>>;
declare global {
interface Window {
nanobotHost?: NanobotHostApi;
}
}
export function getHostApi(): NanobotHostApi | null {
if (typeof window === "undefined") return null;
return window.nanobotHost ?? null;
}
export function toRuntimeSurface(surface: string | null | undefined): RuntimeSurface {
return surface === "native" ? "native" : "browser";
}
export function createRuntimeHost(
surface: RuntimeSurface,
capabilities?: Partial<RuntimeCapabilities> | null,
): RuntimeHost {
const api = getHostApi();
const mergedCapabilities = {
can_export_diagnostics: false,
can_open_logs: false,
can_pick_folder: false,
can_restart_engine: false,
...(capabilities ?? {}),
};
const bridge = getHostSocketBridge();
return {
surface,
capabilities: mergedCapabilities,
socketFactory: bridge ? createHostWebSocket : undefined,
pickFolder: api?.pickFolder,
restartEngine: api?.restartEngine,
openLogs: api?.openLogs,
exportDiagnostics: api?.exportDiagnostics,
};
}
export function createHostWebSocket(url: string): WebSocket {
const api = getHostSocketBridge();
if (!api) {
throw new Error("Host WebSocket bridge is not available");
}
return new HostWebSocket(api, url) as unknown as WebSocket;
}
function getHostSocketBridge(): HostSocketBridge | null {
const api = getHostApi();
if (
!api?.openSocket
|| !api.sendSocket
|| !api.closeSocket
|| !api.onSocketEvent
) {
return null;
}
return {
closeSocket: api.closeSocket,
onSocketEvent: api.onSocketEvent,
openSocket: api.openSocket,
sendSocket: api.sendSocket,
};
}
class HostWebSocket {
binaryType: BinaryType = "blob";
onclose: ((this: WebSocket, ev: CloseEvent) => unknown) | null = null;
onerror: ((this: WebSocket, ev: Event) => unknown) | null = null;
onmessage: ((this: WebSocket, ev: MessageEvent) => unknown) | null = null;
onopen: ((this: WebSocket, ev: Event) => unknown) | null = null;
readyState: number = WebSocket.CONNECTING;
readonly url: string;
private id: string | null = null;
private readonly queued: string[] = [];
private readonly unsubscribe: () => void;
constructor(
private readonly api: HostSocketBridge,
url: string,
) {
this.url = url;
this.unsubscribe = api.onSocketEvent((event) => this.handleEvent(event));
void api.openSocket(url).then(
(id) => {
this.id = id;
},
() => {
this.readyState = WebSocket.CLOSED;
this.onerror?.call(this as unknown as WebSocket, new Event("error"));
this.onclose?.call(this as unknown as WebSocket, closeEvent());
this.unsubscribe();
},
);
}
close(): void {
if (this.readyState === WebSocket.CLOSING || this.readyState === WebSocket.CLOSED) {
return;
}
this.readyState = WebSocket.CLOSING;
if (this.id) {
void this.api.closeSocket(this.id);
} else {
this.readyState = WebSocket.CLOSED;
this.unsubscribe();
}
}
send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
if (typeof data !== "string") {
throw new Error("Host WebSocket bridge only supports text frames");
}
if (this.readyState === WebSocket.OPEN && this.id) {
void this.api.sendSocket(this.id, data);
return;
}
this.queued.push(data);
}
private handleEvent(event: HostSocketEvent): void {
if (!this.id || event.id !== this.id) return;
if (event.type === "open") {
this.readyState = WebSocket.OPEN;
this.onopen?.call(this as unknown as WebSocket, new Event("open"));
while (this.queued.length > 0 && this.id) {
const data = this.queued.shift();
if (data !== undefined) void this.api.sendSocket(this.id, data);
}
return;
}
if (event.type === "message") {
this.onmessage?.call(
this as unknown as WebSocket,
new MessageEvent("message", { data: event.data }),
);
return;
}
if (event.type === "error") {
this.onerror?.call(this as unknown as WebSocket, new Event("error"));
return;
}
this.readyState = WebSocket.CLOSED;
this.onclose?.call(
this as unknown as WebSocket,
closeEvent(event.code, event.reason),
);
this.unsubscribe();
}
}
function closeEvent(code = 1006, reason = ""): CloseEvent {
if (typeof CloseEvent !== "undefined") {
return new CloseEvent("close", { code, reason });
}
return new Event("close") as CloseEvent;
}
+101 -4
View File
@@ -122,6 +122,7 @@ export interface UIFileEdit {
deleted: number;
approximate?: boolean;
status: "editing" | "done" | "error";
operation?: "edit" | "delete" | string;
binary?: boolean;
error?: string;
pending?: boolean;
@@ -139,6 +140,36 @@ export interface ChatSummary {
preview: string;
/** Unix epoch seconds when this session currently has a turn in flight. */
runStartedAt?: number | null;
workspaceScope?: WorkspaceScopePayload | null;
}
export type WorkspaceAccessMode = "restricted" | "full";
export type WebuiDefaultAccessMode = "default" | "full";
export interface WorkspaceScopePayload {
project_path: string;
project_name?: string;
access_mode: WorkspaceAccessMode;
restrict_to_workspace?: boolean;
sandbox_status?: {
restrict_to_workspace: boolean;
workspace_root: string;
level: string;
enforced: boolean;
provider: string;
provider_label: string;
summary: string;
};
}
export interface WorkspacesPayload {
schema_version: number;
default_access_mode: WebuiDefaultAccessMode;
default_scope: WorkspaceScopePayload;
controls: {
can_change_project: boolean;
can_use_full_access: boolean;
};
}
export type SidebarDensity = "comfortable" | "compact";
@@ -157,6 +188,7 @@ export interface SidebarStatePayload {
pinned_keys: string[];
archived_keys: string[];
title_overrides: Record<string, string>;
project_name_overrides: Record<string, string>;
tags_by_key: Record<string, string[]>;
collapsed_groups: Record<string, boolean>;
view: SidebarViewState;
@@ -166,11 +198,38 @@ export interface SidebarStatePayload {
export interface BootstrapResponse {
token: string;
ws_path: string;
ws_url?: string | null;
expires_in: number;
model_name?: string | null;
runtime_surface?: RuntimeSurface;
runtime_capabilities?: RuntimeCapabilities;
}
export type RuntimeSurface = "browser" | "native";
export type RestartBehavior = "none" | "nextTurn" | "engineRestart" | "appRestart";
export type SettingsApplyStatus =
| "idle"
| "pending"
| "applying"
| "restarting_engine"
| "requires_app_restart";
export interface RuntimeCapabilities {
can_restart_engine: boolean;
can_pick_folder: boolean;
can_open_logs: boolean;
can_export_diagnostics: boolean;
}
export interface SettingsPayload {
surface?: RuntimeSurface;
runtime_surface?: RuntimeSurface;
runtime_capabilities?: RuntimeCapabilities;
apply_state?: {
status: SettingsApplyStatus;
sections: string[];
};
restart_behavior_by_section?: Record<string, RestartBehavior>;
agent: {
model: string;
provider: string;
@@ -202,11 +261,15 @@ export interface SettingsPayload {
name: string;
label: string;
configured: boolean;
auth_type?: "api_key" | "oauth";
api_key_required?: boolean;
api_key_hint?: string | null;
api_base?: string | null;
default_api_base?: string | null;
api_type?: "auto" | "chat_completions" | "responses";
oauth_account?: string | null;
oauth_expires_at?: number | null;
oauth_login_supported?: boolean;
}>;
web_search: {
provider: string;
@@ -245,6 +308,7 @@ export interface SettingsPayload {
name: string;
label: string;
configured: boolean;
auth_type?: "api_key" | "oauth";
api_key_hint?: string | null;
api_base?: string | null;
default_api_base?: string | null;
@@ -270,14 +334,27 @@ export interface SettingsPayload {
};
advanced: {
restrict_to_workspace: boolean;
workspace_sandbox?: {
restrict_to_workspace: boolean;
workspace_root: string;
level: "off" | "application" | "system" | string;
enforced: boolean;
provider: string;
provider_label: string;
summary: string;
};
ssrf_whitelist_count: number;
webui_allow_local_service_access: boolean;
allow_local_preview_access?: boolean;
webui_default_access_mode: WebuiDefaultAccessMode;
private_service_protection_enabled: boolean;
mcp_server_count: number;
exec_enabled: boolean;
exec_sandbox?: string | null;
exec_path_append_set: boolean;
};
requires_restart: boolean;
restart_required_sections?: Array<"runtime" | "web" | "image">;
restart_required_sections?: Array<"runtime" | "browser" | "image">;
}
export interface AppPackageRef {
@@ -453,6 +530,13 @@ export interface ModelConfigurationCreate {
model: string;
}
export interface ModelConfigurationUpdate {
name: string;
label?: string;
provider?: string;
model?: string;
}
export interface ProviderSettingsUpdate {
provider: string;
apiKey?: string;
@@ -469,6 +553,11 @@ export interface WebSearchSettingsUpdate {
useJinaReader?: boolean;
}
export interface NetworkSafetySettingsUpdate {
webuiAllowLocalServiceAccess: boolean;
webuiDefaultAccessMode: WebuiDefaultAccessMode;
}
export interface ImageGenerationSettingsUpdate {
enabled: boolean;
provider: string;
@@ -566,8 +655,13 @@ export type InboundEvent =
chat_id: string;
goal_state: GoalStateWsPayload;
}
| { event: "session_updated"; chat_id: string; scope?: "metadata" | "thread" | string }
| { event: "error"; chat_id?: string; detail?: string };
| {
event: "session_updated";
chat_id: string;
scope?: "metadata" | "thread" | string;
workspace_scope?: WorkspaceScopePayload;
}
| { event: "error"; chat_id?: string; detail?: string; reason?: string };
/** Base64-encoded image attached to an outbound ``message`` envelope.
*
@@ -613,11 +707,13 @@ export interface WebuiThreadPersistedPayload {
sessionKey?: string;
savedAt?: string;
messages: UIMessage[];
workspace_scope?: WorkspaceScopePayload;
}
export type Outbound =
| { type: "new_chat" }
| { type: "new_chat"; workspace_scope?: WorkspaceScopePayload }
| { type: "attach"; chat_id: string }
| { type: "set_workspace_scope"; chat_id: string; workspace_scope: WorkspaceScopePayload }
| {
type: "message";
chat_id: string;
@@ -626,6 +722,7 @@ export type Outbound =
image_generation?: OutboundImageGeneration;
cli_apps?: OutboundCliAppMention[];
mcp_presets?: OutboundMcpPresetMention[];
workspace_scope?: WorkspaceScopePayload;
/** Marks messages sent by the embedded WebUI, without changing the
* generic websocket protocol for other clients. */
webui?: true;
+56
View File
@@ -0,0 +1,56 @@
import type { WorkspaceAccessMode, WorkspaceScopePayload } from "@/lib/types";
export function scopeWithAccessMode(
scope: WorkspaceScopePayload,
accessMode: WorkspaceAccessMode,
): WorkspaceScopePayload {
return {
...scope,
access_mode: accessMode,
restrict_to_workspace: accessMode === "restricted",
};
}
export function projectNameFromPath(path: string): string {
const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
return normalized.split("/").filter(Boolean).pop() || path;
}
export function shortWorkspacePath(path: string): string {
const normalized = path.replace(/\\/g, "/");
const parts = normalized.split("/").filter(Boolean);
if (parts.length <= 3) return path;
return `.../${parts.slice(-3).join("/")}`;
}
export function isAbsoluteWorkspacePath(path: string): boolean {
const trimmed = path.trim();
return (
trimmed === "~"
|| trimmed.startsWith("~/")
|| trimmed.startsWith("~\\")
|| trimmed.startsWith("/")
|| /^[A-Za-z]:[\\/]/.test(trimmed)
);
}
export function selectedProjectScope(
scope: WorkspaceScopePayload | null,
defaultScope: WorkspaceScopePayload | null,
): WorkspaceScopePayload | null {
if (!scope || !defaultScope) return null;
return sameWorkspacePath(scope.project_path, defaultScope.project_path) ? null : scope;
}
export function normalizeWorkspacePath(path: string | null | undefined): string {
const normalized = (path ?? "").replace(/\\/g, "/").replace(/\/+$/, "");
return normalized || "/";
}
export function sameWorkspacePath(
a: string | null | undefined,
b: string | null | undefined,
): boolean {
if (!a || !b) return false;
return normalizeWorkspacePath(a) === normalizeWorkspacePath(b);
}