├── .eslintrc.json
├── .github
└── workflows
│ └── docker.yml
├── .gitignore
├── .prettierignore
├── Dockerfile
├── LICENSE
├── README.md
├── app
├── apple-icon.png
├── favicon.ico
├── globals.css
├── layout.tsx
├── page.tsx
└── server.ts
├── components.json
├── components
├── coin.tsx
├── divination.tsx
├── footer.tsx
├── header.tsx
├── hexagram.tsx
├── mode-toggle.tsx
├── question.tsx
├── result-ai.tsx
├── result.tsx
├── svg.tsx
├── ui
│ ├── button.tsx
│ ├── dropdown-menu.tsx
│ └── textarea.tsx
└── umami.tsx
├── docs
└── screenshots.jpg
├── lib
├── animate.ts
├── constant.ts
├── data
│ ├── gua-index.json
│ ├── gua-list.json
│ └── today.json
└── utils.ts
├── next.config.js
├── package.json
├── pnpm-lock.yaml
├── postcss.config.js
├── prettier.config.js
├── public
└── img
│ ├── head.webp
│ ├── tail.webp
│ └── yin-yang.webp
├── tailwind.config.ts
└── tsconfig.json
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["next/core-web-vitals", "prettier"]
3 | }
4 |
--------------------------------------------------------------------------------
/.github/workflows/docker.yml:
--------------------------------------------------------------------------------
1 | name: Build Docker Images
2 |
3 | on:
4 | workflow_dispatch:
5 | release:
6 | types: [published]
7 |
8 | jobs:
9 | build:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checkout
13 | uses: actions/checkout@v4
14 | - name: Set up QEMU
15 | uses: docker/setup-qemu-action@v3
16 | with:
17 | platforms: amd64,arm64
18 | - name: Set up Docker Buildx
19 | uses: docker/setup-buildx-action@v3
20 | with:
21 | platforms: linux/amd64,linux/arm64
22 | - name: Login to Docker Hub
23 | uses: docker/login-action@v3
24 | with:
25 | username: ${{ secrets.DOCKERHUB_USERNAME }}
26 | password: ${{ secrets.DOCKERHUB_TOKEN }}
27 | - name: Docker meta
28 | id: meta
29 | uses: docker/metadata-action@v5
30 | with:
31 | images: sunls24/divination
32 | tags: |
33 | type=raw,value=latest
34 | type=ref,event=tag
35 | - name: Build and push
36 | uses: docker/build-push-action@v6
37 | with:
38 | context: .
39 | push: true
40 | platforms: linux/amd64,linux/arm64
41 | tags: ${{ steps.meta.outputs.tags }}
42 | labels: ${{ steps.meta.outputs.labels }}
43 | cache-from: type=gha
44 | cache-to: type=gha,mode=max
45 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env*.local
29 |
30 | # vercel
31 | .vercel
32 |
33 | # typescript
34 | *.tsbuildinfo
35 | next-env.d.ts
36 |
37 | .idea
38 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | pnpm-lock.yaml
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:20-alpine AS base
2 |
3 | # Install dependencies only when needed
4 | FROM base AS deps
5 | RUN apk add --no-cache libc6-compat
6 | WORKDIR /app
7 |
8 | COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
9 | RUN \
10 | if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
11 | elif [ -f package-lock.json ]; then npm ci; \
12 | elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
13 | else echo "Lockfile not found." && exit 1; \
14 | fi
15 |
16 | # Rebuild the source code only when needed
17 | FROM base AS builder
18 | WORKDIR /app
19 | COPY --from=deps /app/node_modules ./node_modules
20 | COPY . .
21 |
22 | ENV NEXT_TELEMETRY_DISABLED 1
23 | RUN \
24 | if [ -f yarn.lock ]; then yarn run build; \
25 | elif [ -f package-lock.json ]; then npm run build; \
26 | elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
27 | else echo "Lockfile not found." && exit 1; \
28 | fi
29 |
30 | # Production image, copy all the files and run next
31 | FROM base AS runner
32 | WORKDIR /app
33 |
34 | ENV NODE_ENV production
35 | ENV NEXT_TELEMETRY_DISABLED 1
36 |
37 | RUN addgroup --system --gid 1001 nodejs
38 | RUN adduser --system --uid 1001 nextjs
39 |
40 | COPY --from=builder /app/public ./public
41 |
42 | RUN mkdir .next
43 | RUN chown nextjs:nodejs .next
44 |
45 | COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
46 | COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
47 |
48 | USER nextjs
49 |
50 | EXPOSE 3000
51 | ENV PORT 3000
52 | CMD HOSTNAME="127.0.0.1" node server.js
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 sunls24
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 🧙 概述
2 |
3 | **AI 算卦:** 通过进行六次硬币的随机卜筮,生成卦象,并使用 AI 对卦象进行分析。
4 |
5 | ## ⚙️ 设置
6 |
7 | #### 环境变量
8 |
9 | - `OPENAI_API_KEY`:不必多说,懂的都懂
10 | - `OPENAI_BASE_URL`:自定义 API 接口地址,默认:`https://api.openai.com/v1`
11 | - `OPENAI_MODEL`:自定义 OpenAI 模型,默认:`gpt-3.5-turbo`
12 |
13 | ## 🚀 本地运行
14 |
15 | 1. 克隆仓库:
16 |
17 | ```sh
18 | git clone https://github.com/sunls24/divination
19 | ```
20 |
21 | 2. 安装依赖项:
22 |
23 | ```bash
24 | pnpm install
25 | ```
26 |
27 | 3. 本地运行:
28 |
29 | ```bash
30 | # 设置环境变量 OPENAI_API_KEY=sk-xxx
31 | touch .env.local
32 | # 本地运行
33 | pnpm run dev
34 | ```
35 |
36 | ## ☁️ 使用 Vercel 部署
37 |
38 | [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fsunls23%2Fdivination&env=OPENAI_API_KEY)
39 |
40 | ---
41 |
42 | 
43 |
--------------------------------------------------------------------------------
/app/apple-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sunls24/divination/4ba380775a74f8ef7238bc9e584f538a447f0d0a/app/apple-icon.png
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sunls24/divination/4ba380775a74f8ef7238bc9e584f538a447f0d0a/app/favicon.ico
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 60 9% 98%;
8 | --foreground: 0 0% 32%;
9 |
10 | --card: 60 9% 98%;
11 | --card-foreground: 0 0% 32%;
12 |
13 | --popover: 60 9% 98%;
14 | --popover-foreground: 0 0% 32%;
15 |
16 | --primary: 0 0% 32%;
17 | --primary-foreground: 60 9.1% 97.8%;
18 |
19 | --secondary: 60 5% 96%;
20 | --secondary-foreground: 0 0% 32%;
21 |
22 | --muted: 60 4.8% 95.9%;
23 | --muted-foreground: 25 5.3% 44.7%;
24 |
25 | --accent: 20 5.9% 94%;
26 | --accent-foreground: 24 9.8% 10%;
27 |
28 | --destructive: 0 84.2% 60.2%;
29 | --destructive-foreground: 60 9.1% 97.8%;
30 |
31 | --border: 20 5.9% 90%;
32 | --input: 24 6% 85%;
33 | --ring: 0 0% 32%;
34 |
35 | --radius: 0.5rem;
36 | }
37 |
38 | .dark {
39 | --background: 24 10% 10%;
40 | --foreground: 60 9.1% 97.8%;
41 |
42 | --card: 24 10% 10%;
43 | --card-foreground: 60 9.1% 97.8%;
44 |
45 | --popover: 24 10% 10%;
46 | --popover-foreground: 60 9.1% 97.8%;
47 |
48 | --primary: 60 9.1% 97.8%;
49 | --primary-foreground: 0 0% 32%;
50 |
51 | --secondary: 0 0% 20%;
52 | --secondary-foreground: 60 9.1% 97.8%;
53 |
54 | --muted: 24 10% 10%;
55 | --muted-foreground: 24 5.4% 66%;
56 |
57 | --accent: 0 0% 20%;
58 | --accent-foreground: 60 9.1% 97.8%;
59 |
60 | --destructive: 0 74% 42%;
61 | --destructive-foreground: 60 9.1% 97.8%;
62 |
63 | --border: 30 6% 25%;
64 | --input: 33 5% 32%;
65 | --ring: 20 6% 90%;
66 | }
67 | }
68 |
69 | @layer base {
70 | * {
71 | @apply border-border;
72 | }
73 |
74 | body {
75 | @apply gap flex flex-col bg-background text-foreground;
76 | }
77 |
78 | body {
79 | font-family: 'LXGW WenKai Screen';
80 | font-weight: normal;
81 | }
82 |
83 | body,
84 | html {
85 | @apply h-full;
86 | }
87 |
88 | footer {
89 | padding-bottom: max(env(safe-area-inset-bottom), 8px);
90 | }
91 | }
92 |
93 | @layer utilities {
94 | .gap {
95 | @apply gap-4 sm:gap-6;
96 | }
97 | }
98 |
99 | @layer utilities {
100 | ::-webkit-scrollbar {
101 | --bar-width: 5px;
102 | width: var(--bar-width);
103 | height: var(--bar-width);
104 | }
105 |
106 | ::-webkit-scrollbar-thumb {
107 | @apply rounded-md bg-border;
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import "./globals.css";
2 | import type { Metadata, Viewport } from "next";
3 | import React from "react";
4 | import Umami from "@/components/umami";
5 | import { ThemeProvider } from "next-themes";
6 |
7 | export const metadata: Metadata = {
8 | title: "AI 算卦 - 在线卜卦 GPT4 解读",
9 | description:
10 | "AI 算卦 - 通过进行六次硬币的随机卜筮,生成卦象,并使用 AI 对卦象进行分析|AI 算命、在线算命、在线算卦、周易易经64卦",
11 | appleWebApp: {
12 | title: "AI 算卦",
13 | },
14 | };
15 |
16 | export const viewport: Viewport = {
17 | viewportFit: "cover",
18 | width: "device-width",
19 | initialScale: 1,
20 | maximumScale: 1,
21 | themeColor: [
22 | { media: "(prefers-color-scheme: light)", color: "#f5f5f4" },
23 | { media: "(prefers-color-scheme: dark)", color: "#333333" },
24 | ],
25 | };
26 |
27 | export default function RootLayout({
28 | children,
29 | }: {
30 | children: React.ReactNode;
31 | }) {
32 | return (
33 |
34 |
35 |
39 |
40 |
41 |
47 | {children}
48 |
49 |
50 |
51 |
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/app/page.tsx:
--------------------------------------------------------------------------------
1 | import Header from "@/components/header";
2 | import Divination from "@/components/divination";
3 | import Footer from "@/components/footer";
4 |
5 | export default function Home() {
6 | return (
7 | <>
8 |
9 |
10 |
11 | >
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/app/server.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 | import { streamText } from "ai";
3 | import { createOpenAI } from "@ai-sdk/openai";
4 | import { createStreamableValue } from "ai/rsc";
5 | import { ERROR_PREFIX } from "@/lib/constant";
6 |
7 | const model = process.env.OPENAI_MODEL ?? "gpt-3.5-turbo";
8 | const openai = createOpenAI({ baseURL: process.env.OPENAI_BASE_URL });
9 |
10 | const STREAM_INTERVAL = 60;
11 | const MAX_SIZE = 6;
12 |
13 | export async function getAnswer(
14 | prompt: string,
15 | guaMark: string,
16 | guaTitle: string,
17 | guaResult: string,
18 | guaChange: string,
19 | ) {
20 | console.log(prompt, guaTitle, guaResult, guaChange);
21 | const stream = createStreamableValue();
22 | try {
23 | const res = await fetch(
24 | `https://raw.githubusercontent.com/sunls2/zhouyi/main/docs/${guaMark}/index.md`,
25 | );
26 | const guaDetail = await res.text();
27 | const explain = guaDetail
28 | .match(/(\*\*台灣張銘仁[\s\S]*?)(?=周易第\d+卦)/)?.[1]
29 | .replaceAll("\n\n", "\n");
30 |
31 | const changeList: string[] = [];
32 | if (guaChange !== "无变爻") {
33 | guaChange
34 | .split(":")[1]
35 | .trim()
36 | .split(",")
37 | .forEach((change) => {
38 | const detail = guaDetail
39 | .match(`(\\*\\*${change}變卦[\\s\\S]*?)(?=${guaTitle}|$)`)?.[1]
40 | .replaceAll("\n\n", "\n");
41 | if (detail) {
42 | changeList.push(detail.trim());
43 | }
44 | });
45 | }
46 |
47 | const { fullStream } = streamText({
48 | temperature: 0.5,
49 | model: openai(model),
50 | messages: [
51 | {
52 | role: "system",
53 | content: `你是一位精通《周易》的AI助手,根据用户提供的卦象和问题,提供准确的卦象解读和实用建议
54 | 任务要求:逻辑清晰,语气得当
55 | 1. 解读卦象:分析主卦、变爻及变卦,解读整体趋势和吉凶
56 | 2. 关联问题:针对用户问题,结合卦象信息,提供具体分析
57 | 3. 提供建议:根据卦象启示,给出切实可行的建议,帮助用户解决实际问题`,
58 | },
59 | {
60 | role: "user",
61 | content: `我摇到的卦象:${guaTitle} ${guaResult} ${guaChange}
62 | 我的问题:${prompt}
63 |
64 | ${explain}
65 | ${changeList.join("\n")}`,
66 | },
67 | ],
68 | maxRetries: 0,
69 | });
70 |
71 | let buffer = "";
72 | let done = false;
73 | const intervalId = setInterval(() => {
74 | if (done && buffer.length === 0) {
75 | clearInterval(intervalId);
76 | stream.done();
77 | return;
78 | }
79 | if (buffer.length <= MAX_SIZE) {
80 | stream.update(buffer);
81 | buffer = "";
82 | } else {
83 | const chunk = buffer.slice(0, MAX_SIZE);
84 | buffer = buffer.slice(MAX_SIZE);
85 | stream.update(chunk);
86 | }
87 | }, STREAM_INTERVAL);
88 |
89 | (async () => {
90 | for await (const part of fullStream) {
91 | switch (part.type) {
92 | case "text-delta":
93 | buffer += part.textDelta;
94 | break;
95 | case "error":
96 | const err = part.error as any;
97 | stream.update(ERROR_PREFIX + (err.message ?? err.toString()));
98 | break;
99 | }
100 | }
101 | })()
102 | .catch(console.error)
103 | .finally(() => {
104 | done = true;
105 | });
106 |
107 | return { data: stream.value };
108 | } catch (err: any) {
109 | stream.done();
110 | return { error: err.message ?? err };
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "app/globals.css",
9 | "baseColor": "stone",
10 | "cssVariables": true
11 | },
12 | "aliases": {
13 | "components": "@/components",
14 | "utils": "@/lib/utils"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/components/coin.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from "react";
2 | import clsx from "clsx";
3 | import Image from "next/image";
4 |
5 | const rotationDuration = 3800;
6 | const bezier = "cubic-bezier(0.645,0.045,0.355,1)";
7 |
8 | function Coin(props: {
9 | frontList: boolean[];
10 | rotation: boolean;
11 | onTransitionEnd: any;
12 | }) {
13 | const [lastFront, setLastFront] = useState(props.frontList);
14 |
15 | useEffect(function () {
16 | if (!props.rotation) {
17 | return;
18 | }
19 |
20 | let id = setTimeout(function () {
21 | setLastFront(props.frontList);
22 | props.onTransitionEnd();
23 | }, rotationDuration);
24 | return () => clearTimeout(id);
25 | });
26 |
27 | return (
28 |
29 | {props.frontList.map((value, index) => (
30 |
36 | ))}
37 |
38 | );
39 | }
40 |
41 | function CoinItem(props: {
42 | front: boolean;
43 | lastFront: boolean;
44 | rotation: boolean;
45 | onTransitionEnd?: any;
46 | }) {
47 | let animate = "";
48 | if (props.rotation) {
49 | // animate-[coin-front-front_3.8s_cubic-bezier(0.645,0.045,0.355,1)]
50 | // animate-[coin-front-back_3.8s_cubic-bezier(0.645,0.045,0.355,1)]
51 | // animate-[coin-back-front_3.8s_cubic-bezier(0.645,0.045,0.355,1)]
52 | // animate-[coin-back-back_3.8s_cubic-bezier(0.645,0.045,0.355,1)]
53 | animate = `animate-[coin-${getFront(props.lastFront)}-${getFront(
54 | props.front,
55 | )}_${rotationDuration / 1000}s_${bezier}]`;
56 | }
57 | return (
58 |
66 |
75 |
85 |
86 | );
87 | }
88 |
89 | function getFront(front: boolean): string {
90 | return front ? "front" : "back";
91 | }
92 |
93 | export default Coin;
94 |
--------------------------------------------------------------------------------
/components/divination.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import React, { useEffect, useRef, useState } from "react";
3 | import Coin from "@/components/coin";
4 | import Hexagram, { HexagramObj } from "@/components/hexagram";
5 | import { bool } from "aimless.js";
6 | import Result, { ResultObj } from "@/components/result";
7 | import Question from "@/components/question";
8 | import ResultAI from "@/components/result-ai";
9 | import { animateChildren } from "@/lib/animate";
10 | import guaIndexData from "@/lib/data/gua-index.json";
11 | import guaListData from "@/lib/data/gua-list.json";
12 | import { getAnswer } from "@/app/server";
13 | import { readStreamableValue } from "ai/rsc";
14 | import { Button } from "./ui/button";
15 | import { BrainCircuit, ListRestart } from "lucide-react";
16 | import { ERROR_PREFIX } from "@/lib/constant";
17 |
18 | const AUTO_DELAY = 600;
19 |
20 | function Divination() {
21 | const [error, setError] = useState("");
22 | const [isLoading, setIsLoading] = useState(false);
23 | const [completion, setCompletion] = useState("");
24 |
25 | async function onCompletion() {
26 | setError("");
27 | setCompletion("");
28 | setIsLoading(true);
29 | try {
30 | const { data, error } = await getAnswer(
31 | question,
32 | resultObj!.guaMark,
33 | resultObj!.guaTitle,
34 | resultObj!.guaResult,
35 | resultObj!.guaChange,
36 | );
37 | if (error) {
38 | setError(error);
39 | return;
40 | }
41 | if (data) {
42 | let ret = "";
43 | for await (const delta of readStreamableValue(data)) {
44 | if (delta.startsWith(ERROR_PREFIX)) {
45 | setError(delta.slice(ERROR_PREFIX.length));
46 | return;
47 | }
48 | ret += delta;
49 | setCompletion(ret);
50 | }
51 | }
52 | } catch (err: any) {
53 | setError(err.message ?? err);
54 | } finally {
55 | setIsLoading(false);
56 | }
57 | }
58 |
59 | const [frontList, setFrontList] = useState([true, true, true]);
60 | const [rotation, setRotation] = useState(false);
61 |
62 | const [hexagramList, setHexagramList] = useState([]);
63 |
64 | const [resultObj, setResultObj] = useState(null);
65 | const [question, setQuestion] = useState("");
66 |
67 | const [resultAi, setResultAi] = useState(false);
68 |
69 | const flexRef = useRef(null);
70 |
71 | const [count, setCount] = useState(0);
72 |
73 | // 自动卜筮
74 | useEffect(() => {
75 | if (rotation || resultObj || count >= 6 || !question) {
76 | return;
77 | }
78 | setTimeout(startClick, AUTO_DELAY);
79 | }, [question, rotation]);
80 |
81 | useEffect(() => {
82 | if (!flexRef.current) {
83 | return;
84 | }
85 | const observer = animateChildren(flexRef.current);
86 | return () => observer.disconnect();
87 | }, []);
88 |
89 | function onTransitionEnd() {
90 | setRotation(false);
91 | let frontCount = frontList.reduce((acc, val) => (val ? acc + 1 : acc), 0);
92 | setHexagramList((list) => {
93 | const newList = [
94 | ...list,
95 | {
96 | change: frontCount == 0 || frontCount == 3 || null,
97 | yang: frontCount >= 2,
98 | separate: list.length == 3,
99 | },
100 | ];
101 | setResult(newList);
102 | return newList;
103 | });
104 | }
105 |
106 | function startClick() {
107 | if (rotation) {
108 | return;
109 | }
110 | if (hexagramList.length >= 6) {
111 | setHexagramList([]);
112 | }
113 | setFrontList([bool(), bool(), bool()]);
114 | setRotation(true);
115 | setCount(count + 1);
116 | }
117 |
118 | async function testClick() {
119 | for (let i = 0; i < 6; i++) {
120 | onTransitionEnd();
121 | }
122 | }
123 |
124 | function restartClick() {
125 | setResultObj(null);
126 | setHexagramList([]);
127 | setQuestion("");
128 | setResultAi(false);
129 | setCount(0);
130 | stop();
131 | }
132 |
133 | function aiClick() {
134 | setResultAi(true);
135 | onCompletion();
136 | }
137 |
138 | function setResult(list: HexagramObj[]) {
139 | if (list.length != 6) {
140 | return;
141 | }
142 | const guaDict1 = ["坤", "震", "坎", "兑", "艮", "离", "巽", "乾"];
143 | const guaDict2 = ["地", "雷", "水", "泽", "山", "火", "风", "天"];
144 |
145 | const changeYang = ["初九", "九二", "九三", "九四", "九五", "上九"];
146 | const changeYin = ["初六", "六二", "六三", "六四", "六五", "上六"];
147 |
148 | const changeList: String[] = [];
149 | list.forEach((value, index) => {
150 | if (!value.change) {
151 | return;
152 | }
153 | changeList.push(value.yang ? changeYang[index] : changeYin[index]);
154 | });
155 |
156 | // 卦的结果: 第X卦 X卦 XX卦 X上X下
157 | // 计算卦的索引,111对应乾卦,000对应坤卦,索引转为10进制。
158 | const upIndex =
159 | (list[5].yang ? 4 : 0) + (list[4].yang ? 2 : 0) + (list[3].yang ? 1 : 0);
160 | const downIndex =
161 | (list[2].yang ? 4 : 0) + (list[1].yang ? 2 : 0) + (list[0].yang ? 1 : 0);
162 |
163 | const guaIndex = guaIndexData[upIndex][downIndex] - 1;
164 | const guaName1 = guaListData[guaIndex];
165 |
166 | let guaName2;
167 | if (upIndex === downIndex) {
168 | // 上下卦相同,格式为X为X
169 | guaName2 = guaDict1[upIndex] + "为" + guaDict2[upIndex];
170 | } else {
171 | guaName2 = guaDict2[upIndex] + guaDict2[downIndex] + guaName1;
172 | }
173 |
174 | const guaDesc = guaDict1[upIndex] + "上" + guaDict1[downIndex] + "下";
175 |
176 | setResultObj({
177 | // 例:26.山天大畜
178 | guaMark: `${(guaIndex + 1).toString().padStart(2, "0")}.${guaName2}`,
179 | guaTitle: `周易第${guaIndex + 1}卦`,
180 | // 例:大畜卦(山天大畜)_艮上乾下
181 | guaResult: `${guaName1}卦(${guaName2})_${guaDesc}`,
182 | guaChange:
183 | changeList.length === 0 ? "无变爻" : `变爻: ${changeList.toString()}`,
184 | });
185 | }
186 |
187 | const showResult = resultObj !== null;
188 | const inputQuestion = question === "";
189 | return (
190 |
194 |
195 |
196 | {!resultAi && !inputQuestion && (
197 |
202 | )}
203 |
204 | {!inputQuestion && !showResult && (
205 |
206 |
207 | 🎲 第{" "}
208 |
209 | {count === 0 ? "-/-" : `${count}/6`}
210 | {" "}
211 | 次卜筮
212 |
213 |
214 | )}
215 |
216 | {!inputQuestion && hexagramList.length != 0 && (
217 |
218 |
219 | {showResult && (
220 |
221 |
222 |
223 |
232 | {resultAi ? null : (
233 |
237 | )}
238 |
239 |
240 | )}
241 |
242 | )}
243 |
244 | {resultAi && (
245 |
251 | )}
252 |
253 | );
254 | }
255 |
256 | export default Divination;
257 |
--------------------------------------------------------------------------------
/components/footer.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { VERSION } from "@/lib/constant";
3 | import { Github } from "lucide-react";
4 |
5 | function Footer() {
6 | return (
7 |
18 | );
19 | }
20 |
21 | export default Footer;
22 |
--------------------------------------------------------------------------------
/components/header.tsx:
--------------------------------------------------------------------------------
1 | import { ChatGPT } from "@/components/svg";
2 | import { ModeToggle } from "@/components/mode-toggle";
3 |
4 | export default function Header() {
5 | return (
6 |
7 |
8 |
9 |
10 | AI 算卦
11 |
12 |
13 |
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/components/hexagram.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import clsx from "clsx";
3 |
4 | export interface HexagramObj {
5 | change: boolean | null;
6 | yang: boolean;
7 | separate: boolean;
8 | }
9 |
10 | function Hexagram(props: { list: HexagramObj[] }) {
11 | return (
12 |
13 | {props.list.map((value, index) => {
14 | return (
15 |
16 | {value.separate &&
}
17 |
18 |
19 | );
20 | })}
21 |
22 | );
23 | }
24 |
25 | function Line(props: { change: boolean | null; yang: boolean }) {
26 | let changeYang = props.change && props.yang;
27 | const color = props.change ? "bg-red-400" : "bg-stone-400";
28 | return (
29 |
30 | {props.yang ? (
31 |
32 | ) : (
33 |
37 | )}
38 | {props.change ?
: null}
39 |
40 | );
41 | }
42 |
43 | function Change(props: { changeYang: boolean | null }) {
44 | return (
45 |
46 |
47 | {props.changeYang ? (
48 | ○
49 | ) : (
50 |
51 | ✕
52 |
53 | )}
54 |
55 |
56 | );
57 | }
58 |
59 | export default Hexagram;
60 |
--------------------------------------------------------------------------------
/components/mode-toggle.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import * as React from "react";
3 | import { useTheme } from "next-themes";
4 |
5 | import { Button } from "@/components/ui/button";
6 | import {
7 | DropdownMenu,
8 | DropdownMenuContent,
9 | DropdownMenuItem,
10 | DropdownMenuTrigger,
11 | } from "@/components/ui/dropdown-menu";
12 | import { Laptop2, MoonStar, Sun } from "lucide-react";
13 |
14 | export function ModeToggle() {
15 | const { setTheme } = useTheme();
16 |
17 | return (
18 |
19 |
20 |
24 |
25 |
26 | setTheme("light")}>
27 |
28 | Light
29 |
30 | setTheme("dark")}>
31 |
32 | Dark
33 |
34 | setTheme("system")}>
35 | System
36 |
37 |
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/components/question.tsx:
--------------------------------------------------------------------------------
1 | import React, { createRef } from "react";
2 | import clsx from "clsx";
3 | import todayJson from "@/lib/data/today.json";
4 | import { Button } from "@/components/ui/button";
5 | import { Textarea } from "@/components/ui/textarea";
6 | import Image from "next/image";
7 |
8 | const todayData: string[] = todayJson;
9 |
10 | function Question(props: { question: string; setQuestion: any }) {
11 | const inputRef = createRef();
12 |
13 | function startClick() {
14 | const value = inputRef.current?.value;
15 | if (value === "") {
16 | return;
17 | }
18 | props.setQuestion(value);
19 | }
20 |
21 | function todayClick(index: number) {
22 | props.setQuestion(todayData[index]);
23 | }
24 |
25 | return (
26 |
32 | {props.question === "" ? (
33 | <>
34 |
35 |
41 |
42 |
45 |
46 |
47 |
50 |
51 | {todayData.map(function (value, index) {
52 | return (
53 | {
56 | todayClick(index);
57 | }}
58 | className="rounded-md border bg-secondary p-2 text-sm text-muted-foreground shadow transition hover:scale-[1.03] dark:border-0 dark:text-foreground/80 dark:shadow-none"
59 | >
60 | {value}
61 |
62 | );
63 | })}
64 |
65 | >
66 | ) : null}
67 |
68 | {props.question && (
69 |
70 |
77 | {props.question}
78 |
79 | )}
80 |
81 | );
82 | }
83 |
84 | export default Question;
85 |
--------------------------------------------------------------------------------
/components/result-ai.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef, useState } from "react";
2 | import { Button } from "@/components/ui/button";
3 | import { RotateCw } from "lucide-react";
4 | import Markdown from "react-markdown";
5 |
6 | function ResultAI({
7 | completion,
8 | isLoading,
9 | onCompletion,
10 | error,
11 | }: {
12 | completion: string;
13 | isLoading: boolean;
14 | onCompletion: () => void;
15 | error: string;
16 | }) {
17 | const scrollRef = useRef(null);
18 | const [autoScroll, setAutoScroll] = useState(false);
19 |
20 | useEffect(() => {
21 | setAutoScroll(isLoading);
22 | }, [isLoading]);
23 |
24 | useEffect(() => {
25 | if (!autoScroll) {
26 | return;
27 | }
28 | scrollTo();
29 | });
30 |
31 | function scrollTo() {
32 | requestAnimationFrame(() => {
33 | if (
34 | !scrollRef.current ||
35 | scrollRef.current.scrollHeight ===
36 | scrollRef.current.clientHeight + scrollRef.current.scrollTop
37 | ) {
38 | return;
39 | }
40 | scrollRef.current.scrollTo(0, scrollRef.current.scrollHeight);
41 | });
42 | }
43 |
44 | function onScroll(e: HTMLElement) {
45 | if (!isLoading) {
46 | return;
47 | }
48 | const hitBottom = e.scrollTop + e.clientHeight >= e.scrollHeight - 15;
49 | if (hitBottom === autoScroll) {
50 | return;
51 | }
52 | setAutoScroll(hitBottom);
53 | }
54 |
55 | return (
56 |
57 | {isLoading && (
58 |
59 |
60 |
61 |
62 | AI 分析中...
63 |
64 |
65 | )}
66 |
onScroll(e.currentTarget)}
69 | className="max-h-full overflow-auto rounded-md border p-3 shadow dark:border-0 dark:bg-secondary/90 dark:shadow-none sm:p-5"
70 | >
71 | {error ? (
72 |
73 | ಠ_ಠ 请求出错了!
74 |
75 | {error}
76 |
77 | ) : (
78 |
{completion}
79 | )}
80 | {!isLoading && (
81 |
85 | )}
86 |
87 |
88 | );
89 | }
90 |
91 | export default ResultAI;
92 |
--------------------------------------------------------------------------------
/components/result.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export interface ResultObj {
4 | guaTitle: string;
5 | guaMark: string;
6 | guaResult: string;
7 | guaChange: string;
8 | }
9 |
10 | function Result(props: ResultObj) {
11 | return (
12 |
26 | );
27 | }
28 |
29 | export default Result;
30 |
--------------------------------------------------------------------------------
/components/svg.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export const ChatGPT = React.memo(function ChatGPT({
4 | className = "w-6 h-6",
5 | strokeWidth = 3,
6 | }: {
7 | className?: string;
8 | strokeWidth?: number;
9 | }) {
10 | return (
11 |
32 | );
33 | });
34 |
--------------------------------------------------------------------------------
/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Slot } from "@radix-ui/react-slot";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
13 | destructive:
14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90",
15 | outline:
16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
17 | secondary:
18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19 | ghost: "hover:bg-accent hover:text-accent-foreground",
20 | link: "text-primary underline-offset-4 hover:underline",
21 | },
22 | size: {
23 | default: "h-10 px-4 py-2",
24 | sm: "h-9 rounded-md px-3",
25 | lg: "h-11 rounded-md px-8",
26 | icon: "h-10 w-10",
27 | },
28 | },
29 | defaultVariants: {
30 | variant: "default",
31 | size: "default",
32 | },
33 | },
34 | );
35 |
36 | export interface ButtonProps
37 | extends React.ButtonHTMLAttributes,
38 | VariantProps {
39 | asChild?: boolean;
40 | }
41 |
42 | const Button = React.forwardRef(
43 | ({ className, variant, size, asChild = false, ...props }, ref) => {
44 | const Comp = asChild ? Slot : "button";
45 | return (
46 |
51 | );
52 | },
53 | );
54 | Button.displayName = "Button";
55 |
56 | export { Button, buttonVariants };
57 |
--------------------------------------------------------------------------------
/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
5 | import { Check, ChevronRight, Circle } from "lucide-react";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const DropdownMenu = DropdownMenuPrimitive.Root;
10 |
11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
12 |
13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group;
14 |
15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
16 |
17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub;
18 |
19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
20 |
21 | const DropdownMenuSubTrigger = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef & {
24 | inset?: boolean;
25 | }
26 | >(({ className, inset, children, ...props }, ref) => (
27 |
36 | {children}
37 |
38 |
39 | ));
40 | DropdownMenuSubTrigger.displayName =
41 | DropdownMenuPrimitive.SubTrigger.displayName;
42 |
43 | const DropdownMenuSubContent = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef
46 | >(({ className, ...props }, ref) => (
47 |
55 | ));
56 | DropdownMenuSubContent.displayName =
57 | DropdownMenuPrimitive.SubContent.displayName;
58 |
59 | const DropdownMenuContent = React.forwardRef<
60 | React.ElementRef,
61 | React.ComponentPropsWithoutRef
62 | >(({ className, sideOffset = 4, ...props }, ref) => (
63 |
64 | e.preventDefault()}
68 | className={cn(
69 | "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
70 | className,
71 | )}
72 | {...props}
73 | />
74 |
75 | ));
76 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
77 |
78 | const DropdownMenuItem = React.forwardRef<
79 | React.ElementRef,
80 | React.ComponentPropsWithoutRef & {
81 | inset?: boolean;
82 | }
83 | >(({ className, inset, ...props }, ref) => (
84 |
93 | ));
94 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
95 |
96 | const DropdownMenuCheckboxItem = React.forwardRef<
97 | React.ElementRef,
98 | React.ComponentPropsWithoutRef
99 | >(({ className, children, checked, ...props }, ref) => (
100 |
109 |
110 |
111 |
112 |
113 |
114 | {children}
115 |
116 | ));
117 | DropdownMenuCheckboxItem.displayName =
118 | DropdownMenuPrimitive.CheckboxItem.displayName;
119 |
120 | const DropdownMenuRadioItem = React.forwardRef<
121 | React.ElementRef,
122 | React.ComponentPropsWithoutRef
123 | >(({ className, children, ...props }, ref) => (
124 |
132 |
133 |
134 |
135 |
136 |
137 | {children}
138 |
139 | ));
140 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
141 |
142 | const DropdownMenuLabel = React.forwardRef<
143 | React.ElementRef,
144 | React.ComponentPropsWithoutRef & {
145 | inset?: boolean;
146 | }
147 | >(({ className, inset, ...props }, ref) => (
148 |
157 | ));
158 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
159 |
160 | const DropdownMenuSeparator = React.forwardRef<
161 | React.ElementRef,
162 | React.ComponentPropsWithoutRef
163 | >(({ className, ...props }, ref) => (
164 |
169 | ));
170 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
171 |
172 | const DropdownMenuShortcut = ({
173 | className,
174 | ...props
175 | }: React.HTMLAttributes) => {
176 | return (
177 |
181 | );
182 | };
183 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
184 |
185 | export {
186 | DropdownMenu,
187 | DropdownMenuTrigger,
188 | DropdownMenuContent,
189 | DropdownMenuItem,
190 | DropdownMenuCheckboxItem,
191 | DropdownMenuRadioItem,
192 | DropdownMenuLabel,
193 | DropdownMenuSeparator,
194 | DropdownMenuShortcut,
195 | DropdownMenuGroup,
196 | DropdownMenuPortal,
197 | DropdownMenuSub,
198 | DropdownMenuSubContent,
199 | DropdownMenuSubTrigger,
200 | DropdownMenuRadioGroup,
201 | };
202 |
--------------------------------------------------------------------------------
/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | );
20 | },
21 | );
22 | Textarea.displayName = "Textarea";
23 |
24 | export { Textarea };
25 |
--------------------------------------------------------------------------------
/components/umami.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Script from "next/script";
3 | import { unstable_noStore as noStore } from "next/cache";
4 |
5 | function Umami() {
6 | noStore();
7 | if (!process.env.UMAMI_ID) {
8 | return;
9 | }
10 | return (
11 |
18 | );
19 | }
20 |
21 | export default Umami;
22 |
--------------------------------------------------------------------------------
/docs/screenshots.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sunls24/divination/4ba380775a74f8ef7238bc9e584f538a447f0d0a/docs/screenshots.jpg
--------------------------------------------------------------------------------
/lib/animate.ts:
--------------------------------------------------------------------------------
1 | export function animateChildren(
2 | container: Element,
3 | duration: number = 300,
4 | easing: string = "ease-out",
5 | ) {
6 | interface ItemInfo {
7 | element: Element;
8 |
9 | x: number;
10 | y: number;
11 | width: number;
12 | height: number;
13 | }
14 |
15 | function getItemInfos(container: Element): ItemInfo[] {
16 | return Array.from(container.children).map((item) => {
17 | const rect = item.getBoundingClientRect();
18 | return {
19 | element: item,
20 | x: rect.left,
21 | y: rect.top,
22 | width: rect.width,
23 | height: rect.height,
24 | };
25 | });
26 | }
27 |
28 | function animateItems(oldItems: ItemInfo[], newItems: ItemInfo[]) {
29 | for (const newItem of newItems) {
30 | if (newItem.element.className.includes("ignore-animate")) {
31 | continue;
32 | }
33 |
34 | let oldItem = oldItems.find((e) => e.element == newItem.element);
35 | if (!oldItem) {
36 | oldItem = {
37 | element: newItem.element,
38 | x: newItem.x,
39 | y: newItem.y,
40 | height: newItem.height / 1.5,
41 | width: newItem.width / 1.5,
42 | };
43 | }
44 |
45 | const translateX = oldItem.x - newItem.x;
46 | const translateY = oldItem.y - newItem.y;
47 | const scaleX = oldItem.width / newItem.width;
48 | const scaleY = oldItem.height / newItem.height;
49 | newItem.element.animate(
50 | [
51 | {
52 | transform: `translate(${translateX}px, ${translateY}px) scale(${scaleX}, ${scaleY})`,
53 | },
54 | { transform: "none" },
55 | ],
56 | { duration: duration, easing: easing },
57 | );
58 | }
59 | }
60 |
61 | let oldItemInfos = getItemInfos(container);
62 | const observer = new MutationObserver(function (
63 | mutations: MutationRecord[],
64 | observer: MutationObserver,
65 | ) {
66 | const newItemInfos = getItemInfos(container);
67 | if (oldItemInfos) {
68 | animateItems(oldItemInfos, newItemInfos);
69 | }
70 | oldItemInfos = newItemInfos;
71 | });
72 | observer.observe(container, { childList: true });
73 | return observer;
74 | }
75 |
--------------------------------------------------------------------------------
/lib/constant.ts:
--------------------------------------------------------------------------------
1 | export const ERROR_PREFIX = "::error::";
2 | export const VERSION = "1.2.3";
3 |
--------------------------------------------------------------------------------
/lib/data/gua-index.json:
--------------------------------------------------------------------------------
1 | [
2 | [2, 24, 7, 19, 15, 36, 46, 11],
3 | [16, 51, 40, 54, 62, 55, 32, 34],
4 | [8, 3, 29, 60, 39, 63, 48, 5],
5 | [45, 17, 47, 58, 31, 49, 28, 43],
6 | [23, 27, 4, 41, 52, 22, 18, 26],
7 | [35, 21, 64, 38, 56, 30, 50, 14],
8 | [20, 42, 59, 61, 53, 37, 57, 9],
9 | [12, 25, 6, 10, 33, 13, 44, 1]
10 | ]
11 |
--------------------------------------------------------------------------------
/lib/data/gua-list.json:
--------------------------------------------------------------------------------
1 | [
2 | "乾",
3 | "坤",
4 | "屯",
5 | "蒙",
6 | "需",
7 | "讼",
8 | "师",
9 | "比",
10 | "小畜",
11 | "履",
12 | "泰",
13 | "否",
14 | "同人",
15 | "大有",
16 | "谦",
17 | "豫",
18 | "随",
19 | "蛊",
20 | "临",
21 | "观",
22 | "噬嗑",
23 | "贲",
24 | "剥",
25 | "复",
26 | "无妄",
27 | "大畜",
28 | "颐",
29 | "大过",
30 | "坎",
31 | "离",
32 | "咸",
33 | "恒",
34 | "遁",
35 | "大壮",
36 | "晋",
37 | "明夷",
38 | "家人",
39 | "睽",
40 | "蹇",
41 | "解",
42 | "损",
43 | "益",
44 | "夬",
45 | "姤",
46 | "萃",
47 | "升",
48 | "困",
49 | "井",
50 | "革",
51 | "鼎",
52 | "震",
53 | "艮",
54 | "渐",
55 | "归妹",
56 | "丰",
57 | "旅",
58 | "巽",
59 | "兑",
60 | "涣",
61 | "节",
62 | "中孚",
63 | "小过",
64 | "既济",
65 | "未济"
66 | ]
67 |
--------------------------------------------------------------------------------
/lib/data/today.json:
--------------------------------------------------------------------------------
1 | [
2 | "今天运势如何?",
3 | "今天适合买彩票吗?",
4 | "每日一卦",
5 | "事业发展",
6 | "感情状况",
7 | "财运"
8 | ]
9 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | output: "standalone",
4 | };
5 |
6 | module.exports = nextConfig;
7 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "divination",
3 | "version": "0.0.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev --turbo",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@ai-sdk/openai": "^1.0.18",
13 | "@radix-ui/react-dropdown-menu": "^2.1.4",
14 | "@radix-ui/react-slot": "^1.1.1",
15 | "ai": "^4.0.33",
16 | "aimless.js": "^1.0.4",
17 | "class-variance-authority": "^0.7.1",
18 | "clsx": "^2.1.1",
19 | "lucide-react": "^0.468.0",
20 | "next": "^15.1.4",
21 | "next-themes": "^0.4.4",
22 | "react": "^18.3.1",
23 | "react-dom": "^18.3.1",
24 | "react-markdown": "^9.0.3",
25 | "tailwind-merge": "^2.6.0"
26 | },
27 | "devDependencies": {
28 | "@tailwindcss/typography": "^0.5.16",
29 | "@types/node": "^22.10.5",
30 | "@types/react": "^18.3.18",
31 | "@types/react-dom": "^18.3.5",
32 | "autoprefixer": "^10.4.20",
33 | "eslint": "^9.18.0",
34 | "eslint-config-next": "^15.1.4",
35 | "eslint-config-prettier": "^9.1.0",
36 | "postcss": "^8.4.49",
37 | "prettier": "^3.4.2",
38 | "prettier-plugin-tailwindcss": "^0.6.9",
39 | "tailwindcss": "^3.4.17",
40 | "tailwindcss-animate": "^1.0.7",
41 | "typescript": "^5.7.3"
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/prettier.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: ["prettier-plugin-tailwindcss"],
3 | };
4 |
--------------------------------------------------------------------------------
/public/img/head.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sunls24/divination/4ba380775a74f8ef7238bc9e584f538a447f0d0a/public/img/head.webp
--------------------------------------------------------------------------------
/public/img/tail.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sunls24/divination/4ba380775a74f8ef7238bc9e584f538a447f0d0a/public/img/tail.webp
--------------------------------------------------------------------------------
/public/img/yin-yang.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sunls24/divination/4ba380775a74f8ef7238bc9e584f538a447f0d0a/public/img/yin-yang.webp
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: ["class"],
4 | content: ["./components/**/*.{ts,tsx}", "./app/**/*.{ts,tsx}"],
5 | theme: {
6 | container: {
7 | center: true,
8 | padding: "2rem",
9 | screens: {
10 | "2xl": "1400px",
11 | },
12 | },
13 | extend: {
14 | colors: {
15 | border: "hsl(var(--border))",
16 | input: "hsl(var(--input))",
17 | ring: "hsl(var(--ring))",
18 | background: "hsl(var(--background))",
19 | foreground: "hsl(var(--foreground))",
20 | primary: {
21 | DEFAULT: "hsl(var(--primary))",
22 | foreground: "hsl(var(--primary-foreground))",
23 | },
24 | secondary: {
25 | DEFAULT: "hsl(var(--secondary))",
26 | foreground: "hsl(var(--secondary-foreground))",
27 | },
28 | destructive: {
29 | DEFAULT: "hsl(var(--destructive))",
30 | foreground: "hsl(var(--destructive-foreground))",
31 | },
32 | muted: {
33 | DEFAULT: "hsl(var(--muted))",
34 | foreground: "hsl(var(--muted-foreground))",
35 | },
36 | accent: {
37 | DEFAULT: "hsl(var(--accent))",
38 | foreground: "hsl(var(--accent-foreground))",
39 | },
40 | popover: {
41 | DEFAULT: "hsl(var(--popover))",
42 | foreground: "hsl(var(--popover-foreground))",
43 | },
44 | card: {
45 | DEFAULT: "hsl(var(--card))",
46 | foreground: "hsl(var(--card-foreground))",
47 | },
48 | },
49 | borderRadius: {
50 | lg: "var(--radius)",
51 | md: "calc(var(--radius) - 2px)",
52 | sm: "calc(var(--radius) - 4px)",
53 | },
54 | keyframes: {
55 | "accordion-down": {
56 | from: { height: 0 },
57 | to: { height: "var(--radix-accordion-content-height)" },
58 | },
59 | "accordion-up": {
60 | from: { height: "var(--radix-accordion-content-height)" },
61 | to: { height: 0 },
62 | },
63 | "coin-front-front": {
64 | "0%": { transform: "rotateY(0deg)" },
65 | "100%": { transform: "rotateY(3600deg)" },
66 | },
67 | "coin-front-back": {
68 | "0%": { transform: "rotateY(0deg)" },
69 | "100%": { transform: "rotateY(3420deg)" },
70 | },
71 | "coin-back-front": {
72 | "0%": { transform: "rotateY(180deg)" },
73 | "100%": { transform: "rotateY(3600deg)" },
74 | },
75 | "coin-back-back": {
76 | "0%": { transform: "rotateY(180deg)" },
77 | "100%": { transform: "rotateY(3780deg)" },
78 | },
79 | "transform-x": {
80 | "0%": { transform: "translateX(100%)" },
81 | "100%": { transform: "translateX(0)" },
82 | },
83 | },
84 | animation: {
85 | "accordion-down": "accordion-down 0.2s ease-out",
86 | "accordion-up": "accordion-up 0.2s ease-out",
87 | },
88 | },
89 | },
90 | plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
91 | };
92 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "bundler",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "plugins": [
18 | {
19 | "name": "next"
20 | }
21 | ],
22 | "paths": {
23 | "@/*": ["./*"]
24 | }
25 | },
26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
27 | "exclude": ["node_modules"]
28 | }
29 |
--------------------------------------------------------------------------------