@pomelolabs/leads 0.0.0-stage → 0.1.1

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/host/index.js ADDED
@@ -0,0 +1,1361 @@
1
+ /**
2
+ * 柚客 Pomelo — Host half.
3
+ * Local JSON store + token-guarded RPC + DSH model calls.
4
+ * Browser automation and the production license service are next.
5
+ */
6
+ import crypto from 'node:crypto';
7
+ import { execFile } from 'node:child_process';
8
+ import { promisify } from 'node:util';
9
+ import fs from 'node:fs/promises';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import { browserStatus, openLink, openLogin, searchPeerProfile, searchPublic } from './browser.js';
13
+
14
+ const execFileAsync = promisify(execFile);
15
+
16
+ const RPC_PATH = '/pomelo-rpc';
17
+ const MAX_BODY = 2 * 1024 * 1024;
18
+
19
+ const SYSTEM_PROMPT = `你是「柚客 Pomelo」的内容策略助手,服务对象是中国的个体商家、个人 IP 和小团队。
20
+ 你的建议必须具体、可执行、口语化,避免空泛的“要垂直”“要做差异化”。
21
+ 所有分析都要结合创作者提供的 6 个问题资料;资料里没有的数据不要编造。
22
+ 只输出中文。除非用户明确要求,否则不要输出 Markdown 代码块。`;
23
+
24
+ const PROFILE_FIELDS = [
25
+ ['q1', '你是谁?目前在做什么生意?'],
26
+ ['q2', '你有哪些值得分享的个人经历?'],
27
+ ['q3', '你的客户主要是哪些人?他们有什么需求?'],
28
+ ['q4', '你有哪些专业经验或行业观点?'],
29
+ ['q5', '你的产品或服务有哪些特点?'],
30
+ ['q6', '你希望客户如何认识和评价你?'],
31
+ ];
32
+
33
+ function homeDir() {
34
+ return process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
35
+ }
36
+
37
+ function dataDir() {
38
+ return path.join(homeDir(), 'pomelo');
39
+ }
40
+
41
+ function todayKey() {
42
+ const d = new Date();
43
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
44
+ }
45
+
46
+ function nowISO() {
47
+ return new Date().toISOString();
48
+ }
49
+
50
+ const LICENSE_TIERS = {
51
+ month: { label: '月度卡', days: 30, letter: 'M' },
52
+ quarter: { label: '季度卡', days: 90, letter: 'Q' },
53
+ year: { label: '年度卡', days: 365, letter: 'Y' },
54
+ };
55
+ const LICENSE_SECRET = process.env.POMELO_LICENSE_SECRET || 'pomelo-license-v1-7f3d9a2c4e6b8d10';
56
+
57
+ function licenseTierFromLetter(letter) {
58
+ return Object.entries(LICENSE_TIERS).find(([, meta]) => meta.letter === letter)?.[0] || '';
59
+ }
60
+
61
+ function licenseSignature(tier, serial) {
62
+ return crypto
63
+ .createHmac('sha256', LICENSE_SECRET)
64
+ .update(`pomelo-v1:${tier}:${serial}`)
65
+ .digest('hex')
66
+ .slice(0, 12)
67
+ .toUpperCase();
68
+ }
69
+
70
+ function parseLicenseCode(input) {
71
+ const code = String(input || '').toUpperCase().replace(/\s+/g, '');
72
+ const parts = code.split('-');
73
+ if (parts.length !== 5 || parts[0] !== 'PM') return null;
74
+ const tier = licenseTierFromLetter(parts[1]);
75
+ if (!tier) return null;
76
+ const serial = `${parts[2]}${parts[3]}`;
77
+ if (!/^[A-Z2-9]{8}$/.test(serial)) return null;
78
+ if (!/^[A-F0-9]{12}$/.test(parts[4])) return null;
79
+ const expected = licenseSignature(tier, serial);
80
+ if (parts[4] !== expected) return null;
81
+ return { code, tier, serial };
82
+ }
83
+
84
+ function licensesFile() {
85
+ return path.join(dataDir(), 'licenses.json');
86
+ }
87
+
88
+ async function readLicenseDb() {
89
+ try {
90
+ const raw = await fs.readFile(licensesFile(), 'utf8');
91
+ const parsed = JSON.parse(raw);
92
+ return { codes: Array.isArray(parsed?.codes) ? parsed.codes : [] };
93
+ } catch (error) {
94
+ if (error?.code !== 'ENOENT') throw error;
95
+ return { codes: [] };
96
+ }
97
+ }
98
+
99
+ async function writeLicenseDb(db) {
100
+ const file = licensesFile();
101
+ const tmp = `${file}.${process.pid}.tmp`;
102
+ await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
103
+ await fs.writeFile(tmp, JSON.stringify({ codes: db.codes }, null, 2), { mode: 0o600 });
104
+ await fs.rename(tmp, file);
105
+ }
106
+
107
+ function tierMeta(tier) {
108
+ return LICENSE_TIERS[tier] || LICENSE_TIERS.year;
109
+ }
110
+
111
+ function maskCode(code) {
112
+ const value = String(code || '');
113
+ return value.length > 4 ? `••••${value.slice(-4)}` : '••••';
114
+ }
115
+
116
+ function licenseView(license) {
117
+ const now = Date.now();
118
+ const expires = license?.expiresAt ? new Date(license.expiresAt).getTime() : 0;
119
+ const active = Boolean(expires && expires > now);
120
+ return {
121
+ status: active ? 'active' : 'inactive',
122
+ plan: license?.plan || '',
123
+ tier: license?.tier || license?.plan || '',
124
+ tierLabel: tierMeta(license?.tier || license?.plan).label || '',
125
+ expiresAt: license?.expiresAt || null,
126
+ activatedAt: license?.activatedAt || null,
127
+ daysLeft: active ? Math.max(0, Math.ceil((expires - now) / 86400000)) : 0,
128
+ maskedCode: license?.maskedCode || '',
129
+ };
130
+ }
131
+
132
+ function licenseServers() {
133
+ const configured = String(process.env.POMELO_LICENSE_SERVER || '').trim();
134
+ const defaults = ['https://bosshuoke.com/pomelo-admin', 'http://121.43.127.148'];
135
+ return [...new Set([...(configured ? [configured] : []), ...defaults].map((item) => item.replace(/\/$/, '')))];
136
+ }
137
+
138
+ async function licenseRequest(path, body, timeout = 12000) {
139
+ let lastError = null;
140
+ for (const server of licenseServers()) {
141
+ try {
142
+ const response = await fetch(`${server}${path}`, {
143
+ method: 'POST',
144
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
145
+ body: JSON.stringify(body),
146
+ signal: AbortSignal.timeout(timeout),
147
+ });
148
+ return { response, server };
149
+ } catch (error) {
150
+ lastError = error;
151
+ }
152
+ }
153
+ throw lastError || new Error('license server unavailable');
154
+ }
155
+
156
+ function cleanText(value, max = 4000) {
157
+ return String(value ?? '').replace(/\u0000/g, '').trim().slice(0, max);
158
+ }
159
+
160
+ function emptyState() {
161
+ return {
162
+ schemaVersion: 1,
163
+ installId: '',
164
+ profile: {
165
+ answers: { q1: '', q2: '', q3: '', q4: '', q5: '', q6: '' },
166
+ version: 0,
167
+ updatedAt: null,
168
+ },
169
+ archives: [],
170
+ scripts: {},
171
+ peerHistory: [],
172
+ account: null,
173
+ topics: {
174
+ date: '',
175
+ items: [],
176
+ generatedAt: null,
177
+ profileVersion: 0,
178
+ stale: false,
179
+ },
180
+ license: {
181
+ status: 'inactive',
182
+ plan: '',
183
+ expiresAt: null,
184
+ activatedAt: null,
185
+ },
186
+ settings: {},
187
+ };
188
+ }
189
+
190
+ function mergeState(raw) {
191
+ const base = emptyState();
192
+ if (!raw || typeof raw !== 'object') return base;
193
+ return {
194
+ ...base,
195
+ ...raw,
196
+ profile: {
197
+ ...base.profile,
198
+ ...(raw.profile || {}),
199
+ answers: { ...base.profile.answers, ...(raw.profile?.answers || {}) },
200
+ },
201
+ topics: { ...base.topics, ...(raw.topics || {}) },
202
+ license: { ...base.license, ...(raw.license || {}) },
203
+ settings: { ...base.settings, ...(raw.settings || {}) },
204
+ archives: Array.isArray(raw.archives) ? raw.archives : [],
205
+ scripts: raw.scripts && typeof raw.scripts === 'object' ? raw.scripts : {},
206
+ peerHistory: Array.isArray(raw.peerHistory) ? raw.peerHistory : [],
207
+ account: raw.account && typeof raw.account === 'object' ? raw.account : null,
208
+ };
209
+ }
210
+
211
+ function profileText(profile) {
212
+ const answers = profile?.answers || {};
213
+ return PROFILE_FIELDS.map(([key, label]) => `${label}\n${cleanText(answers[key], 2000) || '(未填写)'}`).join('\n\n');
214
+ }
215
+
216
+ function parseJson(text) {
217
+ const raw = String(text || '').trim();
218
+ const unfenced = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '').trim();
219
+ for (const candidate of [unfenced, raw]) {
220
+ try {
221
+ return JSON.parse(candidate);
222
+ } catch {
223
+ /* scan balanced JSON blocks below */
224
+ }
225
+ }
226
+ for (const candidate of [unfenced, raw]) {
227
+ const starts = [];
228
+ for (let index = 0; index < candidate.length; index += 1) {
229
+ if (candidate[index] === '{' || candidate[index] === '[') starts.push(index);
230
+ }
231
+ for (const start of starts.slice(0, 12)) {
232
+ const stack = [];
233
+ let inString = false;
234
+ let escaped = false;
235
+ for (let index = start; index < candidate.length; index += 1) {
236
+ const char = candidate[index];
237
+ if (inString) {
238
+ if (escaped) escaped = false;
239
+ else if (char === '\\') escaped = true;
240
+ else if (char === '"') inString = false;
241
+ continue;
242
+ }
243
+ if (char === '"') { inString = true; continue; }
244
+ if (char === '{' || char === '[') stack.push(char === '{' ? '}' : ']');
245
+ else if (char === '}' || char === ']') {
246
+ if (stack.pop() !== char) break;
247
+ if (stack.length === 0) {
248
+ try {
249
+ return JSON.parse(candidate.slice(start, index + 1));
250
+ } catch {
251
+ break;
252
+ }
253
+ }
254
+ }
255
+ }
256
+ }
257
+ }
258
+ throw new Error('模型返回的内容不是有效 JSON');
259
+ }
260
+
261
+ function asStringList(value, max = 10) {
262
+ if (!Array.isArray(value)) return [];
263
+ return value.map((item) => cleanText(item, 400)).filter(Boolean).slice(0, max);
264
+ }
265
+
266
+ function normalizeTopics(value) {
267
+ const list = Array.isArray(value) ? value : value?.topics;
268
+ if (!Array.isArray(list) || list.length === 0) throw new Error('模型没有返回选题数组');
269
+ return list.slice(0, 10).map((item, index) => ({
270
+ id: `topic-${Date.now()}-${index}`,
271
+ title: cleanText(item?.title || item?.topic, 120) || `选题 ${index + 1}`,
272
+ platform: item?.platform === '抖音' ? '抖音' : '小红书',
273
+ format: item?.format === '视频' ? '视频' : '图文',
274
+ reason: cleanText(item?.reason || item?.why, 320),
275
+ source: cleanText(item?.source || item?.basis, 200),
276
+ tag: cleanText(item?.tag, 20),
277
+ }));
278
+ }
279
+
280
+ function normalizeScript(value, format) {
281
+ const obj = Array.isArray(value) ? value[0] : value;
282
+ if (!obj || typeof obj !== 'object') throw new Error('模型没有返回脚本对象');
283
+ if (format === 'image') {
284
+ return {
285
+ kind: 'image',
286
+ title: cleanText(obj.title, 80),
287
+ body: cleanText(obj.body, 4000),
288
+ images: (Array.isArray(obj.images) ? obj.images : []).slice(0, 8).map((item, i) => ({
289
+ no: cleanText(typeof item === 'object' ? item?.no : '', 4) || String(i + 1).padStart(2, '0'),
290
+ text: cleanText(typeof item === 'string' ? item : item?.text || item?.desc || item?.content, 600),
291
+ })).filter((item) => item.text),
292
+ tags: asStringList(obj.tags, 10),
293
+ };
294
+ }
295
+ const shots = Array.isArray(obj.shots) ? obj.shots.slice(0, 10).map((shot, i) => ({
296
+ no: cleanText(shot?.no, 4) || String(i + 1).padStart(2, '0'),
297
+ scene: cleanText(shot?.scene || shot?.shot, 300),
298
+ line: cleanText(shot?.line || shot?.dialogue, 500),
299
+ dur: cleanText(shot?.dur || shot?.duration, 20),
300
+ })) : [];
301
+ return {
302
+ kind: 'video',
303
+ title: cleanText(obj.title, 80),
304
+ body: cleanText(obj.body, 4000),
305
+ shots,
306
+ tags: asStringList(obj.tags, 10),
307
+ };
308
+ }
309
+
310
+ function normalizePlan(value) {
311
+ const obj = Array.isArray(value) ? value[0] : value;
312
+ const sections = Array.isArray(obj?.sections) ? obj.sections : Array.isArray(obj) ? obj : [];
313
+ if (!sections.length) throw new Error('模型没有返回方案模块');
314
+ return sections.slice(0, 4).map((section, i) => ({
315
+ no: cleanText(section?.no, 4) || String(i + 1).padStart(2, '0'),
316
+ title: cleanText(section?.title, 80) || ['账号定位与人设打造', '内容方向与选题规划', '拍摄与表达建议', '获客路径与转化策略'][i],
317
+ lead: cleanText(section?.lead || section?.intro, 300),
318
+ bullets: asStringList(section?.bullets || section?.items, 8),
319
+ }));
320
+ }
321
+
322
+ function archiveFingerprint(archive) {
323
+ const content = {
324
+ type: archive?.type || '',
325
+ title: archive?.title || '',
326
+ platform: archive?.platform || '',
327
+ sections: (archive?.sections || []).map((section) => ({
328
+ h: section?.h || '',
329
+ p: section?.p || '',
330
+ list: section?.list || [],
331
+ })),
332
+ };
333
+ return crypto.createHash('sha256').update(JSON.stringify(content)).digest('hex');
334
+ }
335
+
336
+ function toArchive(input) {
337
+ const type = ['peer', 'search', 'topics', 'script', 'plan', 'account'].includes(input?.type) ? input.type : 'script';
338
+ return {
339
+ id: crypto.randomUUID(),
340
+ type,
341
+ scriptFormat: input?.scriptFormat === 'video' ? 'video' : input?.scriptFormat === 'image' ? 'image' : '',
342
+ title: cleanText(input?.title, 180) || '未命名归档',
343
+ platform: cleanText(input?.platform, 40) || '小红书',
344
+ summary: cleanText(input?.summary, 400),
345
+ time: nowISO(),
346
+ profileVersion: Number(input?.profileVersion) || 1,
347
+ sections: Array.isArray(input?.sections)
348
+ ? input.sections.slice(0, 12).map((section) => ({
349
+ h: cleanText(section?.h, 80),
350
+ p: cleanText(section?.p, 4000),
351
+ list: asStringList(section?.list, 20),
352
+ }))
353
+ : [],
354
+ };
355
+ }
356
+
357
+ class Store {
358
+ constructor(dir) {
359
+ this.dir = dir;
360
+ this.file = path.join(dir, 'pomelo.json');
361
+ this.state = emptyState();
362
+ this.queue = Promise.resolve();
363
+ this.logFile = path.join(dir, 'logs', 'host.log');
364
+ }
365
+
366
+ async init() {
367
+ await fs.mkdir(this.dir, { recursive: true, mode: 0o700 });
368
+ await fs.mkdir(path.dirname(this.logFile), { recursive: true, mode: 0o700 });
369
+ try {
370
+ const raw = await fs.readFile(this.file, 'utf8');
371
+ this.state = mergeState(JSON.parse(raw));
372
+ } catch (error) {
373
+ if (error?.code !== 'ENOENT') await this.log(`读取数据失败:${String(error?.message || error)}`);
374
+ await this.persist();
375
+ }
376
+ return this;
377
+ }
378
+
379
+ async log(message) {
380
+ try {
381
+ await fs.appendFile(this.logFile, `[${nowISO()}] ${message}\n`, { mode: 0o600 });
382
+ } catch {
383
+ /* logging must never break a request */
384
+ }
385
+ }
386
+
387
+ async persist() {
388
+ const tmp = `${this.file}.${process.pid}.tmp`;
389
+ const body = JSON.stringify(this.state, null, 2);
390
+ await fs.writeFile(tmp, body, { mode: 0o600 });
391
+ await fs.rename(tmp, this.file);
392
+ }
393
+
394
+ update(mutator) {
395
+ this.queue = this.queue
396
+ .catch(() => {})
397
+ .then(async () => {
398
+ const result = await mutator(this.state);
399
+ await this.persist();
400
+ return result;
401
+ });
402
+ return this.queue;
403
+ }
404
+
405
+ snapshot() {
406
+ return JSON.parse(JSON.stringify(this.state));
407
+ }
408
+ }
409
+
410
+ function rpcError(code, message) {
411
+ const error = new Error(message);
412
+ error.code = code;
413
+ return error;
414
+ }
415
+
416
+ async function readBody(req) {
417
+ let size = 0;
418
+ const chunks = [];
419
+ for await (const chunk of req) {
420
+ size += chunk.length;
421
+ if (size > MAX_BODY) throw rpcError('BODY_TOO_LARGE', '请求体过大');
422
+ chunks.push(chunk);
423
+ }
424
+ const raw = Buffer.concat(chunks).toString('utf8');
425
+ if (!raw) return {};
426
+ try {
427
+ return JSON.parse(raw);
428
+ } catch {
429
+ throw rpcError('BAD_JSON', '请求体不是 JSON');
430
+ }
431
+ }
432
+
433
+ function sendJson(res, status, body, origin) {
434
+ const headers = {
435
+ 'content-type': 'application/json; charset=utf-8',
436
+ 'cache-control': 'no-store',
437
+ };
438
+ if (origin) {
439
+ headers['access-control-allow-origin'] = '*';
440
+ headers['access-control-allow-methods'] = 'POST, OPTIONS';
441
+ headers['access-control-allow-headers'] = 'content-type, x-pomelo-token';
442
+ headers.vary = 'origin';
443
+ }
444
+ res.writeHead(status, headers);
445
+ res.end(JSON.stringify(body));
446
+ }
447
+
448
+ export const inject = ['webServer', 'llm', 'agentDefaultModel'];
449
+
450
+ export async function apply(ctx) {
451
+ const webServer = ctx.webServer || ctx.get?.('webServer');
452
+ const getLlm = () => ctx.llm || ctx.get?.('llm');
453
+ const getDefaultModel = () => ctx.agentDefaultModel || ctx.get?.('agentDefaultModel');
454
+ const store = await new Store(dataDir()).init();
455
+ const token = crypto.randomBytes(24).toString('base64url');
456
+ const rpcUrl = () => {
457
+ const webBase = String(process.env.DSH_WEB_URL || '').replace(/\/$/, '');
458
+ if (webBase) return `${webBase}${RPC_PATH}`;
459
+ try {
460
+ const origin = webServer.getOrigin();
461
+ return `${origin}${RPC_PATH}`;
462
+ } catch {
463
+ return RPC_PATH;
464
+ }
465
+ };
466
+
467
+ await store.log(`Host 启动;数据目录 ${dataDir()};模型服务 ${getLlm() ? '可用' : '启动时未就绪'};web=${process.env.DSH_WEB_URL || 'n/a'}`);
468
+
469
+ if (!webServer) {
470
+ await store.log('webServer 不可用,无法为浏览器半区提供 RPC');
471
+ return;
472
+ }
473
+
474
+ if (typeof ctx.on === 'function') {
475
+ ctx.on('webserver/index-inject', (table) => {
476
+ if (!Array.isArray(table)) return;
477
+ table.push({ kind: 'global', name: '__POMELO_TOKEN__', value: token });
478
+ table.push({ kind: 'global', name: '__POMELO_RPC__', value: rpcUrl() });
479
+ table.push({ kind: 'global', name: '__POMELO_LICENSE__', value: licenseView(store.snapshot().license) });
480
+ });
481
+ }
482
+
483
+ // A raw index tap is a second, independent injection path. It keeps the
484
+ // browser half working even when a composition renders index.html without
485
+ // the structured injection table.
486
+ if (typeof webServer.tapIndex === 'function') {
487
+ webServer.tapIndex((html) =>
488
+ String(html).replace(
489
+ '</head>',
490
+ `<script>window.__POMELO_TOKEN__=${JSON.stringify(token)};window.__POMELO_RPC__=${JSON.stringify(rpcUrl())};window.__POMELO_LICENSE__=${JSON.stringify(licenseView(store.snapshot().license))};</script></head>`,
491
+ ),
492
+ );
493
+ }
494
+
495
+ function postsForPrompt(posts) {
496
+ return (posts || []).slice(0, 12).map((post) => ({
497
+ title: cleanText(post.title, 180),
498
+ author: cleanText(post.author, 80),
499
+ likes: cleanText(post.likes, 20),
500
+ platform: cleanText(post.platform, 20),
501
+ }));
502
+ }
503
+
504
+ async function analyzeSearch(posts, keyword, platform) {
505
+ const prompt = `下面是刚从${platform}公开搜索「${keyword}」读到的真实结果(不是示例数据):
506
+ ${JSON.stringify(postsForPrompt(posts), null, 2)}
507
+ 请基于这些标题、作者和互动数据,分析:
508
+ 1. themes:这批内容在反复讨论的 3-5 个需求/痛点;
509
+ 2. gaps:用户还没被满足、你可以切入的 2-4 个空位;
510
+ 3. angles:5 个具体、今天就能拍的内容角度。
511
+ 只输出 JSON,不要 Markdown:{"themes":["..."],"gaps":["..."],"angles":["..."]}
512
+ 不要编造结果里没有出现的品牌、数字或客户。`;
513
+ const text = await complete(prompt, { temperature: 0.45, maxTokens: 1800 });
514
+ const parsed = parseJson(text);
515
+ return {
516
+ themes: asStringList(parsed?.themes, 5),
517
+ gaps: asStringList(parsed?.gaps, 4),
518
+ angles: asStringList(parsed?.angles, 5),
519
+ };
520
+ }
521
+
522
+ function parseFollower(value) {
523
+ const text = String(value || '').replace(/[^\d.万亿wWkK]/g, '');
524
+ if (!text) return null;
525
+ const unit = /[万亿]/.test(String(value)) ? 100000000 : /[wWkK]/.test(String(value)) ? 10000 : 1;
526
+ const number = Number.parseFloat(text);
527
+ return Number.isFinite(number) ? Math.round(number * unit) : null;
528
+ }
529
+
530
+ function comparePeer(previous, result) {
531
+ if (!previous) return null;
532
+ const previousUrls = new Set((previous.posts || []).map((post) => post.url).filter(Boolean));
533
+ const newPosts = (result.posts || []).filter((post) => post.url && !previousUrls.has(post.url));
534
+ const before = parseFollower(previous.followers);
535
+ const after = parseFollower(result.followers);
536
+ return {
537
+ previousAt: previous.collectedAt || null,
538
+ previousPostCount: (previous.posts || []).length,
539
+ newCount: newPosts.length,
540
+ newPosts: newPosts.slice(0, 12),
541
+ followerDelta: before !== null && after !== null ? after - before : null,
542
+ previousFollowers: previous.followers || '—',
543
+ currentFollowers: result.followers || '—',
544
+ };
545
+ }
546
+
547
+ async function analyzePeer(posts, account, platform, comparison = null) {
548
+ const comparisonText = comparison
549
+ ? `\n【与上次对比】上次采集 ${comparison.previousPostCount} 条,这次新增 ${comparison.newCount} 条;粉丝变化 ${comparison.followerDelta === null ? '未知' : comparison.followerDelta}。新增内容:${comparison.newPosts.map((post) => post.title).slice(0, 6).join(';') || '无'}。\n`
550
+ : '';
551
+ const prompt = `下面是${platform}上「${account}」的真实公开作品结果:
552
+ ${JSON.stringify(postsForPrompt(posts), null, 2)}
553
+ ${comparisonText}
554
+ 请拆解这个同行最近的内容打法,输出 JSON:{"summary":["3-5 条具体观察"],"tags":["3 个可复用标签"]}
555
+ 只输出 JSON,不要 Markdown。不要编造结果里没有的数据。`;
556
+ const text = await complete(prompt, { temperature: 0.45, maxTokens: 1400 });
557
+ const parsed = parseJson(text);
558
+ return {
559
+ summary: asStringList(parsed?.summary, 5),
560
+ tags: asStringList(parsed?.tags, 4),
561
+ };
562
+ }
563
+
564
+ async function installBrowser() {
565
+ const first = await browserStatus();
566
+ if (first.installed) return first;
567
+ const pluginRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
568
+ await store.log('开始安装 Chromium(puppeteer)');
569
+ try {
570
+ await execFileAsync('npx', ['puppeteer', 'browsers', 'install', 'chrome'], {
571
+ cwd: pluginRoot,
572
+ timeout: 15 * 60 * 1000,
573
+ maxBuffer: 4 * 1024 * 1024,
574
+ env: process.env,
575
+ });
576
+ } catch (error) {
577
+ await store.log(`Chromium 安装失败:${error?.message || error}`);
578
+ throw rpcError('BROWSER_INSTALL_FAILED', 'Chromium 安装失败,请在项目目录运行 npm run browser:install 后重试');
579
+ }
580
+ const next = await browserStatus();
581
+ await store.log(`Chromium 安装完成:${next.executable || '未找到路径'}`);
582
+ return next;
583
+ }
584
+
585
+ async function complete(prompt, options = {}) {
586
+ const llm = getLlm();
587
+ const defaultModel = getDefaultModel();
588
+ if (!llm || !defaultModel) throw rpcError('MODEL_UNAVAILABLE', 'DSH 模型服务未就绪');
589
+ const selection = defaultModel.currentSelection?.();
590
+ if (!selection?.provider || !selection?.model) throw rpcError('MODEL_UNAVAILABLE', '没有读取到 DSH 默认模型');
591
+
592
+ let reasoningEffort = selection.reasoningEffort;
593
+ try {
594
+ const info = await llm.resolveModelInfo?.(selection.provider, selection.model);
595
+ const efforts = info?.reasoning?.efforts?.map((item) => String(item.id)) || [];
596
+ const minimal = ['none', 'minimal', 'low', 'off'].find((item) => efforts.includes(item));
597
+ if (minimal) reasoningEffort = minimal;
598
+ } catch {
599
+ /* adapter may not expose reasoning metadata */
600
+ }
601
+
602
+ async function once(maxTokens, effort) {
603
+ const input = {
604
+ provider: selection.provider,
605
+ model: selection.model,
606
+ system: SYSTEM_PROMPT,
607
+ messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
608
+ temperature: options.temperature ?? 0.7,
609
+ maxTokens,
610
+ };
611
+ if (effort) input.reasoningEffort = effort;
612
+ let text = '';
613
+ let blockText = '';
614
+ let reasoning = '';
615
+ let failure = null;
616
+ let finishReason = '';
617
+ let usage = null;
618
+ const seen = new Set();
619
+ for await (const chunk of llm.stream(input)) {
620
+ if (chunk?.type) seen.add(chunk.type);
621
+ if (chunk?.type === 'text-delta') text += chunk.text || '';
622
+ if (chunk?.type === 'reasoning-delta') reasoning += chunk.text || '';
623
+ if (chunk?.type === 'block-end' && chunk.block?.type === 'text') blockText += chunk.block.text || '';
624
+ if (chunk?.type === 'usage') usage = chunk.usage || null;
625
+ if (chunk?.type === 'finish') {
626
+ finishReason = chunk.reason?.kind || '';
627
+ if (chunk.reason?.kind === 'error' || chunk.reason?.kind === 'aborted') {
628
+ failure = chunk.reason.failure || { message: '模型调用中断' };
629
+ }
630
+ }
631
+ }
632
+ if (!text && blockText) text = blockText;
633
+ return { text: text.trim(), reasoning: reasoning.trim(), failure, seen: [...seen], finishReason, usage };
634
+ }
635
+
636
+ const firstMax = options.maxTokens ?? 6000;
637
+ let result = await once(firstMax, reasoningEffort);
638
+ if (result.failure) {
639
+ await store.log(`模型失败:${result.failure.message || '未知失败'}(finish=${result.finishReason};chunks: ${result.seen.join(',')})`);
640
+ throw rpcError('MODEL_FAILED', result.failure.message || '模型调用失败');
641
+ }
642
+ // Reasoning models can spend the whole output budget before emitting the
643
+ // answer. Retry empty or reasoning-only replies with a larger budget.
644
+ if (!result.text) {
645
+ const retryMax = Math.max(9000, firstMax * 2);
646
+ await store.log(`模型只返回了思考内容,使用 ${retryMax} tokens 重试(chunks: ${result.seen.join(',')};reasoning=${result.reasoning.length};finish=${result.finishReason})`);
647
+ result = await once(retryMax, reasoningEffort);
648
+ if (result.failure) {
649
+ await store.log(`模型重试失败:${result.failure.message || '未知失败'}(finish=${result.finishReason})`);
650
+ throw rpcError('MODEL_FAILED', result.failure.message || '模型调用失败');
651
+ }
652
+ }
653
+ if (!result.text.trim()) {
654
+ await store.log(`模型空响应(chunks: ${result.seen.join(',') || 'none'};reasoning=${result.reasoning.length};finish=${result.finishReason};usage=${JSON.stringify(result.usage)};provider=${selection.provider};model=${selection.model})`);
655
+ throw rpcError('MODEL_EMPTY', '模型没有返回正文,请重试或换一个非推理模型');
656
+ }
657
+ return result.text.trim();
658
+ }
659
+
660
+ async function completeJson(prompt, options = {}) {
661
+ let lastError = null;
662
+ for (let attempt = 0; attempt < 2; attempt += 1) {
663
+ const retryPrompt = attempt === 0
664
+ ? prompt
665
+ : `${prompt}\n\n上一次输出无法解析为 JSON。请只返回严格 JSON,不要解释,不要 Markdown 代码块,不要遗漏括号。`;
666
+ const raw = await complete(retryPrompt, {
667
+ ...options,
668
+ maxTokens: attempt === 0 ? options.maxTokens : Math.max(6000, (options.maxTokens || 3000) * 2),
669
+ });
670
+ try {
671
+ return parseJson(raw);
672
+ } catch (error) {
673
+ lastError = error;
674
+ await store.log(`JSON 解析失败(第 ${attempt + 1} 次):${String(error?.message || error)}`);
675
+ }
676
+ }
677
+ throw rpcError('MODEL_BAD_JSON', lastError?.message || '模型返回的内容不是有效 JSON,请重试');
678
+ }
679
+
680
+ async function generateTopics() {
681
+ const state = store.snapshot();
682
+ const answers = state.profile.answers;
683
+ if (!answers.q1?.trim() || !answers.q3?.trim() || !answers.q5?.trim()) {
684
+ throw rpcError('PROFILE_REQUIRED', '请先完成第 1、3、5 个问题');
685
+ }
686
+ const recent = state.archives.slice(0, 8).map((item) => `- ${item.title}`).join('\n') || '暂无';
687
+ const prompt = `今天是 ${todayKey()}。请为下面这位创作者生成 10 个今天可以拍的内容选题。
688
+
689
+ 【创作者资料】
690
+ ${profileText(state.profile)}
691
+
692
+ 【最近生成过的内容,尽量避开重复】
693
+ ${recent}
694
+
695
+ 只返回一个 JSON 数组,不要 Markdown 代码块。数组每一项:
696
+ {
697
+ "title": "选题标题,具体、有钩子,不超过 26 字",
698
+ "platform": "小红书" 或 "抖音",
699
+ "format": "图文" 或 "视频",
700
+ "reason": "为什么适合这个创作者,一句话",
701
+ "source": "来自哪段资料或什么判断,一句话",
702
+ "tag": "适合马上拍 / 收藏向 / 涨粉 / 转化 / 建立信任 / 人设 / 观点 六选一"
703
+ }
704
+
705
+ 要求:
706
+ 1. 10 条,至少覆盖 3 个不同的内容方向;干货、故事、观点、转化都要有。
707
+ 2. 必须结合资料里的城市、客户、产品特点、个人经历,不要写通用鸡汤。
708
+ 3. 标题要像真人会点开的标题,不要用“揭秘”“干货满满”这类空词。`;
709
+
710
+ const items = normalizeTopics(await completeJson(prompt, { maxTokens: 3000, temperature: 0.85 }));
711
+ const updated = await store.update((state) => {
712
+ state.topics = {
713
+ date: todayKey(),
714
+ items,
715
+ generatedAt: nowISO(),
716
+ profileVersion: state.profile.version,
717
+ stale: false,
718
+ };
719
+ return state.topics;
720
+ });
721
+ return updated;
722
+ }
723
+
724
+ async function replaceTopic(payload) {
725
+ const state = store.snapshot();
726
+ if (!state.profile.answers.q1?.trim()) throw rpcError('PROFILE_REQUIRED', '请先填写资料,再换选题');
727
+ const topicId = cleanText(payload?.id, 120);
728
+ const title = cleanText(payload?.title, 160);
729
+ const current = (state.topics.items || []).find((item) => (topicId && item.id === topicId) || (!topicId && item.title === title));
730
+ if (!current) throw rpcError('TOPIC_NOT_FOUND', '找不到这条选题,请重新生成今日选题');
731
+ const avoid = (state.topics.items || []).map((item) => item.title).filter(Boolean).join(';');
732
+ const prompt = `为下面这位创作者换一个新的内容选题。
733
+ 【创作者资料】
734
+ ${profileText(state.profile)}
735
+
736
+ 【已有选题,必须避开】
737
+ ${avoid}
738
+
739
+ 【要替换的选题】
740
+ ${current.title}(${current.platform} · ${current.format})
741
+
742
+ 只返回一个 JSON 对象,不要 Markdown:
743
+ {"title":"具体标题,不超过26字","platform":"小红书"或"抖音","format":"图文"或"视频","reason":"为什么适合这个创作者,一句话","source":"依据哪段资料,一句话","tag":"适合马上拍/收藏向/涨粉/转化/建立信任/人设/观点 六选一"}
744
+ 要求:角度、客户场景、标题钩子都要和已有选题不同;必须结合城市、客户和业务。`;
745
+ const parsed = await completeJson(prompt, { maxTokens: 1200, temperature: 0.85 });
746
+ const next = normalizeTopics([parsed])[0];
747
+ next.id = current.id || next.id;
748
+ const items = (state.topics.items || []).map((item) => (item.id === current.id ? next : item));
749
+ await store.update((currentState) => {
750
+ currentState.topics.items = items;
751
+ return true;
752
+ });
753
+ return { items, replaced: next };
754
+ }
755
+
756
+ async function analyzeAccount(payload) {
757
+ const platform = payload?.platform === '抖音' ? '抖音' : '小红书';
758
+ const account = cleanText(payload?.account, 160);
759
+ if (!account) throw rpcError('EMPTY_ACCOUNT', '先粘贴你的主页链接或 ID');
760
+ let result;
761
+ try {
762
+ result = await searchPeerProfile({ platform, account });
763
+ } catch (error) {
764
+ await store.log(`账号分析采集失败:${platform} / ${account} / ${error?.message || error}`);
765
+ throw rpcError(error?.code || 'BROWSER_FAILED', error?.message || '本地浏览器采集失败');
766
+ }
767
+ const state = store.snapshot();
768
+ const prompt = `下面是创作者自己的${platform}账号公开主页和作品。请做账号诊断。
769
+ 【创作者资料】
770
+ ${profileText(state.profile)}
771
+
772
+ 【账号】
773
+ 昵称:${result.name}
774
+ 粉丝:${result.followers}
775
+ 公开作品:${result.postCount}
776
+ 作品列表:
777
+ ${JSON.stringify(postsForPrompt(result.posts), null, 2)}
778
+
779
+ 只输出 JSON 对象,不要 Markdown:
780
+ {"positioning":"一句话账号定位判断","summary":["3-4 条具体诊断,必须引用作品或数据"],"match":["2-3 条与目标客户的匹配/不匹配判断"]}
781
+ 不要编造作品里没有的数据。`;
782
+ let insight = { positioning: '', summary: [], match: [] };
783
+ try {
784
+ const parsed = await completeJson(prompt, { maxTokens: 1600, temperature: 0.5 });
785
+ insight = {
786
+ positioning: cleanText(parsed?.positioning, 300),
787
+ summary: asStringList(parsed?.summary, 5),
788
+ match: asStringList(parsed?.match, 4),
789
+ };
790
+ } catch (error) {
791
+ await store.log(`账号分析失败:${error?.message || error}`);
792
+ }
793
+ const record = {
794
+ platform,
795
+ account: account.replace(/^@/, ''),
796
+ name: result.name,
797
+ avatar: result.avatar,
798
+ followers: result.followers,
799
+ postCount: result.postCount,
800
+ profileUrl: result.profileUrl,
801
+ positioning: insight.positioning,
802
+ summary: insight.summary,
803
+ match: insight.match,
804
+ analyzedAt: nowISO(),
805
+ };
806
+ await store.update((current) => {
807
+ current.account = record;
808
+ return true;
809
+ });
810
+ return record;
811
+ }
812
+
813
+ async function generateScript(payload) {
814
+ const topic = payload?.topic || {};
815
+ const format = payload?.format === 'video' ? 'video' : 'image';
816
+ const state = store.snapshot();
817
+ if (!state.profile.answers.q1?.trim()) throw rpcError('PROFILE_REQUIRED', '请先填写资料,再生成脚本');
818
+ const cacheKey = `${format}:${cleanText(topic.id || topic.title, 160)}`;
819
+ const cached = state.scripts?.[cacheKey];
820
+ if (cached?.script && !payload?.force) {
821
+ return { ...cached.script, cached: true, cachedAt: cached.createdAt || null };
822
+ }
823
+ const prompt = `请为下面这个选题写一份${format === 'image' ? '图文' : '视频'}脚本。
824
+
825
+ 【创作者资料】
826
+ ${profileText(state.profile)}
827
+
828
+ 【选题】
829
+ 标题:${cleanText(topic.title, 120)}
830
+ 平台:${topic.platform === '抖音' ? '抖音' : '小红书'}
831
+ 形式:${format === 'image' ? '图文' : '视频'}
832
+ 推荐理由:${cleanText(topic.reason, 300)}
833
+
834
+ 只返回一个 JSON 对象,不要 Markdown 代码块。
835
+ ${format === 'image'
836
+ ? `{
837
+ "title": "标题,不超过 20 字",
838
+ "body": "正文,300-600 字,分 2-4 段,口语化",
839
+ "images": ["第 1 张:封面画面和封面字;第 2 张:……", "……"],
840
+ "tags": ["#标签1", "#标签2", "5-8 个"]
841
+ }`
842
+ : `{
843
+ "title": "标题,不超过 20 字",
844
+ "body": "口播文案,150-300 字,开头 3 秒先给结论或冲突",
845
+ "shots": [
846
+ {"no": "01", "scene": "画面/景别", "line": "台词或字幕", "dur": "0-3s"}
847
+ ],
848
+ "tags": ["#标签1", "#标签2", "5-8 个"]
849
+ }`}
850
+
851
+ 要求:所有内容都要和资料里的业务、客户、产品特点有关;不要编造数据;图片建议/拍摄脚本要能直接执行。`;
852
+
853
+ const raw = await complete(prompt, { maxTokens: 2600, temperature: 0.75 });
854
+ const script = normalizeScript(parseJson(raw), format);
855
+ const createdAt = nowISO();
856
+ await store.update((current) => {
857
+ if (!current.scripts || typeof current.scripts !== 'object') current.scripts = {};
858
+ current.scripts[cacheKey] = {
859
+ script,
860
+ format,
861
+ topicTitle: cleanText(topic.title, 180),
862
+ createdAt,
863
+ };
864
+ return true;
865
+ });
866
+ return { ...script, cached: false, cachedAt: createdAt };
867
+ }
868
+
869
+ async function generatePlan() {
870
+ const state = store.snapshot();
871
+ if (!state.profile.answers.q1?.trim() || !state.profile.answers.q3?.trim() || !state.profile.answers.q5?.trim()) {
872
+ throw rpcError('PROFILE_REQUIRED', '请先完成第 1、3、5 个问题');
873
+ }
874
+ const prompt = `请为下面这位创作者生成一份内容获客方案。
875
+
876
+ 【创作者资料】
877
+ ${profileText(state.profile)}
878
+
879
+ ${state.account ? `【账号诊断】\n定位:${state.account.positioning || ''}\n${(state.account.summary || []).map((item) => `- ${item}`).join('\n')}\n${(state.account.match || []).map((item) => `- ${item}`).join('\n')}` : ''}
880
+
881
+ 只返回一个 JSON 对象,不要 Markdown 代码块:
882
+ {
883
+ "sections": [
884
+ {
885
+ "no": "01",
886
+ "title": "账号定位与人设打造",
887
+ "lead": "一句话说明这一节要解决什么",
888
+ "bullets": ["具体建议 1", "具体建议 2", "具体建议 3", "具体建议 4"]
889
+ },
890
+ {"no":"02","title":"内容方向与选题规划","lead":"...","bullets":["..."]},
891
+ {"no":"03","title":"拍摄与表达建议","lead":"...","bullets":["..."]},
892
+ {"no":"04","title":"获客路径与转化策略","lead":"...","bullets":["..."]}
893
+ ]
894
+ }
895
+
896
+ 要求:
897
+ 1. 四个模块标题固定,顺序固定。
898
+ 2. 每条建议都要结合资料里的城市、客户、产品、经历,不要通用模板。
899
+ 3. 内容方向要给出比例和发布节奏;获客路径要具体到用户动作和承接方式。
900
+ 4. 不要编造资料里没有的数据。`;
901
+
902
+ const sections = normalizePlan(await completeJson(prompt, { maxTokens: 3000, temperature: 0.7 }));
903
+ const archive = toArchive({
904
+ type: 'plan',
905
+ title: `${cleanText(state.profile.answers.q1, 30) || '我的'} · 内容获客方案`,
906
+ platform: '小红书 / 抖音',
907
+ summary: '账号定位、内容方向、拍摄表达、获客路径 4 个模块',
908
+ profileVersion: state.profile.version || 1,
909
+ sections: sections.map((section) => ({ h: section.title, p: section.lead, list: section.bullets })),
910
+ });
911
+ await store.update((state) => {
912
+ state.archives.unshift(archive);
913
+ state.archives = state.archives.slice(0, 500);
914
+ return archive;
915
+ });
916
+ return { sections, archive };
917
+ }
918
+
919
+ async function reviewAsk(payload) {
920
+ const archive = payload?.archive;
921
+ const question = cleanText(payload?.question, 2000);
922
+ if (!question) throw rpcError('BAD_REQUEST', '问题不能为空');
923
+ if (!archive || !Array.isArray(archive.sections)) throw rpcError('BAD_REQUEST', '归档内容不完整');
924
+ const state = store.snapshot();
925
+ const history = Array.isArray(payload?.history) ? payload.history.slice(-8) : [];
926
+ const archiveText = [
927
+ `标题:${cleanText(archive.title, 200)}`,
928
+ `类型:${cleanText(archive.type, 30)}`,
929
+ `摘要:${cleanText(archive.summary, 400)}`,
930
+ ...archive.sections.flatMap((section) => [
931
+ `## ${cleanText(section.h, 80)}`,
932
+ cleanText(section.p, 4000),
933
+ ...(Array.isArray(section.list) ? section.list.map((item) => `- ${cleanText(item, 500)}`) : []),
934
+ ]),
935
+ ].join('\n');
936
+ const historyText = history.map((m) => `${m.role === 'user' ? '用户' : '助手'}:${cleanText(m.text, 1200)}`).join('\n');
937
+ const prompt = `下面是创作者的一条归档内容,以及我们的复盘对话。请基于归档和创作者资料回答用户的新问题。
938
+
939
+ 【创作者资料】
940
+ ${profileText(state.profile)}
941
+
942
+ 【归档内容】
943
+ ${archiveText}
944
+
945
+ 【之前的对话】
946
+ ${historyText || '(还没有)'}
947
+
948
+ 【用户的新问题】
949
+ ${question}
950
+
951
+ 要求:中文,具体、可执行;结合归档里的细节;不要编造数据;如果信息不足,直接说明需要补什么。只输出回答正文。`;
952
+ const text = await complete(prompt, { maxTokens: 1600, temperature: 0.7 });
953
+ let savedChat = null;
954
+ await store.update((current) => {
955
+ const item = (current.archives || []).find((entry) => entry.id === archive.id);
956
+ if (!item) return false;
957
+ const now = nowISO();
958
+ item.chat = [
959
+ ...(Array.isArray(item.chat) ? item.chat : []),
960
+ { role: 'user', text: question, time: now },
961
+ { role: 'ai', text, time: now },
962
+ ].slice(-60);
963
+ savedChat = item.chat;
964
+ return true;
965
+ });
966
+ return { text, chat: savedChat };
967
+ }
968
+
969
+ async function openDataFolder() {
970
+ const target = dataDir();
971
+ if (process.platform === 'darwin') await execFileAsync('open', [target]);
972
+ else if (process.platform === 'win32') await execFileAsync('explorer', [target]);
973
+ else await execFileAsync('xdg-open', [target]);
974
+ return { path: target };
975
+ }
976
+
977
+ async function exportData() {
978
+ const state = store.snapshot();
979
+ const lines = [
980
+ '# 柚客 Pomelo 本地导出',
981
+ '',
982
+ `导出时间:${new Date().toISOString()}`,
983
+ '',
984
+ '## 创作者资料',
985
+ '',
986
+ ...PROFILE_FIELDS.map(([key, label]) => `### ${label}
987
+ ${state.profile.answers[key] || '(未填写)'}
988
+ `),
989
+ '',
990
+ '## 今日选题',
991
+ '',
992
+ ...(state.topics.items || []).map((topic, index) => `${index + 1}. ${topic.title}(${topic.platform} · ${topic.format})
993
+ ${topic.reason || ''}`),
994
+ '',
995
+ '## 获客方案',
996
+ '',
997
+ ...(state.archives.find((item) => item.type === 'plan')?.sections || []).flatMap((section) => [`### ${section.h}`, section.p || '', ...(section.list || []).map((item) => `- ${item}`), '']),
998
+ '',
999
+ '## 复盘归档',
1000
+ '',
1001
+ ...state.archives.map((archive) => `- [${archive.type}] ${archive.title} · ${archive.time}`),
1002
+ '',
1003
+ ];
1004
+ const folder = path.join(os.homedir(), 'Downloads');
1005
+ let target = dataDir();
1006
+ try {
1007
+ await fs.access(folder);
1008
+ target = folder;
1009
+ } catch {
1010
+ /* use data dir */
1011
+ }
1012
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
1013
+ const file = path.join(target, `pomelo-export-${stamp}.md`);
1014
+ await fs.writeFile(file, lines.join('\n'), 'utf8');
1015
+ return { path: file, archives: state.archives.length };
1016
+ }
1017
+
1018
+ async function dispatch(action, payload) {
1019
+ switch (action) {
1020
+ case 'state': {
1021
+ const state = store.snapshot();
1022
+ let model = null;
1023
+ try {
1024
+ model = getDefaultModel()?.currentSelection?.() || null;
1025
+ } catch {
1026
+ model = null;
1027
+ }
1028
+ return { ...state, license: licenseView(state.license), model };
1029
+ }
1030
+ case 'profile.save': {
1031
+ const answers = {};
1032
+ for (const [key] of PROFILE_FIELDS) answers[key] = cleanText(payload?.profile?.[key], 4000);
1033
+ const profile = await store.update((state) => {
1034
+ state.profile = {
1035
+ answers,
1036
+ version: (state.profile.version || 0) + 1,
1037
+ updatedAt: nowISO(),
1038
+ };
1039
+ state.topics.stale = true;
1040
+ return state.profile;
1041
+ });
1042
+ return profile;
1043
+ }
1044
+ case 'model.check': {
1045
+ const text = await complete('只回复 OK 两个字母,不要标点,不要解释。', { maxTokens: 24, temperature: 0 });
1046
+ let selection = null;
1047
+ try {
1048
+ selection = getDefaultModel()?.currentSelection?.() || null;
1049
+ } catch {
1050
+ selection = null;
1051
+ }
1052
+ return { text, model: selection };
1053
+ }
1054
+ case 'topics.generate':
1055
+ return generateTopics();
1056
+ case 'topic.replace':
1057
+ return replaceTopic(payload);
1058
+ case 'account.analyze':
1059
+ return analyzeAccount(payload);
1060
+ case 'script.generate':
1061
+ return generateScript(payload);
1062
+ case 'plan.generate':
1063
+ return generatePlan();
1064
+ case 'review.ask':
1065
+ return reviewAsk(payload);
1066
+ case 'archive.add': {
1067
+ const archive = toArchive(payload?.archive || {});
1068
+ const fingerprint = archiveFingerprint(archive);
1069
+ const existing = store.snapshot().archives.find((item) => archiveFingerprint(item) === fingerprint);
1070
+ if (existing) return { ...existing, duplicate: true };
1071
+ const saved = await store.update((state) => {
1072
+ state.archives.unshift(archive);
1073
+ state.archives = state.archives.slice(0, 500);
1074
+ return archive;
1075
+ });
1076
+ return { ...saved, duplicate: false };
1077
+ }
1078
+ case 'archive.delete': {
1079
+ const id = cleanText(payload?.id, 80);
1080
+ const removed = await store.update((state) => {
1081
+ const before = state.archives.length;
1082
+ state.archives = state.archives.filter((item) => item.id !== id);
1083
+ return state.archives.length !== before;
1084
+ });
1085
+ return { removed };
1086
+ }
1087
+ case 'state.clear': {
1088
+ await store.update((state) => {
1089
+ const license = state.license;
1090
+ Object.assign(state, emptyState());
1091
+ state.license = license;
1092
+ return true;
1093
+ });
1094
+ return true;
1095
+ }
1096
+ case 'model.list': {
1097
+ const llm = getLlm();
1098
+ const defaultModel = getDefaultModel();
1099
+ const selection = defaultModel?.currentSelection?.() || null;
1100
+ if (!llm) throw rpcError('MODEL_UNAVAILABLE', 'DSH 模型服务未就绪');
1101
+ const providers = typeof llm.listProviders === 'function' ? llm.listProviders() : [];
1102
+ const result = [];
1103
+ for (const provider of providers) {
1104
+ try {
1105
+ const models = typeof llm.listModels === 'function' ? await llm.listModels(provider.id) : [];
1106
+ result.push({
1107
+ id: provider.id,
1108
+ name: provider.name || provider.id,
1109
+ models: (models || []).map((model) => ({
1110
+ id: model.id,
1111
+ name: model.name || model.id,
1112
+ description: model.description || '',
1113
+ })),
1114
+ });
1115
+ } catch (error) {
1116
+ result.push({ id: provider.id, name: provider.name || provider.id, models: [], error: String(error?.message || error) });
1117
+ }
1118
+ }
1119
+ return { selection, providers: result };
1120
+ }
1121
+ case 'model.select': {
1122
+ const llm = getLlm();
1123
+ const defaultModel = getDefaultModel();
1124
+ const provider = cleanText(payload?.provider, 120);
1125
+ const model = cleanText(payload?.model, 240);
1126
+ if (!llm || !defaultModel) throw rpcError('MODEL_UNAVAILABLE', 'DSH 模型服务未就绪');
1127
+ if (!provider || !model) throw rpcError('MODEL_EMPTY', '请选择模型');
1128
+ let models = [];
1129
+ try {
1130
+ models = await llm.listModels(provider);
1131
+ } catch (error) {
1132
+ throw rpcError('MODEL_LIST_FAILED', `读取 ${provider} 模型失败:${error?.message || error}`);
1133
+ }
1134
+ if (!models.some((item) => item.id === model)) {
1135
+ throw rpcError('MODEL_INVALID', '这个模型不在当前 DSH 可选列表中');
1136
+ }
1137
+ try {
1138
+ await defaultModel.saveSelection({ provider, model });
1139
+ } catch (error) {
1140
+ throw rpcError('MODEL_SAVE_FAILED', `切换模型失败:${error?.message || error}`);
1141
+ }
1142
+ const selection = defaultModel.currentSelection?.() || { provider, model };
1143
+ await store.log(`模型切换:${provider} / ${model}`);
1144
+ return { selection };
1145
+ }
1146
+ case 'license.status':
1147
+ return licenseView(store.snapshot().license);
1148
+ case 'license.validate': {
1149
+ const state = store.snapshot();
1150
+ const current = state.license;
1151
+ if (!current?.codeId || !state.installId) return licenseView(current);
1152
+ try {
1153
+ const { response } = await licenseRequest('/api/license/validate', { installId: state.installId }, 8000);
1154
+ if (response.status === 404 || response.status === 403) {
1155
+ await store.update((next) => { next.license = null; return true; });
1156
+ return { status: 'inactive' };
1157
+ }
1158
+ const data = await response.json().catch(() => ({}));
1159
+ if (response.ok && data?.ok && data.license) {
1160
+ const remote = data.license;
1161
+ const license = {
1162
+ status: 'active', plan: remote.plan || remote.tier || current.plan,
1163
+ tier: remote.tier || remote.plan || current.tier,
1164
+ expiresAt: remote.expiresAt || current.expiresAt,
1165
+ activatedAt: remote.activatedAt || current.activatedAt,
1166
+ codeId: remote.codeId || current.codeId,
1167
+ maskedCode: remote.maskedCode || current.maskedCode,
1168
+ };
1169
+ return store.update((next) => { next.license = license; return licenseView(license); });
1170
+ }
1171
+ } catch {
1172
+ /* offline: keep the last known local license */
1173
+ }
1174
+ return licenseView(current);
1175
+ }
1176
+ case 'license.activate': {
1177
+ const code = cleanText(payload?.code, 64).toUpperCase().replace(/\s+/g, '');
1178
+ if (!code) throw rpcError('LICENSE_EMPTY', '请先粘贴授权码');
1179
+ const state = store.snapshot();
1180
+ const installId = state.installId || crypto.randomUUID();
1181
+ let response;
1182
+ try {
1183
+ ({ response } = await licenseRequest('/api/activate', { code, installId }, 12000));
1184
+ } catch (error) {
1185
+ await store.log(`授权服务不可达:${error?.message || error}`);
1186
+ throw rpcError('LICENSE_SERVER_UNAVAILABLE', '授权服务器暂时无法连接,请稍后重试');
1187
+ }
1188
+ const data = await response.json().catch(() => ({}));
1189
+ if (!response.ok || !data?.ok) {
1190
+ throw rpcError(data?.error?.code || 'LICENSE_FAILED', data?.error?.message || `授权失败(${response.status})`);
1191
+ }
1192
+ const remote = data.license || {};
1193
+ const license = {
1194
+ status: 'active',
1195
+ plan: remote.plan || remote.tier || 'year',
1196
+ tier: remote.tier || remote.plan || 'year',
1197
+ expiresAt: remote.expiresAt || null,
1198
+ activatedAt: remote.activatedAt || nowISO(),
1199
+ codeId: remote.codeId || null,
1200
+ maskedCode: remote.maskedCode || '',
1201
+ };
1202
+ const next = await store.update((currentState) => {
1203
+ currentState.installId = installId;
1204
+ currentState.license = license;
1205
+ return licenseView(license);
1206
+ });
1207
+ await store.log(`云端授权激活:${license.maskedCode || '••••'} · ${license.tier} · 到期 ${license.expiresAt}`);
1208
+ return next;
1209
+ }
1210
+ case 'data.openFolder':
1211
+ return openDataFolder();
1212
+ case 'data.export':
1213
+ return exportData();
1214
+ case 'browser.status':
1215
+ return browserStatus();
1216
+ case 'browser.login':
1217
+ return openLogin(payload?.platform || '小红书');
1218
+ case 'browser.install':
1219
+ return installBrowser();
1220
+ case 'browser.open':
1221
+ return openLink(payload?.url);
1222
+ case 'search.run': {
1223
+ const platform = payload?.platform === '抖音' ? '抖音' : '小红书';
1224
+ const keyword = cleanText(payload?.keyword, 80);
1225
+ if (!keyword) throw rpcError('EMPTY_KEYWORD', '请输入关键词');
1226
+ let result;
1227
+ try {
1228
+ result = await searchPublic({ platform, keyword });
1229
+ } catch (error) {
1230
+ await store.log(`搜索失败:${platform} / ${keyword} / ${error?.message || error}`);
1231
+ throw rpcError(error?.code || 'BROWSER_FAILED', error?.message || '本地浏览器采集失败');
1232
+ }
1233
+ let report = null;
1234
+ try {
1235
+ report = await analyzeSearch(result.posts, keyword, platform);
1236
+ } catch (error) {
1237
+ await store.log(`搜索结果分析失败:${error?.message || error}`);
1238
+ }
1239
+ return { ...result, report };
1240
+ }
1241
+ case 'peer.history':
1242
+ return store.snapshot().peerHistory || [];
1243
+ case 'peer.run': {
1244
+ const platform = payload?.platform === '抖音' ? '抖音' : '小红书';
1245
+ const account = cleanText(payload?.account, 120);
1246
+ if (!account) throw rpcError('EMPTY_ACCOUNT', '先粘贴同行账号或 ID');
1247
+ let result;
1248
+ try {
1249
+ result = await searchPeerProfile({ platform, account });
1250
+ } catch (error) {
1251
+ await store.log(`同行搜索失败:${platform} / ${account} / ${error?.message || error}`);
1252
+ throw rpcError(error?.code || 'BROWSER_FAILED', error?.message || '本地浏览器采集失败');
1253
+ }
1254
+ const state = store.snapshot();
1255
+ const previous = (state.peerHistory || []).find((item) => item.platform === platform
1256
+ && ((result.profileUrl && item.profileUrl === result.profileUrl)
1257
+ || item.account === account.replace(/^@/, '')));
1258
+ const comparison = comparePeer(previous, result);
1259
+ let insight = { summary: [], tags: [] };
1260
+ try {
1261
+ insight = await analyzePeer(result.posts, account, platform, comparison);
1262
+ } catch (error) {
1263
+ await store.log(`同行分析失败:${error?.message || error}`);
1264
+ }
1265
+ const report = {
1266
+ id: `peer_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
1267
+ account: account.replace(/^@/, ''),
1268
+ name: result.name,
1269
+ avatar: result.avatar,
1270
+ platform,
1271
+ followers: result.followers,
1272
+ postCount: result.postCount,
1273
+ profileUrl: result.profileUrl,
1274
+ posts: result.posts,
1275
+ summary: insight.summary,
1276
+ tags: insight.tags,
1277
+ comparison,
1278
+ collectedAt: result.collectedAt,
1279
+ };
1280
+ await store.update((current) => {
1281
+ current.peerHistory = [report, ...(current.peerHistory || [])].slice(0, 30);
1282
+ return true;
1283
+ });
1284
+ return report;
1285
+ }
1286
+ default:
1287
+ throw rpcError('UNKNOWN_ACTION', `未知操作:${action}`);
1288
+ }
1289
+ }
1290
+
1291
+ const dispose = webServer.register({
1292
+ kind: 'prefix',
1293
+ path: RPC_PATH,
1294
+ handler: async (req, res) => {
1295
+ const origin = typeof req.headers.origin === 'string' ? req.headers.origin : '';
1296
+ const requestUrl = new URL(String(req.url || '/'), 'http://127.0.0.1');
1297
+ const pathname = requestUrl.pathname;
1298
+ await store.log(`HTTP ${req.method} ${requestUrl.pathname}${requestUrl.search ? '?…' : ''}`);
1299
+ if (req.method === 'GET' && pathname === `${RPC_PATH}/token`) {
1300
+ sendJson(res, 200, { token });
1301
+ return;
1302
+ }
1303
+ if (req.method === 'GET' && pathname === RPC_PATH) {
1304
+ const action = String(requestUrl.searchParams.get('action') || '');
1305
+ let payload = {};
1306
+ try {
1307
+ payload = JSON.parse(requestUrl.searchParams.get('payload') || '{}');
1308
+ } catch {
1309
+ sendJson(res, 400, { ok: false, error: { code: 'BAD_JSON', message: 'payload 不是 JSON' } }, origin);
1310
+ return;
1311
+ }
1312
+ if (String(requestUrl.searchParams.get('t') || req.headers['x-pomelo-token'] || '') !== token) {
1313
+ sendJson(res, 403, { ok: false, error: { code: 'AUTH', message: 'RPC token 无效' } }, origin);
1314
+ return;
1315
+ }
1316
+ try {
1317
+ const data = await dispatch(action, payload);
1318
+ sendJson(res, 200, { ok: true, data }, origin);
1319
+ } catch (error) {
1320
+ await store.log(`RPC GET 失败:${String(error?.message || error)}`);
1321
+ sendJson(res, 200, { ok: false, error: { code: error?.code || 'INTERNAL', message: error?.message || '请求失败' } }, origin);
1322
+ }
1323
+ return;
1324
+ }
1325
+ if (req.method === 'OPTIONS') {
1326
+ sendJson(res, 204, {}, origin);
1327
+ return;
1328
+ }
1329
+ if (req.method !== 'POST') {
1330
+ sendJson(res, 405, { ok: false, error: { code: 'METHOD', message: '只支持 POST' } }, origin);
1331
+ return;
1332
+ }
1333
+ if (String(req.headers['x-pomelo-token'] || '') !== token) {
1334
+ sendJson(res, 403, { ok: false, error: { code: 'AUTH', message: 'RPC token 无效' } }, origin);
1335
+ return;
1336
+ }
1337
+ try {
1338
+ const body = await readBody(req);
1339
+ const data = await dispatch(String(body.action || ''), body.payload || {});
1340
+ sendJson(res, 200, { ok: true, data }, origin);
1341
+ } catch (error) {
1342
+ await store.log(`RPC ${String(req.url)} 失败:${String(error?.message || error)}`);
1343
+ sendJson(res, 200, {
1344
+ ok: false,
1345
+ error: {
1346
+ code: error?.code || 'INTERNAL',
1347
+ message: error?.message || '请求失败',
1348
+ },
1349
+ }, origin);
1350
+ }
1351
+ },
1352
+ });
1353
+
1354
+ await store.log(`RPC 已注册:${RPC_PATH}`);
1355
+
1356
+ await store.log(`连接探针已注册:${RPC_PATH}/token`);
1357
+
1358
+ if (typeof ctx.effect === 'function') {
1359
+ ctx.effect(() => dispose);
1360
+ }
1361
+ }