md2any 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.
package/lib/extract.js ADDED
@@ -0,0 +1,195 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * lib/extract.js — 不依赖大模型的本地文案提取
5
+ *
6
+ * 面板打开时用它做默认预填(标题 / 正文 / 标签),完全离线、零配置、零成本。
7
+ * 用户点「生成文案」时才用 LLM 覆盖。
8
+ *
9
+ * 提取思路:
10
+ * 标题:front matter title → 首个 H1 → 文件名(按平台截断)
11
+ * 标签:front matter tags(最强信号)→ 关键词提取补足
12
+ * 正文:导语句抽取(lead sentences)+ 互动引导句
13
+ *
14
+ * 关键词提取(中英混合,无需分词库):
15
+ * - 英文/技术词:正则抽 ASCII token(LLM、MoE、KV、Playwright…)
16
+ * - 中文:2~4 字 n-gram 词频,去停用词,长词吸收短词(子串抑制)
17
+ * - 出现在标题/小标题/加粗里的词加权
18
+ */
19
+
20
+ const matter = require('gray-matter');
21
+
22
+ // ─── 停用词 ─────────────────────────────────────────────────────────────────
23
+ const ZH_STOP = new Set([
24
+ '我们','他们','你们','这个','那个','这些','那些','什么','怎么','为什么','可以','已经','因为','所以','但是','如果','就是','还是','这样','那样','一个','一种','一样','没有','不是','而且','并且','然后','或者','虽然','由于','对于','关于','通过','进行','需要','能够','可能','应该','这里','那里','时候','之后','之前','的话','来说','其实','非常','特别','真的','就会','也是','都是','不会','不用','只是','这种','那种','以及','等等','比如','例如','目前','现在','所有','每个','任何','其他','另外','同时','直接','实际','基本','完全','主要','重要','问题','方法','方式','情况','结果','内容','部分','过程','系统','使用','提供','支持','实现','发现','表示','认为','看到','知道','觉得','文章','本文','今天','大家',
25
+ ]);
26
+ const EN_STOP = new Set([
27
+ 'the','a','an','and','or','but','if','then','else','for','of','to','in','on','at','by','is','are','was','were','be','been','it','its','this','that','these','those','with','as','from','we','you','they','i','can','will','would','should','could','have','has','had','do','does','did','not','no','yes','so','such','than','too','very','just','also','more','most','some','any','all','each','which','what','how','why','when','where','who','http','https','www','com','png','jpg','md','com','img','src','alt',
28
+ ]);
29
+
30
+ // ─── 文本清洗 ───────────────────────────────────────────────────────────────
31
+ function cleanMarkdown(md) {
32
+ return String(md || '')
33
+ .replace(/```[\s\S]*?```/g, ' ') // 代码块
34
+ .replace(/`[^`\n]*`/g, ' ') // 行内代码
35
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') // 图片
36
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')// 链接保留文字
37
+ .replace(/<[^>]+>/g, ' ') // HTML
38
+ .replace(/https?:\/\/\S+/g, ' ') // 裸链接
39
+ .replace(/^\s*>+\s?/gm, '') // 引用符
40
+ .replace(/[*_~#|]+/g, ' ') // md 符号
41
+ .replace(/\r/g, '');
42
+ }
43
+
44
+ // ─── 关键词提取 ─────────────────────────────────────────────────────────────
45
+ function extractKeywords(md, boostText, limit = 6) {
46
+ const text = cleanMarkdown(md);
47
+ const boost = cleanMarkdown(boostText || '');
48
+ const scores = new Map();
49
+ const bump = (w, n) => scores.set(w, (scores.get(w) || 0) + n);
50
+
51
+ // 1) 英文 / 技术词
52
+ const enRe = /[A-Za-z][A-Za-z0-9+\-.#]{1,19}/g;
53
+ for (const m of text.matchAll(enRe)) {
54
+ const w = m[0];
55
+ const lw = w.toLowerCase();
56
+ if (lw.length < 2 || EN_STOP.has(lw)) continue;
57
+ bump(w, 1);
58
+ }
59
+ for (const m of boost.matchAll(enRe)) {
60
+ const lw = m[0].toLowerCase();
61
+ if (lw.length < 2 || EN_STOP.has(lw)) continue;
62
+ bump(m[0], 4); // 标题/小标题里的词加权
63
+ }
64
+
65
+ // 2) 中文 n-gram(2~4 字)
66
+ const zhRuns = text.match(/[一-龥]{2,}/g) || [];
67
+ const boostRuns = boost.match(/[一-龥]{2,}/g) || [];
68
+ const gram = (runs, weight) => {
69
+ for (const run of runs) {
70
+ for (let n = 4; n >= 2; n--) {
71
+ for (let i = 0; i + n <= run.length; i++) {
72
+ const g = run.slice(i, i + n);
73
+ if (ZH_STOP.has(g)) continue;
74
+ bump(g, weight * (n >= 3 ? 1.2 : 1)); // 长词略加权
75
+ }
76
+ }
77
+ }
78
+ };
79
+ gram(zhRuns, 1);
80
+ gram(boostRuns, 4);
81
+
82
+ // 3) 只保留出现 >=2 次的(boost 词天然过线),按分排序
83
+ let cands = [...scores.entries()]
84
+ .filter(([w, s]) => s >= 2)
85
+ .sort((a, b) => b[1] - a[1])
86
+ .map(([w]) => w);
87
+
88
+ // 4) 子串抑制:若长词已入选,丢掉它的子串("稀疏激活" 压掉 "稀疏")
89
+ const picked = [];
90
+ for (const w of cands) {
91
+ if (picked.length >= limit) break;
92
+ if (picked.some(p => p.includes(w) || w.includes(p))) continue;
93
+ picked.push(w);
94
+ }
95
+ return picked;
96
+ }
97
+
98
+ // ─── 导语抽取 ───────────────────────────────────────────────────────────────
99
+ function leadSentences(md, maxChars) {
100
+ // 正文提取前先剔除:标题行、引用块(原文链接/广告)、分割线、列表符
101
+ const stripped = String(md || '')
102
+ .replace(/^#{1,6}\s+.*$/gm, '') // 标题行
103
+ .replace(/^\s*>.*$/gm, '') // 引用块
104
+ .replace(/^\s*[-*_]{3,}\s*$/gm, '') // 分割线
105
+ .replace(/^\s*[-*+]\s+/gm, ''); // 列表符号
106
+
107
+ const text = cleanMarkdown(stripped)
108
+ .split('\n')
109
+ .map(l => l.trim())
110
+ .filter(l => l && l.length > 8)
111
+ .join('\n');
112
+
113
+ const sentences = text
114
+ .split(/(?<=[。!?!?])|\n/)
115
+ .map(s => s.trim())
116
+ .filter(s => s.length >= 10);
117
+
118
+ const out = [];
119
+ let len = 0;
120
+ for (const s of sentences) {
121
+ if (len + s.length > maxChars) break;
122
+ out.push(s);
123
+ len += s.length;
124
+ if (out.length >= 4) break;
125
+ }
126
+ if (!out.length && sentences.length) out.push(sentences[0].slice(0, maxChars));
127
+ return out.join(' ');
128
+ }
129
+
130
+ // ─── 主入口 ─────────────────────────────────────────────────────────────────
131
+
132
+ const HOOKS = {
133
+ xiaohongshu: '你们在这块踩过坑吗?评论区聊聊👇',
134
+ twitter: '你怎么看?',
135
+ };
136
+
137
+ /**
138
+ * 本地提取默认文案(不调用任何模型)
139
+ * @param {{rawMarkdown:string, platform:'xiaohongshu'|'twitter'}} p
140
+ * @returns {{title:string, body:string, tags:string[], source:'local'}}
141
+ */
142
+ function extractCopy({ rawMarkdown, platform }) {
143
+ const raw = String(rawMarkdown || '');
144
+ let fm = {}, content = raw;
145
+ try { const p = matter(raw); fm = p.data || {}; content = p.content || raw; } catch (_) {}
146
+
147
+ // 标题
148
+ let title = fm.title || (content.match(/^#\s+(.+)$/m) || [])[1] || '';
149
+ title = String(title).replace(/["'`]/g, '').trim();
150
+
151
+ // 加权文本:标题 + 各级小标题 + 加粗
152
+ const heads = (content.match(/^#{1,4}\s+.+$/gm) || []).join('\n');
153
+ const bolds = (content.match(/\*\*([^*]+)\*\*/g) || []).join(' ');
154
+ const boostText = [title, heads, bolds].join('\n');
155
+
156
+ // 标签:front matter tags 优先,再用关键词补足
157
+ const fmTags = []
158
+ .concat(Array.isArray(fm.tags) ? fm.tags : (typeof fm.tags === 'string' ? fm.tags.split(/[,,\s]+/) : []))
159
+ .map(t => String(t).replace(/^#/, '').trim())
160
+ .filter(Boolean);
161
+
162
+ const want = platform === 'twitter' ? 3 : 5;
163
+ const kw = extractKeywords(content, boostText, want + 4);
164
+ const tags = [];
165
+ for (const t of [...fmTags, ...kw]) {
166
+ if (tags.length >= want) break;
167
+ if (!tags.some(x => x.toLowerCase() === t.toLowerCase())) tags.push(t);
168
+ }
169
+ // 每篇必带的固定标签
170
+ if (!tags.some(x => x.toLowerCase() === 'marsggbo')) tags.push('marsggbo');
171
+
172
+ // 正文
173
+ const maxChars = platform === 'twitter' ? 170 : 260;
174
+ let body = leadSentences(content, maxChars);
175
+ const hook = HOOKS[platform] || '';
176
+ if (hook) body = body ? `${body}\n\n${hook}` : hook;
177
+
178
+ // 小红书标题限 20 字:优先在天然分隔处断,实在不行才硬截
179
+ if (platform === 'xiaohongshu' && title.length > 20) {
180
+ const parts = title.split(/\s*[||——\-–—::]\s*/).map(s => s.trim()).filter(Boolean);
181
+ // 取 <=20 字里信息量最大(最长)的那段
182
+ const fit = parts.filter(p => p.length <= 20).sort((a, b) => b.length - a.length)[0];
183
+ if (fit && fit.length >= 6) {
184
+ title = fit;
185
+ } else {
186
+ const cut = title.slice(0, 20);
187
+ const m = cut.match(/^.*[,,。、\s]/); // 回退到最近的标点
188
+ title = (m && m[0].length >= 10 ? m[0] : cut).replace(/[,,。、\s]$/, '');
189
+ }
190
+ }
191
+
192
+ return { title, body, tags, source: 'local' };
193
+ }
194
+
195
+ module.exports = { extractCopy, extractKeywords, cleanMarkdown };
package/lib/llm.js ADDED
@@ -0,0 +1,281 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * lib/llm.js — 文案生成(OpenAI 兼容接口)
5
+ *
6
+ * 隐私说明:
7
+ * - 只把文章内容 + 链接发往你在配置里填的 baseUrl,不经过任何第三方。
8
+ * - apiKey 从 VS Code 配置读取,仅用于该请求的 Authorization 头。
9
+ *
10
+ * 对外主要接口:
11
+ * getDefaultInstruction(platform) -> string 面板里可编辑的默认 prompt
12
+ * buildContext({title, link, images, rawMarkdown}) -> string 自动注入的文章上下文
13
+ * generateCopy({ platform, instruction, context, config, signal }) -> { title, body, tags }
14
+ */
15
+
16
+ const https = require('https');
17
+ const http = require('http');
18
+ const { URL } = require('url');
19
+
20
+ // ─── 各平台默认指令(面板里可编辑,可整体替换) ───────────────
21
+
22
+ const DEFAULT_INSTRUCTIONS = {
23
+ xiaohongshu: `你是资深的小红书爆款写手。根据下面提供的技术文章,写一条小红书风格的推广笔记。要求:
24
+ 1. 标题:不超过 20 个字,有钩子、能勾起点击欲,可适当用 emoji;
25
+ 2. 正文:对文章做极简总结(口语化、分点或短段落、适度 emoji),结尾一定要有一句能引发讨论的互动引导(提问 / 求经验 / 让人评论区聊),目标是拉高评论和互动;
26
+ 3. 标签:3~6 个,输出为不带 # 的关键词数组,贴合内容和搜索。
27
+ 只输出 JSON,格式:{"title": "...", "body": "...", "tags": ["...","..."]}`,
28
+
29
+ twitter: `你是在 X(Twitter) 上写技术 thread 的资深工程师,风格像 karpathy、Jim Fan 那类:直给、有信息密度、有个人判断,靠"真东西"吸引人,而不是靠夸张。
30
+
31
+ 根据下面提供的技术文章,写一个中文推文串(thread)。
32
+
33
+ 【硬性字数约束 —— 极其重要】
34
+ X 的字数是加权计算的:**一个中文字符算 2 个字符**,链接固定算 23 个。所以:
35
+ **每条正文必须控制在 100 个中文字以内**(宁可短,绝不能超)。超了会发不出去。
36
+ 不要自己写 "1/5" 这类编号,系统会自动加。
37
+
38
+ 【条数】
39
+ 如果下面的「文章信息」里给了明确的条数要求(配图与条数),**就严格按那个条数写,一条不多一条不少**。
40
+ 如果没给,就写 3~5 条。无论哪种情况:
41
+ - **绝对禁止**把一句话拆成一条推文。每条都必须是**信息密集**的一段,把这个点讲透(接近但不超过字数上限才算合格)。
42
+ - 一条推文 = 一个完整的要点 + 支撑它的具体机制/数字,不是一句话的标题。
43
+ - **绝不为凑数注水**。
44
+
45
+ 【怎么写才吸引人(但不许标题党、不许造假)】
46
+ - 第 1 条 = 钩子:抛出这篇最**反直觉的事实**或**核心矛盾**,用具体的东西勾人。
47
+ ✅ 好:"Gemma 4 的 12B 把 550M 的 ViT 换成了一个 35M 的矩阵乘法。audio encoder 直接删了。"
48
+ ❌ 差:"今天来聊聊 Gemma 4 这篇论文的多模态设计。"(平铺直叙,没人看)
49
+ ❌ 差:"震惊!Google 这个操作颠覆了整个 AI 界!"(标题党,禁止)
50
+ - 中间每条只讲**一个**点,且尽量**带具体数字/机制名**(37.5%、262k、4 层 cross-attend…)。数字必须来自原文,**严禁编造**。
51
+ - 用"结论先行":先甩结论,再一句话解释为什么。不要铺垫、不要"首先我们来看"。
52
+ - 可以有个人判断("这个设计是真的巧""代价是训练侧更重"),但必须诚实,不夸大。
53
+ - 最后一条:给出你的 take + 一个真诚的开放问题引导讨论。
54
+
55
+ 【标签 —— 整串统一】
56
+ **整个 thread 共用同一组标签**(不是每条各写各的)。给 2~3 个即可(不带 #),放在顶层的 tags 字段里。
57
+ 标签也占字数,克制点。系统会自动给每条补上固定标签 #marsggbo,你不用写。
58
+
59
+ 【配图】
60
+ 长图会按顺序切块挂到各条推文下面。下面「文章信息」里会告诉你**每条推文具体配第几张到第几张图、对应文章的哪一段**。
61
+ 请让**每条推文讲的就是它配的那几张图里的内容**,做到文图对得上。
62
+
63
+ 只输出 JSON,格式:
64
+ {"title":"...","tags":["整串共用的标签"],"tweets":[{"body":"..."},{"body":"..."}]}`,
65
+ };
66
+
67
+ function getDefaultInstruction(platform) {
68
+ return DEFAULT_INSTRUCTIONS[platform] || DEFAULT_INSTRUCTIONS.xiaohongshu;
69
+ }
70
+
71
+ // ─── 文章上下文(自动注入到 prompt) ─────────────────────────
72
+
73
+ /**
74
+ * 把文章标题、全文链接、配图信息、正文拼成注入用的上下文文本
75
+ */
76
+ function buildContext({ title, link, images, rawMarkdown }) {
77
+ const parts = [];
78
+ if (title) parts.push(`【文章标题】${title}`);
79
+ if (link) parts.push(`【全文链接】${link}`);
80
+ const imgs = Array.isArray(images) ? images.filter(Boolean) : [];
81
+ if (imgs.length) {
82
+ const K = 4; // X 单条最多 4 张图
83
+ const n = Math.ceil(imgs.length / K); // 推文条数 = 总图数 / 每条上限
84
+ const lines = [];
85
+ for (let i = 0; i < n; i++) {
86
+ const from = i * K + 1;
87
+ const to = Math.min((i + 1) * K, imgs.length);
88
+ const pctA = Math.round((from - 1) / imgs.length * 100);
89
+ const pctB = Math.round(to / imgs.length * 100);
90
+ lines.push(` · 第 ${i + 1} 条 → 配第 ${from}~${to} 张图,对应文章大约 ${pctA}%~${pctB}% 的那一段`);
91
+ }
92
+ parts.push(
93
+ `【配图与条数 —— 必须严格遵守】\n` +
94
+ `文章被从上到下切成了 ${imgs.length} 张长图;X 单条最多带 ${K} 张。\n` +
95
+ `因此请【正好写 ${n} 条】推文,每条带的图和它要讲的内容如下:\n` +
96
+ lines.join('\n') + '\n' +
97
+ `每条推文只讲它对应的那一段内容,做到"文图对得上"。不要多写也不要少写。`
98
+ );
99
+ }
100
+
101
+ // 去掉 front matter,避免把 YAML 当正文;截断防止 token 过多
102
+ let body = String(rawMarkdown || '');
103
+ body = body.replace(/^---\n[\s\S]*?\n---\n/, '');
104
+ const MAX = 6000;
105
+ if (body.length > MAX) body = body.slice(0, MAX) + '\n…(正文过长已截断)';
106
+ parts.push(`【正文】\n${body}`);
107
+
108
+ return parts.join('\n');
109
+ }
110
+
111
+ // ─── OpenAI 兼容 /chat/completions 调用 ──────────────────────
112
+
113
+ function requestJson({ baseUrl, apiKey, payload, signal, timeout = 60000 }) {
114
+ return new Promise((resolve, reject) => {
115
+ let u;
116
+ try {
117
+ const base = baseUrl.replace(/\/+$/, '');
118
+ u = new URL(base + '/chat/completions');
119
+ } catch (e) {
120
+ reject(new Error(`baseUrl 无效:${baseUrl}`));
121
+ return;
122
+ }
123
+ const client = u.protocol === 'https:' ? https : http;
124
+ const body = JSON.stringify(payload);
125
+ const req = client.request({
126
+ hostname: u.hostname,
127
+ port: u.port || (u.protocol === 'https:' ? 443 : 80),
128
+ path: u.pathname + u.search,
129
+ method: 'POST',
130
+ headers: {
131
+ 'Content-Type': 'application/json',
132
+ 'Authorization': `Bearer ${apiKey}`,
133
+ 'Content-Length': Buffer.byteLength(body),
134
+ },
135
+ }, (res) => {
136
+ const chunks = [];
137
+ res.on('data', c => chunks.push(c));
138
+ res.on('end', () => {
139
+ const text = Buffer.concat(chunks).toString('utf8');
140
+ if (res.statusCode < 200 || res.statusCode >= 300) {
141
+ let msg = `LLM 接口返回 HTTP ${res.statusCode}`;
142
+ try { msg += ':' + (JSON.parse(text).error?.message || text.slice(0, 300)); }
143
+ catch (_) { msg += ':' + text.slice(0, 300); }
144
+ reject(new Error(msg));
145
+ return;
146
+ }
147
+ resolve(text);
148
+ });
149
+ });
150
+ req.on('error', reject);
151
+ req.setTimeout(timeout, () => { req.destroy(); reject(new Error('LLM 请求超时')); });
152
+ if (signal) signal.addEventListener('abort', () => { req.destroy(); reject(new Error('已取消')); });
153
+ req.write(body);
154
+ req.end();
155
+ });
156
+ }
157
+
158
+ /**
159
+ * 从模型回复里尽量鲁棒地抽出 {title, body, tags}
160
+ */
161
+ function parseCopyJson(content) {
162
+ let raw = String(content || '').trim();
163
+ // 去掉 ```json ... ``` 围栏
164
+ raw = raw.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/i, '').trim();
165
+ // 抽取第一个 { ... } 块
166
+ const start = raw.indexOf('{');
167
+ const end = raw.lastIndexOf('}');
168
+ if (start >= 0 && end > start) raw = raw.slice(start, end + 1);
169
+ let obj;
170
+ try { obj = JSON.parse(raw); }
171
+ catch (e) { throw new Error('无法解析模型返回的 JSON,请重试或调整 prompt。原始返回:' + String(content).slice(0, 200)); }
172
+
173
+ const normTags = (v) => Array.isArray(v)
174
+ ? v.map(t => String(t).replace(/^#/, '').trim()).filter(Boolean)
175
+ : (typeof v === 'string' ? v.split(/[;;,,]+/).map(t => t.replace(/^#/, '').trim()).filter(Boolean) : []);
176
+
177
+ // Twitter 串推:tweets 数组。标签整串统一 —— 取顶层 tags;没有就用第一条的。
178
+ if (Array.isArray(obj.tweets) && obj.tweets.length) {
179
+ let shared = normTags(obj.tags);
180
+ if (!shared.length) shared = normTags(obj.tweets[0] && obj.tweets[0].tags);
181
+ return {
182
+ title: String(obj.title || '').trim(),
183
+ tags: withFixedTag(shared),
184
+ tweets: obj.tweets
185
+ .map(t => ({ body: String(t.body || '').trim() }))
186
+ .filter(t => t.body),
187
+ };
188
+ }
189
+
190
+ // 单条(小红书 / 兜底)
191
+ return {
192
+ title: String(obj.title || '').trim(),
193
+ body: String(obj.body || '').trim(),
194
+ tags: withFixedTag(normTags(obj.tags)),
195
+ };
196
+ }
197
+
198
+ /** 每篇必带的固定标签 */
199
+ const FIXED_TAG = 'marsggbo';
200
+ function withFixedTag(tags) {
201
+ const t = (tags || []).filter(Boolean);
202
+ if (!t.some(x => x.toLowerCase() === FIXED_TAG.toLowerCase())) t.push(FIXED_TAG);
203
+ return t;
204
+ }
205
+
206
+ /**
207
+ * 生成文案
208
+ * @param {object} p
209
+ * @param {'xiaohongshu'|'twitter'} p.platform
210
+ * @param {string} p.instruction 面板里(可编辑的)指令
211
+ * @param {string} p.context buildContext() 产出的文章上下文
212
+ * @param {{ baseUrl, apiKey, model }} p.config
213
+ * @returns {Promise<{title, body, tags}>}
214
+ */
215
+ /** 本地端点(Ollama / LM Studio 等)不需要 API Key */
216
+ function isLocalEndpoint(baseUrl) {
217
+ return /^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])/i.test(String(baseUrl || ''));
218
+ }
219
+
220
+ async function generateCopy({ platform, instruction, context, config, signal }) {
221
+ const baseUrl = (config?.baseUrl || '').trim();
222
+ const apiKey = (config?.apiKey || '').trim();
223
+ const model = (config?.model || '').trim();
224
+ if (!baseUrl) throw new Error('未配置 LLM 接口地址(在面板的「LLM 配置」里填)');
225
+ if (!model) throw new Error('未配置 LLM 模型名(在面板的「LLM 配置」里填)');
226
+ if (!apiKey && !isLocalEndpoint(baseUrl)) {
227
+ throw new Error('未配置 LLM API Key(在面板的「LLM 配置」里填,会安全存入系统钥匙串)');
228
+ }
229
+
230
+ const payload = {
231
+ model,
232
+ temperature: 0.8,
233
+ messages: [
234
+ { role: 'system', content: '你只输出一个 JSON 对象,不要输出任何解释或 markdown 围栏。' },
235
+ { role: 'user', content: `${instruction}\n\n===== 文章信息 =====\n${context}` },
236
+ ],
237
+ };
238
+
239
+ const text = await requestJson({ baseUrl, apiKey, payload, signal });
240
+ let content;
241
+ try {
242
+ const data = JSON.parse(text);
243
+ content = data.choices?.[0]?.message?.content;
244
+ } catch (e) {
245
+ throw new Error('LLM 返回不是合法 JSON:' + text.slice(0, 200));
246
+ }
247
+ if (!content) throw new Error('LLM 返回为空');
248
+ return parseCopyJson(content);
249
+ }
250
+
251
+ /**
252
+ * 测试连接:发一个最小请求,确认 baseUrl / key / model 三件套可用
253
+ * @returns {Promise<{ok:true, reply:string}>}
254
+ */
255
+ async function testConnection({ config }) {
256
+ const baseUrl = (config?.baseUrl || '').trim();
257
+ const apiKey = (config?.apiKey || '').trim();
258
+ const model = (config?.model || '').trim();
259
+ if (!baseUrl) throw new Error('接口地址不能为空');
260
+ if (!model) throw new Error('模型名不能为空');
261
+ if (!apiKey && !isLocalEndpoint(baseUrl)) throw new Error('API Key 不能为空(本地端点除外)');
262
+
263
+ const text = await requestJson({
264
+ baseUrl, apiKey, timeout: 30000,
265
+ payload: { model, max_tokens: 10, messages: [{ role: 'user', content: '回复 OK' }] },
266
+ });
267
+ const data = JSON.parse(text);
268
+ const reply = data.choices?.[0]?.message?.content || '';
269
+ return { ok: true, reply: String(reply).slice(0, 40) };
270
+ }
271
+
272
+ module.exports = {
273
+ DEFAULT_INSTRUCTIONS,
274
+ getDefaultInstruction,
275
+ buildContext,
276
+ generateCopy,
277
+ testConnection,
278
+ isLocalEndpoint,
279
+ withFixedTag,
280
+ FIXED_TAG,
281
+ };