From 9f7b443268286e023bb4a1e6a472e4c3c17af36c Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sat, 24 Jan 2026 13:33:50 +0800 Subject: [PATCH] feat: add prompt/personalization selection and conversation APIs --- api_doc/README.md | 5 + api_doc/messages_tasks.md | 2 + api_doc/prompts_personalization.md | 58 +++++++ core/main_terminal.py | 3 +- server/api_v1.py | 267 ++++++++++++++++++++++++++++- server/context.py | 44 +++++ utils/context_manager.py | 5 + 7 files changed, 380 insertions(+), 4 deletions(-) create mode 100644 api_doc/prompts_personalization.md diff --git a/api_doc/README.md b/api_doc/README.md index a0ef1d6..6c7b3b9 100644 --- a/api_doc/README.md +++ b/api_doc/README.md @@ -24,6 +24,10 @@ 4. 停止任务:`POST /api/v1/tasks//cancel` 5. 文件上传(仅 user_upload):`POST /api/v1/files/upload` 6. 文件浏览/下载:`GET /api/v1/files`、`GET /api/v1/files/download` +7. 对话查询/删除:`GET /api/v1/conversations`、`GET/DELETE /api/v1/conversations/{id}` +8. Prompt 管理:`GET/POST /api/v1/prompts`、`GET /api/v1/prompts/{name}` +9. 个性化管理:`GET/POST /api/v1/personalizations`、`GET /api/v1/personalizations/{name}` +10. 模型列表与健康检查:`GET /api/v1/models`、`GET /api/v1/health` 详细参数与返回请看: @@ -31,6 +35,7 @@ - `messages_tasks.md`:发送消息/轮询/停止 - `events.md`:事件流格式与事件类型说明(与 WebSocket 同源) - `files.md`:上传/列目录/下载 +- `prompts_personalization.md`:Prompt 与个性化管理 - `errors.md`:HTTP 错误码与常见排查 - `examples.md`:curl/Python/JS/Flutter 示例 - `openapi.yaml`:OpenAPI 3.0 规范(可导入 Postman/Swagger) diff --git a/api_doc/messages_tasks.md b/api_doc/messages_tasks.md index a4cfabb..90de2d4 100644 --- a/api_doc/messages_tasks.md +++ b/api_doc/messages_tasks.md @@ -56,6 +56,8 @@ Body(JSON): | `run_mode` | string/null | 否 | 运行模式:`"fast"` \| `"thinking"` \| `"deep"`;若传入则优先使用 | | `thinking_mode` | boolean/null | 否 | 兼容字段:true=thinking,false=fast;当 `run_mode` 为空时才使用 | | `max_iterations` | integer/null | 否 | 最大迭代次数,默认服务端配置为 **100**(`config.MAX_ITERATIONS_PER_TASK`);传入可覆盖 | +| `prompt_name` | string/null | 否 | 选择自定义主 prompt(存放于 `data/prompts/.txt`);若不存在返回 404 | +| `personalization_name` | string/null | 否 | 选择个性化配置(`data/personalization/.json`);若不存在返回 404 | | `images` | string[] | 否 | 图片路径列表(服务端可访问的路径);一般配合特定模型使用 | 优先级:`run_mode` > `thinking_mode` > 终端当前配置。`run_mode="deep"` 将启用深度思考模式(若模型与配置允许)。 diff --git a/api_doc/prompts_personalization.md b/api_doc/prompts_personalization.md new file mode 100644 index 0000000..8195673 --- /dev/null +++ b/api_doc/prompts_personalization.md @@ -0,0 +1,58 @@ +# Prompt 与个性化(Personalization)管理 API + +## Prompt(主提示词)存储位置 +- 路径:`api/users//data/prompts/.txt` +- 内容格式:纯文本 + +## 个性化存储位置 +- 路径:`api/users//data/personalization/.json` +- 内容格式:JSON,对应原有 personalization 配置结构 + +## 接口 + +### 列出 Prompt +`GET /api/v1/prompts` + +响应: +```json +{ "success": true, "items": [ { "name": "default", "size": 123, "updated_at": 1769182550.59 } ] } +``` + +### 获取 Prompt 内容 +`GET /api/v1/prompts/{name}` + +响应: +```json +{ "success": true, "name": "custom_a", "content": "你的主系统提示..." } +``` + +### 创建/覆盖 Prompt +`POST /api/v1/prompts` +```json +{ "name": "custom_a", "content": "你的主系统提示内容" } +``` +成功返回 `{ "success": true, "name": "custom_a" }` + +--- + +### 列出个性化 +`GET /api/v1/personalizations` + +### 获取个性化内容 +`GET /api/v1/personalizations/{name}` + +### 创建/覆盖个性化 +`POST /api/v1/personalizations` +```json +{ "name": "biz_mobile", "content": { ... personalization json ... } } +``` + +--- + +## 在对话/消息中使用 + +- `POST /api/v1/conversations` 可选参数:`prompt_name`、`personalization_name`,会写入对话元数据并在后续消息中应用。 +- `POST /api/v1/messages` 可选参数:`prompt_name`、`personalization_name`,立即应用并写入元数据。 +- 元数据字段:`custom_prompt_name`、`personalization_name`;对话加载时会自动套用对应文件(若不存在则忽略)。 + +优先级:调用时传入 > 对话元数据 > 默认主 prompt / 默认个性化配置。 diff --git a/core/main_terminal.py b/core/main_terminal.py index 6c07b6e..bf2387b 100644 --- a/core/main_terminal.py +++ b/core/main_terminal.py @@ -2542,7 +2542,8 @@ class MainTerminal: ) messages.append({"role": "system", "content": thinking_prompt}) - personalization_config = load_personalization_config(self.data_dir) + # 支持按对话覆盖的个性化配置 + personalization_config = getattr(self.context_manager, "custom_personalization_config", None) or load_personalization_config(self.data_dir) personalization_block = build_personalization_prompt(personalization_config, include_header=False) if personalization_block: personalization_template = self.load_prompt("personalization").strip() diff --git a/server/api_v1.py b/server/api_v1.py index 1643864..76986df 100644 --- a/server/api_v1.py +++ b/server/api_v1.py @@ -1,18 +1,20 @@ """API v1:Bearer Token 版轻量接口(后台任务 + 轮询 + 文件)。""" from __future__ import annotations import os +import json import zipfile from io import BytesIO from pathlib import Path -from typing import Dict, Any +from typing import Dict, Any, Optional from flask import Blueprint, request, jsonify, send_file, session from .api_auth import api_token_required from .tasks import task_manager -from .context import get_user_resources, ensure_conversation_loaded, get_upload_guard +from .context import get_user_resources, ensure_conversation_loaded, get_upload_guard, apply_conversation_overrides from .files import sanitize_filename_preserve_unicode from .utils_common import debug_log +from config.model_profiles import MODEL_PROFILES api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1") @@ -30,6 +32,40 @@ def _within_uploads(workspace, rel_path: str) -> Path: return target +def _conversation_path(workspace, conv_id: str) -> Path: + return Path(workspace.data_dir) / "conversations" / f"{conv_id}.json" + + +def _update_conversation_metadata(workspace, conv_id: str, updates: Dict[str, Any]): + path = _conversation_path(workspace, conv_id) + if not path.exists(): + return + try: + data = {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + data = {} + meta = data.get("metadata") or {} + meta.update({k: v for k, v in updates.items() if v is not None}) + data["metadata"] = meta + path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + except Exception as exc: + debug_log(f"[meta] 更新对话元数据失败: {exc}") + + +def _prompt_dir(workspace): + p = Path(workspace.data_dir) / "prompts" + p.mkdir(parents=True, exist_ok=True) + return p + + +def _personalization_dir(workspace): + p = Path(workspace.data_dir) / "personalization" + p.mkdir(parents=True, exist_ok=True) + return p + + @api_v1_bp.route("/conversations", methods=["POST"]) @api_token_required def create_conversation_api(): @@ -37,10 +73,21 @@ def create_conversation_api(): terminal, workspace = get_user_resources(username) if not terminal: return jsonify({"success": False, "error": "系统未初始化"}), 503 + payload = request.get_json(silent=True) or {} + prompt_name = payload.get("prompt_name") + personalization_name = payload.get("personalization_name") result = terminal.create_new_conversation() if not result.get("success"): return jsonify({"success": False, "error": result.get("error") or "创建对话失败"}), 500 - return jsonify({"success": True, "conversation_id": result.get("conversation_id")}) + conv_id = result.get("conversation_id") + # 记录元数据,稍后消息时可自动应用 + _update_conversation_metadata(workspace, conv_id, { + "custom_prompt_name": prompt_name, + "personalization_name": personalization_name, + }) + # 立即应用覆盖 + apply_conversation_overrides(terminal, workspace, conv_id) + return jsonify({"success": True, "conversation_id": conv_id}) @api_v1_bp.route("/messages", methods=["POST"]) @@ -55,6 +102,8 @@ def send_message_api(): thinking_mode = payload.get("thinking_mode") run_mode = payload.get("run_mode") max_iterations = payload.get("max_iterations") + prompt_name = payload.get("prompt_name") + personalization_name = payload.get("personalization_name") if not message and not images: return jsonify({"success": False, "error": "消息不能为空"}), 400 @@ -66,6 +115,29 @@ def send_message_api(): except Exception as exc: return jsonify({"success": False, "error": f"对话加载失败: {exc}"}), 400 + # 应用自定义 prompt/personalization(如果提供) + try: + if prompt_name: + prompt_path = _prompt_dir(workspace) / f"{prompt_name}.txt" + if not prompt_path.exists(): + return jsonify({"success": False, "error": "prompt 不存在"}), 404 + terminal.context_manager.custom_system_prompt = prompt_path.read_text(encoding="utf-8") + if personalization_name: + pers_path = _personalization_dir(workspace) / f"{personalization_name}.json" + if not pers_path.exists(): + return jsonify({"success": False, "error": "personalization 不存在"}), 404 + try: + terminal.context_manager.custom_personalization_config = json.loads(pers_path.read_text(encoding="utf-8")) + except Exception: + return jsonify({"success": False, "error": "personalization 解析失败"}), 400 + # 持久化到对话元数据 + _update_conversation_metadata(workspace, conversation_id, { + "custom_prompt_name": prompt_name, + "personalization_name": personalization_name, + }) + except Exception as exc: + return jsonify({"success": False, "error": f"自定义参数错误: {exc}"}), 400 + try: rec = task_manager.create_chat_task( username=username, @@ -91,6 +163,70 @@ def send_message_api(): }), 202 +@api_v1_bp.route("/conversations", methods=["GET"]) +@api_token_required +def list_conversations_api(): + username = session.get("username") + terminal, workspace = get_user_resources(username) + if not terminal or not workspace: + return jsonify({"success": False, "error": "系统未初始化"}), 503 + limit = max(1, min(int(request.args.get("limit", 20)), 100)) + offset = max(0, int(request.args.get("offset", 0))) + conv_dir = Path(workspace.data_dir) / "conversations" + items = [] + for p in sorted(conv_dir.glob("conv_*.json"), key=lambda x: x.stat().st_mtime, reverse=True): + try: + data = json.loads(p.read_text(encoding="utf-8")) + meta = data.get("metadata") or {} + items.append({ + "id": data.get("id"), + "title": data.get("title"), + "created_at": data.get("created_at"), + "updated_at": data.get("updated_at"), + "run_mode": meta.get("run_mode"), + "model_key": meta.get("model_key"), + "custom_prompt_name": meta.get("custom_prompt_name"), + "personalization_name": meta.get("personalization_name"), + "messages_count": len(data.get("messages", [])), + }) + except Exception: + continue + sliced = items[offset:offset + limit] + return jsonify({"success": True, "data": sliced, "total": len(items)}) + + +@api_v1_bp.route("/conversations/", methods=["GET"]) +@api_token_required +def get_conversation_api(conv_id: str): + username = session.get("username") + _, workspace = get_user_resources(username) + if not workspace: + return jsonify({"success": False, "error": "系统未初始化"}), 503 + path = _conversation_path(workspace, conv_id) + if not path.exists(): + return jsonify({"success": False, "error": "对话不存在"}), 404 + try: + data = json.loads(path.read_text(encoding="utf-8")) + include_messages = request.args.get("full", "0") == "1" + if not include_messages: + data["messages"] = None + return jsonify({"success": True, "data": data}) + except Exception as exc: + return jsonify({"success": False, "error": str(exc)}), 500 + + +@api_v1_bp.route("/conversations/", methods=["DELETE"]) +@api_token_required +def delete_conversation_api(conv_id: str): + username = session.get("username") + terminal, workspace = get_user_resources(username) + if not terminal or not workspace: + return jsonify({"success": False, "error": "系统未初始化"}), 503 + result = terminal.delete_conversation(conv_id) + status = 200 if result.get("success") else 404 + return jsonify(result), status + + @api_v1_bp.route("/tasks/", methods=["GET"]) @api_token_required def get_task_events(task_id: str): @@ -235,4 +371,129 @@ def download_file_api(): return send_file(target, as_attachment=True, download_name=target.name) +@api_v1_bp.route("/prompts", methods=["GET"]) +@api_token_required +def list_prompts_api(): + username = session.get("username") + _, workspace = get_user_resources(username) + if not workspace: + return jsonify({"success": False, "error": "系统未初始化"}), 503 + base = _prompt_dir(workspace) + items = [] + for p in sorted(base.glob("*.txt")): + stat = p.stat() + items.append({ + "name": p.stem, + "size": stat.st_size, + "updated_at": stat.st_mtime, + }) + return jsonify({"success": True, "items": items}) + + +@api_v1_bp.route("/prompts/", methods=["GET"]) +@api_token_required +def get_prompt_api(name: str): + username = session.get("username") + _, workspace = get_user_resources(username) + if not workspace: + return jsonify({"success": False, "error": "系统未初始化"}), 503 + p = _prompt_dir(workspace) / f"{name}.txt" + if not p.exists(): + return jsonify({"success": False, "error": "prompt 不存在"}), 404 + return jsonify({"success": True, "name": name, "content": p.read_text(encoding="utf-8")}) + + +@api_v1_bp.route("/prompts", methods=["POST"]) +@api_token_required +def create_prompt_api(): + username = session.get("username") + _, workspace = get_user_resources(username) + if not workspace: + return jsonify({"success": False, "error": "系统未初始化"}), 503 + payload = request.get_json() or {} + name = (payload.get("name") or "").strip() + content = payload.get("content") or "" + if not name: + return jsonify({"success": False, "error": "name 不能为空"}), 400 + p = _prompt_dir(workspace) / f"{name}.txt" + p.write_text(content, encoding="utf-8") + return jsonify({"success": True, "name": name}) + + +@api_v1_bp.route("/personalizations", methods=["GET"]) +@api_token_required +def list_personalizations_api(): + username = session.get("username") + _, workspace = get_user_resources(username) + if not workspace: + return jsonify({"success": False, "error": "系统未初始化"}), 503 + base = _personalization_dir(workspace) + items = [] + for p in sorted(base.glob("*.json")): + stat = p.stat() + items.append({ + "name": p.stem, + "size": stat.st_size, + "updated_at": stat.st_mtime, + }) + return jsonify({"success": True, "items": items}) + + +@api_v1_bp.route("/personalizations/", methods=["GET"]) +@api_token_required +def get_personalization_api(name: str): + username = session.get("username") + _, workspace = get_user_resources(username) + if not workspace: + return jsonify({"success": False, "error": "系统未初始化"}), 503 + p = _personalization_dir(workspace) / f"{name}.json" + if not p.exists(): + return jsonify({"success": False, "error": "personalization 不存在"}), 404 + try: + content = json.loads(p.read_text(encoding="utf-8")) + except Exception as exc: + return jsonify({"success": False, "error": f"解析失败: {exc}"}), 500 + return jsonify({"success": True, "name": name, "content": content}) + + +@api_v1_bp.route("/personalizations", methods=["POST"]) +@api_token_required +def create_personalization_api(): + username = session.get("username") + _, workspace = get_user_resources(username) + if not workspace: + return jsonify({"success": False, "error": "系统未初始化"}), 503 + payload = request.get_json() or {} + name = (payload.get("name") or "").strip() + content = payload.get("content") + if not name: + return jsonify({"success": False, "error": "name 不能为空"}), 400 + if content is None: + return jsonify({"success": False, "error": "content 不能为空"}), 400 + p = _personalization_dir(workspace) / f"{name}.json" + try: + p.write_text(json.dumps(content, ensure_ascii=False, indent=2), encoding="utf-8") + except Exception as exc: + return jsonify({"success": False, "error": f"保存失败: {exc}"}), 500 + return jsonify({"success": True, "name": name}) + + +@api_v1_bp.route("/models", methods=["GET"]) +def list_models_api(): + items = [] + for key, profile in MODEL_PROFILES.items(): + items.append({ + "model_key": key, + "name": profile.get("name", key), + "supports_thinking": profile.get("supports_thinking", False), + "fast_only": profile.get("fast_only", False), + }) + return jsonify({"success": True, "items": items}) + + +@api_v1_bp.route("/health", methods=["GET"]) +def health_api(): + return jsonify({"success": True, "status": "ok"}) + + __all__ = ["api_v1_bp"] diff --git a/server/context.py b/server/context.py index 083966b..2a1c502 100644 --- a/server/context.py +++ b/server/context.py @@ -8,6 +8,8 @@ from core.web_terminal import WebTerminal from modules.gui_file_manager import GuiFileManager from modules.upload_security import UploadQuarantineManager, UploadSecurityError from modules.personalization_manager import load_personalization_config +import json +from pathlib import Path from modules.usage_tracker import UsageTracker from . import state @@ -164,6 +166,43 @@ def emit_user_quota_update(username: Optional[str]): pass +def apply_conversation_overrides(terminal: WebTerminal, workspace, conversation_id: Optional[str]): + """根据对话元数据应用自定义 prompt / personalization(仅 API 用途)。""" + if not conversation_id: + return + conv_path = Path(workspace.data_dir) / "conversations" / f"{conversation_id}.json" + if not conv_path.exists(): + return + try: + data = json.loads(conv_path.read_text(encoding="utf-8")) + meta = data.get("metadata") or {} + prompt_name = meta.get("custom_prompt_name") + personalization_name = meta.get("personalization_name") + # prompt override + if prompt_name: + prompt_path = Path(workspace.data_dir) / "prompts" / f"{prompt_name}.txt" + if prompt_path.exists(): + terminal.context_manager.custom_system_prompt = prompt_path.read_text(encoding="utf-8") + else: + terminal.context_manager.custom_system_prompt = None + else: + terminal.context_manager.custom_system_prompt = None + # personalization override + if personalization_name: + pers_path = Path(workspace.data_dir) / "personalization" / f"{personalization_name}.json" + if pers_path.exists(): + try: + terminal.context_manager.custom_personalization_config = json.loads(pers_path.read_text(encoding="utf-8")) + except Exception: + terminal.context_manager.custom_personalization_config = None + else: + terminal.context_manager.custom_personalization_config = None + else: + terminal.context_manager.custom_personalization_config = None + except Exception as exc: + debug_log(f"[apply_overrides] 读取对话元数据失败: {exc}") + + def with_terminal(func): """注入用户专属终端和工作区""" @wraps(func) @@ -251,6 +290,11 @@ def ensure_conversation_loaded(terminal: WebTerminal, conversation_id: Optional[ session['thinking_mode'] = terminal.thinking_mode except Exception: pass + # 应用对话级自定义 prompt / personalization(仅 API) + try: + apply_conversation_overrides(terminal, workspace, conversation_id) + except Exception as exc: + debug_log(f"[apply_overrides] 失败: {exc}") return conversation_id, created_new diff --git a/utils/context_manager.py b/utils/context_manager.py index 73a3dfb..56dac81 100644 --- a/utils/context_manager.py +++ b/utils/context_manager.py @@ -943,6 +943,11 @@ class ContextManager: def load_prompt(self, prompt_name: str) -> str: """加载prompt模板""" + # 允许覆盖主系统提示(仅对 main_system 系列生效) + if prompt_name.startswith("main_system"): + override = getattr(self, "custom_system_prompt", None) + if override: + return override prompt_file = Path(PROMPTS_DIR) / f"{prompt_name}.txt" if prompt_file.exists(): with open(prompt_file, 'r', encoding='utf-8') as f: