dsh-linux-commander 0.0.0-stage → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +100 -2
- package/build-client.mjs +59 -0
- package/client.js +427 -0
- package/cordis.patch.yml +12 -0
- package/icon.svg +6 -0
- package/index.js +82 -0
- package/locale/en.json +21 -0
- package/locale/zh.json +21 -0
- package/package.json +41 -3
- package/src/ai.js +190 -0
- package/src/client/widget.js +393 -0
- package/src/commands.js +349 -0
- package/src/core.js +244 -0
- package/src/nl.js +268 -0
package/index.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Linux 指令器 — Host half.
|
|
2
|
+
// Registers one loopback HTTP route that the browser widget calls.
|
|
3
|
+
// All AI traffic (and the API key) stays in this host process; the client
|
|
4
|
+
// bundle contains no credentials. Local library answers never touch AI.
|
|
5
|
+
import { createPipeline } from './src/core.js';
|
|
6
|
+
import { createAiAdapter } from './src/ai.js';
|
|
7
|
+
|
|
8
|
+
const ROUTE = '/linux-commander/query';
|
|
9
|
+
const BODY_LIMIT = 16 * 1024;
|
|
10
|
+
|
|
11
|
+
export const inject = ['webServer'];
|
|
12
|
+
|
|
13
|
+
function sendJson(res, status, value) {
|
|
14
|
+
const body = JSON.stringify(value);
|
|
15
|
+
res.writeHead(status, {
|
|
16
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
17
|
+
'Cache-Control': 'no-store',
|
|
18
|
+
'X-Content-Type-Options': 'nosniff',
|
|
19
|
+
});
|
|
20
|
+
res.end(body);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readBody(req) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
let size = 0;
|
|
26
|
+
const chunks = [];
|
|
27
|
+
req.on('data', (chunk) => {
|
|
28
|
+
size += chunk.length;
|
|
29
|
+
if (size > BODY_LIMIT) {
|
|
30
|
+
reject(Object.assign(new Error('body too large'), { statusCode: 413 }));
|
|
31
|
+
req.destroy();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
chunks.push(chunk);
|
|
35
|
+
});
|
|
36
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
37
|
+
req.on('error', reject);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function apply(ctx, config = {}) {
|
|
42
|
+
const ai = createAiAdapter(config, ctx.logger);
|
|
43
|
+
const run = createPipeline({ ai });
|
|
44
|
+
|
|
45
|
+
ctx.effect(() => {
|
|
46
|
+
const dispose = ctx.webServer.register({
|
|
47
|
+
kind: 'exact',
|
|
48
|
+
path: ROUTE,
|
|
49
|
+
handler: async (req, res) => {
|
|
50
|
+
try {
|
|
51
|
+
if (req.method === 'GET') {
|
|
52
|
+
sendJson(res, 200, { ok: true, service: 'linux-commander', aiConfigured: ai.configured });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (req.method !== 'POST') {
|
|
56
|
+
sendJson(res, 405, { ok: false, code: 'method', message: '仅支持 POST' });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const type = String(req.headers['content-type'] || '');
|
|
60
|
+
if (!type.includes('application/json')) {
|
|
61
|
+
sendJson(res, 415, { ok: false, code: 'content_type', message: '需要 application/json' });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
let payload;
|
|
65
|
+
try {
|
|
66
|
+
payload = JSON.parse(await readBody(req));
|
|
67
|
+
} catch (err) {
|
|
68
|
+
sendJson(res, err.statusCode || 400, { ok: false, code: 'bad_request', message: '请求体无效' });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const result = await run(payload?.input);
|
|
72
|
+
sendJson(res, 200, result);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
ctx.logger?.warn?.(err instanceof Error ? err : new Error(String(err)));
|
|
75
|
+
if (!res.headersSent) sendJson(res, 500, { ok: false, code: 'internal', message: '服务内部错误' });
|
|
76
|
+
else res.end();
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
return dispose;
|
|
81
|
+
}, 'linux-commander.route');
|
|
82
|
+
}
|
package/locale/en.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "Linux commands",
|
|
4
|
+
"description": "A lightweight docked Linux tool: explains commands and turns plain language into commands, local-first with AI fallback."
|
|
5
|
+
},
|
|
6
|
+
"ui": {
|
|
7
|
+
"title": "Linux commands",
|
|
8
|
+
"placeholder": "Command or task",
|
|
9
|
+
"open": "Open Linux commands",
|
|
10
|
+
"close": "Collapse",
|
|
11
|
+
"copy": "Copy",
|
|
12
|
+
"copied": "Copied",
|
|
13
|
+
"copyFailed": "Copy failed",
|
|
14
|
+
"loading": "Searching",
|
|
15
|
+
"sourceLocal": "local",
|
|
16
|
+
"sourceAi": "AI",
|
|
17
|
+
"errorGeneric": "Query failed, please retry",
|
|
18
|
+
"errorNetwork": "Cannot reach the local service",
|
|
19
|
+
"emptyInput": "Type something first"
|
|
20
|
+
}
|
|
21
|
+
}
|
package/locale/zh.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "Linux 指令器",
|
|
4
|
+
"description": "侧边停靠的轻量 Linux 指令工具:解释指令、把自然语言转成指令,本地优先、AI 兜底。"
|
|
5
|
+
},
|
|
6
|
+
"ui": {
|
|
7
|
+
"title": "Linux 指令器",
|
|
8
|
+
"placeholder": "输入指令或操作",
|
|
9
|
+
"open": "打开 Linux 指令器",
|
|
10
|
+
"close": "收起",
|
|
11
|
+
"copy": "复制",
|
|
12
|
+
"copied": "已复制",
|
|
13
|
+
"copyFailed": "复制失败",
|
|
14
|
+
"loading": "查询中",
|
|
15
|
+
"sourceLocal": "本地",
|
|
16
|
+
"sourceAi": "AI",
|
|
17
|
+
"errorGeneric": "查询失败,请稍后重试",
|
|
18
|
+
"errorNetwork": "无法连接本地服务",
|
|
19
|
+
"emptyInput": "请输入内容"
|
|
20
|
+
}
|
|
21
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-linux-commander",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "DSH plugin: a docked Linux command explainer and natural-language-to-command tool, local-first with AI fallback.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "yichenbr1888-svg",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/yichenbr1888-svg/linux-commander.git"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"dsh",
|
|
14
|
+
"dsh-plugin",
|
|
15
|
+
"deepseek-harness",
|
|
16
|
+
"linux",
|
|
17
|
+
"command",
|
|
18
|
+
"cheatsheet"
|
|
19
|
+
],
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./index.js",
|
|
22
|
+
"./client": "./client.js",
|
|
23
|
+
"./locale/*.json": "./locale/*.json",
|
|
24
|
+
"./icon": "./icon.svg"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"index.js",
|
|
28
|
+
"client.js",
|
|
29
|
+
"cordis.patch.yml",
|
|
30
|
+
"src/*.js",
|
|
31
|
+
"src/client/*.js",
|
|
32
|
+
"locale/*.json",
|
|
33
|
+
"icon.svg",
|
|
34
|
+
"build-client.mjs",
|
|
35
|
+
"README.md"
|
|
36
|
+
],
|
|
37
|
+
"dsh": {
|
|
38
|
+
"bundle": { "patch": "./cordis.patch.yml" },
|
|
39
|
+
"client": {
|
|
40
|
+
"platform": "web",
|
|
41
|
+
"immediately": true
|
|
42
|
+
}
|
|
43
|
+
}
|
|
6
44
|
}
|
package/src/ai.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Linux 指令器 — AI fallback adapter (host-side only).
|
|
2
|
+
// Talks to any OpenAI-compatible chat completions endpoint. Endpoint, model
|
|
3
|
+
// and key are supplied per machine via plugin config or environment
|
|
4
|
+
// variables; no vendor-specific values ship with the package. Includes
|
|
5
|
+
// timeout control, tolerant response parsing, an LRU result cache, and
|
|
6
|
+
// in-flight dedupe.
|
|
7
|
+
|
|
8
|
+
const DEFAULT_MODEL = '';
|
|
9
|
+
// Command knowledge is stable; a long TTL makes repeat queries near-free.
|
|
10
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
11
|
+
const CACHE_MAX = 500;
|
|
12
|
+
|
|
13
|
+
const SYSTEM_PROMPT = [
|
|
14
|
+
'你是一个 Linux 指令助手,只输出一行 JSON,不要输出任何其他文字、解释或 Markdown 代码块。',
|
|
15
|
+
'JSON 格式:{"kind":"explain"或"generate","command":"...","explanation":"...","warning":"..."}',
|
|
16
|
+
'规则:',
|
|
17
|
+
'1. kind=explain 时,command 原样返回用户输入的指令,explanation 用通俗简体中文解释这条指令的作用(含参数与管道),不超过 60 字。',
|
|
18
|
+
'2. kind=generate 时,把用户的操作需求转成一条最常用、兼容性好的 Linux 指令放入 command(单行,可直接复制);explanation 是一句不超过 30 字的简短说明,可为空字符串。',
|
|
19
|
+
'3. 缺少路径、用户名、端口等参数时,使用 <路径> <文件> <用户名> <端口> 这类清晰占位符。',
|
|
20
|
+
'4. 指令有实际风险(删除、覆盖、强制、关机等)时,warning 给出不超过 30 字的简短中文提醒,否则 warning 为空字符串。',
|
|
21
|
+
'5. 考虑发行版差异,优先 GNU/Linux 通用命令;不确定时如实说明,绝不编造指令或参数。',
|
|
22
|
+
'6. 绝不执行任何命令,只输出文本。',
|
|
23
|
+
].join('\n');
|
|
24
|
+
|
|
25
|
+
function userPrompt(input, intent) {
|
|
26
|
+
const task = intent === 'command'
|
|
27
|
+
? '请解释这条 Linux 指令(kind=explain)。'
|
|
28
|
+
: intent === 'natural'
|
|
29
|
+
? '请把这句操作需求转成一条 Linux 指令(kind=generate)。'
|
|
30
|
+
: '请先判断输入是 Linux 指令还是操作需求:指令则解释(kind=explain),需求则生成指令(kind=generate)。';
|
|
31
|
+
return `${task}\n输入:${input}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Pull assistant text out of OpenAI-style and common vendor response shapes. */
|
|
35
|
+
function extractContent(data) {
|
|
36
|
+
if (!data || typeof data !== 'object') return null;
|
|
37
|
+
if (data.header && typeof data.header.code === 'number' && data.header.code !== 0) {
|
|
38
|
+
const err = new Error(data.header.message || `AI 服务错误 ${data.header.code}`);
|
|
39
|
+
err.aiCode = 'ai_service';
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
const candidates = [
|
|
43
|
+
data.choices?.[0]?.message?.content,
|
|
44
|
+
data.choices?.[0]?.text,
|
|
45
|
+
data.payload?.choices?.text?.[0]?.content,
|
|
46
|
+
data.output_text,
|
|
47
|
+
data.response,
|
|
48
|
+
data.result,
|
|
49
|
+
data.answer,
|
|
50
|
+
data.content,
|
|
51
|
+
data.message?.content,
|
|
52
|
+
];
|
|
53
|
+
for (const c of candidates) if (typeof c === 'string' && c.trim()) return c;
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Parse the model's JSON answer; fall back to tolerant plain-text reads. */
|
|
58
|
+
function parseAnswer(content, input, intent) {
|
|
59
|
+
let text = content.trim();
|
|
60
|
+
const fence = /```(?:json)?\s*([\s\S]*?)```/i.exec(text);
|
|
61
|
+
if (fence) text = fence[1].trim();
|
|
62
|
+
const start = text.indexOf('{');
|
|
63
|
+
const end = text.lastIndexOf('}');
|
|
64
|
+
if (start !== -1 && end > start) {
|
|
65
|
+
try {
|
|
66
|
+
const obj = JSON.parse(text.slice(start, end + 1));
|
|
67
|
+
const kind = obj.kind === 'generate' ? 'generate' : obj.kind === 'explain' ? 'explain' : null;
|
|
68
|
+
if (kind) {
|
|
69
|
+
let command = typeof obj.command === 'string' ? obj.command.replace(/[\r\n]+/g, ' ').trim() : '';
|
|
70
|
+
let explanation = typeof obj.explanation === 'string' ? obj.explanation.replace(/\s+/g, ' ').trim() : '';
|
|
71
|
+
let warning = typeof obj.warning === 'string' ? obj.warning.replace(/\s+/g, ' ').trim() : '';
|
|
72
|
+
if (kind === 'explain' && !command) command = input;
|
|
73
|
+
if (kind === 'generate' && !command) return null;
|
|
74
|
+
if (command.length > 400) return null;
|
|
75
|
+
return {
|
|
76
|
+
kind,
|
|
77
|
+
command,
|
|
78
|
+
explanation: explanation.slice(0, 160),
|
|
79
|
+
...(warning ? { warning: warning.slice(0, 120) } : {}),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
} catch { /* fall through to plain-text fallback */ }
|
|
83
|
+
}
|
|
84
|
+
// Plain-text fallback: a bare command line, or a short Chinese explanation.
|
|
85
|
+
const oneLine = text.replace(/[\r\n]+/g, ' ').trim();
|
|
86
|
+
const looksCommand = /^[A-Za-z_./~][A-Za-z0-9_./~+-]*(\s|$)/.test(oneLine) && !/[\u4e00-\u9fff]/.test(oneLine);
|
|
87
|
+
if ((intent === 'natural' || intent === 'auto') && looksCommand && oneLine.length <= 400) {
|
|
88
|
+
return { kind: 'generate', command: oneLine, explanation: '' };
|
|
89
|
+
}
|
|
90
|
+
if ((intent === 'command' || intent === 'auto') && /[\u4e00-\u9fff]/.test(oneLine) && oneLine.length <= 200) {
|
|
91
|
+
return { kind: 'explain', command: input, explanation: oneLine };
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function createAiAdapter(config = {}, logger) {
|
|
97
|
+
const endpoint = process.env.DSH_LINUX_COMMANDER_ENDPOINT
|
|
98
|
+
|| (typeof config.endpoint === 'string' ? config.endpoint.trim() : '');
|
|
99
|
+
const model = process.env.DSH_LINUX_COMMANDER_MODEL
|
|
100
|
+
|| (typeof config.model === 'string' && config.model.trim() ? config.model.trim() : DEFAULT_MODEL);
|
|
101
|
+
const apiKey = process.env.DSH_LINUX_COMMANDER_API_KEY || (typeof config.apiKey === 'string' ? config.apiKey.trim() : '');
|
|
102
|
+
// Reasoning models may take 20-30s; allow a generous budget.
|
|
103
|
+
const timeoutMs = Math.min(120000, Math.max(3000, Number(config.timeoutMs) || 45000));
|
|
104
|
+
const cache = new Map();
|
|
105
|
+
const inflight = new Map();
|
|
106
|
+
|
|
107
|
+
const cacheGet = (key) => {
|
|
108
|
+
const hit = cache.get(key);
|
|
109
|
+
if (!hit) return undefined;
|
|
110
|
+
if (Date.now() - hit.t > CACHE_TTL_MS) { cache.delete(key); return undefined; }
|
|
111
|
+
return hit.value;
|
|
112
|
+
};
|
|
113
|
+
const cacheSet = (key, value) => {
|
|
114
|
+
cache.set(key, { t: Date.now(), value });
|
|
115
|
+
if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
async function request(input, intent) {
|
|
119
|
+
const body = {
|
|
120
|
+
model,
|
|
121
|
+
messages: [
|
|
122
|
+
{ role: 'system', content: SYSTEM_PROMPT },
|
|
123
|
+
{ role: 'user', content: userPrompt(input, intent) },
|
|
124
|
+
],
|
|
125
|
+
stream: false,
|
|
126
|
+
max_completion_tokens: 3000,
|
|
127
|
+
// Deep thinking is permanently OFF: this tool only answers simple
|
|
128
|
+
// command questions; thinking adds seconds of latency for no benefit.
|
|
129
|
+
thinking: { type: 'disabled' },
|
|
130
|
+
};
|
|
131
|
+
let res;
|
|
132
|
+
try {
|
|
133
|
+
res = await fetch(endpoint, {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
|
136
|
+
body: JSON.stringify(body),
|
|
137
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
138
|
+
});
|
|
139
|
+
} catch (err) {
|
|
140
|
+
if (err && (err.name === 'TimeoutError' || err.name === 'AbortError')) {
|
|
141
|
+
return { ok: false, code: 'timeout', message: 'AI 响应超时,请稍后重试' };
|
|
142
|
+
}
|
|
143
|
+
return { ok: false, code: 'network', message: 'AI 网络请求失败' };
|
|
144
|
+
}
|
|
145
|
+
let data = null;
|
|
146
|
+
try { data = await res.json(); } catch { /* non-JSON body */ }
|
|
147
|
+
if (!res.ok) {
|
|
148
|
+
const detail = data?.error?.message || data?.message || data?.header?.message || '';
|
|
149
|
+
if (res.status === 401 || res.status === 403) return { ok: false, code: 'auth', message: 'AI 接口鉴权失败,请检查 API Key' };
|
|
150
|
+
if (res.status === 429) return { ok: false, code: 'rate', message: 'AI 请求过于频繁,请稍后重试' };
|
|
151
|
+
return { ok: false, code: 'ai_http', message: `AI 接口错误(${res.status})${detail ? ':' + String(detail).slice(0, 60) : ''}` };
|
|
152
|
+
}
|
|
153
|
+
let content;
|
|
154
|
+
try { content = extractContent(data); } catch (err) {
|
|
155
|
+
return { ok: false, code: err.aiCode || 'ai_error', message: String(err.message || 'AI 服务错误').slice(0, 80) };
|
|
156
|
+
}
|
|
157
|
+
if (!content) {
|
|
158
|
+
if (data?.choices?.[0]?.finish_reason === 'length') {
|
|
159
|
+
return { ok: false, code: 'truncated', message: 'AI 响应被截断,请重试' };
|
|
160
|
+
}
|
|
161
|
+
return { ok: false, code: 'bad_response', message: 'AI 返回格式无法解析' };
|
|
162
|
+
}
|
|
163
|
+
const parsed = parseAnswer(content, input, intent);
|
|
164
|
+
if (!parsed) return { ok: false, code: 'bad_response', message: 'AI 返回内容无法解析' };
|
|
165
|
+
return { ok: true, source: 'ai', ...parsed };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
configured: Boolean(apiKey && endpoint && model),
|
|
170
|
+
async query(input, intent) {
|
|
171
|
+
if (!apiKey || !endpoint || !model) return { ok: false, code: 'not_configured', message: 'AI 接口未配置,仅支持本地查询' };
|
|
172
|
+
const key = `${intent}\u0000${input}`;
|
|
173
|
+
const cached = cacheGet(key);
|
|
174
|
+
if (cached) return { ...cached, source: 'cache' };
|
|
175
|
+
if (inflight.has(key)) return inflight.get(key);
|
|
176
|
+
const task = request(input, intent)
|
|
177
|
+
.then((result) => {
|
|
178
|
+
if (result.ok) cacheSet(key, result);
|
|
179
|
+
return result;
|
|
180
|
+
})
|
|
181
|
+
.catch((err) => {
|
|
182
|
+
logger?.warn?.(err instanceof Error ? err : new Error(String(err)));
|
|
183
|
+
return { ok: false, code: 'ai_error', message: 'AI 服务异常' };
|
|
184
|
+
})
|
|
185
|
+
.finally(() => inflight.delete(key));
|
|
186
|
+
inflight.set(key, task);
|
|
187
|
+
return task;
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|