Implement three divination modes, learn pages, and PM2 deploy on port 3130.

Add liuyao/bazi/combined flows with shared calc and AI infrastructure, 64-gua learn routes, and update Ubuntu PM2 deployment docs for port 3130.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-10 20:19:49 +08:00
parent d141623070
commit fff77dac3f
41 changed files with 2590 additions and 385 deletions
+30
View File
@@ -0,0 +1,30 @@
"use server";
import { streamAIResponse } from "@/lib/ai/stream";
import {
calculateBazi,
formatBaziForPrompt,
type BaziInput,
} from "@/lib/calc/bazi";
import { BAZI_SYSTEM_PROMPT } from "@/lib/prompts";
export async function getBaziAnswer(
input: BaziInput,
question: string,
birthPlaceName: string,
) {
const chart = calculateBazi(input);
const chartText = formatBaziForPrompt(chart);
return streamAIResponse(
BAZI_SYSTEM_PROMPT,
`【出生时空】
出生地:${birthPlaceName}
【排盘信息】
${chartText}
【问事】
${question}`,
);
}
+84
View File
@@ -0,0 +1,84 @@
"use server";
import { streamAIResponse } from "@/lib/ai/stream";
import {
calculateBazi,
formatBaziForPrompt,
type BaziInput,
} from "@/lib/calc/bazi";
import {
formatTimingForPrompt,
getTimingInfoWithLongitude,
} from "@/lib/calc/timing";
import { COMBINED_SYSTEM_PROMPT } from "@/lib/prompts";
import {
extractChangeDetails,
extractZhangMingRen,
readGuaMarkdown,
} from "@/lib/content/zhouyi";
export interface CombinedInput {
birth: BaziInput;
birthPlaceName: string;
currentPlaceName: string;
currentLongitude: number;
calcDate: string;
calcTime: string;
question: string;
hexagram?: {
guaMark: string;
guaTitle: string;
guaResult: string;
guaChange: string;
};
}
export async function getCombinedAnswer(input: CombinedInput) {
const chart = calculateBazi(input.birth);
const chartText = formatBaziForPrompt(chart);
const { timing, trueSolarTime } = getTimingInfoWithLongitude(
input.calcDate,
input.calcTime,
input.currentLongitude,
);
const timingText = [
formatTimingForPrompt(timing, input.currentPlaceName, input.currentLongitude),
`真太阳时:${trueSolarTime}`,
].join("\n");
let hexagramText = "";
if (input.hexagram) {
const { guaMark, guaTitle, guaResult, guaChange } = input.hexagram;
try {
const guaDetail = await readGuaMarkdown(guaMark);
const explain = extractZhangMingRen(guaDetail) ?? "";
const changeList = extractChangeDetails(guaDetail, guaChange, guaTitle);
hexagramText = [
"",
"【卦象 · 可选六爻】",
`${guaTitle} ${guaResult} ${guaChange}`,
explain,
changeList.join("\n"),
].join("\n");
} catch {
hexagramText = [
"",
"【卦象 · 可选六爻】",
`${input.hexagram.guaTitle} ${input.hexagram.guaResult} ${input.hexagram.guaChange}`,
].join("\n");
}
}
return streamAIResponse(
COMBINED_SYSTEM_PROMPT,
`【人和 · 八字排盘】
出生地:${input.birthPlaceName}
${chartText}
${timingText}
${hexagramText}
【问事】
${input.question}`,
);
}
+66
View File
@@ -0,0 +1,66 @@
"use server";
import { streamAIResponse } from "@/lib/ai/stream";
import {
formatLiuyaoTimingForPrompt,
getTimingInfoWithLongitude,
} from "@/lib/calc/timing";
import {
extractChangeDetails,
extractZhangMingRen,
readGuaMarkdown,
} from "@/lib/content/zhouyi";
import { LIUYAO_SYSTEM_PROMPT } from "@/lib/prompts";
export interface LiuyaoInput {
question: string;
calcDate: string;
calcTime: string;
locationName: string;
longitude: number;
guaMark: string;
guaTitle: string;
guaResult: string;
guaChange: string;
}
export async function getLiuyaoAnswer(input: LiuyaoInput) {
const { timing, trueSolarTime } = getTimingInfoWithLongitude(
input.calcDate,
input.calcTime,
input.longitude,
);
const timingText = formatLiuyaoTimingForPrompt(
timing,
trueSolarTime,
input.locationName,
input.longitude,
);
let guaDetailText = "";
try {
const guaDetail = await readGuaMarkdown(input.guaMark);
const explain = extractZhangMingRen(guaDetail) ?? "";
const changeList = extractChangeDetails(
guaDetail,
input.guaChange,
input.guaTitle,
);
guaDetailText = [explain, changeList.join("\n")].filter(Boolean).join("\n");
} catch {
guaDetailText = "";
}
return streamAIResponse(
LIUYAO_SYSTEM_PROMPT,
`${timingText}
【卦象】
${input.guaTitle} ${input.guaResult} ${input.guaChange}
【问事】
${input.question}
${guaDetailText}`,
);
}
+16
View File
@@ -0,0 +1,16 @@
import PageShell from "@/components/page-shell";
import BaziForm from "@/components/modes/bazi-form";
export default function BaziPage() {
return (
<PageShell>
<div className="px-4 pt-6 text-center">
<h1 className="text-xl font-bold"></h1>
<p className="mt-1 text-sm text-muted-foreground">
· · AI
</p>
</div>
<BaziForm />
</PageShell>
);
}
+16
View File
@@ -0,0 +1,16 @@
import PageShell from "@/components/page-shell";
import CombinedForm from "@/components/modes/combined-form";
export default function CombinedPage() {
return (
<PageShell>
<div className="px-4 pt-6 text-center">
<h1 className="text-xl font-bold"></h1>
<p className="mt-1 text-sm text-muted-foreground">
· · ·
</p>
</div>
<CombinedForm />
</PageShell>
);
}
+3 -3
View File
@@ -5,11 +5,11 @@ import Umami from "@/components/umami";
import { ThemeProvider } from "next-themes";
export const metadata: Metadata = {
title: "AI 算卦 - 在线卜卦 GPT4 解读",
title: "知命阁 - 易经学习 · 六爻算卦 · 生辰八字",
description:
"AI 算卦 - 通过进行六次硬币的随机卜筮,生成卦象,并使用 AI 对卦象进行分析|AI 算命、在线算命、在线算卦、周易易经64卦",
"知命阁 — 融合周易智慧与人工智能,提供易经学习、六爻算卦、生辰八字排盘、综合测算等服务",
appleWebApp: {
title: "AI 算卦",
title: "知命阁",
},
};
+47
View File
@@ -0,0 +1,47 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import PageShell from "@/components/page-shell";
import GuaFooter from "@/components/learn/gua-footer";
import MarkdownContent from "@/components/learn/markdown-content";
import {
getGuaName,
getGuaNumber,
listGuaMarks,
readLearnMarkdown,
stripFrontmatter,
} from "@/lib/content/zhouyi";
export async function generateStaticParams() {
const marks = await listGuaMarks("traditional");
return marks.map((guaMark) => ({ guaMark }));
}
export default async function GuaDetailPage({
params,
}: {
params: Promise<{ guaMark: string }>;
}) {
const { guaMark } = await params;
const marks = await listGuaMarks("traditional");
if (!marks.includes(guaMark)) {
notFound();
}
const raw = await readLearnMarkdown(guaMark, "traditional");
const content = stripFrontmatter(raw);
return (
<PageShell className="max-w-3xl px-4 py-8">
<div className="mb-6 text-sm text-muted-foreground">
<Link href="/learn" className="hover:text-foreground">
</Link>
</div>
<div className="mb-4 text-sm text-muted-foreground">
{getGuaNumber(guaMark)} · {getGuaName(guaMark)}
</div>
<MarkdownContent content={content} variant="traditional" />
<GuaFooter guaMark={guaMark} />
</PageShell>
);
}
+47
View File
@@ -0,0 +1,47 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import PageShell from "@/components/page-shell";
import GuaFooter from "@/components/learn/gua-footer";
import MarkdownContent from "@/components/learn/markdown-content";
import {
getGuaName,
getGuaNumber,
listGuaMarks,
readLearnMarkdown,
stripFrontmatter,
} from "@/lib/content/zhouyi";
export async function generateStaticParams() {
const marks = await listGuaMarks("simplified");
return marks.map((guaMark) => ({ guaMark }));
}
export default async function GuaOtherDetailPage({
params,
}: {
params: Promise<{ guaMark: string }>;
}) {
const { guaMark } = await params;
const marks = await listGuaMarks("simplified");
if (!marks.includes(guaMark)) {
notFound();
}
const raw = await readLearnMarkdown(guaMark, "simplified");
const content = stripFrontmatter(raw);
return (
<PageShell className="max-w-3xl px-4 py-8">
<div className="mb-6 text-sm text-muted-foreground">
<Link href="/learn/other" className="hover:text-foreground">
</Link>
</div>
<div className="mb-4 text-sm text-muted-foreground">
{getGuaNumber(guaMark)} · {getGuaName(guaMark)}
</div>
<MarkdownContent content={content} variant="simplified" />
<GuaFooter guaMark={guaMark} />
</PageShell>
);
}
+55
View File
@@ -0,0 +1,55 @@
import Link from "next/link";
import PageShell from "@/components/page-shell";
import {
getGuaName,
getGuaNumber,
listGuaMarks,
} from "@/lib/content/zhouyi";
export default async function LearnOtherPage() {
const guaMarks = await listGuaMarks("simplified");
return (
<PageShell className="max-w-3xl px-4 py-8">
<div className="mb-6">
<h1 className="text-2xl font-bold"> · </h1>
<p className="mt-2 text-muted-foreground">
</p>
<Link
href="/learn"
className="mt-2 inline-block text-sm text-primary underline underline-offset-4"
>
</Link>
</div>
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-4 py-2 text-left font-medium"></th>
<th className="px-4 py-2 text-left font-medium"></th>
</tr>
</thead>
<tbody>
{guaMarks.map((mark) => (
<tr key={mark} className="border-t">
<td className="px-4 py-2 font-mono text-muted-foreground">
{getGuaNumber(mark)}
</td>
<td className="px-4 py-2">
<Link
href={`/learn/other/${encodeURIComponent(mark)}`}
className="text-primary underline-offset-4 hover:underline"
>
{getGuaName(mark)}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</PageShell>
);
}
+55
View File
@@ -0,0 +1,55 @@
import Link from "next/link";
import PageShell from "@/components/page-shell";
import {
getGuaName,
getGuaNumber,
listGuaMarks,
} from "@/lib/content/zhouyi";
export default async function LearnPage() {
const guaMarks = await listGuaMarks("traditional");
return (
<PageShell className="max-w-3xl px-4 py-8">
<div className="mb-6">
<h1 className="text-2xl font-bold"></h1>
<p className="mt-2 text-muted-foreground">
</p>
<Link
href="/learn/other"
className="mt-2 inline-block text-sm text-primary underline underline-offset-4"
>
</Link>
</div>
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-4 py-2 text-left font-medium"></th>
<th className="px-4 py-2 text-left font-medium"></th>
</tr>
</thead>
<tbody>
{guaMarks.map((mark) => (
<tr key={mark} className="border-t">
<td className="px-4 py-2 font-mono text-muted-foreground">
{getGuaNumber(mark)}
</td>
<td className="px-4 py-2">
<Link
href={`/learn/${encodeURIComponent(mark)}`}
className="text-primary underline-offset-4 hover:underline"
>
{getGuaName(mark)}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</PageShell>
);
}
+16
View File
@@ -0,0 +1,16 @@
import PageShell from "@/components/page-shell";
import LiuyaoForm from "@/components/modes/liuyao-form";
export default function LiuyaoPage() {
return (
<PageShell>
<div className="px-4 pt-6 text-center">
<h1 className="text-xl font-bold"></h1>
<p className="mt-1 text-sm text-muted-foreground">
· · 线 / 线
</p>
</div>
<LiuyaoForm />
</PageShell>
);
}
+53 -8
View File
@@ -1,13 +1,58 @@
import Header from "@/components/header";
import Divination from "@/components/divination";
import Footer from "@/components/footer";
import Link from "next/link";
import PageShell from "@/components/page-shell";
import { BookOpen, BrainCircuit, Compass, Sparkles } from "lucide-react";
const MODULES = [
{
href: "/learn",
title: "易经学习",
description: "64 卦原文阅读,繁体精简版与简体图文版",
icon: BookOpen,
},
{
href: "/liuyao",
title: "六爻算卦",
description: "三钱法起卦,结合卦辞 AI 智能解读",
icon: Sparkles,
},
{
href: "/bazi",
title: "生辰八字",
description: "四柱排盘,十神大运流年,AI 命理解读",
icon: BrainCircuit,
},
{
href: "/combined",
title: "综合测算",
description: "天时地利人和融合分析,六爻可选",
icon: Compass,
},
];
export default function Home() {
return (
<>
<Header />
<Divination />
<Footer />
</>
<PageShell className="max-w-2xl px-4 py-8">
<div className="mb-8 text-center">
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="mt-2 text-muted-foreground">
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{MODULES.map(({ href, title, description, icon: Icon }) => (
<Link
key={href}
href={href}
className="group rounded-lg border bg-card p-5 shadow-sm transition-colors hover:border-primary/40 hover:bg-accent/30"
>
<div className="mb-3 flex items-center gap-2">
<Icon size={20} className="text-primary" />
<span className="font-medium">{title}</span>
</div>
<p className="text-sm text-muted-foreground">{description}</p>
</Link>
))}
</div>
</PageShell>
);
}
+16 -88
View File
@@ -1,23 +1,8 @@
"use server";
import { streamText } from "ai";
import { createOpenAI } from "@ai-sdk/openai";
import { createStreamableValue } from "ai/rsc";
import { ERROR_PREFIX } from "@/lib/constant";
import {
extractChangeDetails,
extractZhangMingRen,
readGuaMarkdown,
} from "@/lib/content/zhouyi";
const model =
process.env.OPENAI_MODEL ?? "huihui_ai/gemma-4-abliterated:e4b";
const openai = createOpenAI({
baseURL: process.env.OPENAI_BASE_URL ?? "https://op.bz121.com/v1",
});
const STREAM_INTERVAL = 60;
const MAX_SIZE = 6;
import { getLiuyaoAnswer } from "@/app/actions/liuyao";
/** 兼容旧版 divination 组件签名 */
export async function getAnswer(
prompt: string,
guaMark: string,
@@ -25,76 +10,19 @@ export async function getAnswer(
guaResult: string,
guaChange: string,
) {
console.log(prompt, guaTitle, guaResult, guaChange);
const stream = createStreamableValue();
try {
const guaDetail = await readGuaMarkdown(guaMark);
const explain = extractZhangMingRen(guaDetail) ?? "";
const changeList = extractChangeDetails(guaDetail, guaChange, guaTitle);
const now = new Date();
const calcDate = now.toISOString().slice(0, 10);
const calcTime = `${now.getHours().toString().padStart(2, "0")}:${now.getMinutes().toString().padStart(2, "0")}`;
const { fullStream } = streamText({
temperature: 0.5,
model: openai(model),
messages: [
{
role: "system",
content: `你是一位精通《周易》的AI助手,根据用户提供的卦象和问题,提供准确的卦象解读和实用建议
任务要求:逻辑清晰,语气得当
1. 解读卦象:分析主卦、变爻及变卦,解读整体趋势和吉凶
2. 关联问题:针对用户问题,结合卦象信息,提供具体分析
3. 提供建议:根据卦象启示,给出切实可行的建议,帮助用户解决实际问题`,
},
{
role: "user",
content: `我摇到的卦象:${guaTitle} ${guaResult} ${guaChange}
我的问题:${prompt}
${explain}
${changeList.join("\n")}`,
},
],
maxRetries: 0,
});
let buffer = "";
let done = false;
const intervalId = setInterval(() => {
if (done && buffer.length === 0) {
clearInterval(intervalId);
stream.done();
return;
}
if (buffer.length <= MAX_SIZE) {
stream.update(buffer);
buffer = "";
} else {
const chunk = buffer.slice(0, MAX_SIZE);
buffer = buffer.slice(MAX_SIZE);
stream.update(chunk);
}
}, STREAM_INTERVAL);
(async () => {
for await (const part of fullStream) {
switch (part.type) {
case "text-delta":
buffer += part.textDelta;
break;
case "error":
const err = part.error as any;
stream.update(ERROR_PREFIX + (err.message ?? err.toString()));
break;
}
}
})()
.catch(console.error)
.finally(() => {
done = true;
});
return { data: stream.value };
} catch (err: any) {
stream.done();
return { error: err.message ?? err };
}
return getLiuyaoAnswer({
question: prompt,
calcDate,
calcTime,
locationName: "未指定",
longitude: 120,
guaMark,
guaTitle,
guaResult,
guaChange,
});
}