refactor: modularize ds cli
This commit is contained in:
parent
ea960a523b
commit
60d7d5a598
207
bin/ds
207
bin/ds
@ -1,204 +1,17 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// DeepSeek CLI (Node.js version)
|
// DeepSeek CLI 入口:将解析和命令逻辑委托给模块化实现,便于后续扩展。
|
||||||
// Commands:
|
const path = require('path');
|
||||||
// ds k <key> 设置/替换 API Key
|
const { pathToFileURL } = require('url');
|
||||||
// ds s <prompt> 设置 system prompt
|
|
||||||
// ds v 查看当前 prompt
|
|
||||||
// ds c <text> 使用 deepseek-chat(流式)
|
|
||||||
// ds r <text> 使用 deepseek-reasoner(流式,含思考)
|
|
||||||
|
|
||||||
import fs from 'fs';
|
async function bootstrap() {
|
||||||
import os from 'os';
|
const entry = path.join(__dirname, 'ds-lib', 'main.js');
|
||||||
import path from 'path';
|
|
||||||
import { TextDecoder } from 'util';
|
|
||||||
|
|
||||||
const CONFIG_DIR = path.join(os.homedir(), '.config', 'deepseek');
|
|
||||||
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
||||||
const DEFAULT_PROMPT = '你是Deepseek人工智能助手';
|
|
||||||
|
|
||||||
function ensureConfig() {
|
|
||||||
if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
||||||
if (!fs.existsSync(CONFIG_FILE)) {
|
|
||||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify({ api_key: '', system_prompt: DEFAULT_PROMPT }, null, 2), 'utf8');
|
|
||||||
fs.chmodSync(CONFIG_FILE, 0o600);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function readConfig() {
|
|
||||||
ensureConfig();
|
|
||||||
try {
|
try {
|
||||||
const raw = fs.readFileSync(CONFIG_FILE, 'utf8');
|
const mod = await import(pathToFileURL(entry).href);
|
||||||
const data = JSON.parse(raw);
|
await mod.main(process.argv.slice(2));
|
||||||
return {
|
} catch (err) {
|
||||||
apiKey: data.api_key || '',
|
console.error('程序运行失败:', err?.message || err);
|
||||||
prompt: data.system_prompt || DEFAULT_PROMPT,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return { apiKey: '', prompt: DEFAULT_PROMPT };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveConfig(apiKey, prompt) {
|
|
||||||
ensureConfig();
|
|
||||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify({ api_key: apiKey, system_prompt: prompt }, null, 2), 'utf8');
|
|
||||||
fs.chmodSync(CONFIG_FILE, 0o600);
|
|
||||||
}
|
|
||||||
|
|
||||||
function usage() {
|
|
||||||
console.log(`用法:
|
|
||||||
ds k <key> 设置/替换 API 密钥
|
|
||||||
ds s <prompt> 设置系统提示词
|
|
||||||
ds v 查看当前提示词
|
|
||||||
ds c <text> 使用 deepseek-chat(流式)
|
|
||||||
ds r <text> 使用 deepseek-reasoner(流式,含思考)
|
|
||||||
环境优先级: 命令行 > 环境变量 DEEPSEEK_API_KEY > 配置文件
|
|
||||||
默认模型: deepseek-reasoner`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
if (args.length === 0 || ['-h', '--help'].includes(args[0])) usage();
|
|
||||||
const cmd = args.shift();
|
|
||||||
|
|
||||||
// ANSI blue for **bold**
|
|
||||||
const BLUE = '\x1b[34m';
|
|
||||||
const RESET = '\x1b[0m';
|
|
||||||
let inBold = false;
|
|
||||||
function renderChunk(text) {
|
|
||||||
let out = '';
|
|
||||||
for (let i = 0; i < text.length; i++) {
|
|
||||||
if (text[i] === '*' && text[i + 1] === '*') {
|
|
||||||
inBold = !inBold;
|
|
||||||
out += inBold ? BLUE : RESET;
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
out += text[i];
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function streamChat({ model, apiKey, prompt, userInput }) {
|
|
||||||
const body = {
|
|
||||||
model,
|
|
||||||
messages: [
|
|
||||||
{ role: 'system', content: prompt },
|
|
||||||
{ role: 'user', content: userInput },
|
|
||||||
],
|
|
||||||
stream: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await fetch('https://api.deepseek.com/chat/completions', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${apiKey}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const t = await res.text();
|
|
||||||
console.error(`请求失败 ${res.status}: ${t}`);
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = '';
|
|
||||||
let sawReason = false;
|
|
||||||
let sawContent = false;
|
|
||||||
const enableReason = model === 'deepseek-reasoner';
|
|
||||||
|
|
||||||
process.stdout.write(`发送中 (模型: ${model})...\n`);
|
|
||||||
|
|
||||||
for await (const chunk of res.body) {
|
|
||||||
buffer += decoder.decode(chunk, { stream: true });
|
|
||||||
let lines = buffer.split(/\r?\n/);
|
|
||||||
buffer = lines.pop(); // keep incomplete line
|
|
||||||
for (const line of lines) {
|
|
||||||
if (!line.startsWith('data: ')) continue;
|
|
||||||
const data = line.slice(6);
|
|
||||||
if (data === '[DONE]') {
|
|
||||||
process.stdout.write('\n');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let delta;
|
|
||||||
try {
|
|
||||||
delta = JSON.parse(data)?.choices?.[0]?.delta || {};
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const rc = delta.reasoning_content || '';
|
|
||||||
const ct = delta.content || '';
|
|
||||||
|
|
||||||
if (enableReason && rc) {
|
|
||||||
if (!sawReason) {
|
|
||||||
process.stdout.write('思考:\n');
|
|
||||||
sawReason = true;
|
|
||||||
}
|
|
||||||
process.stdout.write(renderChunk(rc));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ct) {
|
|
||||||
if (!sawContent) {
|
|
||||||
if (sawReason) process.stdout.write('\n\n');
|
|
||||||
process.stdout.write('回复:\n');
|
|
||||||
sawContent = true;
|
|
||||||
}
|
|
||||||
process.stdout.write(renderChunk(ct));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
process.stdout.write('\n');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
(async () => {
|
bootstrap();
|
||||||
switch (cmd) {
|
|
||||||
case 'k': {
|
|
||||||
if (args.length < 1) {
|
|
||||||
console.error('请输入密钥');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
const cfg = readConfig();
|
|
||||||
saveConfig(args[0], cfg.prompt);
|
|
||||||
console.log(`API 密钥已保存到 ${CONFIG_FILE}`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 's': {
|
|
||||||
if (args.length < 1) {
|
|
||||||
console.error('请输入新的 system prompt');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
const cfg = readConfig();
|
|
||||||
const prompt = args.join(' ');
|
|
||||||
saveConfig(cfg.apiKey, prompt);
|
|
||||||
console.log('system prompt 已更新。');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'v': {
|
|
||||||
const cfg = readConfig();
|
|
||||||
console.log('当前 system prompt:');
|
|
||||||
console.log(cfg.prompt);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'c':
|
|
||||||
case 'r': {
|
|
||||||
if (args.length < 1) {
|
|
||||||
console.error('请输入要发送的内容');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
const userInput = args.join(' ');
|
|
||||||
const cfg = readConfig();
|
|
||||||
const apiKey = process.env.DEEPSEEK_API_KEY || cfg.apiKey;
|
|
||||||
if (!apiKey) {
|
|
||||||
console.error('未设置 API 密钥,请先运行: ds k <key> 或设置环境变量 DEEPSEEK_API_KEY');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
const model = cmd === 'c' ? 'deepseek-chat' : 'deepseek-reasoner';
|
|
||||||
await streamChat({ model, apiKey, prompt: cfg.prompt, userInput });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
usage();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|||||||
90
bin/ds-lib/api.js
Normal file
90
bin/ds-lib/api.js
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { TextDecoder } from 'util';
|
||||||
|
import { renderChunk } from './output.js';
|
||||||
|
|
||||||
|
const API_URL = 'https://api.deepseek.com/chat/completions';
|
||||||
|
|
||||||
|
export async function streamChat({ model, apiKey, prompt, userInput }) {
|
||||||
|
const body = {
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: prompt },
|
||||||
|
{ role: 'user', content: userInput },
|
||||||
|
],
|
||||||
|
stream: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch(API_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const t = await res.text().catch(() => '');
|
||||||
|
throw new Error(`请求失败 ${res.status}: ${t}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write(`发送中 (模型: ${model})...\n`);
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
let buffer = '';
|
||||||
|
let sawReason = false;
|
||||||
|
let sawContent = false;
|
||||||
|
const enableReason = model === 'deepseek-reasoner';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split(/\r?\n/);
|
||||||
|
buffer = lines.pop(); // keep incomplete line
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.startsWith('data: ')) continue;
|
||||||
|
const data = line.slice(6);
|
||||||
|
if (data === '[DONE]') {
|
||||||
|
process.stdout.write('\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let delta;
|
||||||
|
try {
|
||||||
|
delta = JSON.parse(data)?.choices?.[0]?.delta || {};
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rc = delta.reasoning_content || '';
|
||||||
|
const ct = delta.content || '';
|
||||||
|
|
||||||
|
if (enableReason && rc) {
|
||||||
|
if (!sawReason) {
|
||||||
|
process.stdout.write('思考:\n');
|
||||||
|
sawReason = true;
|
||||||
|
}
|
||||||
|
process.stdout.write(renderChunk(rc));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ct) {
|
||||||
|
if (!sawContent) {
|
||||||
|
if (sawReason) process.stdout.write('\n\n');
|
||||||
|
process.stdout.write('回复:\n');
|
||||||
|
sawContent = true;
|
||||||
|
}
|
||||||
|
process.stdout.write(renderChunk(ct));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// flush possible remaining buffered text (unlikely but safe)
|
||||||
|
if (buffer.trim()) {
|
||||||
|
try {
|
||||||
|
const delta = JSON.parse(buffer)?.choices?.[0]?.delta || {};
|
||||||
|
const ct = delta.content || '';
|
||||||
|
if (ct) process.stdout.write(ct);
|
||||||
|
} catch {
|
||||||
|
// ignore malformed tail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process.stdout.write('\n');
|
||||||
|
}
|
||||||
56
bin/ds-lib/commands.js
Normal file
56
bin/ds-lib/commands.js
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { readConfig, saveConfig, resolveApiKey, DEFAULT_PROMPT } from './config.js';
|
||||||
|
import { streamChat } from './api.js';
|
||||||
|
import { printInfo, printError } from './output.js';
|
||||||
|
|
||||||
|
export async function setKey(args) {
|
||||||
|
if (args.length < 1) {
|
||||||
|
printError('请输入密钥');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const cfg = readConfig();
|
||||||
|
saveConfig({ apiKey: args[0], prompt: cfg.prompt });
|
||||||
|
printInfo('API 密钥已保存。');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setPrompt(args) {
|
||||||
|
if (args.length < 1) {
|
||||||
|
printError('请输入新的 system prompt');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const cfg = readConfig();
|
||||||
|
const prompt = args.join(' ');
|
||||||
|
saveConfig({ apiKey: cfg.apiKey, prompt });
|
||||||
|
printInfo('system prompt 已更新。');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function viewPrompt() {
|
||||||
|
const cfg = readConfig();
|
||||||
|
printInfo('当前 system prompt:');
|
||||||
|
printInfo(cfg.prompt || DEFAULT_PROMPT);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runChat(model, args) {
|
||||||
|
if (args.length < 1) {
|
||||||
|
printError('请输入要发送的内容');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const userInput = args.join(' ');
|
||||||
|
const cfg = readConfig();
|
||||||
|
const { apiKey, source } = resolveApiKey(cfg);
|
||||||
|
if (!apiKey) {
|
||||||
|
printError('未设置 API 密钥,请先运行: ds k <key> 或设置环境变量 DEEPSEEK_API_KEY');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (source === 'env') {
|
||||||
|
printInfo('使用环境变量 DEEPSEEK_API_KEY');
|
||||||
|
}
|
||||||
|
await streamChat({ model, apiKey, prompt: cfg.prompt, userInput });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function chat(args) {
|
||||||
|
await runChat('deepseek-chat', args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reason(args) {
|
||||||
|
await runChat('deepseek-reasoner', args);
|
||||||
|
}
|
||||||
53
bin/ds-lib/config.js
Normal file
53
bin/ds-lib/config.js
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export const CONFIG_DIR = path.join(os.homedir(), '.config', 'deepseek');
|
||||||
|
export const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
||||||
|
export const DEFAULT_PROMPT = '你是Deepseek人工智能助手';
|
||||||
|
|
||||||
|
function createDefaultConfig() {
|
||||||
|
return { api_key: '', system_prompt: DEFAULT_PROMPT };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureConfigFile() {
|
||||||
|
if (!fs.existsSync(CONFIG_DIR)) {
|
||||||
|
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(CONFIG_FILE)) {
|
||||||
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(createDefaultConfig(), null, 2), 'utf8');
|
||||||
|
fs.chmodSync(CONFIG_FILE, 0o600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readConfig() {
|
||||||
|
ensureConfigFile();
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(CONFIG_FILE, 'utf8');
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
return {
|
||||||
|
apiKey: data.api_key || '',
|
||||||
|
prompt: data.system_prompt || DEFAULT_PROMPT,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { apiKey: '', prompt: DEFAULT_PROMPT };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveConfig({ apiKey, prompt }) {
|
||||||
|
ensureConfigFile();
|
||||||
|
const data = { api_key: apiKey ?? '', system_prompt: prompt ?? DEFAULT_PROMPT };
|
||||||
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2), 'utf8');
|
||||||
|
fs.chmodSync(CONFIG_FILE, 0o600);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveApiKey(config) {
|
||||||
|
const envKey = process.env.DEEPSEEK_API_KEY;
|
||||||
|
if (envKey && envKey.trim()) {
|
||||||
|
return { apiKey: envKey.trim(), source: 'env' };
|
||||||
|
}
|
||||||
|
if (config.apiKey && config.apiKey.trim()) {
|
||||||
|
return { apiKey: config.apiKey.trim(), source: 'file' };
|
||||||
|
}
|
||||||
|
return { apiKey: '', source: 'none' };
|
||||||
|
}
|
||||||
48
bin/ds-lib/main.js
Normal file
48
bin/ds-lib/main.js
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { chat, reason, setKey, setPrompt, viewPrompt } from './commands.js';
|
||||||
|
import { printInfo } from './output.js';
|
||||||
|
|
||||||
|
function usage(exit = true) {
|
||||||
|
printInfo(`用法:
|
||||||
|
ds k <key> 设置/替换 API 密钥
|
||||||
|
ds s <prompt> 设置系统提示词
|
||||||
|
ds v 查看当前提示词
|
||||||
|
ds c <text> 使用 deepseek-chat(流式)
|
||||||
|
ds r <text> 使用 deepseek-reasoner(流式,含思考)
|
||||||
|
环境优先级: 命令行 > 环境变量 DEEPSEEK_API_KEY > 配置文件
|
||||||
|
默认模型: deepseek-reasoner`);
|
||||||
|
if (exit) process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function main(argv) {
|
||||||
|
if (!argv || argv.length === 0 || ['-h', '--help'].includes(argv[0])) {
|
||||||
|
usage(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [cmd, ...rest] = argv;
|
||||||
|
|
||||||
|
switch (cmd) {
|
||||||
|
case 'k':
|
||||||
|
await setKey(rest);
|
||||||
|
break;
|
||||||
|
case 's':
|
||||||
|
await setPrompt(rest);
|
||||||
|
break;
|
||||||
|
case 'v':
|
||||||
|
viewPrompt();
|
||||||
|
break;
|
||||||
|
case 'c':
|
||||||
|
await chat(rest);
|
||||||
|
break;
|
||||||
|
case 'r':
|
||||||
|
await reason(rest);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
usage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若作为独立模块直接调用
|
||||||
|
if (process.argv[1] === new URL(import.meta.url).pathname) {
|
||||||
|
main(process.argv.slice(2));
|
||||||
|
}
|
||||||
31
bin/ds-lib/output.js
Normal file
31
bin/ds-lib/output.js
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
const BLUE = '\x1b[34m';
|
||||||
|
const RESET = '\x1b[0m';
|
||||||
|
|
||||||
|
let inBold = false;
|
||||||
|
|
||||||
|
// Render **bold** blocks using ANSI blue, keep other characters unchanged.
|
||||||
|
export function renderChunk(text) {
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
if (text[i] === '*' && text[i + 1] === '*') {
|
||||||
|
inBold = !inBold;
|
||||||
|
out += inBold ? BLUE : RESET;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out += text[i];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function printHeading(label) {
|
||||||
|
process.stdout.write(`${label}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function printError(msg) {
|
||||||
|
console.error(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function printInfo(msg) {
|
||||||
|
console.log(msg);
|
||||||
|
}
|
||||||
3
bin/ds-lib/package.json
Normal file
3
bin/ds-lib/package.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user