diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..027a830d1 --- /dev/null +++ b/.env.example @@ -0,0 +1,60 @@ +# Rin 本地开发配置 +# 复制此文件为 .env.local 并填入你的实际配置 + +# ============================================ +# 前端配置 (client/.env) +# ============================================ +# 后端 API 地址 +API_URL=http://localhost:11498 + +# 网站基本信息 +NAME=YourName +DESCRIPTION=Your Description +AVATAR=https://avatars.githubusercontent.com/u/your-id + +# 分页设置 +PAGE_SIZE=5 + +# RSS 设置 +RSS_ENABLE=false + +# ============================================ +# 后端配置 (wrangler.toml 中的 vars) +# ============================================ +# 前端地址(用于 Webhook 等) +FRONTEND_URL=http://localhost:5173 + +# S3/R2 存储配置 +S3_FOLDER=images/ +S3_CACHE_FOLDER=cache/ +S3_REGION=auto +S3_ENDPOINT=https://your-account.r2.cloudflarestorage.com +S3_ACCESS_HOST=https://your-image-domain.com +S3_BUCKET=your-bucket-name +S3_FORCE_PATH_STYLE=false + +# Webhook 通知地址(可选) +WEBHOOK_URL= + +# RSS 标题和描述 +RSS_TITLE=Rin Development +RSS_DESCRIPTION=Development Environment + +# ============================================ +# 敏感配置(会被加密存储) +# ============================================ +# GitHub OAuth 配置 +RIN_GITHUB_CLIENT_ID=your-github-client-id +RIN_GITHUB_CLIENT_SECRET=your-github-client-secret + +# JWT 密钥 +JWT_SECRET=your-jwt-secret-key + +# S3 访问密钥 +S3_ACCESS_KEY_ID=your-s3-access-key +S3_SECRET_ACCESS_KEY=your-s3-secret-key + +# ============================================ +# 数据库配置 +# ============================================ +DB_NAME=rin diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..8dd41db53 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + pull_request: + branches: + - main + - dev + +jobs: + check: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Set up Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: 1.3.6 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Run typecheck + run: bun run typecheck + + - name: Run format check + run: bun run format:check + + - name: Run build + run: bun run build diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 22b9e5b04..26754c83c 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -25,6 +25,8 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@v1 + with: + bun-version: 1.3.6 - name: Deploy env: # Or as an environment variable @@ -52,4 +54,4 @@ jobs: run: | cd Rin/ bun install --frozen-lockfile - bun scripts/migrator.ts + bun scripts/deploy-cf.ts diff --git a/.github/workflows/seo.yaml b/.github/workflows/seo.yaml index bbc847b14..783b3295c 100644 --- a/.github/workflows/seo.yaml +++ b/.github/workflows/seo.yaml @@ -46,4 +46,4 @@ jobs: run: | cd Rin/ bun install --frozen-lockfile - bun scripts/render.ts + bun scripts/seo-render.ts diff --git a/.gitignore b/.gitignore index 01ba640b0..964651ce8 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ dist-ssr *.sln *.sw? /.dev.vars + +# Bun +.bun/* \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c6da1f5eb..af1ff95f0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,16 +6,16 @@ We are happy to accept your patches and contributions to this project. You just # Commit-msg hooks -We have a sample commit-msg hook in `scripts/commit-msg.sh`. Please run the following command to set it up: +We have a sample commit-msg hook in `scripts/git-commit-msg.sh`. Please run the following command to set it up: ```sh -ln -s ../../scripts/commit-msg.sh commit-msg +ln -s ../../scripts/git-commit-msg.sh commit-msg ``` -On Windows, copy the `commit-msg.sh` file directly to `.git/hooks/commit-msg`. +On Windows, copy the `git-commit-msg.sh` file directly to `.git/hooks/commit-msg`. ```powershell -cp .\scripts\commit-msg.sh .\.git\hooks\commit-msg +cp .\scripts\git-commit-msg.sh .\.git\hooks\commit-msg ``` This will run the following checks before each commit: @@ -49,9 +49,9 @@ If you want to skip the hook, run `git commit` with the `--no-verify` option. 6. Perform the database migration > [!TIP] > If your database name (`database_name` in `wrangler.toml`) is not `rin`\ - > Please modify the `DB_NAME` field in `scripts/dev-migrator.sh` before performing the migration + > Please modify the `DB_NAME` field in `scripts/db-migrate-local.ts` before performing the migration ```sh - bun m + bun run db:migrate ``` 7. Configuring the `.dev.vars' file diff --git a/CONTRIBUTING_zh_CN.md b/CONTRIBUTING_zh_CN.md index 2603b6bdb..69bf77ad1 100644 --- a/CONTRIBUTING_zh_CN.md +++ b/CONTRIBUTING_zh_CN.md @@ -6,16 +6,16 @@ # Commit-msg 钩子 -我们在 `scripts/commit-msg.sh` 中有一个示例 commit-msg hook。请运行以下命令设置: +我们在 `scripts/git-commit-msg.sh` 中有一个示例 commit-msg hook。请运行以下命令设置: ```sh -ln -s ../../scripts/commit-msg.sh ./.git/hooks/commit-msg +ln -s ../../scripts/git-commit-msg.sh ./.git/hooks/commit-msg ``` -Windows 下请直接将 `commit-msg.sh` 文件复制到 `.git/hooks/commit-msg`。 +Windows 下请直接将 `git-commit-msg.sh` 文件复制到 `.git/hooks/commit-msg`。 ```powershell -cp .\scripts\commit-msg.sh .\.git\hooks\commit-msg +cp .\scripts\git-commit-msg.sh .\.git\hooks\commit-msg ``` 这将在每次提交之前运行以下检查: @@ -48,9 +48,9 @@ cp .\scripts\commit-msg.sh .\.git\hooks\commit-msg 6. 执行数据库迁移 > [!TIP] > 如果您的数据库名称(`wrangler.toml`中`database_name`)不为 `rin`\ - > 请在执行迁移之前修改 `scripts/dev-migrator.sh` 中的 `DB_NAME` 字段 + > 请在执行迁移之前修改 `scripts/db-migrate-local.ts` 中的 `DB_NAME` 字段 ```sh - bun m + bun run db:migrate ``` 7. 配置 `.dev.vars` 文件 diff --git a/README.md b/README.md index f9238c436..ca3e111cf 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,31 @@ Rin is a blog based on Cloudflare Pages + Workers + D1 + R2. It does not require 13. For more features, please refer to https://xeu.life # Documentation -[docs.openrin.org](https://docs.openrin.org) + +## 快速开始 + +```bash +# 1. 克隆项目 +git clone https://github.com/openRin/Rin.git && cd Rin + +# 2. 安装依赖 +bun install + +# 3. 配置环境变量 +cp .env.example .env.local +# 编辑 .env.local 填入你的配置 + +# 4. 启动开发服务器 +bun run dev +``` + +访问 http://localhost:5173 开始开发! + +详细文档: +- 📖 [本地开发指南](./docs/DEVELOPMENT.md) +- 🚀 [部署指南](./docs/DEPLOY.md) +- 🔧 [环境变量说明](./docs/ENV.md) +- 📚 [完整文档](https://docs.openrin.org) ## Star History diff --git a/bun.lockb b/bun.lockb index d8f899308..3d32d605e 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/client/public/locales/en/translation.json b/client/public/locales/en/translation.json index 9cdb23dca..195472c64 100644 --- a/client/public/locales/en/translation.json +++ b/client/public/locales/en/translation.json @@ -3,6 +3,9 @@ "notfound": "Set the article alias to `about` to create an about page", "title": "About" }, + "ai_summary": { + "title": "AI Summary" + }, "alert": "Alert", "alias": "Alias", "article": { @@ -196,6 +199,38 @@ }, "title": "Friend Link Settings" }, + "ai_summary": { + "title": "AI Summary Settings", + "enable": { + "title": "Enable AI Summary", + "desc": "Automatically generate AI summary when publishing articles" + }, + "provider": { + "title": "AI Provider", + "desc": "Select or enter an AI service provider" + }, + "model": { + "title": "Model", + "desc": "Select or enter model name" + }, + "api_key": { + "title": "API Key", + "desc": "Enter your API key", + "set": "Set", + "placeholder_set": "Enter new key to update" + }, + "api_url": { + "title": "API URL", + "desc": "Custom API URL (OpenAI compatible format)" + }, + "save": { + "title": "Save Configuration", + "desc": "Save AI configuration to database", + "button": "Save Configuration" + }, + "save_success": "Saved successfully", + "unsaved_changes": "You have unsaved changes" + }, "get_config_failed$message": "Failed to get configuration, {{message}}", "import_failed$message": "Import failed, {{message}}", "import_result": "Import Result", @@ -255,4 +290,4 @@ }, "writing": "Writing", "year$year": "{{year}}" -} +} \ No newline at end of file diff --git a/client/public/locales/ja/translation.json b/client/public/locales/ja/translation.json index 6bed72cd8..43f163fc7 100644 --- a/client/public/locales/ja/translation.json +++ b/client/public/locales/ja/translation.json @@ -3,6 +3,9 @@ "notfound": "About ページを作成するには、記事のエイリアスを about に設定します", "title": "概要" }, + "ai_summary": { + "title": "AI 要約" + }, "alert": "警告", "alias": "エイリアス", "article": { @@ -196,6 +199,38 @@ }, "title": "友リンク設定" }, + "ai_summary": { + "title": "AI 要約設定", + "enable": { + "title": "AI 要約を有効にする", + "desc": "記事公開時に AI 要約を自動生成" + }, + "provider": { + "title": "AI プロバイダー", + "desc": "AI サービスプロバイダーを選択または入力" + }, + "model": { + "title": "モデル", + "desc": "モデル名を選択または入力" + }, + "api_key": { + "title": "API キー", + "desc": "API キーを入力してください", + "set": "設定済み", + "placeholder_set": "新しいキーを入力して更新" + }, + "api_url": { + "title": "API URL", + "desc": "カスタム API URL(OpenAI 互換形式)" + }, + "save": { + "title": "設定を保存", + "desc": "AI 設定をデータベースに保存", + "button": "設定を保存" + }, + "save_success": "保存しました", + "unsaved_changes": "保存されていない変更があります" + }, "get_config_failed$message": "設定の取得に失敗しました: {{message}}", "import_failed$message": "インポートに失敗しました: {{message}}", "import_result": "インポート結果", @@ -255,4 +290,4 @@ }, "writing": "執筆", "year$year": "{{year}} 年" -} +} \ No newline at end of file diff --git a/client/public/locales/zh-CN/translation.json b/client/public/locales/zh-CN/translation.json index 03589758d..229ba5dc2 100644 --- a/client/public/locales/zh-CN/translation.json +++ b/client/public/locales/zh-CN/translation.json @@ -3,6 +3,9 @@ "notfound": "将文章别名设置为 about 可以创建关于页面", "title": "关于" }, + "ai_summary": { + "title": "AI 总结" + }, "alert": "提示", "alias": "别名", "article": { @@ -196,6 +199,38 @@ }, "title": "友链设置" }, + "ai_summary": { + "title": "AI 总结设置", + "enable": { + "title": "启用 AI 总结", + "desc": "发布文章时自动生成 AI 总结" + }, + "provider": { + "title": "AI 供应商", + "desc": "选择或输入 AI 服务供应商" + }, + "model": { + "title": "模型", + "desc": "选择或输入模型名称" + }, + "api_key": { + "title": "API Key", + "desc": "输入您的 API 密钥", + "set": "已设置", + "placeholder_set": "输入新密钥以更新" + }, + "api_url": { + "title": "API URL", + "desc": "自定义 API 地址(OpenAI 兼容格式)" + }, + "save": { + "title": "保存配置", + "desc": "保存 AI 配置到数据库", + "button": "保存配置" + }, + "save_success": "保存成功", + "unsaved_changes": "您有未保存的更改" + }, "get_config_failed$message": "获取配置失败,{{message}}", "import_failed$message": "导入失败,{{message}}", "import_result": "导入结果", @@ -255,4 +290,4 @@ }, "writing": "写作", "year$year": "{{year}} 年" -} +} \ No newline at end of file diff --git a/client/public/locales/zh-TW/translation.json b/client/public/locales/zh-TW/translation.json index 4ecef6a7a..005227da7 100644 --- a/client/public/locales/zh-TW/translation.json +++ b/client/public/locales/zh-TW/translation.json @@ -3,6 +3,9 @@ "notfound": "將文章別名設定為 about 可以創建關於頁面", "title": "關於" }, + "ai_summary": { + "title": "AI 總結" + }, "alert": "提示", "alias": "別名", "article": { @@ -196,6 +199,38 @@ }, "title": "友情連結設定" }, + "ai_summary": { + "title": "AI 總結設定", + "enable": { + "title": "啟用 AI 總結", + "desc": "發佈文章時自動產生 AI 總結" + }, + "provider": { + "title": "AI 供應商", + "desc": "選擇或輸入 AI 服務供應商" + }, + "model": { + "title": "模型", + "desc": "選擇或輸入模型名稱" + }, + "api_key": { + "title": "API Key", + "desc": "輸入您的 API 金鑰", + "set": "已設定", + "placeholder_set": "輸入新金鑰以更新" + }, + "api_url": { + "title": "API URL", + "desc": "自訂 API 地址(OpenAI 相容格式)" + }, + "save": { + "title": "儲存設定", + "desc": "儲存 AI 設定到資料庫", + "button": "儲存設定" + }, + "save_success": "儲存成功", + "unsaved_changes": "您有未儲存的變更" + }, "get_config_failed$message": "獲取配置失敗,{{message}}", "import_failed$message": "匯入失敗,{{message}}", "import_result": "匯入结果", @@ -255,4 +290,4 @@ }, "writing": "寫作", "year$year": "{{year}} 年" -} +} \ No newline at end of file diff --git a/client/src/components/button.tsx b/client/src/components/button.tsx index 0ef4f3bea..315843cd1 100644 --- a/client/src/components/button.tsx +++ b/client/src/components/button.tsx @@ -1,8 +1,8 @@ import ReactLoading from "react-loading"; -export function Button({ title, onClick, secondary = false }: { title: string, secondary?: boolean, onClick: () => void }) { +export function Button({ title, onClick, secondary = false, disabled = false }: { title: string, secondary?: boolean, onClick: () => void, disabled?: boolean }) { return ( - ); diff --git a/client/src/components/markdown_editor.tsx b/client/src/components/markdown_editor.tsx index 41889b1f6..b61e19f30 100644 --- a/client/src/components/markdown_editor.tsx +++ b/client/src/components/markdown_editor.tsx @@ -1,6 +1,6 @@ import Editor from '@monaco-editor/react'; import { editor } from 'monaco-editor'; -import React, { useRef, useState } from "react"; +import React, { useRef, useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; import Loading from 'react-loading'; import { useColorMode } from "../utils/darkModeUtils"; @@ -19,6 +19,7 @@ export function MarkdownEditor({ content, setContent, placeholder = "> Write you const { t } = useTranslation(); const colorMode = useColorMode(); const editorRef = useRef(); + const isComposingRef = useRef(false); const [preview, setPreview] = useState<'edit' | 'preview' | 'comparison'>('edit'); const [uploading, setUploading] = useState(false); @@ -107,6 +108,50 @@ export function MarkdownEditor({ content, setContent, placeholder = "> Write you ); } + /* ---------------- Monaco Mount & IME Optimization ---------------- */ + + const handleEditorMount = (editor: editor.IStandaloneCodeEditor) => { + editorRef.current = editor; + + editor.onDidCompositionStart(() => { + isComposingRef.current = true; + }); + + editor.onDidCompositionEnd(() => { + isComposingRef.current = false; + setContent(editor.getValue()); + }); + + editor.onDidChangeModelContent(() => { + if (!isComposingRef.current) { + setContent(editor.getValue()); + } + }); + + editor.onDidBlurEditorText(() => { + setContent(editor.getValue()); + }); + }; + + /* ---------------- synchronization ---------------- */ + + useEffect(() => { + const editor = editorRef.current; + if (!editor) return; + + const model = editor.getModel(); + if (!model) return; + + const editorValue = model.getValue(); + + // Avoid infinite loops & prevent overwriting content being edited + if (editorValue !== content) { + editor.setValue(content); + } + }, [content]); + + /* ---------------- UI ---------------- */ + return (
@@ -149,23 +194,31 @@ export function MarkdownEditor({ content, setContent, placeholder = "> Write you onPaste={handlePaste} > { - editorRef.current = editor; - }} + onMount={handleEditorMount} height={height} defaultLanguage="markdown" - className="" - value={content} - onChange={(data, _) => { - setContent(data ?? ""); - }} + defaultValue={content} theme={colorMode === "dark" ? "vs-dark" : "light"} options={{ wordWrap: "on", + + // Chinese IME stability key + fontFamily: "Sarasa Mono SC, JetBrains Mono, monospace", + fontLigatures: false, + letterSpacing: 0, + fontSize: 14, lineNumbers: "off", + + accessibilitySupport: "off", + unicodeHighlight: { ambiguousCharacters: false }, + + renderWhitespace: "none", + renderControlCharacters: false, + smoothScrolling: false, + dragAndDrop: true, - pasteAs: { enabled: false } + pasteAs: { enabled: false }, }} />
diff --git a/client/src/page/feed.tsx b/client/src/page/feed.tsx index a9c918c81..fc0b02cf2 100644 --- a/client/src/page/feed.tsx +++ b/client/src/page/feed.tsx @@ -1,24 +1,24 @@ -import {useContext, useEffect, useRef, useState} from "react"; -import {Helmet} from "react-helmet"; -import {useTranslation} from "react-i18next"; +import { useContext, useEffect, useRef, useState } from "react"; +import { Helmet } from "react-helmet"; +import { useTranslation } from "react-i18next"; import ReactModal from "react-modal"; import Popup from "reactjs-popup"; -import {Link, useLocation} from "wouter"; -import {useAlert, useConfirm} from "../components/dialog"; -import {HashTag} from "../components/hashtag"; -import {Waiting} from "../components/loading"; -import {Markdown} from "../components/markdown"; -import {client} from "../main"; -import {ClientConfigContext} from "../state/config"; -import {ProfileContext} from "../state/profile"; -import {headersWithAuth} from "../utils/auth"; -import {siteName} from "../utils/constants"; -import {timeago} from "../utils/timeago"; -import {Button} from "../components/button"; -import {Tips} from "../components/tips"; -import {useLoginModal} from "../hooks/useLoginModal"; +import { Link, useLocation } from "wouter"; +import { useAlert, useConfirm } from "../components/dialog"; +import { HashTag } from "../components/hashtag"; +import { Waiting } from "../components/loading"; +import { Markdown } from "../components/markdown"; +import { client } from "../main"; +import { ClientConfigContext } from "../state/config"; +import { ProfileContext } from "../state/profile"; +import { headersWithAuth } from "../utils/auth"; +import { siteName } from "../utils/constants"; +import { timeago } from "../utils/timeago"; +import { Button } from "../components/button"; +import { Tips } from "../components/tips"; +import { useLoginModal } from "../hooks/useLoginModal"; import mermaid from "mermaid"; -import {AdjacentSection} from "../components/adjacent_feed.tsx"; +import { AdjacentSection } from "../components/adjacent_feed.tsx"; type Feed = { id: number; @@ -27,6 +27,7 @@ type Feed = { uid: number; createdAt: Date; updatedAt: Date; + ai_summary: string; hashtags: { id: number; name: string; @@ -55,6 +56,30 @@ export function FeedPage({ id, TOC, clean }: { id: string, TOC: () => JSX.Elemen const [top, setTop] = useState(0); const config = useContext(ClientConfigContext); const counterEnabled = config.get('counter.enabled'); + const [aiSummaryEnabled, setAiSummaryEnabled] = useState(config.get('ai_summary.enabled') ?? false); + + // Listen for config changes + useEffect(() => { + const handleStorageChange = () => { + const configStr = sessionStorage.getItem('config'); + if (configStr) { + try { + const configObj = JSON.parse(configStr); + setAiSummaryEnabled(configObj['ai_summary.enabled'] ?? false); + } catch (e) { + console.error('Failed to parse config:', e); + } + } + }; + + window.addEventListener('storage', handleStorageChange); + // Also check immediately in case the event was already fired + handleStorageChange(); + + return () => { + window.removeEventListener('storage', handleStorageChange); + }; + }, []); function deleteFeed() { // Confirm showConfirm( @@ -140,7 +165,7 @@ export function FeedPage({ id, TOC, clean }: { id: string, TOC: () => JSX.Elemen mermaid.run({ suppressErrors: true, nodes: document.querySelectorAll("pre.mermaid_default") - }).then(()=>{ + }).then(() => { mermaid.initialize({ startOnLoad: false, theme: "dark", @@ -277,6 +302,19 @@ export function FeedPage({ id, TOC, clean }: { id: string, TOC: () => JSX.Elemen )}
+ {feed.ai_summary && aiSummaryEnabled && ( +
+
+ + + {t('ai_summary.title')} + +
+

+ {feed.ai_summary} +

+
+ )}
{feed.hashtags.length > 0 && ( @@ -299,13 +337,13 @@ export function FeedPage({ id, TOC, clean }: { id: string, TOC: () => JSX.Elemen
- + {feed && }
@@ -408,7 +446,7 @@ function CommentInput({ }); } return ( -
+
diff --git a/client/src/page/settings.tsx b/client/src/page/settings.tsx index d454e9b73..74d837323 100644 --- a/client/src/page/settings.tsx +++ b/client/src/page/settings.tsx @@ -1,11 +1,11 @@ import * as Switch from '@radix-ui/react-switch'; -import {ChangeEvent, useContext, useEffect, useRef, useState} from "react"; -import {useTranslation} from "react-i18next"; +import { ChangeEvent, useContext, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import ReactLoading from "react-loading"; import Modal from "react-modal"; -import {Button} from "../components/button.tsx"; -import {useAlert, useConfirm} from "../components/dialog.tsx"; -import {client, oauth_url} from "../main.tsx"; +import { Button } from "../components/button.tsx"; +import { useAlert, useConfirm } from "../components/dialog.tsx"; +import { client, endpoint, oauth_url } from "../main.tsx"; import { ClientConfigContext, ConfigWrapper, @@ -15,7 +15,7 @@ import { defaultServerConfigWrapper, ServerConfigContext } from "../state/config.tsx"; -import {headersWithAuth} from "../utils/auth.ts"; +import { headersWithAuth } from "../utils/auth.ts"; import '../utils/thumb.css'; @@ -138,7 +138,7 @@ export function Settings() { - + @@ -163,6 +163,7 @@ export function Settings() { +
@@ -507,3 +508,387 @@ function ItemWithUpload({
); } + +// AI Provider presets with their default API URLs +const AI_PROVIDER_PRESETS = [ + { value: 'openai', label: 'OpenAI', url: 'https://api.openai.com/v1' }, + { value: 'claude', label: 'Claude', url: 'https://api.anthropic.com/v1' }, + { value: 'gemini', label: 'Gemini', url: 'https://generativelanguage.googleapis.com/v1beta/openai' }, + { value: 'deepseek', label: 'DeepSeek', url: 'https://api.deepseek.com/v1' }, + { value: 'zhipu', label: 'Zhipu', url: 'https://open.bigmodel.cn/api/paas/v4' } +]; + +const AI_MODEL_PRESETS: Record = { + openai: [ + // GPT-5 series (latest) + 'gpt-5.2', + 'gpt-5.1', + 'gpt-5', + 'gpt-5-mini', + 'gpt-5-nano', + 'gpt-5-pro', + // GPT-5 Codex series + 'gpt-5.1-codex', + 'gpt-5.1-codex-max', + 'gpt-5.1-codex-mini', + 'gpt-5-codex', + // GPT-4 series + 'gpt-4.1', + 'gpt-4.1-mini', + 'gpt-4.1-nano', + 'gpt-4o', + 'gpt-4o-mini', + 'gpt-4-turbo', + // Legacy models + 'gpt-3.5-turbo', + 'o3', + 'o3-mini', + 'o1', + 'o1-mini', + 'o1-preview' + ], + claude: [ + // Claude 4.5 series (latest) + 'claude-opus-4-5-20251101', + 'claude-sonnet-4-5-20250929', + 'claude-haiku-4-5-20251001', + // Aliases for 4.5 + 'claude-opus-4-5', + 'claude-sonnet-4-5', + 'claude-haiku-4-5', + // Legacy Claude 3 series + 'claude-3-5-sonnet-20241022', + 'claude-3-5-haiku-20241022', + 'claude-3-opus-20240229' + ], + gemini: [ + // Gemini 3 series (latest) + 'gemini-3-pro-preview', + 'gemini-3-flash-preview', + // Gemini 2.5 series + 'gemini-2.5-flash', + 'gemini-2.5-flash-preview-09-2025', + 'gemini-2.5-flash-lite', + 'gemini-2.5-flash-lite-preview-09-2025', + 'gemini-2.5-pro', + // Gemini 2.0 series + 'gemini-2.0-flash', + 'gemini-2.0-flash-001', + 'gemini-2.0-flash-exp', + 'gemini-2.0-flash-lite', + 'gemini-2.0-flash-lite-001', + // Legacy + 'gemini-1.5-pro', + 'gemini-1.5-flash' + ], + deepseek: ['deepseek-chat', 'deepseek-reasoner'], + zhipu: [ + // GLM-4 series (latest) + 'glm-4.7', + 'glm-4.6', + 'glm-4.5', + // High performance/cost-effective models + 'glm-4.5-air', + 'glm-4.5-airx', + 'glm-4.5-flash', + // Long context model + 'glm-4-long', + // Vision models + 'glm-4.6v', + 'glm-4.1v-thinking-flashx', + 'glm-4.6v-flash', + 'glm-4.1v-thinking-flash', + 'glm-4v-flash', + // Legacy models + 'glm-4', + 'glm-4-plus', + 'glm-4-air', + 'glm-4-flash', + 'glm-4-flash-250414', + 'glm-4-flashx-250414', + 'glm-3-turbo' + ] +}; + +function AISummarySettings() { + const { t } = useTranslation(); + const [enabled, setEnabled] = useState(false); + const [provider, setProvider] = useState('openai'); + const [model, setModel] = useState('gpt-4o-mini'); + const [apiKey, setApiKey] = useState(''); + const [apiKeySet, setApiKeySet] = useState(false); + const [apiUrl, setApiUrl] = useState(''); + const [loading, setLoading] = useState(false); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const { showAlert, AlertUI } = useAlert(); + + // Load AI config from database via new API + useEffect(() => { + const loadConfig = async () => { + try { + const response = await fetch(`${endpoint}/ai-config`, { + headers: headersWithAuth() + }); + if (response.ok) { + const data = await response.json() as { + enabled?: boolean; + provider?: string; + model?: string; + api_key_set?: boolean; + api_url?: string; + }; + setEnabled(data.enabled ?? false); + setProvider(data.provider ?? 'openai'); + setModel(data.model ?? 'gpt-4o-mini'); + setApiKeySet(data.api_key_set ?? false); + setApiUrl(data.api_url ?? ''); + } + } catch (err) { + console.error('Failed to load AI config:', err); + } + }; + loadConfig(); + }, []); + + const updateConfig = async (updates: Record) => { + setLoading(true); + try { + const response = await fetch(`${endpoint}/ai-config`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...headersWithAuth() + }, + body: JSON.stringify(updates) + }); + if (!response.ok) { + const errorData = await response.text(); + console.error('AI config save error:', response.status, errorData); + throw new Error(`Failed to save config: ${response.status} - ${errorData}`); + } + } catch (err: any) { + showAlert(t('settings.update_failed$message', { message: err.message })); + throw err; + } finally { + setLoading(false); + } + }; + + const handleToggleEnabled = async (checked: boolean) => { + setEnabled(checked); + // Reset unsaved changes when toggling AI feature on/off + setHasUnsavedChanges(false); + await updateConfig({ enabled: checked }); + // Refresh client config to update ai_summary.enabled in global state + try { + const { data } = await client.config({ type: 'client' }).get({ + headers: headersWithAuth() + }); + if (data && typeof data !== 'string') { + sessionStorage.setItem('config', JSON.stringify(data)); + // Trigger a storage event to notify other components + window.dispatchEvent(new Event('storage')); + } + } catch (err) { + console.error('Failed to refresh client config:', err); + } + }; + + const handleProviderChange = (newProvider: string) => { + setProvider(newProvider); + const preset = AI_PROVIDER_PRESETS.find(p => p.value === newProvider); + if (preset) { + setApiUrl(preset.url); + const models = AI_MODEL_PRESETS[newProvider]; + if (models && models.length > 0) { + setModel(models[0]); + } + } + setHasUnsavedChanges(true); + }; + + const handleSaveConfig = async () => { + const preset = AI_PROVIDER_PRESETS.find(p => p.value === provider); + await updateConfig({ + provider: provider, + model: model, + api_url: apiUrl || preset?.url, + api_key: apiKey.trim() || undefined + }); + setHasUnsavedChanges(false); + if (apiKey.trim()) { + setApiKeySet(true); + } + setApiKey(''); + showAlert(t('settings.ai_summary.save_success')); + }; + + const modelOptions = AI_MODEL_PRESETS[provider] || []; + + return ( + <> + +
+
+
+

+ {t('settings.ai_summary.enable.title')} +

+

+ {t('settings.ai_summary.enable.desc')} +

+
+
+ {loading && } + + + +
+
+
+ + {enabled && ( + <> + {/* Provider */} +
+
+
+

+ {t('settings.ai_summary.provider.title')} +

+

+ {t('settings.ai_summary.provider.desc')} +

+
+
+ +
+
+
+ + {/* Model */} +
+
+
+

+ {t('settings.ai_summary.model.title')} +

+

+ {t('settings.ai_summary.model.desc')} +

+
+
+ { + setModel(e.target.value); + setHasUnsavedChanges(true); + }} + className="rounded-lg px-3 py-1.5 bg-secondary t-primary text-sm w-56" + placeholder="选择或输入模型" + /> + + {modelOptions.map(m => ( + + ))} + +
+
+
+ + {/* API Key */} +
+
+
+

+ {t('settings.ai_summary.api_key.title')} + {apiKeySet && (✓ {t('settings.ai_summary.api_key.set')})} +

+

+ {t('settings.ai_summary.api_key.desc')} +

+
+
+ { + setApiKey(e.target.value); + setHasUnsavedChanges(true); + }} + placeholder={apiKeySet ? t('settings.ai_summary.api_key.placeholder_set') : "sk-..."} + className="rounded-lg px-3 py-1.5 bg-secondary t-primary text-sm w-56" + /> +
+
+
+ + {/* API URL */} +
+
+
+

+ {t('settings.ai_summary.api_url.title')} +

+

+ {t('settings.ai_summary.api_url.desc')} +

+
+
+ { + setApiUrl(e.target.value); + setHasUnsavedChanges(true); + }} + placeholder="https://api.openai.com/v1" + className="rounded-lg px-3 py-1.5 bg-secondary t-primary text-sm w-64" + /> +
+
+
+ + )} + + {/* Save Button - only visible when there are unsaved configuration changes */} + {hasUnsavedChanges && ( +
+
+
+

+ {t('settings.ai_summary.save.title')} +

+

+ {t('settings.ai_summary.save.desc')} +

+
+
+ {loading && } +
+
+

+ {t('settings.ai_summary.unsaved_changes')} +

+
+ )} + + + ); +} diff --git a/client/src/page/timeline.tsx b/client/src/page/timeline.tsx index 828d3e712..dc2fe1b89 100644 --- a/client/src/page/timeline.tsx +++ b/client/src/page/timeline.tsx @@ -7,23 +7,43 @@ import {headersWithAuth} from "../utils/auth" import {siteName} from "../utils/constants" import {useTranslation} from "react-i18next"; +interface FeedItem { + id: number; + createdAt: Date; + title: string | null; +} export function TimelinePage() { - const [feeds, setFeeds] = useState>>() + const [feeds, setFeeds] = useState>>() const [length, setLength] = useState(0) const ref = useRef(false) const { t } = useTranslation() function fetchFeeds() { client.feed.timeline.get({ headers: headersWithAuth() - }).then(({ data }) => { + }) + .then(({ data }) => { if (data && typeof data !== 'string') { - setLength(data.length) - const groups = Object.groupBy(data, ({ createdAt }) => new Date(createdAt).getFullYear()) + const arr = Array.isArray(data) ? data : [] + setLength(arr.length) + // 兼容的分组逻辑 + const groups = (Object.groupBy + ? Object.groupBy(arr, ({ createdAt }) => new Date(createdAt).getFullYear()) + : arr.reduce>((acc, item) => { + const key = new Date(item.createdAt).getFullYear() + ;(acc[key] ||= []).push(item) + return acc + }, {}) + ) + setFeeds(groups) } }) + .catch(err => { + console.error("fetchFeeds error:", err) + }) } + useEffect(() => { if (ref.current) return fetchFeeds() diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 560cbec1b..74dca76f1 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -111,6 +111,7 @@ S3_SECRET_ACCESS_KEY=<你的S3SecretAccessKey> > [!IMPORTANT] > 最后两行环境变量 `SKIP_DEPENDENCY_INSTALL` 和 `UNSTABLE_PRE_BUILD` 为配置 Cloudflare 使用 Bun 进行构建的参数,不要修改 +Bun 的版本要低于 1.3.0,可以是 1.2.13 或 1.2.15 ```ini NAME=Xeu # 昵称,显示在左上角 @@ -119,7 +120,7 @@ AVATAR=https://avatars.githubusercontent.com/u/36541432 # 头像地址,显示 API_URL=https://rin.xeu.life # 服务端域名,可以先使用默认值查看效果,后续部署服务端后再修改 PAGE_SIZE=5 # 默认分页大小,推荐 5 SKIP_DEPENDENCY_INSTALL=true -UNSTABLE_PRE_BUILD=asdf install bun latest && asdf global bun latest && bun i +UNSTABLE_PRE_BUILD=asdf install bun 1.2.13 && asdf global bun 1.2.13 && bun i ``` ![1000000660](https://github.com/openRin/Rin/assets/36541432/0fe9276f-e16f-4b8a-87c5-14de582c9a3a) @@ -259,6 +260,8 @@ S3_ENDPOINT=https://8879900e5e1219fb745c9f69b086565a.r2.cloudflarestorage.com S3_ACCESS_HOST=https://image.xeu.life ``` +> 这里记得启用 **公共开发 URL**,否则前端会提示CORS跨域、后端1101等问题。[参考](https://developers.cloudflare.com/r2/buckets/public-buckets/#managed-public-buckets-through-r2dev) + 然后创建一个 API 令牌用于访问存储桶,可参考 https://developers.cloudflare.com/r2/api/s3/tokens/ ,这里不再赘述,拿到 ID 和 TOKEN 对应于`S3_ACCESS_KEY_ID` 和 `S3_SECRET_ACCESS_KEY` 变量,填入 Workers 的环境变量中 至此后端就已经部署完成了,记得将前端的 API_URL 修改为后端的地址,与此同时,如果你需要 WebHook 通知的话,还可在后端配置环境变量`WEBHOOK_URL`为你的 Webhook 地址,在新增评论时会像目标 URL 发送一条 POST 消息,消息格式为: diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 000000000..dab118ce9 --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,210 @@ +# 本地开发指南 + +本文档介绍如何在本地开发和调试 Rin 项目。 + +## 快速开始 + +### 1. 克隆项目 + +```bash +git clone https://github.com/openRin/Rin.git +cd Rin +``` + +### 2. 安装依赖 + +```bash +bun install +``` + +### 3. 配置环境变量 + +```bash +# 复制示例配置文件 +cp .env.example .env.local + +# 编辑配置文件,填入你的实际配置 +vim .env.local # 或使用其他编辑器 +``` + +### 4. 启动开发服务器 + +```bash +bun run dev +``` + +这将自动完成以下操作: +- ✅ 生成 `wrangler.toml` 配置文件 +- ✅ 生成 `client/.env` 前端环境变量 +- ✅ 生成 `.dev.vars` 敏感信息文件 +- ✅ 运行数据库迁移 +- ✅ 启动后端服务(端口 11498) +- ✅ 启动前端服务(端口 5173) + +访问 http://localhost:5173 即可开始开发! + +## 环境变量配置 + +所有配置都集中在 `.env.local` 文件中: + +### 前端配置 + +| 变量名 | 必填 | 说明 | 示例 | +|--------|------|------|------| +| `API_URL` | 是 | 后端 API 地址 | `http://localhost:11498` | +| `NAME` | 是 | 网站名称 | `My Blog` | +| `AVATAR` | 是 | 头像地址 | `https://...` | +| `DESCRIPTION` | 否 | 网站描述 | `A blog` | +| `PAGE_SIZE` | 否 | 分页大小 | `5` | +| `RSS_ENABLE` | 否 | 启用 RSS | `false` | + +### 后端配置 + +| 变量名 | 必填 | 说明 | 示例 | +|--------|------|------|------| +| `FRONTEND_URL` | 是 | 前端地址 | `http://localhost:5173` | +| `S3_ENDPOINT` | 是 | S3/R2 端点 | `https://...r2.cloudflarestorage.com` | +| `S3_BUCKET` | 是 | 存储桶名称 | `images` | +| `S3_REGION` | 否 | 区域 | `auto` | +| `S3_FOLDER` | 否 | 图片存储路径 | `images/` | +| `WEBHOOK_URL` | 否 | 通知 Webhook | `https://...` | + +### 敏感配置(必须) + +| 变量名 | 说明 | +|--------|------| +| `RIN_GITHUB_CLIENT_ID` | GitHub OAuth Client ID | +| `RIN_GITHUB_CLIENT_SECRET` | GitHub OAuth Client Secret | +| `JWT_SECRET` | JWT 签名密钥 | +| `S3_ACCESS_KEY_ID` | S3 Access Key | +| `S3_SECRET_ACCESS_KEY` | S3 Secret Key | + +## 常用命令 + +```bash +# 启动完整开发环境(推荐) +bun run dev + +# 仅启动前端 +bun run dev:client + +# 仅启动后端 +bun run dev:server + +# 运行数据库迁移 +bun run db:migrate + +# 生成数据库迁移文件 +bun run db:generate + +# 重新生成配置文件 +bun run dev:setup + +# 构建项目 +bun run build + +# 清理生成的文件 +bun run clean + +# 运行类型检查 +bun run typecheck + +# 格式化代码 +bun run format:write +bun run format:check +``` + +## 开发工作流 + +### 首次设置 + +1. Fork 项目仓库 +2. 克隆到本地 +3. 安装依赖:`bun install` +4. 配置 `.env.local` +5. 运行 `bun run dev` + +### 日常开发 + +1. 修改代码 +2. 前端自动热更新,后端修改后自动重启 +3. 测试功能 +4. 提交代码 + +### 数据库变更 + +1. 修改 `server/src/db/schema.ts` +2. 运行 `bun run db:generate` 生成迁移文件 +3. 运行 `bun run db:migrate` 应用迁移 + +## 故障排除 + +### 端口被占用 + +如果端口 5173 或 11498 被占用,可以修改 `.env.local` 中的配置: + +```bash +# 修改前端端口(需要在 vite.config.ts 中配置) +# 修改后端端口 +bun run dev:server -- --port 11499 +``` + +### 数据库迁移失败 + +```bash +# 清理本地数据库并重新迁移 +rm -rf .wrangler/state +bun run db:migrate +``` + +### 配置文件未生成 + +```bash +# 手动运行配置生成 +bun run dev:setup +``` + +### GitHub OAuth 配置 + +本地开发时需要配置 GitHub OAuth: + +1. 访问 https://github.com/settings/developers +2. 创建新的 OAuth App +3. Authorization callback URL 填写:`http://localhost:11498/user/github/callback` +4. 将 Client ID 和 Client Secret 填入 `.env.local` + +## 项目结构 + +``` +. +├── client/ # 前端代码 +│ ├── src/ +│ │ ├── page/ # 页面组件 +│ │ ├── state/ # 状态管理 +│ │ └── utils/ # 工具函数 +│ └── package.json +├── server/ # 后端代码 +│ ├── src/ +│ │ ├── services/ # 业务服务 +│ │ ├── db/ # 数据库 +│ │ └── utils/ # 工具函数 +│ └── package.json +├── scripts/ # 开发脚本 +│ ├── dev.ts # 开发服务器 +│ ├── setup-dev.ts # 配置生成 +│ └── db-migrate-local.ts # 数据库迁移 +├── docs/ # 文档 +├── .env.example # 环境变量示例 +├── .env.local # 本地配置(不提交到 Git) +└── package.json +``` + +## 生产部署 + +请参考 [DEPLOY.md](./DEPLOY.md) 了解生产环境部署流程。 + +## 获取帮助 + +- 📖 完整文档:https://docs.openrin.org +- 💬 Discord:https://discord.gg/JWbSTHvAPN +- 🐛 提交 Issue:https://github.com/openRin/Rin/issues diff --git a/package.json b/package.json index 0bfdb9c8e..406a39f55 100644 --- a/package.json +++ b/package.json @@ -7,18 +7,19 @@ ], "private": true, "scripts": { - "dev": "turbo dev", + "dev": "bun scripts/dev.ts", + "dev:setup": "bun scripts/setup-dev.ts", "dev:client": "bun --filter './client' dev", "dev:server": "bun wrangler dev --port 11498", "dev:cron": "bun wrangler dev --port 11498 --test-scheduled", - "check": "turbo check", - "cf-deploy": "bun scripts/migrator.ts", - "b": "turbo build", - "t": "turbo t", - "g": "turbo run g", - "m": "bun scripts/dev-migrator.ts", - "i18n": "bun --filter './client' i18n", - "clean": "rm -rf dist node_modules client/node_modules server/node_modules", + "db:migrate": "bun scripts/db-migrate-local.ts", + "db:generate": "bun --filter './server' db:gen", + "db:fix": "bun scripts/db-fix-top-field.ts", + "typecheck": "turbo check", + "deploy": "bun scripts/deploy-cf.ts", + "build": "turbo build", + "i18n:extract": "bun --filter './client' i18n", + "clean": "rm -rf dist node_modules client/node_modules server/node_modules .turbo wrangler.toml client/.env .dev.vars", "format:check": "turbo format:check", "format:write": "turbo format:write" }, diff --git a/scripts/fix-top-field.ts b/scripts/db-fix-top-field.ts similarity index 100% rename from scripts/fix-top-field.ts rename to scripts/db-fix-top-field.ts diff --git a/scripts/dev-migrator.ts b/scripts/db-migrate-local.ts similarity index 97% rename from scripts/dev-migrator.ts rename to scripts/db-migrate-local.ts index aadaa563b..bba2e9147 100644 --- a/scripts/dev-migrator.ts +++ b/scripts/db-migrate-local.ts @@ -1,7 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { execSync } from 'child_process'; -import { fixTopField, getMigrationVersion, isInfoExist, updateMigrationVersion } from './fix-top-field'; +import { fixTopField, getMigrationVersion, isInfoExist, updateMigrationVersion } from './db-fix-top-field'; const DB_NAME = "rin"; const SQL_DIR = path.join(__dirname, '..', 'server', 'sql'); diff --git a/scripts/migrator.ts b/scripts/deploy-cf.ts similarity index 99% rename from scripts/migrator.ts rename to scripts/deploy-cf.ts index 153d0c487..e71891d18 100644 --- a/scripts/migrator.ts +++ b/scripts/deploy-cf.ts @@ -1,7 +1,7 @@ import { $ } from "bun" import { readdir } from "node:fs/promises" import stripIndent from 'strip-indent' -import { fixTopField, getMigrationVersion, isInfoExist, updateMigrationVersion } from "./fix-top-field" +import { fixTopField, getMigrationVersion, isInfoExist, updateMigrationVersion } from "./db-fix-top-field" function env(name: string, defaultValue?: string, required = false) { const env = process.env diff --git a/scripts/dev.ts b/scripts/dev.ts new file mode 100644 index 000000000..31df7bc54 --- /dev/null +++ b/scripts/dev.ts @@ -0,0 +1,242 @@ +#!/usr/bin/env bun +/** + * 统一开发服务器 + * 同时启动前端和后端,并处理数据库迁移 + */ + +import { spawn } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as net from 'net'; + +const ROOT_DIR = process.cwd(); +const FRONTEND_PORT = 5173; +const BACKEND_PORT = 11498; + +// 颜色输出 +const colors = { + reset: '\x1b[0m', + bright: '\x1b[1m', + dim: '\x1b[2m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + magenta: '\x1b[35m', + cyan: '\x1b[36m', +}; + +function log(label: string, message: string, color: string = colors.reset) { + const timestamp = new Date().toLocaleTimeString('zh-CN', { hour12: false }); + console.log(`${colors.dim}[${timestamp}]${colors.reset} ${color}[${label}]${colors.reset} ${message}`); +} + +// 检查端口是否被占用 +function checkPort(port: number): Promise { + return new Promise((resolve) => { + const server = net.createServer(); + server.once('error', (err: any) => { + if (err.code === 'EADDRINUSE') { + resolve(false); + } else { + resolve(true); + } + }); + server.once('listening', () => { + server.close(); + resolve(true); + }); + server.listen(port); + }); +} + +// 检查配置文件 +if (!fs.existsSync(path.join(ROOT_DIR, '.env.local'))) { + log('Setup', '首次运行,正在初始化配置...', colors.yellow); + + // 运行配置生成脚本 + const setupProcess = spawn('bun', ['scripts/setup-dev.ts'], { + stdio: 'inherit', + cwd: ROOT_DIR + }); + + setupProcess.on('exit', (code) => { + if (code !== 0) { + process.exit(code || 1); + } + startDev(); + }); +} else { + // 检查是否需要重新生成配置 + const envStat = fs.statSync(path.join(ROOT_DIR, '.env.local')); + const wranglerStat = fs.existsSync(path.join(ROOT_DIR, 'wrangler.toml')) + ? fs.statSync(path.join(ROOT_DIR, 'wrangler.toml')) + : { mtime: new Date(0) }; + + if (envStat.mtime > wranglerStat.mtime) { + log('Setup', '检测到配置更新,正在重新生成...', colors.yellow); + const setupProcess = spawn('bun', ['scripts/setup-dev.ts'], { + stdio: 'inherit', + cwd: ROOT_DIR + }); + + setupProcess.on('exit', (code) => { + if (code !== 0) { + process.exit(code || 1); + } + startDev(); + }); + } else { + startDev(); + } +} + +async function startDev() { + log('Dev', '启动开发服务器...', colors.green); + + // 检查端口占用 + const frontendAvailable = await checkPort(FRONTEND_PORT); + const backendAvailable = await checkPort(BACKEND_PORT); + + if (!frontendAvailable) { + log('Error', `端口 ${FRONTEND_PORT} 已被占用`, colors.red); + log('Help', '请检查是否有其他进程占用了该端口,或修改 .env.local 中的 FRONTEND_URL', colors.yellow); + process.exit(1); + } + + if (!backendAvailable) { + log('Error', `端口 ${BACKEND_PORT} 已被占用`, colors.red); + log('Help', '请检查是否有其他 wrangler dev 进程在运行', colors.yellow); + process.exit(1); + } + + // 先运行数据库迁移 + log('DB', '检查数据库迁移...', colors.cyan); + const migrateProcess = spawn('bun', ['scripts/db-migrate-local.ts'], { + stdio: 'inherit', + cwd: ROOT_DIR + }); + + migrateProcess.on('exit', (code) => { + if (code !== 0) { + log('DB', '数据库迁移失败', colors.red); + process.exit(code || 1); + } + + log('DB', '数据库迁移完成', colors.green); + startServers(); + }); +} + +function startServers() { + log('Dev', '正在启动前端和后端服务...', colors.green); + + let backendReady = false; + let frontendReady = false; + + // 启动后端 + const backend = spawn('bun', ['wrangler', 'dev', '--port', String(BACKEND_PORT)], { + cwd: ROOT_DIR, + env: { ...process.env } + }); + + // 启动前端 + const frontend = spawn('bun', ['--filter', './client', 'dev'], { + cwd: ROOT_DIR, + env: { ...process.env } + }); + + // 输出处理 + backend.stdout.on('data', (data) => { + const lines = data.toString().split('\n').filter((l: string) => l.trim()); + lines.forEach((line: string) => { + if (line.includes('Ready') || line.includes('http://localhost')) { + log('Backend', line, colors.blue); + if (!backendReady && line.includes('Ready')) { + backendReady = true; + checkAllReady(); + } + } else if (line.includes('Error') || line.includes('error')) { + log('Backend', line, colors.red); + } else { + log('Backend', line, colors.dim); + } + }); + }); + + backend.stderr.on('data', (data) => { + const lines = data.toString().split('\n').filter((l: string) => l.trim()); + lines.forEach((line: string) => log('Backend', line, colors.red)); + }); + + frontend.stdout.on('data', (data) => { + const lines = data.toString().split('\n').filter((l: string) => l.trim()); + lines.forEach((line: string) => { + if (line.includes('Local') || line.includes('http://localhost')) { + log('Frontend', line, colors.magenta); + if (!frontendReady && line.includes('Local:')) { + frontendReady = true; + checkAllReady(); + } + } else if (line.includes('Error') || line.includes('error')) { + log('Frontend', line, colors.red); + } else { + log('Frontend', line, colors.dim); + } + }); + }); + + frontend.stderr.on('data', (data) => { + const lines = data.toString().split('\n').filter((l: string) => l.trim()); + lines.forEach((line: string) => log('Frontend', line, colors.red)); + }); + + // 进程退出处理 + backend.on('exit', (code) => { + log('Backend', `进程退出,代码: ${code}`, colors.red); + frontend.kill(); + process.exit(code || 0); + }); + + frontend.on('exit', (code) => { + log('Frontend', `进程退出,代码: ${code}`, colors.red); + backend.kill(); + process.exit(code || 0); + }); + + // 优雅退出 + process.on('SIGINT', () => { + log('Dev', '正在关闭开发服务器...', colors.yellow); + backend.kill('SIGINT'); + frontend.kill('SIGINT'); + }); + + process.on('SIGTERM', () => { + backend.kill('SIGTERM'); + frontend.kill('SIGTERM'); + }); + + // 检查是否都准备好了 + function checkAllReady() { + if (backendReady && frontendReady) { + showReadyMessage(); + } + } + + // 显示访问信息 + function showReadyMessage() { + console.log('\n' + '='.repeat(60)); + console.log(`${colors.bright}🚀 开发服务器已启动!${colors.reset}`); + console.log('='.repeat(60)); + console.log(`${colors.cyan}📱 前端地址:${colors.reset} http://localhost:${FRONTEND_PORT}`); + console.log(`${colors.blue}🔌 后端地址:${colors.reset} http://localhost:${BACKEND_PORT}`); + console.log('='.repeat(60) + '\n'); + } + + // 超时显示(如果检测失败) + setTimeout(() => { + if (!backendReady || !frontendReady) { + showReadyMessage(); + } + }, 8000); +} diff --git a/scripts/commit-msg.sh b/scripts/git-commit-msg.sh similarity index 100% rename from scripts/commit-msg.sh rename to scripts/git-commit-msg.sh diff --git a/scripts/render.ts b/scripts/seo-render.ts similarity index 100% rename from scripts/render.ts rename to scripts/seo-render.ts diff --git a/scripts/setup-dev.ts b/scripts/setup-dev.ts new file mode 100644 index 000000000..3a393fbd2 --- /dev/null +++ b/scripts/setup-dev.ts @@ -0,0 +1,125 @@ +#!/usr/bin/env bun +/** + * 开发环境配置加载器 + * 从 .env.local 加载配置并生成 wrangler.toml 和 client/.env + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +const ROOT_DIR = process.cwd(); +const ENV_FILE = path.join(ROOT_DIR, '.env.local'); + +// 检查 .env.local 是否存在 +if (!fs.existsSync(ENV_FILE)) { + console.error('❌ 错误:找不到 .env.local 文件'); + console.log('\n请执行以下步骤:'); + console.log(' 1. cp .env.example .env.local'); + console.log(' 2. 编辑 .env.local 填入你的配置'); + console.log(' 3. 重新运行 dev 命令\n'); + process.exit(1); +} + +// 解析 .env.local +function parseEnv(content: string): Record { + const env: Record = {}; + const lines = content.split('\n'); + + for (const line of lines) { + const trimmed = line.trim(); + // 跳过注释和空行 + if (!trimmed || trimmed.startsWith('#')) continue; + + const equalIndex = trimmed.indexOf('='); + if (equalIndex > 0) { + const key = trimmed.substring(0, equalIndex).trim(); + const value = trimmed.substring(equalIndex + 1).trim(); + env[key] = value; + } + } + + return env; +} + +const envContent = fs.readFileSync(ENV_FILE, 'utf-8'); +const env = parseEnv(envContent); + +// 验证必要的环境变量 +const requiredVars = [ + 'API_URL', + 'NAME', + 'AVATAR', + 'S3_ENDPOINT', + 'S3_BUCKET', + 'RIN_GITHUB_CLIENT_ID', + 'RIN_GITHUB_CLIENT_SECRET', + 'JWT_SECRET', + 'S3_ACCESS_KEY_ID', + 'S3_SECRET_ACCESS_KEY' +]; + +const missingVars = requiredVars.filter(v => !env[v]); +if (missingVars.length > 0) { + console.error('❌ 错误:以下必要环境变量未设置:'); + missingVars.forEach(v => console.error(` - ${v}`)); + console.log('\n请编辑 .env.local 文件并添加这些配置\n'); + process.exit(1); +} + +// 生成 wrangler.toml +const wranglerContent = `#:schema node_modules/wrangler/config-schema.json +name = "${env.WORKER_NAME || 'rin-server'}" +main = "server/src/_worker.ts" +compatibility_date = "2024-05-29" +node_compat = true + +[triggers] +crons = ["*/20 * * * *"] + +[vars] +FRONTEND_URL = "${env.FRONTEND_URL || 'http://localhost:5173'}" +S3_FOLDER = "${env.S3_FOLDER || 'images/'}" +S3_CACHE_FOLDER = "${env.S3_CACHE_FOLDER || 'cache/'}" +S3_REGION = "${env.S3_REGION || 'auto'}" +S3_ENDPOINT = "${env.S3_ENDPOINT}" +S3_ACCESS_HOST = "${env.S3_ACCESS_HOST || env.S3_ENDPOINT}" +S3_BUCKET = "${env.S3_BUCKET}" +S3_FORCE_PATH_STYLE = "${env.S3_FORCE_PATH_STYLE || 'false'}" +WEBHOOK_URL = "${env.WEBHOOK_URL || ''}" +RSS_TITLE = "${env.RSS_TITLE || 'Rin Development'}" +RSS_DESCRIPTION = "${env.RSS_DESCRIPTION || 'Development Environment'}" + +[[d1_databases]] +binding = "DB" +database_name = "${env.DB_NAME || 'rin'}" +database_id = "local" +`; + +fs.writeFileSync(path.join(ROOT_DIR, 'wrangler.toml'), wranglerContent); +console.log('✅ 已生成 wrangler.toml'); + +// 生成 client/.env +const clientEnvContent = `API_URL=${env.API_URL} +NAME=${env.NAME} +DESCRIPTION=${env.DESCRIPTION || ''} +AVATAR=${env.AVATAR} +PAGE_SIZE=${env.PAGE_SIZE || '5'} +RSS_ENABLE=${env.RSS_ENABLE || 'false'} +`; + +fs.writeFileSync(path.join(ROOT_DIR, 'client', '.env'), clientEnvContent); +console.log('✅ 已生成 client/.env'); + +// 生成 .dev.vars(用于 wrangler dev 的敏感信息) +const devVarsContent = `RIN_GITHUB_CLIENT_ID=${env.RIN_GITHUB_CLIENT_ID} +RIN_GITHUB_CLIENT_SECRET=${env.RIN_GITHUB_CLIENT_SECRET} +JWT_SECRET=${env.JWT_SECRET} +S3_ACCESS_KEY_ID=${env.S3_ACCESS_KEY_ID} +S3_SECRET_ACCESS_KEY=${env.S3_SECRET_ACCESS_KEY} +`; + +fs.writeFileSync(path.join(ROOT_DIR, '.dev.vars'), devVarsContent); +console.log('✅ 已生成 .dev.vars'); + +console.log('\n🎉 配置加载完成!'); +console.log(' 现在可以运行:bun run dev\n'); diff --git a/server/sql/0005.sql b/server/sql/0005.sql new file mode 100644 index 000000000..de4434e79 --- /dev/null +++ b/server/sql/0005.sql @@ -0,0 +1,3 @@ +ALTER TABLE `feeds` ADD COLUMN `ai_summary` text DEFAULT '' NOT NULL; +-->statement-breakpoint +UPDATE `info` SET `value` = '5' WHERE `key` = 'migration_version'; diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index d187c106b..85bc2c1a7 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -9,6 +9,7 @@ export const feeds = sqliteTable("feeds", { alias: text("alias"), title: text("title"), summary: text("summary").default("").notNull(), + ai_summary: text("ai_summary").default("").notNull(), content: text("content").notNull(), listed: integer("listed").default(1).notNull(), draft: integer("draft").default(1).notNull(), diff --git a/server/src/server.ts b/server/src/server.ts index eba2fe895..7f694b588 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -1,6 +1,7 @@ import cors from '@elysiajs/cors'; import { serverTiming } from '@elysiajs/server-timing'; import { Elysia } from 'elysia'; +import { AIConfigService } from './services/ai-config'; import { CommentService } from './services/comments'; import { FaviconService } from "./services/favicon"; import { FeedService } from './services/feed'; @@ -39,6 +40,7 @@ export const app = () => new Elysia({ aot: false }) .use(SEOService()) .use(RSSService()) .use(ConfigService()) + .use(AIConfigService()) .use(MomentsService()) .get('/', () => `Hi`) .onError(({ path, params, code }) => { diff --git a/server/src/services/ai-config.ts b/server/src/services/ai-config.ts new file mode 100644 index 000000000..adfa41c45 --- /dev/null +++ b/server/src/services/ai-config.ts @@ -0,0 +1,49 @@ +import Elysia, { t } from "elysia"; +import { setup } from "../setup"; +import { getAIConfigForFrontend, setAIConfig } from "../utils/db-config"; + +/** + * AI Configuration Service + * Handles AI summary settings with database storage + * API key is never exposed to frontend + */ +export function AIConfigService() { + return new Elysia({ aot: false }) + .use(setup()) + .group('/ai-config', (group) => + group + // Get AI configuration (with masked API key) + .get('/', async ({ set, admin }) => { + if (!admin) { + set.status = 401; + return { error: 'Unauthorized' }; + } + return await getAIConfigForFrontend(); + }) + // Update AI configuration + .post('/', async ({ set, admin, body }) => { + if (!admin) { + set.status = 401; + return { error: 'Unauthorized' }; + } + + await setAIConfig({ + enabled: body.enabled, + provider: body.provider, + model: body.model, + api_key: body.api_key, + api_url: body.api_url, + }); + + return { success: true }; + }, { + body: t.Object({ + enabled: t.Optional(t.Boolean()), + provider: t.Optional(t.String()), + model: t.Optional(t.String()), + api_key: t.Optional(t.String()), + api_url: t.Optional(t.String()), + }) + }) + ); +} diff --git a/server/src/services/config.ts b/server/src/services/config.ts index 3e7854112..b67547960 100644 --- a/server/src/services/config.ts +++ b/server/src/services/config.ts @@ -1,6 +1,23 @@ import Elysia, { t } from "elysia"; import { setup } from "../setup"; import { ClientConfig, PublicCache, ServerConfig } from "../utils/cache"; +import { getAIConfigForFrontend } from "../utils/db-config"; + +// Sensitive fields that should not be exposed to frontend +const SENSITIVE_FIELDS = ['ai_summary.api_key']; + +function maskSensitiveFields(config: Map): Record { + const result: Record = {}; + for (const [key, value] of config) { + if (SENSITIVE_FIELDS.includes(key) && value) { + // Mask the value - show only that it's set + result[key] = '••••••••'; + } else { + result[key] = value; + } + } + return result; +} export function ConfigService() { return new Elysia({ aot: false }) @@ -18,7 +35,17 @@ export function ConfigService() { } const config = type === 'server' ? ServerConfig() : ClientConfig(); const all = await config.all(); - return Object.fromEntries(all); + // Mask sensitive fields for server config + if (type === 'server') { + return maskSensitiveFields(all); + } + // For client config, include AI summary enabled status + const clientConfig = Object.fromEntries(all); + const aiConfig = await getAIConfigForFrontend(); + return { + ...clientConfig, + 'ai_summary.enabled': aiConfig.enabled ?? false + }; }) .post('/:type', async ({ set, admin, body, params: { type } }) => { if (type !== 'server' && type !== 'client') { diff --git a/server/src/services/feed.ts b/server/src/services/feed.ts index 34af8c903..327806dc3 100644 --- a/server/src/services/feed.ts +++ b/server/src/services/feed.ts @@ -1,14 +1,15 @@ -import {and, asc, count, desc, eq, gt, like, lt, or} from "drizzle-orm"; -import Elysia, {t} from "elysia"; -import {XMLParser} from "fast-xml-parser"; +import { and, asc, count, desc, eq, gt, like, lt, or } from "drizzle-orm"; +import Elysia, { t } from "elysia"; +import { XMLParser } from "fast-xml-parser"; import html2md from 'html-to-md'; -import type {DB} from "../_worker"; -import {feeds, visits} from "../db/schema"; -import {setup} from "../setup"; -import {ClientConfig, PublicCache} from "../utils/cache"; -import {getDB} from "../utils/di"; -import {extractImage} from "../utils/image"; -import {bindTagToPost} from "./tag"; +import type { DB } from "../_worker"; +import { feeds, visits } from "../db/schema"; +import { setup } from "../setup"; +import { ClientConfig, PublicCache } from "../utils/cache"; +import { getDB } from "../utils/di"; +import { extractImage } from "../utils/image"; +import { generateAISummary } from "../utils/ai"; +import { bindTagToPost } from "./tag"; export function FeedService() { const db: DB = getDB(); @@ -125,10 +126,21 @@ export function FeedService() { return 'Content already exists'; } const date = createdAt ? new Date(createdAt) : new Date(); + + // Generate AI summary if enabled and not a draft + let ai_summary = ""; + if (!draft) { + const generatedSummary = await generateAISummary(content); + if (generatedSummary) { + ai_summary = generatedSummary; + } + } + const result = await db.insert(feeds).values({ title, content, summary, + ai_summary, uid, alias, listed: listed ? 1 : 0, @@ -233,7 +245,7 @@ export function FeedService() { const feed = await db.query.feeds.findFirst({ where: eq(feeds.id, id_num), - columns: {createdAt: true}, + columns: { createdAt: true }, }); if (!feed) { set.status = 404; @@ -257,12 +269,12 @@ export function FeedService() { // NOTE: feed.id is adjacent feed, id_num is current feed id const cacheKey = `${feed.id}_${feedDirection}_${id_num}`; const cacheData = { - id: feed.id, - title: feed.title, - summary: summary, - hashtags: hashtags_flatten, - createdAt: feed.createdAt, - updatedAt: feed.updatedAt, + id: feed.id, + title: feed.title, + summary: summary, + hashtags: hashtags_flatten, + createdAt: feed.createdAt, + updatedAt: feed.updatedAt, }; cache.set(cacheKey, cacheData); return cacheData; @@ -359,10 +371,31 @@ export function FeedService() { set.status = 403; return 'Permission denied'; } + + // Generate AI summary if content changed and not a draft + let ai_summary: string | undefined = undefined; + const contentChanged = content && content !== feed.content; + const isDraft = draft !== undefined ? draft : (feed.draft === 1); + if (contentChanged && !isDraft) { + const generatedSummary = await generateAISummary(content); + if (generatedSummary) { + ai_summary = generatedSummary; + } + } + // Also generate if publishing a draft for the first time + if (!isDraft && feed.draft === 1 && !feed.ai_summary) { + const contentToSummarize = content || feed.content; + const generatedSummary = await generateAISummary(contentToSummarize); + if (generatedSummary) { + ai_summary = generatedSummary; + } + } + await db.update(feeds).set({ title, content, summary, + ai_summary, alias, top, listed: listed ? 1 : 0, @@ -450,7 +483,7 @@ export function FeedService() { const cacheKey = `search_${keyword}`; const searchKeyword = `%${keyword}%`; const whereClause = or(like(feeds.title, searchKeyword), - like(feeds.content, searchKeyword), + like(feeds.content, searchKeyword), like(feeds.summary, searchKeyword), like(feeds.alias, searchKeyword)); const feed_list = (await cache.getOrSet(cacheKey, () => db.query.feeds.findMany({ diff --git a/server/src/utils/ai.ts b/server/src/utils/ai.ts new file mode 100644 index 000000000..1b2dd6ebd --- /dev/null +++ b/server/src/utils/ai.ts @@ -0,0 +1,98 @@ +import { getAIConfig } from "./db-config"; + +// AI Provider presets with their default API URLs +const AI_PROVIDER_URLS: Record = { + openai: "https://api.openai.com/v1", + claude: "https://api.anthropic.com/v1", + gemini: "https://generativelanguage.googleapis.com/v1beta/openai", + deepseek: "https://api.deepseek.com/v1", +}; + +/** + * Generate AI summary for article content + * Uses OpenAI-compatible API format + * Configuration is read from D1 database + */ +export async function generateAISummary(content: string): Promise { + // Get AI configuration from database + const config = await getAIConfig(); + + // Check if AI summary is enabled + if (!config.enabled) { + return null; + } + + const { provider, model, api_key, api_url } = config; + + if (!api_key) { + console.error("[AI Summary] API key not configured"); + return null; + } + + // Use preset URL if not custom configured + let finalApiUrl = api_url; + if (!finalApiUrl && AI_PROVIDER_URLS[provider]) { + finalApiUrl = AI_PROVIDER_URLS[provider]; + } + + if (!finalApiUrl) { + console.error("[AI Summary] API URL not configured"); + return null; + } + + // Truncate content if too long (to save tokens) + const maxContentLength = 8000; + const truncatedContent = content.length > maxContentLength + ? content.slice(0, maxContentLength) + "..." + : content; + + try { + const response = await fetch(`${finalApiUrl}/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${api_key}`, + }, + body: JSON.stringify({ + model: model, + messages: [ + { + role: "system", + content: "你是一个专业的文章总结助手。请用简洁的中文总结文章的主要内容,不超过200字。只输出总结内容,不要有任何前缀或解释。" + }, + { + role: "user", + content: truncatedContent + } + ], + max_tokens: 500, + temperature: 0.3, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error(`[AI Summary] API error: ${response.status} - ${errorText}`); + return null; + } + + const data = await response.json() as { + choices?: Array<{ + message?: { + content?: string; + }; + }>; + }; + + const summary = data.choices?.[0]?.message?.content?.trim(); + if (!summary) { + console.error("[AI Summary] Empty response from API"); + return null; + } + + return summary; + } catch (error) { + console.error("[AI Summary] Failed to generate summary:", error); + return null; + } +} diff --git a/server/src/utils/db-config.ts b/server/src/utils/db-config.ts new file mode 100644 index 000000000..33cf1611d --- /dev/null +++ b/server/src/utils/db-config.ts @@ -0,0 +1,108 @@ +import { eq } from "drizzle-orm"; +import { getDB } from "./di"; +import { info } from "../db/schema"; + +/** + * Database-backed configuration storage for sensitive data like API keys + * This stores configurations in the D1 database instead of S3 + */ + +// Prefix for AI summary related configurations +const AI_CONFIG_PREFIX = "ai_summary."; + +export interface AIConfig { + enabled: boolean; + provider: string; + model: string; + api_key: string; + api_url: string; +} + +const defaultAIConfig: AIConfig = { + enabled: false, + provider: "openai", + model: "gpt-4o-mini", + api_key: "", + api_url: "https://api.openai.com/v1/chat/completions", +}; + +/** + * Get a configuration value from the database + */ +export async function getDBConfig(key: string): Promise { + const db = getDB(); + const result = await db.select().from(info).where(eq(info.key, key)).get(); + return result?.value ?? null; +} + +/** + * Set a configuration value in the database (upsert) + */ +export async function setDBConfig(key: string, value: string): Promise { + const db = getDB(); + // Use SQLite's INSERT OR REPLACE to handle upsert + await db.insert(info) + .values({ key, value }) + .onConflictDoUpdate({ + target: info.key, + set: { value } + }); +} + +/** + * Get AI configuration from database + */ +export async function getAIConfig(): Promise { + const config: AIConfig = { ...defaultAIConfig }; + + const enabled = await getDBConfig(AI_CONFIG_PREFIX + "enabled"); + if (enabled !== null) config.enabled = enabled === "true"; + + const provider = await getDBConfig(AI_CONFIG_PREFIX + "provider"); + if (provider !== null) config.provider = provider; + + const model = await getDBConfig(AI_CONFIG_PREFIX + "model"); + if (model !== null) config.model = model; + + const apiKey = await getDBConfig(AI_CONFIG_PREFIX + "api_key"); + if (apiKey !== null) config.api_key = apiKey; + + const apiUrl = await getDBConfig(AI_CONFIG_PREFIX + "api_url"); + if (apiUrl !== null) config.api_url = apiUrl; + + return config; +} + +/** + * Set AI configuration in database + */ +export async function setAIConfig(updates: Partial): Promise { + if (updates.enabled !== undefined) { + await setDBConfig(AI_CONFIG_PREFIX + "enabled", String(updates.enabled)); + } + if (updates.provider !== undefined) { + await setDBConfig(AI_CONFIG_PREFIX + "provider", updates.provider); + } + if (updates.model !== undefined) { + await setDBConfig(AI_CONFIG_PREFIX + "model", updates.model); + } + if (updates.api_key !== undefined && updates.api_key.trim() !== "") { + // Only update API key if a new value is provided + await setDBConfig(AI_CONFIG_PREFIX + "api_key", updates.api_key); + } + if (updates.api_url !== undefined) { + await setDBConfig(AI_CONFIG_PREFIX + "api_url", updates.api_url); + } +} + +/** + * Get AI config for frontend (with masked API key) + */ +export async function getAIConfigForFrontend(): Promise { + const config = await getAIConfig(); + return { + ...config, + api_key: "", // Never expose the actual key + api_key_set: config.api_key.length > 0, + }; +}