feat: add prompt/personalization selection and conversation APIs

This commit is contained in:
JOJO 2026-01-24 13:33:50 +08:00
parent 82e7d0680a
commit 9f7b443268
7 changed files with 380 additions and 4 deletions

View File

@ -24,6 +24,10 @@
4. 停止任务:`POST /api/v1/tasks/<task_id>/cancel` 4. 停止任务:`POST /api/v1/tasks/<task_id>/cancel`
5. 文件上传(仅 user_upload`POST /api/v1/files/upload` 5. 文件上传(仅 user_upload`POST /api/v1/files/upload`
6. 文件浏览/下载:`GET /api/v1/files`、`GET /api/v1/files/download` 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`:发送消息/轮询/停止 - `messages_tasks.md`:发送消息/轮询/停止
- `events.md`:事件流格式与事件类型说明(与 WebSocket 同源) - `events.md`:事件流格式与事件类型说明(与 WebSocket 同源)
- `files.md`:上传/列目录/下载 - `files.md`:上传/列目录/下载
- `prompts_personalization.md`Prompt 与个性化管理
- `errors.md`HTTP 错误码与常见排查 - `errors.md`HTTP 错误码与常见排查
- `examples.md`curl/Python/JS/Flutter 示例 - `examples.md`curl/Python/JS/Flutter 示例
- `openapi.yaml`OpenAPI 3.0 规范(可导入 Postman/Swagger - `openapi.yaml`OpenAPI 3.0 规范(可导入 Postman/Swagger

View File

@ -56,6 +56,8 @@ Body(JSON)
| `run_mode` | string/null | 否 | 运行模式:`"fast"` \| `"thinking"` \| `"deep"`;若传入则优先使用 | | `run_mode` | string/null | 否 | 运行模式:`"fast"` \| `"thinking"` \| `"deep"`;若传入则优先使用 |
| `thinking_mode` | boolean/null | 否 | 兼容字段true=thinkingfalse=fast`run_mode` 为空时才使用 | | `thinking_mode` | boolean/null | 否 | 兼容字段true=thinkingfalse=fast`run_mode` 为空时才使用 |
| `max_iterations` | integer/null | 否 | 最大迭代次数,默认服务端配置为 **100**`config.MAX_ITERATIONS_PER_TASK`);传入可覆盖 | | `max_iterations` | integer/null | 否 | 最大迭代次数,默认服务端配置为 **100**`config.MAX_ITERATIONS_PER_TASK`);传入可覆盖 |
| `prompt_name` | string/null | 否 | 选择自定义主 prompt存放于 `data/prompts/<name>.txt`);若不存在返回 404 |
| `personalization_name` | string/null | 否 | 选择个性化配置(`data/personalization/<name>.json`);若不存在返回 404 |
| `images` | string[] | 否 | 图片路径列表(服务端可访问的路径);一般配合特定模型使用 | | `images` | string[] | 否 | 图片路径列表(服务端可访问的路径);一般配合特定模型使用 |
优先级:`run_mode` > `thinking_mode` > 终端当前配置。`run_mode="deep"` 将启用深度思考模式(若模型与配置允许)。 优先级:`run_mode` > `thinking_mode` > 终端当前配置。`run_mode="deep"` 将启用深度思考模式(若模型与配置允许)。

View File

@ -0,0 +1,58 @@
# Prompt 与个性化Personalization管理 API
## Prompt主提示词存储位置
- 路径:`api/users/<user>/data/prompts/<name>.txt`
- 内容格式:纯文本
## 个性化存储位置
- 路径:`api/users/<user>/data/personalization/<name>.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 / 默认个性化配置。

View File

@ -2542,7 +2542,8 @@ class MainTerminal:
) )
messages.append({"role": "system", "content": thinking_prompt}) 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) personalization_block = build_personalization_prompt(personalization_config, include_header=False)
if personalization_block: if personalization_block:
personalization_template = self.load_prompt("personalization").strip() personalization_template = self.load_prompt("personalization").strip()

View File

@ -1,18 +1,20 @@
"""API v1Bearer Token 版轻量接口(后台任务 + 轮询 + 文件)。""" """API v1Bearer Token 版轻量接口(后台任务 + 轮询 + 文件)。"""
from __future__ import annotations from __future__ import annotations
import os import os
import json
import zipfile import zipfile
from io import BytesIO from io import BytesIO
from pathlib import Path 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 flask import Blueprint, request, jsonify, send_file, session
from .api_auth import api_token_required from .api_auth import api_token_required
from .tasks import task_manager 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 .files import sanitize_filename_preserve_unicode
from .utils_common import debug_log from .utils_common import debug_log
from config.model_profiles import MODEL_PROFILES
api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1") 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 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_v1_bp.route("/conversations", methods=["POST"])
@api_token_required @api_token_required
def create_conversation_api(): def create_conversation_api():
@ -37,10 +73,21 @@ def create_conversation_api():
terminal, workspace = get_user_resources(username) terminal, workspace = get_user_resources(username)
if not terminal: if not terminal:
return jsonify({"success": False, "error": "系统未初始化"}), 503 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() result = terminal.create_new_conversation()
if not result.get("success"): if not result.get("success"):
return jsonify({"success": False, "error": result.get("error") or "创建对话失败"}), 500 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"]) @api_v1_bp.route("/messages", methods=["POST"])
@ -55,6 +102,8 @@ def send_message_api():
thinking_mode = payload.get("thinking_mode") thinking_mode = payload.get("thinking_mode")
run_mode = payload.get("run_mode") run_mode = payload.get("run_mode")
max_iterations = payload.get("max_iterations") max_iterations = payload.get("max_iterations")
prompt_name = payload.get("prompt_name")
personalization_name = payload.get("personalization_name")
if not message and not images: if not message and not images:
return jsonify({"success": False, "error": "消息不能为空"}), 400 return jsonify({"success": False, "error": "消息不能为空"}), 400
@ -66,6 +115,29 @@ def send_message_api():
except Exception as exc: except Exception as exc:
return jsonify({"success": False, "error": f"对话加载失败: {exc}"}), 400 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: try:
rec = task_manager.create_chat_task( rec = task_manager.create_chat_task(
username=username, username=username,
@ -91,6 +163,70 @@ def send_message_api():
}), 202 }), 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/<conv_id>", 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/<conv_id>", 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/<task_id>", methods=["GET"]) @api_v1_bp.route("/tasks/<task_id>", methods=["GET"])
@api_token_required @api_token_required
def get_task_events(task_id: str): 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) 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/<name>", 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/<name>", 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"] __all__ = ["api_v1_bp"]

View File

@ -8,6 +8,8 @@ from core.web_terminal import WebTerminal
from modules.gui_file_manager import GuiFileManager from modules.gui_file_manager import GuiFileManager
from modules.upload_security import UploadQuarantineManager, UploadSecurityError from modules.upload_security import UploadQuarantineManager, UploadSecurityError
from modules.personalization_manager import load_personalization_config from modules.personalization_manager import load_personalization_config
import json
from pathlib import Path
from modules.usage_tracker import UsageTracker from modules.usage_tracker import UsageTracker
from . import state from . import state
@ -164,6 +166,43 @@ def emit_user_quota_update(username: Optional[str]):
pass 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): def with_terminal(func):
"""注入用户专属终端和工作区""" """注入用户专属终端和工作区"""
@wraps(func) @wraps(func)
@ -251,6 +290,11 @@ def ensure_conversation_loaded(terminal: WebTerminal, conversation_id: Optional[
session['thinking_mode'] = terminal.thinking_mode session['thinking_mode'] = terminal.thinking_mode
except Exception: except Exception:
pass 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 return conversation_id, created_new

View File

@ -943,6 +943,11 @@ class ContextManager:
def load_prompt(self, prompt_name: str) -> str: def load_prompt(self, prompt_name: str) -> str:
"""加载prompt模板""" """加载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" prompt_file = Path(PROMPTS_DIR) / f"{prompt_name}.txt"
if prompt_file.exists(): if prompt_file.exists():
with open(prompt_file, 'r', encoding='utf-8') as f: with open(prompt_file, 'r', encoding='utf-8') as f: