Files
billowliu2 711b978240 feat: 配置独立到 ~/kimi-eyes/,向导支持目标工具选择与协议联动
配置从 ~/.kimi-code/kimi-eyes 移到 ~/kimi-eyes/config.json(KIMI_EYES_HOME 可覆盖),KimiCode/Claude Code/Codex 三工具共用;setup.mjs 向导新增目标工具选择(ClaudeCode 固定 Anthropic、Codex 固定 OpenAI、KimiCode 手动选协议)并写入 tool 字段;uninstall/SYSTEM/README/网页同步。1.0.9 → 1.0.10。
2026-08-10 10:27:53 +08:00

292 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 { fileURLToPath } from 'node:url';
import { callVLM, modelsEndpoint, ONE_PX_PNG_BASE64, modelCapabilityTag } from './mcp/vision.mjs';
// Directory that contains this script — used for the /plugins install hint.
// Works both when run locally and when run via `npx kimi-eyes setup` from the npm package.
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// ---------------------------------------------------------------------------
// 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) => {
if (lines.length === 0) {
console.error('\n[setup] 输入已结束,向导中止。');
process.exit(1);
}
return 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 chooseTool() {
console.log(' 选择目标工具:');
console.log(' [1] KimiCode(插件方式,需手动选协议)');
console.log(' [2] Claude Code(MCP 方式,协议固定 Anthropic)');
console.log(' [3] Codex(MCP 方式,协议固定 OpenAI)');
for (;;) {
const v = (await ask(' 输入序号 [1/2/3]: ')).trim();
if (v === '1') return 'kimicode';
if (v === '2') return 'claudecode';
if (v === '3') return 'codex';
console.log(' 无效输入,请输入 1、2 或 3。');
}
}
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 configPath = path.join(
process.env.KIMI_EYES_HOME || path.join(os.homedir(), 'kimi-eyes'),
'config.json'
);
console.log('=== Kimi Eyes 配置向导 ===');
console.log(`配置将写入: ${configPath}\n`);
const tool = await chooseTool();
let protocol;
if (tool === 'claudecode') {
console.log(' Claude Code 目标:协议固定为 Anthropic (Messages API)\n');
protocol = 'anthropic';
} else if (tool === 'codex') {
console.log(' Codex 目标:协议固定为 OpenAI (Chat Completions)\n');
protocol = 'openai';
} else {
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, tool };
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(` tool : ${cfg.tool}`);
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('下一步:');
if (tool === 'kimicode') {
console.log(` 1. 安装插件: /plugins install ${__dirname}`);
console.log(' (国内镜像: /plugins install https://git.codingplan.site/admin/kimi-eyes/archive/main.zip)');
console.log(' 2. 启用插件: /reload');
} else if (tool === 'claudecode') {
console.log(' 1. 挂载 MCP(全局): claude mcp add --scope user kimi-eyes -- node <插件目录>/mcp/server.mjs');
console.log(' npx 通用版: claude mcp add --scope user kimi-eyes -- npx --prefer-online -y -p kimi-eyes kimi-eyes-mcp');
console.log(' 2. 验证: claude mcp list');
} else {
console.log(' 1. 挂载 MCP: codex mcp add kimi-eyes -- node <插件目录>/mcp/server.mjs');
console.log(' npx 通用版: codex mcp add kimi-eyes -- npx --prefer-online -y -p kimi-eyes kimi-eyes-mcp');
console.log(' 2. 验证: codex mcp list');
}
console.log(' 3. 开始使用: 非多模态模型下 @图片路径 或 截图后提问');
}
main().catch((err) => {
console.error(`\n[setup] 出错: ${err.message}`);
process.exit(1);
});