kimi-code/apps/kimi-code/scripts/update-catalog.mjs
7Sageer a200a297ac
feat(kimi-code): add /connect command with bundled model catalog (#30)
* feat: add /connect command with models.dev catalog support

Add a /connect slash command that configures a provider and model
from a models.dev-style catalog. Users no longer need to hand-write
model metadata (context window, output limit, capabilities).

Architecture (3 layers):
- kosong: pure data layer — Catalog schema, inferWireType,
  catalogModelToCapability
- node-sdk: IO + config write — fetchCatalog, applyCatalogProvider,
  catalogModelToAlias
- app: TUI flow — /connect command, provider/model selection,
  credential input, config persistence

UI improvements in this PR:
- ChoicePickerComponent: add searchable (fuzzy filter + search bar)
- ModelSelectorComponent: add searchable (same)
- Extract reusable paging.ts for list pagination

Changesets included for kosong, kimi-code-sdk, and kimi-code.

* feat: bundle pruned models.dev catalog for offline /connect

When the network is unavailable, /connect now falls back to a built-in
snapshot of the models.dev catalog.

- `scripts/update-catalog.mjs`: fetches models.dev/api.json, strips
  unnecessary fields, and writes `src/built-in-catalog.ts` with the
  JSON string as a TS constant.
- `loadBuiltInCatalog(text?)` in node-sdk: parses the JSON string safely;
  returns undefined on any failure.
- `handleConnectCommand`: on fetch failure, shows an informative offline
  message and tries the built-in snapshot.
- The snapshot file is a placeholder (`undefined`) in source control;
  `update-catalog.mjs` populates it before release builds so the actual
  catalog is inlined into the bundle by rolldown.

* refactor(tui): share search and pagination across list pickers

ChoicePicker and ModelSelector each carried their own copy of the cursor +
fuzzy-search + pagination state machine. Extract it into a reusable
SearchableList so both pickers share one implementation; behavior is unchanged.

* fix(tui): filter unsupported catalog providers

* docs(tui): clarify /connect stale-alias cleanup depends on removeProvider

* fix(kimi-code): inject built-in catalog at release time

* feat(tui): hint at /login and /connect when /model has no models

Replace the bare "No models configured." error with a notice that
points users to /login for Kimi and /connect for other providers.

* fix(tui): tighten /connect error reporting for edge cases

Two silent-failure cases in /connect could leave users without any
feedback to act on:

- Reject `--url` when its value is missing (e.g. `/connect --url` or
  `/connect --url=`). Previously the argument parser silently fell
  back to the default catalog, so a malformed flag still appeared to
  succeed but with the wrong source.
- Show an explicit error when the resolved catalog yields no providers
  with supported wire types. Previously the picker resolved with no
  selection and the command returned without any UI feedback.

* chore(tui): restore slash invalid intent type

* fix(tui): support /logout for /connect-configured providers

After /connect writes a non-managed provider (e.g. openai), /logout
fell through to "Nothing to logout." because the handler only matched
the managed default and isOpenPlatformId branches, leaving users no
in-app way to drop the API key and model aliases they just configured.

Collapse the OpenPlatform branch into a generic "provider is present
in config" check so any non-managed provider in config — OpenPlatform
OAuth targets and /connect catalog providers — goes through the same
removeProvider path.

* fix(tui): reject --url values that are not http(s) URLs

`resolveConnectCatalogRequest` previously matched any non-space token
after `--url` as the URL, so `/connect --url --refresh` parsed
`--refresh` as the value and bypassed the missing-value error path.
Bare non-URL tokens (`/connect --url not-a-url`) and non-http(s)
schemes were also silently accepted.

Constrain the captured value to `https?://...` so flag-like and
non-URL tokens fall through to the existing `URL_FLAG_PRESENT_RE`
check and surface a clear error.

* ci(native): scope built-in catalog generation to signed macOS jobs

The catalog-generation step ran whenever `inputs.sign-macos` was true,
including Linux and Windows targets that take the local-profile build
path and never consume the generated catalog. A transient models.dev
outage would therefore fail unrelated artifact builds.

Match the condition to the macOS signed release-profile build that
actually consumes the bundled catalog.

* fix(ci): embed built-in catalog in non-macOS native artifacts

The earlier narrowing to `runner.os == 'macOS' && inputs.sign-macos`
relied on a misread of build.mjs: its `profile === 'release'` guard
only auto-fetches the catalog as a dev fallback. Whether the binary
actually embeds the catalog is decided by tsdown's define at bundle
time, which reads KIMI_CODE_BUILT_IN_CATALOG_FILE regardless of
profile.

Linux and Windows release artifacts therefore lost their bundled
catalog and silently regressed offline /connect on those targets.
Restore generation for all OS jobs when sign-macos is true.

* chore(tui): mention /connect in welcome panel hints

Align the welcome panel with the /model picker so the empty-state copy
points users to both /login and /connect.
2026-05-26 12:34:49 +08:00

87 lines
2.8 KiB
JavaScript

#!/usr/bin/env node
/**
* Fetches models.dev/api.json, strips fields not needed by kimi-code, and
* writes the result as raw JSON for release builds to inline.
*
* This script intentionally does not write into src/. The source tree keeps a
* placeholder so the generated catalog is not committed.
*/
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
const scriptDir = import.meta.dirname;
const outFile = resolveOutputFile(process.argv.slice(2));
const modelsUrl = process.env.MODELS_DEV_URL || "https://models.dev/api.json";
const KEEP_PROVIDER = new Set(["id", "name", "api", "env", "npm", "type", "models"]);
const KEEP_MODEL = new Set(["id", "name", "family", "limit", "tool_call", "reasoning", "modalities"]);
function resolveOutputFile(args) {
const index = args.indexOf("--out");
if (index !== -1) {
const value = args[index + 1];
if (value === undefined || value.length === 0) {
throw new Error("Missing value for --out");
}
return resolve(process.cwd(), value);
}
return resolve(scriptDir, "../dist/built-in-catalog.json");
}
function stripModel(model) {
if (typeof model !== "object" || model === null) return undefined;
const result = {};
for (const key of Object.keys(model)) {
if (KEEP_MODEL.has(key)) result[key] = model[key];
}
return result;
}
function stripProvider(provider) {
if (typeof provider !== "object" || provider === null) return undefined;
const result = {};
for (const key of Object.keys(provider)) {
if (!KEEP_PROVIDER.has(key)) continue;
const value = provider[key];
if (key === "models") {
const stripped = {};
for (const [mId, m] of Object.entries(value)) {
const s = stripModel(m);
if (s !== undefined) stripped[mId] = s;
}
if (Object.keys(stripped).length > 0) result[key] = stripped;
} else {
result[key] = value;
}
}
return result;
}
async function fetchCatalog(url) {
const res = await fetch(url, { headers: { Accept: "application/json" } });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const raw = await res.json();
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
throw new Error("invalid payload shape");
}
const stripped = {};
for (const [k, v] of Object.entries(raw)) {
const p = stripProvider(v);
if (p !== undefined && Object.keys(p).length > 0) stripped[k] = p;
}
return JSON.stringify(stripped);
}
async function main() {
console.log(`Fetching ${modelsUrl} ...`);
const json = await fetchCatalog(modelsUrl);
mkdirSync(dirname(outFile), { recursive: true });
writeFileSync(outFile, json, "utf-8");
console.log(`Wrote ${outFile} (${(json.length / 1024).toFixed(0)} KB JSON)`);
}
main().catch((error) => {
console.error(error.message);
process.exit(1);
});