From ccab0253314ab8479dfd5c993e4136ee4681a7aa Mon Sep 17 00:00:00 2001 From: Zhipeng Luo <2830942138@qq.com> Date: Mon, 20 Oct 2025 10:41:02 +0800 Subject: [PATCH 01/11] fix Bun version (#389) --- .github/workflows/deploy.yaml | 2 ++ docs/DEPLOY.md | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 22b9e5b04..0e39a4fff 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.2.13 - name: Deploy env: # Or as an environment variable diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 560cbec1b..4c6b50ab1 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) From 9f0b7ab058913f3d5f8cb775dcc77ce71df4e68b Mon Sep 17 00:00:00 2001 From: Zhipeng Luo <2830942138@qq.com> Date: Mon, 20 Oct 2025 17:27:03 +0800 Subject: [PATCH 02/11] =?UTF-8?q?fix:=20=E6=B5=8F=E8=A7=88=E5=99=A8=20?= =?UTF-8?q?=E6=97=A0=E6=B3=95=E6=98=BE=E7=A4=BA=20=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E8=BD=B4=E7=9A=84=E9=97=AE=E9=A2=98=20(#391)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/page/timeline.tsx | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) 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() From 86359671d260dbd0090c60d27ca975508585692b Mon Sep 17 00:00:00 2001 From: Mao Chenyu Date: Thu, 8 Jan 2026 23:39:29 +0800 Subject: [PATCH 03/11] Update DEPLOY.md (#408) --- docs/DEPLOY.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 4c6b50ab1..74dca76f1 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -260,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 消息,消息格式为: From 7ef2af3d1764e9f6cabc4136461f07ee980f040f Mon Sep 17 00:00:00 2001 From: Zhipeng Luo <2830942138@qq.com> Date: Thu, 8 Jan 2026 23:42:20 +0800 Subject: [PATCH 04/11] fix monaco editor IME problem (#407) --- client/src/components/markdown_editor.tsx | 73 +++++++++++++++++++---- 1 file changed, 63 insertions(+), 10 deletions(-) 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 }, }} />
From 77ae9dc35c2ef3d84981c8f22edcb20e44230e88 Mon Sep 17 00:00:00 2001 From: nicad <36125594+PBnicad@users.noreply.github.com> Date: Thu, 15 Jan 2026 20:05:20 +0800 Subject: [PATCH 05/11] feat: implement AI summary feature with extensive model support (#411) This commit adds comprehensive AI summary functionality for articles with support for multiple AI providers and models. **Frontend:** - Add AI configuration UI in settings with manual save button - AI enable/disable toggle auto-saves immediately - Change provider/model inputs from text to select dropdowns for better UX - Provider/model selection dynamically updates available options - Add storage event listener to sync AI enabled state across pages - Add disabled prop support to Button component **Backend:** - Add AI configuration table (ai_config) with provider, model, API key, and URL - Add /ai-config API endpoint for CRUD operations - Integrate AI summary generation in feed service - Include AI enabled status in client config response **AI Providers Supported (5 providers, 70+ models):** - OpenAI: GPT-5/4/3.5 series (22 models) - Claude: 4.5/3 series (10 models) - Gemini: 3/2.5/2.0/1.5 series (14 models) - DeepSeek: chat/reasoner (2 models) - Zhipu: GLM-4 series (20 models) **Performance:** - Optimize config loading by including AI status in client config - Reduce API requests per page load from 2 to 1 **i18n:** - Add translations for AI configuration UI (zh-CN, en, ja, zh-TW) **Database:** - Migration 0005.sql adds ai_config table Co-authored-by: Claude --- client/public/locales/en/translation.json | 37 +- client/public/locales/ja/translation.json | 37 +- client/public/locales/zh-CN/translation.json | 37 +- client/public/locales/zh-TW/translation.json | 37 +- client/src/components/button.tsx | 4 +- client/src/page/feed.tsx | 82 +++- client/src/page/settings.tsx | 397 ++++++++++++++++++- server/sql/0005.sql | 3 + server/src/db/schema.ts | 1 + server/src/server.ts | 2 + server/src/services/ai-config.ts | 49 +++ server/src/services/config.ts | 29 +- server/src/services/feed.ts | 69 +++- server/src/utils/ai.ts | 98 +++++ server/src/utils/db-config.ts | 108 +++++ 15 files changed, 936 insertions(+), 54 deletions(-) create mode 100644 server/sql/0005.sql create mode 100644 server/src/services/ai-config.ts create mode 100644 server/src/utils/ai.ts create mode 100644 server/src/utils/db-config.ts 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/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..e3dc23dbb 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,385 @@ 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-air', + 'glm-4-flash-250414', + 'glm-4.5-flash', + 'glm-4-flashx-250414', + // 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-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: preset?.url || apiUrl, + 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')} +

+
+
+ +
+
+
+ + {/* 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/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, + }; +} From a69e420ae4cd95a199a843ce46b181e5272099b5 Mon Sep 17 00:00:00 2001 From: Xeu Date: Thu, 15 Jan 2026 12:18:00 +0000 Subject: [PATCH 06/11] feat: support custom models --- .gitignore | 3 +++ client/src/page/settings.tsx | 18 ++++++++++-------- 2 files changed, 13 insertions(+), 8 deletions(-) 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/client/src/page/settings.tsx b/client/src/page/settings.tsx index e3dc23dbb..7f1a12bc1 100644 --- a/client/src/page/settings.tsx +++ b/client/src/page/settings.tsx @@ -590,10 +590,7 @@ const AI_MODEL_PRESETS: Record = { // High performance/cost-effective models 'glm-4.5-air', 'glm-4.5-airx', - 'glm-4-air', - 'glm-4-flash-250414', 'glm-4.5-flash', - 'glm-4-flashx-250414', // Long context model 'glm-4-long', // Vision models @@ -607,6 +604,8 @@ const AI_MODEL_PRESETS: Record = { 'glm-4-plus', 'glm-4-air', 'glm-4-flash', + 'glm-4-flash-250414', + 'glm-4-flashx-250414', 'glm-3-turbo' ] }; @@ -789,18 +788,21 @@ function AISummarySettings() {

- +
From 313b08197950932cb71c8c2addc7ef86420a976d Mon Sep 17 00:00:00 2001 From: Xeu Date: Thu, 15 Jan 2026 12:52:33 +0000 Subject: [PATCH 07/11] fix: custom apiUrl does not work --- client/src/page/settings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/page/settings.tsx b/client/src/page/settings.tsx index 7f1a12bc1..74d837323 100644 --- a/client/src/page/settings.tsx +++ b/client/src/page/settings.tsx @@ -712,7 +712,7 @@ function AISummarySettings() { await updateConfig({ provider: provider, model: model, - api_url: preset?.url || apiUrl, + api_url: apiUrl || preset?.url, api_key: apiKey.trim() || undefined }); setHasUnsavedChanges(false); From bc62c7c40279b7e259f99ab63a475023449a8617 Mon Sep 17 00:00:00 2001 From: Xeu Date: Sun, 1 Feb 2026 22:58:01 +0800 Subject: [PATCH 08/11] refactor: Simplifies the configuration of the local development environment (#417) --- .env.example | 60 +++++ .github/workflows/deploy.yaml | 2 +- .github/workflows/seo.yaml | 2 +- CONTRIBUTING.md | 12 +- CONTRIBUTING_zh_CN.md | 12 +- README.md | 26 +- bun.lockb | Bin 526412 -> 536652 bytes docs/DEVELOPMENT.md | 210 +++++++++++++++ package.json | 19 +- .../{fix-top-field.ts => db-fix-top-field.ts} | 0 .../{dev-migrator.ts => db-migrate-local.ts} | 2 +- scripts/{migrator.ts => deploy-cf.ts} | 2 +- scripts/dev.ts | 242 ++++++++++++++++++ scripts/{commit-msg.sh => git-commit-msg.sh} | 0 scripts/{render.ts => seo-render.ts} | 0 scripts/setup-dev.ts | 125 +++++++++ 16 files changed, 688 insertions(+), 26 deletions(-) create mode 100644 .env.example create mode 100644 docs/DEVELOPMENT.md rename scripts/{fix-top-field.ts => db-fix-top-field.ts} (100%) rename scripts/{dev-migrator.ts => db-migrate-local.ts} (97%) rename scripts/{migrator.ts => deploy-cf.ts} (99%) create mode 100644 scripts/dev.ts rename scripts/{commit-msg.sh => git-commit-msg.sh} (100%) rename scripts/{render.ts => seo-render.ts} (100%) create mode 100644 scripts/setup-dev.ts 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/deploy.yaml b/.github/workflows/deploy.yaml index 0e39a4fff..e00dc2cea 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -54,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/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 d8f8993081630df9ef20c8dbc321648c6b8aeaf2..2ab226119341cfae14656f190260db015ff829d9 100755 GIT binary patch delta 20189 zcmZ`h37C|{vGecFECaj9s*Cg}3L~H*uq^jt@XD&_%QeW69#LeJXP}MPpWtrbmM^h8W|CzUO;UQC@Xb_y2bf!+zf|e^pmkS65e8 zRagJR3$1-$*w?r9^q}&VLH&cFw`5MKTzp*OSF3wv^3yZPd~2po%(yN-5+0Iym8s2d zh0lRZBYrnbn2toXtGnjgCiFnu?g@=SEMJ_^l;58jY*-}o{Nfs7gQOQ5(u+qDvh(e( z`P9T7&@Vl4JaTE7IMVB{CN_q=*rZx<-WQb(BGV`6n$J!e6Sl}n5tTAjC^(yp4WhMP z_n7STpoVw=EJ6&xwdF4nNFkE!m_r}v#71oN|K9gKQ%S_m)6CxW{ zGpz(#JF`&mS>vI1ZdMbn6s?V#W$gBYS!lPS6?Hz_(Ttq!a8fS7=1>c*M|MsR$GS~Z zzH<(Xuxu{MG1pPxTt|Vf_uqZWb8j*Dh3HD-Cii}DuQNfr4t_*dCd(^WI8(EsGyHw{A9pjsRTdqLpE zkp=O^HDZ5N5ZQ%X>}bS!W=kSGUqXXc(x6U@GA#kNu%$#-1>pj=xq)aURET@I9K*n-;#7Y6Fr_dDu zH%BVqE1bobEA(o$udtg!tC_8&J=L(1!m4&Ka-}oCN?Hb7+kE^GOl&O-==c}D z*0T|4we!LRWi9orU03I;ex24)ZXM|e^|~$^L#?yUXq}1agV&pf`SxMg`bOiv(4IDs z6l7i8K*l6CIGjVe;sjXVjU@)L(fH7fq+-~|K&3Wnl@>RWWx!t07JCiKe~lwQV%iBq zc}?LOvdk_bw?1ghr#AJV1ZD6h5}n+H+@x8xZ;Du{O*EOKXmed_j6g3{_F5ZW^-h!5 z+F5Hz8K?_|SGGBtHs4HyUGq6`Rc)I~9M5LQ%-D6lLGcn;UPn6GeVuEDYzqyLz_8B^ zlqA2gCE}>lu)>zmQ|mR@8ntRW;9Iq}b6cC7EO%>71hDHJfV+Tn0d>94mfaA+I=ju@ z;6}C^9G=Rd)B>>|IE~x|qzfnmh)lJPMmHMvI@`$JSnc^Cd85NoS)leNC;#I&IV92r z)J-)WGqz3ZE4_`P%+Wd^z+X>{GPtj{Sxzs4>bH^VyP~UMH=6*nuNeZ&&9s8mCONwq z{m9Kt*4gHD-j!-~JVmQxHL^7_Hf0;I$1P5r8~tMdux;q@mpPT7f`p7 zu(ZKh-r>aazJ6rhFzH+f^Upi0&bAxxzI|k5UUIv`OSu5M&1vDckpL#fg-{wob>X&1 zN$hsFlMBe(i39i(y?1Yqq^=dr_z6J?4(0vA>^niu{D5%I5L`y;m-H+$Ix8wjTJRdt zK0v#mw$OdRfrhu?R%;{2R)d>WwK+OxJ4nWpD1G07&}&hY3aB52tD)omUD0ZwPr8d*DZh~hIsqa&tlV96kUR-? zU38G#2@KpIxsw#$1;;g(fq$a<++YX>(Ex#MZy4&^8cRPLAPa|XGKQ;EReQw)NL+jHf|5IX0 zcE|2_E06MNauFoZf7?qVoUZ2MVhH{}xLJHG_~c5f*S zzqgdNJPe}_!?6E!dWqI+b(;F0rL3h(L<#(181{3A5z=rEleq)b`twrG%A>&`b|0O! z@jwBVIuPSS=03_i!3XL3#(kvap851F(DHq>SUqv8vR$@KXGGkAAf4FdQYxnncG7=& z{X9r|kn$k*3$LFCNe@yX)cy`QxL>-m@L#%Iqz5SvV!K^R?m^Onln1e2dHp;{8mNnC zPeZvw{VF`T;6k$dqy492Q}%um9k!fK?d~VWUC1jNC_#s(UsD@UYA+E5@@zM`dR-Spd4xMg3>_M`Qmn%CU_up`wkI4rRZrM(0;lTK@2v{baG1>+$Q-JE z4~t-GDZ0X42wPFjPx`{}C++wcK%X=VHa!T&<_k|&Db5(^ zILZ(H;B1elw85mG0)yEWp@8ZdpaCp4$SUpsvx&K9j%6XI+`Q;_-D+z zKI2yaUG|qE6 z>}3+IDxcc{s(YE3cfl-Qnzlfs12{1hUe^Aj__Cc@`yS}<3YN28vA80XtO~C<-k$v> zRJ&G$fgpQ>d|4U@0^0)U_7@tPb`E=$`axAto6D;f4XluJNe>~+V;^sT|`sO{H1HN5U@4)%rv@Hc4WmN%T^p)$w= z7O5jc_HWLL$-g-~)ZeT_p9BtluU>`pUhrA=W^6BIjTC&^>$m+KGC#9V|4E#wjKmFOcAbG}RP|4zd|miUy-OBn z?v~#5tB~?t9MqIf4Jnq-Q~z?6|1TQb#M*y3YE%D;#AfeB&)j*I&a-;Y>CE1DI=t_g zjC2~*`!u)-(H|HxUFgbwK)Qz-!833sG7u0x0bVYGv2Y;UcYU(YhQQ zec?k&neYPsBl8UQm$lDJAsBpQZtFjaasl?SBfax1Kc=0u`(vaPol{_t)PX23;|J{4 zgfl_g0h$9HGt_~IC;NmZZVERBs-cMS3DI?a!eBbZPsmcp+w)Hy6-&rX^-MD}^-Mp- z!15_k*X4_MLSXrC8ow@|`wDazL6BG$LI~aP|ByV;4>Za>NP3WpAaK^H&+G{42{2v= zX)f|Jm#6X|_P=f$_aNy(%7fVddHp;{dXVxU_HVDB2T2c79>hMUey}rjRQueuoMU$nV<52wn+!oN?zu+BQr}g#r*)%#0ZihXJh$9(Q}jqdAFIc>6>Y zfZ9F!3YLh(ngHDa#IqCSK<(FHF;_@SgP7{WTOqW0>2@FYO7b8lwE*J;}pda`JOVU6|Gf;Q!N`y>?Q^0;iR7?g?;TGwx zfwwb+?20_v0Uurb3)U?_>lF>%f^csM30Qjw2i+X*ZU}(LW`?`v@?nc(ayM6tiL>dw>D# z?13vBr*4N~SZySR$kYaPo=nndB?hGkc68J~-?|re-O*s+?!2T&p)Ii#!H$V|h}Jc* zOR~q5GI2;qkWvIYHe!MlTqB(O3L#4o%0Zx86{)&tcVfsDSqGyNE79g6aAVa)60uDpFl?X)F(97Ko3L4#m zwqW46@iwZjHb3~I*Ua>6IW(c+okUI*z%Y6VWgE#$v6S1$Q)r{PRDcCdu{#xv1piP> z1>wO$u;cYa>C7ZD#|Lz13h;VWNNB{NF=-a+cxQXF6C8lMfHVN6aiVa72@tpidxs3Q zKjFPez}|rgb$pMEG?Z{f>cr3i>6pP!H2tykDCCLc*iH<>TOR0gQUKvoq)$R; zn4_=ulgvKFS1$zXaD#CzFVwI0|&qFH|FX-+rX`e#pPQ zAH)kU4d{=}>Heg_{?Ke{uJ~4&DDU30m`gF4xaJCXz!g=;0Pshaw>2QI{;MgFd8|2s;C!FOcTE7W?x@`~*>=G)U z5FHpBT*godmo|OLb+!#I!w?944Gk$HUKyMuTxgF_ln{j>I6!fTGc{I^{45>51t7BZ zNEjmFt?N2Z3I)A91RIKMa>o4er52GL>MSpA5$o0o7^3?pRe2S-^)gsl5r76%goY&(}vHp@zBcgAFI`54W6k zQ~(@yfD`P#M$tArsHXgN`l?2uhNJx!ha1P=Fd~2wmB@`Cdmd4`-$!b_MwJ!80Ri-B zcR=amC~GK;M3s4jY?Rj-D+VGv$|r=Euti%54)+Fh#%cA&gqV#E{WQ9N03)v|Z6jr) z%7RcTzr9hU#*~KiF@_hu?VIqrxOsv|eCGmqGdmu=E_v~^{qL-Q)zgB<2r zM;_`6QGJaBF9*@z=zRd}x{0j}P`R50Xj=Kf2$Stu8jKaHNA|H#L6Yf!dfh(#6LL3ZAH*lg<7K9uS&JS$7J)Vy@ zDx}m^j)xi9(~aJYh4Gm07RLwlawWbi(DS;KkRbAOPPWLdTe+ev0U{wOM@0Ii~fn^D-N??{L>0biNjIn8bwk>4zPIjU@ zn_zlZ(LvRM@J9BM08)twCcE#PU`~T{C}9^HyT+9T1EaThG4s9HJ6=ej#ERN6dI!Ke zaFjHu@@O`(EHy8KhGIwCM3YgK@IIWna|Q&eNoA?|q_RWG!_Xqr6LRiCc`j^nSuVUh zT9!wv@@O`tJn!;oc^F!sz3F8jUr+4&<@uIJ%kpSd9?f`p-hd`wseOe62!pfzrBURj z6f~i2CCF>AtbyURV0bl6a0?%gb}IqYIe6B|iWT5I?oNJmiT0cJa#&~3r3mRD@JFJe zaEZ<#ojz=8DU(u!bP)QqTn8c+r#d3COO2L#Dk#R}?TLZ|T6^6~4XHkmv|WlxbBMzX zri2dDvC9spNp3mF&ZHHvJ`xFE_$ zWX_H1ISO&PlDas*!mz;G5FxKXvi2*0Y=+Gc_J9+)((`WoO4BPY+M!G#+H5nOE0k^S zjv8b$yfOF;M=ojG;A8;TCCY+O69xDa1sO=V4B-a*{AeXJ;~aqW~{BOc0v|>})EVbS!0e{&qvHMc5=8cz5@V6qn9=c79HByLZJ%tD zX{7MYEl77s5{phvXo$O}Aimu2cfSv^? zmzZ5=h~cFny=5&3V(7Al4=Mnon8dbNU7?5AVi;AEHF7)87S^WkEv>9KZL3V=&avzjFsCkOF9CudhLMM16kvQK*%qxH*!DJO z7`-j){2`R4_6B7)mtr_z%n&tOw%;7Urvg5e9ouh?e>Uxy-N&KsW7qbl?{4G7T_44V ziNAalU)6I6;bQ$@=6dq_zZ+=~xsT(=_8eiF1_EAJiS^OCspI;O<5vY2Jp!*u=$9m> zLfdiheIk4w#aAa{vBiMXr!4)OzsC&S62R%3j6U$$V{pqfUB`DmiFb(yCw5H!EdISN z-F6!pJI8hW;$ZyvV4#+-9zH|i(*U1g@X>2`FeiiT6UB75@K71~K z&-jk?*YOjAs$-!bwl)xF9E$G{>QMaO-MhhTs^RlLFr&{N*qazr)xo+X&a3LKXQd_9 z5Pn#oCguklJI*~l0d7d+HJ<^X z81d4eL|@kFu(m^_bt^Dt@!vpeNB_Zzza-<>rSI58-=Fm!m53wQ0pR|MxMm@e;}X-? z>!*XzS$8`ZzPG_g<1}3QeM_0PyurCVwwIx^yxO2`G)zgtH;f_vIW93Qeu-8??=us> ztLXObEYjxowHValv4u{9i~<4>v3^7>w%ZUqIr8CCY3Nh?il;KXs<3Ez(6CenZqIw9?HP(+B12g){&`nwP(dz7>o=+yl*+8II z9u}{DG&lsn)J4;Kge zSJlCAPXG$m`VBoQOjIu=EZBVLS_fT!{+(0z9J;ag4;7+EJ&bC!38U@+0`Pkqrmec* z&!6sp?;F?^O?5Z4=(Yarjo9oDkJ`JoLRg0&JbOgjv3&+qh_}qJsMdy9FCYM?wc#rEjW6bGnKV-ms}kBQ~ z%x@UAcGj*sG!nPSSRjBB)rJo;w?0w#YI}8s$N_;ib3-?(zENxKGDCOi z*k1i}Exqz!=0HGZKD>TdEVk9iFnm*w#g{$19@UzHp&2 delta 18458 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 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'); From 34e0a386ea31fb6ab5844bfd96a4576f1d8806bb Mon Sep 17 00:00:00 2001 From: Xeu Date: Sun, 1 Feb 2026 15:01:29 +0000 Subject: [PATCH 09/11] chore: update bun.lockb --- bun.lockb | Bin 536652 -> 536652 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/bun.lockb b/bun.lockb index 2ab226119341cfae14656f190260db015ff829d9..3d32d605e7da67c3c582ca583297ea44323c12c6 100755 GIT binary patch delta 52708 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%0AvmDUL0t;OaE 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 From a439b3b2b1dab7c01d956fed0f084fc8ab6b668c Mon Sep 17 00:00:00 2001 From: Xeu Date: Sun, 1 Feb 2026 15:04:55 +0000 Subject: [PATCH 10/11] chore: bump bun to 1.3.6 --- .github/workflows/deploy.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index e00dc2cea..26754c83c 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -26,7 +26,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@v1 with: - bun-version: 1.2.13 + bun-version: 1.3.6 - name: Deploy env: # Or as an environment variable From a88024dc2b1b46fbbddb756ed002338dde0c3421 Mon Sep 17 00:00:00 2001 From: Xeu Date: Sun, 1 Feb 2026 15:12:25 +0000 Subject: [PATCH 11/11] ci: add pr check workflow --- .github/workflows/ci.yml | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/ci.yml 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