guanwei 1.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/LICENSE +21 -0
- package/README.en.md +202 -0
- package/README.md +247 -0
- package/dist/assets/index-CsOD5rSo.js +102 -0
- package/dist/assets/index-CsOD5rSo.js.map +1 -0
- package/dist/assets/index-DQx62TUr.css +1 -0
- package/dist/favicon.svg +12 -0
- package/dist/index.html +15 -0
- package/eslint.config.js +28 -0
- package/index.html +25 -0
- package/package.json +96 -0
- package/postcss.config.js +10 -0
- package/public/favicon.svg +12 -0
- package/scripts/guanwei +245 -0
- package/scripts/install.sh +79 -0
- package/scripts/release.sh +42 -0
- package/scripts/setup.bat +36 -0
- package/scripts/setup.sh +123 -0
- package/server/src/data/db.json +12059 -0
- package/server/src/data/guanwei.db +0 -0
- package/server/src/data/lifeContext.ts +423 -0
- package/server/src/data/spreads.ts +255 -0
- package/server/src/data/tarotCards.ts +2353 -0
- package/server/src/index.ts +71 -0
- package/server/src/routes/ai.ts +376 -0
- package/server/src/routes/divine.ts +140 -0
- package/server/src/routes/hour.ts +40 -0
- package/server/src/routes/tarot.ts +129 -0
- package/server/src/routes/users.ts +140 -0
- package/server/src/services/chartBrief.ts +131 -0
- package/server/src/services/divineStore.ts +188 -0
- package/server/src/services/hourInference.ts +150 -0
- package/server/src/services/llmProvider.ts +181 -0
- package/server/src/services/promptBuilder.ts +327 -0
- package/server/src/services/relativesCheck.ts +139 -0
- package/server/src/services/sixRelatives.ts +70 -0
- package/server/src/services/skills.ts +116 -0
- package/server/src/types/index.ts +104 -0
- package/server/src/utils/semanticAnalyzer.ts +555 -0
- package/server/src/utils/tarotEngine.ts +849 -0
- package/server/tsconfig.json +18 -0
- package/shared/core/data/classics.ts +276 -0
- package/shared/core/data/courses.ts +211 -0
- package/shared/core/data/duanyu.ts +148 -0
- package/shared/core/data/ganzhi.ts +53 -0
- package/shared/core/data/gua64.ts +93 -0
- package/shared/core/data/liuren.ts +18 -0
- package/shared/core/data/liuyao.ts +73 -0
- package/shared/core/data/qimen.ts +27 -0
- package/shared/core/data/region-full.ts +3 -0
- package/shared/core/data/region.ts +310 -0
- package/shared/core/data/tarotSpreads.ts +78 -0
- package/shared/core/data/xiaoliuren.ts +18 -0
- package/shared/core/data/ziwei.ts +97 -0
- package/shared/core/data/zodiac.ts +20 -0
- package/shared/core/engine/astrology.ts +169 -0
- package/shared/core/engine/bazi.ts +306 -0
- package/shared/core/engine/calendar.ts +127 -0
- package/shared/core/engine/daily.ts +133 -0
- package/shared/core/engine/liuren.ts +86 -0
- package/shared/core/engine/liuyao.ts +93 -0
- package/shared/core/engine/meihua.ts +87 -0
- package/shared/core/engine/qimen.ts +78 -0
- package/shared/core/engine/questionFit.ts +94 -0
- package/shared/core/engine/tarot.ts +69 -0
- package/shared/core/engine/trueSolarTime.ts +86 -0
- package/shared/core/engine/xiaoliuren.ts +19 -0
- package/shared/core/engine/ziwei.ts +217 -0
- package/shared/core/engine/ziweiInterpret.ts +161 -0
- package/shared/core/types.ts +206 -0
- package/src/App.tsx +69 -0
- package/src/arts/registry.ts +31 -0
- package/src/assets/react.svg +1 -0
- package/src/components/Backdrop.tsx +27 -0
- package/src/components/BambooArt.tsx +41 -0
- package/src/components/CalendarInput.tsx +131 -0
- package/src/components/DateInput.tsx +103 -0
- package/src/components/Disclaimer.tsx +12 -0
- package/src/components/Empty.tsx +8 -0
- package/src/components/ErrorBoundary.tsx +38 -0
- package/src/components/HomeSideAnchor.tsx +62 -0
- package/src/components/InkVein.tsx +10 -0
- package/src/components/LifeEventsInput.tsx +47 -0
- package/src/components/LocationPicker.tsx +117 -0
- package/src/components/Motes.tsx +65 -0
- package/src/components/Navigation.tsx +80 -0
- package/src/components/ProfileForm.tsx +40 -0
- package/src/components/ProfilePicker.tsx +40 -0
- package/src/components/QuestionFields.tsx +47 -0
- package/src/components/ReportView.tsx +211 -0
- package/src/components/ResultCard.tsx +40 -0
- package/src/components/SealButton.tsx +18 -0
- package/src/components/SiteFooter.tsx +17 -0
- package/src/components/SiteNav.tsx +103 -0
- package/src/components/SongDialog.tsx +64 -0
- package/src/components/SongSearchSelect.tsx +81 -0
- package/src/components/SongSelect.tsx +61 -0
- package/src/components/StarField.tsx +119 -0
- package/src/components/TarotCard.tsx +129 -0
- package/src/components/TimeShichenInput.tsx +29 -0
- package/src/components/arts/AstrologyArt.tsx +141 -0
- package/src/components/arts/BaziArt.tsx +269 -0
- package/src/components/arts/LiurenArt.tsx +87 -0
- package/src/components/arts/LiuyaoArt.tsx +111 -0
- package/src/components/arts/MeihuaArt.tsx +105 -0
- package/src/components/arts/QimenArt.tsx +86 -0
- package/src/components/arts/TarotArt.tsx +110 -0
- package/src/components/arts/XiaoliurenArt.tsx +93 -0
- package/src/components/arts/ZiweiArt.tsx +296 -0
- package/src/data/arts.ts +84 -0
- package/src/data/lifeContext.ts +423 -0
- package/src/data/sample-report.json +146 -0
- package/src/data/shichen.ts +21 -0
- package/src/data/spreads.ts +255 -0
- package/src/data/tarotCards.ts +2353 -0
- package/src/hooks/useAIInterpret.ts +76 -0
- package/src/hooks/useDivine.ts +39 -0
- package/src/hooks/useTheme.ts +29 -0
- package/src/index.css +190 -0
- package/src/lib/utils.ts +6 -0
- package/src/main.tsx +12 -0
- package/src/pages/AboutPage.tsx +25 -0
- package/src/pages/AcademyPage.tsx +128 -0
- package/src/pages/AstrologyPage.tsx +33 -0
- package/src/pages/AuthPage.tsx +263 -0
- package/src/pages/BaZiPage.tsx +33 -0
- package/src/pages/ClassicsPage.tsx +120 -0
- package/src/pages/DemoPage.tsx +161 -0
- package/src/pages/HistoryPage.tsx +133 -0
- package/src/pages/Home.tsx +179 -0
- package/src/pages/HomePage.tsx +174 -0
- package/src/pages/ModulePage.tsx +320 -0
- package/src/pages/SpreadEditor.tsx +230 -0
- package/src/pages/SpreadEditorPage.tsx +105 -0
- package/src/pages/SpreadLibrary.tsx +199 -0
- package/src/pages/SpreadLibraryPage.tsx +17 -0
- package/src/pages/TarotPage.tsx +738 -0
- package/src/pages/ZiWeiPage.tsx +33 -0
- package/src/services/api.ts +342 -0
- package/src/stores/useDivinationStore.ts +22 -0
- package/src/styles/song.css +1066 -0
- package/src/styles/tokens.css +60 -0
- package/src/types/index.ts +104 -0
- package/src/utils/astrologyCalc.ts +61 -0
- package/src/utils/comboEngine.ts +134 -0
- package/src/utils/dst.ts +24 -0
- package/src/utils/panTone.ts +206 -0
- package/src/utils/recordStore.ts +60 -0
- package/src/utils/semanticAnalyzer.ts +555 -0
- package/src/utils/spreadStorage.ts +32 -0
- package/src/utils/storage.ts +27 -0
- package/src/utils/tarotEngine.ts +849 -0
- package/src/utils/userStore.ts +234 -0
- package/src/vite-env.d.ts +1 -0
- package/tailwind.config.js +52 -0
- package/tsconfig.json +40 -0
- package/vite.config.ts +34 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// 时辰反推引擎:给定出生年月日 + 关键人生事件,对候选时辰按「流年 × 时柱互动」打分排序
|
|
2
|
+
// 原理:大运/起运不受时辰影响(已实测验证),区分候选时辰的关键在时柱与流年的冲合刑害、时干伏吟
|
|
3
|
+
import { baziCalc, shishen } from '../../../shared/core/engine/bazi.js';
|
|
4
|
+
import { GAN, ZHI, jiaziIndex } from '../../../shared/core/data/ganzhi.js';
|
|
5
|
+
import type { GeoLocation } from '../../../shared/core/types';
|
|
6
|
+
|
|
7
|
+
export interface HourInferEvent {
|
|
8
|
+
year: number;
|
|
9
|
+
text: string;
|
|
10
|
+
/** 事件类别(可选):health 健康/手术/伤病;love 恋爱/分手/结婚;job 事业/辞职/入职/公司;
|
|
11
|
+
* family 父母/家庭变故;money 财务/赔偿;study 学业/升学;move 搬家/远行。缺省时从文本关键词推断 */
|
|
12
|
+
type?: 'health' | 'love' | 'job' | 'family' | 'money' | 'study' | 'move' | 'breakup';
|
|
13
|
+
}
|
|
14
|
+
export interface HourInferHit { year: number; text: string; reason: string }
|
|
15
|
+
export interface HourInferCandidate {
|
|
16
|
+
hourIndex: number; // 0-11
|
|
17
|
+
hourGZ: string; // 时柱干支
|
|
18
|
+
shichen: string; // 时辰名(子丑…)
|
|
19
|
+
score: number;
|
|
20
|
+
hits: HourInferHit[];
|
|
21
|
+
}
|
|
22
|
+
export interface HourInferResult {
|
|
23
|
+
best: HourInferCandidate;
|
|
24
|
+
candidates: HourInferCandidate[];
|
|
25
|
+
chart: { yearGZ: string; monthGZ: string; dayGZ: string };
|
|
26
|
+
note: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const SHICHEN = ['子', '丑', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥'];
|
|
30
|
+
|
|
31
|
+
// ─── 地支关系 ───
|
|
32
|
+
const CHONG: Record<string, string> = { 子: '午', 午: '子', 丑: '未', 未: '丑', 寅: '申', 申: '寅', 卯: '酉', 酉: '卯', 辰: '戌', 戌: '辰', 巳: '亥', 亥: '巳' };
|
|
33
|
+
const LIUHE: Record<string, string> = { 子: '丑', 丑: '子', 寅: '亥', 亥: '寅', 卯: '戌', 戌: '卯', 辰: '酉', 酉: '辰', 巳: '申', 申: '巳', 午: '未', 未: '午' };
|
|
34
|
+
const SANHE_GROUP: Record<string, string> = { 申: '水', 子: '水', 辰: '水', 寅: '火', 午: '火', 戌: '火', 巳: '金', 酉: '金', 丑: '金', 亥: '木', 卯: '木', 未: '木' };
|
|
35
|
+
const PO: Record<string, string> = { 子: '酉', 酉: '子', 丑: '辰', 辰: '丑', 寅: '亥', 亥: '寅', 卯: '午', 午: '卯', 巳: '申', 申: '巳', 未: '戌', 戌: '未' };
|
|
36
|
+
const XING: Record<string, string> = { 寅: '巳', 巳: '申', 申: '寅', 丑: '戌', 戌: '未', 未: '丑', 子: '卯', 卯: '子' };
|
|
37
|
+
|
|
38
|
+
function liuGZ(year: number): string {
|
|
39
|
+
const idx = jiaziIndex(year);
|
|
40
|
+
return GAN[idx % 10] + ZHI[idx % 12];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 对单个事件打分:返回 (score, reason?)——只记「涉及时柱/日支/月支的应象」理由,供用户理解
|
|
44
|
+
function scoreEvent(ev: HourInferEvent, dayGan: string, hourGZ: string, dayGZ: string, monthGZ: string): { score: number; reason: string } {
|
|
45
|
+
const gz = liuGZ(ev.year);
|
|
46
|
+
const lg = gz[0], lz = gz[1];
|
|
47
|
+
const sz = hourGZ[1], dz = dayGZ[1], mz = monthGZ[1];
|
|
48
|
+
const t = ev.text || '';
|
|
49
|
+
const has = (...ks: string[]) => ks.some(k => t.includes(k));
|
|
50
|
+
// 类别判断:优先用显式 type,其次文本关键词
|
|
51
|
+
const is = (cat: NonNullable<HourInferEvent['type']>) =>
|
|
52
|
+
ev.type === cat || (ev.type === undefined && (
|
|
53
|
+
(cat === 'health' && has('病', '癌', '手术', '住院', '伤', '灾', '去世', '死', '切除', '健康')) ||
|
|
54
|
+
(cat === 'love' && has('恋', '对象', '男友', '女友', '结婚', '相亲', '遇到', '姻缘')) ||
|
|
55
|
+
(cat === 'breakup' && has('分手', '破裂', '退婚', '分开')) ||
|
|
56
|
+
(cat === 'job' && has('辞职', '入职', '工作', '公司', '事业', '创业', '裁员', '解散', '失业', '跳槽', '离职', '外贸', '职场', '晋升', '职位')) ||
|
|
57
|
+
(cat === 'study' && has('上学', '高考', '大学', '读书', '复学', '学业', '高中', '初中', '升学')) ||
|
|
58
|
+
(cat === 'move' && has('搬家', '到北京', '外地', '出国', '远行', '北漂', '去了', '变动')) ||
|
|
59
|
+
(cat === 'money' && has('赔', '破财', '损失', '亏', '没干起来', '财务', '收入', '奖金', '赔偿')) ||
|
|
60
|
+
(cat === 'family' && has('父母', '妈妈', '爸爸', '家里', '家庭', '离婚', '变故'))
|
|
61
|
+
));
|
|
62
|
+
let s = 0;
|
|
63
|
+
const rs: string[] = [];
|
|
64
|
+
const push = (r: string) => { if (!rs.includes(r)) rs.push(r); };
|
|
65
|
+
const ss = shishen(dayGan, lg);
|
|
66
|
+
|
|
67
|
+
// ① 疾病/手术/灾伤/丧亡 → 冲刑破害日支(身体)、时支(子女/下焦);半三合引动时支(如辰申合水主肾/生殖/下焦)
|
|
68
|
+
if (is('health')) {
|
|
69
|
+
if (lz === CHONG[dz]) { s += 2.5; push(lz + '冲日支' + dz + '(身体宫受冲)'); }
|
|
70
|
+
if (lz === CHONG[sz]) { s += 2.5; push(lz + '冲时支' + sz + '(子女/下焦宫受冲)'); }
|
|
71
|
+
if (lz === PO[dz]) { s += 2; push(lz + '破日支' + dz); }
|
|
72
|
+
if (lz === PO[sz]) { s += 2; push(lz + '破时支' + sz); }
|
|
73
|
+
if (lz === XING[dz]) { s += 1.5; push(lz + '刑日支' + dz); }
|
|
74
|
+
if (lz === XING[sz]) { s += 1.5; push(lz + '刑时支' + sz); }
|
|
75
|
+
// 半合引动时支与冲同权:如辰申合水主肾/骨/生殖(肾主骨,恰应骨骼与下焦之疾)
|
|
76
|
+
if (SANHE_GROUP[lz] && SANHE_GROUP[lz] === SANHE_GROUP[sz]) { s += 3; push(lz + sz + '半三合(' + SANHE_GROUP[lz] + '局,引动时支/对应脏腑)'); }
|
|
77
|
+
}
|
|
78
|
+
// ② 恋爱/对象/结婚/相亲(姻缘始)→ 时干伏吟 / 时支六合三合 / 日支(夫妻宫)六合 / 夫星(女命官杀)
|
|
79
|
+
if (is('love')) {
|
|
80
|
+
if (lg === hourGZ[0]) { s += 2; push('流年' + lg + '与时干伏吟(引动时柱/姻缘宫)'); }
|
|
81
|
+
if (lz === LIUHE[sz]) { s += 1.5; push(lz + '与' + sz + '六合(时支被合动)'); }
|
|
82
|
+
if (lz === LIUHE[dz]) { s += 1.5; push(lz + '与' + dz + '六合(日支/夫妻宫被合动)'); }
|
|
83
|
+
if (SANHE_GROUP[lz] && SANHE_GROUP[lz] === SANHE_GROUP[sz]) { s += 1.5; push(lz + sz + '半三合(' + SANHE_GROUP[lz] + '局)'); }
|
|
84
|
+
if (ss === '正官' || ss === '七杀') { s += 1.5; push('流年' + lg + '为' + ss + '(官星现,主姻缘/担当)'); }
|
|
85
|
+
}
|
|
86
|
+
// ③ 分手/感情破裂 → 伤官克官 / 冲日支(夫妻宫)
|
|
87
|
+
if (is('breakup') || (is('love') && has('分手'))) {
|
|
88
|
+
if (ss === '伤官' || ss === '七杀') { s += 2; push('流年' + lg + '为' + ss + '(克官伤情)'); }
|
|
89
|
+
if (lz === CHONG[dz]) { s += 2; push(lz + '冲日支' + dz + '(夫妻宫动)'); }
|
|
90
|
+
if (lz === CHONG[sz]) { s += 1; push(lz + '冲时支' + sz); }
|
|
91
|
+
}
|
|
92
|
+
// ④ 事业/职场变动(辞职/入职/换工作/公司/裁员/失业)
|
|
93
|
+
if (is('job')) {
|
|
94
|
+
if (lz === CHONG[mz]) { s += 1.5; push(lz + '冲月支' + mz + '(父母/根基宫动)'); }
|
|
95
|
+
if (lz === CHONG[sz]) { s += 1; push(lz + '冲时支' + sz); }
|
|
96
|
+
if (lz === sz) { s += 1.5; push('流年支' + lz + '伏吟时支(变动之象)'); }
|
|
97
|
+
if (ss === '伤官') { s += 1; push('流年' + lg + '为伤官(伤官见官,主与体制/上司冲突)'); }
|
|
98
|
+
if (ss === '正官' || ss === '七杀') { s += 1; push('流年' + lg + '为' + ss + '(官星动,主职场体制变动)'); }
|
|
99
|
+
}
|
|
100
|
+
// ⑤ 学业/考试
|
|
101
|
+
if (is('study')) {
|
|
102
|
+
if (ss === '正印' || ss === '偏印') { s += 1.5; push('流年' + lg + '为' + ss + '(印星主学业)'); }
|
|
103
|
+
if (ss === '食神' || ss === '伤官') { s += 1; push('流年' + lg + '为' + ss + '(食伤泄秀利学习输出)'); }
|
|
104
|
+
}
|
|
105
|
+
// ⑥ 搬家/远行/外地
|
|
106
|
+
if (is('move')) {
|
|
107
|
+
if (lz === CHONG[sz]) { s += 1.5; push(lz + '冲时支' + sz + '(远行之象)'); }
|
|
108
|
+
if (lz === CHONG[mz]) { s += 1; push(lz + '冲月支' + mz); }
|
|
109
|
+
if (lz === sz) { s += 1.5; push('流年支' + lz + '伏吟时支(变动之象)'); }
|
|
110
|
+
}
|
|
111
|
+
// ⑦ 破财/损失
|
|
112
|
+
if (is('money')) {
|
|
113
|
+
if (ss === '劫财') { s += 2; push('流年' + lg + '为劫财(夺财之象)'); }
|
|
114
|
+
}
|
|
115
|
+
// ⑧ 父母/家庭变故
|
|
116
|
+
if (is('family')) {
|
|
117
|
+
if (lz === CHONG[mz]) { s += 2; push(lz + '冲月支' + mz + '(父母宫动)'); }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { score: s, reason: rs.join(';') };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function inferHour(input: { y: number; m: number; d: number; gender: '男' | '女'; location?: GeoLocation | null; candidates?: number[]; events: HourInferEvent[] }): HourInferResult {
|
|
124
|
+
const { y, m, d, gender, location, events } = input;
|
|
125
|
+
// 默认全 12 时辰候选(无真太阳时校正:候选按「时辰序」理解,前端可提示用户按钟表时辰选择)
|
|
126
|
+
const candidates = input.candidates && input.candidates.length ? input.candidates : [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
|
|
127
|
+
// 基准盘(时辰未知模式排年月日三柱,用于取 dayGan 与三柱)
|
|
128
|
+
const base = baziCalc({ y, m, d, hourIndex: -1, gender, location: location || undefined });
|
|
129
|
+
|
|
130
|
+
const rows: HourInferCandidate[] = candidates.map(hourIndex => {
|
|
131
|
+
const r = baziCalc({ y, m, d, hourIndex, gender, location: location || undefined });
|
|
132
|
+
let score = 0;
|
|
133
|
+
const hits: HourInferHit[] = [];
|
|
134
|
+
for (const ev of events) {
|
|
135
|
+
const { score: es, reason } = scoreEvent(ev, base.dayGan, r.hourGZ, r.dayGZ, r.monthGZ);
|
|
136
|
+
score += es;
|
|
137
|
+
if (reason) hits.push({ year: ev.year, text: ev.text, reason });
|
|
138
|
+
}
|
|
139
|
+
return { hourIndex, hourGZ: r.hourGZ, shichen: SHICHEN[hourIndex], score: Math.round(score * 10) / 10, hits };
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
rows.sort((a, b) => b.score - a.score);
|
|
143
|
+
const best = rows[0];
|
|
144
|
+
return {
|
|
145
|
+
best,
|
|
146
|
+
candidates: rows,
|
|
147
|
+
chart: { yearGZ: base.yearGZ, monthGZ: base.monthGZ, dayGZ: base.dayGZ },
|
|
148
|
+
note: '按「流年 × 时柱」冲合刑害与时干伏吟应象打分:疾病手术看冲刑日/时支,姻缘看时干伏吟与六合三合,事业变动看冲月支,父母变故看冲月支。大运序列不受时辰影响,本推演只区分时柱细节。',
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// LLM Provider 适配层:Gemini / Groq / DeepSeek / Qwen / 自定义 OpenAI 兼容端点
|
|
2
|
+
// Key 只存服务端环境变量,绝不进入前端
|
|
3
|
+
|
|
4
|
+
export interface ChatMessage {
|
|
5
|
+
role: 'system' | 'user' | 'assistant';
|
|
6
|
+
content: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type ProviderId = 'gemini' | 'groq' | 'deepseek' | 'qwen' | 'custom';
|
|
10
|
+
|
|
11
|
+
interface ProviderConfig {
|
|
12
|
+
id: ProviderId;
|
|
13
|
+
label: string;
|
|
14
|
+
keyEnv: string;
|
|
15
|
+
endpoint?: string;
|
|
16
|
+
modelEnv?: string;
|
|
17
|
+
defaultModel: string;
|
|
18
|
+
// OpenAI 兼容(chat/completions)还是 Google 格式
|
|
19
|
+
kind: 'openai' | 'google';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const PROVIDERS: ProviderConfig[] = [
|
|
23
|
+
{ id: 'gemini', label: 'Gemini', keyEnv: 'LLM_GEMINI_KEY', modelEnv: 'LLM_GEMINI_MODEL', defaultModel: 'gemini-2.0-flash', kind: 'google' },
|
|
24
|
+
{ id: 'groq', label: 'Groq', keyEnv: 'LLM_GROQ_KEY', modelEnv: 'LLM_GROQ_MODEL', defaultModel: 'llama-3.3-70b-versatile', kind: 'openai', endpoint: 'https://api.groq.com/openai/v1/chat/completions' },
|
|
25
|
+
{ id: 'deepseek', label: 'DeepSeek', keyEnv: 'LLM_DEEPSEEK_KEY', modelEnv: 'LLM_DEEPSEEK_MODEL', defaultModel: 'deepseek-chat', kind: 'openai', endpoint: 'https://api.deepseek.com/v1/chat/completions' },
|
|
26
|
+
{ id: 'qwen', label: '通义千问', keyEnv: 'LLM_QWEN_KEY', modelEnv: 'LLM_QWEN_MODEL', defaultModel: 'qwen-turbo', kind: 'openai', endpoint: 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions' },
|
|
27
|
+
{ id: 'custom', label: '自定义端点', keyEnv: 'LLM_CUSTOM_KEY', modelEnv: 'LLM_CUSTOM_MODEL', defaultModel: 'gpt-4o-mini', kind: 'openai' },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export function activeProvider(): ProviderConfig | null {
|
|
31
|
+
const env = process.env.LLM_PROVIDER as ProviderId | undefined;
|
|
32
|
+
if (env) {
|
|
33
|
+
const p = PROVIDERS.find(x => x.id === env);
|
|
34
|
+
if (p && process.env[p.keyEnv]) return p;
|
|
35
|
+
}
|
|
36
|
+
// 自动探测:第一个有 Key 的 Provider
|
|
37
|
+
return PROVIDERS.find(p => process.env[p.keyEnv]) || null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function providerStatus(): { id: string; label: string; configured: boolean; model: string }[] {
|
|
41
|
+
return PROVIDERS.map(p => ({
|
|
42
|
+
id: p.id,
|
|
43
|
+
label: p.label,
|
|
44
|
+
configured: !!process.env[p.keyEnv],
|
|
45
|
+
model: process.env[p.modelEnv || ''] || p.defaultModel,
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function callOpenAI(cfg: ProviderConfig, messages: ChatMessage[], stream: boolean): Promise<Response> {
|
|
50
|
+
const endpoint = cfg.endpoint || process.env.LLM_CUSTOM_ENDPOINT || 'https://api.openai.com/v1/chat/completions';
|
|
51
|
+
return fetch(endpoint, {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: {
|
|
54
|
+
'Content-Type': 'application/json',
|
|
55
|
+
'Authorization': 'Bearer ' + process.env[cfg.keyEnv],
|
|
56
|
+
},
|
|
57
|
+
body: JSON.stringify({
|
|
58
|
+
model: process.env[cfg.modelEnv || ''] || cfg.defaultModel,
|
|
59
|
+
messages,
|
|
60
|
+
stream,
|
|
61
|
+
// 温度:偏低以保证解读稳定自洽(同一盘面多次解读不互相矛盾、不编造盘面事实);留有余地避免呆板
|
|
62
|
+
temperature: Number(process.env.LLM_TEMPERATURE || 0.35),
|
|
63
|
+
// 输出上限:长报告(原始解读+性格+阶段+三域+建议)约需 3-6K tokens
|
|
64
|
+
max_tokens: Number(process.env.LLM_MAX_TOKENS || 8192),
|
|
65
|
+
// 关闭思考模式:pro 模型默认思考,reasoning_content 会占满输出预算导致正文截断(finish_reason: length)
|
|
66
|
+
...(cfg.id === 'deepseek' ? { thinking: { type: 'disabled' } } : {}),
|
|
67
|
+
// 强制 JSON 输出(DeepSeek/Groq/Qwen 及 OpenAI 兼容端点均支持)
|
|
68
|
+
response_format: { type: 'json_object' },
|
|
69
|
+
}),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function callGoogle(cfg: ProviderConfig, messages: ChatMessage[], stream: boolean): Promise<Response> {
|
|
74
|
+
const model = process.env[cfg.modelEnv || ''] || cfg.defaultModel;
|
|
75
|
+
const endpoint = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${process.env[cfg.keyEnv]}`;
|
|
76
|
+
// 合并 messages 为 Google 格式:system → systemInstruction,其余 → contents
|
|
77
|
+
const system = messages.filter(m => m.role === 'system').map(m => m.content).join('\n\n');
|
|
78
|
+
const contents = messages.filter(m => m.role !== 'system').map(m => ({
|
|
79
|
+
role: m.role === 'assistant' ? 'model' : 'user',
|
|
80
|
+
parts: [{ text: m.content }],
|
|
81
|
+
}));
|
|
82
|
+
return fetch(endpoint, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: { 'Content-Type': 'application/json' },
|
|
85
|
+
body: JSON.stringify({
|
|
86
|
+
systemInstruction: system ? { parts: [{ text: system }] } : undefined,
|
|
87
|
+
contents,
|
|
88
|
+
...(stream ? { stream: true } : {}),
|
|
89
|
+
generationConfig: { temperature: 0.8, responseMimeType: 'application/json' },
|
|
90
|
+
}),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 瞬时错误重试(429/5xx/网络),最多 2 次
|
|
95
|
+
async function withRetry<T>(fn: () => Promise<T>, label: string): Promise<T> {
|
|
96
|
+
let lastErr: any;
|
|
97
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
98
|
+
try {
|
|
99
|
+
return await fn();
|
|
100
|
+
} catch (e: any) {
|
|
101
|
+
lastErr = e;
|
|
102
|
+
if (!/AI_HTTP_(429|5\d\d|400)/.test(e.message || '')) throw e;
|
|
103
|
+
if (attempt < 2) {
|
|
104
|
+
console.warn('[llm] ' + label + ' 第 ' + (attempt + 1) + ' 次失败(' + e.message + '),重试…');
|
|
105
|
+
await new Promise(r => setTimeout(r, 1500 * (attempt + 1)));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
throw lastErr;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 非流式调用:返回完整文本
|
|
113
|
+
export async function chatOnce(messages: ChatMessage[]): Promise<string> {
|
|
114
|
+
const cfg = activeProvider();
|
|
115
|
+
if (!cfg) throw new Error('AI_UNCONFIGURED');
|
|
116
|
+
const res = await withRetry(async () => {
|
|
117
|
+
const r0 = cfg.kind === 'google' ? await callGoogle(cfg, messages, false) : await callOpenAI(cfg, messages, false);
|
|
118
|
+
if (!r0.ok) {
|
|
119
|
+
const body = await r0.text().catch(() => '');
|
|
120
|
+
console.error('[llm] HTTP ' + r0.status + ' 响应体:', body.slice(0, 500));
|
|
121
|
+
throw new Error('AI_HTTP_' + r0.status);
|
|
122
|
+
}
|
|
123
|
+
return r0;
|
|
124
|
+
}, 'chatOnce');
|
|
125
|
+
const data = await res.json();
|
|
126
|
+
if (cfg.kind === 'google') {
|
|
127
|
+
const text = data?.candidates?.[0]?.content?.parts?.map((p: any) => p.text).join('') ?? '';
|
|
128
|
+
return text;
|
|
129
|
+
}
|
|
130
|
+
return data?.choices?.[0]?.message?.content ?? '';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 最近一次流式调用的结束原因('stop' | 'length' 截断 | null)
|
|
134
|
+
export let lastFinishReason: string | null = null;
|
|
135
|
+
|
|
136
|
+
// 流式调用:逐块输出文本
|
|
137
|
+
export async function* chatStream(messages: ChatMessage[]): AsyncGenerator<string> {
|
|
138
|
+
const cfg = activeProvider();
|
|
139
|
+
if (!cfg) throw new Error('AI_UNCONFIGURED');
|
|
140
|
+
const res = await withRetry(async () => {
|
|
141
|
+
const r0 = cfg.kind === 'google' ? await callGoogle(cfg, messages, true) : await callOpenAI(cfg, messages, true);
|
|
142
|
+
if (!r0.ok) {
|
|
143
|
+
const body = await r0.text().catch(() => '');
|
|
144
|
+
console.error('[llm] stream HTTP ' + r0.status + ' 响应体:', body.slice(0, 500));
|
|
145
|
+
throw new Error('AI_HTTP_' + r0.status);
|
|
146
|
+
}
|
|
147
|
+
return r0;
|
|
148
|
+
}, 'chatStream');
|
|
149
|
+
lastFinishReason = null;
|
|
150
|
+
const reader = res.body?.getReader();
|
|
151
|
+
if (!reader) return;
|
|
152
|
+
const decoder = new TextDecoder();
|
|
153
|
+
let buffer = '';
|
|
154
|
+
while (true) {
|
|
155
|
+
const { done, value } = await reader.read();
|
|
156
|
+
if (done) break;
|
|
157
|
+
buffer += decoder.decode(value, { stream: true });
|
|
158
|
+
// 按行解析 SSE
|
|
159
|
+
const lines = buffer.split('\n');
|
|
160
|
+
buffer = lines.pop() || '';
|
|
161
|
+
for (const line of lines) {
|
|
162
|
+
const t = line.trim();
|
|
163
|
+
if (t.startsWith('data:')) {
|
|
164
|
+
const payload = t.slice(5).trim();
|
|
165
|
+
if (payload === '[DONE]') continue;
|
|
166
|
+
try {
|
|
167
|
+
const json = JSON.parse(payload);
|
|
168
|
+
if (cfg.kind === 'google') {
|
|
169
|
+
const text = json?.candidates?.[0]?.content?.parts?.map((p: any) => p.text).join('') ?? '';
|
|
170
|
+
if (text) yield text;
|
|
171
|
+
} else {
|
|
172
|
+
const choice = json?.choices?.[0];
|
|
173
|
+
if (choice?.finish_reason) lastFinishReason = choice.finish_reason;
|
|
174
|
+
const delta = choice?.delta?.content ?? '';
|
|
175
|
+
if (delta) yield delta;
|
|
176
|
+
}
|
|
177
|
+
} catch { /* 忽略非 JSON 行 */ }
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|