From a1ab1024f03e0e137833aea395037f50dc1dd948 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Sat, 5 Sep 2026 22:26:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=85=A8=E5=B1=80?= =?UTF-8?q?=E4=BA=BA=E8=AE=BE=E4=B8=8E=E5=A4=B4=E5=83=8F=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E5=B9=B6=E4=BC=98=E5=8C=96=E5=AF=B9=E8=AF=9D=E5=8F=8A=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/providers/factory.py | 5 +- backend/app/routes.py | 14 ++ backend/app/services/persona_settings.py | 65 ++++++++++ backend/tests/test_global_persona.py | 48 +++++++ docs/development/模型上下文管理.md | 7 + .../common/SecondarySidebar.spec.ts | 19 +++ .../components/common/SecondarySidebar.vue | 19 ++- .../features/chat/ChatPersonaDialog.spec.ts | 46 +++++++ .../src/features/chat/ChatPersonaDialog.vue | 122 ++++++++++++++++++ frontend/src/features/chat/ChatView.spec.ts | 13 +- frontend/src/features/chat/ChatView.vue | 27 +++- .../features/settings/ProviderForm.spec.ts | 12 ++ .../src/features/settings/ProviderForm.vue | 17 ++- .../src/features/settings/SettingsView.vue | 4 + .../src/features/settings/UsageChart.spec.ts | 8 +- frontend/src/features/settings/UsageChart.vue | 11 +- frontend/src/stores/chat.spec.ts | 12 ++ frontend/src/stores/chatPreferences.ts | 23 ++++ 18 files changed, 450 insertions(+), 22 deletions(-) create mode 100644 backend/app/services/persona_settings.py create mode 100644 backend/tests/test_global_persona.py create mode 100644 frontend/src/features/chat/ChatPersonaDialog.spec.ts create mode 100644 frontend/src/features/chat/ChatPersonaDialog.vue create mode 100644 frontend/src/stores/chatPreferences.ts 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 @@ diff --git a/frontend/src/features/chat/ChatPersonaDialog.spec.ts b/frontend/src/features/chat/ChatPersonaDialog.spec.ts new file mode 100644 index 0000000..e59909c --- /dev/null +++ b/frontend/src/features/chat/ChatPersonaDialog.spec.ts @@ -0,0 +1,46 @@ +// @vitest-environment happy-dom +import { beforeEach, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import ChatPersonaDialog from './ChatPersonaDialog.vue' +import { useChatPreferences } from '@/stores/chatPreferences' + +import { apiClient } from '@/services/apiClient' +vi.mock('@/services/apiClient', () => ({apiClient:{get:vi.fn(),put:vi.fn()}})) +beforeEach(() => { vi.mocked(apiClient.get).mockResolvedValue({version:0,name:'',system_prompt:'',dialogue_pairs:[]}); vi.mocked(apiClient.put).mockImplementation(async (_url, value) => ({...(value as object),version:1})); localStorage.removeItem('chat-persona-preferences-v1'); setActivePinia(createPinia()) }) + +it('saves global prompt and structured dialogue pairs to the AI Core', async () => { + const wrapper = mount(ChatPersonaDialog) + await flushPromises() + await wrapper.get('.persona-prompt').setValue('耐心的老师') + await wrapper.findAll('button').find(b => b.text().includes('添加对话对'))!.trigger('click') + const inputs = wrapper.findAll('.dialogue-pairs textarea') + await inputs[0]!.setValue('你好') + await inputs[1]!.setValue('你好,我是老师') + await wrapper.get('form').trigger('submit') + await flushPromises() + expect(apiClient.put).toHaveBeenCalledWith('/api/settings/persona', expect.objectContaining({system_prompt:'耐心的老师',dialogue_pairs:[{user:'你好',assistant:'你好,我是老师'}]})) + expect(wrapper.emitted('close')).toHaveLength(1) + wrapper.unmount() +}) + +it('keeps unsaved edits out of active preferences', async () => { + const wrapper = mount(ChatPersonaDialog) + await flushPromises() + await wrapper.findAll('textarea')[0]!.setValue('未保存人设') + await wrapper.get('dialog').trigger('cancel') + expect(useChatPreferences().settings.persona).toBe('') + expect(wrapper.emitted('close')).toHaveLength(1) + wrapper.unmount() +}) + +it('persists separate local avatars and rejects remote avatar URLs', () => { + const preferences = useChatPreferences() + const aiAvatar = 'data:image/png;base64,aGVsbG8=' + const userAvatar = 'data:image/webp;base64,d29ybGQ=' + preferences.save({...preferences.settings,aiAvatar,userAvatar}) + setActivePinia(createPinia()) + expect(useChatPreferences().settings).toMatchObject({aiAvatar,userAvatar}) + expect(() => useChatPreferences().save({...preferences.settings,aiAvatar:'https://example.com/avatar.png'})).toThrow() + expect(useChatPreferences().settings.aiAvatar).toBe(aiAvatar) +}) diff --git a/frontend/src/features/chat/ChatPersonaDialog.vue b/frontend/src/features/chat/ChatPersonaDialog.vue new file mode 100644 index 0000000..5fcdf76 --- /dev/null +++ b/frontend/src/features/chat/ChatPersonaDialog.vue @@ -0,0 +1,122 @@ + + +