feat(web): settings-native provider management, blue-mark brand fork (#173)

- 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
This commit is contained in:
liruifengv 2026-08-04 16:44:25 +08:00 committed by GitHub
parent bc02d894ec
commit 1b97e95243
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 2243 additions and 1091 deletions

View file

@ -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``<kimi-code checkout>/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 UIVite代理到 127.0.0.1:58627

View file

@ -7,6 +7,8 @@
- `src/renderer/``apps/web/src` 的整份快照副本改任何共享组件前先想清楚边界web 版只能走 daemon RESTdesktop 走原生,用 `lib/desktopFlag.ts``isDesktop` / `isMacosDesktop``window.kimiDesktop` 探测分流。
- 改共享文件后需同步回 `apps/web`(开发顺序:先在 desktop 开发,再同步 web见根 AGENTS.md
- desktop 侧已做品牌字符串清理2026-07web 侧未同步,属分叉):页面/窗口标题为 `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` 已改为只写 desktopfavicon 与组件 `brand-mark` 块均不再输出到 apps/webweb 这三处不是产物、可手改。两端同步(含整目录 re-copyweb 侧保留小蓝 SVG 块与简化版 BrandLogo无 tile、无 `variant` propdesktop 侧保留带 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-op8 个 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 双别名,用户自定义后精确匹配。**steerCtrl/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 先行、同步 webweb 侧无 track/loginSource 块desktop 侧保留)。
- **设置页「程序坞图标」行desktop macOS only**
- 实现:通用页外观组**末尾**新增 `components/settings/DockIconPicker.vue`desktop 专属组件,**不复用 web-ui 的 SegmentedControl**,不同步 web控件模式已登记进 `DesignSystemView.vue` §03 的 SegmentedControl 之后,带活体 demoweb 的 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 推送时镜像写入 `<userData>/ui-state.json``ui-state.ts``getDockIconChoice`/`saveDockIconChoice`,同 vibrancy 先例——renderer localStorage 是本地存储正本但要等窗口 boot 才经 IPC 到达,主进程这份只是启动种子,推送永远覆盖它),`initDockIcon` 启动先读种子再 apply读不到回落 `'light'`,与 .icns 一致),之后仅在 IPC 推送时重设 Dock 图标preload 与 IPC 均校验取值。行可见条件 `isMacosDesktop && canSetDockIconChoice()`(桥探测)。
- **web 不改**:浏览器没有 Dockweb 的 `SettingsDialog.vue` 无此行、无此 import`DockIconPicker.vue``lib/dockIconChoice.ts` 均不存在于 apps/web整目录 re-copy 时需保留 desktop 侧分叉块。

View file

@ -1,240 +0,0 @@
<!-- apps/web/src/components/chat/ToolRow.vue -->
<script setup lang="ts">
import { inject, nextTick, ref } from 'vue';
import { Icon, StatusDot, Tooltip } from '@moonshot-ai/web-ui';
withDefaults(
defineProps<{
status: 'running' | 'ok' | 'error' | 'suspended';
/** Inline-SVG glyph string (toolGlyph), or empty for none. */
icon?: string;
name: string;
arg?: string;
time?: string;
open?: boolean;
expandable?: boolean;
monospace?: boolean;
stacked?: boolean;
stackPosition?: 'single' | 'first' | 'middle' | 'last';
}>(),
{
icon: '',
arg: '',
time: '',
open: false,
expandable: false,
monospace: false,
stacked: false,
stackPosition: 'single',
},
);
const emit = defineEmits<{ toggle: [] }>();
const pinScroll = inject<(el: HTMLElement, ms?: number) => void>('pinScroll', () => {});
const bhEl = ref<HTMLElement | null>(null);
function onHeadClick(): void {
emit('toggle');
const el = bhEl.value;
if (el) nextTick(() => pinScroll(el));
}
</script>
<template>
<div
class="box"
:class="{
open,
stacked,
err: status === 'error',
'stack-first': stackPosition === 'first',
'stack-middle': stackPosition === 'middle',
'stack-last': stackPosition === 'last',
}"
>
<div class="bh" :class="{ monospace }" ref="bhEl" @click="onHeadClick">
<span v-if="icon" class="gl" v-html="icon" aria-hidden="true" />
<span class="bh-text">
<span class="a">{{ name }}</span>
<Tooltip :text="arg">
<span v-if="arg" class="p">{{ arg }}</span>
</Tooltip>
</span>
<span class="rt">
<span class="status" :class="status" role="status" :aria-label="status">
<Icon v-if="status === 'ok'" name="check" size="sm" />
<Icon v-else-if="status === 'error'" name="close" size="sm" />
<StatusDot v-else-if="status === 'suspended'" status="suspended" />
<StatusDot v-else status="running" />
</span>
<slot name="trailing" />
<span v-if="time" class="tm">{{ time }}</span>
</span>
<Icon v-if="expandable" class="car" :name="open ? 'chevron-down' : 'chevron-right'" size="sm" />
</div>
<div class="bb" :class="{ open }" :inert="!open">
<div class="bb-pad">
<slot />
</div>
</div>
</div>
</template>
<style scoped>
.box {
margin: 0;
background: var(--color-surface);
border: 0.5px solid var(--color-line-strong);
border-radius: var(--radius-md);
overflow: hidden;
transition: border-color var(--duration-base) var(--ease-out);
}
.box.err {
border-color: color-mix(in srgb, var(--color-danger) 45%, var(--bg));
}
/* Stacked calls: the group owns the outer border + radius, so each row is flat
and separated only by a top hairline. */
.box.stacked {
border: none;
border-radius: 0;
}
.box.stacked .bh {
border-radius: 0;
}
.box.stack-middle,
.box.stack-last {
border-top: 0.5px solid var(--color-line-strong);
}
.box.stack-middle.err,
.box.stack-last.err {
border-top-color: color-mix(in srgb, var(--color-danger) 45%, var(--bg));
}
.bh {
display: flex;
align-items: center;
gap: 8px;
min-height: 30px;
padding: 0 11px;
cursor: pointer;
font: var(--text-base) var(--font-ui);
color: var(--color-text);
}
.bh.monospace {
font-family: var(--font-mono);
font-size: var(--text-xs);
font-feature-settings: "liga" 0, "calt" 0;
font-variant-ligatures: none;
}
.box.open .bh,
.bh:hover {
background: var(--color-surface-sunken);
}
.box.err .bh {
background: color-mix(in srgb, var(--color-danger) 4%, var(--bg));
}
.box.err .bh:hover {
background: color-mix(in srgb, var(--color-danger) 7%, var(--bg));
}
.gl {
display: inline-flex;
align-items: center;
color: var(--color-text-faint);
flex: none;
}
.bh-text {
display: flex;
align-items: center;
gap: inherit;
flex: 1;
min-width: 0;
}
.a {
color: var(--color-text);
font-family: var(--font-ui);
font-size: var(--ui-font-size);
font-weight: var(--weight-medium);
flex: none;
}
.p {
color: var(--color-text-muted);
font-size: inherit;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.rt {
margin-left: auto;
color: var(--color-text-muted);
font-size: var(--text-xs);
display: flex;
align-items: center;
gap: 6px;
flex: none;
}
.tm {
color: var(--color-text-faint);
}
:slotted(.chip) {
color: var(--color-text-muted);
font-family: var(--font-ui);
font-size: var(--text-xs);
flex: none;
}
/* Status indicator at the right edge of the row: done = green , error = red ,
running = pulsing accent dot. */
.status {
display: inline-flex;
align-items: center;
flex: none;
}
.status.ok {
color: var(--color-success);
}
.status.error {
color: var(--color-danger);
}
/* Expanded detail: sunken panel under the row. Opens downward / collapses upward
via a `grid-template-rows` transition (0fr 1fr), which animates smoothly in
every modern browser unlike `height: auto`, which only interpolates in
Chromium (via `interpolate-size`) and snaps everywhere else. The inner
`.bb-pad` needs `min-height: 0` + `overflow: hidden` so the 0fr track can
collapse fully. */
.bb {
display: grid;
grid-template-rows: minmax(0, 0fr);
overflow: hidden;
transition: grid-template-rows var(--duration-base) var(--ease-out);
}
.bb.open {
grid-template-rows: minmax(0, 1fr);
}
.bb.open .bb-pad {
max-height: 16.5em; /* 10 lines at the 1.65 detail line-height. */
overflow-y: auto;
overscroll-behavior: contain;
}
.bb-pad {
min-height: 0;
overflow: hidden;
padding: var(--space-2) var(--space-3) var(--space-3);
background: var(--color-surface-sunken);
border-top: 0.5px solid var(--color-line);
color: var(--color-text);
font: var(--text-sm)/1.65 var(--font-mono);
white-space: pre-wrap;
word-break: break-word;
}
/* Mobile bubble layout: no left gutter indent, softer corners. */
.box.mob {
margin: 0;
}
</style>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Before After
Before After

View file

@ -12,7 +12,6 @@ import SideChatPanel from './components/chat/SideChatPanel.vue';
import DiffView from './components/chat/DiffView.vue';
import TurnDiffPanel from './components/chat/TurnDiffPanel.vue';
import ModelPicker from './components/settings/ModelPicker.vue';
import ProviderManager from './components/settings/ProviderManager.vue';
import LoginDialog from './components/dialogs/LoginDialog.vue';
import SettingsDialog from './components/settings/SettingsDialog.vue';
import AddWorkspaceDialog from './components/dialogs/AddWorkspaceDialog.vue';
@ -145,6 +144,14 @@ function completeOnboarding(): void {
showOnboarding.value = false;
}
// The wizard's custom-provider card: first run completes via this detour
// (same as skipping the login step), landing directly on the Providers tab.
function handleWizardAddProvider(): void {
completeOnboarding();
settingsInitialTab.value = 'providers';
showSettings.value = true;
}
// iOS Safari does not shrink `dvh` for the on-screen keyboard. Instead it pans
// the visual viewport (offsetTop > 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<InstanceType<typeof ConversationPane> | 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<boolean>(
() =>
openDialogCount.value > 0 ||
showModelPicker.value ||
showProviders.value ||
showLogin.value ||
showAddWorkspace.value ||
showStatusPanel.value ||
@ -386,8 +391,6 @@ const anyOverlayOpen = computed<boolean>(
// 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<void> {
@ -406,19 +409,6 @@ async function openModelPicker(): Promise<void> {
}
}
async function openProviders(): Promise<void> {
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<void> {
}
}
async function handleAddProvider(input: { type: string; apiKey?: string; baseUrl?: string; defaultModel?: string }): Promise<void> {
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<void> {
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<void> {
// 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<void> {
});
}
async function confirmDeleteProvider(id: string): Promise<void> {
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<AppConfig>): Promise<void> {
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"
/>
<!-- Provider Manager overlay -->
<ProviderManager
v-if="showProviders"
:providers="client.providers.value"
:loading="providersLoading"
:unavailable="providersUnavailable"
@add="handleAddProvider($event)"
@refresh="handleRefreshProvider($event)"
@delete="confirmDeleteProvider($event)"
@open-login="() => { showProviders = false; openLogin(); }"
@close="showProviders = false"
/>
<!-- Status panel overlay (/status) renders current client state, no daemon call -->
<StatusPanel
v-if="showStatusPanel"
@ -1373,6 +1323,7 @@ function openPr(url: string): void {
:on-cancel-o-auth-login="handleCancelOAuthLogin"
@complete="completeOnboarding"
@login-success="handleWizardLoginSuccess"
@add-provider="handleWizardAddProvider"
/>
<!-- Login Dialog overlay. It is outside `.app` so `/login` can open it too. -->
<LoginDialog

View file

@ -1,65 +0,0 @@
// apps/kimi-web/src/api/devBackend.ts
// Dev-only backend switcher client. Talks to the Vite dev-server endpoints
// mounted by `backendSwitcherPlugin` in vite.config.ts:
// GET /__kimi-dev/backend → { current, presets }
// POST /__kimi-dev/backend { name } → repoint the /api/v1 proxy
// The endpoints only exist on the Vite dev server (not preview, not the
// production same-origin server) — every helper here degrades to a no-op
// outside that environment so callers can stay unconditional in dev-only UI.
export type BackendName = 'default' | 'multi';
export interface DevBackendState {
/** Current upstream target of the dev proxy, e.g. `http://127.0.0.1:58627`. */
current: string;
/** Named presets offered by the switcher menu. */
presets: Record<BackendName, string>;
}
/** 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<DevBackendState | null> {
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<DevBackendState | null> {
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(/\/$/, '');
}

View file

@ -5,17 +5,7 @@
<script setup lang="ts">
import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, onUpdated, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { serverEndpointLabel } from '../api/config';
import {
fetchDevBackendState,
initialDevBackendState,
shortOrigin,
switchDevBackend,
type BackendName,
type DevBackendState,
} from '../api/devBackend';
import { copyTextToClipboard } from '../lib/clipboard';
import { logWarn } from '../lib/log';
import {
loadCollapsedWorkspaces,
saveCollapsedWorkspaces,
@ -34,40 +24,10 @@ import WorkspaceGroup from './WorkspaceGroup.vue';
import PinnedSessionList from './PinnedSessionList.vue';
import { isMacosDesktop } from '../lib/desktopFlag';
import { SESSIONS_EXPAND_BATCH } from '../composables/client/useWorkspaceState';
import { Icon, IconButton, Kbd, Menu, MenuItem, Pill } from '@moonshot-ai/web-ui';
import { Icon, IconButton, Kbd, Menu, MenuItem } from '@moonshot-ai/web-ui';
const { t } = useI18n();
// Dev-only affordance: when the page is served by the Vite dev server, a
// backend pill next to the brand shows the engine generation reported by
// /meta (v1 = older server binary, v2 = kap-server) plus the endpoint the dev
// proxy forwards to click it to switch presets without restarting Vite. In
// production this is all inert.
const isDev = import.meta.env.DEV;
const devBackend = ref<DevBackendState | null>(isDev ? initialDevBackendState() : null);
if (isDev) {
onMounted(async () => {
const live = await fetchDevBackendState();
if (live) devBackend.value = live;
});
}
// host:port of the server the dev proxy currently forwards to (fallback: the
// build-time label when the dev endpoints are unavailable).
const endpoint = computed(() => {
if (!isDev) return '';
const current = devBackend.value?.current;
return current ? shortOrigin(current) : serverEndpointLabel();
});
const backendNames: BackendName[] = ['default', 'multi'];
function presetUrl(name: BackendName): string {
const url = devBackend.value?.presets[name] ?? '';
return url ? shortOrigin(url) : '';
}
function isCurrentBackend(name: BackendName): boolean {
const state = devBackend.value;
return state !== null && state.current === state.presets[name];
}
const props = withDefaults(
defineProps<{
activeWorkspace: WorkspaceView | null;
@ -78,8 +38,6 @@ const props = withDefaults(
* pinned section above all workspace groups; empty hides the section. */
pinnedSessions?: Session[];
activeId: string;
/** Backend engine generation from /meta — dev-only badge next to the brand. */
backend?: 'v1' | 'v2';
attentionBySession?: Record<string, number>;
/** Per-session pending counts split by kind, for the coloured tags. */
pendingBySession?: Record<string, { approvals: number; questions: number }>;
@ -97,7 +55,6 @@ const props = withDefaults(
activeWorkspace: null,
activeWorkspaceId: null,
pinnedSessions: () => [],
backend: 'v1',
attentionBySession: () => ({}),
pendingBySession: () => ({}),
unreadBySession: () => ({}),
@ -140,7 +97,10 @@ const emit = defineEmits<{
// ---------------------------------------------------------------------------
const showSearch = ref(false);
const sessionSearchKeys = isAppleShortcutPlatform() ? ['⌘', 'K'] : ['Ctrl', 'K'];
const newChatKeys = isAppleShortcutPlatform() ? ['⌘', 'N'] : ['Ctrl', 'N'];
// New chat is Ctrl+Shift+O on every platform (real Control key, on macOS):
// browsers reserve N / Ctrl+N for "new window" and never deliver that
// keydown to the page, so the old chord could never fire outside Electron.
const newChatKeys = isAppleShortcutPlatform() ? ['⌃', '⇧', 'O'] : ['Ctrl', 'Shift', 'O'];
function openSearch(): void {
// Sessions are loaded per-workspace (first page only); lazily drain the rest
@ -155,7 +115,9 @@ function onSearchKeydown(e: KeyboardEvent): void {
if (e.key.toLowerCase() === 'k') {
e.preventDefault();
openSearch();
} else if (e.key.toLowerCase() === 'n') {
} else if (!e.metaKey && e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'o') {
// See newChatKeys: N/Ctrl+N is reserved by the browser chrome, so new
// chat listens for the Ctrl+Shift+O chord instead.
e.preventDefault();
emit('create');
}
@ -602,76 +564,10 @@ function deleteWs(ws: WorkspaceView): void {
emit('deleteWorkspace', ws.id);
}
// ---------------------------------------------------------------------------
// Dev backend switcher menu (the pill next to the brand). Dev-only: repoints
// the Vite dev proxy at the other engine, then reloads so every client state
// (REST, WS, /meta) re-initializes against the new backend.
// ---------------------------------------------------------------------------
const backendMenuOpen = ref(false);
const backendMenuStyle = ref<Record<string, string>>({});
const backendMenuRef = ref<InstanceType<typeof Menu> | null>(null);
function onBackendMenuDocClick(e: MouseEvent): void {
const target = e.target as Element;
if (target.closest('.ch-backend') || target.closest('.backend-menu')) return;
closeBackendMenu();
}
async function toggleBackendMenu(e: MouseEvent): Promise<void> {
if (devBackend.value === null) return;
if (backendMenuOpen.value) {
closeBackendMenu();
return;
}
const btn = e.currentTarget as HTMLElement;
backendMenuOpen.value = true;
document.addEventListener('mousedown', onBackendMenuDocClick);
window.addEventListener('resize', closeBackendMenu);
await nextTick();
const menu = backendMenuRef.value?.el;
const r = btn.getBoundingClientRect();
const gap = 4;
const margin = 8;
const menuH = menu?.offsetHeight ?? 0;
let top = r.bottom + gap;
if (top + menuH > window.innerHeight - margin) {
top = Math.max(margin, r.top - menuH - gap);
}
backendMenuStyle.value = {
top: `${Math.round(top)}px`,
left: `${Math.round(Math.max(margin, r.left))}px`,
};
}
function closeBackendMenu(): void {
backendMenuOpen.value = false;
document.removeEventListener('mousedown', onBackendMenuDocClick);
window.removeEventListener('resize', closeBackendMenu);
}
async function chooseBackend(name: BackendName): Promise<void> {
if (isCurrentBackend(name)) {
closeBackendMenu();
return;
}
const next = await switchDevBackend(name);
if (next === null) {
logWarn('[kimi-web] dev backend switch failed:', name);
closeBackendMenu();
return;
}
// Full reload: every client channel (REST base state, WS, /meta) must
// re-initialize against the new backend a soft swap would leave stale
// session streams subscribed through the old target.
window.location.reload();
}
onBeforeUnmount(() => {
document.removeEventListener('mousedown', onGhMenuDocClick, true);
document.removeEventListener('mousedown', onWsMenuDocClick);
document.removeEventListener('mousedown', onBackendMenuDocClick);
window.removeEventListener('resize', closeWsMenu);
window.removeEventListener('resize', closeBackendMenu);
});
// Logo easter-egg: clicking the Kimi mark plays one quick blink. It's a one-shot
@ -756,44 +652,26 @@ onBeforeUnmount(() => {
<div class="ch">
<div class="ch-brand">
<template v-if="!isMacosDesktop">
<!-- Brand mark: the robot mascot (transparent background, no tile).
The .ch-eyes/.ch-eye classes hook the shared idle look/blink
keyframes in style.css; the viewBox stays ~32 wide so those
px-based transforms keep their old proportions. -->
<!-- brand-mark:start generated by scripts/build-brand-icons.mjs from
KIMI CODE LOGO/Original Logo.svg; do not edit by hand -->
<svg ref="logoRef" class="ch-logo" viewBox="0 0 32 28.2791" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Kimi Code" @click="onLogoClick" @pointerdown="onLogoPointerDown" @pointerup="onLogoPointerUp" @pointercancel="onLogoPointerUp">
<!-- Brand mark: the legacy "little blue" (rounded rect with eye
cutouts). WEB FORK desktop ships the robot mascot generated by
scripts/build-brand-icons.mjs; the generator no longer touches
this file, so keep this divergence on purpose. The
.ch-eyes/.ch-eye classes hook the shared idle look/blink
keyframes in style.css (the eyes are mask cutouts, so animating
them moves the transparent holes). -->
<svg ref="logoRef" class="ch-logo" viewBox="0 0 32 22" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Kimi Code" @click="onLogoClick" @pointerdown="onLogoPointerDown" @pointerup="onLogoPointerUp" @pointercancel="onLogoPointerUp">
<defs>
<radialGradient id="chLogoFace" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(14.5195 12.5993) scale(14.1117)">
<stop stop-color="#117DFB" />
<stop offset="0.759254" stop-color="#449BFF" />
<stop offset="1" stop-color="#77B6FF" />
</radialGradient>
<mask id="kimiEyes" maskUnits="userSpaceOnUse">
<rect x="0" y="0" width="32" height="22" fill="#fff" />
<g class="ch-eyes" fill="#000">
<rect class="ch-eye" x="11.8" y="7" width="2.8" height="8" rx="1.4" />
<rect class="ch-eye" x="17.4" y="7" width="2.8" height="8" rx="1.4" />
</g>
</mask>
</defs>
<path d="M 14.5195 0.3556 C 22.2186 0.3556 28.4601 6.5969 28.4601 14.2961 C 28.4601 21.9952 22.2186 28.2366 14.5195 28.2366 C 6.8204 28.2366 0.579 21.9952 0.579 14.2961 C 0.579 6.5969 6.8204 0.3556 14.5195 0.3556 Z" fill="#2389FF" />
<path d="M 14.5195 0.3556 C 22.2186 0.3556 28.4601 6.5969 28.4601 14.2961 C 28.4601 21.9952 22.2186 28.2366 14.5195 28.2366 C 6.8204 28.2366 0.579 21.9952 0.579 14.2961 C 0.579 6.5969 6.8204 0.3556 14.5195 0.3556 Z" fill="url(#chLogoFace)" />
<g class="ch-eyes" fill="#fff">
<path class="ch-eye" d="M 12.8637 8.8339 C 12.7301 7.8611 13.4042 6.965 14.3693 6.8324 C 15.3344 6.6999 16.2251 7.3811 16.3587 8.3539 L 16.7656 11.3165 C 16.8992 12.2893 16.2251 13.1854 15.26 13.318 C 14.2949 13.4505 13.4042 12.7693 13.2706 11.7965 L 12.8637 8.8339 Z" />
<path class="ch-eye" d="M 20.1974 7.8996 C 20.0705 6.9754 20.6717 6.1295 21.5403 6.0102 C 22.4089 5.8909 23.216 6.5434 23.3429 7.4676 L 23.7295 10.282 C 23.8564 11.2062 23.2551 12.0521 22.3865 12.1714 C 21.5179 12.2907 20.7109 11.6382 20.5839 10.714 L 20.1974 7.8996 Z" />
</g>
<rect x="0" y="17.8221" width="31.9124" height="10.4405" rx="1.158" fill="#002E58" />
<path d="M 2.6265 21.0396 L 5.5577 22.7318 C 5.7989 22.8711 5.7989 23.2193 5.5577 23.3586 L 2.6265 25.0509" stroke="#fff" stroke-width="1.3133" stroke-linecap="round" />
<path d="M 8.7332 25.439 H 11.6282" stroke="#007CFF" stroke-width="1.3133" stroke-linecap="round" />
<path d="M 28.9734 0 C 30.2364 0 31.2604 1.0239 31.2604 2.287 C 31.2604 3.55 30.2364 4.574 28.9734 4.574 L 26.9555 4.574 C 26.8068 4.574 26.6864 4.4535 26.6864 4.3049 L 26.6864 2.287 C 26.6864 1.0239 27.7103 0 28.9734 0 Z" fill="#1783FF" />
<rect x="1" y="1" width="30" height="20" rx="6" fill="var(--logo)" mask="url(#kimiEyes)" />
</svg>
<!-- brand-mark:end -->
<span class="ch-name">Kimi Code</span>
<Pill
v-if="isDev"
class="ch-backend"
:clickable="devBackend !== null"
:title="t('sidebar.backendTitle', { backend, endpoint })"
@click="toggleBackendMenu"
>
<span class="ch-backend-kind" :class="`is-${backend}`">{{ backend }}</span>
<span class="ch-backend-ep"> · {{ endpoint }}</span>
<Icon v-if="devBackend !== null" name="chevron-down" size="sm" />
</Pill>
</template>
</div>
<div class="ch-tail">
@ -998,22 +876,6 @@ onBeforeUnmount(() => {
</MenuItem>
</Menu>
</Transition>
<!-- Dev backend switcher menu (position:fixed, anchored to the brand pill) -->
<Menu
v-if="backendMenuOpen"
ref="backendMenuRef"
class="backend-menu"
:style="backendMenuStyle"
@click.stop
>
<MenuItem v-for="name in backendNames" :key="name" @click="chooseBackend(name)">
<span class="section-menu-check">
<Icon v-if="isCurrentBackend(name)" name="check" size="sm" />
</span>
<span class="backend-menu-name">{{ name }}</span>
<span class="backend-menu-url">{{ presetUrl(name) }}</span>
</MenuItem>
</Menu>
<!-- Session search dialog (Cmd/Ctrl+K) -->
<SearchSessionsDialog
v-if="showSearch"
@ -1122,9 +984,7 @@ onBeforeUnmount(() => {
}
.ch-logo {
height: 22px;
/* The mascot is near-square (32×28.914 viewBox); let the width follow the
fixed height instead of pinning the old wide-mark 32px. */
width: auto;
width: 32px;
flex: none;
display: block;
cursor: pointer;
@ -1165,35 +1025,8 @@ onBeforeUnmount(() => {
text-overflow: ellipsis;
white-space: nowrap;
}
/* Dev-only backend pill next to the brand: shows the engine generation from
/meta (v1 / v2) and opens the dev-proxy preset switcher menu. v2 is
accent-colored so it reads differently at a glance. */
.ch-backend {
flex: none;
min-width: 0;
font-family: var(--font-ui);
}
.ch-backend-kind {
font-family: inherit;
font-weight: 500;
color: var(--color-text-muted);
}
.ch-backend-kind.is-v2 {
color: var(--color-accent);
}
.ch-backend-ep {
font-family: inherit;
color: var(--color-text-faint);
overflow: hidden;
text-overflow: ellipsis;
}
/* Responsive brand row: below 320px the pill's endpoint drops out (the v1/v2
kind + chevron stay the full target is one tooltip away); below 250px the
product name also drops out so the logo and action buttons keep their room. */
@container sidebar-col (max-width: 320px) {
.ch-backend-ep { display: none; }
}
/* Responsive brand row: below 250px the product name drops out so the logo
and action buttons keep their room. */
@container sidebar-col (max-width: 250px) {
.ch-name { display: none; }
}
@ -1476,8 +1309,7 @@ onBeforeUnmount(() => {
/* Workspace menus — surface + items come from Menu / MenuItem; only the
fixed positioning stays here (anchored to the trigger / cursor). */
.ws-menu,
.gh-menu,
.backend-menu {
.gh-menu {
position: fixed;
top: 0;
left: 0;
@ -1517,15 +1349,4 @@ onBeforeUnmount(() => {
width: 14px;
}
/* Backend switcher menu rows: mono engine name + muted preset URL. */
.backend-menu-name {
font-family: var(--mono);
font-weight: 500;
}
.backend-menu-url {
margin-left: 8px;
font-family: var(--mono);
color: var(--color-text-muted);
}
</style>

View file

@ -33,6 +33,16 @@ function isError(warning: AppWarning): boolean {
return warning.startsWith(`${t('warnings.errorLabel')}:`) || /\b4\d\d\b|error|失败|failed/i.test(warning);
}
function toastVariant(warning: AppWarning): 'info' | 'success' | 'warning' | 'danger' {
if (isNotice(warning)) {
if (warning.severity === 'error') return 'danger';
if (warning.severity === 'success') return 'success';
if (warning.severity === 'info') return 'info';
return 'warning';
}
return isError(warning) ? 'danger' : 'warning';
}
function warningKey(warning: AppWarning): string {
if (!isNotice(warning)) return `text:${warning}`;
return `notice:${warning.severity}:${warning.title}:${warning.message ?? ''}:${JSON.stringify(warning.details ?? [])}`;
@ -193,7 +203,7 @@ onUnmounted(() => {
<Toast
v-for="toast in toasts"
:key="toast.id"
:variant="isError(toast.warning) ? 'danger' : 'warning'"
:variant="toastVariant(toast.warning)"
:title="toastTitle(toast.warning)"
:message="toastMessage(toast.warning)"
:dismiss-label="t('warnings.dismiss')"

View file

@ -1,23 +1,21 @@
<!-- apps/web/src/components/onboarding/BrandLogo.vue -->
<!-- Kimi Code app icon for the onboarding wizard, inlined so the mascot's
eyes hook the shared idle look/blink keyframes in style.css (the same
.ch-eyes/.ch-eye classes the sidebar logo uses); a click plays one quick
blink via .blink-now the sidebar mark's easter egg, minus its
long-press. The black/white tile swaps by data-color-scheme (two <rect>s,
no CSS colors); the face gradient id is per-instance so coexisting logos
(wizard + login card) can't collide. Geometry tracks the KIMI CODE LOGO
kit's White/Black Background variants; tiles are rounded (rx=27) to match
the shipped app icon, whose squircle is baked into the artwork so older
macOS versions (pre-Tahoe, no system masking) still show a rounded icon. -->
<!-- Legacy "little blue" brand mark (rounded rect with eye cutouts) for the
onboarding wizard. WEB FORK desktop ships the robot mascot generated by
scripts/build-brand-icons.mjs (its own BrandLogo.vue copy keeps the tile
and the variant prop); the generator no longer touches this file, so keep
this divergence on purpose. The eyes are mask cutouts sharing the
.ch-eyes/.ch-eye idle look/blink keyframes in style.css (same mechanism as
the sidebar logo); a click plays one quick blink via .blink-now. The mask
id is per-instance so coexisting logos (wizard + login card) can't
collide. -->
<script setup lang="ts">
import { onBeforeUnmount, ref, useId } from 'vue';
withDefaults(defineProps<{ size?: number; variant?: 'system' | 'light' | 'dark' | 'theme' }>(), {
withDefaults(defineProps<{ size?: number }>(), {
size: 64,
variant: 'system',
});
const faceId = `bl-face-${useId()}`;
const maskId = `bl-eyes-${useId()}`;
// Click-to-blink easter egg (same mechanism as the sidebar logo): force a
// reflow so rapid clicks restart the one-shot, then drop the class so the
@ -37,40 +35,28 @@ onBeforeUnmount(() => clearTimeout(blinkTimer));
</script>
<template>
<!-- brand-mark:start generated by scripts/build-brand-icons.mjs from
KIMI CODE LOGO/White Background.svg; do not edit by hand -->
<svg
ref="logoRef"
:class="['brand-logo', `bl-variant-${variant}`]"
:style="{ width: `${size}px`, height: `${size}px` }"
viewBox="0 0 120 120"
class="brand-logo"
:style="{ width: `${size}px`, height: `${(size * 22) / 32}px` }"
viewBox="0 0 32 22"
fill="none"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="Kimi Code"
@click="blinkOnce"
>
<rect class="bl-tile bl-tile-light" width="120" height="120" rx="27" fill="black" />
<rect class="bl-tile bl-tile-dark" width="120" height="120" rx="27" fill="white" />
<defs>
<radialGradient :id="faceId" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(55.9622 55.1402) scale(37.9251)">
<stop stop-color="#117DFB" />
<stop offset="0.759254" stop-color="#449BFF" />
<stop offset="1" stop-color="#77B6FF" />
</radialGradient>
<mask :id="maskId" maskUnits="userSpaceOnUse">
<rect x="0" y="0" width="32" height="22" fill="#fff" />
<g class="ch-eyes" fill="#000">
<rect class="ch-eye" x="11.8" y="7" width="2.8" height="8" rx="1.4" />
<rect class="ch-eye" x="17.4" y="7" width="2.8" height="8" rx="1.4" />
</g>
</mask>
</defs>
<path d="M 55.9622 22.2352 C 76.6537 22.2352 93.4274 39.0089 93.4274 59.7004 C 93.4274 80.3918 76.6537 97.1656 55.9622 97.1656 C 35.2708 97.1656 18.4971 80.3918 18.4971 59.7004 C 18.4971 39.0089 35.2708 22.2352 55.9622 22.2352 Z" fill="#2389FF" />
<path d="M 55.9622 22.2352 C 76.6537 22.2352 93.4274 39.0089 93.4274 59.7004 C 93.4274 80.3918 76.6537 97.1656 55.9622 97.1656 C 35.2708 97.1656 18.4971 80.3918 18.4971 59.7004 C 18.4971 39.0089 35.2708 22.2352 55.9622 22.2352 Z" :fill="`url(#${faceId})`" />
<g class="ch-eyes" fill="#fff">
<path class="ch-eye" d="M 51.5123 45.0209 C 51.1532 42.4064 52.9647 39.9981 55.5585 39.6419 C 58.1522 39.2856 60.546 41.1163 60.9051 43.7309 L 61.9986 51.6926 C 62.3576 54.3072 60.5461 56.7154 57.9523 57.0717 C 55.3586 57.4279 52.9648 55.5972 52.6058 52.9827 L 51.5123 45.0209 Z" />
<path class="ch-eye" d="M 71.2214 42.5099 C 70.8803 40.0261 72.4961 37.7527 74.8305 37.432 C 77.1649 37.1114 79.3338 38.865 79.675 41.3488 L 80.7138 48.9125 C 81.0549 51.3963 79.4391 53.6697 77.1047 53.9904 C 74.7703 54.311 72.6014 52.5573 72.2603 50.0736 L 71.2214 42.5099 Z" />
</g>
<rect x="16.9412" y="69.1764" width="85.7647" height="28.0588" rx="3.112" fill="#002E58" />
<path d="M 24 77.8235 L 31.8773 82.3715 C 32.5257 82.7458 32.5257 83.6816 31.8773 84.0559 L 24 88.6039" stroke="white" stroke-width="3.5294" stroke-linecap="round" />
<path d="M 40.4116 89.647 H 48.1917" stroke="#007CFF" stroke-width="3.5294" stroke-linecap="round" />
<path d="M 94.8071 21.2797 C 98.2016 21.2797 100.953 24.0314 100.953 27.4259 C 100.953 30.8204 98.2016 33.5722 94.8071 33.5722 L 89.384 33.5722 C 88.9846 33.5722 88.6609 33.2484 88.6609 32.8491 L 88.6609 27.4259 C 88.6609 24.0314 91.4126 21.2797 94.8071 21.2797 Z" fill="#1783FF" />
<rect x="1" y="1" width="30" height="20" rx="6" fill="var(--logo)" :mask="`url(#${maskId})`" />
</svg>
<!-- brand-mark:end -->
</template>
<style scoped>
@ -80,35 +66,5 @@ onBeforeUnmount(() => clearTimeout(blinkTimer));
cursor: pointer;
user-select: none;
touch-action: manipulation;
/* Lift the tile off near-black dark surfaces (and vice versa). */
border: 0.5px solid var(--color-line);
border-radius: 22.5%; /* matches the tile's own rx=27/120 */
box-sizing: border-box;
}
/* Tile swap by theme the two <rect>s carry their own fill, so this block
stays token-clean (same selectors the token layer uses). */
.bl-tile-dark { display: none; }
html[data-color-scheme="dark"] .bl-tile-light { display: none; }
html[data-color-scheme="dark"] .bl-tile-dark { display: block; }
@media (prefers-color-scheme: dark) {
html[data-color-scheme="system"] .bl-tile-light { display: none; }
html[data-color-scheme="system"] .bl-tile-dark { display: block; }
}
/* Forced variants (settings app-icon preview) are ICON-accurate: 'light' pins
the white tile, 'dark' the black one. The tile classes are mode-named
(bl-tile-light is the BLACK tile shown in light mode for page contrast), so
this mapping is intentionally the inverse of the theme swap above. */
.brand-logo.bl-variant-light .bl-tile-light { display: none; }
.brand-logo.bl-variant-light .bl-tile-dark { display: block; }
.brand-logo.bl-variant-dark .bl-tile-light { display: block; }
.brand-logo.bl-variant-dark .bl-tile-dark { display: none; }
/* Same-direction appearance for chrome: white tile in light, black in dark. */
.brand-logo.bl-variant-theme .bl-tile-light { display: none; }
.brand-logo.bl-variant-theme .bl-tile-dark { display: block; }
html[data-color-scheme="dark"] .brand-logo.bl-variant-theme .bl-tile-light { display: block; }
html[data-color-scheme="dark"] .brand-logo.bl-variant-theme .bl-tile-dark { display: none; }
@media (prefers-color-scheme: dark) {
html[data-color-scheme="system"] .brand-logo.bl-variant-theme .bl-tile-light { display: block; }
html[data-color-scheme="system"] .brand-logo.bl-variant-theme .bl-tile-dark { display: none; }
}
</style>

View file

@ -27,7 +27,7 @@ const props = defineProps<{
onCancelOAuthLogin: () => Promise<void>;
}>();
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 {
</div>
<Icon name="chevron-right" size="lg" class="ls-card-chevron" />
</button>
<!-- TODO: 三方 API 入口OpenAI 兼容服务 + 自带 Key待设计/实现后开放 -->
<!-- Third-party entry: lands on Settings Providers (the parent completes
onboarding first, same as skipping the login step). -->
<button class="ls-card" type="button" @click="emit('addProvider')">
<span class="ls-card-logo ls-card-icon"><Icon name="bolt" size="lg" /></span>
<div class="ls-card-text">
<div class="ls-card-title">{{ t('onboarding.login.customProviderTitle') }}</div>
<div class="ls-card-hint">{{ t('onboarding.login.customProviderHint') }}</div>
</div>
<Icon name="chevron-right" size="lg" class="ls-card-chevron" />
</button>
</div>
<!-- Device-code flow -->
@ -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;

View file

@ -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"
/>
</div>
</section>

View file

@ -0,0 +1,578 @@
<!-- apps/web/src/components/settings/AddProviderFlow.vue -->
<!-- The add-provider expand area of the Providers tab: a source switch
(models.dev directory vs. manual) on top of the shared ProviderForm. The
directory view browses the server-proxied catalog (search, greyed-out
rejected entries) and turns a picked entry into a short import form
(name / API key / base URL only when the entry needs one); manual is the
existing full form. Servers that predate the catalog routes hide the
switch and fall back to manual-only. Dirty/guard events mirror
ProviderForm's contract so the parent accordion treats both sources the
same. -->
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { AppCatalogProvider } from '../../api/types';
import { useKimiWebClient } from '../../composables/useKimiWebClient';
import { PROVIDER_ID_PATTERN } from '../../lib/providerForm';
import ProviderForm from './ProviderForm.vue';
import {
Badge,
Banner,
Button,
Icon,
IconButton,
Input,
SegmentedControl,
Spinner,
} from '@moonshot-ai/web-ui';
defineProps<{
/** Show the unsaved-changes guard bar at the top (shared by both sources). */
guard?: boolean;
}>();
const emit = defineEmits<{
dirtyChange: [dirty: boolean];
guardStay: [];
guardDiscard: [];
added: [id: string];
cancel: [];
}>();
const { t, te } = useI18n();
const client = useKimiWebClient();
const source = ref<'catalog' | 'registry' | 'manual'>('catalog');
const sourceOptions = computed(() => [
{ value: 'catalog', label: t('providers.catalog.sourceCatalog') },
{ value: 'registry', label: t('providers.catalog.sourceRegistry') },
{ value: 'manual', label: t('providers.catalog.sourceManual') },
]);
// ---------------------------------------------------------------------------
// Directory loading (three-way: ready / error-retryable / unsupported server)
// ---------------------------------------------------------------------------
const catalogState = ref<'loading' | 'ready' | 'error' | 'unsupported'>('loading');
const catalogItems = ref<AppCatalogProvider[]>([]);
async function loadCatalog(): Promise<void> {
catalogState.value = 'loading';
const result = await client.loadCatalogProviders();
if (result.kind === 'ok') {
catalogItems.value = result.items;
catalogState.value = 'ready';
} else if (result.kind === 'unsupported') {
catalogState.value = 'unsupported';
// Only pull the user out of the catalog view a slow directory answer
// must not yank them away from the registry/manual form mid-input.
if (source.value === 'catalog') source.value = 'manual';
} else {
catalogState.value = 'error';
}
}
onMounted(loadCatalog);
// ---------------------------------------------------------------------------
// Directory browsing (browsing/searching is never dirty only form input is)
// ---------------------------------------------------------------------------
const search = ref('');
const filtered = computed(() => {
const q = search.value.trim().toLowerCase();
if (q === '') return catalogItems.value;
return catalogItems.value.filter(
(entry) => entry.name.toLowerCase().includes(q) || entry.id.toLowerCase().includes(q),
);
});
function rejectReasonText(entry: AppCatalogProvider): string {
const reason = entry.rejectReason;
if (reason !== null && te(`providers.catalog.rejectReason.${reason}`)) {
return t(`providers.catalog.rejectReason.${reason}`);
}
return t('providers.catalog.rejected');
}
// ---------------------------------------------------------------------------
// Import form (one picked entry)
// ---------------------------------------------------------------------------
const selected = ref<AppCatalogProvider | null>(null);
const importForm = ref({ id: '', apiKey: '', baseUrl: '' });
const showApiKey = ref(false);
const importing = ref(false);
const importError = ref('');
function selectEntry(entry: AppCatalogProvider): void {
selected.value = entry;
importForm.value = { id: entry.id, apiKey: '', baseUrl: '' };
importError.value = '';
showApiKey.value = false;
}
function backToList(): void {
// The form is discarded on purpose; clear the dirty flag it may have raised.
selected.value = null;
importError.value = '';
emit('dirtyChange', false);
}
function markDirty(): void {
emit('dirtyChange', true);
}
/** Importing an id that already exists is a server-side refresh — warn. */
const overwrite = computed(() => {
const entry = selected.value;
if (entry === null) return false;
const id = importForm.value.id.trim();
return id !== '' && client.providers.value.some((p) => p.id === id);
});
const importErrorBox = ref<HTMLElement>();
function showImportError(message: string): void {
importError.value = message;
void nextTick(() =>
importErrorBox.value?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }),
);
}
function validateImport(): string | null {
const form = importForm.value;
const id = form.id.trim();
if (id === '') return t('providers.error.idRequired');
if (!PROVIDER_ID_PATTERN.test(id)) return t('providers.error.idInvalid');
if (form.apiKey.trim() === '') return t('providers.error.apiKeyRequired');
if (selected.value?.needsBaseUrl === true && form.baseUrl.trim() === '') {
return t('providers.error.baseUrlRequired');
}
return null;
}
async function submitImport(): Promise<void> {
const entry = selected.value;
if (entry === null || importing.value) return;
const invalid = validateImport();
if (invalid !== null) {
showImportError(invalid);
return;
}
importError.value = '';
importing.value = true;
try {
const form = importForm.value;
const id = form.id.trim();
const baseUrl = form.baseUrl.trim();
const message = await client.importCatalogProvider({
catalogId: entry.id,
apiKey: form.apiKey.trim(),
...(baseUrl === '' ? {} : { baseUrl }),
...(id === entry.id ? {} : { id }),
});
if (message !== null) {
showImportError(message);
return;
}
client.notify({ severity: 'success', title: t('providers.added') });
emit('dirtyChange', false);
emit('added', id);
} finally {
importing.value = false;
}
}
// ---------------------------------------------------------------------------
// Registry source (api.json URL + optional Bearer key)
// ---------------------------------------------------------------------------
const registryForm = ref({ url: '', apiKey: '' });
const showRegistryKey = ref(false);
const importingRegistry = ref(false);
const registryError = ref('');
const registryErrorBox = ref<HTMLElement>();
function showRegistryError(message: string): void {
registryError.value = message;
void nextTick(() =>
registryErrorBox.value?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }),
);
}
async function submitRegistry(): Promise<void> {
if (importingRegistry.value) return;
const url = registryForm.value.url.trim();
if (url === '') {
showRegistryError(t('providers.error.registryUrlRequired'));
return;
}
registryError.value = '';
importingRegistry.value = true;
try {
const apiKey = registryForm.value.apiKey.trim();
const result = await client.importCustomRegistry({
url,
...(apiKey === '' ? {} : { apiKey }),
});
if (typeof result === 'string') {
showRegistryError(result);
return;
}
client.notify({
severity: 'success',
title: t('providers.catalog.registryImported', { count: result.providers.length }),
});
emit('dirtyChange', false);
const first = result.providers[0];
// A registry can list several providers; expand the first, same landing
// as the other add paths. (The server rejects empty registries, so this
// fallback is only a type guard.)
if (first !== undefined) emit('added', first.id);
else emit('cancel');
} finally {
importingRegistry.value = false;
}
}
</script>
<template>
<div class="af">
<!-- Unsaved-changes guard (shared across both sources) -->
<Banner v-if="guard" variant="warning" class="af-guard">
<span class="msg">{{ t('providers.unsavedGuard') }}</span>
<Button variant="secondary" size="sm" @click="emit('guardStay')">{{ t('providers.guardStay') }}</Button>
<Button variant="danger" size="sm" @click="emit('guardDiscard')">{{ t('providers.guardDiscard') }}</Button>
</Banner>
<SegmentedControl
v-if="catalogState !== 'unsupported'"
v-model="source"
size="sm"
:options="sourceOptions"
/>
<!-- Directory source (kept mounted across source switches to preserve state) -->
<div v-if="catalogState !== 'unsupported'" v-show="source === 'catalog'" class="af-catalog">
<div v-if="catalogState === 'loading'" class="af-center">
<Spinner size="sm" />
<span>{{ t('providers.catalog.loading') }}</span>
</div>
<div v-else-if="catalogState === 'error'" class="af-error">
<Banner variant="danger">{{ t('providers.catalog.loadError') }}</Banner>
<div>
<Button variant="secondary" size="sm" @click="loadCatalog">
{{ t('providers.catalog.retry') }}
</Button>
</div>
</div>
<template v-else-if="selected === null">
<Input
v-model="search"
:placeholder="t('providers.catalog.searchPlaceholder')"
autocomplete="off"
spellcheck="false"
/>
<div class="af-list">
<button
v-for="entry in filtered"
:key="entry.id"
type="button"
class="af-entry"
:disabled="entry.rejected"
@click="selectEntry(entry)"
>
<span class="af-entry-name">{{ entry.name }}</span>
<Badge v-if="entry.wireType !== null" variant="neutral" size="sm">{{ entry.wireType }}</Badge>
<span class="grow" />
<span v-if="entry.rejected" class="af-entry-reason">{{ rejectReasonText(entry) }}</span>
<span v-else class="af-entry-count">
{{ t('providers.modelCount', { count: entry.models.length }) }}
</span>
</button>
<div v-if="filtered.length === 0" class="af-empty">{{ t('providers.catalog.empty') }}</div>
</div>
</template>
<div v-else class="af-import" @input="markDirty">
<button type="button" class="af-back" @click="backToList">
<Icon name="arrow-left" size="sm" />
{{ t('providers.catalog.backToList') }}
</button>
<div class="af-field">
<label class="af-label">{{ t('providers.fieldId') }}<span class="req"> *</span></label>
<Input v-model="importForm.id" autocomplete="off" spellcheck="false" />
</div>
<div class="af-field">
<label class="af-label">{{ t('providers.fieldApiKey') }}<span class="req"> *</span></label>
<div class="af-key-wrap">
<Input
v-model="importForm.apiKey"
:type="showApiKey ? 'text' : 'password'"
placeholder="sk-…"
autocomplete="off"
spellcheck="false"
/>
<IconButton
class="af-key-eye"
size="sm"
:label="t(showApiKey ? 'providers.hideApiKey' : 'providers.showApiKey')"
@click="showApiKey = !showApiKey"
>
<Icon :name="showApiKey ? 'eye-off' : 'eye'" size="sm" />
</IconButton>
</div>
</div>
<div v-if="selected.needsBaseUrl" class="af-field">
<label class="af-label">{{ t('providers.fieldBaseUrl') }}<span class="req"> *</span></label>
<Input
v-model="importForm.baseUrl"
:placeholder="t('providers.baseUrlPlaceholder')"
autocomplete="off"
spellcheck="false"
/>
</div>
<Banner v-if="overwrite" variant="warning">
{{ t('providers.catalog.overwriteWarning') }}
</Banner>
<div class="af-note">
{{ t('providers.catalog.willImport', { count: selected.models.length }) }}
</div>
<div v-if="importError" ref="importErrorBox">
<Banner variant="danger">{{ importError }}</Banner>
</div>
<div class="af-foot">
<Button variant="secondary" size="sm" @click="emit('cancel')">
{{ t('common.cancel') }}
</Button>
<Button variant="primary" size="sm" :disabled="importing" @click="submitImport">
{{ t('providers.catalog.importAction') }}
</Button>
</div>
</div>
</div>
<!-- Registry source -->
<div v-show="source === 'registry'" class="af-registry" @input="markDirty">
<div class="af-hint">{{ t('providers.catalog.registryHint') }}</div>
<div class="af-field">
<label class="af-label">{{ t('providers.catalog.registryUrlLabel') }}<span class="req"> *</span></label>
<Input
v-model="registryForm.url"
placeholder="https://example.com/api.json"
autocomplete="off"
spellcheck="false"
/>
</div>
<div class="af-field">
<label class="af-label">{{ t('providers.fieldApiKey') }}</label>
<div class="af-key-wrap">
<Input
v-model="registryForm.apiKey"
:type="showRegistryKey ? 'text' : 'password'"
:placeholder="t('providers.modelNamePlaceholder')"
autocomplete="off"
spellcheck="false"
/>
<IconButton
class="af-key-eye"
size="sm"
:label="t(showRegistryKey ? 'providers.hideApiKey' : 'providers.showApiKey')"
@click="showRegistryKey = !showRegistryKey"
>
<Icon :name="showRegistryKey ? 'eye-off' : 'eye'" size="sm" />
</IconButton>
</div>
</div>
<div v-if="registryError" ref="registryErrorBox">
<Banner variant="danger">{{ registryError }}</Banner>
</div>
<div class="af-foot">
<Button variant="secondary" size="sm" @click="emit('cancel')">
{{ t('common.cancel') }}
</Button>
<Button variant="primary" size="sm" :disabled="importingRegistry" @click="submitRegistry">
{{ t('providers.catalog.importAction') }}
</Button>
</div>
</div>
<!-- Manual source -->
<div v-show="source === 'manual'" class="af-manual">
<ProviderForm
mode="add"
:guard="false"
@dirty-change="emit('dirtyChange', $event)"
@added="emit('added', $event)"
@cancel="emit('cancel')"
/>
</div>
</div>
</template>
<style scoped>
.af {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4) var(--space-4) var(--space-5);
border-top: 0.5px solid var(--color-line);
}
/* The guard bar's buttons align right inside the banner's text slot. */
.af-guard :deep(.ui-banner__text) {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
}
.af-guard .msg { flex: 1; }
.af-catalog {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.af-center {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-4) 0;
color: var(--color-text-muted);
font-family: var(--font-ui);
font-size: var(--text-base);
}
.af-error {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-2);
}
/* Directory list: compact rows inside a fixed-height scroller the full
catalog is 100+ entries and must not stretch the accordion. */
.af-list {
display: flex;
flex-direction: column;
max-height: 320px;
overflow-y: auto;
border: 0.5px solid var(--color-line);
border-radius: var(--radius-md);
}
.af-list > * + * { border-top: 0.5px solid var(--color-line); }
.af-entry {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
min-height: 34px;
padding: var(--space-1) var(--space-3);
border: none;
background: transparent;
text-align: left;
font-family: var(--font-ui);
color: var(--color-text);
cursor: pointer;
transition: background var(--duration-fast) var(--ease-out);
}
.af-entry:hover:not(:disabled) { background: var(--color-hover); }
.af-entry:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.af-entry-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--text-base);
font-weight: var(--weight-medium);
}
.af-entry .grow { flex: 1; min-width: 0; }
.af-entry-count {
flex: none;
font-size: var(--text-xs);
color: var(--color-text-faint);
white-space: nowrap;
}
.af-entry-reason {
flex: none;
font-size: var(--text-xs);
color: var(--color-text-faint);
white-space: nowrap;
}
.af-empty {
padding: var(--space-4);
text-align: center;
color: var(--color-text-faint);
font-family: var(--font-ui);
font-size: var(--text-sm);
}
.af-import {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.af-registry {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.af-hint {
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--color-text-faint);
}
.af-back {
display: inline-flex;
align-items: center;
gap: var(--space-1);
align-self: flex-start;
padding: 0;
border: none;
background: transparent;
color: var(--color-text-muted);
font-family: var(--font-ui);
font-size: var(--text-sm);
cursor: pointer;
transition: color var(--duration-fast) var(--ease-out);
}
.af-back:hover { color: var(--color-text); }
.af-field { display: flex; flex-direction: column; gap: 6px; }
.af-label {
font-family: var(--font-ui);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
color: var(--color-text-muted);
}
.req { color: var(--color-danger); }
.af-key-wrap { position: relative; }
.af-key-wrap :deep(.ui-input) { padding-right: calc(var(--icon-button-sm) + var(--space-2)); }
.af-key-eye { position: absolute; right: var(--space-1); top: 50%; transform: translateY(-50%); }
.af-note {
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--color-text-faint);
}
.af-foot {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-2);
padding-top: var(--space-4);
border-top: 0.5px solid var(--color-line);
}
/* The manual form brings its own padding/border; strip the wrapper's share. */
.af-manual :deep(.pf-form) {
padding: 0;
border-top: none;
}
</style>

View file

@ -0,0 +1,495 @@
<!-- apps/web/src/components/settings/ProviderForm.vue -->
<!-- The shared vertical provider form of the Providers tab accordion: mode
'add' (blank, footer = cancel + add) and mode 'edit' (prefilled, footer =
delete + save with an inline delete-confirm bar; managed OAuth providers
render read-only with an account-tab hint). One instance per expanded row
so every row owns its form state; user input is reported upward as dirty
so the parent can gate collapsing on the unsaved-changes guard. -->
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { AppProvider } from '../../api/types';
import { useKimiWebClient } from '../../composables/useKimiWebClient';
import {
buildAddProviderInput,
buildUpdateProviderInput,
emptyModelRow,
isManagedOAuthProvider,
PROVIDER_TYPES,
providerModelRows,
validateProviderForm,
type ProviderFormState,
} from '../../lib/providerForm';
import { Banner, Button, Icon, IconButton, Input, Select } from '@moonshot-ai/web-ui';
const props = defineProps<{
mode: 'add' | 'edit';
provider?: AppProvider;
/** Show the unsaved-changes guard bar at the top of the form. */
guard?: boolean;
}>();
const emit = defineEmits<{
dirtyChange: [dirty: boolean];
guardStay: [];
guardDiscard: [];
added: [id: string];
saved: [id: string];
deleting: [];
deleted: [id: string];
cancel: [];
}>();
const { t } = useI18n();
const client = useKimiWebClient();
const form = reactive<ProviderFormState>({
id: '',
type: 'openai',
apiKey: '',
baseUrl: '',
models: [emptyModelRow()],
});
const error = ref('');
const submitting = ref(false);
const confirmingDelete = ref(false);
const deleting = ref(false);
const adding = computed(() => props.mode === 'add');
const managed = computed(() => props.provider !== undefined && isManagedOAuthProvider(props.provider));
// The delete-confirm banner counts the PERSISTED models, not the form's
// unsaved rows (the user may have edited rows without saving).
const persistedModelCount = computed(() => {
const p = props.provider;
if (p === undefined) return 0;
return providerModelRows(p, client.config.value?.models).length;
});
// A managed provider is read-only here, so with zero models the single blank
// row adds nothing its disabled inputs show placeholder hints that read
// like real values. Show an explicit empty state instead.
const showModelsEmpty = computed(() => managed.value && persistedModelCount.value === 0);
const typeOptions = computed(() =>
PROVIDER_TYPES.map((type) => ({ value: type, label: t(`providers.types.${type}`) })),
);
// The key can never be read back, so the placeholder carries the state:
// managed (OAuth, disabled), already set (type to replace), or a plain hint.
const apiKeyPlaceholder = computed(() => {
if (managed.value) return t('providers.apiKeyManaged');
if (!adding.value && props.provider?.hasApiKey === true) return t('providers.apiKeySet');
return 'sk-…';
});
function fillForm(): void {
error.value = '';
confirmingDelete.value = false;
const p = props.provider;
if (adding.value || p === undefined) {
form.id = '';
form.type = 'openai';
form.apiKey = '';
form.baseUrl = '';
form.models = [emptyModelRow()];
return;
}
form.id = p.id;
form.type = p.type;
form.apiKey = '';
form.baseUrl = p.baseUrl ?? '';
const rows = providerModelRows(p, client.config.value?.models);
form.models = rows.length > 0 ? rows : [emptyModelRow()];
}
onMounted(() => {
fillForm();
void loadApiKey();
});
// Prefill the stored key into the password field (edit only): the single GET
// is the one place the daemon reveals it. keyLoaded flips the blank-field
// semantics at save time from "keep" to "clear" (the input is authoritative
// once the user can see the real value). The prefill must never overwrite a
// key the user started typing while the GET was in flight apiKeyTouched
// guards that race.
const keyLoaded = ref(false);
const apiKeyTouched = ref(false);
async function loadApiKey(): Promise<void> {
const p = props.provider;
if (adding.value || p === undefined || managed.value || p.hasApiKey !== true) return;
try {
const detail = await client.getProvider(p.id);
if (apiKeyTouched.value) return;
if (detail.apiKey !== undefined && detail.apiKey !== '') {
form.apiKey = detail.apiKey;
keyLoaded.value = true;
}
} catch {
// The key stays empty and the "set enter to replace" placeholder keeps
// the state honest; saving still preserves the stored key.
}
}
// Dirty = any USER input: native input events bubble up from the inputs, while
// programmatic v-model writes dispatch no events, so refills after a save stay
// clean. The custom Select emits no input event and marks dirty explicitly.
function markDirty(): void {
emit('dirtyChange', true);
}
// The API key renders as a password field with a visibility toggle so users
// can double-check what they pasted; toggling is view-only, never dirty.
const showApiKey = ref(false);
// ---------------------------------------------------------------------------
// Submit (add / save)
// ---------------------------------------------------------------------------
// Surface a submit failure both in the banner and in view: the banner sits at
// the top of the form while the submit button is at the bottom, so without
// scrolling a failed add/save looks like a no-op.
const errorBox = ref<HTMLElement>();
function showError(message: string): void {
error.value = message;
void nextTick(() => errorBox.value?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }));
}
async function submit(): Promise<void> {
if (submitting.value) return;
// The id is editable on the edit path too (renames go through PUT new_id).
// apiKey/baseUrl are mandatory only when adding: on edit a blank key means
// keep-or-clear (see keyLoaded) and a blank base URL means unset, so
// providers without credentials (env-based) stay editable.
const invalid = validateProviderForm(form, {
requireApiKey: adding.value,
requireBaseUrl: adding.value,
});
if (invalid !== null) {
showError(t(`providers.error.${invalid}`));
return;
}
error.value = '';
submitting.value = true;
try {
if (adding.value) {
const message = await client.addProvider(buildAddProviderInput(form));
if (message !== null) {
showError(message);
return;
}
emit('dirtyChange', false);
client.notify({ severity: 'success', title: t('providers.added') });
emit('added', form.id.trim());
} else {
const p = props.provider;
if (p === undefined) return;
// The provider's configured default (raw config, alias form) NOT the
// catalog item's materialized default_model, which falls back to the
// global default and would be persisted as provider-level by mistake.
const configuredDefault = client.config.value?.providers?.[p.id]?.defaultModel;
const message = await client.updateProvider(
p.id,
buildUpdateProviderInput(form, p, {
includeBlankApiKey: keyLoaded.value,
existingDefaultModel: configuredDefault,
}),
);
if (message !== null) {
showError(message);
return;
}
// An edit can clear the global default (cleared_default) re-read
// auth/config state, mirroring handleUpdateConfig in App.vue.
await client.checkAuth();
// No refill: the form already holds exactly what was persisted, and a
// refill blanks the key field for a beat (reads as a flash). The next
// expand re-reads server state anyway.
client.notify({ severity: 'success', title: t('providers.saved') });
emit('dirtyChange', false);
emit('saved', form.id.trim());
}
} finally {
submitting.value = false;
}
}
// ---------------------------------------------------------------------------
// Delete (inline confirm bar, no modal)
// ---------------------------------------------------------------------------
async function confirmDelete(): Promise<void> {
const p = props.provider;
if (p === undefined || deleting.value) return;
deleting.value = true;
// Collapse first, then delete: the parent's leaving transition keeps this
// form mounted for the animation, so the request only fires once the item
// is visually closed the row's removal from the reloaded list lands on a
// zero-height item instead of tearing a 500px hole mid-list.
emit('deleting');
await new Promise((resolve) => setTimeout(resolve, 300));
try {
// Resolves null on failure (already toasted by the composable).
const result = await client.deleteProvider(p.id);
if (result === null) {
confirmingDelete.value = false;
return;
}
emit('dirtyChange', false);
emit('deleted', p.id);
} finally {
deleting.value = false;
}
}
// ---------------------------------------------------------------------------
// Model rows
// ---------------------------------------------------------------------------
function addModelRow(): void {
form.models.push(emptyModelRow());
markDirty();
}
function removeModelRow(index: number): void {
// The form always keeps at least one model row (server requires >= 1).
if (form.models.length <= 1) return;
form.models.splice(index, 1);
markDirty();
}
</script>
<template>
<div class="pf-form" @input="markDirty">
<!-- Unsaved-changes guard (inserted by the parent on collapse attempts) -->
<Banner v-if="guard" variant="warning" class="pf-guard">
<span class="msg">{{ t('providers.unsavedGuard') }}</span>
<Button variant="secondary" size="sm" @click="emit('guardStay')">{{ t('providers.guardStay') }}</Button>
<Button variant="danger" size="sm" @click="emit('guardDiscard')">{{ t('providers.guardDiscard') }}</Button>
</Banner>
<div v-if="error" ref="errorBox">
<Banner variant="danger">{{ error }}</Banner>
</div>
<div class="pf-field">
<label class="pf-field-label">{{ t('providers.fieldId') }}<span class="req"> *</span></label>
<Input
v-model="form.id"
placeholder="my-openai"
:disabled="managed"
autocomplete="off"
spellcheck="false"
/>
</div>
<div class="pf-field">
<label class="pf-field-label">{{ t('providers.fieldType') }}<span class="req"> *</span></label>
<Select
:model-value="form.type"
:options="typeOptions"
:disabled="managed"
@update:model-value="form.type = $event; markDirty()"
/>
</div>
<div class="pf-field">
<label class="pf-field-label">{{ t('providers.fieldApiKey') }}<span class="req"> *</span></label>
<div class="pf-key-wrap">
<Input
v-model="form.apiKey"
:type="showApiKey ? 'text' : 'password'"
:placeholder="apiKeyPlaceholder"
:disabled="managed"
autocomplete="off"
spellcheck="false"
@input="apiKeyTouched = true"
/>
<IconButton
v-if="!managed"
class="pf-key-eye"
size="sm"
:label="t(showApiKey ? 'providers.hideApiKey' : 'providers.showApiKey')"
@click="showApiKey = !showApiKey"
>
<Icon :name="showApiKey ? 'eye-off' : 'eye'" size="sm" />
</IconButton>
</div>
</div>
<div class="pf-field">
<label class="pf-field-label">{{ t('providers.fieldBaseUrl') }}<span class="req"> *</span></label>
<Input
v-model="form.baseUrl"
:placeholder="t('providers.baseUrlPlaceholder')"
:disabled="managed"
autocomplete="off"
spellcheck="false"
/>
</div>
<div class="pf-field">
<label class="pf-field-label">{{ t('providers.fieldModels') }}<span class="req"> *</span></label>
<div class="pf-models">
<div v-if="showModelsEmpty" class="pf-models-empty">{{ t('providers.noModels') }}</div>
<template v-else>
<div class="pf-model-grid pf-model-head">
<span>{{ t('providers.colModelId') }}<span class="req"> *</span></span>
<span>{{ t('providers.colContext') }}<span class="req"> *</span></span>
<span>{{ t('providers.colDisplayName') }}</span>
<span />
</div>
<div v-for="(row, index) in form.models" :key="index" class="pf-model-grid">
<Input
v-model="row.model"
:placeholder="t('providers.modelIdPlaceholder')"
:disabled="managed"
autocomplete="off"
spellcheck="false"
/>
<Input
v-model="row.maxContextSize"
inputmode="numeric"
:placeholder="t('providers.modelContextPlaceholder')"
:disabled="managed"
autocomplete="off"
spellcheck="false"
/>
<Input
v-model="row.displayName"
:placeholder="t('providers.modelNamePlaceholder')"
:disabled="managed"
autocomplete="off"
spellcheck="false"
/>
<IconButton
v-if="!managed"
size="sm"
:label="t('providers.removeModel')"
:disabled="form.models.length <= 1"
@click="removeModelRow(index)"
>
<Icon name="trash" size="sm" />
</IconButton>
<span v-else />
</div>
<div v-if="!managed">
<Button variant="ghost" size="sm" @click="addModelRow">
<Icon name="plus" size="sm" />
{{ t('providers.addModel') }}
</Button>
</div>
</template>
</div>
</div>
<!-- Footer: three states -->
<div class="pf-foot">
<!-- Managed OAuth: login/logout lives on the account tab -->
<span v-if="managed" class="pf-managed-note">{{ t('providers.managedHint') }}</span>
<!-- Add -->
<template v-else-if="adding">
<Button variant="secondary" size="sm" @click="emit('cancel')">
{{ t('common.cancel') }}
</Button>
<Button variant="primary" size="sm" :disabled="submitting" @click="submit">
{{ t('providers.addProvider') }}
</Button>
</template>
<!-- Edit: inline delete-confirm bar replaces the buttons -->
<template v-else-if="confirmingDelete && props.provider !== undefined">
<span class="pf-confirm-msg">
{{ t('providers.deleteConfirm', { id: props.provider.id, count: persistedModelCount }) }}
</span>
<span class="spacer" />
<Button variant="secondary" size="sm" :disabled="deleting" @click="confirmingDelete = false">
{{ t('common.cancel') }}
</Button>
<Button variant="danger" size="sm" :disabled="deleting" @click="confirmDelete">
{{ t('providers.deleteConfirmYes') }}
</Button>
</template>
<template v-else>
<Button variant="danger-soft" size="sm" @click="confirmingDelete = true">
{{ t('providers.deleteProvider') }}
</Button>
<span class="spacer" />
<Button variant="primary" size="sm" :disabled="submitting" @click="submit">
{{ t('providers.save') }}
</Button>
</template>
</div>
</div>
</template>
<style scoped>
.pf-form {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-4) var(--space-4) var(--space-5);
border-top: 0.5px solid var(--color-line);
}
/* The guard bar's buttons align right inside the banner's text slot. */
.pf-guard :deep(.ui-banner__text) {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
}
.pf-guard .msg { flex: 1; }
.pf-field { display: flex; flex-direction: column; gap: 6px; }
.pf-key-wrap { position: relative; }
.pf-key-wrap :deep(.ui-input) { padding-right: calc(var(--icon-button-sm) + var(--space-2)); }
.pf-key-eye { position: absolute; right: var(--space-1); top: 50%; transform: translateY(-50%); }
/* Same label styling as web-ui's Field, plus a required marker. */
.pf-field-label {
font-family: var(--font-ui);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
color: var(--color-text-muted);
}
.req { color: var(--color-danger); }
.pf-models { display: flex; flex-direction: column; gap: var(--space-2); }
.pf-model-grid {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) minmax(0, 2fr) auto;
gap: var(--space-2);
align-items: center;
}
.pf-model-head span {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--color-text-faint);
}
.pf-models-empty {
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--color-text-faint);
}
.pf-foot {
display: flex;
align-items: center;
gap: var(--space-2);
padding-top: var(--space-4);
border-top: 0.5px solid var(--color-line);
}
.pf-foot .spacer { flex: 1; }
.pf-confirm-msg {
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--color-danger);
}
.pf-managed-note {
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--color-text-faint);
}
@media (max-width: 640px) {
.pf-model-grid { grid-template-columns: minmax(0, 1fr) auto; }
}
</style>

View file

@ -1,377 +0,0 @@
<!-- apps/kimi-web/src/components/settings/ProviderManager.vue -->
<!-- Modal overlay for managing providers: list, add, refresh, delete. -->
<script setup lang="ts">
import { onMounted, onUnmounted, reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { AppProvider } from '../../api/types';
import { useDialogFocus } from '../../composables/useDialogFocus';
import { Badge, Button, Dialog, Field, Icon, Input, Kbd, Select, Spinner, Tooltip } from '@moonshot-ai/web-ui';
const { t } = useI18n();
const dialogRef = ref<HTMLElement | null>(null);
// Move focus into the dialog on open; restore it to the opener on close.
useDialogFocus(dialogRef);
const props = defineProps<{
providers: AppProvider[];
loading?: boolean;
/** If true, providers could not be fetched (daemon 404 / unsupported) */
unavailable?: boolean;
}>();
const emit = defineEmits<{
add: [input: { type: string; apiKey?: string; baseUrl?: string; defaultModel?: string }];
refresh: [id: string];
delete: [id: string];
/** Open the login dialog for the given platform (OAuth flow) */
openLogin: [platform: string];
close: [];
}>();
// -------------------------------------------------------------------------
// Delete confirmation
// -------------------------------------------------------------------------
// Delete the modal confirm and the async delete live in App.vue
// (confirmDeleteProvider); the manager only emits the intent.
function onDeleteProvider(id: string): void {
emit('delete', id);
}
// -------------------------------------------------------------------------
// Add-provider form
// -------------------------------------------------------------------------
const showAddForm = ref(false);
const addForm = reactive({
type: 'moonshot',
apiKey: '',
baseUrl: '',
defaultModel: '',
});
const addError = ref('');
const PROVIDER_TYPES = ['moonshot', 'anthropic', 'openai', 'custom'];
function openAdd(): void {
addForm.type = 'moonshot';
addForm.apiKey = '';
addForm.baseUrl = '';
addForm.defaultModel = '';
addError.value = '';
showAddForm.value = true;
}
function cancelAdd(): void {
showAddForm.value = false;
}
function submitAdd(): void {
if (!addForm.apiKey.trim()) {
addError.value = t('providers.apiKeyRequired');
return;
}
addError.value = '';
emit('add', {
type: addForm.type,
apiKey: addForm.apiKey.trim() || undefined,
baseUrl: addForm.baseUrl.trim() || undefined,
defaultModel: addForm.defaultModel.trim() || undefined,
});
showAddForm.value = false;
}
// -------------------------------------------------------------------------
// Keyboard Esc closes
// -------------------------------------------------------------------------
function handleKeydown(e: KeyboardEvent): void {
if (e.key === 'Escape') {
if (showAddForm.value) { cancelAdd(); return; }
emit('close');
}
}
onMounted(() => document.addEventListener('keydown', handleKeydown));
onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
// -------------------------------------------------------------------------
// Status helpers
// -------------------------------------------------------------------------
function statusColor(status: AppProvider['status']): string {
if (status === 'connected') return 'var(--color-success)';
if (status === 'error') return 'var(--color-danger)';
return 'var(--color-text-faint)';
}
function statusLabel(status: AppProvider['status']): string {
if (status === 'connected') return t('providers.status.connected');
if (status === 'error') return t('providers.status.error');
return t('providers.status.unconfigured');
}
</script>
<template>
<Dialog :open="true" :close-on-esc="false" :title="t('providers.title')" size="xl" height="fixed" :padded="false" @close="emit('close')">
<div ref="dialogRef" class="pm">
<!-- Provider list -->
<div class="prov-list">
<!-- Loading state -->
<div v-if="loading" class="state-row">
<Spinner size="sm" />
<span>{{ t('providers.loading') }}</span>
</div>
<!-- Unavailable (daemon 404) -->
<div v-else-if="unavailable" class="state-row unavail">
<Icon name="alert-triangle" size="md" />
<span>{{ t('providers.unavailable') }}</span>
</div>
<!-- Empty -->
<div v-else-if="providers.length === 0" class="empty">{{ t('providers.empty') }}</div>
<!-- Provider rows -->
<template v-else>
<div v-for="p in providers" :key="p.id" class="prov-row">
<!-- Status dot -->
<Tooltip :text="statusLabel(p.status)">
<span
class="status-dot"
:class="{ 'status-dot--empty': p.status !== 'connected' && p.status !== 'error' }"
:style="p.status === 'connected' || p.status === 'error' ? { background: statusColor(p.status) } : undefined"
/>
</Tooltip>
<div class="prov-info">
<span class="prov-type">{{ p.type }}</span>
<span v-if="p.baseUrl" class="prov-url">{{ p.baseUrl }}</span>
<span class="prov-meta">
<Badge :variant="p.hasApiKey ? 'success' : 'neutral'" size="sm">
{{ p.hasApiKey ? t('providers.keySet') : t('providers.keyNotSet') }}
</Badge>
<span v-if="p.models && p.models.length > 0"> · {{ t('providers.modelCount', { count: p.models.length }) }}</span>
</span>
</div>
<!-- Actions -->
<div class="prov-actions">
<Tooltip :text="t('providers.refreshTitle', { type: p.type })">
<Button variant="secondary" size="sm" @click="emit('refresh', p.id)">{{ t('providers.refresh') }}</Button>
</Tooltip>
<Tooltip :text="t('providers.deleteTitle', { type: p.type })">
<Button variant="danger-soft" size="sm" @click="onDeleteProvider(p.id)">{{ t('providers.delete') }}</Button>
</Tooltip>
</div>
</div>
</template>
</div>
<!-- Add provider form / button -->
<div v-if="!unavailable" class="add-section">
<template v-if="!showAddForm">
<div class="add-btns">
<!-- OAuth login shortcuts for common platforms -->
<Button variant="secondary" size="sm" @click="emit('openLogin', 'moonshot')">
<Icon name="user" size="sm" />
{{ t('providers.loginKimi') }}
</Button>
<Button variant="secondary" size="sm" @click="emit('openLogin', 'anthropic')">
<Icon name="user" size="sm" />
{{ t('providers.loginAnthropic') }}
</Button>
<Button variant="primary" size="sm" @click="openAdd">
<Icon name="plus" size="sm" />
{{ t('providers.enterApiKey') }}
</Button>
</div>
</template>
<template v-else>
<div class="add-form">
<Field :label="t('providers.fieldType')">
<Select
v-model="addForm.type"
:options="PROVIDER_TYPES.map((type) => ({ value: type, label: type }))"
/>
</Field>
<Field :label="t('providers.fieldApiKey')">
<Input
v-model="addForm.apiKey"
type="password"
placeholder="sk-…"
autocomplete="off"
spellcheck="false"
/>
</Field>
<Field :label="t('providers.fieldBaseUrl')">
<Input
v-model="addForm.baseUrl"
:placeholder="t('providers.baseUrlPlaceholder')"
autocomplete="off"
spellcheck="false"
/>
</Field>
<Field :label="t('providers.fieldDefaultModel')">
<Input
v-model="addForm.defaultModel"
:placeholder="t('providers.optional')"
autocomplete="off"
spellcheck="false"
/>
</Field>
<div v-if="addError" class="add-error">{{ addError }}</div>
<div class="form-btns">
<Button variant="primary" size="sm" @click="submitAdd">{{ t('providers.add') }}</Button>
<Button variant="secondary" size="sm" @click="cancelAdd">{{ t('common.cancel') }}</Button>
</div>
</div>
</template>
</div>
<!-- Footer: full-bleed shortcut bar, same as ModelPicker's. -->
<div class="footer-hint" aria-hidden="true">
<Kbd :keys="['Esc']" />
<span>{{ t('providers.hintClose') }}</span>
</div>
</div>
</Dialog>
</template>
<style scoped>
/* Flush anatomy (Dialog :padded="false"): content zones inset at 22px
(title-aligned); the footer is a full-bleed bar. */
.pm { display: flex; flex-direction: column; gap: var(--space-3); height: 100%; min-height: 0; padding-top: 4px; }
/* Provider list */
.prov-list {
display: flex;
flex-direction: column;
gap: var(--space-1);
flex: 1;
min-height: 0;
overflow-y: auto;
margin: 0 22px;
}
.state-row {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-4) 0;
color: var(--color-text-muted);
font-family: var(--font-ui);
font-size: var(--text-base);
}
.state-row.unavail { color: var(--color-warning); }
.empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-4) 0;
color: var(--color-text-muted);
font-family: var(--font-ui);
font-size: var(--text-base);
}
.prov-row {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) 0;
border-bottom: 0.5px solid var(--color-line);
transition: background var(--duration-fast) var(--ease-out);
}
.prov-row:last-child { border-bottom: none; }
.status-dot {
width: 8px;
height: 8px;
flex: none;
border-radius: 50%;
box-sizing: border-box;
}
.status-dot--empty {
background: transparent;
border: 0.5px solid var(--color-text-faint);
}
.prov-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.prov-type {
font-family: var(--font-ui);
font-size: var(--text-base);
font-weight: var(--weight-medium);
color: var(--color-text);
}
.prov-url {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.prov-meta {
display: flex;
align-items: center;
gap: var(--space-2);
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.prov-actions {
display: flex;
gap: var(--space-2);
flex: none;
align-items: center;
flex-wrap: wrap;
}
/* Add section */
.add-section {
margin: 0 22px;
border-top: 0.5px solid var(--color-line);
padding-top: var(--space-3);
}
.add-btns {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
/* Form */
.add-form { display: flex; flex-direction: column; gap: var(--space-3); }
.add-error {
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--color-danger);
}
.form-btns {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
/* Footer: full-bleed shortcut bar — same container as ModelPicker's. */
.footer-hint {
flex: none;
display: flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-2) var(--space-4);
border-top: 0.5px solid var(--color-line);
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--color-text-faint);
}
@media (max-width: 640px) {
.prov-row {
align-items: flex-start;
flex-wrap: wrap;
}
.prov-actions {
flex: 1 1 100%;
justify-content: flex-end;
}
}
</style>

View file

@ -0,0 +1,349 @@
<!-- apps/web/src/components/settings/ProvidersPanel.vue -->
<!-- Providers settings tab (accordion, design F): each provider is a full-width
row (id + type badge + model count + chevron) that expands inline into the
shared ProviderForm view = edit = add. At most one item is open; the add
form pins to the top of the list, driven by the sec-head button. Collapsing
or switching with unsaved edits inserts an inline guard bar (keep editing /
discard) never a modal. -->
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import type { AppProvider } from '../../api/types';
import { useKimiWebClient } from '../../composables/useKimiWebClient';
import { isManagedOAuthProvider, providerModelRows } from '../../lib/providerForm';
import AddProviderFlow from './AddProviderFlow.vue';
import ProviderForm from './ProviderForm.vue';
import { Badge, Button, Icon, Spinner } from '@moonshot-ai/web-ui';
const { t } = useI18n();
const client = useKimiWebClient();
/** openKey value for the pinned add-form row. Deliberately fails the provider
id pattern (`$`), so a real provider can never collide with the sentinel. */
const ADD_KEY = '$add';
const loading = ref(true);
/** Accordion state: provider id | ADD_KEY | null — at most one item is open. */
const openKey = ref<string | null>(null);
/** The item that is mid-collapse: its form stays mounted for the 0fr1fr
transition so closing animates too. */
const leavingKey = ref<string | null>(null);
let leaveTimer = 0;
/** Dirty flag of the currently open form (synced from the child). */
const dirty = ref(false);
const guardVisible = ref(false);
/** Where tryOpen wants to go while the guard is up (null = collapse). */
const pendingOpen = ref<string | null>(null);
const flashId = ref('');
let flashTimer = 0;
// Sorted by id (dictionary order).
const providers = computed<AppProvider[]>(() =>
[...client.providers.value].sort((a, b) => a.id.localeCompare(b.id)),
);
function modelCountFor(p: AppProvider): number {
return providerModelRows(p, client.config.value?.models).length;
}
watch(openKey, (next, prev) => {
if (prev !== null && prev !== next) {
leavingKey.value = prev;
window.clearTimeout(leaveTimer);
// Timer instead of transitionend: reduced-motion kills the transition,
// so the event may never fire. 300ms = --duration-slow (260ms) + slack.
leaveTimer = window.setTimeout(() => {
leavingKey.value = null;
}, 300);
}
// The newly opened form starts clean.
dirty.value = false;
});
// Every path that clears dirty (save/add success, cancel, discard) must also
// retire the guard otherwise the bar outlives the state it protects and its
// "discard" button would jump to a stale pendingOpen.
watch(dirty, (next) => {
if (!next) {
guardVisible.value = false;
pendingOpen.value = null;
}
});
onUnmounted(() => {
window.clearTimeout(leaveTimer);
window.clearTimeout(flashTimer);
});
/** The add row mounts collapsed and gets its `open` class one frame later
applying it at mount would skip the 0fr1fr transition and read as a jump. */
const addOpen = ref(false);
watch(openKey, (next) => {
if (next === ADD_KEY) {
addOpen.value = false;
void nextTick(() => requestAnimationFrame(() => { addOpen.value = true; }));
} else {
addOpen.value = false;
}
});
onMounted(async () => {
loading.value = true;
try {
// All three loaders catch and toast their own failures. config must be
// fresh too: model counts and the edit form's rows derive from its
// models section, which another client may have rewritten since launch.
await Promise.all([client.loadProviders(), client.loadModels(), client.loadConfig()]);
} finally {
loading.value = false;
}
});
function tryOpen(key: string): void {
const target = openKey.value === key ? null : key;
if (dirty.value) {
pendingOpen.value = target;
guardVisible.value = true;
return;
}
openKey.value = target;
}
function onGuardStay(): void {
guardVisible.value = false;
pendingOpen.value = null;
}
function onGuardDiscard(): void {
guardVisible.value = false;
openKey.value = pendingOpen.value;
pendingOpen.value = null;
}
function flashRow(id: string): void {
flashId.value = id;
window.clearTimeout(flashTimer);
flashTimer = window.setTimeout(() => {
flashId.value = '';
}, 1200);
}
function onSaved(id: string): void {
// Renames move the row re-point the open item. No flash: the success toast
// already confirms; the flash is reserved for the add flow's row insertion.
openKey.value = id;
}
function onAdded(id: string): void {
// The add row turns into the new provider's expanded edit row in place.
openKey.value = id;
flashRow(id);
}
function onDeleted(): void {
openKey.value = null;
}
</script>
<template>
<section class="pp">
<div class="pp-head">
<h3 class="pp-title">{{ t('settings.tabs.providers') }}</h3>
<Button variant="secondary" size="sm" @click="tryOpen(ADD_KEY)">
<Icon name="plus" size="sm" />
{{ t('providers.addProvider') }}
</Button>
</div>
<div v-if="loading" class="pp-loading">
<Spinner size="sm" />
<span>{{ t('providers.loading') }}</span>
</div>
<div v-else class="pp-group">
<!-- Add form, pinned to the top of the list while open -->
<div
v-if="openKey === ADD_KEY || leavingKey === ADD_KEY"
class="pp-item pp-add-item"
:class="{ open: openKey === ADD_KEY && addOpen }"
>
<button type="button" class="pp-row pp-add-row" @click="tryOpen(ADD_KEY)">
<span class="pp-add-label">{{ t('providers.addProvider') }}</span>
<span class="grow" />
<span class="pp-chev"><Icon name="chevron-right" size="sm" /></span>
</button>
<div class="pp-acc">
<div class="pp-acc-in">
<AddProviderFlow
:guard="guardVisible && openKey === ADD_KEY"
@dirty-change="dirty = $event"
@guard-stay="onGuardStay"
@guard-discard="onGuardDiscard"
@added="onAdded"
@cancel="openKey = null"
/>
</div>
</div>
</div>
<div v-if="providers.length === 0" class="pp-empty">{{ t('providers.empty') }}</div>
<div
v-for="p in providers"
:key="p.id"
class="pp-item"
:class="{ open: openKey === p.id, flash: flashId === p.id }"
>
<button type="button" class="pp-row" @click="tryOpen(p.id)">
<div class="grow">
<span class="pp-id">{{ p.id }}</span>
<Badge variant="neutral" size="sm">{{ p.type }}</Badge>
<Badge v-if="isManagedOAuthProvider(p)" variant="info" size="sm">
{{ t('providers.managedBadge') }}
</Badge>
</div>
<span class="pp-count">{{ t('providers.modelCount', { count: modelCountFor(p) }) }}</span>
<span class="pp-chev"><Icon name="chevron-right" size="sm" /></span>
</button>
<div class="pp-acc">
<div class="pp-acc-in">
<ProviderForm
v-if="openKey === p.id || leavingKey === p.id"
mode="edit"
:provider="p"
:guard="guardVisible && openKey === p.id"
@dirty-change="dirty = $event"
@guard-stay="onGuardStay"
@guard-discard="onGuardDiscard"
@saved="onSaved"
@deleting="openKey = null"
@deleted="onDeleted"
/>
</div>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.pp { display: flex; flex-direction: column; }
.pp-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.pp-title {
margin: 0;
font-family: var(--font-ui);
font-size: var(--text-lg);
font-weight: var(--weight-medium);
color: var(--color-text);
}
.pp-loading {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-4) 0;
color: var(--color-text-muted);
font-family: var(--font-ui);
font-size: var(--text-base);
}
/* Group language mirrors SettingsDialog's .settings-group. */
.pp-group {
overflow: hidden;
border: 0.5px solid var(--color-line);
border-radius: var(--radius-xl);
background: var(--color-surface-raised);
}
/* Let an open protocol-type dropdown escape the group's clipping (same trick
as SettingsDialog's .settings-group). */
.pp-group:has(.ui-select.is-open) {
position: relative;
z-index: var(--z-dropdown);
overflow: visible;
}
.pp-group > * + * { border-top: 0.5px solid var(--color-line); }
.pp-row {
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
min-height: 40px;
padding: var(--space-2) var(--space-4);
border: none;
background: transparent;
text-align: left;
font-family: var(--font-ui);
color: var(--color-text);
cursor: pointer;
transition: background var(--duration-fast) var(--ease-out);
}
.pp-row:hover { background: var(--color-hover); }
.pp-item.open > .pp-row { background: var(--color-surface-sunken); }
.pp-row .grow {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: var(--space-2);
}
.pp-id {
font-size: var(--text-base);
font-weight: var(--weight-medium);
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pp-count {
flex: none;
font-size: var(--text-xs);
color: var(--color-text-faint);
white-space: nowrap;
}
.pp-chev {
display: inline-flex;
flex: none;
color: var(--color-text-faint);
transition: transform var(--duration-base) var(--ease-out);
}
.pp-item.open .pp-chev { transform: rotate(90deg); }
.pp-add-row {
gap: var(--space-2);
color: var(--color-text);
font-size: var(--text-base);
font-weight: var(--weight-medium);
}
/* Accordion: 0fr1fr animates open/close and auto-sizes as model rows are
added or removed. */
.pp-acc { display: grid; grid-template-rows: 0fr; transition: grid-template-rows var(--duration-slow) var(--ease-out); }
.pp-item.open > .pp-acc { grid-template-rows: 1fr; }
.pp-acc-in { overflow: hidden; min-height: 0; }
/* Once open, the content must escape the mask or the type dropdown clips. */
.pp-item.open .pp-acc-in { overflow: visible; }
.pp-item.flash > .pp-row { animation: pp-flash 1.2s var(--ease-out); }
@keyframes pp-flash {
0% { background: var(--color-accent-soft); }
100% { background: transparent; }
}
.pp-empty {
padding: var(--space-5) var(--space-4);
color: var(--color-text-faint);
font-family: var(--font-ui);
font-size: var(--text-sm);
text-align: center;
}
</style>

View file

@ -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<AppConfig>];
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 {
<PlanUsageCard v-else-if="signedIn" :on-fetch-usage="props.onFetchUsage" />
</section>
<!-- Providers (accordion management). v-if (not v-show): the panel
refetches providers/models on mount, so reopening the tab always
shows fresh data. -->
<section v-if="activeTab === 'providers'" class="panel">
<ProvidersPanel />
</section>
<!-- Agent defaults -->
<section v-show="activeTab === 'agent'" class="panel">
<section class="sec">

View file

@ -21,7 +21,6 @@ export function createAuxiliaryTranscriptPool(deps: {
const entries = shallowReactive(new Map<string, AuxiliaryTranscriptEntry>());
const desiredAgentBySession = new Map<string, string>();
const subscribedAgentBySession = new Map<string, string>();
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,

View file

@ -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<void>;
/** 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<unknown>;
}
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<void> {
/**
* 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<AppProviderDetail> {
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<string | null> {
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<void> {
/**
* 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<string | null> {
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<DeleteProviderResult | null> {
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<string | null> {
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<OAuthLoginStartResult | null> {
try {
@ -597,7 +711,12 @@ export function useModelProviderState(
toggleStarModel,
activateSkill,
addProvider,
updateProvider,
deleteProvider,
getProvider,
loadCatalogProviders,
importCatalogProvider,
importCustomRegistry,
refreshProvider,
refreshAllProviders,
startOAuthLogin,

View file

@ -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,

View file

@ -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 {

View file

@ -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

10
apps/web/src/env.d.ts vendored
View file

@ -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

View file

@ -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;

View file

@ -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<IconName, IconEntry> = {
'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<readonly [string, readonly IconName[]]>
'sliders',
'log-in',
'log-out',
'eye',
'eye-off',
],
],
[

View file

@ -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);

View file

@ -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> = {}): ModelRow {
return {
model: 'gpt-4.1',
maxContextSize: '1047576',
displayName: '',
capabilities: ['tool_use', 'thinking'],
supportEfforts: [],
adaptiveThinking: true,
...overrides,
};
}
function validForm(overrides: Partial<ProviderFormState> = {}): 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 (`<prefix>/<model>`); 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,
});
});
});

View file

@ -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 `<prefix>/<model>` 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<string, unknown> | undefined,
): ModelRow[] {
const rows: ModelRow[] = [];
for (const raw of Object.values(configModels ?? {})) {
if (raw === null || typeof raw !== 'object') continue;
const record = raw as Record<string, unknown>;
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 `<prefix>/<model>` 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';
}

View file

@ -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.

View file

@ -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.

View file

@ -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 (1648), Windows tray (white tile)
// apps/web/public/favicon.ico 5 frames (1664)
// 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');
}
// ---------------------------------------------------------------------------