From 1b97e952438754fb1be7609501ac6458a4edae43 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 4 Aug 2026 16:44:25 +0800 Subject: [PATCH] feat(web): settings-native provider management, blue-mark brand fork (#173) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move custom provider management into Settings as a Providers tab (ProvidersPanel + AddProviderFlow + ProviderForm + providerForm lib), replacing the standalone ProviderManager overlay; add/update/delete, models.dev directory import, custom registry import, API key show/hide, inline error banners - Onboarding login step gains a custom-provider card landing on Settings → Providers - Fork web branding back to the legacy little-blue mark (sidebar, onboarding BrandLogo, favicon); build-brand-icons.mjs now only writes desktop assets - Drop the dev backend switcher pill from the sidebar (switch backends via KIMI_SERVER_URL instead) - Rebind web new-chat shortcut to Ctrl+Shift+O (browsers reserve Cmd/Ctrl+N for new window) - Source __KIMI_CLIENT_VERSION__ from the kimi-code submodule CLI version instead of apps/web/package.json - Warning toasts honor notice severity (info/success/error variants) - Remove the stale ToolRow.vue copy from the desktop renderer snapshot - Minor internal tidy-ups in transcript/detail-panel helpers --- AGENTS.md | 4 +- apps/desktop/docs/native-todos.md | 6 +- .../src/renderer/components/chat/ToolRow.vue | 240 -------- apps/web/public/favicon.ico | Bin 10202 -> 3461 bytes apps/web/src/App.vue | 71 +-- apps/web/src/api/devBackend.ts | 65 -- apps/web/src/components/Sidebar.vue | 235 +------ apps/web/src/components/WarningToasts.vue | 12 +- .../src/components/onboarding/BrandLogo.vue | 88 +-- .../onboarding/OnboardingLoginStep.vue | 22 +- .../onboarding/OnboardingWizard.vue | 9 +- .../components/settings/AddProviderFlow.vue | 578 ++++++++++++++++++ .../src/components/settings/ProviderForm.vue | 495 +++++++++++++++ .../components/settings/ProviderManager.vue | 377 ------------ .../components/settings/ProvidersPanel.vue | 349 +++++++++++ .../components/settings/SettingsDialog.vue | 13 +- .../client/useAuxiliaryTranscripts.ts | 14 +- .../client/useModelProviderState.ts | 153 ++++- .../composables/client/useWorkspaceState.ts | 8 +- apps/web/src/composables/useDetailPanel.ts | 9 +- apps/web/src/composables/useKimiWebClient.ts | 12 + apps/web/src/env.d.ts | 10 +- .../web/src/lib/auxiliaryTranscriptToTurns.ts | 31 +- apps/web/src/lib/icons.ts | 10 + apps/web/src/lib/modelThinking.test.ts | 2 + apps/web/src/lib/providerForm.test.ts | 268 ++++++++ apps/web/src/lib/providerForm.ts | 204 +++++++ apps/web/src/style.css | 2 +- apps/web/vite.config.ts | 23 +- scripts/build-brand-icons.mjs | 24 +- 30 files changed, 2243 insertions(+), 1091 deletions(-) delete mode 100644 apps/desktop/src/renderer/components/chat/ToolRow.vue delete mode 100644 apps/web/src/api/devBackend.ts create mode 100644 apps/web/src/components/settings/AddProviderFlow.vue create mode 100644 apps/web/src/components/settings/ProviderForm.vue delete mode 100644 apps/web/src/components/settings/ProviderManager.vue create mode 100644 apps/web/src/components/settings/ProvidersPanel.vue create mode 100644 apps/web/src/lib/providerForm.test.ts create mode 100644 apps/web/src/lib/providerForm.ts diff --git a/AGENTS.md b/AGENTS.md index 577f0bb09..d155296a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ - `packages/*`:`@moonshot-ai/{web-core,web-i18n,web-markdown,web-ui}` + `vite-preset`(exports→src,被 apps/web 与 desktop renderer 复用);共享字体产物在 `web-ui/src/assets/fonts`(gitignored),由 `scripts/prepare-fonts.mjs` 自动准备。 - `kimi-code/`:git submodule(核心仓)。`kimi-code/packages/*` 提供 `kap-server`、`agent-core-v2`、`kimi-code-sdk` 等源码。 - `scripts/sync-web-to-kimi-code.mjs`:`apps/web/dist` → `/apps/kimi-code/dist-web`(`KIMI_CODE_REPO` 必传,指定目标 checkout)。 -- `KIMI CODE LOGO/` + `scripts/build-brand-icons.mjs`:品牌源文件与全部图标资源的生成脚本(`pnpm build:icons`);`apps/desktop/build/` 的图标与组件内联品牌标是产物,勿手改。 +- `KIMI CODE LOGO/` + `scripts/build-brand-icons.mjs`:品牌源文件与 desktop 图标资源的生成脚本(`pnpm build:icons`);`apps/desktop/build/` 的图标与 desktop 组件内联品牌标是产物,勿手改。**web 品牌已分叉**:`apps/web` 的侧栏 / onboarding / favicon 用回旧版小蓝标,不由该脚本生成(脚本刻意不再写 apps/web),勿用机器人标冲掉。 - `publish-desktop-cdn.sh`:desktop 产物的 CDN 发布(GH Release → TOS 双链路 + 切 latest*.yml 指针 + 刷新 `download/` 入口),本地手动,TOS 凭证限内网;完整流程见根 `README.md` 发布节。 - `.github/workflows/`:`desktop-build.yml` 打四平台安装包(macOS 签名 secrets 等见文件头注释);`release.yml` 驱动 changeset 发版(不发 npm),细节见 `.changeset/README.md`。 @@ -39,7 +39,7 @@ ```bash pnpm run sync # git submodule update --init --recursive pnpm install # 装依赖(首次或 workspace 变动后;postinstall 会准备共享字体) -pnpm build:icons # 从 KIMI CODE LOGO/ 品牌源文件重新生成全部图标资源(apps/desktop/build/、web favicon、组件内联品牌标) +pnpm build:icons # 从 KIMI CODE LOGO/ 品牌源文件重新生成 desktop 图标资源(apps/desktop/build/ + desktop 组件内联品牌标;web 已分叉回小蓝,不在输出内) pnpm dev:desktop # 桌面端(renderer HMR + 默认启动内嵌 server) pnpm dev:desktop:debug # 桌面端,并开启 Electron remote debugging(端口 9222,供 agent-browser 连接) pnpm dev:web # Web UI(Vite,代理到 127.0.0.1:58627) diff --git a/apps/desktop/docs/native-todos.md b/apps/desktop/docs/native-todos.md index c87d2ce76..da406816d 100644 --- a/apps/desktop/docs/native-todos.md +++ b/apps/desktop/docs/native-todos.md @@ -7,6 +7,8 @@ - `src/renderer/` 是 `apps/web/src` 的整份快照副本,改任何共享组件前先想清楚边界:web 版只能走 daemon REST,desktop 走原生,用 `lib/desktopFlag.ts` 的 `isDesktop` / `isMacosDesktop` 或 `window.kimiDesktop` 探测分流。 - 改共享文件后需同步回 `apps/web`(开发顺序:先在 desktop 开发,再同步 web,见根 AGENTS.md)。 - desktop 侧已做品牌字符串清理(2026-07,web 侧未同步,属分叉):页面/窗口标题为 `Kimi Code`(`index.html`、`usePageTitle.ts`)、`DesignSystemView.vue` 品牌文案为 Kimi Code、日志前缀 `[kimi-code]`、trace 导出文件名 `kimi-code-log-*`、`workspaceName` 兜底值 `kimi-code`、文件头来源注释指向 `apps/web`。整目录 re-copy 时这些点会被 web 版冲回旧值,需保留 desktop 侧。注意 `kimi-web.*` localStorage key 与 `kimiWeb.optimisticUserMessage` metadata key 是有意保留的持久化协议,不要"顺手清理"。 +- **web 品牌标已分叉回旧版"小蓝"**(2026-08,用户明确决定):`apps/web` 的 `Sidebar.vue` 侧栏 logo、`onboarding/BrandLogo.vue`、`public/favicon.ico` 用回机器人换标(`993afaea`)之前的圆角矩形小蓝标(`fill="var(--logo)"` + 眼睛 mask 镂空),desktop 保持 KIMI CODE LOGO kit 的机器人标。`scripts/build-brand-icons.mjs` 已改为只写 desktop(favicon 与组件 `brand-mark` 块均不再输出到 apps/web),web 这三处不是产物、可手改。两端同步(含整目录 re-copy)时:web 侧保留小蓝 SVG 块与简化版 BrandLogo(无 tile、无 `variant` prop),desktop 侧保留带 generated 标记的机器人块,互不覆盖。 +- **web 已删 dev 后端切换 pill**(2026-08,随品牌分叉一起决定):`apps/web` 的 `Sidebar.vue` 移除了品牌行的 v1/v2 后端 pill 及其切换菜单、逻辑与 CSS,`App.vue` 不再给 Sidebar 传 `:backend`,`api/devBackend.ts` 与 `env.d.ts` 的 `__KIMI_DEV_BACKENDS__` 声明一并删除(dev 切后端改走 `KIMI_SERVER_URL` 重启 Vite)。desktop 侧完整保留该 pill;两端同步 `Sidebar.vue` / `App.vue` 时需保留各自状态。`sidebar.backendTitle` i18n key 在共享包保留(desktop 仍在用)。 ## TODO @@ -54,7 +56,7 @@ - 分发:全局 scope 由 `App.vue` 的 `onShortcutKeydown`(window bubble)统一处理——`defaultPrevented`/IME/`e.repeat` 跳过,overlay 打开时除"关闭自己"(openSettings/searchSessions)外全禁,requiresSession 的 action(归档/侧边聊天/Open in)无活动会话时 no-op;8 个 action(设置 ⌘,、侧栏 ⌘B、搜索 ⌘K、新建 ⌘N、归档 ⌥⌘A 无确认、btw ⌥⌘B、打开文件夹 ⌘O 走 `requestAddWorkspace`、Open in 默认应用 ⌥⌘O)全部复用现有 handler。默认键体系参考 VS Code(⌘,/⌘B/⌘N/⌘O 对齐,应用特有动作用 ⌥⌘+字母一族)。composer scope(发送/换行)留在 `Composer.vue` 读同一注册表——默认 Shift+Enter 走浏览器原生插入 + stopPropagation(保原生 undo),重绑的组合手动插 `\n`;expanded 模式默认发送认 mod+enter 与 ctrl+enter 双别名,用户自定义后精确匹配。**steer(Ctrl/Cmd+S)与中断(Esc)按用户决策保持硬编码、不可自定义**(steer 恢复旧条件 `ctrlKey || metaKey`,匹配即 consume 防漏给全局 dispatcher;中断恢复原 Escape + 4 重守卫)。`Sidebar.vue` 的原 `Cmd+K/Cmd+N` 监听已删(迁入注册表),键帽改读 `resolvedBindingKeys`,搜索对话框经 `defineExpose({ openSearch, toggleSearch, isSearchOpen })` 给 App 调用。 - 设置页:`SettingsDialog.vue` 新 tab「键盘快捷键」+ `components/settings/ShortcutsPanel.vue`(搜索过滤、单卡片平铺列表、Kbd 键帽、录制交互——accent 描边录制框实时回显按住的修饰键 + 显式取消按钮/Esc 取消,capture 阶段 preventDefault+stopPropagation 防 App dispatcher 与对话框 Esc 抢键,非法/冲突就地报错、单条重置/取消分配/全部重置)。 - 原生菜单联动(合入「App/File/Help 菜单扩展」后):菜单项「设置…」「新建会话」「打开文件夹」的 accelerator **跟随用户绑定**——renderer 经 `kimi:menu-shortcut` 推送 map,主进程 `menu.ts` 的 `MENU_SHORTCUT_DEFAULTS` + `setMenuShortcuts` 存 overrides 并 `buildMenu()`,`bindingToAccelerator` 转换(转不了则不显示,renderer 绑定兜底);三项 click 均 `showMainWindow()` + `kimi:menu-action` 转发,renderer 端菜单 id 经 `MENU_ACTION_TO_SHORTCUT` 映射到同一批 shortcut action(菜点击与按键永远同效;菜单 accelerator 会遮蔽 renderer keydown,所以 click 必须存在)。「新建窗口」仍 display-only(真多窗口要动 mainWindow 单例模型,未做)。 - - **web 刻意不改**:整套自定义能力 desktop-only(用户明确决定),web 保持硬编码键位。分叉块:`App.vue`(dispatcher + 菜单监听 + import)、`Sidebar.vue`(键帽 + expose + 删监听)、`Composer.vue`(steer/send/newline 读绑定 + `insertNewlineAtCaret`)、`ConversationPane.vue`(interrupt 读绑定)、`SettingsDialog.vue`(shortcuts tab)、`lib/icons.ts`(keyboard/trash 图标)、`lib/storage.ts`(shortcutOverrides key);新文件 keymap.ts / useShortcuts.ts / ShortcutsPanel.vue 不同步。i18n 的 `shortcuts.*` 与 `settings.tabs.shortcuts` 在共享包(web 不使用,无副作用)。整目录 re-copy 时需保留以上全部。 + - **web 刻意不改**:整套自定义能力 desktop-only(用户明确决定),web 保持硬编码键位(2026-08 起 web 新建会话改绑 Ctrl+Shift+O——浏览器把 ⌘N/Ctrl+N 保留给新建窗口,keydown 到不了页面,原绑定在浏览器里永远不触发;desktop 侧 ⌘N 不变)。分叉块:`App.vue`(dispatcher + 菜单监听 + import)、`Sidebar.vue`(键帽 + expose + 删监听)、`Composer.vue`(steer/send/newline 读绑定 + `insertNewlineAtCaret`)、`ConversationPane.vue`(interrupt 读绑定)、`SettingsDialog.vue`(shortcuts tab)、`lib/icons.ts`(keyboard/trash 图标)、`lib/storage.ts`(shortcutOverrides key);新文件 keymap.ts / useShortcuts.ts / ShortcutsPanel.vue 不同步。i18n 的 `shortcuts.*` 与 `settings.tabs.shortcuts` 在共享包(web 不使用,无副作用)。整目录 re-copy 时需保留以上全部。 - 测试:`tests/renderer/keymap.test.ts`(22 用例:平台判定/解析/精确修饰键匹配/⌥ 变形字符按 code 匹配/录制/校验/查重/键帽);`tests/main/menu.test.ts`(设置/新建/打开文件夹项 accelerator 跟随/缺省 + bindingToAccelerator);`tests/main/preload.test.ts`(白名单 + setMenuShortcuts 通道校验)。 - 明确未做:web 端、右侧面板/diff 快捷键、跨端同步、多窗口。 @@ -194,6 +196,8 @@ ## 共享组件 UI 分叉 +- **自定义供应商管理已两端同步**(2026-08,原 #93 desktop-first):`components/settings/ProvidersPanel.vue`、`AddProviderFlow.vue`、`ProviderForm.vue`、`lib/providerForm.ts`(+测试)、`useModelProviderState.ts` 的供应商重写、`SettingsDialog.vue` 的 providers tab、onboarding 的自定义供应商卡(`OnboardingWizard`/`OnboardingLoginStep` 的 `addProvider` emit 与卡片)现已两端一致(仅品牌头/日志前缀差异),旧 `ProviderManager.vue` 已从 web 删除。后续两端共用改动照常 desktop 先行、同步 web;web 侧无 track/loginSource 块(desktop 侧保留)。 + - **设置页「程序坞图标」行(desktop macOS only)** - 实现:通用页外观组**末尾**新增 `components/settings/DockIconPicker.vue`(desktop 专属组件,**不复用 web-ui 的 SegmentedControl**,不同步 web;控件模式已登记进 `DesignSystemView.vue` §03 的 SegmentedControl 之后,带活体 demo,web 的 DesignSystemView 不涉及):**两个选项「默认 / 黑色」**直接渲染应用图标瓷砖(BrandLogo 强制 variant,白砖 / 黑砖),分组容器 + 滑动选中 pill 复刻分段控件视觉语言;瓷砖去描边并用 n=4.5 超椭圆 clip-path(与打进 .icns 的圆角同形),底下垫随主题反转的文字色 wash 保持可读。**刻意不提供「跟随系统」**——避免与上一行的明暗主题设置混淆(早期版本有 `auto`,后经 `kimi:os-appearance` 系列 IPC 跟随真实 OS 外观,已整体拆除:picker 的 OS 外观查询/订阅、主进程 `defaults read`/`osascript` 探测与 `nativeTheme` + `AppleInterfaceThemeChangedNotification` 监听、`kimi:os-appearance[-changed]` 通道与 preload 的 `getOsAppearance`/`onOsAppearanceChanged` 全部移除)。状态在 desktop 专属 `lib/dockIconChoice.ts`(**不同步 web**,同 keymap.ts 先例;`kimi-web.dock-icon-choice` key 只加在 desktop 的 `lib/storage.ts`):localStorage 持久化 + 经桥 `setDockIconChoice`(IPC `kimi:dock-icon-choice`)推给主进程,模块加载时做一次启动首推;取值仅 `'light'|'dark'`,**存量 `auto` 与任何异常值都回落 `'light'`**(与打进 .icns 的静态图标一致)。主进程 `dock-icon.ts` 存 `currentChoice`,每次 IPC 推送时镜像写入 `/ui-state.json`(`ui-state.ts` 的 `getDockIconChoice`/`saveDockIconChoice`,同 vibrancy 先例——renderer localStorage 是本地存储正本但要等窗口 boot 才经 IPC 到达,主进程这份只是启动种子,推送永远覆盖它),`initDockIcon` 启动先读种子再 apply(读不到回落 `'light'`,与 .icns 一致),之后仅在 IPC 推送时重设 Dock 图标;preload 与 IPC 均校验取值。行可见条件 `isMacosDesktop && canSetDockIconChoice()`(桥探测)。 - **web 不改**:浏览器没有 Dock;web 的 `SettingsDialog.vue` 无此行、无此 import,`DockIconPicker.vue` 与 `lib/dockIconChoice.ts` 均不存在于 apps/web,整目录 re-copy 时需保留 desktop 侧分叉块。 diff --git a/apps/desktop/src/renderer/components/chat/ToolRow.vue b/apps/desktop/src/renderer/components/chat/ToolRow.vue deleted file mode 100644 index e6e90d4f4..000000000 --- a/apps/desktop/src/renderer/components/chat/ToolRow.vue +++ /dev/null @@ -1,240 +0,0 @@ - - - - - - diff --git a/apps/web/public/favicon.ico b/apps/web/public/favicon.ico index e278188c93e246b0a44651d3386b1fe6c768752e..f554e98ef4830a14479926865e0a3ea258025d08 100644 GIT binary patch delta 3269 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 ML5~0bQaLC54}=EVL;wH) literal 10202 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 0) to reveal the focused field, which a // 100dvh in-flow shell cannot follow: the dock ends up behind the keyboard, or @@ -324,7 +331,6 @@ const conversationPaneRef = ref | null>(nu // Dialog visibility refs const showModelPicker = ref(false); -const showProviders = ref(false); const showLogin = ref(false); const showAddWorkspace = ref(false); @@ -373,7 +379,6 @@ const anyOverlayOpen = computed( () => openDialogCount.value > 0 || showModelPicker.value || - showProviders.value || showLogin.value || showAddWorkspace.value || showStatusPanel.value || @@ -386,8 +391,6 @@ const anyOverlayOpen = computed( // Loading state for model/provider fetches const modelsLoading = ref(false); const modelsUnavailable = ref(false); -const providersLoading = ref(false); -const providersUnavailable = ref(false); const configSaving = ref(false); async function openModelPicker(): Promise { @@ -406,19 +409,6 @@ async function openModelPicker(): Promise { } } -async function openProviders(): Promise { - providersLoading.value = true; - providersUnavailable.value = false; - showProviders.value = true; - try { - await client.loadProviders(); - } catch { - providersUnavailable.value = true; - } finally { - providersLoading.value = false; - } -} - function openLogin(): void { showLogin.value = true; } @@ -459,18 +449,7 @@ async function handleComposerSelectModel(modelId: string): Promise { } } -async function handleAddProvider(input: { type: string; apiKey?: string; baseUrl?: string; defaultModel?: string }): Promise { - await client.addProvider(input); - // Provider count feeds /auth readiness (the composer send gate) — refresh it - // so a just-added provider unblocks sending without a full reload. - await client.checkAuth(); -} - -async function handleRefreshProvider(id: string): Promise { - await client.refreshProvider(id); -} - -// Destructive workspace/provider actions confirm through the shared modal +// Destructive workspace actions confirm through the shared modal // here (the menu components only emit the intent). Each passes its work as // the dialog `action`, so the dialog stays open with a loading state until // the operation settles. Both client calls toast their own errors and never @@ -502,7 +481,7 @@ async function undoArchive(): Promise { // Deep link into a settings tab (the archive undo toast opens Settings → // Archived). Read once at SettingsDialog mount, then reset on close so later // manual opens land on General again. -const settingsInitialTab = ref<'archived' | undefined>(undefined); +const settingsInitialTab = ref<'providers' | 'archived' | undefined>(undefined); // Same deep link for the mobile settings sheet (its archived sub-view). // Reset when the sheet closes so later manual opens land on the main view. const mobileSettingsInitialView = ref<'archived' | undefined>(undefined); @@ -533,20 +512,6 @@ async function confirmDeleteWorkspace(id: string): Promise { }); } -async function confirmDeleteProvider(id: string): Promise { - await confirm({ - title: t('providers.delete'), - message: t('providers.confirmDelete'), - variant: 'danger', - // Refresh /auth readiness too — deleting the last provider must re-arm - // the composer send gate. - action: async () => { - await client.deleteProvider(id); - await client.checkAuth(); - }, - }); -} - async function handleUpdateConfig(patch: Partial): Promise { configSaving.value = true; try { @@ -939,7 +904,6 @@ function openPr(url: string): void { :attention-by-session="client.attentionBySession.value" :pending-by-session="client.pendingBySession.value" :unread-by-session="client.unreadBySession.value" - :backend="client.backend.value" @select="client.selectSession($event)" @create="handleCreateSession" @create-in-workspace="handleCreateSessionInWorkspace($event)" @@ -1248,23 +1212,9 @@ function openPr(url: string): void { @update-config="handleUpdateConfig($event)" @login="() => { showSettings = false; openLogin(); }" @logout="confirmLogout" - @open-providers="() => { showSettings = false; openProviders(); }" @close="showSettings = false; settingsInitialTab = undefined" /> - - - ; -} - -/** Synchronous initial state from the Vite-injected define (no flicker). */ -export function initialDevBackendState(): DevBackendState | null { - if (!import.meta.env.DEV) return null; - const presets = - typeof __KIMI_DEV_BACKENDS__ !== 'undefined' ? __KIMI_DEV_BACKENDS__ : null; - if (!presets) return null; - const current = - typeof __KIMI_DEV_PROXY_TARGET__ !== 'undefined' && __KIMI_DEV_PROXY_TARGET__ - ? __KIMI_DEV_PROXY_TARGET__ - : presets.default; - return { current, presets }; -} - -/** Live state from the dev server. Null when the endpoints don't exist. */ -export async function fetchDevBackendState(): Promise { - if (!import.meta.env.DEV) return null; - try { - const res = await fetch('/__kimi-dev/backend'); - if (!res.ok) return null; - return (await res.json()) as DevBackendState; - } catch { - return null; - } -} - -/** - * Repoint the dev proxy at another backend preset. Returns the new state, or - * null when the switch failed (caller keeps the old target). - */ -export async function switchDevBackend(name: BackendName): Promise { - try { - const res = await fetch('/__kimi-dev/backend', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name }), - }); - if (!res.ok) return null; - return (await res.json()) as DevBackendState; - } catch { - return null; - } -} - -/** Strip the scheme for a compact display origin, mirroring api/config.ts. */ -export function shortOrigin(origin: string): string { - return origin.replace(/^https?:\/\//, '').replace(/\/$/, ''); -} diff --git a/apps/web/src/components/Sidebar.vue b/apps/web/src/components/Sidebar.vue index 487aaf331..32cf973c9 100644 --- a/apps/web/src/components/Sidebar.vue +++ b/apps/web/src/components/Sidebar.vue @@ -5,17 +5,7 @@ diff --git a/apps/web/src/components/onboarding/OnboardingLoginStep.vue b/apps/web/src/components/onboarding/OnboardingLoginStep.vue index e7c83c060..5a517f49e 100644 --- a/apps/web/src/components/onboarding/OnboardingLoginStep.vue +++ b/apps/web/src/components/onboarding/OnboardingLoginStep.vue @@ -27,7 +27,7 @@ const props = defineProps<{ onCancelOAuthLogin: () => Promise; }>(); -const emit = defineEmits<{ success: [] }>(); +const emit = defineEmits<{ success: []; addProvider: [] }>(); // 'choice' → picking a service card; 'flow' → device-code authorization UI. const phase = ref<'choice' | 'flow'>('choice'); @@ -102,7 +102,16 @@ function formatSeconds(s: number): string { - + + @@ -250,6 +259,15 @@ function formatSeconds(s: number): string { box-shadow: var(--p-focus-ring-strong); } .ls-card-logo { align-self: flex-start; } +/* Icon stand-in matching the 40px brand-logo slot on the Kimi card. */ +.ls-card-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + color: var(--color-text-muted); +} .ls-card-text { flex: 1; min-width: 0; diff --git a/apps/web/src/components/onboarding/OnboardingWizard.vue b/apps/web/src/components/onboarding/OnboardingWizard.vue index 6bfd1382e..f5609ca06 100644 --- a/apps/web/src/components/onboarding/OnboardingWizard.vue +++ b/apps/web/src/components/onboarding/OnboardingWizard.vue @@ -33,7 +33,13 @@ const props = defineProps<{ // `complete` — onboarding finished OR the login step was skipped (parent marks // the onboarded flag and closes). `loginSuccess` — the device-code flow // authenticated (parent re-checks auth + reloads, then also completes). -const emit = defineEmits<{ complete: []; loginSuccess: [] }>(); +// `addProvider` — the custom-provider card: parent completes onboarding and +// opens Settings on the Providers tab. +const emit = defineEmits<{ complete: []; loginSuccess: []; addProvider: [] }>(); + +function onAddProvider(): void { + emit('addProvider'); +} // ------------------------------------------------------------------------- // Steps @@ -146,6 +152,7 @@ function onLoginSuccess(): void { :on-poll-o-auth-login="props.onPollOAuthLogin" :on-cancel-o-auth-login="props.onCancelOAuthLogin" @success="onLoginSuccess" + @add-provider="onAddProvider" /> diff --git a/apps/web/src/components/settings/AddProviderFlow.vue b/apps/web/src/components/settings/AddProviderFlow.vue new file mode 100644 index 000000000..f90f49a76 --- /dev/null +++ b/apps/web/src/components/settings/AddProviderFlow.vue @@ -0,0 +1,578 @@ + + + + + + + diff --git a/apps/web/src/components/settings/ProviderForm.vue b/apps/web/src/components/settings/ProviderForm.vue new file mode 100644 index 000000000..05ee853a5 --- /dev/null +++ b/apps/web/src/components/settings/ProviderForm.vue @@ -0,0 +1,495 @@ + + + + + + + diff --git a/apps/web/src/components/settings/ProviderManager.vue b/apps/web/src/components/settings/ProviderManager.vue deleted file mode 100644 index 406df039d..000000000 --- a/apps/web/src/components/settings/ProviderManager.vue +++ /dev/null @@ -1,377 +0,0 @@ - - - - - - - diff --git a/apps/web/src/components/settings/ProvidersPanel.vue b/apps/web/src/components/settings/ProvidersPanel.vue new file mode 100644 index 000000000..f9e4a79e6 --- /dev/null +++ b/apps/web/src/components/settings/ProvidersPanel.vue @@ -0,0 +1,349 @@ + + + + + + + diff --git a/apps/web/src/components/settings/SettingsDialog.vue b/apps/web/src/components/settings/SettingsDialog.vue index 8872d448a..28416d1c7 100644 --- a/apps/web/src/components/settings/SettingsDialog.vue +++ b/apps/web/src/components/settings/SettingsDialog.vue @@ -10,6 +10,7 @@ import type { AppSession } from '../../api/types'; import { useDialogFocus } from '../../composables/useDialogFocus'; import { useConfirmDialog } from '../../composables/useConfirmDialog'; import LanguageSwitcher from './LanguageSwitcher.vue'; +import ProvidersPanel from './ProvidersPanel.vue'; import { serverEndpointLabel } from '../../api/config'; import { downloadTraceLog, isTraceEnabled } from '../../debug/trace'; import { useUpdateStatus, type UpdateCheckResult } from '../../composables/useUpdateStatus'; @@ -24,7 +25,7 @@ import { Badge, Button, Dialog, Icon, IconButton, SegmentedControl, Select, Swit const { t } = useI18n(); -type SettingsTab = 'general' | 'agent' | 'account' | 'advanced' | 'archived'; +type SettingsTab = 'general' | 'agent' | 'account' | 'providers' | 'advanced' | 'archived'; const props = defineProps<{ colorScheme: ColorScheme; @@ -70,7 +71,6 @@ const emit = defineEmits<{ setNotifySound: [on: boolean]; login: []; logout: []; - openProviders: []; updateConfig: [patch: Partial]; close: []; }>(); @@ -121,6 +121,8 @@ const tabs: { id: SettingsTab; labelKey: string; icon: IconName }[] = [ { id: 'general', labelKey: 'settings.tabs.general', icon: 'sliders' }, { id: 'agent', labelKey: 'settings.tabs.agent', icon: 'robot' }, { id: 'account', labelKey: 'settings.tabs.account', icon: 'user' }, + // No plug-style glyph exists in the icon registry; the bolt is the closest. + { id: 'providers', labelKey: 'settings.tabs.providers', icon: 'bolt' }, { id: 'advanced', labelKey: 'settings.tabs.advanced', icon: 'microscope' }, { id: 'archived', labelKey: 'settings.tabs.archived', icon: 'archive' }, ]; @@ -629,6 +631,13 @@ function archiveTime(iso: string): string { + +
+ +
+
diff --git a/apps/web/src/composables/client/useAuxiliaryTranscripts.ts b/apps/web/src/composables/client/useAuxiliaryTranscripts.ts index 9d5d50b94..df0915610 100644 --- a/apps/web/src/composables/client/useAuxiliaryTranscripts.ts +++ b/apps/web/src/composables/client/useAuxiliaryTranscripts.ts @@ -21,7 +21,6 @@ export function createAuxiliaryTranscriptPool(deps: { const entries = shallowReactive(new Map()); const desiredAgentBySession = new Map(); const subscribedAgentBySession = new Map(); - const keyOf = (sessionId: string, agentId: string) => `${sessionId}\0${agentId}`; // Streaming ops can notify many times per second; the version ref only feeds // UI recomputation, so bump it once per frame (task fallback for hidden @@ -52,6 +51,10 @@ export function createAuxiliaryTranscriptPool(deps: { taskHandle = setTimeout(flushNotifications, 50); } + function keyOf(sessionId: string, agentId: string): string { + return `${sessionId}\0${agentId}`; + } + function subscribeCurrent(sessionId: string, agentId: string, sinceSeq?: number): void { const connection = deps.getEventConnection(); if (connection === null) return; @@ -62,16 +65,19 @@ export function createAuxiliaryTranscriptPool(deps: { function getOrCreate(sessionId: string, agentId: string): AuxiliaryTranscriptEntry { const key = keyOf(sessionId, agentId); const existing = entries.get(key); - if (existing) return existing; + if (existing !== undefined) return existing; const entry: AuxiliaryTranscriptEntry = { channel: new TranscriptChannel({ sessionId, agentId, - fetchPage: (query) => deps.api.getSessionTranscript(sessionId, { ...query, agentId }), + fetchPage: (query) => + deps.api.getSessionTranscript(sessionId, { ...query, agentId }), onChange: () => { scheduleNotification(entry); }, - onGap: () => void refreshAndResume(entry), + onGap: () => { + void refreshAndResume(entry); + }, }), version: ref(0), baselineLoaded: false, diff --git a/apps/web/src/composables/client/useModelProviderState.ts b/apps/web/src/composables/client/useModelProviderState.ts index 4c6664a9c..a7f9d173b 100644 --- a/apps/web/src/composables/client/useModelProviderState.ts +++ b/apps/web/src/composables/client/useModelProviderState.ts @@ -7,8 +7,9 @@ import { ref, watch, type ComputedRef } from 'vue'; import { getKimiWebApi } from '../../api'; -import type { AppMessage, AppModel, AppProvider, AppSession, AppSkill, ManagedUsageResult, OAuthLoginStartResult, ThinkingLevel } from '../../api/types'; -import { logWarn } from '../../lib/log'; +import { DaemonApiError } from '../../api/errors'; +import type { AddProviderInput, AppCatalogProvider, AppMessage, AppModel, AppProvider, AppProviderDetail, AppSession, AppSkill, DeleteProviderResult, ImportCatalogProviderInput, ImportCustomRegistryInput, ManagedUsageResult, OAuthLoginStartResult, ThinkingLevel, UpdateProviderInput } from '../../api/types'; +import { logError, logWarn } from '../../lib/log'; import { safeGetString, safeSetString, STORAGE_KEYS } from '../../lib/storage'; import { defaultThinkingLevelFor, @@ -80,6 +81,14 @@ export interface UseModelProviderStateDeps { sessionId: string, update: (messages: AppMessage[]) => AppMessage[], ) => void; + /** Reload the global config. Provider/model writes (and discovery refreshes) + * rewrite config sections; the edit form reads model records from config, + * so every mutation must end with a fresh snapshot. */ + loadConfig: () => Promise; + /** Re-check /auth readiness. The provider count feeds the composer send + * gate, so every provider mutation (add/delete/import) must refresh it — + * adding the first provider unblocks sending, deleting the last re-arms it. */ + checkAuth: () => Promise; } export function useModelProviderState( @@ -93,6 +102,8 @@ export function useModelProviderState( activity, updateSession, updateSessionMessages, + loadConfig, + checkAuth, } = deps; // Models + Providers reactive state (lazy-loaded, cached) @@ -463,30 +474,67 @@ export function useModelProviderState( } } - /** Add a provider, then reload providers + models */ - async function addProvider(input: { - type: string; - apiKey?: string; - baseUrl?: string; - defaultModel?: string; - }): Promise { + /** + * Fetch a single provider's detail — the only call that reveals the stored + * API key, used by the edit form to prefill the password field. Throws to + * the caller (the form degrades to the redacted placeholder on failure). + */ + async function getProvider(id: string): Promise { + return getKimiWebApi().getProvider(id); + } + + /** + * Add a provider, then reload providers + models. Resolves to null on + * success, or to the error message on failure — the add form shows that + * message in its inline banner, so failures are NOT toasted here (only + * logged, keeping them diagnosable from the console/web log). + */ + async function addProvider(input: AddProviderInput): Promise { try { const api = getKimiWebApi(); await api.addProvider(input); - await Promise.all([loadProviders(), loadModels()]); + await Promise.all([loadProviders(), loadModels(), loadConfig()]); + await checkAuth(); + return null; } catch (err) { - pushOperationFailure('addProvider', err); + logError('[kimi-web] operation failed: addProvider', err); + return err instanceof Error ? err.message : String(err); } } - /** Delete a provider, then reload providers + models */ - async function deleteProvider(id: string): Promise { + /** + * Update a provider (PUT replace semantics), then reload providers + models. + * Same error contract as addProvider: resolves to null on success, or to the + * error message for the panel's inline banner (failures are logged, not + * toasted). + */ + async function updateProvider(id: string, input: UpdateProviderInput): Promise { try { const api = getKimiWebApi(); - await api.deleteProvider(id); - await Promise.all([loadProviders(), loadModels()]); + await api.updateProvider(id, input); + await Promise.all([loadProviders(), loadModels(), loadConfig()]); + return null; + } catch (err) { + logError('[kimi-web] operation failed: updateProvider', err); + return err instanceof Error ? err.message : String(err); + } + } + + /** + * Delete a provider, then reload providers + models + config. Resolves to + * `{ deleted: id }` on success (the daemon never touches the global default + * pointers on delete), or null on failure (toasted here). + */ + async function deleteProvider(id: string): Promise { + try { + const api = getKimiWebApi(); + const result = await api.deleteProvider(id); + await Promise.all([loadProviders(), loadModels(), loadConfig()]); + await checkAuth(); + return result; } catch (err) { pushOperationFailure('deleteProvider', err); + return null; } } @@ -499,7 +547,7 @@ export function useModelProviderState( message: failure.provider, }); } - await Promise.all([loadProviders(), loadModels()]); + await Promise.all([loadProviders(), loadModels(), loadConfig()]); } catch (err) { pushOperationFailure('refreshProvider', err); } @@ -514,12 +562,78 @@ export function useModelProviderState( message: failure.provider, }); } - await Promise.all([loadProviders(), loadModels()]); + await Promise.all([loadProviders(), loadModels(), loadConfig()]); } catch (err) { pushOperationFailure('refreshAllProviders', err); } } + /** + * Load the server-proxied models.dev directory for the add-provider flow. + * Three-way outcome: 'ok' carries the entries, 'unsupported' means the + * connected server predates the catalog routes (an old daemon answers the + * unknown route with a non-envelope 404, surfacing as a DaemonApiError + * without a code — the flow then hides the directory source), 'error' is + * any other failure (directory unavailable, network down — retryable). + */ + async function loadCatalogProviders(): Promise< + | { kind: 'ok'; items: AppCatalogProvider[] } + | { kind: 'unsupported' } + | { kind: 'error' } + > { + try { + const items = await getKimiWebApi().listCatalogProviders(); + return { kind: 'ok', items }; + } catch (err) { + if (err instanceof DaemonApiError && err.code === undefined) { + return { kind: 'unsupported' }; + } + logError('[kimi-web] operation failed: loadCatalogProviders', err); + return { kind: 'error' }; + } + } + + /** + * Import a models.dev directory entry as a provider, then reload providers + * + models + config. Same error contract as addProvider: null on success, + * the error message otherwise (inline banner, logged not toasted). + */ + async function importCatalogProvider( + input: ImportCatalogProviderInput, + ): Promise { + try { + const api = getKimiWebApi(); + await api.importCatalogProvider(input); + await Promise.all([loadProviders(), loadModels(), loadConfig()]); + await checkAuth(); + return null; + } catch (err) { + logError('[kimi-web] operation failed: importCatalogProvider', err); + return err instanceof Error ? err.message : String(err); + } + } + + /** + * Import a custom registry (api.json URL + optional key), then reload + * providers + models + config. Success carries the imported providers (the + * flow expands the first one); failure resolves to the error message for + * the flow's inline banner (logged, not toasted). + */ + async function importCustomRegistry( + input: ImportCustomRegistryInput, + ): Promise<{ providers: AppProvider[]; modelsImported: number } | string> { + try { + const api = getKimiWebApi(); + const result = await api.importCustomRegistry(input); + await Promise.all([loadProviders(), loadModels(), loadConfig()]); + await checkAuth(); + return result; + } catch (err) { + logError('[kimi-web] operation failed: importCustomRegistry', err); + return err instanceof Error ? err.message : String(err); + } + } + /** Start managed Kimi OAuth device flow. Returns flow data or null on error. */ async function startOAuthLogin(): Promise { try { @@ -597,7 +711,12 @@ export function useModelProviderState( toggleStarModel, activateSkill, addProvider, + updateProvider, deleteProvider, + getProvider, + loadCatalogProviders, + importCatalogProvider, + importCustomRegistry, refreshProvider, refreshAllProviders, startOAuthLogin, diff --git a/apps/web/src/composables/client/useWorkspaceState.ts b/apps/web/src/composables/client/useWorkspaceState.ts index 38683e605..cf6342f36 100644 --- a/apps/web/src/composables/client/useWorkspaceState.ts +++ b/apps/web/src/composables/client/useWorkspaceState.ts @@ -516,10 +516,10 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta * - 'retry' — transient failure (network, timeout, 5xx); the * caller should retry instead of treating it as * "not signed in" */ - // Generation guard for the fire-and-forget /oauth/userinfo fetch below: - // bumped at every checkAuth() entry so the last-issued call wins — a stale - // late response must not overwrite, nor a stale rejection clear, the - // profile the newest call wrote. + // Generation guard for the /oauth/userinfo fetches below: bumped at every + // issued fetch so the last-issued call wins — a stale late response must + // not overwrite, nor a stale rejection clear, the profile the newest call + // wrote. let userInfoFetchGeneration = 0; /** One guarded /oauth/userinfo fetch. Shared by checkAuth (fire-and-forget, diff --git a/apps/web/src/composables/useDetailPanel.ts b/apps/web/src/composables/useDetailPanel.ts index 16ce8a873..1376445b8 100644 --- a/apps/web/src/composables/useDetailPanel.ts +++ b/apps/web/src/composables/useDetailPanel.ts @@ -179,6 +179,7 @@ export function useDetailPanel({ outputLines: toolMetadata.outputLines, }; }); + const agentPanelTurns = computed(() => { const entry = agentTranscriptState.value.entry; if (!entry) return []; @@ -231,7 +232,9 @@ export function useDetailPanel({ function closeAgentPanel(): void { const target = agentTarget.value; - if (target) client.auxiliaryTranscripts.deactivate(target.sessionId, target.subagentId); + if (target) { + client.auxiliaryTranscripts.deactivate(target.sessionId, target.subagentId); + } agentTarget.value = null; if (detailTarget.value === 'agent') detailTarget.value = null; } @@ -239,7 +242,9 @@ export function useDetailPanel({ watch(detailTarget, (current, previous) => { if (previous !== 'agent' || current === 'agent') return; const target = agentTarget.value; - if (target) client.auxiliaryTranscripts.deactivate(target.sessionId, target.subagentId); + if (target) { + client.auxiliaryTranscripts.deactivate(target.sessionId, target.subagentId); + } }); function loadOlderAgentMessages(): void { diff --git a/apps/web/src/composables/useKimiWebClient.ts b/apps/web/src/composables/useKimiWebClient.ts index f5750fffa..e83684f93 100644 --- a/apps/web/src/composables/useKimiWebClient.ts +++ b/apps/web/src/composables/useKimiWebClient.ts @@ -2637,6 +2637,9 @@ const modelProvider = useModelProviderState(rawState, { activity, updateSession, updateSessionMessages, + // Lazy: workspaceState is composed below, but only invoked after creation. + loadConfig: () => workspaceState.loadConfig(), + checkAuth: () => workspaceState.checkAuth(), }); /** Git info for the active session from the daemon's fs:git_status response */ @@ -3496,9 +3499,14 @@ export function useKimiWebClient() { setModel: modelProvider.setModel, toggleStarModel: modelProvider.toggleStarModel, addProvider: modelProvider.addProvider, + updateProvider: modelProvider.updateProvider, + getProvider: modelProvider.getProvider, deleteProvider: modelProvider.deleteProvider, refreshProvider: modelProvider.refreshProvider, refreshAllProviders: modelProvider.refreshAllProviders, + loadCatalogProviders: modelProvider.loadCatalogProviders, + importCatalogProvider: modelProvider.importCatalogProvider, + importCustomRegistry: modelProvider.importCustomRegistry, // Auth state authReady, @@ -3507,8 +3515,12 @@ export function useKimiWebClient() { managedUserInfo, managedMembership, + // Transient notices (WarningToasts) + notify: pushWarning, + // Config state + actions config, + loadConfig: workspaceState.loadConfig, updateConfig: workspaceState.updateConfig, // Auth actions diff --git a/apps/web/src/env.d.ts b/apps/web/src/env.d.ts index 225ba43c0..640870a2e 100644 --- a/apps/web/src/env.d.ts +++ b/apps/web/src/env.d.ts @@ -12,13 +12,9 @@ declare global { // In production builds this is still defined but unused (same-origin daemon). const __KIMI_DEV_PROXY_TARGET__: string; - // Injected by Vite `define`: the named dev-backend presets offered by the dev - // backend switcher (see api/devBackend.ts). Absent when the switcher's Vite - // plugin is not mounted. - const __KIMI_DEV_BACKENDS__: { default: string; multi: string }; - - // Injected by Vite `define`: the client version, from apps/web/package.json - // (see vite.config.ts). + // Injected by Vite `define`: the client version — the Kimi Code CLI version + // this web bundle ships with (from the kimi-code submodule, see + // vite.config.ts), NOT apps/web/package.json. const __KIMI_CLIENT_VERSION__: string; // Injected by Vite `define`: true only in the web bundle embedded in the Kimi diff --git a/apps/web/src/lib/auxiliaryTranscriptToTurns.ts b/apps/web/src/lib/auxiliaryTranscriptToTurns.ts index 42c66846e..c4ad58733 100644 --- a/apps/web/src/lib/auxiliaryTranscriptToTurns.ts +++ b/apps/web/src/lib/auxiliaryTranscriptToTurns.ts @@ -30,12 +30,8 @@ export function auxiliaryTranscriptToTurns( ) : [], ); - return messagesToTurns( - messages, - [], - getFileUrl, - snapshot.meta.activity === 'turn', - ).map(clearMissingTimestamps); + const running = snapshot.meta.activity === 'turn'; + return messagesToTurns(messages, [], getFileUrl, running).map(clearMissingTimestamps); } function turnToMessages( @@ -158,10 +154,13 @@ function turnToMessages( } } } + const duration = turn.durationMs ?? durationMs(createdAt || undefined, endedAt); - const lastAssistant = messages.findLastIndex((message) => message.role === 'assistant'); - if (duration !== undefined && lastAssistant >= 0) { - messages[lastAssistant] = { ...messages[lastAssistant]!, durationMs: duration }; + if (duration !== undefined) { + const lastAssistant = messages.findLastIndex((message) => message.role === 'assistant'); + if (lastAssistant >= 0) { + messages[lastAssistant] = { ...messages[lastAssistant]!, durationMs: duration }; + } } return messages; } @@ -217,17 +216,19 @@ function attachmentToContent( if (attachment.mediaType.startsWith('image/')) { return { type: 'image', - source: attachment.source.kind === 'url' - ? { kind: 'url', url: attachment.source.url } - : { kind: 'file', fileId: attachment.source.fileId }, + source: + attachment.source.kind === 'url' + ? { kind: 'url', url: attachment.source.url } + : { kind: 'file', fileId: attachment.source.fileId }, }; } if (attachment.mediaType.startsWith('video/')) { return { type: 'video', - source: attachment.source.kind === 'url' - ? { kind: 'url', url: attachment.source.url } - : { kind: 'file', fileId: attachment.source.fileId }, + source: + attachment.source.kind === 'url' + ? { kind: 'url', url: attachment.source.url } + : { kind: 'file', fileId: attachment.source.fileId }, }; } if (attachment.source.kind !== 'file') return undefined; diff --git a/apps/web/src/lib/icons.ts b/apps/web/src/lib/icons.ts index 5f626657f..77471a646 100644 --- a/apps/web/src/lib/icons.ts +++ b/apps/web/src/lib/icons.ts @@ -113,6 +113,8 @@ import RiCalendarTodoLine from '~icons/ri/calendar-todo-line'; import RiCodeLine from '~icons/ri/code-line'; import RiEmotionLine from '~icons/ri/emotion-line'; import RiExternalLinkLine from '~icons/ri/external-link-line'; +import RiEyeLine from '~icons/ri/eye-line'; +import RiEyeOffLine from '~icons/ri/eye-off-line'; import RiFileAddLine from '~icons/ri/file-add-line'; import RiFlashlightLine from '~icons/ri/flashlight-line'; import RiFolderFill from '~icons/ri/folder-fill'; @@ -207,6 +209,8 @@ import RawCalendarTodoLine from '~icons/ri/calendar-todo-line?raw'; import RawCodeLine from '~icons/ri/code-line?raw'; import RawEmotionLine from '~icons/ri/emotion-line?raw'; import RawExternalLinkLine from '~icons/ri/external-link-line?raw'; +import RawEyeLine from '~icons/ri/eye-line?raw'; +import RawEyeOffLine from '~icons/ri/eye-off-line?raw'; import RawFileAddLine from '~icons/ri/file-add-line?raw'; import RawFlashlightLine from '~icons/ri/flashlight-line?raw'; import RawFolderFill from '~icons/ri/folder-fill?raw'; @@ -277,6 +281,8 @@ export type IconName = | 'file-off' | 'attachment' | 'image-off' + | 'eye' + | 'eye-off' | 'code' | 'terminal' | 'pencil' @@ -383,6 +389,8 @@ export const ICONS: Record = { 'file-off': entry(KimiFile, RawKimiFile), attachment: entry(TablerPaperclip, RawTablerPaperclip), 'image-off': entry(KimiImageFailed, RawKimiImageFailed), + eye: entry(RiEyeLine, RawEyeLine), + 'eye-off': entry(RiEyeOffLine, RawEyeOffLine), code: entry(RiCodeLine, RawCodeLine), terminal: entry(KimiTerminal, RawKimiTerminal), pencil: entry(KimiPencil, RawKimiPencil), @@ -462,6 +470,8 @@ export const ICON_GROUPS: ReadonlyArray 'sliders', 'log-in', 'log-out', + 'eye', + 'eye-off', ], ], [ diff --git a/apps/web/src/lib/modelThinking.test.ts b/apps/web/src/lib/modelThinking.test.ts index c32e83496..ce9608f6e 100644 --- a/apps/web/src/lib/modelThinking.test.ts +++ b/apps/web/src/lib/modelThinking.test.ts @@ -297,6 +297,8 @@ describe('useModelProviderState thinking on model selection', () => { ); }, updateSessionMessages: vi.fn(), + loadConfig: vi.fn().mockResolvedValue(undefined), + checkAuth: vi.fn().mockResolvedValue(undefined), ...depOverrides, }; const provider = useModelProviderState(state, deps); diff --git a/apps/web/src/lib/providerForm.test.ts b/apps/web/src/lib/providerForm.test.ts new file mode 100644 index 000000000..c4810d3eb --- /dev/null +++ b/apps/web/src/lib/providerForm.test.ts @@ -0,0 +1,268 @@ +import { describe, expect, it } from 'vitest'; +import { + buildAddProviderInput, + buildUpdateProviderInput, + emptyModelRow, + isManagedOAuthProvider, + PROVIDER_TYPES, + providerModelRows, + validateProviderForm, + type ModelRow, + type ProviderFormState, +} from './providerForm'; + +function modelRow(overrides: Partial = {}): ModelRow { + return { + model: 'gpt-4.1', + maxContextSize: '1047576', + displayName: '', + capabilities: ['tool_use', 'thinking'], + supportEfforts: [], + adaptiveThinking: true, + ...overrides, + }; +} + +function validForm(overrides: Partial = {}): ProviderFormState { + return { + id: 'my-openai', + type: 'openai', + apiKey: 'sk-test', + baseUrl: 'https://api.openai.com/v1', + models: [modelRow()], + ...overrides, + }; +} + +describe('PROVIDER_TYPES', () => { + it('matches the daemon wire protocols', () => { + expect(PROVIDER_TYPES).toEqual([ + 'kimi', + 'openai', + 'openai_responses', + 'anthropic', + 'google-genai', + 'vertexai', + ]); + }); +}); + +describe('validateProviderForm', () => { + it('accepts a valid form', () => { + expect(validateProviderForm(validForm())).toBeNull(); + }); + + it('requires the provider id', () => { + expect(validateProviderForm(validForm({ id: ' ' }))).toBe('idRequired'); + }); + + it('enforces the server id pattern', () => { + expect(validateProviderForm(validForm({ id: 'bad!id' }))).toBe('idInvalid'); + expect(validateProviderForm(validForm({ id: '-lead-dash' }))).toBe('idInvalid'); + expect(validateProviderForm(validForm({ id: 'ok-id_2' }))).toBeNull(); + // Unicode names (Chinese + space) are allowed, matching the daemon schema. + expect(validateProviderForm(validForm({ id: '测试 Kimi' }))).toBeNull(); + expect(validateProviderForm(validForm({ id: 'My OpenAI' }))).toBeNull(); + }); + + it('requires the api key and base url only when the caller asks (add path)', () => { + // Add path: both enforced. + expect( + validateProviderForm(validForm({ apiKey: ' ' }), { requireApiKey: true, requireBaseUrl: true }), + ).toBe('apiKeyRequired'); + expect( + validateProviderForm(validForm({ baseUrl: ' ' }), { requireApiKey: true, requireBaseUrl: true }), + ).toBe('baseUrlRequired'); + // Edit path (no flags): blank means keep/clear (api key) or unset (base url). + expect(validateProviderForm(validForm({ apiKey: ' ', baseUrl: ' ' }))).toBeNull(); + }); + + it('requires at least one model row with a model id', () => { + expect(validateProviderForm(validForm({ models: [] }))).toBe('modelRequired'); + expect( + validateProviderForm(validForm({ models: [modelRow({ model: ' ', maxContextSize: '128000' })] })), + ).toBe('modelRequired'); + }); + + it('requires a positive integer max context size', () => { + const withSize = (maxContextSize: string) => + validateProviderForm(validForm({ models: [modelRow({ maxContextSize })] })); + expect(withSize('')).toBe('contextSizeRequired'); + expect(withSize('abc')).toBe('contextSizeInvalid'); + expect(withSize('12.5')).toBe('contextSizeInvalid'); + expect(withSize('0')).toBe('contextSizeInvalid'); + expect(withSize('128000')).toBeNull(); + }); +}); + +describe('buildAddProviderInput', () => { + it('trims fields, parses the context size, and drops blank optionals', () => { + const input = buildAddProviderInput( + validForm({ + id: ' my-openai ', + apiKey: ' ', + baseUrl: ' ', + models: [ + modelRow({ model: ' gpt-4.1 ', displayName: ' ', capabilities: [], adaptiveThinking: undefined }), + modelRow({ model: 'gpt-4.1-mini', displayName: ' Mini ' }), + ], + }), + ); + expect(input).toEqual({ + id: 'my-openai', + type: 'openai', + // Blank capability/effort lists are omitted (missing ≠ explicitly none). + models: [ + { model: 'gpt-4.1', maxContextSize: 1047576 }, + { + model: 'gpt-4.1-mini', + maxContextSize: 1047576, + displayName: 'Mini', + capabilities: ['tool_use', 'thinking'], + adaptiveThinking: true, + }, + ], + }); + }); +}); + +describe('buildUpdateProviderInput', () => { + const existing = { + id: 'my-openai', + type: 'openai', + baseUrl: 'https://api.openai.com/v1', + defaultModel: 'gpt-4.1', + hasApiKey: true, + status: 'connected' as const, + }; + + it('omits a blank api key (keep the stored one) and a blank base url', () => { + const input = buildUpdateProviderInput(validForm({ apiKey: ' ', baseUrl: '' }), existing); + expect(input).not.toHaveProperty('apiKey'); + expect(input).not.toHaveProperty('baseUrl'); + }); + + it('sends a blank api key as a clear once the stored key was loaded into the form', () => { + const input = buildUpdateProviderInput( + validForm({ apiKey: ' ' }), + existing, + { includeBlankApiKey: true }, + ); + expect(input.apiKey).toBe(''); + }); + + it('sends a newly entered api key and the trimmed base url', () => { + const input = buildUpdateProviderInput( + validForm({ apiKey: ' sk-new ', baseUrl: ' https://gw.example.com/v1 ' }), + existing, + ); + expect(input.apiKey).toBe('sk-new'); + expect(input.baseUrl).toBe('https://gw.example.com/v1'); + }); + + it('sends new_id only when the name changed', () => { + expect( + buildUpdateProviderInput(validForm({ id: 'renamed' }), existing).newId, + ).toBe('renamed'); + expect( + buildUpdateProviderInput(validForm({ id: 'my-openai' }), existing).newId, + ).toBeUndefined(); + }); + + it('preserves the configured default model only while it matches a model row', () => { + // The configured value is the raw config alias (`/`); the + // wire gets the bare model name back. + expect( + buildUpdateProviderInput(validForm(), existing, { + existingDefaultModel: 'my-openai/gpt-4.1', + }).defaultModel, + ).toBe('gpt-4.1'); + // The pointed-at model was removed from the form — dropped, not repointed. + expect( + buildUpdateProviderInput( + validForm({ models: [modelRow({ model: 'gpt-4.1-mini' })] }), + existing, + { existingDefaultModel: 'my-openai/gpt-4.1' }, + ).defaultModel, + ).toBeUndefined(); + // No configured default (the catalog item's materialized default_model is + // a global-default fallback) — nothing is persisted as provider-level. + expect( + buildUpdateProviderInput(validForm(), { + ...existing, + defaultModel: 'my-openai/gpt-4.1', + }).defaultModel, + ).toBeUndefined(); + // A bare model id may itself contain "/" (gateway catalogs): strip only + // the alias prefix (up to the FIRST "/"). + expect( + buildUpdateProviderInput(validForm({ models: [modelRow({ model: 'openai/gpt-4o' })] }), existing, { + existingDefaultModel: 'openrouter/openai/gpt-4o', + }).defaultModel, + ).toBe('openai/gpt-4o'); + }); +}); + +describe('isManagedOAuthProvider', () => { + it('flags only the fixed managed id + kimi type pair', () => { + expect(isManagedOAuthProvider({ id: 'managed:kimi-code', type: 'kimi' })).toBe(true); + expect(isManagedOAuthProvider({ id: 'managed:kimi-code', type: 'openai' })).toBe(false); + expect(isManagedOAuthProvider({ id: 'my-kimi', type: 'kimi' })).toBe(false); + expect(isManagedOAuthProvider({ id: 'my-openai', type: 'openai' })).toBe(false); + }); +}); + +describe('providerModelRows', () => { + const provider = { + id: 'mock-anthropic', + type: 'anthropic', + hasApiKey: true, + status: 'connected' as const, + }; + + it('builds rows from config model records with bare model ids (never the alias)', () => { + const rows = providerModelRows(provider, { + 'mock/kimi-highspeed': { + provider: 'mock-anthropic', + model: 'mock kimi', + maxContextSize: 262144, + displayName: 'Mock Anthropic (local)', + capabilities: ['thinking', 'always_thinking', 'tool_use'], + supportEfforts: ['low', 'max'], + adaptiveThinking: true, + }, + 'other/x': { provider: 'other', model: 'x', max_context_size: 1 }, + }); + expect(rows).toEqual([ + { + model: 'mock kimi', + maxContextSize: '262144', + displayName: 'Mock Anthropic (local)', + capabilities: ['thinking', 'always_thinking', 'tool_use'], + supportEfforts: ['low', 'max'], + adaptiveThinking: true, + }, + ]); + }); + + it('ignores other providers and tolerates missing record fields', () => { + expect(providerModelRows(provider, { 'a/b': { provider: 'other', model: 'b' } })).toEqual([]); + expect(providerModelRows(provider, { 'a/b': { provider: 'mock-anthropic' } })).toEqual([ + { model: '', maxContextSize: '', displayName: '', capabilities: [], supportEfforts: [] }, + ]); + expect(providerModelRows(provider, undefined)).toEqual([]); + }); +}); + +describe('emptyModelRow', () => { + it('starts blank with tool_use + thinking on and adaptive thinking defaulted', () => { + expect(emptyModelRow()).toEqual({ + model: '', + maxContextSize: '', + displayName: '', + capabilities: ['tool_use', 'thinking'], + supportEfforts: [], + adaptiveThinking: true, + }); + }); +}); diff --git a/apps/web/src/lib/providerForm.ts b/apps/web/src/lib/providerForm.ts new file mode 100644 index 000000000..0976f997b --- /dev/null +++ b/apps/web/src/lib/providerForm.ts @@ -0,0 +1,204 @@ +// apps/web/src/lib/providerForm.ts +// Logic for the settings Providers panel: the daemon's wire protocol types, +// client-side validation (mirrors the server zod schema, design §4.1), request +// building for the add (POST) and edit (PUT) paths, and the managed-OAuth +// provider check. + +import type { AddProviderInput, AddProviderModelInput, AppProvider, UpdateProviderInput } from '../api/types'; + +/** Wire protocols the daemon accepts for a provider (agent-core config schema). */ +export const PROVIDER_TYPES = [ + 'kimi', + 'openai', + 'openai_responses', + 'anthropic', + 'google-genai', + 'vertexai', +] as const; + +/** One editable model row; maxContextSize stays a string until submit. */ +export interface ModelRow { + model: string; + maxContextSize: string; + displayName: string; + /** Full capabilities array, including values unknown to the toggles (e.g. + always_thinking) — they round-trip untouched through the form. */ + capabilities: string[]; + supportEfforts: string[]; + /** Tri-state on purpose: undefined must stay undefined (core infers from the + model name); only newly added rows default to true. Not shown in the UI. */ + adaptiveThinking?: boolean; +} + +export interface ProviderFormState { + id: string; + type: string; + apiKey: string; + baseUrl: string; + models: ModelRow[]; +} + +export function emptyModelRow(): ModelRow { + return { + model: '', + maxContextSize: '', + displayName: '', + capabilities: ['tool_use', 'thinking'], + supportEfforts: [], + adaptiveThinking: true, + }; +} + +/** + * Model rows for an existing provider, built from the config's model records + * (GET /config → models section). The record's `model` field is the bare + * remote model id — never the `/` catalog alias (prefixes are + * an alias-key scheme and may not even match the provider id). May return an + * empty array when the provider has no records. + */ +export function providerModelRows( + provider: AppProvider, + configModels: Record | undefined, +): ModelRow[] { + const rows: ModelRow[] = []; + for (const raw of Object.values(configModels ?? {})) { + if (raw === null || typeof raw !== 'object') continue; + const record = raw as Record; + if (record['provider'] !== provider.id) continue; + rows.push({ + model: typeof record['model'] === 'string' ? record['model'] : '', + maxContextSize: + typeof record['maxContextSize'] === 'number' ? String(record['maxContextSize']) : '', + displayName: typeof record['displayName'] === 'string' ? record['displayName'] : '', + capabilities: Array.isArray(record['capabilities']) + ? record['capabilities'].filter((c): c is string => typeof c === 'string') + : [], + supportEfforts: Array.isArray(record['supportEfforts']) + ? record['supportEfforts'].filter((e): e is string => typeof e === 'string') + : [], + ...(typeof record['adaptiveThinking'] === 'boolean' + ? { adaptiveThinking: record['adaptiveThinking'] } + : {}), + }); + } + return rows; +} + +/** Validation failures, rendered via the providers.error.* i18n keys. */ +export type ProviderFormError = + | 'idRequired' + | 'idInvalid' + | 'apiKeyRequired' + | 'baseUrlRequired' + | 'modelRequired' + | 'contextSizeRequired' + | 'contextSizeInvalid'; + +// Server schema (design §4.1): Unicode letters/digits + "-", "_", spaces. +export const PROVIDER_ID_PATTERN = /^[\p{L}\p{N}][\p{L}\p{N}\-_ ]*$/u; + +/** + * Validate the provider form. Both the add and the edit path validate the id + * (it stays editable there — renames go through PUT new_id). `requireApiKey` + * / `requireBaseUrl` are enforced only on the add path: on edit a blank API + * key means keep-or-clear (decided by whether the stored key was loaded) and + * a blank base URL means unset, so neither may block saving. + */ +export function validateProviderForm( + state: ProviderFormState, + opts: { requireApiKey?: boolean; requireBaseUrl?: boolean } = {}, +): ProviderFormError | null { + const id = state.id.trim(); + if (id === '') return 'idRequired'; + if (!PROVIDER_ID_PATTERN.test(id)) return 'idInvalid'; + if (opts.requireApiKey === true && state.apiKey.trim() === '') return 'apiKeyRequired'; + if (opts.requireBaseUrl === true && state.baseUrl.trim() === '') return 'baseUrlRequired'; + if (state.models.length === 0) return 'modelRequired'; + for (const row of state.models) { + if (row.model.trim() === '') return 'modelRequired'; + const size = row.maxContextSize.trim(); + if (size === '') return 'contextSizeRequired'; + if (!/^\d+$/.test(size) || Number(size) < 1) return 'contextSizeInvalid'; + } + return null; +} + +/** Trim rows, parse the context size, and drop blank display names. Empty + * capability/effort lists are OMITTED (not persisted as `[]`): a missing key + * and an empty array mean different things at runtime (inferred defaults vs + * explicitly none). Unknown capability members ride along untouched. */ +function buildModels(rows: ModelRow[]): AddProviderModelInput[] { + return rows.map((row) => { + const displayName = row.displayName.trim(); + return { + model: row.model.trim(), + maxContextSize: Number(row.maxContextSize.trim()), + ...(row.capabilities.length > 0 ? { capabilities: [...row.capabilities] } : {}), + ...(row.supportEfforts.length > 0 ? { supportEfforts: [...row.supportEfforts] } : {}), + ...(row.adaptiveThinking !== undefined ? { adaptiveThinking: row.adaptiveThinking } : {}), + ...(displayName === '' ? {} : { displayName }), + }; + }); +} + +/** Map the validated form to the add-provider request: trims strings and drops + * blank optionals. */ +export function buildAddProviderInput(state: ProviderFormState): AddProviderInput { + const apiKey = state.apiKey.trim(); + const baseUrl = state.baseUrl.trim(); + return { + id: state.id.trim(), + type: state.type, + models: buildModels(state.models), + ...(apiKey === '' ? {} : { apiKey }), + ...(baseUrl === '' ? {} : { baseUrl }), + }; +} + +/** + * Map the validated form to the update-provider request (PUT replace + * semantics). A blank API-key input normally means "keep the stored key", so + * the field is omitted; when the form had the stored key loaded into it + * (`includeBlankApiKey`), the input is authoritative and a blank means clear + * (sent as '' — the wire tri-state). The panel has no default-model field: + * the provider's CONFIGURED default (`existingDefaultModel`, the raw config + * value in `/` alias form — never the catalog item's + * materialized `default_model`, which falls back to the GLOBAL default and + * would be wrongly persisted as provider-level) is preserved while it still + * points at a listed model, otherwise it is dropped and the server clears it. + * The wire wants the BARE model name (the server rebuilds the alias itself), + * so the alias prefix — everything up to the first "/" — is stripped; a bare + * model id may itself contain "/" (gateway catalogs like openrouter). + */ +export function buildUpdateProviderInput( + state: ProviderFormState, + existing?: AppProvider, + opts?: { includeBlankApiKey?: boolean; existingDefaultModel?: string }, +): UpdateProviderInput { + const models = buildModels(state.models); + const newId = state.id.trim(); + const apiKey = state.apiKey.trim(); + const baseUrl = state.baseUrl.trim(); + const configuredDefault = opts?.existingDefaultModel?.trim() ?? ''; + const bareDefault = + configuredDefault.indexOf('/') >= 0 + ? configuredDefault.slice(configuredDefault.indexOf('/') + 1) + : configuredDefault; + return { + ...(existing !== undefined && newId !== '' && newId !== existing.id ? { newId } : {}), + type: state.type, + models, + ...(apiKey === '' && opts?.includeBlankApiKey !== true ? {} : { apiKey }), + ...(baseUrl === '' ? {} : { baseUrl }), + ...(bareDefault !== '' && models.some((m) => m.model === bareDefault) + ? { defaultModel: bareDefault } + : {}), + }; +} + +/** The managed OAuth login materializes as this fixed provider id + type + * (core KIMI_CODE_PROVIDER_NAME); the catalog wire carries no oauth flag, so + * the stable id is the reliable tell. The server rejects deleting it too. */ +export function isManagedOAuthProvider(provider: { id: string; type: string }): boolean { + return provider.id === 'managed:kimi-code' && provider.type === 'kimi'; +} diff --git a/apps/web/src/style.css b/apps/web/src/style.css index 45825b5ec..f314e039f 100644 --- a/apps/web/src/style.css +++ b/apps/web/src/style.css @@ -289,7 +289,7 @@ body { /* --------------------------------------------------------------------------- Mobile dialogs → bottom sheets. On narrow viewports the centered modal overlays used by ModelPicker, - StatusPanel, AddWorkspaceDialog, ProviderManager and + StatusPanel, AddWorkspaceDialog and LoginDialog become bottom-anchored full-width sheets (rounded top, slides up). The selectors are compound (`.backdrop .dialog`, specificity 0,2,0) so they win over each component's scoped `.dialog` rule regardless of injection order. diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 4872e679c..d6f38dae6 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -7,9 +7,24 @@ const webPort = Number(process.env.WEB_PORT) || 5175; // Where the dev proxy forwards server traffic. Defaults to the local server // (or `pnpm dev:stub`). Override to point dev at another server instance. const serverTarget = process.env.KIMI_SERVER_URL || 'http://127.0.0.1:58627'; -const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8')) as { - version: string; -}; + +// The web bundle ships inside the Kimi Code CLI (apps/web/dist is synced to +// apps/kimi-code/dist-web in the kimi-code submodule), so the version the UI +// shows — and reports to the daemon as clientVersion — is the CLI's version, +// not the workspace package's own (apps/web/package.json is a dev-only +// artifact). Sourced from the submodule checkout. +function readCliVersion(): string { + try { + const pkg = JSON.parse( + readFileSync(new URL('../../kimi-code/apps/kimi-code/package.json', import.meta.url), 'utf-8'), + ) as { version: string }; + return pkg.version; + } catch { + throw new Error( + '读取 CLI 版本失败:未找到 kimi-code/apps/kimi-code/package.json——先运行 `pnpm run sync` 初始化 submodule。', + ); + } +} // Shared renderer config (Vue + unplugin-icons `kimi` collection, ES2022 target, // module workers, `__KIMI_*` defines) lives in `@moonshot-ai/vite-preset` so the @@ -23,7 +38,7 @@ const preset = kimiRendererViteConfig({ // own same-origin URL). Unused by the same-origin production build. defines: { __KIMI_DEV_PROXY_TARGET__: JSON.stringify(serverTarget), - __KIMI_CLIENT_VERSION__: JSON.stringify(pkg.version), + __KIMI_CLIENT_VERSION__: JSON.stringify(readCliVersion()), // True only for the web bundle embedded in the Kimi Desktop app (set by the // desktop-build workflow). Gates an "internal testing build" banner. When // false (default) the banner is tree-shaken out of the production bundle. diff --git a/scripts/build-brand-icons.mjs b/scripts/build-brand-icons.mjs index bf3c54282..08e8e2d0f 100644 --- a/scripts/build-brand-icons.mjs +++ b/scripts/build-brand-icons.mjs @@ -17,10 +17,13 @@ // apps/desktop/build/tray.png, tray@2x.png Linux tray (white silhouette) // apps/desktop/build/trayTemplate.png, trayTemplate@2x.png macOS menu-bar template // apps/desktop/build/tray.ico 4 frames (16–48), Windows tray (white tile) -// apps/web/public/favicon.ico 5 frames (16–64) // Inline brand marks between `brand-mark:start/end` comments in: -// apps/desktop/src/renderer/components/onboarding/BrandLogo.vue (+ apps/web copy) -// apps/desktop/src/renderer/components/Sidebar.vue (+ apps/web copy) +// apps/desktop/src/renderer/components/onboarding/BrandLogo.vue +// apps/desktop/src/renderer/components/Sidebar.vue +// +// NOTE: apps/web is intentionally NOT an output. The web UI forked its brand +// back to the legacy "little blue" mark (sidebar, onboarding, favicon) — do +// not re-add web targets here, or the next run clobbers that fork. // // Geometry decisions (do not change casually — they keep the icon consistent // with macOS conventions and the pre-refresh assets): @@ -33,7 +36,6 @@ // - Tile corners are a baked macOS squircle (superellipse n=4.5, ~1.5px AA), // so pre-Tahoe macOS (no system masking) still shows a rounded icon; Tahoe // re-clips the already-transparent corners to the same shape. -// - favicon keeps the tile full-bleed (a 16px tab icon can't afford margins). // - App icons rasterize from the kit's SVG vectors (White/Black Background) // for full sharpness; the kit PNGs are only previews and tray sources. // - Tray silhouette: mark fit into a 16px box centered on a 22px canvas @@ -216,11 +218,6 @@ async function buildAppIcons() { const res = spawnSync('iconutil', ['-c', 'icns', iconset, '-o', icnsOut], { stdio: 'inherit' }); if (res.status !== 0) throw new Error('iconutil failed'); written.push(path.relative(ROOT, icnsOut)); - - // favicon: full-bleed squircle (no margin — tab icons are tiny). - const favSizes = [16, 24, 32, 48, 64]; - const favFrames = await Promise.all(favSizes.map(resizeFull)); - save(path.join(ROOT, 'apps', 'web', 'public', 'favicon.ico'), icoBuffer(favFrames, favSizes)); } // --------------------------------------------------------------------------- @@ -409,10 +406,11 @@ function buildComponentMarks() { const originalKit = parseKitSvg(path.join(KIT, 'Original Logo.svg')); const brandLogo = genBrandLogo(whiteKit); const sidebarLogo = genSidebarLogo(originalKit); - for (const app of ['apps/desktop/src/renderer', 'apps/web/src']) { - spliceBrandMark(path.join(ROOT, app, 'components/onboarding/BrandLogo.vue'), brandLogo, 'White Background.svg'); - spliceBrandMark(path.join(ROOT, app, 'components/Sidebar.vue'), sidebarLogo, 'Original Logo.svg'); - } + // Desktop only — apps/web forked its brand marks back to the legacy + // "little blue"; do not re-add it here (see the header note). + const app = path.join(ROOT, 'apps', 'desktop', 'src', 'renderer'); + spliceBrandMark(path.join(app, 'components/onboarding/BrandLogo.vue'), brandLogo, 'White Background.svg'); + spliceBrandMark(path.join(app, 'components/Sidebar.vue'), sidebarLogo, 'Original Logo.svg'); } // ---------------------------------------------------------------------------