llm-api-gateway-cli 1.0.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.
Files changed (50) hide show
  1. package/.env.example +10 -0
  2. package/README.md +1127 -0
  3. package/cli-agent.js +666 -0
  4. package/cli-anthropic.js +236 -0
  5. package/cli-claude-code.js +317 -0
  6. package/cli-openai.js +212 -0
  7. package/completions/_llm-api-gateway-cli +65 -0
  8. package/completions/llm-api-gateway-cli.bash +64 -0
  9. package/completions/llm-api-gateway-cli.fish +43 -0
  10. package/images/chat.png +0 -0
  11. package/images/settings.png +0 -0
  12. package/images/task.png +0 -0
  13. package/lib/agent.js +607 -0
  14. package/lib/commands.js +468 -0
  15. package/lib/common.js +196 -0
  16. package/lib/config.js +70 -0
  17. package/lib/configcmd.js +230 -0
  18. package/lib/hub.js +1494 -0
  19. package/lib/jsonstore.js +49 -0
  20. package/lib/mcp.js +375 -0
  21. package/lib/memory.js +109 -0
  22. package/lib/plandoc.js +178 -0
  23. package/lib/pricing.js +52 -0
  24. package/lib/runner.js +234 -0
  25. package/lib/runstore.js +96 -0
  26. package/lib/secrets.js +198 -0
  27. package/lib/sessionstore.js +269 -0
  28. package/lib/settings.js +517 -0
  29. package/lib/tasksession.js +594 -0
  30. package/lib/taskstore.js +740 -0
  31. package/lib/tools.js +927 -0
  32. package/package.json +55 -0
  33. package/public/app.js +1055 -0
  34. package/public/index.html +167 -0
  35. package/public/manual.css +215 -0
  36. package/public/manual.html +381 -0
  37. package/public/manual.js +186 -0
  38. package/public/models.js +121 -0
  39. package/public/render.js +250 -0
  40. package/public/styles.css +955 -0
  41. package/public/task-slash.js +493 -0
  42. package/public/task.css +739 -0
  43. package/public/task.html +220 -0
  44. package/public/task.js +3127 -0
  45. package/public/theme.js +91 -0
  46. package/public/tint.js +261 -0
  47. package/scripts/install.ps1 +537 -0
  48. package/scripts/install.sh +510 -0
  49. package/server.js +14 -0
  50. package/task-server.js +15 -0
@@ -0,0 +1,121 @@
1
+ /**
2
+ * 模型下拉框(聊天页与任务页共用的唯一实现)
3
+ *
4
+ * 为什么要有这个文件:设置面板里的「模型」**不是自由文本**,而是从网关 `/v1/models`
5
+ * 拉下来的候选里选一个。两页各有自己的设置面板(`app.js` / `task.js`),这类"下拉怎么建、
6
+ * 当前值不在列表里怎么办"的规则如果各写一遍,两边迟早不一样 —— 与 `render.js` 同一个理由。
7
+ *
8
+ * 三个容易踩的点,都在这里收口:
9
+ * 1. **当前值可能不在列表里**(上游把模型下线了、或者当初是手输的、或者这次没拉到列表):
10
+ * 这时要把原值作为一个选项补进去并选中,绝不静默改成别的东西,也不让下拉变成空值;
11
+ * 2. **`<select>.value = 不存在的值` 在真实浏览器里会变成空串**(`selectedIndex = -1`),
12
+ * 所以 `ensure()` 必须先补选项再赋值 —— 「配置被悄悄清掉」就是这么来的;
13
+ * 3. **「服务端默认」是一个合法选项**(值 `''`):配置里不写 model 时服务端用 `cfg.model`,
14
+ * 所以第一项永远留出来,并把它叫什么写清楚。
15
+ *
16
+ * 纯逻辑(不碰 DOM 之外的全局),可在 node 里直接测:见 tests/model-select.test.mjs。
17
+ */
18
+ (function (root, factory) {
19
+ root.ModelSelect = factory();
20
+ })(typeof globalThis !== 'undefined' ? globalThis : this, function () {
21
+ 'use strict';
22
+
23
+ const DEFAULT_TEXT = '(用服务端默认)';
24
+
25
+ /** 「服务端默认」那一项的字面量:知道默认模型名就写出来,不知道就只说默认 */
26
+ function defaultLabel(defaultModel) {
27
+ const name = String(defaultModel || '').trim();
28
+ return name ? `(用服务端默认:${name})` : DEFAULT_TEXT;
29
+ }
30
+
31
+ /** 不在上游列表里的当前值:标出来,让用户知道它是"继承下来的",不是这次能选的 */
32
+ function currentLabel(value) {
33
+ return String(value) + '(当前设置,不在上游列表里)';
34
+ }
35
+
36
+ /** 大小写不敏感地判断 name 是否在 models 里(网关的模型名大小写敏感,但这里允许用户大小写随便打) */
37
+ function match(models, name) {
38
+ const want = String(name == null ? '' : name).trim().toLowerCase();
39
+ if (!want) return null;
40
+ return (models || []).find((m) => String(m).toLowerCase() === want) || null;
41
+ }
42
+
43
+ function addOption(select, value, label) {
44
+ const opt = document.createElement('option');
45
+ opt.value = value;
46
+ opt.textContent = label;
47
+ select.appendChild(opt);
48
+ return opt;
49
+ }
50
+
51
+ function clear(select) {
52
+ if (typeof select.replaceChildren === 'function') {
53
+ select.replaceChildren();
54
+ return;
55
+ }
56
+ if ('innerHTML' in select) select.innerHTML = '';
57
+ else if (Array.isArray(select.children)) select.children.length = 0;
58
+ }
59
+
60
+ /** 用 options 里的值选中 select;没有对应选项时返回 false(不赋值,避免变空) */
61
+ function select_(select, value) {
62
+ const want = String(value == null ? '' : value);
63
+ const opts = select.options || select.children || [];
64
+ for (const o of opts) {
65
+ if (String(o.value) === want) {
66
+ select.value = want;
67
+ return true;
68
+ }
69
+ }
70
+ return false;
71
+ }
72
+
73
+ /**
74
+ * 重建下拉:默认项 → 上游列表 → 当前值兜底项。
75
+ *
76
+ * @param {{models?:string[], current?:string, defaultModel?:string}} data
77
+ * @returns {{count:number, empty:boolean, missing:string, extra:boolean}}
78
+ * count 上游模型个数
79
+ * empty 这次没拉到任何模型(页面据此说清原因)
80
+ * missing 当前值(不在列表里时原样回传,空串表示在列表里或没有当前值)
81
+ * extra 是否补了"当前设置"兜底项
82
+ */
83
+ function fill(select, data) {
84
+ // 上游偶尔会重复返回同一个模型名:去重,免得下拉里出现两条一模一样的
85
+ const models = [...new Set(((data && data.models) || []).map((m) => String(m)).filter(Boolean))];
86
+ const current = String((data && data.current) || '').trim();
87
+
88
+ clear(select);
89
+ addOption(select, '', defaultLabel(data && data.defaultModel));
90
+ for (const m of models) addOption(select, m, m);
91
+
92
+ let missing = '';
93
+ let extra = false;
94
+ if (current && !match(models, current)) {
95
+ // 列表里没有它:补一项并保持选中(R3 —— 拉不到/下线了也不能把配置弄丢)
96
+ addOption(select, current, currentLabel(current));
97
+ missing = current;
98
+ extra = true;
99
+ }
100
+ select_(select, current);
101
+ return { count: models.length, empty: models.length === 0, missing, extra };
102
+ }
103
+
104
+ /** 确保某个值在选项里存在并选中它(设置面板在别处改了模型时用) */
105
+ function ensure(select, value, label) {
106
+ const want = String(value == null ? '' : value);
107
+ if (!select_(select, want)) addOption(select, want, label || want);
108
+ return select_(select, want);
109
+ }
110
+
111
+ /** 页面上要显示给用户的一句话:为什么只有这些、当前值为什么不在里面 */
112
+ function hintText(result) {
113
+ const r = result || {};
114
+ if (r.empty) return '没拉到模型列表:只能选「服务端默认」或当前设置,点「刷新模型」重试';
115
+ const base = `从网关拉到 ${r.count} 个模型,只能选、不能填`;
116
+ if (!r.missing) return base;
117
+ return `${base};当前设置的 ${r.missing} 不在列表里,已单独列出并保持选中`;
118
+ }
119
+
120
+ return { DEFAULT_TEXT, defaultLabel, currentLabel, match, fill, ensure, hintText };
121
+ });
@@ -0,0 +1,250 @@
1
+ /**
2
+ * 模式四 / 模式五 共用的前端渲染工具(经典脚本,挂在全局作用域)
3
+ * 被 index.html 与 task.html 在各自页面脚本之前加载。
4
+ */
5
+
6
+ function escapeHtml(s) {
7
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
8
+ }
9
+
10
+ /**
11
+ * 行内 Markdown → HTML:行内代码先占位(内容单独转义,免得被后面的粗斜体规则二次解析),
12
+ * 其余文本整体转义,再处理粗体 / 斜体 / 删除线 / 链接。
13
+ * 行内语法都不跨行,所以整篇和单个表格单元格都能直接调它。
14
+ */
15
+ function renderInline(src) {
16
+ const codes = [];
17
+ let s = String(src ?? '').replace(/`([^`\n]+)`/g, (_m, code) => {
18
+ codes.push(`<code>${escapeHtml(code)}</code>`);
19
+ return `\u0000I${codes.length - 1}\u0000`;
20
+ });
21
+
22
+ s = escapeHtml(s);
23
+
24
+ s = s.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
25
+ s = s.replace(/(^|[\s(])\*([^*\n]+)\*/g, '$1<em>$2</em>');
26
+ s = s.replace(/~~([^~\n]+)~~/g, '<del>$1</del>');
27
+ s = s.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
28
+
29
+ return s.replace(/\u0000I(\d+)\u0000/g, (_m, i) => codes[Number(i)]);
30
+ }
31
+
32
+ /* ---------- 表格 ---------- */
33
+
34
+ /** 按裸 | 切分单元格;\| 是转义的字面竖线;行首 / 行尾那根裸 | 只是表格边框,不算空单元格 */
35
+ function splitTableRow(line) {
36
+ let t = String(line).trim();
37
+ if (t.startsWith('|')) t = t.slice(1);
38
+ const cells = [];
39
+ let cur = '';
40
+ for (let i = 0; i < t.length; i++) {
41
+ const ch = t[i];
42
+ if (ch === '\\' && t[i + 1] === '|') {
43
+ cur += '|';
44
+ i++;
45
+ continue;
46
+ }
47
+ if (ch === '|') {
48
+ cells.push(cur);
49
+ cur = '';
50
+ continue;
51
+ }
52
+ cur += ch;
53
+ }
54
+ cells.push(cur);
55
+ if (cells.length > 1 && cells[cells.length - 1].trim() === '' && /(^|[^\\])\|$/.test(t)) cells.pop();
56
+ return cells.map((c) => c.trim());
57
+ }
58
+
59
+ /** 分隔行:`| --- | :---: |` 之类,每个单元格必须整体由 - 和两端可选冒号组成 */
60
+ function isTableAlignRow(line) {
61
+ const t = String(line).trim();
62
+ if (!t.includes('-')) return false;
63
+ const cells = splitTableRow(t);
64
+ return cells.length > 0 && cells.every((c) => /^:?-+:?$/.test(c));
65
+ }
66
+
67
+ /** :--- / ---: / :---: → left / right / center */
68
+ function tableAlign(cell) {
69
+ const left = cell.startsWith(':');
70
+ const right = cell.endsWith(':');
71
+ if (left && right) return 'center';
72
+ if (right) return 'right';
73
+ if (left) return 'left';
74
+ return '';
75
+ }
76
+
77
+ /**
78
+ * 表格 → HTML。页面显示真表格,原文整段塞进 data-md:
79
+ * 复制时给回 Markdown(见 enhanceTables),显示和复制各取所需。
80
+ */
81
+ function buildTableHtml(heads, aligns, rows, raw) {
82
+ const width = heads.length;
83
+ const alignAttr = (i) => (aligns[i] && aligns[i] !== 'left' ? ` style="text-align:${aligns[i]}"` : '');
84
+ const th = heads.map((c, i) => `<th${alignAttr(i)}>${renderInline(c)}</th>`).join('');
85
+ const trs = rows
86
+ .map((cells) => {
87
+ const tds = [];
88
+ for (let i = 0; i < width; i++) tds.push(`<td${alignAttr(i)}>${renderInline(cells[i] ?? '')}</td>`);
89
+ return `<tr>${tds.join('')}</tr>`;
90
+ })
91
+ .join('');
92
+ return (
93
+ `<div class="md-table" data-md="${escapeHtml(raw)}">` +
94
+ `<div class="md-table-scroll"><table><thead><tr>${th}</tr></thead>` +
95
+ (trs ? `<tbody>${trs}</tbody>` : '') +
96
+ `</table></div></div>`
97
+ );
98
+ }
99
+
100
+ /**
101
+ * 把成块的 Markdown 表格换成占位符(和围栏代码块一个套路:先摘出去,最后再还原)。
102
+ * 必须在代码块抽取之后调用,否则代码块里当样例贴的 |---|---| 也会被当成表格。
103
+ */
104
+ function extractTables(src, tables) {
105
+ const lines = src.split('\n');
106
+ const kept = [];
107
+ for (let i = 0; i < lines.length; i++) {
108
+ const head = lines[i];
109
+ if (head.includes('|') && i + 1 < lines.length && isTableAlignRow(lines[i + 1])) {
110
+ const heads = splitTableRow(head);
111
+ const aligns = splitTableRow(lines[i + 1]).map(tableAlign);
112
+ const rows = [];
113
+ let j = i + 2;
114
+ // 表体一直吃到空行或不再含竖线的那一行为止
115
+ for (; j < lines.length; j++) {
116
+ const t = lines[j].trim();
117
+ if (!t || !t.includes('|')) break;
118
+ rows.push(splitTableRow(lines[j]));
119
+ }
120
+ tables.push(buildTableHtml(heads, aligns, rows, lines.slice(i, j).join('\n')));
121
+ kept.push(`\u0000T${tables.length - 1}\u0000`);
122
+ i = j - 1;
123
+ continue;
124
+ }
125
+ kept.push(head);
126
+ }
127
+ return kept.join('\n');
128
+ }
129
+
130
+ /**
131
+ * 极简 Markdown 渲染:先抽出围栏代码块与表格占位,再转义,再处理行内语法与块级结构,
132
+ * 最后还原占位符 —— 保证代码块里的内容不会被当成语法解析,也不会有 XSS。
133
+ */
134
+ function renderMarkdown(src) {
135
+ const blocks = [];
136
+ let s = String(src ?? '').replace(/```([^\n`]*)\n?([\s\S]*?)```/g, (_m, lang, code) => {
137
+ const label = lang ? ` data-lang="${escapeHtml(lang.trim())}"` : '';
138
+ blocks.push(`<pre class="code-block"${label}><code>${escapeHtml(code.replace(/\n$/, ''))}</code></pre>`);
139
+ return `\u0000B${blocks.length - 1}\u0000`;
140
+ });
141
+
142
+ const tables = [];
143
+ s = extractTables(s, tables);
144
+
145
+ s = renderInline(s);
146
+
147
+ const out = [];
148
+ let para = [];
149
+ const flush = () => {
150
+ if (para.length) {
151
+ out.push(`<p>${para.join('<br>')}</p>`);
152
+ para = [];
153
+ }
154
+ };
155
+
156
+ for (const line of s.split('\n')) {
157
+ const t = line.trim();
158
+ if (!t) {
159
+ flush();
160
+ continue;
161
+ }
162
+ if (/^\u0000[BT]\d+\u0000$/.test(t)) {
163
+ flush();
164
+ out.push(t);
165
+ continue;
166
+ }
167
+ let m;
168
+ if ((m = t.match(/^(#{1,6})\s+(.*)$/))) {
169
+ flush();
170
+ const lvl = Math.min(m[1].length + 1, 6);
171
+ out.push(`<h${lvl}>${m[2]}</h${lvl}>`);
172
+ continue;
173
+ }
174
+ if ((m = t.match(/^&gt;\s?(.*)$/))) {
175
+ flush();
176
+ out.push(`<blockquote>${m[1]}</blockquote>`);
177
+ continue;
178
+ }
179
+ if (/^(-{3,}|\*{3,}|_{3,})$/.test(t)) {
180
+ flush();
181
+ out.push('<hr>');
182
+ continue;
183
+ }
184
+ if ((m = t.match(/^[-*+]\s+(.*)$/))) {
185
+ flush();
186
+ out.push(`<li data-kind="ul">${m[1]}</li>`);
187
+ continue;
188
+ }
189
+ if ((m = t.match(/^\d+[.)]\s+(.*)$/))) {
190
+ flush();
191
+ out.push(`<li data-kind="ol">${m[1]}</li>`);
192
+ continue;
193
+ }
194
+ para.push(t);
195
+ }
196
+ flush();
197
+
198
+ let html = out.join('\n');
199
+ // 把连续的 <li> 归组到同一个列表里
200
+ html = html.replace(/(?:<li data-kind="ul">[\s\S]*?<\/li>\n?)+/g, (m) => `<ul>${m.replace(/\n/g, '')}</ul>`);
201
+ html = html.replace(/(?:<li data-kind="ol">[\s\S]*?<\/li>\n?)+/g, (m) => `<ol>${m.replace(/\n/g, '')}</ol>`);
202
+ html = html.replace(/\u0000T(\d+)\u0000/g, (_m, i) => tables[Number(i)]);
203
+ html = html.replace(/\u0000B(\d+)\u0000/g, (_m, i) => blocks[Number(i)]);
204
+ return html;
205
+ }
206
+
207
+ /** 给每个代码块加一个「复制」按钮 */
208
+ function enhanceCodeBlocks(root) {
209
+ for (const pre of root.querySelectorAll('pre.code-block')) {
210
+ if (pre.querySelector('.copy')) continue;
211
+ const btn = document.createElement('button');
212
+ btn.className = 'copy';
213
+ btn.type = 'button';
214
+ btn.textContent = '复制';
215
+ btn.addEventListener('click', async () => {
216
+ const code = pre.querySelector('code')?.textContent ?? '';
217
+ try {
218
+ await navigator.clipboard.writeText(code);
219
+ btn.textContent = '已复制';
220
+ } catch {
221
+ btn.textContent = '复制失败';
222
+ }
223
+ setTimeout(() => (btn.textContent = '复制'), 1200);
224
+ });
225
+ pre.appendChild(btn);
226
+ }
227
+ }
228
+
229
+ /** 给每个表格加一个「复制 Markdown」按钮:屏幕上看到的是 HTML 表格,粘贴出去仍是 | 竖线原文 */
230
+ function enhanceTables(root) {
231
+ for (const box of root.querySelectorAll('.md-table')) {
232
+ if (box.querySelector('.copy')) continue;
233
+ const btn = document.createElement('button');
234
+ btn.className = 'copy';
235
+ btn.type = 'button';
236
+ btn.textContent = '复制 Markdown';
237
+ btn.title = '复制为 Markdown 表格';
238
+ btn.addEventListener('click', async () => {
239
+ const md = box.dataset?.md ?? '';
240
+ try {
241
+ await navigator.clipboard.writeText(md);
242
+ btn.textContent = '已复制';
243
+ } catch {
244
+ btn.textContent = '复制失败';
245
+ }
246
+ setTimeout(() => (btn.textContent = '复制 Markdown'), 1200);
247
+ });
248
+ box.appendChild(btn);
249
+ }
250
+ }