diff --git a/backend/app/providers/factory.py b/backend/app/providers/factory.py index 8683da1..b7a3219 100644 --- a/backend/app/providers/factory.py +++ b/backend/app/providers/factory.py @@ -22,6 +22,7 @@ class ProviderFactory: from contextlib import aclosing from uuid import uuid4 from app.providers.context_budget import prepare_context + from app.services.persona_settings import apply_global_persona from app.providers.base import ProviderError from app.contracts import ModelEvent, ModelEventType from datetime import datetime, timezone @@ -29,7 +30,7 @@ class ProviderFactory: async def complete_with_trace(request): token = usage_context.set({"request_id": uuid4().hex, "run_id": request.metadata.get("run_id")}) try: - request = await prepare_context(request, config, complete) + request = await prepare_context(apply_global_persona(request), config, complete) return await complete(request) finally: usage_context.reset(token) @@ -38,7 +39,7 @@ class ProviderFactory: token = usage_context.set({"request_id": uuid4().hex, "run_id": request.metadata.get("run_id")}) try: original = request - request = await prepare_context(request, config, complete, stream=True) + request = await prepare_context(apply_global_persona(request), config, complete, stream=True) if request.messages != original.messages: yield ModelEvent(event=ModelEventType.context_status, sequence=sequence, timestamp=datetime.now(timezone.utc), data={"message": "本次请求已压缩旧对话;原始记录保留,摘要生成计入用量。"}) sequence += 1 diff --git a/backend/app/routes.py b/backend/app/routes.py index a2dc87b..a35e598 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -10,6 +10,7 @@ from fastapi.responses import StreamingResponse from app.agent import AgentCapacityError, AgentRunNotFoundError from app.container import container +from app.services.persona_settings import PersonaSettings, load_persona, save_persona from app.contracts import ( AgentRun, AgentRunCreateRequest, @@ -102,6 +103,7 @@ from app.agent import AgentCapacityError, AgentRunNotFoundError from app.benchmarks import datasets as benchmark_datasets from app.benchmarks import service as benchmark_service from app.container import container +from app.services.persona_settings import PersonaSettings, load_persona, save_persona from app.errors import ApiError from app.extensions import ExtensionError from app.extensions.mcp_registry import McpRegistryError @@ -1469,3 +1471,15 @@ async def get_benchmark_report(run_id: str) -> BenchmarkReport: 404, "BENCHMARK_RUN_NOT_FOUND", "benchmark report not found", {"run_id": run_id} ) return report + + + + +@router.get("/settings/persona", response_model=PersonaSettings, tags=["Settings"]) +async def get_global_persona(): + return load_persona() + + +@router.put("/settings/persona", response_model=PersonaSettings, tags=["Settings"]) +async def put_global_persona(request: PersonaSettings): + return save_persona(request) diff --git a/backend/app/services/persona_settings.py b/backend/app/services/persona_settings.py new file mode 100644 index 0000000..6998d3c --- /dev/null +++ b/backend/app/services/persona_settings.py @@ -0,0 +1,65 @@ +"""One persistent persona for all configured chat/agent providers on this AI Core.""" +from contextlib import closing +from pydantic import BaseModel, ConfigDict, Field +from app.database.db import connect + + +class DialoguePair(BaseModel): + model_config = ConfigDict(extra="forbid") + user: str = Field(default="", max_length=8000) + assistant: str = Field(default="", max_length=8000) + + +class PersonaSettings(BaseModel): + model_config = ConfigDict(extra="forbid") + version: int = Field(default=0, ge=0) + name: str = Field(default="", max_length=128) + system_prompt: str = Field(default="", max_length=16000) + dialogue_pairs: list[DialoguePair] = Field(default_factory=list, max_length=20) + + +def connection(): + conn = connect() + conn.execute("CREATE TABLE IF NOT EXISTS global_persona (id INTEGER PRIMARY KEY CHECK(id=1), data TEXT NOT NULL)") + return conn + + +def load_persona(): + with closing(connection()) as conn: + row = conn.execute("SELECT data FROM global_persona WHERE id=1").fetchone() + return PersonaSettings.model_validate_json(row[0]) if row else PersonaSettings() + + +def save_persona(settings): + from app.errors import ApiError + with closing(connection()) as conn: + conn.execute("BEGIN IMMEDIATE") + try: + row = conn.execute("SELECT data FROM global_persona WHERE id=1").fetchone() + current = PersonaSettings.model_validate_json(row[0]) if row else PersonaSettings() + if current.version != settings.version: + raise ApiError(409, "PERSONA_VERSION_CONFLICT", "全局人设已被修改,请重新打开表单后保存。") + updated = settings.model_copy(update={"version": current.version + 1}) + conn.execute("INSERT OR REPLACE INTO global_persona(id,data) VALUES(1,?)", (updated.model_dump_json(),)) + conn.commit() + return updated + except BaseException: + conn.rollback() + raise + + +def apply_global_persona(request): + settings = load_persona() + parts = [request.system or ""] + if settings.system_prompt.strip(): + parts.append("全局人设 / Global persona\n" + settings.system_prompt.strip()) + examples = [] + for pair in settings.dialogue_pairs: + lines = [] + if pair.user.strip(): lines.append("User: " + pair.user.strip()) + if pair.assistant.strip(): lines.append("Assistant: " + pair.assistant.strip()) + if lines: examples.append("\n".join(lines)) + if examples: + parts.append("预设对话示例 / Example dialogue\n" + "\n\n".join(examples)) + system = "\n\n".join(part for part in parts if part.strip()) + return request.model_copy(update={"system": system or None}) diff --git a/backend/tests/test_global_persona.py b/backend/tests/test_global_persona.py new file mode 100644 index 0000000..abc743c --- /dev/null +++ b/backend/tests/test_global_persona.py @@ -0,0 +1,48 @@ +import asyncio +import pytest +from app.contracts import ModelRequest, Message, ProviderConfig +from app.errors import ApiError +from app.services.persona_settings import PersonaSettings, DialoguePair, save_persona, load_persona, apply_global_persona + + +def request(): + return ModelRequest(provider_id="p", model="test", system="任务要求", messages=[Message(role="user", content="hello")]) + + +def test_global_persona_persists_and_keeps_task_prompt(): + save_persona(PersonaSettings(name="老师", system_prompt="耐心解释", dialogue_pairs=[DialoguePair(user="问题", assistant="回答"), DialoguePair()])) + assert load_persona().version == 1 + original = request() + assembled = apply_global_persona(original) + assert assembled.system == "任务要求\n\n全局人设 / Global persona\n耐心解释\n\n预设对话示例 / Example dialogue\nUser: 问题\nAssistant: 回答" + assert original.system == "任务要求" + with pytest.raises(ApiError): + save_persona(PersonaSettings()) + + +def test_empty_persona_omits_all_global_sections(): + save_persona(PersonaSettings(system_prompt=" ", dialogue_pairs=[DialoguePair(user=" ")])) + assert apply_global_persona(request()).system == "任务要求" + + +def test_existing_provider_reads_latest_global_persona_for_complete_and_stream(monkeypatch): + from app.providers.factory import ProviderFactory + from app.providers.base import ProviderTurn + seen = [] + class Adapter: + async def complete(self, req): + seen.append(req.system) + return ProviderTurn(text="ok") + async def stream(self, req): + seen.append(req.system) + if False: yield + factory = ProviderFactory(None) + monkeypatch.setattr(factory, "_build", lambda _: Adapter()) + adapter = factory.build(ProviderConfig(provider_id="p",name="test",provider_type="openai_compatible")) + save_persona(PersonaSettings(system_prompt="全局人设")) + async def run(): + await adapter.complete(request()) + async for _ in adapter.stream(request()): pass + asyncio.run(run()) + assert len(seen) == 2 + assert all(text.count("全局人设 / Global persona") == 1 for text in seen) diff --git a/docs/development/模型上下文管理.md b/docs/development/模型上下文管理.md index 64732b9..b7beada 100644 --- a/docs/development/模型上下文管理.md +++ b/docs/development/模型上下文管理.md @@ -36,3 +36,10 @@ Provider 表单按精确模型 ID 保存 `context_policies`,包含窗口、输 ## 验证范围 离线测试覆盖预算触发、模型隔离、无副作用压缩、工具历史保护、单条输入超限、无效摘要、输出覆盖参数、流式错误事件以及配置校验。前端覆盖保存恢复与切换地址清理配置。没有调用用户的真实厂商账号进行收费验收。 + + +## 全局人设 + +`GET /api/settings/persona` 和 `PUT /api/settings/persona` 管理此 AI Core 的唯一全局人设,保存在 SQLite 中。包含名称、系统提示词、结构化 user/assistant 对话对和乐观锁版本。设置页“通用”及聊天页均可打开同一表单。头像仍仅保存在本机浏览器。 + +所有通过 ProviderFactory 创建的模型调用(普通对话与智能体、流式与非流式)在上下文预算检查前读取最新全局配置,将非空系统人设与对话示例追加到调用方原有系统提示词,保留 RAG 和任务约束。浏览器不再拼接本地人设,因此不会因更换浏览器丢失或重复注入。清空并保存后不再注入。厂商测试推理同样经过此边界;应用内部的历史摘要生成使用独立摘要提示词,避免人设干扰摘要格式。Mock 演示适配器不模拟真实系统提示词执行效果。 diff --git a/frontend/src/components/common/SecondarySidebar.spec.ts b/frontend/src/components/common/SecondarySidebar.spec.ts index a9f7091..dc8fec2 100644 --- a/frontend/src/components/common/SecondarySidebar.spec.ts +++ b/frontend/src/components/common/SecondarySidebar.spec.ts @@ -4,6 +4,25 @@ import { mount } from '@vue/test-utils' import { createRouter, createMemoryHistory } from 'vue-router' import SecondarySidebar from './SecondarySidebar.vue' +it('keeps conversation and file widths separate across route changes', async () => { + localStorage.setItem('chat-sidebar-width', '320') + localStorage.setItem('workspace-sidebar-width', '240') + const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '
' } }] }) + await router.push('/') + const wrapper = mount(SecondarySidebar, {props:{component:'conversation-list'}, global:{plugins:[router],stubs:{ConversationListPanel:true,FileTreePanel:true}}}) + await wrapper.vm.$nextTick() + expect(wrapper.get('aside').attributes('style')).toContain('320px') + await wrapper.get('[role="separator"]').trigger('keydown', {key:'ArrowRight'}) + expect(localStorage.getItem('chat-sidebar-width')).toBe('336') + await wrapper.setProps({component:'file-tree'}) + expect(wrapper.get('aside').attributes('style')).toContain('240px') + await wrapper.setProps({component:'conversation-list'}) + expect(wrapper.get('aside').attributes('style')).toContain('336px') + wrapper.unmount() + localStorage.removeItem('chat-sidebar-width') + localStorage.removeItem('workspace-sidebar-width') +}) + it('resizes by keyboard, clamps bounds and restores the saved width', async () => { localStorage.removeItem('workspace-sidebar-width') const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '' } }] }) diff --git a/frontend/src/components/common/SecondarySidebar.vue b/frontend/src/components/common/SecondarySidebar.vue index 0a2440c..5d40ef7 100644 --- a/frontend/src/components/common/SecondarySidebar.vue +++ b/frontend/src/components/common/SecondarySidebar.vue @@ -1,5 +1,5 @@ -