Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion nanobot/channels/feishu/webui/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { lazy } from "react";

import type { ChannelUiContribution } from "@/channel-plugins/types";
import { chatAppGuideUrl } from "@/components/settings/channels/catalog";

import { FeishuAssistantsPanel } from "./FeishuAssistantsPanel";
const FeishuAssistantsPanel = lazy(() =>
import("./FeishuAssistantsPanel").then(({ FeishuAssistantsPanel: component }) => ({
default: component,
})),
);

export default {
Panel: FeishuAssistantsPanel,
Expand Down
26 changes: 4 additions & 22 deletions nanobot/channels/weixin/webui/WeixinPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,28 +33,10 @@ import {
WEIXIN_AUTH_EXPIRED_MESSAGE,
WeixinConnectFlow,
} from "./WeixinConnectFlow";

export const WEIXIN_PRIMARY_FIELD_KEYS = [
"channels.weixin.sendProgress",
"channels.weixin.sendToolHints",
"channels.weixin.streaming",
] as const;

export const WEIXIN_ADVANCED_FIELD_KEYS = [
"channels.weixin.allowFrom",
"channels.weixin.token",
"channels.weixin.replyProgressMessages",
"channels.weixin.replyProgressMaxMessages",
"channels.weixin.contextMessageBudget",
"channels.weixin.blockStreaming",
"channels.weixin.blockStreamingMinChars",
"channels.weixin.blockStreamingMaxMessages",
"channels.weixin.baseUrl",
"channels.weixin.cdnBaseUrl",
"channels.weixin.routeTag",
"channels.weixin.stateDir",
"channels.weixin.pollTimeout",
] as const;
import {
WEIXIN_ADVANCED_FIELD_KEYS,
WEIXIN_PRIMARY_FIELD_KEYS,
} from "./presentation";

export function WeixinPanel({
token,
Expand Down
15 changes: 12 additions & 3 deletions nanobot/channels/weixin/webui/index.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import { lazy } from "react";

import type { ChannelUiContribution } from "@/channel-plugins/types";
import { chatAppGuideUrl } from "@/components/settings/channels/catalog";

import { WeixinConnectFlow } from "./WeixinConnectFlow";
import {
WEIXIN_ADVANCED_FIELD_KEYS,
WEIXIN_PRIMARY_FIELD_KEYS,
WeixinPanel,
} from "./WeixinPanel";
} from "./presentation";

const WeixinPanel = lazy(() =>
import("./WeixinPanel").then(({ WeixinPanel: component }) => ({ default: component })),
);
const WeixinConnectFlow = lazy(() =>
import("./WeixinConnectFlow").then(({ WeixinConnectFlow: component }) => ({
default: component,
})),
);

export default {
Panel: WeixinPanel,
Expand Down
21 changes: 21 additions & 0 deletions nanobot/channels/weixin/webui/presentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export const WEIXIN_PRIMARY_FIELD_KEYS = [
"channels.weixin.sendProgress",
"channels.weixin.sendToolHints",
"channels.weixin.streaming",
] as const;

export const WEIXIN_ADVANCED_FIELD_KEYS = [
"channels.weixin.allowFrom",
"channels.weixin.token",
"channels.weixin.replyProgressMessages",
"channels.weixin.replyProgressMaxMessages",
"channels.weixin.contextMessageBudget",
"channels.weixin.blockStreaming",
"channels.weixin.blockStreamingMinChars",
"channels.weixin.blockStreamingMaxMessages",
"channels.weixin.baseUrl",
"channels.weixin.cdnBaseUrl",
"channels.weixin.routeTag",
"channels.weixin.stateDir",
"channels.weixin.pollTimeout",
] as const;
2 changes: 1 addition & 1 deletion webui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1, user-scalable=no"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<meta name="color-scheme" content="light dark" />
<meta
Expand Down
77 changes: 64 additions & 13 deletions webui/public/sw.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const CACHE_NAME = "nanobot-static-v1";
const PRECACHE = ["/", "/manifest.json"];
const ASSET_MANIFEST_PATH = "/asset-manifest.json";
const PRECACHE = ["/", "/manifest.json", ASSET_MANIFEST_PATH];

self.addEventListener("install", (event) => {
event.waitUntil(
Expand All @@ -20,6 +21,42 @@ function referencedAssetPaths(html) {
return refs;
}

// Vite's build manifest contains every emitted entry, static dependency, and
// lazy chunk. The HTML alone only references the entry chunk, so pruning from
// its tags can delete a current build's not-yet-requested dynamic imports.
async function manifestedAssetPaths(cache) {
const response = await cache.match(ASSET_MANIFEST_PATH);
if (!response) return new Set();
try {
const manifest = await response.json();
const refs = new Set();
for (const entry of Object.values(manifest)) {
if (!entry || typeof entry !== "object") continue;
for (const file of [entry.file, ...(entry.css ?? []), ...(entry.assets ?? [])]) {
if (typeof file !== "string") continue;
const url = new URL(file, self.location.origin);
if (url.origin === self.location.origin) refs.add(url.pathname + url.search);
}
}
return refs;
} catch {
return new Set();
}
}

async function refreshAssetManifest(cache) {
const response = await fetch(ASSET_MANIFEST_PATH, { cache: "no-store" });
if (!response.ok) return false;
try {
const manifest = await response.clone().json();
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) return false;
} catch {
return false;
}
await cache.put(ASSET_MANIFEST_PATH, response);
return true;
}

// Drop cached entries that the current index.html no longer references.
// CACHE_NAME is stable across deployments, so without this, hashed assets from
// previous builds would pile up in the same cache forever. The cached
Expand All @@ -31,11 +68,16 @@ async function pruneStaleEntries() {
const cachedIndex = await cache.match("/");
if (!cachedIndex) return;
const refs = referencedAssetPaths(await cachedIndex.text());
for (const path of await manifestedAssetPaths(cache)) refs.add(path);
const keys = await cache.keys();
await Promise.all(
keys.map(async (request) => {
const url = new URL(request.url);
if (url.pathname === "/" || url.pathname === "/manifest.json") return;
if (
url.pathname === "/"
|| url.pathname === "/manifest.json"
|| url.pathname === ASSET_MANIFEST_PATH
) return;
if (refs.has(url.pathname + url.search)) return;
await cache.delete(request);
})
Expand Down Expand Up @@ -108,19 +150,28 @@ self.addEventListener("fetch", (event) => {
}

// Everything else: network-first (index.html, manifest, brand assets, etc.)
event.respondWith(
fetch(request)
.then((response) => {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then((c) => c.put(request, clone));
// The shell just changed; prune entries the new index.html no longer
// references so hashed assets from old builds do not accumulate even
// when sw.js itself is unchanged between deployments.
if (path === "/") pruneStaleEntries();
const networkResponse = fetch(request);
event.waitUntil(
networkResponse
.then(async (response) => {
if (!response.ok) return;
// Clone before the first await. The original response is also handed
// to respondWith(), which may lock its body as soon as this callback
// yields to the event loop.
const cachedResponse = response.clone();
const cache = await caches.open(CACHE_NAME);
await cache.put(request, cachedResponse);
// Refresh the complete build graph before pruning. A deployment can
// change index.html without changing sw.js, so this cannot rely only
// on the manifest cached when the worker was installed.
if (path === "/") {
if (await refreshAssetManifest(cache)) await pruneStaleEntries();
}
return response;
})
.catch(() => undefined)
);
event.respondWith(
networkResponse
.catch(() => {
// Offline: serve the app shell for navigations (deep links resolve
// client-side), the last cached copy for everything else.
Expand Down
55 changes: 41 additions & 14 deletions webui/src/channel-plugins/locale-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,20 @@ type ChannelMessagesModule = {
default?: ChannelMessages;
};

type ChannelMessagesLoader = () => Promise<ChannelMessagesModule>;

const modules = import.meta.glob<ChannelMessagesModule>(
"../../../nanobot/channels/*/webui/locales/*.json",
{ eager: true },
);

const loadersByChannel = new Map<
string,
Map<SupportedLocale, ChannelMessagesLoader>
>();
const translationsByChannel = new Map<string, Map<SupportedLocale, ChannelMessages>>();
const supportedLocaleCodes = new Set<string>(supportedLocales.map(({ code }) => code));

for (const [modulePath, module] of Object.entries(modules)) {
const messages = module.default;
if (!messages) continue;
for (const [modulePath, loader] of Object.entries(modules)) {
const match = modulePath.match(/nanobot\/channels\/([^/]+)\/webui\/locales\/([^/]+)\.json$/);
if (!match) {
throw new Error(`Cannot derive channel locale identity from '${modulePath}'`);
Expand All @@ -28,25 +31,27 @@ for (const [modulePath, module] of Object.entries(modules)) {
if (!supportedLocaleCodes.has(locale)) {
throw new Error(`Channel '${channel}' has unsupported locale '${locale}'`);
}
const translations = translationsByChannel.get(channel) ?? new Map();
if (translations.has(locale as SupportedLocale)) {
const loaders = loadersByChannel.get(channel) ?? new Map();
if (loaders.has(locale as SupportedLocale)) {
throw new Error(`Channel '${channel}' registers locale '${locale}' more than once`);
}
translations.set(locale as SupportedLocale, messages);
translationsByChannel.set(channel, translations);
loaders.set(locale as SupportedLocale, loader);
loadersByChannel.set(channel, loaders);
}

export function channelLocaleNamespaces(): string[] {
return [...translationsByChannel.keys()].map(channelNamespace);
return [...loadersByChannel.keys()].map(channelNamespace);
}

export function channelLocaleResources(locale: SupportedLocale): Record<string, unknown> {
return Object.fromEntries(
[...translationsByChannel.keys()].map((channel) => [
export async function channelLocaleResources(
locale: SupportedLocale,
): Promise<Record<string, ChannelMessages>> {
return Object.fromEntries(await Promise.all(
[...loadersByChannel.keys()].map(async (channel) => [
channelNamespace(channel),
channelLocaleMessages(channel, locale) ?? {},
await loadChannelLocale(channel, locale),
]),
);
));
}

export function channelLocaleMessages(
Expand All @@ -63,3 +68,25 @@ export function registeredChannelLocales(): ReadonlyMap<
> {
return translationsByChannel;
}

async function loadChannelLocale(
channel: string,
locale: SupportedLocale,
): Promise<ChannelMessages> {
const translations = translationsByChannel.get(channel) ?? new Map();
const loaded = translations.get(locale);
if (loaded) return loaded;

const loaders = loadersByChannel.get(channel);
const loader = loaders?.get(locale) ?? loaders?.get("en");
if (!loader) {
throw new Error(`Channel '${channel}' has no locale loader for '${locale}' or 'en'`);
}
const messages = (await loader()).default;
if (!messages) {
throw new Error(`Channel '${channel}' locale '${locale}' has no default export`);
}
translations.set(locale, messages);
translationsByChannel.set(channel, translations);
return messages;
}
2 changes: 1 addition & 1 deletion webui/src/channel-plugins/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ type ChannelUiContributionModule = {
};

const modules = import.meta.glob<ChannelUiContributionModule>(
"../../../nanobot/channels/*/webui/**/*.{ts,tsx}",
"../../../nanobot/channels/*/webui/index.{ts,tsx}",
{
eager: true,
},
Expand Down
25 changes: 23 additions & 2 deletions webui/src/components/ChatList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
type ChatGroupLabels,
} from "@/lib/chat-groups";
import { deriveTemporaryChatTitle } from "@/lib/temporary-chat";
import { clearDraggedSession, writeDraggedSession } from "@/lib/session-drag";
import { cn } from "@/lib/utils";
import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";

Expand Down Expand Up @@ -616,6 +617,7 @@ export const ChatList = memo(function ChatList({
: updated.has(s.chatId) && !topicActive
? "updated"
: null;
const canDragSession = !topicActive && !deleteSelectionMode;
return (
<li
key={s.key}
Expand Down Expand Up @@ -648,12 +650,21 @@ export const ChatList = memo(function ChatList({
}
if (!topicActive) onSelect(s.key);
}}
draggable={false}
draggable={canDragSession}
onDragStart={(event) => {
if (!canDragSession) {
event.preventDefault();
return;
}
writeDraggedSession(event.dataTransfer, s.key);
}}
onDragEnd={clearDraggedSession}
aria-current={topicActive ? "page" : undefined}
aria-pressed={deleteSelectionMode ? tabSelected : undefined}
title={tooltipTitle}
className={cn(
"flex min-w-0 flex-1 items-center gap-2 overflow-hidden text-left",
canDragSession && "cursor-grab active:cursor-grabbing",
deleteSelectionMode && "cursor-default",
compact ? "py-1" : "py-1.5",
projectMode && "pl-7",
Expand Down Expand Up @@ -1050,6 +1061,7 @@ function ActivePaneRows({
const selected = selectedDeleteKeys.has(pane.key);
const isPinned = pinned.has(pane.key);
const isArchived = archived.has(pane.key);
const canDragSession = !active && !deleteSelectionMode;

return (
<li
Expand Down Expand Up @@ -1079,12 +1091,21 @@ function ActivePaneRows({
}
onSelectPane?.(group.tabKey, pane.key);
}}
draggable={false}
draggable={canDragSession}
onDragStart={(event) => {
if (!canDragSession) {
event.preventDefault();
return;
}
writeDraggedSession(event.dataTransfer, pane.key);
}}
onDragEnd={clearDraggedSession}
aria-current={active ? "true" : undefined}
aria-pressed={deleteSelectionMode ? selected : undefined}
title={pane.title}
className={cn(
"flex min-w-0 flex-1 items-center gap-2 overflow-hidden text-left font-medium leading-5",
canDragSession && "cursor-grab active:cursor-grabbing",
compact ? "py-1" : "py-1.5",
deleteSelectionMode && "cursor-default",
)}
Expand Down
Loading
Loading