├── public ├── favicon.png └── vite.svg ├── postcss.config.js ├── tsconfig.json ├── src ├── main.tsx ├── components │ ├── PromptEditor │ │ ├── types.ts │ │ ├── MessageIcon.tsx │ │ ├── TabButton.tsx │ │ ├── EditorSkeleton.tsx │ │ ├── APITab.tsx │ │ ├── DefinitionTab.tsx │ │ └── ChatTestingContent.tsx │ ├── ErrorBoundary.tsx │ ├── Toast.tsx │ ├── MyPromptItemSkeleton.tsx │ ├── EditorHeader.tsx │ └── ConfirmDialog.tsx ├── App.css ├── App.tsx ├── contexts │ └── ToastContext.tsx ├── index.css ├── assets │ └── react.svg ├── utils │ └── encryption.ts ├── services │ └── localStorage.ts └── pages │ ├── PromptEditor.tsx │ └── MyPrompts.tsx ├── vite.config.ts ├── .gitignore ├── eslint.config.js ├── tsconfig.node.json ├── index.html ├── tsconfig.app.json ├── .env.example ├── .env.README.md ├── package.json ├── README.md └── tailwind.config.js /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChinaSiro/open-prompt-manager-for-prompub/HEAD/public/favicon.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import "./index.css"; 4 | import App from "./App.tsx"; 5 | 6 | createRoot(document.getElementById("root")!).render( 7 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import path from "path"; 4 | 5 | // https://vite.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react()], 8 | resolve: { 9 | alias: { 10 | "@": path.resolve(__dirname, "./src"), 11 | }, 12 | }, 13 | server: { 14 | host: "0.0.0.0", 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Environment variables 16 | .env 17 | .env.local 18 | 19 | # Editor directories and files 20 | .vscode/* 21 | !.vscode/extensions.json 22 | .idea 23 | .DS_Store 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw? 29 | 30 | .claude 31 | CLAUDE.md 32 | -------------------------------------------------------------------------------- /src/components/PromptEditor/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * PromptEditor 共享类型定义 3 | */ 4 | 5 | export interface PromptFormData { 6 | title: string; 7 | description: string; 8 | content: string; 9 | apiUrl: string; 10 | apiKey: string; 11 | } 12 | 13 | export interface ChatMessage { 14 | role: "user" | "assistant" | "system"; 15 | content: string; 16 | } 17 | 18 | export interface Model { 19 | id: string; 20 | object: string; 21 | created?: number; 22 | owned_by?: string; 23 | } 24 | -------------------------------------------------------------------------------- /src/components/PromptEditor/MessageIcon.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * MessageIcon 组件 3 | */ 4 | 5 | export function MessageIcon() { 6 | return ( 7 | 14 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import globals from "globals"; 3 | import reactHooks from "eslint-plugin-react-hooks"; 4 | import reactRefresh from "eslint-plugin-react-refresh"; 5 | import tseslint from "typescript-eslint"; 6 | import { defineConfig, globalIgnores } from "eslint/config"; 7 | 8 | export default defineConfig([ 9 | globalIgnores(["dist"]), 10 | { 11 | files: ["**/*.{ts,tsx}"], 12 | extends: [ 13 | js.configs.recommended, 14 | tseslint.configs.recommended, 15 | reactHooks.configs["recommended-latest"], 16 | reactRefresh.configs.vite, 17 | ], 18 | languageOptions: { 19 | ecmaVersion: 2020, 20 | globals: globals.browser, 21 | }, 22 | }, 23 | ]); 24 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2023", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "types": ["node"], 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "verbatimModuleSyntax": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "erasableSyntaxOnly": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true 24 | }, 25 | "include": ["vite.config.ts"] 26 | } 27 | -------------------------------------------------------------------------------- /src/components/PromptEditor/TabButton.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * TabButton 组件 3 | */ 4 | 5 | interface TabButtonProps { 6 | icon: React.ReactNode; 7 | label: string; 8 | active: boolean; 9 | onClick: () => void; 10 | } 11 | 12 | export function TabButton({ icon, label, active, onClick }: TabButtonProps) { 13 | return ( 14 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | transition: filter 300ms; 13 | } 14 | .logo:hover { 15 | filter: drop-shadow(0 0 2em #646cffaa); 16 | } 17 | .logo.react:hover { 18 | filter: drop-shadow(0 0 2em #61dafbaa); 19 | } 20 | 21 | @keyframes logo-spin { 22 | from { 23 | transform: rotate(0deg); 24 | } 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | 30 | @media (prefers-reduced-motion: no-preference) { 31 | a:nth-of-type(2) .logo { 32 | animation: logo-spin infinite 20s linear; 33 | } 34 | } 35 | 36 | .card { 37 | padding: 2em; 38 | } 39 | 40 | .read-the-docs { 41 | color: #888; 42 | } 43 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter as Router, Routes, Route, Navigate } from "react-router-dom"; 2 | import { ToastProvider } from "./contexts/ToastContext"; 3 | import { ErrorBoundary } from "./components/ErrorBoundary"; 4 | import { MyPrompts } from "./pages/MyPrompts"; 5 | import { PromptEditor } from "./pages/PromptEditor"; 6 | import "./index.css"; 7 | 8 | function App() { 9 | return ( 10 | 11 | 12 | 13 | 14 | } /> 15 | } /> 16 | } /> 17 | } /> 18 | 19 | 20 | 21 | 22 | ); 23 | } 24 | 25 | export default App; 26 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | PromPub - 发现和管理 AI 提示词 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2022", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "types": ["vite/client"], 9 | "skipLibCheck": true, 10 | 11 | /* Bundler mode */ 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "moduleDetection": "force", 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | 19 | /* Linting */ 20 | "strict": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "erasableSyntaxOnly": true, 24 | "noFallthroughCasesInSwitch": true, 25 | "noUncheckedSideEffectImports": true, 26 | 27 | /* Path Aliases */ 28 | "baseUrl": ".", 29 | "paths": { 30 | "@/*": ["./src/*"] 31 | } 32 | }, 33 | "include": ["src"] 34 | } 35 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # 分类配置(JSON格式) 2 | # 你可以修改这些分类来自定义你的提示词管理器 3 | VITE_CATEGORIES='[ 4 | {"id": 1, "name": "写作", "slug": "writing", "description": "写作相关提示词"}, 5 | {"id": 2, "name": "编程", "slug": "coding", "description": "编程相关提示词"}, 6 | {"id": 3, "name": "创意", "slug": "creative", "description": "创意相关提示词"}, 7 | {"id": 4, "name": "商务", "slug": "business", "description": "商务相关提示词"}, 8 | {"id": 5, "name": "教育", "slug": "education", "description": "教育相关提示词"}, 9 | {"id": 6, "name": "其他", "slug": "other", "description": "其他提示词"} 10 | ]' 11 | 12 | # AI 模型配置(JSON格式) 13 | # 你可以修改这些模型来自定义你的提示词管理器 14 | VITE_AI_MODELS='[ 15 | {"id": 1, "slug": "gpt-4", "name": "GPT-4", "provider": "OpenAI"}, 16 | {"id": 2, "slug": "gpt-3.5-turbo", "name": "GPT-3.5 Turbo", "provider": "OpenAI"}, 17 | {"id": 3, "slug": "claude-3-opus", "name": "Claude 3 Opus", "provider": "Anthropic"}, 18 | {"id": 4, "slug": "claude-3-sonnet", "name": "Claude 3 Sonnet", "provider": "Anthropic"}, 19 | {"id": 5, "slug": "gemini-pro", "name": "Gemini Pro", "provider": "Google"} 20 | ]' 21 | -------------------------------------------------------------------------------- /.env.README.md: -------------------------------------------------------------------------------- 1 | # 环境变量配置说明 2 | 3 | ## 如何自定义分类和 AI 模型 4 | 5 | ### 1. 分类配置 (VITE_CATEGORIES) 6 | 7 | 在 `.env` 文件中修改 `VITE_CATEGORIES` 变量来自定义分类: 8 | 9 | ```bash 10 | VITE_CATEGORIES='[ 11 | {"id": 1, "name": "写作", "slug": "writing", "description": "写作相关提示词"}, 12 | {"id": 2, "name": "编程", "slug": "coding", "description": "编程相关提示词"} 13 | ]' 14 | ``` 15 | 16 | **字段说明:** 17 | - `id`: 唯一标识符(数字) 18 | - `name`: 分类名称(显示给用户) 19 | - `slug`: URL 友好的标识符(英文,用于筛选) 20 | - `description`: 分类描述 21 | 22 | ### 2. AI 模型配置 (VITE_AI_MODELS) 23 | 24 | 在 `.env` 文件中修改 `VITE_AI_MODELS` 变量来自定义 AI 模型: 25 | 26 | ```bash 27 | VITE_AI_MODELS='[ 28 | {"id": 1, "slug": "gpt-4", "name": "GPT-4", "provider": "OpenAI"}, 29 | {"id": 2, "slug": "claude-3-opus", "name": "Claude 3 Opus", "provider": "Anthropic"} 30 | ]' 31 | ``` 32 | 33 | **字段说明:** 34 | - `id`: 唯一标识符(数字) 35 | - `slug`: URL 友好的标识符(英文,用于标识模型) 36 | - `name`: 模型名称(显示给用户) 37 | - `provider`: 提供商名称(如 OpenAI、Anthropic、Google) 38 | 39 | ### 3. 使用步骤 40 | 41 | 1. 复制 `.env.example` 为 `.env` 42 | 2. 根据需要修改 `VITE_CATEGORIES` 和 `VITE_AI_MODELS` 43 | 3. 重启开发服务器 `npm run dev` 44 | 45 | ### 4. 注意事项 46 | 47 | - JSON 格式必须正确,否则会使用默认配置 48 | - 修改后需要重启开发服务器 49 | - `id` 必须唯一 50 | - `slug` 建议使用英文,用于 URL 和数据存储 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flowgpt-clone", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite --host 0.0.0.0", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "class-variance-authority": "^0.7.1", 14 | "clsx": "^2.1.1", 15 | "dompurify": "^3.3.0", 16 | "lucide-react": "^0.546.0", 17 | "react": "^19.1.1", 18 | "react-dom": "^19.1.1", 19 | "react-router-dom": "^7.9.4", 20 | "tailwind-merge": "^3.3.1" 21 | }, 22 | "devDependencies": { 23 | "@eslint/js": "^9.36.0", 24 | "@tailwindcss/postcss": "^4.1.14", 25 | "@types/dompurify": "^3.2.0", 26 | "@types/node": "^24.8.1", 27 | "@types/react": "^19.1.16", 28 | "@types/react-dom": "^19.1.9", 29 | "@vitejs/plugin-react": "^5.0.4", 30 | "autoprefixer": "^10.4.21", 31 | "eslint": "^9.36.0", 32 | "eslint-plugin-react-hooks": "^5.2.0", 33 | "eslint-plugin-react-refresh": "^0.4.22", 34 | "globals": "^16.4.0", 35 | "postcss": "^8.5.6", 36 | "prettier": "^3.6.2", 37 | "puppeteer": "^24.25.0", 38 | "tailwindcss": "3.4.17", 39 | "typescript": "~5.9.3", 40 | "typescript-eslint": "^8.45.0", 41 | "vite": "^7.1.7" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/contexts/ToastContext.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-refresh/only-export-components */ 2 | import React, { createContext, useContext, useState, useCallback } from "react"; 3 | import { Toast } from "../components/Toast"; 4 | 5 | interface ToastContextType { 6 | showToast: (message: string, type: "success" | "error") => void; 7 | } 8 | 9 | const ToastContext = createContext(undefined); 10 | 11 | export function ToastProvider({ children }: { children: React.ReactNode }) { 12 | const [toast, setToast] = useState<{ 13 | message: string; 14 | type: "success" | "error"; 15 | } | null>(null); 16 | 17 | const showToast = useCallback( 18 | (message: string, type: "success" | "error") => { 19 | setToast({ message, type }); 20 | }, 21 | [], 22 | ); 23 | 24 | const handleClose = useCallback(() => { 25 | setToast(null); 26 | }, []); 27 | 28 | return ( 29 | 30 | {children} 31 | {toast && ( 32 | 37 | )} 38 | 39 | ); 40 | } 41 | 42 | export function useToast() { 43 | const context = useContext(ToastContext); 44 | if (context === undefined) { 45 | throw new Error("useToast must be used within a ToastProvider"); 46 | } 47 | return context; 48 | } 49 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer utilities { 6 | .no-scrollbar::-webkit-scrollbar { 7 | display: none; 8 | } 9 | .no-scrollbar { 10 | -ms-overflow-style: none; 11 | scrollbar-width: none; 12 | } 13 | } 14 | 15 | @layer base { 16 | :root { 17 | --color-base-main-950: 10 10 10; 18 | --background: 0 0% 4%; 19 | --foreground: 0 0% 95%; 20 | --card: 0 0% 12%; 21 | --card-foreground: 0 0% 95%; 22 | --popover: 0 0% 12%; 23 | --popover-foreground: 0 0% 95%; 24 | --primary: 48 100% 50%; 25 | --primary-foreground: 0 0% 0%; 26 | --secondary: 0 0% 15%; 27 | --secondary-foreground: 0 0% 95%; 28 | --muted: 0 0% 15%; 29 | --muted-foreground: 0 0% 60%; 30 | --accent: 48 100% 50%; 31 | --accent-foreground: 0 0% 0%; 32 | --destructive: 0 84% 60%; 33 | --destructive-foreground: 0 0% 95%; 34 | --border: 0 0% 20%; 35 | --input: 0 0% 20%; 36 | --ring: 48 100% 50%; 37 | --radius: 0.5rem; 38 | } 39 | } 40 | 41 | @layer base { 42 | * { 43 | @apply border-border; 44 | } 45 | body { 46 | @apply bg-background text-foreground; 47 | font-feature-settings: 48 | "rlig" 1, 49 | "calt" 1; 50 | } 51 | } 52 | 53 | @layer utilities { 54 | .scrollbar-hide::-webkit-scrollbar { 55 | display: none; 56 | } 57 | .scrollbar-hide { 58 | -ms-overflow-style: none; 59 | scrollbar-width: none; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PromPub 开源提示词管理器 2 | 3 | > Prompt 的简写 + Pub 酒馆 4 | 5 | 有太多的优质提示词不知道安放在什么地方,所以诞生了这个提示词管理工具。 6 | 7 | ## 项目地址 8 | 9 | - **线上 Demo**: [https://prompub.com](https://prompub.com) 10 | - **开源仓库**: [https://github.com/ChinaSiro/open-prompt-manager-for-prompub](https://github.com/ChinaSiro/open-prompt-manager-for-prompub) 11 | 12 | ## 架构说明 13 | 14 | - **线上版本**: 后端使用 WP API(过于复杂,未开源) 15 | - **开源版本**: 使用 LocalStorage 本地存储(简单易用) 16 | 17 | ## 基本功能 18 | 19 | ### 私人仓库 - 管理你的海量提示词 20 | - 搜索功能 - 快速找到你需要的提示词 21 | - 分类筛选 - 写作/编程/创意/商务/教育等 22 | - 模型筛选 - 支持 GPT/Claude/Gemini 等主流模型 23 | - 状态管理 - 草稿/已发布状态切换 24 | 25 | ### 创作中心 - 实时调试提示词 26 | - 实时预览 - 编写提示词的同时立即测试效果 27 | - 对话调试 - 系统提示词实时更新到对话中 28 | - 第三方 API - 支持任意兼容 OpenAI 格式的中转 API 29 | - 安全保障 - **API KEY 不上传服务器,仅存储在浏览器本地** 30 | 31 | ## 技术栈 32 | 33 | - **框架**: React 19.1 + TypeScript 34 | - **构建工具**: Vite 7.1 35 | - **样式**: Tailwind CSS 4.1 36 | - **路由**: React Router 7.9 37 | - **存储**: LocalStorage (浏览器本地存储) 38 | 39 | 40 | ## 快速开始 41 | 42 | ### 安装依赖 43 | 44 | ```bash 45 | npm install 46 | ``` 47 | 48 | ### 配置分类和模型(可选) 49 | 50 | 复制 `.env.example` 为 `.env`,根据需要自定义分类和 AI 模型: 51 | 52 | ```bash 53 | # 查看配置说明 54 | cat .env.README.md 55 | ``` 56 | 57 | ### 启动开发服务器 58 | 59 | ```bash 60 | npm run dev 61 | ``` 62 | 63 | 服务器将运行在 `http://localhost:5173` 64 | 65 | ### 构建生产版本 66 | 67 | ```bash 68 | npm run build 69 | ``` 70 | 71 | ## 注意事项 72 | 73 | - **隐私安全**: 所有数据(提示词、API KEY)仅存储在本地浏览器,不会上传到任何服务器 74 | - **自定义配置**: 可通过 `.env` 文件自定义分类和 AI 模型列表 75 | 76 | ## 开发计划 v1.1 77 | 78 | - [ ] 数据导入/导出 79 | - [ ] JSON 格式导入/导出 80 | - [ ] 单个提示词导出 81 | - [ ] 批量导入/导出 82 | - [ ] 更多功能待定... 83 | 84 | ## 贡献与反馈 85 | 86 | 欢迎提交 Issue 或 Pull Request! 87 | 88 | ## 许可证 89 | 90 | MIT License - 本项目开源免费使用 91 | -------------------------------------------------------------------------------- /src/components/PromptEditor/EditorSkeleton.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * EditorSkeleton 组件 - 加载骨架屏 3 | */ 4 | 5 | export function EditorSkeleton() { 6 | return ( 7 |
8 |
9 | 10 | {/* Avatar Skeleton */} 11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | 22 | {/* Name Skeleton */} 23 |
24 |
25 |
26 |
27 |
28 | 29 | {/* Description Skeleton */} 30 |
31 |
32 |
33 |
34 |
35 | 36 | {/* Content Skeleton */} 37 |
38 |
39 |
40 |
41 |
42 | 43 | {/* Welcome Message Skeleton */} 44 |
45 |
46 |
47 |
48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | darkMode: ["class"], 4 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], 5 | theme: { 6 | extend: { 7 | screens: { 8 | xs: "480px", 9 | }, 10 | fontSize: { 11 | "2xs": "0.625rem", // 10px 12 | "1.5xs": "0.6875rem", // 11px 13 | "2sm": "0.8125rem", // 13px 14 | }, 15 | borderRadius: { 16 | lg: "var(--radius)", 17 | md: "calc(var(--radius) - 2px)", 18 | sm: "calc(var(--radius) - 4px)", 19 | }, 20 | colors: { 21 | fgMain: { 22 | 950: "rgb(var(--color-base-main-950) / )", 23 | }, 24 | background: "hsl(var(--background))", 25 | foreground: "hsl(var(--foreground))", 26 | card: { 27 | DEFAULT: "hsl(var(--card))", 28 | foreground: "hsl(var(--card-foreground))", 29 | }, 30 | popover: { 31 | DEFAULT: "hsl(var(--popover))", 32 | foreground: "hsl(var(--popover-foreground))", 33 | }, 34 | primary: { 35 | DEFAULT: "hsl(var(--primary))", 36 | foreground: "hsl(var(--primary-foreground))", 37 | }, 38 | secondary: { 39 | DEFAULT: "hsl(var(--secondary))", 40 | foreground: "hsl(var(--secondary-foreground))", 41 | }, 42 | muted: { 43 | DEFAULT: "hsl(var(--muted))", 44 | foreground: "hsl(var(--muted-foreground))", 45 | }, 46 | accent: { 47 | DEFAULT: "hsl(var(--accent))", 48 | foreground: "hsl(var(--accent-foreground))", 49 | }, 50 | destructive: { 51 | DEFAULT: "hsl(var(--destructive))", 52 | foreground: "hsl(var(--destructive-foreground))", 53 | }, 54 | border: "hsl(var(--border))", 55 | input: "hsl(var(--input))", 56 | ring: "hsl(var(--ring))", 57 | chart: { 58 | 1: "hsl(var(--chart-1))", 59 | 2: "hsl(var(--chart-2))", 60 | 3: "hsl(var(--chart-3))", 61 | 4: "hsl(var(--chart-4))", 62 | 5: "hsl(var(--chart-5))", 63 | }, 64 | }, 65 | }, 66 | }, 67 | plugins: [], 68 | }; 69 | -------------------------------------------------------------------------------- /src/components/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component, type ReactNode } from "react"; 2 | 3 | interface Props { 4 | children: ReactNode; 5 | } 6 | 7 | interface State { 8 | hasError: boolean; 9 | error: Error | null; 10 | errorInfo: React.ErrorInfo | null; 11 | } 12 | 13 | export class ErrorBoundary extends Component { 14 | constructor(props: Props) { 15 | super(props); 16 | this.state = { 17 | hasError: false, 18 | error: null, 19 | errorInfo: null, 20 | }; 21 | } 22 | 23 | static getDerivedStateFromError(error: Error): State { 24 | return { 25 | hasError: true, 26 | error, 27 | errorInfo: null, 28 | }; 29 | } 30 | 31 | componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { 32 | console.error("ErrorBoundary caught an error:", error, errorInfo); 33 | this.setState({ 34 | error, 35 | errorInfo, 36 | }); 37 | } 38 | 39 | render() { 40 | if (this.state.hasError) { 41 | return ( 42 |
43 |
44 |

出错了

45 |

页面渲染时发生错误:

46 |
47 |
48 |                 {this.state.error?.toString()}
49 |               
50 |
51 | {this.state.errorInfo && ( 52 |
53 | 54 | 详细信息 55 | 56 |
57 |
58 |                     {this.state.errorInfo.componentStack}
59 |                   
60 |
61 |
62 | )} 63 | 69 |
70 |
71 | ); 72 | } 73 | 74 | return this.props.children; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/components/Toast.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { X, CheckCircle, AlertCircle } from "lucide-react"; 3 | 4 | interface ToastProps { 5 | message: string; 6 | type: "success" | "error"; 7 | onClose: () => void; 8 | duration?: number; 9 | } 10 | 11 | export function Toast({ message, type, onClose, duration = 3000 }: ToastProps) { 12 | const [progress, setProgress] = useState(100); 13 | 14 | useEffect(() => { 15 | const timer = setTimeout(() => { 16 | onClose(); 17 | }, duration); 18 | 19 | // 进度条动画 20 | const interval = setInterval(() => { 21 | setProgress((prev) => { 22 | const newProgress = prev - 100 / (duration / 50); 23 | return newProgress > 0 ? newProgress : 0; 24 | }); 25 | }, 50); 26 | 27 | return () => { 28 | clearTimeout(timer); 29 | clearInterval(interval); 30 | }; 31 | }, [duration, onClose]); 32 | 33 | return ( 34 |
35 |
42 | {/* 进度条 */} 43 |
44 |
50 |
51 | 52 |
53 | {type === "success" ? ( 54 | 59 | ) : ( 60 | 61 | )} 62 |
63 | 64 |

65 | {message} 66 |

67 | 68 | 74 |
75 |
76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /src/components/MyPromptItemSkeleton.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * 我的提示词列表项骨架屏组件 3 | */ 4 | export function MyPromptItemSkeleton() { 5 | return ( 6 |
7 |
8 | {/* Left: Thumbnail Skeleton */} 9 |
10 |
11 |
12 | 13 | {/* Middle: Content Skeleton */} 14 |
15 | {/* Title and Visibility */} 16 |
17 |
18 | {/* Title skeleton */} 19 |
20 | {/* Visibility badge skeleton */} 21 |
22 |
23 | {/* Tags skeleton */} 24 |
25 |
26 |
27 |
28 |
29 | 30 | {/* Actions skeleton */} 31 |
32 |
33 |
34 |
35 |
36 |
37 | 38 | {/* Right: Stats Skeleton */} 39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 | ); 58 | } 59 | 60 | /** 61 | * 我的提示词列表骨架屏 62 | * @param count 骨架屏数量,默认5个 63 | */ 64 | export function MyPromptsListSkeleton({ count = 5 }: { count?: number }) { 65 | return ( 66 |
67 | {Array.from({ length: count }).map((_, index) => ( 68 | 69 | ))} 70 |
71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /src/components/EditorHeader.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigate } from "react-router-dom"; 2 | import { Github } from "lucide-react"; 3 | 4 | export function EditorHeader() { 5 | const navigate = useNavigate(); 6 | 7 | const handleNewPrompt = () => { 8 | navigate("/prompt-editor"); 9 | }; 10 | 11 | const handleGoToRepository = () => { 12 | navigate("/my-prompts"); 13 | }; 14 | 15 | const handleLogoClick = () => { 16 | navigate("/my-prompts"); 17 | }; 18 | 19 | return ( 20 |
21 |
22 |
26 |
30 | PromPub 31 |
32 |
33 |
34 | 35 | 创作中心 36 | 37 | 43 | 64 | 65 | {/* 右侧导航链接 */} 66 | 85 |
86 |
87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/utils/encryption.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 前端加密/解密工具 3 | * 使用 AES-GCM 加密算法 4 | */ 5 | 6 | // 固定的应用级盐值 7 | const APP_SALT = "flowgpt-encryption-salt-v1"; 8 | 9 | // 获取或生成设备专用密钥 10 | async function getDeviceKey(): Promise { 11 | const DEVICE_KEY_STORAGE = "flowgpt_device_key"; 12 | 13 | // 尝试从 localStorage 获取现有密钥 14 | let deviceKey = localStorage.getItem(DEVICE_KEY_STORAGE); 15 | 16 | if (!deviceKey) { 17 | // 如果不存在,生成一个新的随机密钥 18 | const randomBytes = window.crypto.getRandomValues(new Uint8Array(32)); 19 | deviceKey = Array.from(randomBytes) 20 | .map((b) => b.toString(16).padStart(2, "0")) 21 | .join(""); 22 | 23 | // 保存到 localStorage 24 | localStorage.setItem(DEVICE_KEY_STORAGE, deviceKey); 25 | } 26 | 27 | return deviceKey; 28 | } 29 | 30 | // 生成或获取加密密钥 31 | async function getEncryptionKey(): Promise { 32 | // 使用设备专用密钥 + 应用盐值 33 | const deviceKey = await getDeviceKey(); 34 | const keyMaterial = await window.crypto.subtle.importKey( 35 | "raw", 36 | new TextEncoder().encode(`${deviceKey}-${APP_SALT}`), 37 | { name: "PBKDF2" }, 38 | false, 39 | ["deriveBits", "deriveKey"], 40 | ); 41 | 42 | // 派生真正的加密密钥 43 | return window.crypto.subtle.deriveKey( 44 | { 45 | name: "PBKDF2", 46 | salt: new TextEncoder().encode(APP_SALT), 47 | iterations: 100000, 48 | hash: "SHA-256", 49 | }, 50 | keyMaterial, 51 | { name: "AES-GCM", length: 256 }, 52 | false, 53 | ["encrypt", "decrypt"], 54 | ); 55 | } 56 | 57 | /** 58 | * 加密字符串 59 | */ 60 | export async function encryptString(text: string): Promise { 61 | try { 62 | const key = await getEncryptionKey(); 63 | const iv = window.crypto.getRandomValues(new Uint8Array(12)); 64 | const encodedText = new TextEncoder().encode(text); 65 | 66 | const encryptedData = await window.crypto.subtle.encrypt( 67 | { 68 | name: "AES-GCM", 69 | iv: iv, 70 | }, 71 | key, 72 | encodedText, 73 | ); 74 | 75 | // 组合 IV 和加密数据 76 | const combined = new Uint8Array(iv.length + encryptedData.byteLength); 77 | combined.set(iv, 0); 78 | combined.set(new Uint8Array(encryptedData), iv.length); 79 | 80 | // 转换为 Base64 81 | return btoa(String.fromCharCode(...combined)); 82 | } catch (error) { 83 | console.error("加密失败:", error); 84 | throw new Error("加密失败"); 85 | } 86 | } 87 | 88 | /** 89 | * 解密字符串 90 | */ 91 | export async function decryptString(encryptedText: string): Promise { 92 | try { 93 | const key = await getEncryptionKey(); 94 | 95 | // 从 Base64 解码 96 | const combined = Uint8Array.from(atob(encryptedText), (c) => 97 | c.charCodeAt(0), 98 | ); 99 | 100 | // 分离 IV 和加密数据 101 | const iv = combined.slice(0, 12); 102 | const encryptedData = combined.slice(12); 103 | 104 | const decryptedData = await window.crypto.subtle.decrypt( 105 | { 106 | name: "AES-GCM", 107 | iv: iv, 108 | }, 109 | key, 110 | encryptedData, 111 | ); 112 | 113 | return new TextDecoder().decode(decryptedData); 114 | } catch (error) { 115 | console.error("解密失败:", error); 116 | throw new Error("解密失败"); 117 | } 118 | } 119 | 120 | /** 121 | * 保存加密的配置到 localStorage 122 | */ 123 | export async function saveEncryptedConfig( 124 | key: string, 125 | value: string, 126 | ): Promise { 127 | const encrypted = await encryptString(value); 128 | localStorage.setItem(key, encrypted); 129 | } 130 | 131 | /** 132 | * 从 localStorage 读取并解密配置 133 | */ 134 | export async function getEncryptedConfig(key: string): Promise { 135 | const encrypted = localStorage.getItem(key); 136 | if (!encrypted) return null; 137 | 138 | try { 139 | return await decryptString(encrypted); 140 | } catch (error) { 141 | console.error(`解密配置 ${key} 失败:`, error); 142 | return null; 143 | } 144 | } 145 | 146 | /** 147 | * 删除加密的配置 148 | */ 149 | export function removeEncryptedConfig(key: string): void { 150 | localStorage.removeItem(key); 151 | } 152 | -------------------------------------------------------------------------------- /src/components/ConfirmDialog.tsx: -------------------------------------------------------------------------------- 1 | import { X } from "lucide-react"; 2 | 3 | interface ConfirmDialogProps { 4 | isOpen: boolean; 5 | onClose: () => void; 6 | onConfirm: () => void; 7 | title?: string; 8 | message?: string; 9 | confirmText?: string; 10 | cancelText?: string; 11 | type?: "warning" | "delete" | "publish"; 12 | } 13 | 14 | export function ConfirmDialog({ 15 | isOpen, 16 | onClose, 17 | onConfirm, 18 | title = "Are you sure?", 19 | message = "You can update the prompt anytime, but careful, we have a auto-save feature that will overwrite your changes.", 20 | confirmText = "Edit", 21 | cancelText = "Exit", 22 | type = "warning", 23 | }: ConfirmDialogProps) { 24 | if (!isOpen) return null; 25 | 26 | const getIconConfig = () => { 27 | switch (type) { 28 | case "delete": 29 | return { 30 | bgColor: "#ef4444", 31 | icon: ( 32 | 33 | 34 | 35 | ), 36 | emoji: "🗑️", 37 | }; 38 | case "publish": 39 | return { 40 | bgColor: "#3fda8c", 41 | icon: ( 42 | 43 | 44 | 45 | ), 46 | emoji: "🚀", 47 | }; 48 | default: // warning 49 | return { 50 | bgColor: "#ffd700", 51 | icon: ( 52 | 53 | 57 | 58 | ), 59 | emoji: "😟", 60 | }; 61 | } 62 | }; 63 | 64 | const iconConfig = getIconConfig(); 65 | 66 | return ( 67 |
68 | {/* Backdrop */} 69 |
73 | 74 | {/* Dialog */} 75 |
76 | {/* Close Button */} 77 | 83 | 84 | {/* Content */} 85 |
86 | {/* Title */} 87 |

88 | {title} 89 |

90 | 91 | {/* Icon and Message */} 92 |
93 | {/* Icon with Card */} 94 |
95 | {/* Card */} 96 |
97 | {/* Lines on card */} 98 |
99 |
100 |
101 |
102 |
103 |
104 | 105 | {/* Icon Circle */} 106 |
110 |
111 | {iconConfig.icon} 112 |
113 |
114 | 115 | {/* Emoji */} 116 |
117 | {iconConfig.emoji} 118 |
119 |
120 | 121 | {/* Message */} 122 |

123 | {message} 124 |

125 |
126 | 127 | {/* Buttons */} 128 |
129 | 135 | 148 |
149 |
150 |
151 |
152 | ); 153 | } 154 | -------------------------------------------------------------------------------- /src/services/localStorage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * LocalStorage 服务 - 管理 prompts 和 categories 3 | */ 4 | 5 | // ============= 类型定义 ============= 6 | 7 | export interface Category { 8 | id: number; 9 | name: string; 10 | slug: string; 11 | description: string; 12 | } 13 | 14 | export interface AIModel { 15 | id: number; 16 | slug: string; 17 | name: string; 18 | provider: string; 19 | } 20 | 21 | export interface Prompt { 22 | id: number; 23 | title: string; 24 | description: string; 25 | content: string; 26 | image?: string; // 图片 URL 或 base64 27 | categoryId?: number; 28 | aiModelSlug?: string; 29 | status: "draft" | "published"; 30 | visibility: "public" | "private"; 31 | statistics: { 32 | views: number; 33 | uses: number; 34 | favorites: number; 35 | }; 36 | createdAt: string; 37 | updatedAt: string; 38 | } 39 | 40 | // ============= 存储键 ============= 41 | 42 | const STORAGE_KEYS = { 43 | PROMPTS: "prompts", 44 | CATEGORIES: "categories", 45 | AI_MODELS: "ai_models", 46 | NEXT_ID: "next_prompt_id", 47 | }; 48 | 49 | // ============= 默认数据(从环境变量读取) ============= 50 | 51 | // 从环境变量读取分类配置 52 | function loadCategoriesFromEnv(): Category[] { 53 | try { 54 | const envCategories = import.meta.env.VITE_CATEGORIES; 55 | if (envCategories) { 56 | return JSON.parse(envCategories); 57 | } 58 | } catch (error) { 59 | console.error("Failed to parse VITE_CATEGORIES:", error); 60 | } 61 | 62 | // 默认分类(如果环境变量未设置) 63 | return [ 64 | { id: 1, name: "写作", slug: "writing", description: "写作相关提示词" }, 65 | { id: 2, name: "编程", slug: "coding", description: "编程相关提示词" }, 66 | { id: 3, name: "创意", slug: "creative", description: "创意相关提示词" }, 67 | { id: 4, name: "商务", slug: "business", description: "商务相关提示词" }, 68 | { id: 5, name: "教育", slug: "education", description: "教育相关提示词" }, 69 | { id: 6, name: "其他", slug: "other", description: "其他提示词" }, 70 | ]; 71 | } 72 | 73 | // 从环境变量读取 AI 模型配置 74 | function loadAIModelsFromEnv(): AIModel[] { 75 | try { 76 | const envModels = import.meta.env.VITE_AI_MODELS; 77 | if (envModels) { 78 | return JSON.parse(envModels); 79 | } 80 | } catch (error) { 81 | console.error("Failed to parse VITE_AI_MODELS:", error); 82 | } 83 | 84 | // 默认模型(如果环境变量未设置) 85 | return [ 86 | { id: 1, slug: "gpt-4", name: "GPT-4", provider: "OpenAI" }, 87 | { id: 2, slug: "gpt-3.5-turbo", name: "GPT-3.5 Turbo", provider: "OpenAI" }, 88 | { id: 3, slug: "claude-3-opus", name: "Claude 3 Opus", provider: "Anthropic" }, 89 | { id: 4, slug: "claude-3-sonnet", name: "Claude 3 Sonnet", provider: "Anthropic" }, 90 | { id: 5, slug: "gemini-pro", name: "Gemini Pro", provider: "Google" }, 91 | ]; 92 | } 93 | 94 | const DEFAULT_CATEGORIES = loadCategoriesFromEnv(); 95 | const DEFAULT_AI_MODELS = loadAIModelsFromEnv(); 96 | 97 | // ============= 初始化 ============= 98 | 99 | function initializeStorage() { 100 | // 始终从环境变量更新分类和 AI 模型配置 101 | localStorage.setItem( 102 | STORAGE_KEYS.CATEGORIES, 103 | JSON.stringify(DEFAULT_CATEGORIES) 104 | ); 105 | 106 | localStorage.setItem( 107 | STORAGE_KEYS.AI_MODELS, 108 | JSON.stringify(DEFAULT_AI_MODELS) 109 | ); 110 | 111 | // 初始化提示词列表(只在第一次时创建) 112 | if (!localStorage.getItem(STORAGE_KEYS.PROMPTS)) { 113 | localStorage.setItem(STORAGE_KEYS.PROMPTS, JSON.stringify([])); 114 | } 115 | 116 | // 初始化 ID 计数器(只在第一次时创建) 117 | if (!localStorage.getItem(STORAGE_KEYS.NEXT_ID)) { 118 | localStorage.setItem(STORAGE_KEYS.NEXT_ID, "1"); 119 | } 120 | } 121 | 122 | // 自动初始化 123 | initializeStorage(); 124 | 125 | // ============= 辅助函数 ============= 126 | 127 | function getNextId(): number { 128 | const nextId = parseInt(localStorage.getItem(STORAGE_KEYS.NEXT_ID) || "1"); 129 | localStorage.setItem(STORAGE_KEYS.NEXT_ID, (nextId + 1).toString()); 130 | return nextId; 131 | } 132 | 133 | // ============= Prompts 操作 ============= 134 | 135 | export function getAllPrompts(): Prompt[] { 136 | try { 137 | const data = localStorage.getItem(STORAGE_KEYS.PROMPTS); 138 | return data ? JSON.parse(data) : []; 139 | } catch (error) { 140 | console.error("读取提示词失败:", error); 141 | return []; 142 | } 143 | } 144 | 145 | export function getPromptById(id: number): Prompt | null { 146 | const prompts = getAllPrompts(); 147 | return prompts.find((p) => p.id === id) || null; 148 | } 149 | 150 | export function savePrompt(prompt: Omit & { id?: number }): Prompt { 151 | const prompts = getAllPrompts(); 152 | const now = new Date().toISOString(); 153 | 154 | if (prompt.id) { 155 | // 更新现有提示词 156 | const index = prompts.findIndex((p) => p.id === prompt.id); 157 | if (index !== -1) { 158 | const updatedPrompt: Prompt = { 159 | ...prompts[index], 160 | ...prompt, 161 | id: prompt.id, 162 | updatedAt: now, 163 | }; 164 | prompts[index] = updatedPrompt; 165 | localStorage.setItem(STORAGE_KEYS.PROMPTS, JSON.stringify(prompts)); 166 | return updatedPrompt; 167 | } 168 | } 169 | 170 | // 创建新提示词 171 | const newPrompt: Prompt = { 172 | ...prompt, 173 | id: prompt.id || getNextId(), 174 | createdAt: now, 175 | updatedAt: now, 176 | statistics: prompt.statistics || { 177 | views: 0, 178 | uses: 0, 179 | favorites: 0, 180 | }, 181 | }; 182 | 183 | prompts.push(newPrompt); 184 | localStorage.setItem(STORAGE_KEYS.PROMPTS, JSON.stringify(prompts)); 185 | return newPrompt; 186 | } 187 | 188 | export function deletePrompt(id: number): boolean { 189 | const prompts = getAllPrompts(); 190 | const filteredPrompts = prompts.filter((p) => p.id !== id); 191 | 192 | if (filteredPrompts.length === prompts.length) { 193 | return false; // 未找到要删除的提示词 194 | } 195 | 196 | localStorage.setItem(STORAGE_KEYS.PROMPTS, JSON.stringify(filteredPrompts)); 197 | return true; 198 | } 199 | 200 | export function publishPrompt(id: number): Prompt | null { 201 | const prompts = getAllPrompts(); 202 | const index = prompts.findIndex((p) => p.id === id); 203 | 204 | if (index === -1) return null; 205 | 206 | prompts[index].status = "published"; 207 | prompts[index].visibility = "public"; 208 | prompts[index].updatedAt = new Date().toISOString(); 209 | 210 | localStorage.setItem(STORAGE_KEYS.PROMPTS, JSON.stringify(prompts)); 211 | return prompts[index]; 212 | } 213 | 214 | export function unpublishPrompt(id: number): Prompt | null { 215 | const prompts = getAllPrompts(); 216 | const index = prompts.findIndex((p) => p.id === id); 217 | 218 | if (index === -1) return null; 219 | 220 | prompts[index].status = "draft"; 221 | prompts[index].visibility = "private"; 222 | prompts[index].updatedAt = new Date().toISOString(); 223 | 224 | localStorage.setItem(STORAGE_KEYS.PROMPTS, JSON.stringify(prompts)); 225 | return prompts[index]; 226 | } 227 | 228 | // ============= 搜索和筛选 ============= 229 | 230 | export interface FilterOptions { 231 | status?: "all" | "draft" | "published"; 232 | search?: string; 233 | categorySlug?: string; 234 | aiModelSlug?: string; 235 | } 236 | 237 | export function filterPrompts(options: FilterOptions = {}): Prompt[] { 238 | let prompts = getAllPrompts(); 239 | 240 | // 状态筛选 241 | if (options.status && options.status !== "all") { 242 | prompts = prompts.filter((p) => p.status === options.status); 243 | } 244 | 245 | // 搜索 246 | if (options.search) { 247 | const searchLower = options.search.toLowerCase(); 248 | prompts = prompts.filter( 249 | (p) => 250 | p.title.toLowerCase().includes(searchLower) || 251 | p.description.toLowerCase().includes(searchLower) || 252 | p.content.toLowerCase().includes(searchLower) 253 | ); 254 | } 255 | 256 | // 分类筛选 257 | if (options.categorySlug) { 258 | const category = getCategoryBySlug(options.categorySlug); 259 | if (category) { 260 | prompts = prompts.filter((p) => p.categoryId === category.id); 261 | } 262 | } 263 | 264 | // AI 模型筛选 265 | if (options.aiModelSlug) { 266 | prompts = prompts.filter((p) => p.aiModelSlug === options.aiModelSlug); 267 | } 268 | 269 | // 按更新时间降序排序 270 | prompts.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); 271 | 272 | return prompts; 273 | } 274 | 275 | // ============= Categories 操作 ============= 276 | 277 | export function getAllCategories(): Category[] { 278 | try { 279 | const data = localStorage.getItem(STORAGE_KEYS.CATEGORIES); 280 | return data ? JSON.parse(data) : DEFAULT_CATEGORIES; 281 | } catch (error) { 282 | console.error("读取分类失败:", error); 283 | return DEFAULT_CATEGORIES; 284 | } 285 | } 286 | 287 | export function getCategoryById(id: number): Category | null { 288 | const categories = getAllCategories(); 289 | return categories.find((c) => c.id === id) || null; 290 | } 291 | 292 | export function getCategoryBySlug(slug: string): Category | null { 293 | const categories = getAllCategories(); 294 | return categories.find((c) => c.slug === slug) || null; 295 | } 296 | 297 | // ============= AI Models 操作 ============= 298 | 299 | export function getAllAIModels(): AIModel[] { 300 | try { 301 | const data = localStorage.getItem(STORAGE_KEYS.AI_MODELS); 302 | return data ? JSON.parse(data) : DEFAULT_AI_MODELS; 303 | } catch (error) { 304 | console.error("读取 AI 模型失败:", error); 305 | return DEFAULT_AI_MODELS; 306 | } 307 | } 308 | 309 | export function getAIModelBySlug(slug: string): AIModel | null { 310 | const models = getAllAIModels(); 311 | return models.find((m) => m.slug === slug) || null; 312 | } 313 | 314 | // ============= 统计 ============= 315 | 316 | export function getPromptCounts() { 317 | const prompts = getAllPrompts(); 318 | return { 319 | all: prompts.length, 320 | published: prompts.filter((p) => p.status === "published").length, 321 | draft: prompts.filter((p) => p.status === "draft").length, 322 | }; 323 | } 324 | -------------------------------------------------------------------------------- /src/components/PromptEditor/APITab.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * APITab 组件 - API 配置标签页 3 | */ 4 | 5 | import { useState, useEffect } from "react"; 6 | import { Loader2 } from "lucide-react"; 7 | import { useToast } from "../../contexts/ToastContext"; 8 | import { 9 | getEncryptedConfig, 10 | saveEncryptedConfig, 11 | removeEncryptedConfig, 12 | } from "../../utils/encryption"; 13 | import type { PromptFormData } from "./types"; 14 | 15 | interface APITabProps { 16 | formData: PromptFormData; 17 | setFormData: React.Dispatch>; 18 | onApiConfigDeleted?: () => void; 19 | } 20 | 21 | export function APITab({ 22 | formData, 23 | setFormData, 24 | onApiConfigDeleted, 25 | }: APITabProps) { 26 | const { showToast } = useToast(); 27 | const [isTesting, setIsTesting] = useState(false); 28 | const [isSaving, setIsSaving] = useState(false); 29 | const [isDeleting, setIsDeleting] = useState(false); 30 | const [testResult, setTestResult] = useState<{ 31 | success: boolean; 32 | message: string; 33 | } | null>(null); 34 | 35 | // 页面加载时从加密存储读取配置 36 | useEffect(() => { 37 | const loadApiConfig = async () => { 38 | const apiUrl = (await getEncryptedConfig("encrypted_api_url")) || ""; 39 | const apiKey = (await getEncryptedConfig("encrypted_api_key")) || ""; 40 | 41 | if (apiUrl || apiKey) { 42 | setFormData((prev) => ({ 43 | ...prev, 44 | apiUrl, 45 | apiKey, 46 | })); 47 | } 48 | }; 49 | 50 | loadApiConfig(); 51 | }, [setFormData]); 52 | 53 | const handleSaveApiSettings = async () => { 54 | if (!formData.apiUrl.trim() && !formData.apiKey.trim()) { 55 | showToast("请至少填写 API 地址或密钥", "error"); 56 | return; 57 | } 58 | 59 | setIsSaving(true); 60 | try { 61 | // 移除尾部斜杠 62 | const cleanApiUrl = formData.apiUrl.trim() 63 | ? formData.apiUrl.trim().replace(/\/+$/, "") 64 | : ""; 65 | const cleanApiKey = formData.apiKey.trim(); 66 | 67 | // 加密保存到本地 localStorage 68 | if (cleanApiUrl) { 69 | await saveEncryptedConfig("encrypted_api_url", cleanApiUrl); 70 | // 同步更新表单数据 71 | setFormData((prev) => ({ ...prev, apiUrl: cleanApiUrl })); 72 | } 73 | 74 | if (cleanApiKey) { 75 | await saveEncryptedConfig("encrypted_api_key", cleanApiKey); 76 | } 77 | 78 | // 触发自定义事件通知其他组件刷新 79 | window.dispatchEvent(new CustomEvent("api-config-updated")); 80 | 81 | showToast("API 设置已加密保存到本地", "success"); 82 | } catch (error) { 83 | console.error("保存 API 设置错误:", error); 84 | showToast("保存失败", "error"); 85 | } finally { 86 | setIsSaving(false); 87 | } 88 | }; 89 | 90 | const handleDeleteApiSettings = async () => { 91 | setIsDeleting(true); 92 | try { 93 | // 删除加密存储的配置 94 | removeEncryptedConfig("encrypted_api_url"); 95 | removeEncryptedConfig("encrypted_api_key"); 96 | 97 | // 清空表单 98 | setFormData({ ...formData, apiUrl: "", apiKey: "" }); 99 | 100 | // 通知父组件 101 | onApiConfigDeleted?.(); 102 | 103 | // 触发自定义事件通知其他组件刷新和清理状态 104 | window.dispatchEvent(new CustomEvent("api-config-deleted")); 105 | window.dispatchEvent(new CustomEvent("api-config-updated")); 106 | 107 | showToast("API 设置已删除", "success"); 108 | } catch (error) { 109 | console.error("删除 API 设置错误:", error); 110 | showToast("删除失败", "error"); 111 | } finally { 112 | setIsDeleting(false); 113 | } 114 | }; 115 | 116 | const handleTestAPI = async () => { 117 | if (!formData.apiUrl.trim()) { 118 | showToast("请先填写 API 地址", "error"); 119 | return; 120 | } 121 | 122 | if (!formData.apiKey.trim()) { 123 | showToast("请先填写 API 密钥", "error"); 124 | return; 125 | } 126 | 127 | setIsTesting(true); 128 | setTestResult(null); 129 | 130 | try { 131 | // 移除尾部斜杠并拼接模型列表路径 132 | const apiBaseUrl = formData.apiUrl.trim().replace(/\/+$/, ""); 133 | const modelsApiUrl = `${apiBaseUrl}/v1/models`; 134 | 135 | const response = await fetch(modelsApiUrl, { 136 | method: "GET", 137 | headers: { 138 | Authorization: `Bearer ${formData.apiKey}`, 139 | }, 140 | }); 141 | 142 | if (response.ok) { 143 | const data = await response.json(); 144 | if (data.data && Array.isArray(data.data)) { 145 | const modelCount = data.data.length; 146 | setTestResult({ 147 | success: true, 148 | message: `API 连接成功!检测到 ${modelCount} 个可用模型`, 149 | }); 150 | showToast("API 连接成功!", "success"); 151 | 152 | // 测试成功后自动保存配置 153 | const cleanApiUrl = formData.apiUrl.trim(); 154 | const cleanApiKey = formData.apiKey.trim(); 155 | 156 | if (cleanApiUrl) { 157 | await saveEncryptedConfig("encrypted_api_url", cleanApiUrl); 158 | setFormData((prev) => ({ ...prev, apiUrl: cleanApiUrl })); 159 | } 160 | 161 | if (cleanApiKey) { 162 | await saveEncryptedConfig("encrypted_api_key", cleanApiKey); 163 | } 164 | 165 | // 触发自定义事件通知对话测试刷新模型列表 166 | window.dispatchEvent(new CustomEvent("api-config-updated")); 167 | } else { 168 | setTestResult({ 169 | success: false, 170 | message: "API 响应格式不正确", 171 | }); 172 | showToast("API 响应格式不正确", "error"); 173 | } 174 | } else { 175 | const errorText = await response.text(); 176 | let errorMessage = `连接失败: ${response.status} ${response.statusText}`; 177 | 178 | // 尝试解析错误信息 179 | try { 180 | const errorData = JSON.parse(errorText); 181 | if (errorData.error?.message) { 182 | errorMessage = `${errorMessage} - ${errorData.error.message}`; 183 | } 184 | } catch { 185 | // 忽略 JSON 解析错误 186 | } 187 | 188 | setTestResult({ 189 | success: false, 190 | message: errorMessage, 191 | }); 192 | showToast(`连接失败: ${response.status}`, "error"); 193 | console.error("API 测试失败:", errorText); 194 | } 195 | } catch (error) { 196 | console.error("API 测试错误:", error); 197 | setTestResult({ 198 | success: false, 199 | message: `连接错误: ${error instanceof Error ? error.message : "未知错误"}`, 200 | }); 201 | showToast("API 连接失败", "error"); 202 | } finally { 203 | setIsTesting(false); 204 | } 205 | }; 206 | 207 | return ( 208 |
209 |

API 配置

210 | 211 | {/* 安全说明 */} 212 |
213 |
214 |
215 | 221 | 227 | 228 |
229 |
230 |

231 | 本地加密存储 232 |

233 |

234 | 您的 API 密钥使用{" "} 235 | 236 | AES-256-GCM 加密算法 237 | 238 | 存储在浏览器本地,不会上传到我们的服务器。所有 API 239 | 请求直接从您的浏览器发送到目标服务,保护您的隐私安全。 240 |

241 |
242 |
243 |
244 | 245 |
246 |

247 | 配置自定义 API 端点以连接外部 OpenAI 兼容服务。 248 |

249 | 250 | {/* API URL */} 251 |
252 | 255 | 260 | setFormData({ ...formData, apiUrl: e.target.value }) 261 | } 262 | className="w-full bg-[#2C2A2F] border border-white/10 rounded-lg px-4 py-3 text-white placeholder-gray-600 focus:outline-none focus:border-[#3fda8c] transition-colors" 263 | /> 264 |

265 | 只需输入基础域名,系统会自动拼接 /v1/chat/completions 266 |

267 |
268 | 269 | {/* API Key */} 270 |
271 | 272 | 277 | setFormData({ ...formData, apiKey: e.target.value }) 278 | } 279 | className="w-full bg-[#2C2A2F] border border-white/10 rounded-lg px-4 py-3 text-white placeholder-gray-600 focus:outline-none focus:border-[#3fda8c] transition-colors" 280 | /> 281 |

282 | 你的 API 密钥将被加密并安全存储 283 |

284 |
285 | 286 | {/* Test Result */} 287 | {testResult && ( 288 |
295 |

300 | {testResult.message} 301 |

302 |
303 | )} 304 | 305 | {/* Action Buttons */} 306 |
307 | 315 | 323 | {(formData.apiUrl || formData.apiKey) && ( 324 | 332 | )} 333 |
334 |
335 |
336 | ); 337 | } 338 | -------------------------------------------------------------------------------- /src/components/PromptEditor/DefinitionTab.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * DefinitionTab 组件 - 提示词定义标签页 3 | */ 4 | 5 | import { useState } from "react"; 6 | import { ChevronDown, Upload, Loader2, Send as SendIcon } from "lucide-react"; 7 | import type { PromptFormData } from "./types"; 8 | 9 | interface Metadata { 10 | categories: Array<{ 11 | id: number; 12 | name: string; 13 | slug: string; 14 | description: string; 15 | count: number; 16 | }>; 17 | ai_models: Array<{ 18 | id: number; 19 | slug: string; 20 | name: string; 21 | provider: string; 22 | description: string; 23 | supports_vision: boolean; 24 | context_length: number; 25 | }>; 26 | tags: string[]; 27 | visibility_options: Array<{ 28 | value: string; 29 | label: string; 30 | description: string; 31 | }>; 32 | } 33 | 34 | interface DefinitionTabProps { 35 | formData: PromptFormData; 36 | setFormData: React.Dispatch>; 37 | coverImage: { id?: number; url: string } | null; 38 | onImageSelect: () => void; 39 | isUploading: boolean; 40 | onSave?: () => void; 41 | onPublish?: () => void; 42 | isSaving: boolean; 43 | isPublishing: boolean; 44 | metadata: Metadata | null; 45 | selectedCategory: number | null; 46 | setSelectedCategory: (id: number | null) => void; 47 | selectedModel: { 48 | id: number; 49 | slug: string; 50 | name: string; 51 | provider: string; 52 | } | null; 53 | setSelectedModel: ( 54 | model: { id: number; slug: string; name: string; provider: string } | null, 55 | ) => void; 56 | promptStatus: "draft" | "publish" | "published" | "archived"; 57 | } 58 | 59 | export function DefinitionTab({ 60 | formData, 61 | setFormData, 62 | coverImage, 63 | onImageSelect, 64 | isUploading, 65 | onSave, 66 | onPublish, 67 | isSaving, 68 | isPublishing, 69 | metadata, 70 | selectedCategory, 71 | setSelectedCategory, 72 | selectedModel, 73 | setSelectedModel, 74 | promptStatus, 75 | }: DefinitionTabProps) { 76 | const [showCategoryDropdown, setShowCategoryDropdown] = useState(false); 77 | const [showModelDropdown, setShowModelDropdown] = useState(false); 78 | return ( 79 |
80 |

提示词

81 | 82 | {/* Name and Cover */} 83 |
84 |
85 | {/* Name */} 86 |
87 | 90 | 95 | setFormData({ ...formData, title: e.target.value }) 96 | } 97 | maxLength={100} 98 | className="w-full bg-[#2C2A2F] border border-white/10 rounded-lg px-4 py-3 text-white placeholder-gray-600 focus:outline-none focus:border-[#3fda8c] transition-colors" 99 | /> 100 |

101 | {formData.title.length} / 100 字符 102 |

103 |
104 | 105 | {/* Cover */} 106 |
107 | 108 |
109 |
110 | {coverImage ? ( 111 | Cover 116 | ) : ( 117 | 125 | 126 | 127 | 128 | )} 129 |
130 |
131 | 143 |

JPG、PNG、WEBP

144 |
145 |
146 |
147 |
148 |
149 | 150 | {/* Category and AI Model */} 151 | {metadata && ( 152 |
153 |
154 | {/* Category */} 155 |
156 | 157 |
158 | 175 | {showCategoryDropdown && ( 176 |
177 | {metadata.categories.map((category: { id: number; name: string; description: string }) => ( 178 | 199 | ))} 200 |
201 | )} 202 |
203 |
204 | 205 | {/* AI Model */} 206 |
207 | 210 |
211 | 234 | {showModelDropdown && ( 235 |
236 | {metadata.ai_models.map((model: { id: number; slug: string; name: string; provider: string; description: string }) => ( 237 | 259 | ))} 260 |
261 | )} 262 |
263 |
264 |
265 |
266 | )} 267 | 268 | {/* Bot Intro */} 269 |
270 | 271 |