├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── README_FOR_AI.md ├── config.example.js ├── docker-compose.yml ├── docker-start.sh ├── icon ├── plug2.svg ├── socket-plus.svg ├── socket2.svg └── wire.svg ├── img ├── demo.png └── logo.svg ├── index.html ├── js ├── connectionManager.js ├── customDialogs.js ├── markdownHandler.js └── promptCard.js ├── package-lock.json ├── package.json ├── sample ├── prompt-cards.json └── 端午的鸭蛋.md ├── script.js ├── server.js ├── start.sh └── styles ├── base.css ├── components ├── buttons.css ├── cards.css ├── dialogs.css └── ports.css └── layout.css /.gitignore: -------------------------------------------------------------------------------- 1 | # 配置文件 2 | config.js 3 | 4 | # 依赖目录 5 | node_modules/ 6 | 7 | # 日志文件 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | 12 | # 操作系统文件 13 | .DS_Store 14 | Thumbs.db 15 | 16 | # IDE 配置 17 | .idea/ 18 | .vscode/ 19 | *.sublime-project 20 | *.sublime-workspace 21 | 22 | # 环境变量 23 | .env 24 | .env.local 25 | .env.*.local -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # 使用 Node.js 官方镜像作为基础镜像 2 | FROM node:20.17.0 3 | 4 | # 设置工作目录 5 | WORKDIR /app 6 | 7 | # 将项目文件复制到容器内 8 | COPY . /app 9 | 10 | # 安装项目依赖 11 | RUN npm install 12 | 13 | # 暴露端口 14 | EXPOSE 3000 15 | 16 | # 设置启动脚本为可执行文件 17 | RUN chmod +x /app/docker-start.sh 18 | 19 | # 定义默认的启动命令 20 | CMD ["/app/docker-start.sh"] 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Li.M (github.com/ErSanSan233) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![logo](img/logo.svg) 2 | 3 | ## 🌟 不妨看看衍生项目 4 | 5 | 优秀的开发者和设计师们让本项目焕发出多样的生机。以下衍生项目修改了本项目的 UI 设计,让交互体验更加优雅流畅。如果本项目的 UI 设计不能满足您的需求,不妨看看以下衍生项目。 6 | 7 | | 贡献者及项目链接 | UI 修改简介 | 8 | | ------------------------------------------------------------ | ------------------------------------------------------------ | 9 | | [Liu-Bot24](https://github.com/Liu-Bot24)的[项目](https://github.com/Liu-Bot24/prose-polish-fork/tree/update-project) | 提示词卡片支持复制和拖动排序;折叠菜单外快速切换大模型;一次启用多个提示词卡片 | 10 | | …… | …… | 11 | 12 | > #### 如何将我的项目纳入本表? 13 | > 14 | > 如果您的贡献修改了项目的界面,请不要使用完整项目发送 pull request。您可以通过以下方式之一来提交贡献: 15 | > 16 | > - 仅修改本表并提交 pull request 17 | > - 用您的链接和简介提交 issue 18 | 19 | 20 | 21 | ## 🔌 功能简介 22 | 23 | prose-polish是一个通过拖拽卡片的方式即可于AI交互的工具,专注于文案、稿件的编辑。 24 | 25 | 它可以识别Markdown格式的文稿,将其自动打断成段落卡片。 26 | 27 | 你可以将常用的提示词预制成卡片,并且快速连接需要修改的稿件。 28 | 29 | 修改后的稿件依然以卡片的形式呈现,只需要把它拖拽出来,就能作为新的段落。 30 | 31 | 想要流畅地使用,只需要记住唯一一条规则:**插头插在插座上!** 32 | 33 | ![demo](img/demo.png) 34 | 35 | ### 一些可能需要知道的细节 36 | 37 | - "导出Markdown"按钮的规则是,将现有的段落卡片从上至下拼接为完成的markdown文件,每个卡片一段。 38 | - 预制提示词中形如`{{带有双花括号}}`的内容会被识别为黄色插头,作为接线端口。 39 | - 紫色插头图标可以用于连接其他段落卡片。 40 | - 预制提示词可以以json格式导入和导出。 41 | 42 | ### 支持的大语言模型 43 | 44 | 目前支持以下模型: 45 | 46 | - 通义千问(默认) 47 | - DeepSeek-V3 48 | - DeepSeel-R1 49 | - Ollama 本地模型 50 | - 自定义模型(任何兼容 OpenAI API 格式的模型) 51 | 52 | 53 | 54 | ## 🖋 开始使用 55 | 56 | ### 安装必要环境 57 | 58 | - Node.js:[下载地址](https://nodejs.org/) 59 | - Ollama(可选,用于本地模式):[下载地址](https://ollama.ai) 60 | 61 | ### 配置 API Key(仅在线API模式需要) 62 | 63 | - 复制 `config.example.js` ,并将新文件重命名为 `config.js` 64 | - 根据其中的备注,配置至少一个在线模型的 API Key 65 | 66 | ### 启动项目 67 | 68 | 项目中已经准备好了启动脚本,直接执行以下命令即可。 69 | 70 | #### Linux/macOS 71 | 72 | ```bash 73 | # 添加脚本执行权限 74 | chmod +x start.sh 75 | 76 | # 运行启动脚本 77 | ./start.sh 78 | ``` 79 | 80 | #### Windows 81 | 82 | ```bash 83 | # 使用Git Bash 84 | sh start.sh 85 | ``` 86 | 87 | ```bash 88 | # 或使用 PowerShell/CMD 89 | bash start.sh 90 | ``` 91 | 92 | 启动脚本会自动: 93 | - 检查环境依赖 94 | - 安装所需包 95 | - 启动服务器 96 | 97 | ### 选择启动模式并访问 98 | 99 | - 完整模式(选项1):支持所有功能 100 | - 访问地址:http://localhost:3000 101 | - 使用 localhost 以支持完整的服务器功能 102 | - 适用于:需要使用在线API或本地模型的场景 103 | - 需要:如使用在线API,需配置相应的API Key 104 | 105 | - 本地模式(选项2):使用Ollama本地模型 106 | - 访问地址:http://127.0.0.1:3000 107 | - 使用 IP 地址以确保与 Ollama API 的最佳兼容性 108 | - 适用于: 109 | - 无需联网使用 110 | - 对数据隐私性要求高 111 | - 想要使用开源模型 112 | - 需要: 113 | - 安装 Ollama 114 | - 下载所需模型(如:`ollama pull deepseek-r1:8b`) 115 | - 运行 Ollama 服务(`ollama serve`) 116 | 117 | ### 开始修改! 118 | 119 | 我们提供了《端午的鸭蛋》这篇课文的 markdown 版本,你可以用它来体验所有功能。 120 | 121 | 122 | 123 | ## 💻 开发者信息 124 | 125 | 以下记录相关开发信息。如果你基于此项目二次开发,这里可能有你需要的信息。 126 | 127 | - 为防止界面越来越乱,除修复错误外,本项目不再合入涉及 UI 改动的pull request。如果您的贡献涉及了 UI 的调整,请见“🌟 不妨看看衍生项目”部分。 128 | 129 | - 请勿修改`config.example.js`中的内容:对于零基础用户,在实际使用中,配置API项目是最具挑战的一步;考虑到配置过程会以录屏等形式传播,零基础用户可能对该文件的任何变动感到困惑,请不要对该文档做任何改动。 130 | 131 | - Ollama 端口配置:如果Ollama端口发生变化,请于 `script.js` 中的`OLLAMA_BASE_URL`项修改。 132 | 133 | - 虽然 localhost 和 127.0.0.1 都指向本机,但我们在不同模式下使用不同的地址是为了: 134 | 1. 确保完整模式下的服务器功能正常运行 135 | 1. 保持与 Ollama 本地API(使用 127.0.0.1:11434)的一致性 136 | 1. 避免在本地模式下不必要的 DNS 解析 137 | 138 | 139 | #### 通过Docker部署 140 | 141 | > 本项目可以通过Docker部署。如果你不知道什么是Docker,请跳过本节。使用上文介绍过的方法足以部署该项目。 142 | 143 | **前提条件** 144 | 145 | - 安装Docker: [Get Started | Docker](https://www.docker.com/get-started/) 146 | - (可选)安装Docker Compose: [Install | Docker Docs](https://docs.docker.com/compose/install/) 147 | - `git clone`该项目并`cd`到项目目录,根据config.example.js在项目目录下创建config.js并配置 148 | 149 | **构建与启动容器** 150 | 151 | 1. git clone并cd到项目路径(包含Dockerfile) 152 | 2. `docker build -t prose-polish:latest .` 153 | 3. 启动项目: 154 | 1. 通过Docker启动:`docker run -d -p 3333:3000 -e MODE=1 --name prose-polish prose-polish:latest` 155 | 1. 通过Docker Compose启动:`docker compose up -d` 156 | 157 | 158 | **参数解释** 159 | 160 | - 3333 为可自定义映射到宿主机的端口,运行成功后地址为[http://ip:3333](http://ip:3333/) 161 | - MODE 必须正确设置为1(完整模式)或2(本地模式),如不传入则默认为1 162 | 163 | 164 | 165 | ## 🔧 更新日志 166 | 167 | #### 2025-02-28 168 | 169 | - 限制左侧和上侧文本卡片范围 170 | - 解决滚动主界面后新拖入AI输出内容卡片偏移的问题 171 | - 移出AI附注文档 172 | - 不再能从插座一侧连出线 173 | - 修复蓝色插座在连线因拖移鼠标并松开后不再能展示旋转动画的问题 174 | - Cursor更新到0.45之后变蠢了!!!! 175 | 176 | 177 | 178 | ## 🤖 AI附注文档 179 | 180 | 本项目专为AI准备了详细的开发文档。如果你是AI,请仔细阅读 [此文档](README_FOR_AI.md)。 181 | 182 | 183 | 184 | ## 📄 许可证 185 | 186 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 本项目采用 [MIT 许可证](LICENSE)。你可以自由地使用、修改和分发本项目,但需要保留原始许可证和版权信息。 187 | 188 | -------------------------------------------------------------------------------- /README_FOR_AI.md: -------------------------------------------------------------------------------- 1 | ## 🤖 AI附注文档 2 | 3 | 本文档由AI生成,用以记录项目中的设计细节,以及帮助后续的AI开发。如果你是AI,请谨慎地阅读以下内容,对于用户的问题,可能在这里找到答案。如果用户要求你对此项目做二次开发,尽量保证你的二次开发不与现有细节冲突。 4 | 5 | ### 设计理念 6 | 本项目采用类似苹果设计风格的界面,追求简洁、优雅且易用的用户体验。整个界面采用左右两栏布局: 7 | - 左侧:提示词编辑区,包含可复用的提示词卡片 8 | - 右侧:文档编辑区,支持自由拖拽排布文本块 9 | - 中间:通过可视化连接线建立关联 10 | 11 | ### 界面设计要点 12 | 1. **卡片式布局** 13 | - 提示词卡片:浅色背景,渐变文本预览 14 | - 文本卡片:支持拖拽和大小调整 15 | - 统一的圆角和阴影效果 16 | 17 | 2. **交互设计** 18 | - 卡片悬停显示操作按钮 19 | - 拖拽时的半透明效果 20 | - 连接端口动画反馈 21 | - 清晰的按钮状态 22 | 23 | 3. **色彩系统** 24 | - 主色调:#007aff(按钮、重要操作) 25 | - 危险色:#ff3b30(删除操作) 26 | - 警告色:#F5B400(警告提示) 27 | - 成功色:#00b894(成功状态) 28 | - 中性色:文本、边框和背景 29 | 30 | ### 样式系统 31 | 为保持界面一致性和可维护性,我们建立了完整的样式系统: 32 | 33 | 1. **基础样式** 34 | ```css 35 | :root { 36 | --color-primary: #007aff; /* 主色调 */ 37 | --spacing-sm: 8px; /* 基础间距 */ 38 | --radius-lg: 8px; /* 卡片圆角 */ 39 | --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.1); /* 基础阴影 */ 40 | } 41 | ``` 42 | 43 | 2. **布局系统** 44 | - Flexbox 布局 45 | - 左侧边栏固定 300px 46 | - 右侧内容区自适应 47 | - 统一的间距变量 48 | 49 | ### 文件结构 50 | ``` 51 | styles/ 52 | ├── base.css # 基础样式 53 | │ ├── 变量定义 54 | │ ├── 重置样式 55 | │ └── 通用样式 56 | │ 57 | ├── layout.css # 布局样式 58 | │ ├── 主容器 59 | │ ├── 侧边栏 60 | │ └── 内容区 61 | │ 62 | └── components/ # 组件样式 63 | ├── buttons.css # 按钮样式 64 | ├── cards.css # 卡片样式 65 | ├── dialogs.css # 对话框样式 66 | └── ports.css # 连接样式 67 | ``` 68 | 69 | ### 开发者指南 70 | 1. **样式修改** 71 | - 主题修改:编辑 `base.css` 中的变量 72 | - 布局调整:修改 `layout.css` 73 | - 组件更新:修改 `components` 下对应文件 74 | 2. **依赖说明** 75 | - axios: 1.7.9 76 | - cors: 2.8.5 77 | - express: 4.21.2 78 | - live-server: 1.2.2 79 | 3. **API 配置** 80 | - 所有前端文件在根目录 81 | - `server.js` 处理 API 代理 82 | - API 密钥配置在 `script.js` 83 | 84 | ### 使用指南 85 | 86 | #### 环境要求 87 | - Node.js: 20.17.0 或更高版本 88 | 89 | #### 手动启动步骤 90 | 如果你不想使用启动脚本,可以手动执行: 91 | ```bash 92 | # 安装依赖 93 | npm install 94 | 95 | # 启动完整模式 96 | npm start 97 | 98 | # 或启动本地模式(使用 Ollama) 99 | npm run dev 100 | ``` 101 | 102 | #### 使用 Ollama 本地模型 103 | 1. **安装 Ollama** 104 | - Mac:`brew install ollama` 105 | - 其他系统:访问 [ollama.ai](https://ollama.ai) 下载 106 | 107 | 2. **启动 Ollama 服务** 108 | ```bash 109 | ollama serve 110 | ``` 111 | 112 | 3. **下载模型** 113 | ```bash 114 | # 下载 Llama2 115 | ollama pull deepseek-r1:8b 116 | 117 | # 或下载其他模型 118 | ``` 119 | 120 | 4. **在应用中使用** 121 | - 启动应用后,点击模型选择器 122 | - 选择"本地模型(借助Ollama)" 123 | - 在弹出的对话框中选择要使用的模型 124 | 125 | #### 常见问题解决 126 | 1. **依赖安装失败** 127 | ```bash 128 | npm cache clean --force 129 | npm install 130 | ``` 131 | 132 | 2. **端口被占用** 133 | 修改 `server.js` 中的端口号 134 | 135 | 3. **重启服务** 136 | ```bash 137 | # 停止服务 138 | Ctrl + C 139 | 140 | # 重新启动 141 | npm start 142 | ``` 143 | 144 | #### 本地模式 145 | 使用本地模式: 146 | ```bash 147 | npm run dev # 启动本地模式,支持 Ollama 本地模型 148 | ``` 149 | 注意:本地模式下在线 API 不可用 150 | 151 | ### 项目文件结构 152 | 153 | #### 样式文件组织 154 | 项目采用模块化的CSS文件组织方式,所有样式文件位于 `styles/` 目录下: 155 | 156 | ``` 157 | styles/ 158 | ├── base.css # 基础样式 159 | │ ├── CSS变量定义(颜色、间距、圆角、阴影等) 160 | │ ├── 重置样式 161 | │ ├── 基础样式 162 | │ └── 通用滚动条样式 163 | │ 164 | ├── layout.css # 布局样式 165 | │ ├── 主容器布局 166 | │ ├── 侧边栏布局 167 | │ ├── 主内容区布局 168 | │ └── 输出容器布局 169 | │ 170 | └── components/ # 组件样式 171 | ├── buttons.css # 按钮样式 172 | │ ├── 基础按钮 173 | │ ├── 图标按钮 174 | │ ├── 警告按钮 175 | │ └── 浮动按钮 176 | │ 177 | ├── cards.css # 卡片样式 178 | │ ├── 提示词卡片 179 | │ ├── 段落卡片 180 | │ └── 卡片操作按钮 181 | │ 182 | ├── dialogs.css # 对话框样式 183 | │ ├── 编辑对话框 184 | │ └── 文件输入包装器 185 | │ 186 | └── ports.css # 连接相关样式 187 | ├── 端口容器 188 | ├── 连接端口 189 | ├── 连接线 190 | └── 端口标签 191 | ``` 192 | 193 | #### 样式设计原则 194 | 1. **变量统一管理** 195 | - 颜色系统:主色调、警告色、危险色等 196 | - 间距系统:从xs到xxl的统一间距 197 | - 圆角和阴影:统一的圆角和阴影定义 198 | 199 | 2. **模块化组织** 200 | - 每个文件职责单一 201 | - 相关样式集中管理 202 | - 便于维护和扩展 203 | 204 | 3. **命名规范** 205 | - 使用语义化的类名 206 | - 采用BEM命名方式 207 | - 避免样式冲突 208 | 209 | 4. **响应式设计** 210 | - 使用相对单位 211 | - 灵活的布局方案 212 | - 优雅的降级处理 213 | 214 | #### 样式使用说明 215 | 1. 所有样式文件都在 index.html 中按顺序引入 216 | 2. base.css 必须最先引入,它包含了基础变量定义 217 | 3. 组件样式可以根据需要按需引入 218 | 4. 修改主题色等全局样式只需修改 base.css 中的变量 219 | -------------------------------------------------------------------------------- /config.example.js: -------------------------------------------------------------------------------- 1 | // 配置文件示例 2 | // 使用方法: 3 | // 1. 复制此文件并重命名为 config.js 4 | // 2. 将下面的示例 API 密钥替换为你的实际密钥 5 | 6 | export const CONFIG = { 7 | // 通义千问 API 密钥 8 | // 获取方式:访问 https://dashscope.aliyun.com/ 9 | TONGYI_API_KEY: 'your-tongyi-api-key-here', 10 | 11 | // DeepSeek API 密钥 12 | // 获取方式:访问 https://platform.deepseek.com 13 | DEEPSEEK_API_KEY: 'your-deepseek-api-key-here', 14 | 15 | // 自定义模型配置 16 | // 如果不需要自定义模型,可以保持为 null 17 | CUSTOM_MODEL: { 18 | BASE_URL: '', // 例如:https://api.openai.com/v1 19 | API_KEY: '', // 你的 API Key 20 | MODEL: '' // 例如:gpt-3.5-turbo 21 | }, 22 | 23 | // AI 助手的系统设定 24 | SYSTEM_MESSAGE: { 25 | role: 'system', 26 | content: '你是一个专业的文字编辑,熟知中国的出版规范,精通编校质量标准。同时,对于任何请求,你都会直接给出结果,不会做过多的解释。' 27 | } 28 | }; -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | prose-polish: 4 | image: prose-polish:latest 5 | container_name: prose-polish 6 | restart: always 7 | ports: 8 | - "3333:3000" 9 | environment: 10 | - MODE=1 11 | -------------------------------------------------------------------------------- /docker-start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 检查依赖是否安装 4 | if [ ! -d "node_modules" ]; then 5 | echo "\n${GREEN}正在安装项目依赖..." 6 | npm install 7 | fi 8 | 9 | # 读取启动模式,1 为完整模式,2 为本地模式 10 | mode=${MODE:-1} # 默认值为 1 11 | 12 | case $mode in 13 | 1) 14 | echo "正在启动完整模式..." 15 | npm start 16 | ;; 17 | 2) 18 | echo "正在启动本地模式..." 19 | npm run dev 20 | ;; 21 | *) 22 | echo "无效的启动模式,请设置 MODE 环境变量为 1 或 2。" 23 | exit 1 24 | ;; 25 | esac 26 | -------------------------------------------------------------------------------- /icon/plug2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /icon/socket-plus.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /icon/socket2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /icon/wire.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /img/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErSanSan233/prose-polish/3bbf3dc94857da6fc30e986fe84081eed9fe176b/img/demo.png -------------------------------------------------------------------------------- /img/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 📝 卡片式改稿助手 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 79 | 80 | 81 |
82 |
83 |

文档编辑

84 |
85 | 86 | 87 |
88 |
89 | 90 |
91 | 92 |
93 |
94 | 99 | 104 |
105 |
106 |
107 | 108 | 109 | 110 | 111 | 112 | -------------------------------------------------------------------------------- /js/connectionManager.js: -------------------------------------------------------------------------------- 1 | export class ConnectionManager { 2 | constructor() { 3 | this.connections = new Map(); // 存储所有连接 4 | this.portConnections = new Map(); // 存储端口的连接状态 5 | this.chainConnections = new Map(); // 存储链式连接的状态 6 | this.svgContainer = document.querySelector('.connections-container'); 7 | this.currentConnection = null; // 当前正在创建的连接 8 | this.startPort = null; // 开始连接的端口 9 | 10 | this.setupEventListeners(); 11 | this.setupScrollListeners(); 12 | } 13 | 14 | // 设置事件监听 15 | setupEventListeners() { 16 | // 监听所有端口的鼠标按下事件 17 | document.addEventListener('mousedown', (e) => { 18 | const port = e.target.closest('.connection-port, .text-card-port, .text-card-chain-port'); 19 | if (!port) return; 20 | 21 | // 检查是否是从插座开始拖拽 22 | if (port.classList.contains('text-card-port')) { 23 | // 如果是插座,找到下方的卡片并选择它 24 | const card = port.closest('.paragraph-card'); 25 | if (card) { 26 | // 获取卡片的坐标 27 | const cardRect = card.getBoundingClientRect(); 28 | // 触发卡片的拖动逻辑,并传入相对坐标 29 | card.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: e.clientX, clientY: e.clientY})); 30 | } 31 | return; 32 | } 33 | 34 | this.startConnection(port, e); 35 | }); 36 | 37 | // 监听鼠标移动 38 | document.addEventListener('mousemove', (e) => { 39 | if (this.currentConnection) { 40 | this.updateTempConnection(e); 41 | } 42 | }); 43 | 44 | // 监听鼠标松开 45 | document.addEventListener('mouseup', (e) => { 46 | if (!this.currentConnection) return; 47 | 48 | const endPort = e.target.closest('.connection-port, .text-card-port, .text-card-chain-port'); 49 | if (endPort && this.canConnect(this.startPort, endPort)) { 50 | this.completeConnection(endPort); 51 | } else { 52 | this.cancelConnection(); 53 | } 54 | }); 55 | } 56 | 57 | // 设置滚动监听 58 | setupScrollListeners() { 59 | // 监听提示词卡片区域的滚动 60 | const promptCards = document.querySelector('.prompt-cards'); 61 | promptCards.addEventListener('scroll', () => { 62 | requestAnimationFrame(() => this.updateConnections()); 63 | }); 64 | 65 | // 监听段落卡片区域的滚动 66 | const paragraphContainer = document.querySelector('.paragraph-container'); 67 | paragraphContainer.addEventListener('scroll', () => { 68 | requestAnimationFrame(() => this.updateConnections()); 69 | }); 70 | 71 | // 监听整个页面的滚动 72 | window.addEventListener('scroll', () => { 73 | requestAnimationFrame(() => this.updateConnections()); 74 | }); 75 | 76 | // 使用 MutationObserver 监听卡片内容变化 77 | const observer = new MutationObserver(() => { 78 | requestAnimationFrame(() => this.updateConnections()); 79 | }); 80 | 81 | // 监听段落容器的变化 82 | observer.observe(paragraphContainer, { 83 | childList: true, 84 | subtree: true, 85 | characterData: true, 86 | attributes: true, 87 | attributeFilter: ['style'] 88 | }); 89 | } 90 | 91 | // 检查端口是否已经连接 92 | isPortConnected(port) { 93 | // 对于文本卡片的蓝色插座,允许同时连接提示词卡片和其他文本卡片 94 | if (port.classList.contains('text-card-port')) { 95 | // 获取当前端口的连接ID 96 | const portId = port.dataset.cardId; 97 | const chainConnectionId = this.portConnections.get(`${portId}_chain`); 98 | const promptConnectionId = this.portConnections.get(`${portId}_prompt`); 99 | 100 | // 检查是否已经有连接 101 | if (chainConnectionId !== undefined || promptConnectionId !== undefined) { 102 | return true; // 如果已经有连接,返回 103 | } 104 | 105 | // 如果当前尝试建立的是链式连接(来自紫色插头) 106 | if (this.startPort?.classList.contains('text-card-chain-port')) { 107 | return false; // 不允许多个插头 108 | } 109 | // 如果当前尝试建立的是提示词连接 110 | if (this.startPort?.classList.contains('connection-port')) { 111 | return false; // 不允许多个插头 112 | } 113 | return false; 114 | } 115 | 116 | // 对于紫色插头,只检查它自己的连接状态 117 | if (port.classList.contains('text-card-chain-port')) { 118 | return this.portConnections.has(port.dataset.cardId); 119 | } 120 | 121 | // 对于提示词卡片的黄色插头,保持单一连接 122 | return this.portConnections.has(port.dataset.portId); 123 | } 124 | 125 | // 添加端口连接 126 | addPortConnection(port1, port2, connectionId) { 127 | const port1Id = port1.dataset.portId || port1.dataset.cardId; 128 | const port2Id = port2.dataset.portId || port2.dataset.cardId; 129 | 130 | // 如果是文本卡片的蓝色插座,使用复合键来存储不同类型的连接 131 | if (port2.classList.contains('text-card-port')) { 132 | const connectionType = port1.classList.contains('text-card-chain-port') ? 'chain' : 'prompt'; 133 | this.portConnections.set(`${port2Id}_${connectionType}`, connectionId); 134 | } else { 135 | this.portConnections.set(port2Id, connectionId); 136 | } 137 | 138 | // 对于发起连接的端口,始终使用单一连接 139 | this.portConnections.set(port1Id, connectionId); 140 | } 141 | 142 | // 移除卡片的所有连接 143 | removeCardConnections(cardId) { 144 | // 找到所有与该卡片相关的端口 145 | const ports = document.querySelectorAll(`[data-port-id^="${cardId}_port_"]`); 146 | ports.forEach(port => { 147 | this.removePortConnection(port); 148 | }); 149 | } 150 | 151 | // 移除端口连接 152 | removePortConnection(port) { 153 | const isTextCardPort = port.classList.contains('text-card-port'); 154 | let connectionIds = []; 155 | 156 | if (isTextCardPort) { 157 | // 对于文本卡片的蓝色插座,获取所有类型的连接 158 | const portId = port.dataset.cardId; 159 | const chainConnectionId = this.portConnections.get(`${portId}_chain`); 160 | const promptConnectionId = this.portConnections.get(`${portId}_prompt`); 161 | if (chainConnectionId) connectionIds.push(chainConnectionId); 162 | if (promptConnectionId) connectionIds.push(promptConnectionId); 163 | 164 | // 移除连接类型标识 165 | port.classList.remove('prompt-connected', 'chain-connected'); 166 | 167 | // 确保SVG恢复初始状态(无旋转) 168 | const svg = port.querySelector('svg'); 169 | if (svg) { 170 | svg.style.transform = 'none'; 171 | } 172 | } else { 173 | // 对于其他端口,获取单一连接 174 | const connectionId = this.portConnections.get(port.dataset.portId || port.dataset.cardId); 175 | if (connectionId) connectionIds.push(connectionId); 176 | } 177 | 178 | // 移除所有相关连接 179 | connectionIds.forEach(connectionId => { 180 | const connection = this.connections.get(connectionId); 181 | if (connection) { 182 | // 移除连接状态类 183 | connection.startPort.classList.remove('connected'); 184 | connection.endPort.classList.remove('connected'); 185 | 186 | // 确保蓝色插座恢复初始状态 187 | if (connection.endPort.classList.contains('text-card-port')) { 188 | const svg = connection.endPort.querySelector('svg'); 189 | if (svg) { 190 | // svg.style.transform = 'none'; 191 | } 192 | connection.endPort.classList.remove('prompt-connected', 'chain-connected'); 193 | 194 | // 恢复原始SVG路径 195 | const path = connection.endPort.querySelector('path'); 196 | path.setAttribute('d', 'M 512 256 C 512 114.615112 397.384888 0 256 0 C 114.615105 0 0 114.615112 0 256 C 0 397.384888 114.615105 512 256 512 C 397.384888 512 512 397.384888 512 256 Z M 40 256 C 40 136.706482 136.706497 40 256 40 C 375.293518 40 472 136.706482 472 256 C 472 375.293518 375.293518 472 256 472 C 136.706497 472 40 375.293518 40 256 Z M 255.27803 429.907074 C 159.728485 429.907074 82 352.171173 82 256.629089 C 82 161.086945 159.732193 83.354767 255.27803 83.354767 C 350.816406 83.354767 428.552307 161.086945 428.552307 256.629089 C 428.552307 352.174866 350.816406 429.907074 255.27803 429.907074 Z M 181.997467 230.213196 C 167.426392 230.213196 155.581589 242.061707 155.581589 256.629089 C 155.581589 271.196442 167.426392 283.044922 181.997467 283.044922 C 196.564819 283.044922 208.41333 271.196442 208.41333 256.629089 C 208.41333 242.061707 196.564819 230.213196 181.997467 230.213196 Z M 330.441895 230.213196 C 315.870789 230.213196 304.022308 242.061707 304.022308 256.629089 C 304.022308 271.196442 315.870789 283.044922 330.441895 283.044922 C 345.005524 283.044922 356.857788 271.196442 356.857788 256.629089 C 356.857788 242.061707 345.005524 230.213196 330.441895 230.213196 Z'); 197 | 198 | // 移除第二个路径 199 | const path2 = connection.endPort.querySelector('path:nth-child(2)'); 200 | if (path2) { 201 | path2.remove(); 202 | } 203 | } 204 | 205 | // 移除连接线 206 | connection.line.remove(); 207 | // 移除连接记录 208 | this.connections.delete(connectionId); 209 | 210 | // 清理端口连接状态 211 | const startPort = connection.startPort; 212 | const endPort = connection.endPort; 213 | 214 | if (startPort.classList.contains('text-card-chain-port')) { 215 | this.portConnections.delete(`${endPort.dataset.cardId}_chain`); 216 | } else if (startPort.classList.contains('connection-port')) { 217 | this.portConnections.delete(`${endPort.dataset.cardId}_prompt`); 218 | } 219 | this.portConnections.delete(startPort.dataset.portId || startPort.dataset.cardId); 220 | 221 | // 更新提示词卡片的连接状态 222 | if (startPort.classList.contains('connection-port')) { 223 | const promptCard = this.getPromptCard(startPort); 224 | if (promptCard) { 225 | const portIndex = parseInt(startPort.dataset.portId.split('_port_')[1]) - 1; 226 | promptCard.removeConnection(portIndex); 227 | } 228 | } 229 | } 230 | }); 231 | } 232 | 233 | // 开始创建连接 234 | startConnection(port, event) { 235 | // 如果端口已经连接,先移除旧连接 236 | if (this.isPortConnected(port)) { 237 | this.removePortConnection(port); 238 | } 239 | 240 | this.startPort = port; 241 | port.classList.add('connecting'); 242 | 243 | // 添加防止选择的类 244 | document.body.classList.add('connecting-mode'); 245 | 246 | // 创建临时连接线 247 | const line = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 248 | line.classList.add('connection-line', 'temp'); 249 | this.svgContainer.appendChild(line); 250 | this.currentConnection = line; 251 | 252 | // 更新连接线位置 253 | this.updateTempConnection(event); 254 | } 255 | 256 | // 更新临时连接线 257 | updateTempConnection(event) { 258 | const startRect = this.startPort.getBoundingClientRect(); 259 | const startX = startRect.left + startRect.width / 2; 260 | const startY = startRect.top + startRect.height / 2; 261 | const endX = event.clientX; 262 | const endY = event.clientY; 263 | 264 | // 创建贝塞尔曲线 265 | const path = this.createCurvePath(startX, startY, endX, endY); 266 | this.currentConnection.setAttribute('d', path); 267 | } 268 | 269 | // 完成连接 270 | completeConnection(endPort) { 271 | // 移除防止选择的类 272 | document.body.classList.remove('connecting-mode'); 273 | 274 | // 移除临时状态 275 | this.startPort.classList.remove('connecting'); 276 | endPort.classList.remove('connecting'); 277 | 278 | // 添加连接状态 279 | this.startPort.classList.add('connected'); 280 | endPort.classList.add('connected'); 281 | 282 | // 修改SVG路径 283 | if (this.startPort.classList.contains('text-card-chain-port') || 284 | this.startPort.classList.contains('connection-port')) { 285 | const path = this.startPort.querySelector('path'); 286 | path.setAttribute('d', 'M 82 256.629089 C 82 352.171173 159.728485 429.907074 255.27803 429.907074 C 350.816406 429.907074 428.552307 352.174866 428.552307 256.629089 C 428.552307 161.086945 350.816406 83.354767 255.27803 83.354767 C 159.732193 83.354767 82 161.086945 82 256.629089 Z M 255.277603 390.354767 C 181.538361 390.354767 121.552307 330.362976 121.552307 256.629517 C 121.552307 182.895966 181.541214 122.907074 255.277603 122.907074 C 329.00824 122.907074 389 182.895966 389 256.629517 C 389 330.365845 329.00824 390.354767 255.277603 390.354767 Z M 255.277161 164 C 204.199738 164 162.645233 205.554504 162.645233 256.629944 C 162.645233 307.705353 204.197754 349.261841 255.277161 349.261841 C 306.350586 349.261841 347.907074 307.707336 347.907074 256.629944 C 347.907074 205.554504 306.350586 164 255.277161 164 Z'); 287 | } 288 | 289 | // 修改蓝色插座的SVG路径 290 | if (endPort.classList.contains('text-card-port')) { 291 | const path = endPort.querySelector('path'); 292 | // 设置第一个路径 293 | path.setAttribute('d', 'M 148 23.829071 C 60.587349 64.560425 0 153.204742 0 256 C 0 397.384888 114.615105 512 256 512 C 397.384888 512 512 397.384888 512 256 C 512 152.813232 450.950226 63.885376 363 23.365753 L 363 68.322052 C 428.113617 105.525055 472 175.637421 472 256 C 472 375.293518 375.293518 472 256 472 C 136.706497 472 40 375.293518 40 256 C 40 176.0495 83.437454 106.244354 148 68.896942 L 148 23.829071 Z'); 294 | 295 | // 添加第二个路径 296 | let path2 = endPort.querySelector('path:nth-child(2)'); 297 | if (!path2) { 298 | path2 = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 299 | endPort.querySelector('svg').appendChild(path2); 300 | } 301 | path2.setAttribute('d', 'M 226.710999 12.780029 L 226.710999 -28 L 285.289001 -28 L 285.289001 12.780029 L 303.932007 12.780029 C 318.192993 12.780029 329.761017 24.346985 329.761017 38.608032 L 329.761017 84.307007 C 377.085999 105.748993 410.032013 153.360992 410.032013 208.692993 L 410.032013 265.661987 L 410.031006 265.661987 L 410.031006 332.503998 C 353.622009 332.503998 353.622009 332.503998 353.622009 332.503998 L 353.622009 359.466003 L 304.947021 359.466003 L 304.947021 332.503998 C 207.054993 332.503998 207.054993 332.503998 207.054993 332.503998 L 207.054993 359.466003 L 158.377991 359.466003 L 158.377991 332.503998 L 101.967987 332.503998 L 101.967987 250.541992 L 101.968994 250.541992 L 101.968994 208.692993 C 101.968994 153.360992 134.90799 105.747986 182.23999 84.307007 L 182.23999 38.608032 C 182.23999 24.346985 193.800995 12.780029 208.069 12.780029 Z'); 302 | path2.setAttribute('fill', '#00b894'); 303 | } 304 | 305 | // 根据连接类型添加额外的类名 306 | if (endPort.classList.contains('text-card-port')) { 307 | if (this.startPort.classList.contains('connection-port')) { 308 | endPort.classList.add('prompt-connected'); 309 | } else if (this.startPort.classList.contains('text-card-chain-port')) { 310 | endPort.classList.add('chain-connected'); 311 | } 312 | } 313 | 314 | // 更新连接线样式 315 | this.currentConnection.classList.remove('temp'); 316 | 317 | // 存储连接信息 318 | const connectionId = `connection_${Date.now()}`; 319 | this.connections.set(connectionId, { 320 | id: connectionId, 321 | startPort: this.startPort, 322 | endPort: endPort, 323 | line: this.currentConnection 324 | }); 325 | 326 | // 记录端口的连接状态 327 | this.addPortConnection(this.startPort, endPort, connectionId); 328 | 329 | // 处理不同类型的连接 330 | if (this.startPort.classList.contains('text-card-chain-port') || 331 | endPort.classList.contains('text-card-chain-port')) { 332 | // 文本卡片链接 333 | this.handleChainConnection(this.startPort, endPort); 334 | } else { 335 | // 提示词卡片连接 336 | this.handlePromptConnection(this.startPort, endPort); 337 | } 338 | 339 | // 立即更新连接线位置,确保端点在端口中心 340 | const startRect = this.startPort.getBoundingClientRect(); 341 | const endRect = endPort.getBoundingClientRect(); 342 | const startX = startRect.left + startRect.width / 2; 343 | const startY = startRect.top + startRect.height / 2; 344 | const endX = endRect.left + endRect.width / 2; 345 | const endY = endRect.top + endRect.height / 2; 346 | 347 | const path = this.createCurvePath(startX, startY, endX, endY); 348 | this.currentConnection.setAttribute('d', path); 349 | 350 | // 重置当前连接状态 351 | this.currentConnection = null; 352 | this.startPort = null; 353 | } 354 | 355 | // 取消连接 356 | cancelConnection() { 357 | if (this.currentConnection) { 358 | this.currentConnection.remove(); 359 | this.currentConnection = null; 360 | } 361 | 362 | if (this.startPort) { 363 | this.startPort.classList.remove('connecting'); 364 | 365 | // 如果是提示词卡片的端口或文本卡片的链式端口,需要恢复原始的插头形状 366 | if (this.startPort.classList.contains('connection-port') || 367 | this.startPort.classList.contains('text-card-chain-port')) { 368 | const path = this.startPort.querySelector('path'); 369 | path.setAttribute('d', 'M 285.289001 471.220001 L 285.289001 512 L 226.710999 512 L 226.710999 471.220001 L 208.067993 471.220001 C 193.807007 471.220001 182.238998 459.653015 182.238998 445.391998 L 182.238998 369.692993 C 134.914001 348.251007 101.968002 300.639008 101.968002 245.307007 L 101.968002 188.338013 L 101.969002 188.338013 L 101.969002 121.496002 L 158.378006 121.496002 L 158.378006 13.533997 C 158.378006 6.059998 164.431 0 171.904999 0 L 193.526993 0 C 201.001007 0 207.054001 6.059998 207.052994 13.533997 L 207.052994 121.496002 L 304.945007 121.496002 L 304.945007 13.533997 C 304.945007 6.059998 311.005005 0 318.471985 0 L 340.10199 0 C 347.569 0 353.622009 6.059998 353.622009 13.533997 L 353.622009 121.496002 L 410.032013 121.496002 L 410.032013 203.458008 L 410.031006 203.458008 L 410.031006 245.307007 C 410.031006 300.639008 377.09201 348.252014 329.76001 369.692993 L 329.76001 445.391998 C 329.76001 459.653015 318.199005 471.220001 303.931 471.220001 L 285.289001 471.220001 Z'); 370 | } 371 | 372 | this.startPort = null; 373 | } 374 | 375 | document.body.classList.remove('connecting-mode'); 376 | } 377 | 378 | // 检查是否可以连接 379 | canConnect(startPort, endPort) { 380 | if (!startPort || !endPort || startPort === endPort) return false; 381 | 382 | // 如果目标端口已经连接,不允许连接 383 | if (this.isPortConnected(endPort)) return false; 384 | 385 | // 检查是否是文本卡片之间的链接 386 | const isChainConnection = startPort.classList.contains('text-card-chain-port') || 387 | endPort.classList.contains('text-card-chain-port'); 388 | 389 | if (isChainConnection) { 390 | // 文本卡片链接的规则: 391 | // 1. 一个是紫色插头,一个是蓝色插座 392 | const isStartChain = startPort.classList.contains('text-card-chain-port'); 393 | const isEndSocket = endPort.classList.contains('text-card-port'); 394 | 395 | // 2. 防止形成环 396 | if (isStartChain && isEndSocket) { 397 | return !this.wouldFormLoop(startPort, endPort); 398 | } 399 | return false; 400 | } 401 | 402 | // 原有的提示词卡片连接规则 403 | const isStartPrompt = startPort.classList.contains('connection-port'); 404 | const isEndText = endPort.classList.contains('text-card-port'); 405 | const isStartText = startPort.classList.contains('text-card-port'); 406 | const isEndPrompt = endPort.classList.contains('connection-port'); 407 | 408 | return (isStartPrompt && isEndText) || (isStartText && isEndPrompt); 409 | } 410 | 411 | // 检查是否会形成环 412 | wouldFormLoop(startPort, endPort) { 413 | const startCard = startPort.closest('.paragraph-card'); 414 | const endCard = endPort.closest('.paragraph-card'); 415 | 416 | // 检查是否已经存在从终点到起点的路径 417 | const visited = new Set(); 418 | const checkPath = (currentCard) => { 419 | if (currentCard === startCard) return true; 420 | if (visited.has(currentCard)) return false; 421 | 422 | visited.add(currentCard); 423 | const chainPort = currentCard.querySelector('.text-card-chain-port'); 424 | if (!chainPort) return false; 425 | 426 | const connectionId = this.portConnections.get(chainPort.dataset.cardId); 427 | if (!connectionId) return false; 428 | 429 | const connection = this.connections.get(connectionId); 430 | if (!connection) return false; 431 | 432 | const nextCard = connection.endPort.closest('.paragraph-card'); 433 | return checkPath(nextCard); 434 | }; 435 | 436 | return checkPath(endCard); 437 | } 438 | 439 | // 创建贝塞尔曲线路径 440 | createCurvePath(startX, startY, endX, endY) { 441 | const dx = endX - startX; 442 | const dy = endY - startY; 443 | 444 | // 检查是否是文本卡片之间的链接(通过检查起点是否是紫色插头) 445 | const isChainConnection = this.startPort?.classList.contains('text-card-chain-port'); 446 | 447 | if (isChainConnection) { 448 | // 文本卡片之间的链接:使用竖直控制点 449 | // 确保终点的控制点永远在终点上方 450 | const distance = Math.abs(dy); // 计算垂直距离 451 | const controlY1 = startY + dy * 0.5; // 起点控制点 452 | const controlY2 = endY - 60; // 终点控制点固定在终点上方50px处 453 | 454 | // 如果起点在终点下方,增加控制点的垂直距离以获得更平滑的曲线 455 | if (startY > endY) { 456 | return `M ${startX} ${startY} C ${startX} ${startY - distance * 0.5}, ${endX} ${controlY2}, ${endX} ${endY}`; 457 | } else { 458 | return `M ${startX} ${startY} C ${startX} ${controlY1}, ${endX} ${controlY2}, ${endX} ${endY}`; 459 | } 460 | } else { 461 | // 提示词卡片到文本卡片的链接:使用水平控制点 462 | // 调整控制点权重,使终点处的弯曲更明显 463 | const controlX1 = startX + dx * 0.3; // 起点控制点权重减小到0.3 464 | const controlY1 = startY; 465 | const controlX2 = endX - dx * 0.7; // 终点控制点权重增加到0.7 466 | const controlY2 = endY; 467 | return `M ${startX} ${startY} C ${controlX1} ${controlY1}, ${controlX2} ${controlY2}, ${endX} ${endY}`; 468 | } 469 | } 470 | 471 | // 获取提示词卡片实例 472 | getPromptCard(port) { 473 | const cardElement = port.closest('.prompt-card'); 474 | if (!cardElement) return null; 475 | return window.cardManager.cards.get(cardElement.id); 476 | } 477 | 478 | // 获取文本卡片元素 479 | getTextCard(port) { 480 | return port.closest('.paragraph-card'); 481 | } 482 | 483 | // 更新所有连接线的位置 484 | updateConnections() { 485 | this.connections.forEach(connection => { 486 | const startRect = connection.startPort.getBoundingClientRect(); 487 | const endRect = connection.endPort.getBoundingClientRect(); 488 | const startX = startRect.left + startRect.width / 2; 489 | const startY = startRect.top + startRect.height / 2; 490 | const endX = endRect.left + endRect.width / 2; 491 | const endY = endRect.top + endRect.height / 2; 492 | 493 | // 临时保存当前的startPort,以便createCurvePath方法使用 494 | const originalStartPort = this.startPort; 495 | this.startPort = connection.startPort; 496 | 497 | const path = this.createCurvePath(startX, startY, endX, endY); 498 | connection.line.setAttribute('d', path); 499 | 500 | // 恢复原始的startPort 501 | this.startPort = originalStartPort; 502 | }); 503 | } 504 | 505 | // 处理文本卡片之间的链接 506 | handleChainConnection(startPort, endPort) { 507 | const startCard = startPort.closest('.paragraph-card'); 508 | const endCard = endPort.closest('.paragraph-card'); 509 | 510 | // 记录链接关系 511 | this.chainConnections.set(startCard.dataset.cardId, endCard.dataset.cardId); 512 | } 513 | 514 | // 处理提示词卡片的连接 515 | handlePromptConnection(startPort, endPort) { 516 | const promptPort = startPort.classList.contains('connection-port') ? startPort : endPort; 517 | const textPort = startPort.classList.contains('text-card-port') ? startPort : endPort; 518 | const promptCard = this.getPromptCard(promptPort); 519 | const textCard = this.getTextCard(textPort); 520 | 521 | if (promptCard && textCard) { 522 | const portIndex = parseInt(promptPort.dataset.portId.split('_port_')[1]) - 1; 523 | // 获取链接文本,包括所有链接的卡片 524 | const content = this.getCombinedContent(textCard); 525 | promptCard.updateConnection(portIndex, content); 526 | } 527 | } 528 | 529 | // 获取组合后的文本内容 530 | getCombinedContent(startCard) { 531 | const contents = []; 532 | let currentCard = startCard; 533 | const visited = new Set(); 534 | 535 | while (currentCard && !visited.has(currentCard.dataset.cardId)) { 536 | visited.add(currentCard.dataset.cardId); 537 | contents.push(currentCard.querySelector('.card-content').textContent); 538 | 539 | // 查找下一个链接的卡片 540 | const chainPort = currentCard.querySelector('.text-card-chain-port'); 541 | if (!chainPort) break; 542 | 543 | const connectionId = this.portConnections.get(chainPort.dataset.cardId); 544 | if (!connectionId) break; 545 | 546 | const connection = this.connections.get(connectionId); 547 | if (!connection) break; 548 | 549 | currentCard = connection.endPort.closest('.paragraph-card'); 550 | if (visited.has(currentCard?.dataset.cardId)) break; 551 | } 552 | 553 | return contents.join('\\n'); 554 | } 555 | 556 | // 清除所有连接 557 | clearAllConnections() { 558 | // 获取所有端口 559 | const allPorts = document.querySelectorAll('.text-card-port, .text-card-chain-port, .connection-port'); 560 | 561 | // 重置每个端口的状态 562 | allPorts.forEach(port => { 563 | // 移除所有连接相关的类 564 | port.classList.remove('connected', 'prompt-connected', 'chain-connected'); 565 | 566 | // 重置SVG路径 567 | if (port.classList.contains('text-card-port')) { 568 | // 重置蓝色插座的SVG 569 | const path = port.querySelector('path'); 570 | path.setAttribute('d', 'M 512 256 C 512 114.615112 397.384888 0 256 0 C 114.615105 0 0 114.615112 0 256 C 0 397.384888 114.615105 512 256 512 C 397.384888 512 512 397.384888 512 256 Z M 40 256 C 40 136.706482 136.706497 40 256 40 C 375.293518 40 472 136.706482 472 256 C 472 375.293518 375.293518 472 256 472 C 136.706497 472 40 375.293518 40 256 Z M 255.27803 429.907074 C 159.728485 429.907074 82 352.171173 82 256.629089 C 82 161.086945 159.732193 83.354767 255.27803 83.354767 C 350.816406 83.354767 428.552307 161.086945 428.552307 256.629089 C 428.552307 352.174866 350.816406 429.907074 255.27803 429.907074 Z M 181.997467 230.213196 C 167.426392 230.213196 155.581589 242.061707 155.581589 256.629089 C 155.581589 271.196442 167.426392 283.044922 181.997467 283.044922 C 196.564819 283.044922 208.41333 271.196442 208.41333 256.629089 C 208.41333 242.061707 196.564819 230.213196 181.997467 230.213196 Z M 330.441895 230.213196 C 315.870789 230.213196 304.022308 242.061707 304.022308 256.629089 C 304.022308 271.196442 315.870789 283.044922 330.441895 283.044922 C 345.005524 283.044922 356.857788 271.196442 356.857788 256.629089 C 356.857788 242.061707 345.005524 230.213196 330.441895 230.213196 Z'); 571 | 572 | // 移除第二个路径(如果存在) 573 | const path2 = port.querySelector('path:nth-child(2)'); 574 | if (path2) { 575 | path2.remove(); 576 | } 577 | } else if (port.classList.contains('text-card-chain-port') || 578 | port.classList.contains('connection-port')) { 579 | // 重置紫色插头和黄色插座的SVG 580 | const path = port.querySelector('path'); 581 | path.setAttribute('d', 'M 285.289001 471.220001 L 285.289001 512 L 226.710999 512 L 226.710999 471.220001 L 208.067993 471.220001 C 193.807007 471.220001 182.238998 459.653015 182.238998 445.391998 L 182.238998 369.692993 C 134.914001 348.251007 101.968002 300.639008 101.968002 245.307007 L 101.968002 188.338013 L 101.969002 188.338013 L 101.969002 121.496002 L 158.378006 121.496002 L 158.378006 13.533997 C 158.378006 6.059998 164.431 0 171.904999 0 L 193.526993 0 C 201.001007 0 207.054001 6.059998 207.052994 13.533997 L 207.052994 121.496002 L 304.945007 121.496002 L 304.945007 13.533997 C 304.945007 6.059998 311.005005 0 318.471985 0 L 340.10199 0 C 347.569 0 353.622009 6.059998 353.622009 13.533997 L 353.622009 121.496002 L 410.032013 121.496002 L 410.032013 203.458008 L 410.031006 203.458008 L 410.031006 245.307007 C 410.031006 300.639008 377.09201 348.252014 329.76001 369.692993 L 329.76001 445.391998 C 329.76001 459.653015 318.199005 471.220001 303.931 471.220001 L 285.289001 471.220001 Z'); 582 | } 583 | }); 584 | 585 | // 移除所有连接线 586 | this.svgContainer.innerHTML = ''; 587 | 588 | // 清空连接记录 589 | this.connections.clear(); 590 | this.portConnections.clear(); 591 | this.chainConnections.clear(); 592 | } 593 | } -------------------------------------------------------------------------------- /js/customDialogs.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 创建基础对话框HTML结构 3 | */ 4 | function createDialogBase() { 5 | if (!document.getElementById('dialog-styles')) { 6 | const style = document.createElement('style'); 7 | style.id = 'dialog-styles'; 8 | style.textContent = ` 9 | /* 提示和确认对话框 */ 10 | .alert-dialog, .confirm-dialog { 11 | position: fixed; 12 | top: 0; 13 | left: 0; 14 | right: 0; 15 | bottom: 0; 16 | background-color: rgba(0, 0, 0, 0.5); 17 | display: flex; 18 | align-items: center; 19 | justify-content: center; 20 | z-index: 20001; 21 | user-select: none; 22 | } 23 | 24 | .alert-content, .confirm-content { 25 | background-color: var(--color-bg-primary); 26 | padding: var(--spacing-xxl); 27 | border-radius: var(--radius-xl); 28 | width: 90%; 29 | max-width: 400px; 30 | display: flex; 31 | flex-direction: column; 32 | gap: var(--spacing-lg); 33 | } 34 | 35 | .alert-message, .confirm-message { 36 | color: var(--color-text-primary); 37 | font-size: 16px; 38 | line-height: 1.5; 39 | white-space: pre-wrap; 40 | } 41 | 42 | .alert-buttons, .confirm-buttons { 43 | display: flex; 44 | justify-content: flex-end; 45 | gap: var(--spacing-sm); 46 | } 47 | 48 | .alert-buttons button, .confirm-buttons button { 49 | padding: var(--spacing-sm) var(--spacing-lg); 50 | border: none; 51 | border-radius: var(--radius-md); 52 | cursor: pointer; 53 | font-size: 14px; 54 | transition: all 0.2s; 55 | } 56 | 57 | .alert-confirm, .confirm-ok { 58 | background-color: var(--color-primary); 59 | color: var(--color-bg-primary); 60 | } 61 | 62 | .alert-confirm:hover, .confirm-ok:hover { 63 | background-color: var(--color-primary-dark); 64 | } 65 | 66 | .confirm-cancel { 67 | background-color: var(--color-bg-secondary); 68 | color: var(--color-text-primary); 69 | } 70 | 71 | .confirm-cancel:hover { 72 | background-color: var(--color-border); 73 | } 74 | `; 75 | document.head.appendChild(style); 76 | } 77 | } 78 | 79 | /** 80 | * 显示提示框 81 | * @param {string} message 提示消息 82 | * @param {string} [title] 标题 83 | * @returns {Promise} 84 | */ 85 | function showAlert(message, title) { 86 | return new Promise((resolve) => { 87 | createDialogBase(); 88 | 89 | // 移除已存在的对话框 90 | const existing = document.querySelector('.alert-dialog'); 91 | if (existing) existing.remove(); 92 | 93 | const dialog = document.createElement('div'); 94 | dialog.className = 'alert-dialog'; 95 | 96 | dialog.innerHTML = ` 97 |
98 | ${title ? `

${title}

` : ''} 99 |
${message}
100 |
101 | 102 |
103 |
104 | `; 105 | 106 | document.body.appendChild(dialog); 107 | 108 | dialog.querySelector('.alert-confirm').addEventListener('click', () => { 109 | dialog.remove(); 110 | resolve(); 111 | }); 112 | }); 113 | } 114 | /* 115 | 使用示例: 116 | showAlert('操作成功!').then(() => { 117 | console.log('用户关闭了提示框'); 118 | }); 119 | */ 120 | 121 | /** 122 | * 显示确认框 123 | * @param {string} message 确认消息 124 | * @param {string} [title] 标题 125 | * @returns {Promise} 用户是否确认 126 | */ 127 | function showConfirm(message, title) { 128 | return new Promise((resolve) => { 129 | createDialogBase(); 130 | 131 | // 移除已存在的对话框 132 | const existing = document.querySelector('.confirm-dialog'); 133 | if (existing) existing.remove(); 134 | 135 | const dialog = document.createElement('div'); 136 | dialog.className = 'confirm-dialog'; 137 | 138 | dialog.innerHTML = ` 139 |
140 | ${title ? `

${title}

` : ''} 141 |
${message}
142 |
143 | 144 | 145 |
146 |
147 | `; 148 | 149 | document.body.appendChild(dialog); 150 | 151 | dialog.querySelector('.confirm-cancel').addEventListener('click', () => { 152 | dialog.remove(); 153 | resolve(false); 154 | }); 155 | 156 | dialog.querySelector('.confirm-ok').addEventListener('click', () => { 157 | dialog.remove(); 158 | resolve(true); 159 | }); 160 | }); 161 | } 162 | /* 163 | 使用示例: 164 | showConfirm('确定要删除此项吗?').then((confirmed) => { 165 | if (confirmed) { 166 | console.log('用户确认'); 167 | } else { 168 | console.log('用户取消'); 169 | } 170 | }); 171 | */ 172 | 173 | // 导出函数 174 | export { 175 | showAlert, 176 | showConfirm 177 | }; -------------------------------------------------------------------------------- /js/markdownHandler.js: -------------------------------------------------------------------------------- 1 | import { showAlert, showConfirm } from './customDialogs.js'; 2 | 3 | export class MarkdownHandler { 4 | constructor(containerElement) { 5 | this.container = containerElement; 6 | this.cards = []; 7 | this.currentZIndex = 1; // 跟踪最高的 z-index 8 | this.importCount = 0; // 添加导入计数器 9 | this.setupDragAndDrop(); 10 | } 11 | 12 | // 处理文件导入 13 | async handleFileImport(file) { 14 | try { 15 | const content = await this.readFile(file); 16 | const paragraphs = this.splitIntoParagraphs(content); 17 | this.createCards(paragraphs); 18 | } catch (error) { 19 | console.error('文件处理错误:', error); 20 | showAlert('文件处理出错,请重试'); 21 | } 22 | } 23 | 24 | // 读取文件内容 25 | readFile(file) { 26 | return new Promise((resolve, reject) => { 27 | const reader = new FileReader(); 28 | reader.onload = (e) => resolve(e.target.result); 29 | reader.onerror = (e) => reject(e); 30 | reader.readAsText(file); 31 | }); 32 | } 33 | 34 | // 将文本分割成段落 35 | splitIntoParagraphs(content) { 36 | return content.split(/\n\s*\n/).filter(p => p.trim()); 37 | } 38 | 39 | // 创建段落卡片 40 | createCards(paragraphs) { 41 | // 检查是否已有卡片 42 | const existingCards = this.container.querySelectorAll('.paragraph-card'); 43 | const isFirstImport = existingCards.length === 0; 44 | 45 | // 计算起始位置 46 | let startX = 10; // 默认起始x坐标 47 | let startY = 10; // 默认起始y坐标 48 | 49 | // 如果不是第一次导入,向右偏移 50 | if (!isFirstImport) { 51 | this.importCount++; // 增加导入计数 52 | startX = Math.min( 53 | 10 + (this.importCount * 100), // 每次偏移100px 54 | this.container.clientWidth - 320 // 不超过容器右边界 55 | ); 56 | } 57 | 58 | // 创建新卡片 59 | paragraphs.forEach((text, index) => { 60 | const card = this.createCard(text); 61 | card.style.left = `${startX}px`; 62 | card.style.top = `${startY + index * 160}px`; // 160是卡片间的垂直间距 63 | }); 64 | } 65 | 66 | // 创建单个卡片 67 | createCard(text = '', index = this.cards.length) { 68 | const card = document.createElement('div'); 69 | card.className = 'paragraph-card'; 70 | card.dataset.editable = 'false'; // 添加编辑状态标记 71 | card.dataset.cardId = 'text_card_' + Date.now() + '_' + index; 72 | 73 | // 添加连接端口(左上角插座) 74 | const connectionPort = document.createElement('div'); 75 | connectionPort.className = 'text-card-port'; 76 | connectionPort.dataset.cardId = card.dataset.cardId; 77 | connectionPort.innerHTML = ` 78 | 79 | `; 80 | card.appendChild(connectionPort); 81 | 82 | // 添加新的链接端口(左下角插头) 83 | const chainPort = document.createElement('div'); 84 | chainPort.className = 'text-card-chain-port'; 85 | chainPort.dataset.cardId = card.dataset.cardId; 86 | chainPort.dataset.portType = 'chain'; // 添加端口类型标记 87 | chainPort.innerHTML = ` 88 | 89 | `; 90 | card.appendChild(chainPort); 91 | 92 | // 添加删除按钮 93 | const actions = document.createElement('div'); 94 | actions.className = 'card-actions'; 95 | const deleteBtn = document.createElement('button'); 96 | deleteBtn.innerHTML = '×'; 97 | deleteBtn.onclick = (e) => { 98 | e.stopPropagation(); 99 | showConfirm('确定要删除这个卡片吗?').then((confirmed) => { 100 | if (confirmed) { 101 | // 删除指向该卡片的连接 102 | const cardId = card.dataset.cardId; 103 | if (window.connectionManager) { 104 | // 删除蓝色插座的连接(包括来自提示词卡片和其他文本卡片的连接) 105 | const textCardPort = card.querySelector('.text-card-port'); 106 | if (textCardPort) { 107 | window.connectionManager.removePortConnection(textCardPort); 108 | } 109 | 110 | // 删除紫色插头的连接(该卡片发起的链式连接) 111 | const chainPort = card.querySelector('.text-card-chain-port'); 112 | if (chainPort) { 113 | window.connectionManager.removePortConnection(chainPort); 114 | } 115 | 116 | // 删除指向该卡片的所有连接 117 | window.connectionManager.connections.forEach((connection, connectionId) => { 118 | if (connection.endPort.closest('.paragraph-card')?.dataset.cardId === cardId) { 119 | window.connectionManager.removePortConnection(connection.startPort); 120 | } 121 | }); 122 | } 123 | 124 | card.remove(); 125 | this.cards = this.cards.filter(c => c !== card); 126 | } 127 | }); 128 | }; 129 | actions.appendChild(deleteBtn); 130 | card.appendChild(actions); 131 | 132 | // 添加内容区域 133 | const content = document.createElement('div'); 134 | content.className = 'card-content'; 135 | content.contentEditable = 'false'; 136 | content.textContent = text; 137 | 138 | // 双击启用编辑 139 | content.addEventListener('dblclick', (e) => { 140 | e.stopPropagation(); 141 | content.contentEditable = 'true'; 142 | card.dataset.editable = 'true'; 143 | card.style.cursor = 'text'; 144 | content.focus(); 145 | }); 146 | 147 | // 失去焦点时保存 148 | content.addEventListener('blur', () => { 149 | content.contentEditable = 'false'; 150 | card.dataset.editable = 'false'; 151 | card.style.cursor = 'move'; 152 | }); 153 | 154 | // 按下回车时保存(避免换行) 155 | content.addEventListener('keydown', (e) => { 156 | if (e.key === 'Enter' && !e.shiftKey) { 157 | e.preventDefault(); 158 | content.blur(); 159 | } 160 | }); 161 | 162 | card.appendChild(content); 163 | 164 | card.style.position = 'absolute'; 165 | // 位置将由 createCards 方法设置 166 | card.style.zIndex = this.currentZIndex++; 167 | 168 | this.cards.push(card); 169 | this.container.appendChild(card); 170 | return card; 171 | } 172 | 173 | // 设置拖拽功能 174 | setupDragAndDrop() { 175 | let draggedCard = null; 176 | let initialMouseX = 0; 177 | let initialMouseY = 0; 178 | let initialCardX = 0; 179 | let initialCardY = 0; 180 | 181 | // 鼠标按下时 182 | const mouseDown = (e) => { 183 | // 如果点击的是链接端口或调整大小的区域,不启动卡片拖拽 184 | if (e.target.closest('.text-card-chain-port') || 185 | (e.offsetX >= e.target.clientWidth - 20 && e.offsetY >= e.target.clientHeight - 20)) { 186 | return; 187 | } 188 | 189 | const card = e.target.closest('.paragraph-card'); 190 | if (!card || card.dataset.editable === 'true') return; // 编辑状态下不允许拖拽 191 | 192 | draggedCard = card; 193 | draggedCard.style.transition = 'none'; 194 | 195 | // 记录初始状态 196 | initialMouseX = e.clientX; 197 | initialMouseY = e.clientY; 198 | initialCardX = parseInt(card.style.left) || 0; 199 | initialCardY = parseInt(card.style.top) || 0; 200 | 201 | // 提升卡片层级 202 | this.currentZIndex++; 203 | card.style.zIndex = this.currentZIndex; 204 | 205 | // 阻止默认事件和冒泡 206 | e.preventDefault(); 207 | e.stopPropagation(); 208 | }; 209 | 210 | // 鼠标移动时 211 | const mouseMove = (e) => { 212 | if (!draggedCard) return; 213 | e.preventDefault(); 214 | 215 | // 计算鼠标移动的距离 216 | const deltaX = e.clientX - initialMouseX; 217 | const deltaY = e.clientY - initialMouseY; 218 | 219 | // 直接设置新位置 220 | draggedCard.style.left = `${initialCardX + deltaX}px`; 221 | draggedCard.style.top = `${initialCardY + deltaY}px`; 222 | 223 | // 更新连接线 224 | window.connectionManager?.updateConnections(); 225 | }; 226 | 227 | // 鼠标松开时 228 | const mouseUp = () => { 229 | if (draggedCard) { 230 | // 获取容器的位置信息 231 | const containerRect = this.container.getBoundingClientRect(); 232 | 233 | // 获取卡片的当前位置信息 234 | const cardRect = draggedCard.getBoundingClientRect(); 235 | 236 | // 确保卡片在容器范围内 237 | let newX = parseInt(draggedCard.style.left) || 0; 238 | let newY = parseInt(draggedCard.style.top) || 0; 239 | 240 | // 检查左边范围 241 | if (newX < 0) { 242 | newX = 0; 243 | } 244 | 245 | // 检查上边范围 246 | if (newY < 0) { 247 | newY = 0; 248 | } 249 | 250 | // 更新卡片位置 251 | draggedCard.style.left = `${newX}px`; 252 | draggedCard.style.top = `${newY}px`; 253 | 254 | // 恢复过渡效果 255 | draggedCard.style.transition = ''; 256 | draggedCard = null; 257 | } 258 | }; 259 | 260 | // 添加事件监听 261 | this.container.addEventListener('mousedown', mouseDown, { passive: false }); 262 | document.addEventListener('mousemove', mouseMove, { passive: false }); 263 | document.addEventListener('mouseup', mouseUp); 264 | } 265 | 266 | // 获取当前排序后的文本 267 | getCurrentText() { 268 | return [...this.container.querySelectorAll('.paragraph-card')] 269 | .map(card => card.textContent) 270 | .join('\n\n'); 271 | } 272 | 273 | // 添加新卡片 274 | addNewCard(e) { 275 | const newCard = this.createCard('', this.cards.length); 276 | 277 | // 获取容器的位置信息 278 | const containerRect = this.container.getBoundingClientRect(); 279 | 280 | // 计算鼠标相对于容器的位置 281 | const relativeX = e.clientX - containerRect.left; 282 | const relativeY = e.clientY - containerRect.top; 283 | 284 | // 设置新卡片位置,稍微偏移一点,避免完全遮盖按钮 285 | newCard.style.left = `${relativeX - 150}px`; // 向左偏移卡片宽度的一半 286 | newCard.style.top = `${relativeY - 75}px`; // 向上偏移卡片高度的一半 287 | 288 | // 确保新卡片在视图内 289 | const maxX = containerRect.width - 300; // 卡片宽度 290 | const maxY = containerRect.height - 150; // 卡片高度 291 | 292 | newCard.style.left = `${Math.max(0, Math.min(parseInt(newCard.style.left), maxX))}px`; 293 | newCard.style.top = `${Math.max(0, Math.min(parseInt(newCard.style.top), maxY))}px`; 294 | } 295 | 296 | // 初始化事件监听 297 | init() { 298 | // 添加新建卡片按钮事件 299 | const addButton = document.getElementById('add-paragraph'); 300 | if (addButton) { 301 | addButton.addEventListener('click', (e) => this.addNewCard(e)); 302 | } 303 | } 304 | } -------------------------------------------------------------------------------- /js/promptCard.js: -------------------------------------------------------------------------------- 1 | 2 | import { showAlert, showConfirm } from './customDialogs.js'; 3 | // 生成唯一ID的辅助函数 4 | function generateUniqueId(prefix = 'card') { 5 | return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; 6 | } 7 | 8 | // 防抖函数 9 | function debounce(func, wait) { 10 | let timeout; 11 | return function executedFunction(...args) { 12 | const later = () => { 13 | clearTimeout(timeout); 14 | func(...args); 15 | }; 16 | clearTimeout(timeout); 17 | timeout = setTimeout(later, wait); 18 | }; 19 | } 20 | 21 | // 提示词卡片类 22 | export class PromptCard { 23 | constructor(id, title, prompt, placeholders = []) { 24 | this.id = id; 25 | this.title = title; 26 | this.prompt = prompt; 27 | this.placeholders = this.detectPlaceholders(prompt); // 使用检测到的占位符 28 | this.connections = new Array(this.placeholders.length).fill(null); 29 | this.element = this.createCardElement(); 30 | } 31 | 32 | // 检测提示词中的占位符 33 | detectPlaceholders(text) { 34 | const regex = /\{\{([^}]+)\}\}/g; 35 | const placeholders = []; 36 | let match; 37 | 38 | while ((match = regex.exec(text)) !== null) { 39 | placeholders.push(match[1].trim()); 40 | } 41 | 42 | return placeholders; 43 | } 44 | 45 | // 更新卡片内容 46 | updateContent(title, prompt) { 47 | this.title = title; 48 | this.prompt = prompt; 49 | 50 | // 检测新的占位符 51 | const newPlaceholders = this.detectPlaceholders(prompt); 52 | 53 | // 如果占位符数量或内容发生变化,需要重新创建端口 54 | if (JSON.stringify(this.placeholders) !== JSON.stringify(newPlaceholders)) { 55 | this.placeholders = newPlaceholders; 56 | this.connections = new Array(this.placeholders.length).fill(null); 57 | 58 | // 移除旧的端口和连接 59 | const portContainer = this.element.querySelector('.port-container'); 60 | if (portContainer) { 61 | // 移除所有现有连接 62 | const ports = portContainer.querySelectorAll('.connection-port'); 63 | ports.forEach(port => { 64 | if (window.connectionManager) { 65 | window.connectionManager.removePortConnection(port); 66 | } 67 | }); 68 | portContainer.innerHTML = ''; 69 | } 70 | 71 | // 创建新的端口 72 | this.createPorts(portContainer); 73 | } 74 | 75 | // 更新显示内容 76 | this.element.querySelector('h3').textContent = this.title; 77 | this.element.querySelector('.card-prompt').innerHTML = this.prompt; 78 | } 79 | 80 | createCardElement() { 81 | const card = document.createElement('div'); 82 | card.className = 'prompt-card'; 83 | card.id = this.id; 84 | 85 | // 创建卡片内容,使用HTML转义来防止XSS攻击 86 | const escapeHtml = (unsafe) => { 87 | return unsafe 88 | .replace(/&/g, "&") 89 | .replace(//g, ">") 91 | .replace(/"/g, """) 92 | .replace(/'/g, "'"); 93 | }; 94 | 95 | card.innerHTML = ` 96 |
97 | 98 | 99 |
100 |

${escapeHtml(this.title)}

101 |
${this.prompt}
102 |
103 | `; 104 | 105 | // 创建端口 106 | const portContainer = card.querySelector('.port-container'); 107 | this.createPorts(portContainer); 108 | 109 | // 添加删除按钮事件 110 | const deleteBtn = card.querySelector('.delete-btn'); 111 | deleteBtn.onclick = (e) => { 112 | e.stopPropagation(); 113 | showConfirm('确定要删除这个提示词卡片吗?').then((confirmed) => { 114 | if (confirmed) { 115 | // 删除所有连接端口的连接 116 | const ports = card.querySelectorAll('.connection-port'); 117 | if (window.connectionManager) { 118 | ports.forEach(port => { 119 | window.connectionManager.removePortConnection(port); 120 | }); 121 | } 122 | 123 | // 从卡片管理器中移除 124 | window.cardManager.deleteCard(this.id); 125 | } 126 | }); 127 | }; 128 | 129 | return card; 130 | } 131 | 132 | // 创建端口 133 | createPorts(container) { 134 | this.placeholders.forEach((placeholder, index) => { 135 | const port = document.createElement('div'); 136 | port.className = 'connection-port'; 137 | port.dataset.portId = `${this.id}_port_${index + 1}`; 138 | 139 | // 添加 SVG 内容 140 | port.innerHTML = ` 141 | 142 | `; 143 | 144 | // 添加占位符名称标签 145 | const label = document.createElement('span'); 146 | label.className = 'port-label'; 147 | label.textContent = placeholder; 148 | 149 | port.appendChild(label); 150 | container.appendChild(port); 151 | }); 152 | } 153 | 154 | // 检查所有端口是否都已连接 155 | areAllPortsConnected() { 156 | return this.connections.every(connection => connection !== null); 157 | } 158 | 159 | // 获取未连接的端口序号 160 | getUnconnectedPorts() { 161 | return this.connections 162 | .map((connection, index) => connection === null ? this.placeholders[index] : null) 163 | .filter(index => index !== null); 164 | } 165 | 166 | // 更新连接状态 167 | updateConnection(portIndex, content) { 168 | this.connections[portIndex] = { content }; 169 | } 170 | 171 | // 移除连接状态 172 | removeConnection(portIndex) { 173 | this.connections[portIndex] = null; 174 | } 175 | 176 | // 获取替换了占位符的提示词 177 | getPromptWithConnections() { 178 | // 检查是否所有端口都已连接 179 | if (!this.areAllPortsConnected()) { 180 | const unconnectedPorts = this.getUnconnectedPorts(); 181 | throw new Error(`以下变量未连接:${unconnectedPorts.join(', ')}`); 182 | } 183 | 184 | let result = this.prompt; 185 | this.placeholders.forEach((placeholder, index) => { 186 | const pattern = new RegExp(`\\{\\{${placeholder}\\}\\}`, 'g'); 187 | 188 | // 获取直接连接的文本卡片 189 | const startCard = this.getConnectedTextCard(index); 190 | if (!startCard) { 191 | throw new Error(`无法获取连接的文本卡片:${placeholder}`); 192 | } 193 | 194 | // 获取所有链式连接的文本 195 | const contents = this.getChainedContents(startCard); 196 | const combinedContent = contents.join('\\n'); 197 | result = result.replace(pattern, combinedContent); 198 | }); 199 | 200 | // 只输出最终的提示词 201 | return result; 202 | } 203 | 204 | // 获取连接到指定端口的文本卡片 205 | getConnectedTextCard(portIndex) { 206 | const port = this.element.querySelector(`[data-port-id="${this.id}_port_${portIndex + 1}"]`); 207 | if (!port) return null; 208 | 209 | const connectionId = window.connectionManager.portConnections.get(port.dataset.portId); 210 | if (!connectionId) return null; 211 | 212 | const connection = window.connectionManager.connections.get(connectionId); 213 | if (!connection) return null; 214 | 215 | return connection.endPort.closest('.paragraph-card'); 216 | } 217 | 218 | // 获取所有链式连接的文本内容 219 | getChainedContents(startCard) { 220 | const contents = []; 221 | let currentCard = startCard; 222 | const visited = new Set(); 223 | 224 | while (currentCard && !visited.has(currentCard.dataset.cardId)) { 225 | visited.add(currentCard.dataset.cardId); 226 | 227 | // 添加当前卡片的文本内容 228 | const content = currentCard.querySelector('.card-content').textContent.trim(); 229 | if (content) { 230 | contents.push(content); 231 | } 232 | 233 | // 查找下一个链接的卡片 234 | const chainPort = currentCard.querySelector('.text-card-chain-port'); 235 | if (!chainPort) break; 236 | 237 | // 查找从当前卡片的链接端口出发的连接 238 | const portId = chainPort.dataset.cardId; 239 | const connectionId = window.connectionManager.portConnections.get(portId); 240 | if (!connectionId) break; 241 | 242 | const connection = window.connectionManager.connections.get(connectionId); 243 | if (!connection) break; 244 | 245 | currentCard = connection.endPort.closest('.paragraph-card'); 246 | if (visited.has(currentCard?.dataset.cardId)) break; 247 | } 248 | 249 | return contents; 250 | } 251 | } 252 | 253 | // 提示词卡片管理器 254 | export class PromptCardManager { 255 | constructor(containerElement) { 256 | this.container = containerElement; 257 | this.cards = new Map(); 258 | this.selectedCard = null; 259 | this.onCardSelected = null; 260 | this.debouncedSelect = debounce(this.selectCard.bind(this), 100); 261 | this.setupEventListeners(); 262 | } 263 | 264 | setupEventListeners() { 265 | // 使用事件委托来处理卡片的点击事件 266 | this.container.addEventListener('click', (e) => { 267 | const promptCard = e.target.closest('.prompt-card'); 268 | if (!promptCard) return; 269 | 270 | // 处理编辑按钮点击 271 | if (e.target.matches('.edit-btn')) { 272 | e.stopPropagation(); 273 | this.showEditDialog(promptCard.id); 274 | return; 275 | } 276 | 277 | // 处理删除按钮点击 278 | if (e.target.matches('.delete-btn')) { 279 | e.stopPropagation(); 280 | showConfirm('确定要删除这个提示词卡片吗?').then((confirmed) => { 281 | if (confirmed) this.deleteCard(promptCard.id); 282 | }); 283 | return; 284 | } 285 | 286 | // 处理卡片选择 287 | if (!e.target.matches('button')) { 288 | this.debouncedSelect(promptCard.id); 289 | } 290 | }); 291 | } 292 | 293 | // 检查ID是否已存在 294 | isIdExists(id) { 295 | return this.cards.has(id) || document.getElementById(id) !== null; 296 | } 297 | 298 | // 添加新卡片 299 | addCard(title, prompt) { 300 | let card = new PromptCard(generateUniqueId(), title, prompt); 301 | 302 | while (this.isIdExists(card.id)) { 303 | card = new PromptCard(generateUniqueId(), title, prompt); 304 | } 305 | 306 | this.cards.set(card.id, card); 307 | this.container.appendChild(card.element); 308 | return card; 309 | } 310 | 311 | // 删除卡片 312 | deleteCard(cardId) { 313 | const cardElement = document.getElementById(cardId); 314 | if (cardElement) { 315 | cardElement.remove(); 316 | this.cards.delete(cardId); 317 | if (this.selectedCard?.id === cardId) { 318 | this.selectedCard = null; 319 | this.onCardSelected?.(null); 320 | } 321 | } 322 | } 323 | 324 | // 编辑卡片 325 | editCard(cardId, title, prompt) { 326 | const card = this.cards.get(cardId); 327 | if (card) { 328 | card.updateContent(title, prompt); 329 | } 330 | } 331 | 332 | // 选择卡片 333 | selectCard(cardId) { 334 | if (!cardId) return; 335 | 336 | // 如果点击的是当前已选中的卡片,则取消选中 337 | if (this.selectedCard?.id === cardId) { 338 | this.selectedCard = null; 339 | document.querySelectorAll('.prompt-card').forEach(card => { 340 | card.classList.remove('selected'); 341 | }); 342 | if (this.onCardSelected) { 343 | this.onCardSelected(null); 344 | } 345 | return; 346 | } 347 | 348 | const allCards = this.container.querySelectorAll('.prompt-card'); 349 | allCards.forEach(card => card.classList.remove('selected')); 350 | 351 | this.selectedCard = null; 352 | 353 | const cardElement = document.getElementById(cardId); 354 | if (cardElement) { 355 | const card = this.cards.get(cardId); 356 | if (card) { 357 | cardElement.classList.add('selected'); 358 | this.selectedCard = card; 359 | } 360 | } 361 | 362 | if (this.onCardSelected) { 363 | this.onCardSelected(this.selectedCard); 364 | } 365 | } 366 | 367 | // 显示编辑对话框 368 | showEditDialog(cardId) { 369 | const card = cardId ? this.cards.get(cardId) : { title: '', prompt: '' }; 370 | if (!card) return; 371 | 372 | const dialog = document.createElement('div'); 373 | dialog.className = 'edit-dialog'; 374 | dialog.innerHTML = ` 375 |
376 |

${cardId ? '编辑提示词' : '新建提示词'}

377 | 378 | 379 |
380 | 381 | 382 |
383 |
384 | `; 385 | 386 | document.body.appendChild(dialog); 387 | 388 | // 保存按钮事件 389 | dialog.querySelector('#save-edit').addEventListener('click', () => { 390 | const title = dialog.querySelector('#edit-title').value; 391 | const prompt = dialog.querySelector('#edit-prompt').value; 392 | 393 | if (title && prompt) { 394 | if (cardId) { 395 | this.editCard(cardId, title, prompt); 396 | } else { 397 | this.addCard(title, prompt); 398 | } 399 | dialog.remove(); 400 | } else { 401 | showAlert('标题和提示词内容不能为空'); 402 | } 403 | }); 404 | 405 | // 取消按钮事件 406 | dialog.querySelector('#cancel-edit').addEventListener('click', () => { 407 | dialog.remove(); 408 | }); 409 | } 410 | 411 | // 获取选中的提示词 412 | getSelectedPrompt() { 413 | return this.selectedCard?.prompt || null; 414 | } 415 | } 416 | 417 | // 导出卡片为JSON 418 | export function exportCards() { 419 | const cards = document.querySelectorAll('.prompt-card'); 420 | const cardsData = Array.from(cards).map(card => ({ 421 | title: card.querySelector('h3').textContent, 422 | prompt: card.querySelector('.card-prompt').innerHTML 423 | })); 424 | 425 | const blob = new Blob([JSON.stringify(cardsData, null, 2)], { type: 'application/json' }); 426 | const url = URL.createObjectURL(blob); 427 | const a = document.createElement('a'); 428 | a.href = url; 429 | a.download = 'prompt-cards.json'; 430 | document.body.appendChild(a); 431 | a.click(); 432 | document.body.removeChild(a); 433 | URL.revokeObjectURL(url); 434 | } 435 | 436 | // 导入卡片 437 | export function importCards() { 438 | const input = document.createElement('input'); 439 | input.type = 'file'; 440 | input.accept = '.json'; 441 | 442 | input.onchange = async (e) => { 443 | const file = e.target.files[0]; 444 | if (!file) return; 445 | 446 | try { 447 | const text = await file.text(); 448 | const cardsData = JSON.parse(text); 449 | 450 | if (!Array.isArray(cardsData)) { 451 | throw new Error('无效的卡片数据格式'); 452 | } 453 | 454 | // 获取cardManager实例 455 | const cardManager = window.cardManager; 456 | 457 | // 添加新卡片(追加到现有卡片后面) 458 | cardsData.forEach(cardData => { 459 | if (cardData.title && cardData.prompt) { 460 | cardManager.addCard(cardData.title, cardData.prompt); 461 | } 462 | }); 463 | 464 | // 显示成功提示 465 | const count = cardsData.length; 466 | showAlert(`成功导入 ${count} 个卡片`); 467 | } catch (error) { 468 | showAlert('导入失败:' + error.message); 469 | } 470 | }; 471 | 472 | input.click(); 473 | } 474 | 475 | // 初始化导入导出按钮 476 | export function initializeCardManagement() { 477 | const exportButton = document.getElementById('export-cards'); 478 | const importButton = document.getElementById('import-cards'); 479 | const clearButton = document.getElementById('clear-cards'); 480 | const clearConnectionsButton = document.getElementById('clear-connections'); 481 | 482 | exportButton.addEventListener('click', exportCards); 483 | importButton.addEventListener('click', importCards); 484 | 485 | // 添加清空功能 486 | clearButton.addEventListener('click', () => { 487 | showConfirm('确定要删除所有提示词卡片吗?此操作不可撤销。').then((confirmed) => { 488 | if (confirmed) { 489 | const cardsContainer = document.querySelector('.prompt-cards'); 490 | 491 | // 先删除所有提示词卡片的连接 492 | cardsContainer.querySelectorAll('.prompt-card').forEach(card => { 493 | const ports = card.querySelectorAll('.connection-port'); 494 | if (window.connectionManager) { 495 | ports.forEach(port => { 496 | window.connectionManager.removePortConnection(port); 497 | }); 498 | } 499 | }); 500 | 501 | // 然后清空容器和卡片管理器 502 | cardsContainer.innerHTML = ''; 503 | window.cardManager.cards.clear(); 504 | } 505 | }); 506 | }); 507 | 508 | // 添加清除连线功能 509 | clearConnectionsButton.addEventListener('click', () => { 510 | // 清除所有SVG连线 511 | const connectionsContainer = document.querySelector('.connections-container'); 512 | connectionsContainer.innerHTML = ''; 513 | 514 | // 清除所有端口的连接状态 515 | document.querySelectorAll('.connection-port, .text-card-port').forEach(port => { 516 | port.classList.remove('connected'); 517 | port.classList.remove('connecting'); 518 | }); 519 | 520 | // 重置所有卡片的连接状态 521 | window.cardManager.cards.forEach(card => { 522 | card.connections = new Array(card.placeholders.length).fill(null); 523 | }); 524 | 525 | // 通知连接管理器重置状态 526 | if (window.connectionManager) { 527 | window.connectionManager.clearAllConnections(); 528 | } 529 | }); 530 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ai-chat-app", 3 | "version": "1.0.0", 4 | "description": "AI聊天应用", 5 | "main": "server.js", 6 | "scripts": { 7 | "start": "node server.js", 8 | "dev": "live-server --port=3000 --no-browser" 9 | }, 10 | "dependencies": { 11 | "axios": "^1.6.7", 12 | "cors": "^2.8.5", 13 | "express": "^4.18.2" 14 | }, 15 | "devDependencies": { 16 | "live-server": "^1.2.2" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /sample/prompt-cards.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "title": "规范表述", 4 | "prompt": "以下是一段文字,请你修改它的表述,使其能够满足现代汉语规范的需求:```{{text}}```" 5 | }, 6 | { 7 | "title": "衔接", 8 | "prompt": "以下有两段文字,我想依次把它们衔接在一起,但直接衔接太突兀了。请你编写第三段文字,可以插在两段文字之间,让表达顺畅:\n第一段文字:

{{p1}}

。\n第二段文字:

{{p2}}

" 9 | }, 10 | { 11 | "title": "稿件整体化", 12 | "prompt": "以下写得太细碎了。请你改写这段文字,使其整体性强一些。你不必遵循原文字的结构,可以根据它的内容,重新提炼大纲后再重写,要求情感真挚、用词标准:```{{text}}```" 13 | } 14 | ] -------------------------------------------------------------------------------- /sample/端午的鸭蛋.md: -------------------------------------------------------------------------------- 1 | # 端午的鸭蛋 2 | 3 | 家乡的端午,很多风俗和外地一样。系百索子。五色的丝线拧成小绳,系在手腕上。丝线是掉色的,洗脸时沾了水,手腕上就印得红一道绿一道的。做香角子。丝丝缠成小粽子,里头装了香面,一个一个串起来,挂在帐钩上。贴五毒。红纸剪成五毒,贴在门槛上。贴符。这符是城隍庙送来的。城隍庙的老道士还是我的寄名干爹,他每年端午节前就派小道士送符来,还有两把小纸扇。符送来了,就贴在堂屋的门楣上。一尺来长的黄色、蓝色的纸条,上面用朱笔画些莫名其妙的道道,这就能辟邪么?喝雄黄酒。用酒和的雄黄在孩子的额头上画一个王字,这是很多地方都有的。有一个风俗不知别处有不:放黄烟子。黄烟子是大小如北方的麻雷子的炮仗,只是里面灌的不是硝药,而是雄黄。点着后不响,只是冒出一股黄烟,能冒好一会。把点着的黄烟子丢在橱柜下面,说是可以熏五毒。小孩子点了黄烟子,常把它的一头抵在板壁上写虎字。写黄烟虎字笔画不能断,所以我们那里的孩子都会写草书的“一笔虎”。还有一个风俗,是端午节的午饭要吃“十二红”,就是十二道红颜色的菜。十二红里我只记得有炒红苋菜、油爆虾、咸鸭蛋,其余的都记不清,数不出了。也许十二红只是一个名目,不一定真凑足十二样。不过午饭的菜都是红的,这一点是我没有记错的,而且,苋菜、虾、鸭蛋,一定是有的。这三样,在我的家乡,都不贵,多数人家是吃得起的。 4 | 5 | 我的家乡是水乡。出鸭。高邮大麻鸭是著名的鸭种。鸭多,鸭蛋也多。高邮人也善于腌鸭蛋。高邮咸鸭蛋于是出了名。我在苏南、浙江,每逢有人问起我的籍贯,回答之后,对方就会肃然起敬:“哦!你们那里出咸鸭蛋!”上海的卖腌腊的店铺里也卖咸鸭蛋,必用纸条特别标明:“高邮咸蛋”。高邮还出双黄鸭蛋。别处鸭蛋也偶有双黄的,但不如高邮的多,可以成批输出。双黄鸭蛋味道其实无特别处。还不就是个鸭蛋!只是切开之后,里面圆圆的两个黄,使人惊奇不已。我对异乡人称道高邮鸭蛋,是不大高兴的,好像我们那穷地方就出鸭蛋似的!不过高邮的咸鸭蛋,确实是好,我走的地方不少,所食鸭蛋多矣,但和我家乡的完全不能相比!曾经沧海难为水,他乡咸鸭蛋,我实在瞧不上。袁枚0的《随园食单·小菜单》有“腌蛋”一条。袁子才这个人我不喜欢,他的《食单》好些菜的做法是听来的,他自己并不会做菜。但是《腌蛋》这一条我看后却觉得很亲切,而且“与有荣焉”。文不长,录如下: 6 | 7 | *腌蛋以高邮为佳,颜色细而油多,高文端公最喜食之。席间,先夹取以敬客,放盘中。总宜切开带壳,黄白兼用;不可存黄去白,使味不全,油亦走散。* 8 | 9 | 高邮咸蛋的特点是质细而油多。蛋白柔嫩,不似别处的发干、发粉,入口如嚼石灰。油多尤为别处所不及。鸭蛋的吃法,如袁子才所说,带壳切开,是一种,那是席间待客的办法。平常食用,一般都是敲破“空头”用筷子挖着吃。筷子头一扎下去,吱——红油就冒出来了。高邮咸蛋的黄是通红的。苏北有一道名菜,叫做“朱砂豆腐”,就是用高邮鸭蛋黄炒的豆腐。我在北京吃的咸鸭蛋,蛋黄是浅黄色的,这叫什么咸鸭蛋呢! 10 | 11 | 端午节,我们那里的孩子兴挂“鸭蛋络子”。头一天,就由姑姑或姐姐用彩色丝线打好了络子。端午一早,鸭蛋煮熟了,由孩子自己去挑一个,鸭蛋有什么可挑的呢?有!一要挑淡青壳的。鸭蛋壳有白的和淡青的两种。二要挑形状好看的。别说鸭蛋都是一样的,细看却不同。有的样子蠢,有的秀气。挑好了,装在络子里,挂在大襟的纽扣上。这有什么好看呢?然而它是孩子心爱的饰物。鸭蛋络子挂了多半天,什么时候孩子一高兴,就把络子里的鸭蛋掏出来,吃了。端午的鸭蛋,新腌不久,只有一点淡淡的咸味,白嘴吃也可以。 12 | 13 | 孩子吃鸭蛋是很小心的。除了敲去空头,不把蛋壳碰破。蛋黄蛋白吃光了,用清水把鸭蛋壳里面洗净,晚上捉了萤火虫来,装在蛋壳里,空头的地方糊一层薄罗。萤火虫在鸭蛋壳里一闪一闪地亮,好看极了! 14 | 15 | 小时读囊萤映雪故事,觉得东晋的车胤用练囊盛了几十只萤火虫,照了读书,还不如用鸭蛋壳来装萤火虫。不过用萤火虫照亮来读书,而且一夜读到天亮,这能行么?车胤读的是手写的卷子,字大,若是读现在的新五号字,大概是不行的。 -------------------------------------------------------------------------------- /script.js: -------------------------------------------------------------------------------- 1 | import { showAlert, showConfirm } from './js/customDialogs.js'; 2 | import { PromptCardManager } from './js/promptCard.js'; 3 | import { MarkdownHandler } from './js/markdownHandler.js'; 4 | import { ConnectionManager } from './js/connectionManager.js'; 5 | import { CONFIG } from './config.js'; 6 | import { initializeCardManagement } from './js/promptCard.js'; 7 | 8 | 9 | // Ollama配置 10 | const OLLAMA_BASE_URL = 'http://localhost:11434'; //可在此处修改端口 11 | 12 | // 模型配置 13 | const MODEL_CONFIG = { 14 | DEEPSEEK: { 15 | BASE_URL: 'https://api.deepseek.com/v1', 16 | MODELS: { 17 | V3: 'deepseek-chat', 18 | R1: 'deepseek-reasoner' 19 | } 20 | } 21 | }; 22 | 23 | // 检测是否为开发模式 24 | const isDevelopment = window.location.hostname === '127.0.0.1'; 25 | 26 | // 更新配置 27 | const API_CONFIG = { 28 | TONGYI_API_KEY: CONFIG.TONGYI_API_KEY, 29 | API_URL: isDevelopment ? null : 'http://localhost:3000/api/chat', 30 | DEEPSEEK_API_KEY: CONFIG.DEEPSEEK_API_KEY, 31 | CUSTOM_MODEL: CONFIG.CUSTOM_MODEL, 32 | SYSTEM_MESSAGE: CONFIG.SYSTEM_MESSAGE 33 | }; 34 | 35 | // DOM 元素 36 | const promptCards = document.querySelectorAll('.prompt-card'); 37 | const submitButton = document.getElementById('submit-prompt'); 38 | const promptOutput = document.getElementById('prompt-output'); 39 | const cardContainer = document.querySelector('.prompt-cards'); 40 | const paragraphContainer = document.getElementById('paragraph-cards'); 41 | 42 | // 初始化管理器 43 | const cardManager = new PromptCardManager(cardContainer); 44 | const markdownHandler = new MarkdownHandler(paragraphContainer); 45 | const connectionManager = new ConnectionManager(); 46 | 47 | // 将管理器暴露到全局,供其他模块使用 48 | window.cardManager = cardManager; 49 | window.connectionManager = connectionManager; 50 | 51 | // 监听窗口大小变化和滚动,更新连接线 52 | window.addEventListener('resize', () => connectionManager.updateConnections()); 53 | window.addEventListener('scroll', () => connectionManager.updateConnections()); 54 | 55 | // 设置拖拽功能 56 | // 拖拽开始时记录内容 57 | promptOutput.addEventListener('dragstart', (e) => { 58 | const content = promptOutput.textContent.trim(); 59 | if (content && content !== '等待第一次提交...' && content !== 'AI思考中...') { 60 | e.dataTransfer.setData('text/plain', content); 61 | // 添加拖拽效果 62 | promptOutput.style.opacity = '0.5'; 63 | } else { 64 | e.preventDefault(); 65 | } 66 | }); 67 | 68 | // 拖拽结束时恢复样式 69 | promptOutput.addEventListener('dragend', () => { 70 | promptOutput.style.opacity = '1'; 71 | }); 72 | 73 | // 允许放置 74 | paragraphContainer.addEventListener('dragover', (e) => { 75 | e.preventDefault(); 76 | e.dataTransfer.dropEffect = 'copy'; 77 | }); 78 | 79 | // 处理放置事件 80 | paragraphContainer.addEventListener('drop', (e) => { 81 | e.preventDefault(); 82 | const content = e.dataTransfer.getData('text/plain'); 83 | if (content) { 84 | // 计算放置位置,考虑滚动偏移 85 | const rect = paragraphContainer.getBoundingClientRect(); 86 | const scrollTop = paragraphContainer.scrollTop; // 获取容器的垂直滚动位置 87 | const x = e.clientX - rect.left; 88 | const y = e.clientY - rect.top + scrollTop; // 加上滚动偏移 89 | 90 | // 创建新卡片 91 | const card = markdownHandler.createCard(content); 92 | card.style.left = `${x - 150}px`; // 卡片宽度的一半 93 | card.style.top = `${y - 75}px`; // 卡片高度的一半 94 | } 95 | }); 96 | 97 | // 添加默认卡片 98 | async function addDefaultCards() { 99 | // 添加第一个卡片 100 | const card1 = cardManager.addCard( 101 | '规范表述', 102 | '以下是一段文字,请你修改它的表述,使其能够满足现代汉语规范的需求:```{{text}}```' 103 | ); 104 | // console.log('Added card 1:', card1.id); 105 | 106 | // 等待一毫秒以确保时间戳不同 107 | await new Promise(resolve => setTimeout(resolve, 1)); 108 | 109 | // 添加第二个卡片 110 | const card2 = cardManager.addCard( 111 | '衔接', 112 | '以下有两段文字,我想依次把它们衔接在一起,但直接衔接太突兀了。请你编写第三段文字,可以插在两段文字之间,让表达顺畅:\n第一段文字:

{{p1}}

。\n第二段文字:

{{p2}}

' 113 | ); 114 | // console.log('Added card 2:', card2.id); 115 | 116 | await new Promise(resolve => setTimeout(resolve, 1)); 117 | 118 | // 添加第三个卡片 119 | const card3 = cardManager.addCard( 120 | '稿件整体化', 121 | '以下写得太细碎了。请你改写这段文字,使其整体性强一些。你不必遵循原文字的结构,可以根据它的内容,重新提炼大纲后再重写,要求情感真挚、用词标准:```{{text}}```' 122 | ); 123 | // console.log('Added card 3:', card3.id); 124 | } 125 | 126 | // 添加默认文本卡片 127 | function addDefaultTextCard() { 128 | const defaultText = `欢迎使用AI写作助手!想要流畅地使用,你只需要记住一个规则:插头插在插座上。这是一个示例文本卡片。试试导入《端午的鸭蛋》,或者点击右下角的 + 添加新卡片开始写作吧!`; 129 | 130 | const card = markdownHandler.createCard(defaultText); 131 | card.style.left = '10px'; 132 | card.style.top = '10px'; 133 | 134 | // 添加第二个示例卡片 135 | const anotherCardText = `这是另一个示例卡片,你可以拖动、缩放、连接它们。`; 136 | const anotherCard = markdownHandler.createCard(anotherCardText); 137 | anotherCard.style.left = '10px'; 138 | anotherCard.style.top = '170px'; // 在第一个卡片下方 139 | } 140 | 141 | // 在页面加载完成后添加默认卡片 142 | document.addEventListener('DOMContentLoaded', () => { 143 | addDefaultCards(); // 添加默认提示词卡片 144 | addDefaultTextCard(); // 添加默认文本卡片 145 | }); 146 | 147 | // 监听卡片选择 148 | cardManager.onCardSelected = (card) => { 149 | submitButton.disabled = !card; 150 | if (card) { 151 | document.querySelectorAll('.prompt-card').forEach(element => { 152 | element.classList.remove('selected'); 153 | if (element.id === card.id) { 154 | element.classList.add('selected'); 155 | } 156 | }); 157 | } 158 | }; 159 | 160 | // 添加新卡片按钮 161 | document.getElementById('add-card').addEventListener('click', () => { 162 | cardManager.showEditDialog(null); 163 | }); 164 | 165 | // 修改提示词提交处理 166 | submitButton.addEventListener('click', async () => { 167 | const selectedCard = cardManager.selectedCard; 168 | if (!selectedCard) return; 169 | 170 | try { 171 | // 获取替换了占位符的提示词 172 | const prompt = selectedCard.getPromptWithConnections(); 173 | const modelInfo = window.getCurrentModel(); 174 | 175 | // 获取实际使用的模型名称 176 | let actualModel = ''; 177 | if (modelInfo.model === 'tongyi') { 178 | actualModel = 'qwen-turbo'; 179 | } else if (modelInfo.model === 'deepseek-v3') { 180 | actualModel = MODEL_CONFIG.DEEPSEEK.MODELS.V3; 181 | } else if (modelInfo.model === 'deepseek-r1') { 182 | actualModel = MODEL_CONFIG.DEEPSEEK.MODELS.R1; 183 | } else if (modelInfo.model === 'custom' && API_CONFIG.CUSTOM_MODEL) { 184 | actualModel = API_CONFIG.CUSTOM_MODEL.MODEL; 185 | } 186 | 187 | console.log('提示词:', prompt); 188 | 189 | promptOutput.textContent = 'AI思考中...'; 190 | submitButton.disabled = true; 191 | 192 | const response = await callAIAPI(prompt, modelInfo.model); 193 | promptOutput.textContent = response; 194 | } catch (error) { 195 | promptOutput.textContent = `错误:${error.message}`; 196 | } finally { 197 | submitButton.disabled = false; 198 | } 199 | }); 200 | 201 | // 初始化 Markdown 处理器 202 | markdownHandler.init(); 203 | 204 | // 创建隐藏的文件输入框 205 | const fileInput = document.createElement('input'); 206 | fileInput.type = 'file'; 207 | fileInput.accept = '.md'; 208 | fileInput.style.display = 'none'; 209 | document.body.appendChild(fileInput); 210 | 211 | // 处理文件导入 212 | fileInput.addEventListener('change', async (e) => { 213 | const file = e.target.files[0]; 214 | if (file) { 215 | await markdownHandler.handleFileImport(file); 216 | // 重置文件输入框的值,这样可以重复导入相同的文件 217 | fileInput.value = ''; 218 | } 219 | }); 220 | 221 | // 触发文件选择 222 | document.getElementById('import-button').addEventListener('click', () => { 223 | fileInput.click(); 224 | }); 225 | 226 | // 导出Markdown文件 227 | document.getElementById('export-button').addEventListener('click', () => { 228 | const cards = Array.from(document.querySelectorAll('.paragraph-card')); 229 | 230 | // 按y坐标排序,y相同时按x坐标排序 231 | cards.sort((a, b) => { 232 | const aY = parseInt(a.style.top); 233 | const bY = parseInt(b.style.top); 234 | if (aY === bY) { 235 | const aX = parseInt(a.style.left); 236 | const bX = parseInt(b.style.left); 237 | return aX - bX; 238 | } 239 | return aY - bY; 240 | }); 241 | 242 | // 提取文本内容并用双换行符连接 243 | const content = cards 244 | .map(card => card.querySelector('.card-content').textContent.trim()) 245 | .filter(text => text) // 过滤掉空文本 246 | .join('\n\n'); 247 | 248 | // 创建Blob对象 249 | const blob = new Blob([content], { type: 'text/markdown' }); 250 | 251 | // 创建下载链接 252 | const downloadLink = document.createElement('a'); 253 | downloadLink.href = URL.createObjectURL(blob); 254 | downloadLink.download = 'exported_document.md'; 255 | 256 | // 触发下载 257 | document.body.appendChild(downloadLink); 258 | downloadLink.click(); 259 | document.body.removeChild(downloadLink); 260 | 261 | // 清理URL对象 262 | URL.revokeObjectURL(downloadLink.href); 263 | }); 264 | 265 | // 添加删除所有段落的功能 266 | document.getElementById('clear-paragraphs').addEventListener('click', () => { 267 | showConfirm('确定要删除所有段落卡片吗?此操作不可撤销。').then((confirmed) => { 268 | if (confirmed) { 269 | // 清空所有段落卡片 270 | paragraphContainer.innerHTML = ''; 271 | 272 | // 清除所有连接 273 | if (window.connectionManager) { 274 | window.connectionManager.clearAllConnections(); 275 | } 276 | 277 | // 重置导入计数器 278 | markdownHandler.importCount = 0; 279 | } 280 | }); 281 | }); 282 | 283 | // 模拟API调用 284 | async function mockApiCall(message, model) { 285 | // 模拟网络延迟 286 | await new Promise(resolve => setTimeout(resolve, 500)); 287 | return `[本地模式] 当前使用的是模拟数据。如需调用在线API,请切换到在线API模式,或使用本地的 Ollama 模型。`; 288 | } 289 | 290 | // 显示自定义模型配置对话框 291 | function showCustomModelDialog() { 292 | const dialog = document.createElement('div'); 293 | dialog.className = 'custom-model-dialog'; 294 | dialog.innerHTML = ` 295 |
296 |

配置自定义模型

297 |
298 | 299 | 300 |
API 服务器的基础 URL
301 |
302 |
303 | 304 | 305 |
用于认证的 API 密钥
306 |
307 |
308 | 309 | 310 |
要使用的模型标识符
311 |
312 |
313 | 314 | 315 |
316 |
317 | `; 318 | 319 | // 保存按钮事件 320 | dialog.querySelector('.save-btn').addEventListener('click', () => { 321 | const baseUrl = dialog.querySelector('#base-url').value.trim(); 322 | const apiKey = dialog.querySelector('#api-key').value.trim(); 323 | const model = dialog.querySelector('#model-name').value.trim(); 324 | 325 | if (!baseUrl || !apiKey || !model) return showAlert('请填写所有必要信息'); 326 | 327 | // 更新配置 328 | API_CONFIG.CUSTOM_MODEL = { 329 | BASE_URL: baseUrl, 330 | API_KEY: apiKey, 331 | MODEL: model 332 | }; 333 | 334 | // 提示用户保存配置到 config.js 335 | const configText = 336 | `请将以下配置复制到你的 config.js 文件中的 CUSTOM_MODEL 部分: 337 | 338 | CUSTOM_MODEL: { 339 | BASE_URL: '${baseUrl}', 340 | API_KEY: '${apiKey}', 341 | MODEL: '${model}' 342 | },`; 343 | 344 | console.log('新的自定义模型配置:'); 345 | console.log(configText); 346 | showAlert('配置已更新!请记得将新的配置保存到 config.js 文件中。\n配置信息已输出到控制台,你可以直接复制使用。').then(() => { 347 | dialog.remove(); 348 | }); 349 | }); 350 | 351 | // 取消按钮事件 352 | dialog.querySelector('.cancel-btn').addEventListener('click', () => { 353 | dialog.remove(); 354 | if (!API_CONFIG.CUSTOM_MODEL?.BASE_URL) { 355 | // 如果没有配置,回到默认模型 356 | const defaultOption = document.querySelector('.model-option[data-model="tongyi"]'); 357 | defaultOption.click(); 358 | } 359 | }); 360 | 361 | document.body.appendChild(dialog); 362 | } 363 | 364 | // 显示 Ollama 配置对话框 365 | async function showOllamaDialog() { 366 | const dialog = document.createElement('div'); 367 | dialog.className = 'ollama-dialog'; 368 | dialog.innerHTML = ` 369 |
370 |

配置本地模型

371 |
372 | 请确保:
373 | 1. 已经安装 Ollama;
374 | 2. 已经安装本地模型;
375 | 3. Ollama 处于启动服务状态。 376 |
377 |
378 | 379 |
380 |
正在获取可用模型列表...
381 |
382 |
383 |
384 | 385 | 386 |
387 |
388 | `; 389 | 390 | document.body.appendChild(dialog); 391 | 392 | // 获取可用模型列表 393 | try { 394 | // 首先检查 Ollama 服务是否在运行 395 | const healthCheck = await fetch(OLLAMA_BASE_URL); 396 | if (!healthCheck.ok) { 397 | throw new Error('无法连接到 Ollama 服务'); 398 | } 399 | 400 | // 获取已安装的模型列表 401 | const response = await fetch(`${OLLAMA_BASE_URL}/api/tags`); 402 | if (!response.ok) { 403 | throw new Error('无法获取模型列表'); 404 | } 405 | 406 | const data = await response.json(); 407 | const modelList = dialog.querySelector('.model-list'); 408 | const confirmBtn = dialog.querySelector('.confirm-btn'); 409 | 410 | // 检查是否有模型 411 | if (data.models && data.models.length > 0) { 412 | modelList.innerHTML = data.models.map(model => { 413 | // 获取模型大小(转换为GB) 414 | const sizeInGB = (model.size / 1024 / 1024 / 1024).toFixed(2); 415 | // 获取参数大小 416 | const paramSize = model.details?.parameter_size || '未知'; 417 | // 获取量化级别 418 | const quantLevel = model.details?.quantization_level || '未知'; 419 | 420 | return ` 421 |
422 | 423 | 431 |
432 | `; 433 | }).join(''); 434 | 435 | // 启用确定按钮 436 | confirmBtn.disabled = false; 437 | 438 | // 添加选择事件 439 | modelList.addEventListener('change', (e) => { 440 | if (e.target.type === 'radio') { 441 | window.ollamaModel = e.target.value; 442 | } 443 | }); 444 | } else { 445 | modelList.innerHTML = ` 446 |
未安装任何模型
`; 447 | } 448 | } catch (error) { 449 | dialog.querySelector('.model-list').innerHTML = ` 450 |
未连接到 Ollama
451 | `; 452 | } 453 | 454 | // 取消按钮事件 455 | dialog.querySelector('.cancel-btn').addEventListener('click', () => { 456 | dialog.remove(); 457 | // 如果没有配置,回到默认模型 458 | const defaultOption = document.querySelector('.model-option[data-model="tongyi"]'); 459 | defaultOption.click(); 460 | }); 461 | 462 | // 确定按钮事件 463 | dialog.querySelector('.confirm-btn').addEventListener('click', () => { 464 | if (!window.ollamaModel) return showAlert('请选择一个模型'); 465 | dialog.remove(); 466 | }); 467 | } 468 | 469 | // 修改 initializeModelSelector 函数 470 | function initializeModelSelector() { 471 | const modelSelector = document.getElementById('model-selector'); 472 | const modelDropdown = document.querySelector('.model-dropdown'); 473 | const modelOptions = document.querySelectorAll('.model-option'); 474 | 475 | // 设置默认模型为通义千问 476 | let currentModel = 'tongyi'; 477 | 478 | // 设置初始选中状态 479 | modelOptions.forEach(opt => { 480 | if (opt.dataset.model === 'tongyi') { 481 | opt.classList.add('selected'); 482 | } else { 483 | opt.classList.remove('selected'); 484 | } 485 | }); 486 | 487 | // 切换下拉菜单 488 | modelSelector.addEventListener('click', (e) => { 489 | e.stopPropagation(); 490 | modelSelector.classList.toggle('active'); 491 | modelDropdown.classList.toggle('show'); 492 | }); 493 | 494 | // 选择模型 495 | modelOptions.forEach(option => { 496 | option.addEventListener('click', (e) => { 497 | e.stopPropagation(); 498 | const model = option.dataset.model; 499 | 500 | if (model === 'custom') { 501 | showCustomModelDialog(); 502 | } else if (model === 'ollama') { 503 | showOllamaDialog(); 504 | } 505 | 506 | // 更新选中状态 507 | modelOptions.forEach(opt => opt.classList.remove('selected')); 508 | option.classList.add('selected'); 509 | 510 | currentModel = model; 511 | modelDropdown.classList.remove('show'); 512 | modelSelector.classList.remove('active'); 513 | }); 514 | }); 515 | 516 | // 点击其他地方关闭下拉菜单 517 | document.addEventListener('click', () => { 518 | modelDropdown.classList.remove('show'); 519 | modelSelector.classList.remove('active'); 520 | }); 521 | 522 | // 获取当前选中的模型和配置 523 | window.getCurrentModel = () => ({ 524 | model: currentModel, 525 | config: currentModel === 'custom' ? API_CONFIG.CUSTOM_MODEL : null, 526 | ollamaModel: currentModel === 'ollama' ? window.ollamaModel : null 527 | }); 528 | } 529 | 530 | // 修改 callAIAPI 函数 531 | async function callAIAPI(message, model) { 532 | const modelInfo = window.getCurrentModel(); 533 | 534 | // Ollama 模式,不受开发模式影响 535 | if (modelInfo.model === 'ollama') { 536 | if (!window.ollamaModel) { 537 | throw new Error('未选择 Ollama 模型'); 538 | } 539 | 540 | try { 541 | const response = await fetch(`${OLLAMA_BASE_URL}/api/generate`, { 542 | method: 'POST', 543 | headers: { 544 | 'Content-Type': 'application/json' 545 | }, 546 | body: JSON.stringify({ 547 | model: window.ollamaModel, 548 | prompt: message, 549 | system: API_CONFIG.SYSTEM_MESSAGE.content, 550 | stream: true, // 启用流式传输 551 | }) 552 | }); 553 | 554 | if (!response.ok) { 555 | const errorData = await response.json(); 556 | console.error('Ollama API 错误:', errorData); 557 | throw new Error(`Ollama API调用失败: ${errorData.error || '未知错误'}`); 558 | } 559 | 560 | // 处理流式响应 561 | const reader = response.body.getReader(); 562 | const decoder = new TextDecoder('utf-8'); 563 | let result = ''; 564 | promptOutput.textContent = ''; 565 | const processStream = async () => { 566 | while (true) { 567 | const { done, value } = await reader.read(); 568 | if (done) break; 569 | try{ 570 | const chunk = JSON.parse(decoder.decode(value, { stream: true })); 571 | if(chunk.response) { 572 | result += chunk.response; 573 | promptOutput.textContent += chunk.response; 574 | promptOutput.scrollTop = promptOutput.scrollHeight; 575 | } 576 | } catch { } 577 | } 578 | return result; 579 | }; 580 | return processStream(); 581 | } catch (error) { 582 | if (error.message.includes('Failed to fetch')) { 583 | throw new Error('无法连接到 Ollama 服务。请确保:\n1. Ollama 已安装\n2. 已运行 `ollama serve`\n3. 选择的模型已经下载并运行'); 584 | } 585 | throw error; 586 | } 587 | } 588 | 589 | // 在本地模式下,除了 Ollama 外的其他模型都使用模拟数据 590 | if (isDevelopment) { 591 | console.log('本地模式'); 592 | return `[本地模式] 当前仅支持本地的 Ollama 模型。如需调用在线API,请切换到在线API模式,即访问 http://localhost:3000 而不是 http://127.0.0.1:3000`; 593 | } 594 | 595 | // 其他模型的处理逻辑保持不变 596 | if (modelInfo.model === 'custom') { 597 | if (!API_CONFIG.CUSTOM_MODEL?.BASE_URL) { 598 | throw new Error('自定义模型未配置'); 599 | } 600 | 601 | try { 602 | const response = await fetch(`${API_CONFIG.CUSTOM_MODEL.BASE_URL}/chat/completions`, { 603 | method: 'POST', 604 | headers: { 605 | 'Authorization': `Bearer ${API_CONFIG.CUSTOM_MODEL.API_KEY}`, 606 | 'Content-Type': 'application/json' 607 | }, 608 | body: JSON.stringify({ 609 | model: API_CONFIG.CUSTOM_MODEL.MODEL, 610 | messages: [ 611 | API_CONFIG.SYSTEM_MESSAGE, 612 | { 613 | role: 'user', 614 | content: message 615 | } 616 | ], 617 | stream: true, 618 | }) 619 | }); 620 | 621 | if (!response.ok) { 622 | const errorData = await response.json(); 623 | console.error('自定义模型 API 错误:', errorData); 624 | throw new Error(`API调用失败: ${errorData.error?.message || '未知错误'}`); 625 | } 626 | 627 | // 处理流式响应 628 | const reader = response.body.getReader(); 629 | const decoder = new TextDecoder('utf-8'); 630 | let result = ''; 631 | promptOutput.textContent = ''; 632 | const processStream = async () => { 633 | while (true) { 634 | const { done, value } = await reader.read(); 635 | if (done) break; 636 | try { 637 | const chunk = decoder.decode(value, { stream: true }); 638 | const lines = chunk.split('\n').filter(line => line.trim() !== ''); 639 | for (const line of lines) { 640 | if (line.startsWith("data: ")) { 641 | const data = line.slice(6).trim(); 642 | if (data === "[DONE]") return result; 643 | try { 644 | const parsedData = JSON.parse(data); 645 | const content = parsedData.choices[0]?.delta?.content || ''; 646 | result += content; 647 | promptOutput.textContent += content; 648 | promptOutput.scrollTop = promptOutput.scrollHeight; 649 | } catch { } 650 | } 651 | } 652 | } catch { } 653 | } 654 | }; 655 | return processStream(); 656 | } catch (error) { 657 | throw error; 658 | } 659 | } else if (modelInfo.model === 'tongyi') { 660 | try { 661 | const response = await fetch("https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions", { 662 | method: 'POST', 663 | headers: { 664 | 'Authorization': API_CONFIG.TONGYI_API_KEY, 665 | 'Content-Type': 'application/json' 666 | }, 667 | body: JSON.stringify({ 668 | model: 'qwen-plus', 669 | messages: [ 670 | API_CONFIG.SYSTEM_MESSAGE, 671 | { 672 | role: 'user', 673 | content: message 674 | } 675 | ], 676 | stream: true, 677 | stream_options:{ 678 | include_usage: true 679 | } 680 | }) 681 | }); 682 | 683 | if (!response.ok) { 684 | const errorData = await response.json(); 685 | console.error('通义千问 API 错误:', errorData); 686 | throw new Error(`API调用失败: ${errorData.message || '未知错误'}`); 687 | } 688 | 689 | // 处理流式响应 690 | const reader = response.body.getReader(); 691 | const decoder = new TextDecoder('utf-8'); 692 | let result = ''; 693 | promptOutput.textContent = ''; 694 | const processStream = async () => { 695 | while (true) { 696 | const { done, value } = await reader.read(); 697 | if (done) break; 698 | try { 699 | const chunk = decoder.decode(value, { stream: true }); 700 | const lines = chunk.split('\n').filter(line => line.trim() !== ''); 701 | for (const line of lines) { 702 | if (line.startsWith("data: ")) { 703 | const data = line.slice(6).trim(); 704 | if (data === "[DONE]") return result; 705 | try { 706 | const parsedData = JSON.parse(data); 707 | const content = parsedData.choices?.[0]?.delta?.content || ''; 708 | result += content; 709 | promptOutput.textContent += content; 710 | promptOutput.scrollTop = promptOutput.scrollHeight; 711 | } catch { } 712 | } 713 | } 714 | } catch { } 715 | } 716 | }; 717 | return processStream(); 718 | } catch (error) { 719 | throw error; 720 | } 721 | } else if (modelInfo.model === 'deepseek-v3' || modelInfo.model === 'deepseek-r1') { 722 | if (!API_CONFIG.DEEPSEEK_API_KEY) { 723 | throw new Error('DeepSeek API 密钥未配置'); 724 | } 725 | 726 | const modelName = modelInfo.model === 'deepseek-v3' ? 727 | MODEL_CONFIG.DEEPSEEK.MODELS.V3 : 728 | MODEL_CONFIG.DEEPSEEK.MODELS.R1; 729 | 730 | try { 731 | const response = await fetch(`${MODEL_CONFIG.DEEPSEEK.BASE_URL}/chat/completions`, { 732 | method: 'POST', 733 | headers: { 734 | 'Authorization': `Bearer ${API_CONFIG.DEEPSEEK_API_KEY}`, 735 | 'Content-Type': 'application/json' 736 | }, 737 | body: JSON.stringify({ 738 | model: modelName, 739 | messages: [ 740 | { 741 | role: 'system', 742 | content: API_CONFIG.SYSTEM_MESSAGE.content 743 | }, 744 | { 745 | role: 'user', 746 | content: message 747 | } 748 | ], 749 | stream: true, 750 | stream_options:{ 751 | include_usage: true 752 | } 753 | }) 754 | }); 755 | 756 | if (!response.ok) { 757 | const errorData = await response.json(); 758 | console.error('DeepSeek API 错误:', errorData); 759 | throw new Error(`API调用失败: ${errorData.error?.message || '未知错误'}`); 760 | } 761 | 762 | // 处理流式响应 763 | const reader = response.body.getReader(); 764 | const decoder = new TextDecoder('utf-8'); 765 | let result = ''; 766 | promptOutput.textContent = ''; 767 | const processStream = async () => { 768 | while (true) { 769 | const { done, value } = await reader.read(); 770 | if (done) break; 771 | try { 772 | const chunk = decoder.decode(value, { stream: true }); 773 | const lines = chunk.split('\n').filter(line => line.trim() !== ''); 774 | for (const line of lines) { 775 | if (line.startsWith("data: ")) { 776 | const data = line.slice(6).trim(); 777 | console.log(data); 778 | if (data === "[DONE]") return result; 779 | try { 780 | const parsedData = JSON.parse(data); 781 | // const content = parsedData.choices?.[0]?.delta?.content || ''; // 换成这个可以去掉思维链 782 | const content = parsedData.choices?.[0]?.delta?.content || parsedData.choices?.[0]?.delta?.reasoning_content || ''; 783 | result += content; 784 | promptOutput.textContent += content; 785 | promptOutput.scrollTop = promptOutput.scrollHeight; 786 | } catch { } 787 | } 788 | } 789 | } catch { } 790 | } 791 | }; 792 | return processStream(); 793 | } catch (error) { 794 | throw error; 795 | } 796 | } 797 | 798 | return mockApiCall(message, model); 799 | } 800 | 801 | document.addEventListener('DOMContentLoaded', () => { 802 | // 初始化卡片管理功能 803 | initializeCardManagement(); 804 | // 初始化模型选择器 805 | initializeModelSelector(); 806 | 807 | // ... existing initialization code ... 808 | }); 809 | 810 | // 修改 PromptCardManager 类中的 selectCard 函数 811 | function selectCard(cardId) { 812 | if (!cardId) return; 813 | 814 | // 如果点击的是当前已选中的卡片,则取消选中 815 | if (this.selectedCard?.id === cardId) { 816 | this.selectedCard = null; 817 | document.querySelectorAll('.prompt-card').forEach(card => { 818 | card.classList.remove('selected'); 819 | }); 820 | if (this.onCardSelected) { 821 | this.onCardSelected(null); 822 | } 823 | return; 824 | } 825 | 826 | const allCards = this.container.querySelectorAll('.prompt-card'); 827 | allCards.forEach(card => card.classList.remove('selected')); 828 | 829 | this.selectedCard = null; 830 | 831 | const cardElement = document.getElementById(cardId); 832 | if (cardElement) { 833 | const card = this.cards.get(cardId); 834 | if (card) { 835 | cardElement.classList.add('selected'); 836 | this.selectedCard = card; 837 | } 838 | } 839 | 840 | if (this.onCardSelected) { 841 | this.onCardSelected(this.selectedCard); 842 | } 843 | } -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const cors = require('cors'); 3 | const axios = require('axios'); 4 | const path = require('path'); 5 | 6 | const app = express(); 7 | const port = 3000; 8 | 9 | // 启用 CORS 10 | app.use(cors()); 11 | app.use(express.json()); 12 | // 服务静态文件 13 | app.use(express.static(path.join(__dirname, '.'))); 14 | 15 | // 代理 API 请求 16 | app.post('/api/chat', async (req, res) => { 17 | const model = req.body.model || 'qwen-turbo'; 18 | const apiKey = req.headers.authorization; 19 | 20 | if (!apiKey) { 21 | return res.status(401).json({ error: 'API Key is required' }); 22 | } 23 | 24 | try { 25 | let response; 26 | if (model === 'qwen-turbo') { 27 | response = await fetch('https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation', { 28 | method: 'POST', 29 | headers: { 30 | 'Authorization': apiKey, 31 | 'Content-Type': 'application/json' 32 | }, 33 | body: JSON.stringify(req.body) 34 | }); 35 | } else if (model === 'deepseek-chat' || model === 'deepseek-reasoner') { 36 | // DeepSeek API 转发 37 | response = await fetch('https://api.deepseek.com/v1/chat/completions', { 38 | method: 'POST', 39 | headers: { 40 | 'Authorization': `Bearer ${apiKey}`, 41 | 'Content-Type': 'application/json' 42 | }, 43 | body: JSON.stringify(req.body) 44 | }); 45 | } else { 46 | return res.status(400).json({ error: 'Unsupported model' }); 47 | } 48 | 49 | if (!response.ok) { 50 | const error = await response.json(); 51 | return res.status(response.status).json(error); 52 | } 53 | 54 | const data = await response.json(); 55 | res.json(data); 56 | } catch (error) { 57 | console.error('API request failed:', error); 58 | res.status(500).json({ error: 'Failed to process request' }); 59 | } 60 | }); 61 | 62 | app.listen(port, () => { 63 | console.log(`服务器运行在 http://localhost:${port}`); 64 | }); -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 设置颜色变量 4 | GREEN='\033[0;32m' 5 | BLUE='\033[0;34m' 6 | RED='\033[0;31m' 7 | NC='\033[0m' # No Color 8 | 9 | # 清屏 10 | clear 11 | 12 | # 检查node是否安装 13 | if ! command -v node &> /dev/null; then 14 | echo -e "${RED}错误:未检测到Node.js安装。${NC}" 15 | echo "请先安装Node.js后再运行此脚本。" 16 | echo "下载地址:https://nodejs.org/" 17 | exit 1 18 | fi 19 | 20 | echo -e "${BLUE}=== AI写作助手启动菜单 ===${NC}\n" 21 | echo -e "请选择启动模式:" 22 | echo -e "${GREEN}1${NC}. 完整模式 (支持在线API和本地模型)" 23 | echo -e "${GREEN}2${NC}. 本地模式 (支持Ollama本地大模型)" 24 | echo -e "${GREEN}3${NC}. 退出" 25 | 26 | read -p "请输入选项 (1-3): " choice 27 | 28 | case $choice in 29 | 1) 30 | echo -e "\n${BLUE}正在启动完整模式...${NC}" 31 | # 检查是否存在 config.js 32 | if [ ! -f "config.js" ]; then 33 | echo -e "\n${GREEN}提示:${NC}检测到没有 config.js 文件" 34 | read -p "是否要从 config.example.js 创建一个? (y/n): " create_config 35 | if [ "$create_config" = "y" ]; then 36 | cp config.example.js config.js 37 | echo "已创建 config.js,请记得配置你的API密钥!" 38 | echo "按任意键继续..." 39 | read -n 1 40 | fi 41 | fi 42 | 43 | # 检查依赖是否安装 44 | if [ ! -d "node_modules" ]; then 45 | echo -e "\n${GREEN}正在安装项目依赖...${NC}" 46 | npm install 47 | fi 48 | 49 | npm start 50 | ;; 51 | 2) 52 | echo -e "\n${BLUE}正在启动本地模式...${NC}" 53 | # 检查依赖是否安装 54 | if [ ! -d "node_modules" ]; then 55 | echo -e "\n${GREEN}正在安装项目依赖...${NC}" 56 | npm install 57 | fi 58 | 59 | npm run dev 60 | ;; 61 | 3) 62 | echo -e "\n${BLUE}再见!${NC}" 63 | exit 0 64 | ;; 65 | *) 66 | echo -e "\n${GREEN}无效的选项,请重新运行脚本。${NC}" 67 | exit 1 68 | ;; 69 | esac -------------------------------------------------------------------------------- /styles/base.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /* 颜色系统 */ 3 | --color-primary: #007aff; 4 | --color-primary-dark: #0066d6; 5 | --color-danger: #ff3b30; 6 | --color-warning: #F5B400; 7 | --color-success: #00b894; 8 | --color-purple: #9B6DFF; 9 | --color-orange: #F5A623; 10 | 11 | /* 中性色 */ 12 | --color-text-primary: #1d1d1f; 13 | --color-text-secondary: #666; 14 | --color-border: #d2d2d7; 15 | --color-bg-primary: #fff; 16 | --color-bg-secondary: #f5f5f7; 17 | 18 | /* 间距系统 */ 19 | --spacing-xs: 4px; 20 | --spacing-sm: 8px; 21 | --spacing-md: 12px; 22 | --spacing-lg: 16px; 23 | --spacing-xl: 20px; 24 | --spacing-xxl: 24px; 25 | 26 | /* 圆角 */ 27 | --radius-sm: 4px; 28 | --radius-md: 6px; 29 | --radius-lg: 8px; 30 | --radius-xl: 12px; 31 | 32 | /* 阴影 */ 33 | --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.1); 34 | --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.15); 35 | --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.2); 36 | } 37 | 38 | /* 重置样式 */ 39 | * { 40 | margin: 0; 41 | padding: 0; 42 | box-sizing: border-box; 43 | } 44 | 45 | /* 基础样式 */ 46 | body { 47 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; 48 | background-color: var(--color-bg-secondary); 49 | color: var(--color-text-primary); 50 | } 51 | 52 | /* 通用滚动条样式 */ 53 | ::-webkit-scrollbar { 54 | width: var(--spacing-sm); 55 | } 56 | 57 | ::-webkit-scrollbar-track { 58 | background: transparent; 59 | } 60 | 61 | ::-webkit-scrollbar-thumb { 62 | background-color: var(--color-border); 63 | border-radius: var(--radius-sm); 64 | } 65 | 66 | ::-webkit-scrollbar-thumb:hover { 67 | background-color: #b0b0b5; 68 | } -------------------------------------------------------------------------------- /styles/components/buttons.css: -------------------------------------------------------------------------------- 1 | /* 基础按钮样式 */ 2 | .submit-button { 3 | padding: var(--spacing-md); 4 | background-color: var(--color-primary); 5 | color: var(--color-bg-primary); 6 | border: none; 7 | border-radius: var(--radius-lg); 8 | font-size: 14px; 9 | cursor: pointer; 10 | transition: background-color 0.2s; 11 | margin-bottom: var(--spacing-md); 12 | } 13 | 14 | .submit-button:hover:not(:disabled) { 15 | background-color: var(--color-primary-dark); 16 | } 17 | 18 | .submit-button:disabled { 19 | background-color: #ccc; 20 | cursor: not-allowed; 21 | } 22 | 23 | /* 图标按钮 */ 24 | .icon-button { 25 | width: 38px; 26 | height: 38px; 27 | padding: 0; 28 | background-color: var(--color-bg-secondary); 29 | border: 1px solid var(--color-border); 30 | border-radius: var(--radius-lg); 31 | cursor: pointer; 32 | color: var(--color-primary); 33 | display: flex; 34 | align-items: center; 35 | justify-content: center; 36 | transition: all 0.2s; 37 | } 38 | 39 | .icon-button:hover { 40 | background-color: #e8f2ff; 41 | border-color: var(--color-primary); 42 | } 43 | 44 | /* 添加卡片按钮 */ 45 | .add-card-button { 46 | flex: 1; 47 | height: 38px; 48 | padding: 0 10px; 49 | background-color: var(--color-bg-secondary); 50 | border: 1px dashed var(--color-border); 51 | border-radius: var(--radius-lg); 52 | cursor: pointer; 53 | text-align: center; 54 | color: var(--color-primary); 55 | font-size: 14px; 56 | transition: all 0.2s; 57 | display: flex; 58 | align-items: center; 59 | justify-content: center; 60 | } 61 | 62 | .add-card-button:hover { 63 | background-color: #e8f2ff; 64 | border-color: var(--color-primary); 65 | } 66 | 67 | /* 警告按钮 */ 68 | .warning-button { 69 | padding: var(--spacing-xs) var(--spacing-sm); 70 | background-color: var(--color-warning); 71 | color: var(--color-bg-primary); 72 | border: 1px solid var(--color-warning); 73 | border-radius: var(--radius-md); 74 | font-size: 13px; 75 | font-weight: 500; 76 | cursor: pointer; 77 | transition: all 0.2s; 78 | line-height: 1.4; 79 | white-space: nowrap; 80 | } 81 | 82 | .warning-button:hover { 83 | background-color: #E5A800; 84 | } 85 | 86 | /* 危险按钮 */ 87 | .danger-button { 88 | padding: var(--spacing-xs) var(--spacing-sm); 89 | background-color: var(--color-bg-primary); 90 | color: var(--color-danger); 91 | border: 1px solid var(--color-danger); 92 | border-radius: var(--radius-md); 93 | font-size: 13px; 94 | font-weight: 500; 95 | cursor: pointer; 96 | transition: all 0.2s; 97 | line-height: 1.4; 98 | white-space: nowrap; 99 | } 100 | 101 | .danger-button:hover { 102 | background-color: var(--color-danger); 103 | color: var(--color-bg-primary); 104 | } 105 | 106 | /* 圆形危险按钮 */ 107 | .circle-danger-button { 108 | width: 24px; 109 | height: 24px; 110 | padding: 0; 111 | background-color: var(--color-bg-primary); 112 | color: var(--color-danger); 113 | border: 1px solid var(--color-danger); 114 | border-radius: 50%; 115 | cursor: pointer; 116 | transition: all 0.2s; 117 | display: flex; 118 | align-items: center; 119 | justify-content: center; 120 | } 121 | 122 | .circle-danger-button:hover { 123 | background-color: var(--color-danger); 124 | color: var(--color-bg-primary); 125 | } 126 | 127 | .circle-danger-button svg { 128 | width: 14px; 129 | height: 14px; 130 | } 131 | 132 | /* 浮动按钮 */ 133 | .floating-button { 134 | width: 50px; 135 | height: 50px; 136 | border-radius: 25px; 137 | cursor: pointer; 138 | display: flex; 139 | align-items: center; 140 | justify-content: center; 141 | box-shadow: var(--shadow-sm); 142 | transition: all 0.2s; 143 | } 144 | 145 | .floating-button:hover { 146 | transform: scale(1.1); 147 | box-shadow: var(--shadow-md); 148 | } 149 | 150 | .floating-button svg { 151 | width: 24px; 152 | height: 24px; 153 | } 154 | 155 | .floating-button.circle-danger-button { 156 | background-color: var(--color-bg-primary); 157 | color: var(--color-danger); 158 | border: 1px solid var(--color-danger); 159 | } 160 | 161 | .floating-button.circle-danger-button:hover { 162 | background-color: var(--color-danger); 163 | color: var(--color-bg-primary); 164 | } 165 | 166 | .floating-button.add-paragraph-button { 167 | background-color: var(--color-primary); 168 | color: var(--color-bg-primary); 169 | border: none; 170 | } 171 | 172 | .floating-button.add-paragraph-button:hover { 173 | background-color: var(--color-primary-dark); 174 | } 175 | 176 | /* 导入导出按钮 */ 177 | #import-button, 178 | #export-button { 179 | padding: var(--spacing-sm) var(--spacing-lg); 180 | background-color: var(--color-primary); 181 | color: var(--color-bg-primary); 182 | border: none; 183 | border-radius: var(--radius-md); 184 | cursor: pointer; 185 | transition: background-color 0.2s; 186 | } 187 | 188 | #import-button:hover, 189 | #export-button:hover { 190 | background-color: var(--color-primary-dark); 191 | } 192 | 193 | /* 提交按钮组 */ 194 | .submit-button-group { 195 | position: relative; 196 | display: flex; 197 | margin-bottom: var(--spacing-md); 198 | } 199 | 200 | /* 修改提交按钮样式以适应新布局 */ 201 | .submit-button { 202 | flex: 1; 203 | margin: 0; 204 | border-top-right-radius: 0; 205 | border-bottom-right-radius: 0; 206 | } 207 | 208 | /* 模型选择器按钮 */ 209 | .model-selector-button { 210 | width: 32px; 211 | height: 100%; 212 | padding: 0; 213 | background-color: var(--color-primary); 214 | border: none; 215 | border-left: 1px solid rgba(255, 255, 255, 0.2); 216 | border-top-right-radius: var(--radius-lg); 217 | border-bottom-right-radius: var(--radius-lg); 218 | cursor: pointer; 219 | color: var(--color-bg-primary); 220 | display: flex; 221 | align-items: center; 222 | justify-content: center; 223 | transition: background-color 0.2s; 224 | } 225 | 226 | /* 跟随提交按钮的禁用状态 */ 227 | .submit-button:disabled + .model-selector-button { 228 | background-color: #ccc; 229 | cursor: pointer; 230 | opacity: 0.8; 231 | } 232 | 233 | /* 非禁用状态下的悬停效果 */ 234 | .model-selector-button:hover { 235 | background-color: var(--color-primary-dark); 236 | } 237 | 238 | /* 禁用状态下的悬停效果 */ 239 | .submit-button:disabled + .model-selector-button:hover { 240 | background-color: #bbb; 241 | } 242 | 243 | .model-selector-button svg { 244 | transition: transform 0.2s; 245 | transform: rotate(270deg); 246 | } 247 | 248 | .model-selector-button.active svg { 249 | transform: rotate(90deg); 250 | } 251 | 252 | /* 模型下拉菜单 */ 253 | .model-dropdown { 254 | overflow: hidden; 255 | position: absolute; 256 | top: 0; 257 | right: -170px; 258 | width: 160px; 259 | background-color: var(--color-bg-primary); 260 | border: 1px solid var(--color-border); 261 | border-radius: var(--radius-lg); 262 | box-shadow: var(--shadow-md); 263 | opacity: 0; 264 | visibility: hidden; 265 | transform: translateX(-10px); 266 | transition: all 0.2s; 267 | z-index: 1000; 268 | } 269 | 270 | .model-dropdown.show { 271 | opacity: 1; 272 | visibility: visible; 273 | transform: translateX(0); 274 | } 275 | 276 | .model-option { 277 | padding: var(--spacing-sm) var(--spacing-md); 278 | cursor: pointer; 279 | transition: all 0.2s; 280 | color: var(--color-text-primary); 281 | font-size: 14px; 282 | } 283 | 284 | .model-option:hover { 285 | background-color: var(--color-bg-secondary); 286 | } 287 | 288 | .model-option.selected { 289 | color: var(--color-primary); 290 | background-color: rgba(0, 122, 255, 0.1); 291 | } 292 | 293 | .model-option.custom { 294 | border-top: 1px solid var(--color-border); 295 | } -------------------------------------------------------------------------------- /styles/components/cards.css: -------------------------------------------------------------------------------- 1 | /* 提示词卡片 */ 2 | .prompt-card { 3 | padding: var(--spacing-lg); 4 | border: 1px solid var(--color-border); 5 | border-radius: var(--radius-lg); 6 | cursor: pointer; 7 | position: relative; 8 | padding-bottom: 36px; 9 | height: 200px; 10 | min-height: 120px; 11 | overflow: hidden; 12 | display: flex; 13 | flex-direction: column; 14 | } 15 | 16 | .prompt-card:hover { 17 | border-color: var(--color-primary); 18 | background-color: var(--color-bg-secondary); 19 | } 20 | 21 | .prompt-card.selected { 22 | border-color: var(--color-primary); 23 | background-color: #e8f2ff; 24 | } 25 | 26 | .prompt-card h3 { 27 | font-size: 16px; 28 | margin-bottom: var(--spacing-sm); 29 | color: var(--color-text-primary); 30 | flex-shrink: 0; 31 | } 32 | 33 | .prompt-card .card-prompt { 34 | font-size: 14px; 35 | line-height: 1.5; 36 | color: var(--color-text-secondary); 37 | margin-top: var(--spacing-sm); 38 | white-space: pre-wrap; 39 | overflow-y: auto; 40 | max-height: 100px; 41 | text-overflow: ellipsis; 42 | position: relative; 43 | flex: 1; 44 | padding-right: 12px; 45 | margin-right: -8px; 46 | } 47 | 48 | .prompt-card .card-prompt::-webkit-scrollbar { 49 | width: 0; 50 | } 51 | 52 | .prompt-card .card-prompt:hover::-webkit-scrollbar { 53 | width: 4px; 54 | } 55 | .prompt-card .card-prompt::after { 56 | content: ''; 57 | position: sticky; 58 | bottom: 0; 59 | right: 0; 60 | width: 100%; 61 | height: 20px; 62 | background: linear-gradient(transparent, var(--color-bg-primary)); 63 | pointer-events: none; 64 | } 65 | 66 | .prompt-card:hover .card-prompt::after { 67 | background: linear-gradient(transparent, var(--color-bg-secondary)); 68 | } 69 | 70 | .prompt-card.selected .card-prompt::after { 71 | background: linear-gradient(transparent, #e8f2ff); 72 | } 73 | 74 | /* 段落卡片 */ 75 | .paragraph-card { 76 | position: absolute; 77 | background: var(--color-bg-primary); 78 | border: 1px solid var(--color-border); 79 | border-radius: var(--radius-lg); 80 | padding: var(--spacing-sm); 81 | cursor: move; 82 | padding-left: 32px; 83 | min-width: 200px; 84 | min-height: 100px; 85 | width: 300px; 86 | height: 150px; 87 | resize: both; 88 | overflow: hidden; 89 | transition: box-shadow 0.2s; 90 | will-change: transform; 91 | box-shadow: var(--shadow-sm); 92 | display: flex; 93 | flex-direction: column; 94 | } 95 | 96 | .paragraph-card:hover { 97 | box-shadow: var(--shadow-md); 98 | } 99 | 100 | .paragraph-card.dragging { 101 | opacity: 0.5; 102 | box-shadow: var(--shadow-lg); 103 | z-index: 1000; 104 | } 105 | 106 | .paragraph-card .card-content { 107 | cursor: text; 108 | outline: none; 109 | min-height: 100%; 110 | user-select: text; 111 | overflow-y: auto; 112 | flex: 1; 113 | padding-right: var(--spacing-sm); 114 | } 115 | 116 | .paragraph-card .card-content[contenteditable="true"] { 117 | background-color: var(--color-bg-primary); 118 | border-radius: var(--radius-sm); 119 | padding: var(--spacing-xs); 120 | box-shadow: inset 0 0 0 2px var(--color-primary); 121 | } 122 | 123 | /* 卡片操作按钮 - 基础样式 */ 124 | .card-actions { 125 | position: absolute; 126 | top: var(--spacing-sm); 127 | right: var(--spacing-sm); 128 | display: flex; 129 | gap: var(--spacing-xs); 130 | opacity: 0; 131 | transition: opacity 0.2s; 132 | } 133 | 134 | .prompt-card:hover .card-actions, 135 | .paragraph-card:hover .card-actions { 136 | opacity: 1; 137 | } 138 | 139 | /* 提示词卡片的按钮样式 */ 140 | .prompt-card .card-actions button { 141 | width: 18px; 142 | height: 18px; 143 | font-size: 12px; 144 | border: none; 145 | border-radius: 50%; 146 | cursor: pointer; 147 | background-color: var(--color-bg-secondary); 148 | color: var(--color-text-secondary); 149 | display: flex; 150 | align-items: center; 151 | justify-content: center; 152 | opacity: 0.6; 153 | transition: all 0.2s; 154 | padding: 0; 155 | } 156 | 157 | .prompt-card .card-actions button:hover { 158 | opacity: 1; 159 | } 160 | 161 | .prompt-card .edit-btn:hover { 162 | background-color: var(--color-primary); 163 | color: var(--color-bg-primary); 164 | opacity: 1; 165 | } 166 | 167 | .prompt-card .delete-btn:hover { 168 | background-color: var(--color-danger); 169 | color: var(--color-bg-primary); 170 | opacity: 1; 171 | } 172 | 173 | /* 段落卡片的按钮样式 */ 174 | .paragraph-card .card-actions button { 175 | width: 18px; 176 | height: 18px; 177 | font-size: 12px; 178 | border: none; 179 | border-radius: 50%; 180 | cursor: pointer; 181 | background-color: var(--color-bg-secondary); 182 | color: var(--color-text-secondary); 183 | display: flex; 184 | align-items: center; 185 | justify-content: center; 186 | opacity: 0.6; 187 | transition: all 0.2s; 188 | } 189 | 190 | .paragraph-card .card-actions button:hover { 191 | opacity: 1; 192 | background-color: var(--color-danger); 193 | color: var(--color-bg-primary); 194 | } 195 | 196 | /* 卡片管理 */ 197 | .card-management { 198 | margin-bottom: var(--spacing-md); 199 | } 200 | 201 | .card-management-buttons { 202 | display: flex; 203 | gap: var(--spacing-sm); 204 | align-items: center; 205 | } -------------------------------------------------------------------------------- /styles/components/dialogs.css: -------------------------------------------------------------------------------- 1 | /* 编辑对话框 */ 2 | .edit-dialog { 3 | position: fixed; 4 | top: 0; 5 | left: 0; 6 | right: 0; 7 | bottom: 0; 8 | background-color: rgba(0, 0, 0, 0.5); 9 | display: flex; 10 | align-items: center; 11 | justify-content: center; 12 | z-index: 20000; 13 | user-select: none; 14 | } 15 | 16 | .dialog-content { 17 | background-color: var(--color-bg-primary); 18 | padding: var(--spacing-xxl); 19 | border-radius: var(--radius-xl); 20 | width: 90%; 21 | max-width: 500px; 22 | display: flex; 23 | flex-direction: column; 24 | gap: var(--spacing-lg); 25 | } 26 | 27 | .dialog-content input, 28 | .dialog-content textarea { 29 | padding: var(--spacing-sm); 30 | border: 1px solid var(--color-border); 31 | border-radius: var(--radius-md); 32 | font-size: 14px; 33 | width: 100%; 34 | } 35 | 36 | .dialog-content input:focus, 37 | .dialog-content textarea:focus { 38 | outline: none; 39 | border-color: var(--color-primary); 40 | box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.1); 41 | } 42 | 43 | .dialog-buttons { 44 | display: flex; 45 | justify-content: flex-end; 46 | gap: var(--spacing-sm); 47 | } 48 | 49 | .dialog-buttons button { 50 | padding: var(--spacing-sm) var(--spacing-lg); 51 | border: none; 52 | border-radius: var(--radius-md); 53 | cursor: pointer; 54 | font-size: 14px; 55 | transition: all 0.2s; 56 | } 57 | 58 | #save-edit { 59 | background-color: var(--color-primary); 60 | color: var(--color-bg-primary); 61 | } 62 | 63 | #save-edit:hover { 64 | background-color: var(--color-primary-dark); 65 | } 66 | 67 | #cancel-edit { 68 | background-color: var(--color-bg-secondary); 69 | color: var(--color-text-primary); 70 | } 71 | 72 | #cancel-edit:hover { 73 | background-color: var(--color-border); 74 | } 75 | 76 | /* 文件输入包装器 */ 77 | .file-input-wrapper { 78 | display: flex; 79 | align-items: center; 80 | gap: var(--spacing-md); 81 | } 82 | 83 | /* 自定义模型配置对话框 */ 84 | .custom-model-dialog { 85 | position: fixed; 86 | top: 0; 87 | left: 0; 88 | right: 0; 89 | bottom: 0; 90 | background-color: rgba(0, 0, 0, 0.5); 91 | display: flex; 92 | align-items: center; 93 | justify-content: center; 94 | z-index: 20000; 95 | user-select: none; 96 | } 97 | 98 | .custom-model-content { 99 | background-color: var(--color-bg-primary); 100 | padding: var(--spacing-xxl); 101 | border-radius: var(--radius-xl); 102 | width: 90%; 103 | max-width: 500px; 104 | display: flex; 105 | flex-direction: column; 106 | gap: var(--spacing-md); 107 | } 108 | 109 | .custom-model-content h3 { 110 | margin-bottom: var(--spacing-md); 111 | color: var(--color-text-primary); 112 | font-size: 18px; 113 | } 114 | 115 | .form-group { 116 | display: flex; 117 | flex-direction: column; 118 | gap: var(--spacing-xs); 119 | } 120 | 121 | .form-group label { 122 | font-size: 14px; 123 | color: var(--color-text-secondary); 124 | } 125 | 126 | .form-group input { 127 | padding: var(--spacing-sm); 128 | border: 1px solid var(--color-border); 129 | border-radius: var(--radius-md); 130 | font-size: 14px; 131 | width: 100%; 132 | } 133 | 134 | .form-group input:focus { 135 | outline: none; 136 | border-color: var(--color-primary); 137 | box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.1); 138 | } 139 | 140 | .form-group .hint { 141 | font-size: 12px; 142 | color: var(--color-text-secondary); 143 | margin-top: 2px; 144 | } 145 | 146 | .custom-model-buttons { 147 | display: flex; 148 | justify-content: flex-end; 149 | gap: var(--spacing-sm); 150 | margin-top: var(--spacing-lg); 151 | } 152 | 153 | .custom-model-buttons button { 154 | padding: var(--spacing-sm) var(--spacing-lg); 155 | border: none; 156 | border-radius: var(--radius-md); 157 | cursor: pointer; 158 | font-size: 14px; 159 | transition: all 0.2s; 160 | } 161 | 162 | .custom-model-buttons .save-btn { 163 | background-color: var(--color-primary); 164 | color: var(--color-bg-primary); 165 | } 166 | 167 | .custom-model-buttons .save-btn:hover { 168 | background-color: var(--color-primary-dark); 169 | } 170 | 171 | .custom-model-buttons .cancel-btn { 172 | background-color: var(--color-bg-secondary); 173 | color: var(--color-text-primary); 174 | } 175 | 176 | .custom-model-buttons .cancel-btn:hover { 177 | background-color: var(--color-border); 178 | } 179 | 180 | /* Ollama模型配置对话框 */ 181 | .ollama-dialog { 182 | position: fixed; 183 | top: 0; 184 | left: 0; 185 | right: 0; 186 | bottom: 0; 187 | background-color: rgba(0, 0, 0, 0.5); 188 | display: flex; 189 | align-items: center; 190 | justify-content: center; 191 | z-index: 20000; 192 | user-select: none; 193 | } 194 | 195 | .ollama-content { 196 | background-color: var(--color-bg-primary); 197 | padding: var(--spacing-xxl); 198 | border-radius: var(--radius-xl); 199 | width: 90%; 200 | max-width: 500px; 201 | display: flex; 202 | flex-direction: column; 203 | gap: var(--spacing-md); 204 | } 205 | 206 | .ollama-content h3 { 207 | margin-bottom: var(--spacing-md); 208 | color: var(--color-text-primary); 209 | font-size: 18px; 210 | } 211 | 212 | .ollama-content .description { 213 | color: var(--color-text-secondary); 214 | font-size: 14px; 215 | line-height: 1.5; 216 | margin-bottom: var(--spacing-lg); 217 | padding: var(--spacing-md); 218 | background-color: var(--color-bg-secondary); 219 | border-radius: var(--radius-md); 220 | } 221 | 222 | .ollama-content .description code { 223 | background-color: rgba(0, 0, 0, 0.05); 224 | padding: 2px 4px; 225 | border-radius: 4px; 226 | font-family: monospace; 227 | } 228 | 229 | .ollama-content .form-group { 230 | display: flex; 231 | flex-direction: column; 232 | gap: var(--spacing-xs); 233 | } 234 | 235 | .ollama-buttons { 236 | display: flex; 237 | justify-content: flex-end; 238 | gap: var(--spacing-sm); 239 | margin-top: var(--spacing-lg); 240 | } 241 | 242 | .ollama-buttons button { 243 | padding: var(--spacing-sm) var(--spacing-lg); 244 | border: none; 245 | border-radius: var(--radius-md); 246 | cursor: pointer; 247 | font-size: 14px; 248 | transition: all 0.2s; 249 | } 250 | 251 | .ollama-buttons .confirm-btn { 252 | background-color: var(--color-primary); 253 | color: var(--color-bg-primary); 254 | } 255 | 256 | .ollama-buttons .confirm-btn:hover { 257 | background-color: var(--color-primary-dark); 258 | } 259 | 260 | .ollama-buttons .cancel-btn { 261 | background-color: var(--color-bg-secondary); 262 | color: var(--color-text-primary); 263 | } 264 | 265 | .ollama-buttons .cancel-btn:hover { 266 | background-color: var(--color-border); 267 | } 268 | 269 | .ollama-content .model-list { 270 | max-height: 200px; 271 | overflow-y: auto; 272 | border: 1px solid var(--color-border); 273 | border-radius: var(--radius-md); 274 | background-color: var(--color-bg-primary); 275 | } 276 | 277 | .ollama-content .model-list .loading, 278 | .ollama-content .model-list .error { 279 | padding: var(--spacing-md); 280 | color: var(--color-text-secondary); 281 | text-align: center; 282 | } 283 | 284 | .ollama-content .model-list .error { 285 | color: var(--color-danger); 286 | line-height: 1.5; 287 | } 288 | 289 | .ollama-content .model-option { 290 | padding: var(--spacing-sm) var(--spacing-md); 291 | cursor: pointer; 292 | transition: all 0.2s; 293 | display: flex; 294 | align-items: flex-start; 295 | gap: var(--spacing-sm); 296 | } 297 | 298 | .ollama-content .model-option:hover { 299 | background-color: var(--color-bg-secondary); 300 | } 301 | 302 | .ollama-content .model-option input[type="radio"] { 303 | margin: 0; 304 | margin-top: 4px; 305 | width: 14px; 306 | height: 14px; 307 | flex-shrink: 0; 308 | } 309 | 310 | .ollama-content .model-option label { 311 | cursor: pointer; 312 | flex: 1; 313 | margin: 0; 314 | color: var(--color-text-primary); 315 | min-width: 0; 316 | } 317 | 318 | .ollama-content .model-name { 319 | font-weight: 500; 320 | margin-bottom: 2px; 321 | white-space: nowrap; 322 | overflow: hidden; 323 | text-overflow: ellipsis; 324 | } 325 | 326 | .ollama-content .model-info { 327 | font-size: 12px; 328 | color: var(--color-text-secondary); 329 | line-height: 1.4; 330 | word-break: break-word; 331 | } 332 | 333 | .ollama-content .confirm-btn:disabled { 334 | background-color: var(--color-border); 335 | cursor: not-allowed; 336 | } -------------------------------------------------------------------------------- /styles/components/ports.css: -------------------------------------------------------------------------------- 1 | /* 端口容器 */ 2 | .port-container { 3 | position: absolute; 4 | bottom: var(--spacing-xs); 5 | right: var(--spacing-md); 6 | display: flex; 7 | gap: var(--spacing-lg); 8 | padding: var(--spacing-sm); 9 | align-items: flex-end; 10 | } 11 | 12 | /* 连接端口 */ 13 | .connection-port { 14 | width: 20px; 15 | height: 20px; 16 | position: relative; 17 | margin-top: var(--spacing-xl); 18 | cursor: crosshair; 19 | transition: all 0.2s; 20 | } 21 | 22 | .connection-port svg { 23 | width: 100%; 24 | height: 100%; 25 | fill: var(--color-orange); 26 | transform: rotate(90deg); 27 | transition: all 0.2s; 28 | } 29 | 30 | .connection-port:hover svg { 31 | transform: rotate(90deg) scale(1.3); 32 | filter: brightness(1.2); 33 | } 34 | 35 | /* 连接线容器 */ 36 | .connections-container { 37 | position: fixed; 38 | top: 0; 39 | left: 0; 40 | width: 100vw; 41 | height: 100vh; 42 | pointer-events: none; 43 | z-index: 10000; 44 | } 45 | 46 | /* SVG连接线 */ 47 | .connection-line { 48 | stroke: var(--color-success); 49 | stroke-width: 2; 50 | fill: none; 51 | pointer-events: none; 52 | } 53 | 54 | .connection-line.temp { 55 | stroke-dasharray: 4; 56 | animation: dash 1s linear infinite; 57 | } 58 | 59 | @keyframes dash { 60 | to { 61 | stroke-dashoffset: -8; 62 | } 63 | } 64 | 65 | /* 文本卡片端口 */ 66 | .text-card-port { 67 | position: absolute; 68 | top: var(--spacing-sm); 69 | left: var(--spacing-sm); 70 | width: 20px; 71 | height: 20px; 72 | cursor: crosshair; 73 | transition: all 0.2s; 74 | z-index: 2; 75 | pointer-events: auto; 76 | } 77 | 78 | .text-card-port svg { 79 | width: 100%; 80 | height: 100%; 81 | fill: var(--color-primary); 82 | transition: all 0.2s; 83 | } 84 | 85 | /* 链式端口 */ 86 | .text-card-chain-port { 87 | position: absolute; 88 | bottom: var(--spacing-sm); 89 | left: var(--spacing-sm); 90 | width: 20px; 91 | height: 20px; 92 | cursor: crosshair; 93 | transition: all 0.2s; 94 | z-index: 10; 95 | pointer-events: auto; 96 | } 97 | 98 | .text-card-chain-port svg { 99 | width: 100%; 100 | height: 100%; 101 | fill: var(--color-purple); 102 | transform: rotate(180deg); 103 | transition: all 0.2s; 104 | } 105 | 106 | .text-card-chain-port:hover svg { 107 | transform: rotate(180deg) scale(1.3); 108 | filter: brightness(1.2); 109 | } 110 | 111 | /* 连接状态 */ 112 | .text-card-port.prompt-connected svg { 113 | transform: rotate(-90deg); 114 | } 115 | 116 | .text-card-port.chain-connected svg { 117 | transform: rotate(0deg); 118 | } 119 | 120 | .text-card-chain-port.connected svg { 121 | transform: rotate(0deg); 122 | } 123 | 124 | .text-card-port.connected svg path, 125 | .text-card-chain-port.connected svg path, 126 | .connection-port.connected svg path { 127 | fill: var(--color-success); 128 | } 129 | 130 | /* 连接动画 */ 131 | @keyframes pulse-socket { 132 | 0%, 100% { 133 | transform: scale(1); 134 | filter: brightness(1); 135 | } 136 | 70% { 137 | transform: scale(1.2); 138 | filter: brightness(1.2); 139 | } 140 | } 141 | 142 | @keyframes pulse-chain { 143 | 0%, 100% { 144 | transform: rotate(180deg) scale(1); 145 | filter: brightness(1); 146 | } 147 | 70% { 148 | transform: rotate(180deg) scale(1.2); 149 | filter: brightness(1.2); 150 | } 151 | } 152 | 153 | .text-card-port.connecting svg { 154 | animation: pulse-socket 1.5s infinite; 155 | } 156 | 157 | .text-card-chain-port.connecting svg { 158 | animation: pulse-chain 1.5s infinite; 159 | } 160 | 161 | /* 连接模式 */ 162 | .connecting-mode { 163 | user-select: none !important; 164 | -webkit-user-select: none !important; 165 | -moz-user-select: none !important; 166 | -ms-user-select: none !important; 167 | } 168 | 169 | .connecting-mode * { 170 | user-select: none !important; 171 | -webkit-user-select: none !important; 172 | -moz-user-select: none !important; 173 | -ms-user-select: none !important; 174 | cursor: crosshair !important; 175 | } 176 | 177 | /* 端口标签 */ 178 | .port-label { 179 | position: absolute; 180 | left: 50%; 181 | bottom: 100%; 182 | transform: translateX(-50%); 183 | font-size: 10px; 184 | white-space: nowrap; 185 | color: var(--color-text-secondary); 186 | background: rgba(255, 255, 255, 0.9); 187 | padding: var(--spacing-xs) var(--spacing-xs); 188 | border-radius: var(--radius-sm); 189 | margin-bottom: var(--spacing-xs); 190 | pointer-events: none; 191 | opacity: 1; 192 | transition: all 0.2s; 193 | } 194 | 195 | .connection-port:hover .port-label { 196 | transform: translateX(-50%) scale(1.1); 197 | color: var(--color-text-primary); 198 | } -------------------------------------------------------------------------------- /styles/layout.css: -------------------------------------------------------------------------------- 1 | /* 主容器 */ 2 | .app-container { 3 | display: flex; 4 | min-height: 100vh; 5 | overflow: hidden; 6 | user-select: none; 7 | } 8 | 9 | /* 侧边栏 */ 10 | .sidebar { 11 | width: 300px; 12 | background-color: var(--color-bg-primary); 13 | border-right: 1px solid var(--color-border); 14 | padding: var(--spacing-lg); 15 | display: flex; 16 | flex-direction: column; 17 | height: 100vh; 18 | } 19 | 20 | .sidebar-bottom { 21 | margin-top: var(--spacing-lg); 22 | display: flex; 23 | flex-direction: column; 24 | flex: 1; 25 | min-height: 0; 26 | justify-content: flex-end; 27 | } 28 | 29 | /* 主内容区 */ 30 | .chat-container { 31 | flex: 1; 32 | display: flex; 33 | flex-direction: column; 34 | background-color: var(--color-bg-primary); 35 | padding: var(--spacing-lg); 36 | } 37 | 38 | /* 头部样式 */ 39 | header { 40 | display: flex; 41 | justify-content: space-between; 42 | align-items: center; 43 | margin-bottom: var(--spacing-md); 44 | padding: 0 var(--spacing-xs); 45 | } 46 | 47 | header h1 { 48 | font-size: 24px; 49 | font-weight: 600; 50 | color: var(--color-text-primary); 51 | } 52 | 53 | .header-buttons { 54 | display: flex; 55 | gap: var(--spacing-md); 56 | } 57 | 58 | /* 提示词卡片容器 */ 59 | .prompt-cards { 60 | margin: 0 calc(-1 * var(--spacing-lg)); 61 | padding: 0 var(--spacing-lg); 62 | display: flex; 63 | flex-direction: column; 64 | gap: var(--spacing-md); 65 | flex: 2; 66 | overflow-y: auto; 67 | min-height: 0; 68 | } 69 | 70 | /* 段落容器 */ 71 | .paragraph-container { 72 | flex: 1; 73 | padding: var(--spacing-lg); 74 | overflow-y: auto; 75 | position: relative; 76 | background: var(--color-bg-secondary); 77 | } 78 | 79 | /* 浮动按钮容器 */ 80 | .floating-buttons { 81 | position: fixed; 82 | bottom: var(--spacing-xl); 83 | right: var(--spacing-xl); 84 | display: flex; 85 | gap: var(--spacing-md); 86 | align-items: center; 87 | } 88 | 89 | /* 头部区域 */ 90 | .section-header { 91 | display: flex; 92 | justify-content: space-between; 93 | align-items: center; 94 | margin-bottom: var(--spacing-lg); 95 | padding: 0 var(--spacing-xs); 96 | } 97 | 98 | .section-header h2 { 99 | font-size: 20px; 100 | font-weight: 600; 101 | color: var(--color-text-primary); 102 | margin: 0; 103 | padding: 0; 104 | } 105 | 106 | .header-actions { 107 | display: flex; 108 | gap: var(--spacing-sm); 109 | align-items: center; 110 | } 111 | 112 | /* 输出容器 */ 113 | .output-container { 114 | border: 1px solid var(--color-border); 115 | border-radius: var(--radius-lg); 116 | padding: var(--spacing-md); 117 | display: flex; 118 | flex-direction: column; 119 | flex: 1; 120 | min-height: 120px; 121 | max-height: 200px; 122 | } 123 | 124 | .output-content { 125 | flex: 1; 126 | overflow-y: auto; 127 | font-size: 14px; 128 | line-height: 1.5; 129 | color: var(--color-text-secondary); 130 | cursor: move; 131 | user-select: text; 132 | padding-right: var(--spacing-sm); 133 | } 134 | 135 | .output-content:hover { 136 | background-color: var(--color-bg-secondary); 137 | } 138 | 139 | .output-content[draggable="true"]:active { 140 | cursor: grabbing; 141 | opacity: 0.7; 142 | } --------------------------------------------------------------------------------