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:
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user