初始化 kimi-eyes:给 KimiCode 非多模态模型补上视觉能力

This commit is contained in:
billowliu2 committed 2026-08-02 01:24:28 +08:00
commit ed8f7ae977
12 files changed
+1510

No files matched your search

+241
View File
@@ -0,0 +1,241 @@
#!/usr/bin/env node
// kimi-eyes setup wizard — interactive one-time configuration.
// Zero-dependency: raw-mode stdin input + native fetch.
//
// Flow: choose protocol → base URL → API key → pick multimodal model → verify → write config.
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { callVLM, modelsEndpoint, ONE_PX_PNG_BASE64, modelCapabilityTag } from './mcp/vision.mjs';
// ---------------------------------------------------------------------------
// Raw-mode line input (supports masked echo)
// ---------------------------------------------------------------------------
let pipeLines = null;
function ensurePipeLines() {
if (!pipeLines) {
pipeLines = new Promise((resolve, reject) => {
let s = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (c) => (s += c));
process.stdin.on('end', () => resolve(s.split(/\r?\n/)));
process.stdin.on('error', reject);
process.stdin.resume();
});
}
return pipeLines;
}
function ask(query, { hidden = false } = {}) {
const stdin = process.stdin;
if (!stdin.isTTY) {
// Non-TTY fallback (piped input in tests/CI): plain line read, no masking.
process.stdout.write(query);
return ensurePipeLines().then((lines) => lines.shift() ?? '');
}
process.stdout.write(query);
stdin.setRawMode(true);
stdin.resume();
return new Promise((resolve) => {
let val = '';
let esc = false;
const cleanup = () => {
stdin.setRawMode(false);
stdin.removeListener('data', onData);
process.stdout.write('\n');
};
const onData = (chunk) => {
for (const ch of chunk.toString('utf8')) {
if (esc) {
if ((ch >= 'A' && ch <= 'D') || ch === '~') esc = false;
continue;
}
if (ch === '\u001b') {
esc = true;
continue;
}
if (ch === '\r' || ch === '\n') {
cleanup();
resolve(val);
return;
}
if (ch === '\u0003') process.exit(130);
if (ch === '\u007f' || ch === '\b') {
if (val.length > 0) {
val = val.slice(0, -1);
process.stdout.write('\b \b');
}
continue;
}
val += ch;
process.stdout.write(hidden ? '*' : ch);
}
};
stdin.on('data', onData);
});
}
async function askRequired(query, { hidden = false, validate = null } = {}) {
for (;;) {
const v = (await ask(query, { hidden })).trim();
if (!v) {
console.log(' 输入不能为空,请重试。');
continue;
}
if (validate) {
const err = validate(v);
if (err) {
console.log(` ${err}`);
continue;
}
}
return v;
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const VISION_HINTS =
/(vision|visual|vlm|multimodal|4o|omni|glm-4v|qwen-vl|qwen2.*?vl|internvl|minicpm|gpt-4v|gpt-5|claude-3|claude-sonnet|claude-opus|gemini)/i;
async function fetchModels(protocol, baseUrl, apiKey) {
const url = modelsEndpoint(baseUrl, protocol);
const headers =
protocol === 'anthropic'
? { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' }
: { Authorization: `Bearer ${apiKey}` };
const res = await fetch(url, { headers, signal: AbortSignal.timeout(15000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const list = Array.isArray(data?.data)
? data.data.map((m) => m.id || m.name).filter((x) => typeof x === 'string' && x)
: [];
if (list.length === 0) throw new Error('接口未返回任何模型');
return [...new Set(list)];
}
async function chooseProtocol() {
console.log(' 选择视觉 API 协议:');
console.log(' [1] OpenAI 兼容 (Chat Completions)');
console.log(' [2] Anthropic (Messages API)');
for (;;) {
const v = (await ask(' 输入序号 [1/2]: ')).trim();
if (v === '1') return 'openai';
if (v === '2') return 'anthropic';
console.log(' 无效输入,请输入 1 或 2。');
}
}
async function chooseModel(protocol, baseUrl, apiKey) {
console.log(' 拉取模型列表 ...');
let models = null;
try {
models = await fetchModels(protocol, baseUrl, apiKey);
} catch (err) {
console.log(` 拉取模型列表失败 (${err.message}),将改为手动输入模型名。`);
}
if (models) {
const shown = models.slice(0, 50);
console.log(
` 可用模型 (✓视觉=支持识图 / 文本=纯文本 / ★疑似=未收录按关键词猜测): ` +
`${models.length > 50 ? `显示前 50 / 共 ${models.length}` : ''}`
);
shown.forEach((m, i) => {
const tag = modelCapabilityTag(m, VISION_HINTS);
console.log(` [${String(i + 1).padStart(3)}] ${m}${tag ? ` ${tag}` : ''}`);
});
console.log(' 输入序号选择模型,或直接输入自定义模型名。');
for (;;) {
const v = (await ask(' 模型: ')).trim();
if (v === '') {
console.log(' 输入不能为空。');
continue;
}
const idx = Number.parseInt(v, 10);
if (!Number.isNaN(idx) && idx >= 1 && idx <= shown.length) return shown[idx - 1];
return v; // treat as custom model name
}
}
return askRequired(' 模型名: ');
}
async function verifyModel(cfg) {
console.log(' 视觉验证:发送 1×1 测试图到模型 ...');
try {
const text = await callVLM(
{ imageBase64: ONE_PX_PNG_BASE64, mediaType: 'image/png', prompt: 'Reply with the single word OK.' },
cfg
);
console.log(` ✓ 通过,模型可接收图片 (返回: ${text.slice(0, 60)})`);
return true;
} catch (err) {
console.log(` ✗ 失败: ${err.message.slice(0, 200)}`);
return false;
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const home = process.env.KIMI_CODE_HOME || path.join(os.homedir(), '.kimi-code');
const configPath = path.join(home, 'kimi-eyes', 'config.json');
console.log('=== Kimi Eyes 配置向导 ===');
console.log(`配置将写入: ${configPath}\n`);
const protocol = await chooseProtocol();
const baseUrl = await askRequired(' 视觉 API BaseUrl (如 https://api.openai.com/v1): ', {
validate: (v) => (/^https?:\/\//i.test(v) ? null : 'BaseUrl 必须以 http:// 或 https:// 开头。'),
});
const apiKey = await askRequired(' API Key: ', { hidden: true });
const model = await chooseModel(protocol, baseUrl, apiKey);
const cfg = { protocol, baseUrl, apiKey, model };
console.log('');
for (let attempt = 0; attempt < 3; attempt++) {
if (await verifyModel(cfg)) break;
if (attempt === 2) {
console.log(' 连续验证失败,跳过验证(配置仍将写入)。');
break;
}
const act = (await ask(' [r] 重试 [c] 换模型 [s] 跳过验证: ')).trim().toLowerCase();
if (act === 'c') {
cfg.model = await chooseModel(protocol, baseUrl, apiKey);
continue;
}
if (act === 's') break;
// 'r' or anything else → retry the loop
}
const mainModel = (await ask(' 当前主模型名(可选,用于触发判断;留空跳过): ')).trim();
if (mainModel) cfg.mainModel = mainModel;
const dir = path.dirname(configPath);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(configPath, `${JSON.stringify(cfg, null, 2)}\n`, 'utf8');
if (process.platform !== 'win32') fs.chmodSync(configPath, 0o600);
console.log('');
console.log(`✓ 配置已写入: ${configPath}`);
console.log(` protocol: ${cfg.protocol}`);
console.log(` baseUrl : ${cfg.baseUrl}`);
console.log(` model : ${cfg.model}`);
if (cfg.mainModel) console.log(` mainModel: ${cfg.mainModel} (触发判断用)`);
console.log('');
console.log('下一步:');
console.log(` 1. 安装插件: /plugins install ${process.cwd()}`);
console.log(' 2. 启用插件: /reload');
console.log(' 3. 开始使用: 非多模态模型下 @图片路径 或 截图后提问');
}
main().catch((err) => {
console.error(`\n[setup] 出错: ${err.message}`);
process.exit(1);
});