From 55b7c5b0ea9d9e5fae6deda99c4c1d784d6f64df Mon Sep 17 00:00:00 2001 From: Nelson <1475262689@qq.com> Date: Tue, 7 Jul 2026 12:58:26 +0800 Subject: [PATCH] Complete admin model management --- .../apps/admin-web/src/App.vue | 358 ++++++++++++++++-- .../apps/admin-web/src/services/api.ts | 7 + .../apps/admin-web/src/styles.css | 24 ++ .../apps/admin-web/src/types/api.ts | 14 + .../versions/0002_expand_model_config.py | 56 +++ .../apps/backend/app/api/admin_settings.py | 86 +++++ .../apps/backend/app/models/ai_config.py | 14 + .../apps/backend/app/schemas/admin.py | 18 +- .../backend/app/services/model_service.py | 193 +++++++++- .../2026-07-06-decision-register.md | 6 +- .../2026-07-07-model-management-completion.md | 122 ++++++ .../development_records/README.md | 1 + 12 files changed, 859 insertions(+), 40 deletions(-) create mode 100644 ai_knowledge_base_v2/apps/backend/alembic/versions/0002_expand_model_config.py create mode 100644 ai_knowledge_base_v2/development_records/2026-07-07-model-management-completion.md diff --git a/ai_knowledge_base_v2/apps/admin-web/src/App.vue b/ai_knowledge_base_v2/apps/admin-web/src/App.vue index 80f752d..ab66a6c 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/App.vue +++ b/ai_knowledge_base_v2/apps/admin-web/src/App.vue @@ -32,6 +32,7 @@ const promptContent = ref(""); const chatDetail = ref(null); const chatDetailOpen = ref(false); const recordTab = ref("chats"); +const editingModelId = ref(null); const knowledgeForm = reactive({ name: "", @@ -42,15 +43,115 @@ const knowledgeForm = reactive({ }); const modelForm = reactive({ - provider: "openai-compatible", + provider: "openai", + displayName: "", + apiType: "openai_compatible", modelName: "", + baseUrl: "https://api.openai.com/v1", apiUrl: "", apiKey: "", - temperature: 0.2, + authType: "bearer", + apiVersion: "", + temperature: 0.2 as number | null, + topP: null as number | null, + topK: null as number | null, + presencePenalty: null as number | null, + frequencyPenalty: null as number | null, maxToken: 1024, + contextWindow: null as number | null, + streamEnabled: 1, + responseFormat: "", + extraParams: "", + remark: "", timeoutSecond: 30, }); +const modelPresets = [ + { + label: "OpenAI", + provider: "openai", + apiType: "openai_compatible", + baseUrl: "https://api.openai.com/v1", + apiUrl: "", + authType: "bearer", + }, + { + label: "DeepSeek", + provider: "deepseek", + apiType: "openai_compatible", + baseUrl: "https://api.deepseek.com/v1", + apiUrl: "", + authType: "bearer", + }, + { + label: "通义千问 DashScope", + provider: "qwen", + apiType: "openai_compatible", + baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", + apiUrl: "", + authType: "bearer", + }, + { + label: "Kimi / Moonshot", + provider: "moonshot", + apiType: "openai_compatible", + baseUrl: "https://api.moonshot.cn/v1", + apiUrl: "", + authType: "bearer", + }, + { + label: "硅基流动 SiliconFlow", + provider: "siliconflow", + apiType: "openai_compatible", + baseUrl: "https://api.siliconflow.cn/v1", + apiUrl: "", + authType: "bearer", + }, + { + label: "火山方舟 Ark", + provider: "volcengine-ark", + apiType: "openai_compatible", + baseUrl: "https://ark.cn-beijing.volces.com/api/v3", + apiUrl: "", + authType: "bearer", + }, + { + label: "Anthropic Claude", + provider: "anthropic", + apiType: "anthropic_messages", + baseUrl: "https://api.anthropic.com", + apiUrl: "", + authType: "api_key", + apiVersion: "2023-06-01", + temperature: null, + topP: null, + topK: null, + }, + { + label: "Google Gemini", + provider: "gemini", + apiType: "gemini_generate_content", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + apiUrl: "", + authType: "api_key", + temperature: null, + topP: null, + topK: null, + }, +]; + +const nullableModelFields = [ + "temperature", + "topP", + "topK", + "presencePenalty", + "frequencyPenalty", + "contextWindow", + "responseFormat", + "extraParams", + "remark", +] as const; + const configForm = reactive({ configKey: "daily_chat_limit", configValue: "100", @@ -143,26 +244,141 @@ async function resetPrompt() { } async function saveModel() { - await api.createModel({ ...modelForm }); - ElMessage.success("模型已新增"); - Object.assign(modelForm, { - provider: "openai-compatible", - modelName: "", - apiUrl: "", - apiKey: "", - temperature: 0.2, - maxToken: 1024, - timeoutSecond: 30, - }); + if (!validateModelForm()) return; + const payload = buildModelPayload(); + if (editingModelId.value) { + await api.updateModel(editingModelId.value, payload); + ElMessage.success("模型已更新"); + } else { + await api.createModel(payload); + ElMessage.success("模型已新增"); + } + resetModelForm(); await loadCurrentMenu(); } +function buildModelPayload() { + const payload = { ...modelForm } as Record; + nullableModelFields.forEach((field) => { + if (payload[field] === undefined || payload[field] === "") { + payload[field] = null; + } + }); + return payload; +} + async function enableModel(id: number) { await api.enableModel(id); ElMessage.success("模型已启用"); await loadCurrentMenu(); } +async function testModel(id: number) { + const result = await api.testModel(id); + if (result.ok) { + ElMessage.success(result.message); + } else { + ElMessage.error(result.message); + } +} + +function applyModelPreset(label: string) { + const preset = modelPresets.find((item) => item.label === label); + if (!preset) return; + Object.assign( + modelForm, + { + apiVersion: "", + authType: "bearer", + temperature: 0.2, + topP: null, + topK: null, + presencePenalty: null, + frequencyPenalty: null, + responseFormat: "", + streamEnabled: 1, + }, + preset, + ); +} + +function editModel(row: ModelItem) { + editingModelId.value = row.id; + Object.assign(modelForm, { + provider: row.provider, + displayName: row.displayName ?? "", + apiType: row.apiType, + modelName: row.modelName, + baseUrl: row.baseUrl ?? "", + apiUrl: row.apiUrl, + apiKey: "", + authType: row.authType, + apiVersion: row.apiVersion ?? "", + temperature: row.temperature ?? null, + topP: row.topP ?? null, + topK: row.topK ?? null, + presencePenalty: row.presencePenalty ?? null, + frequencyPenalty: row.frequencyPenalty ?? null, + maxToken: row.maxToken ?? 1024, + contextWindow: row.contextWindow ?? null, + streamEnabled: row.streamEnabled, + responseFormat: row.responseFormat ?? "", + extraParams: row.extraParams ?? "", + remark: row.remark ?? "", + timeoutSecond: row.timeoutSecond, + }); +} + +function resetModelForm() { + editingModelId.value = null; + Object.assign(modelForm, { + provider: "openai", + displayName: "", + apiType: "openai_compatible", + modelName: "", + baseUrl: "https://api.openai.com/v1", + apiUrl: "", + apiKey: "", + authType: "bearer", + apiVersion: "", + temperature: 0.2, + topP: null, + topK: null, + presencePenalty: null, + frequencyPenalty: null, + maxToken: 1024, + contextWindow: null, + streamEnabled: 1, + responseFormat: "", + extraParams: "", + remark: "", + timeoutSecond: 30, + }); +} + +function validateModelForm() { + if (!modelForm.apiUrl && !modelForm.baseUrl) { + ElMessage.error("请填写 Base URL 或 API URL"); + return false; + } + if (!editingModelId.value && !modelForm.apiKey) { + ElMessage.error("新增模型必须填写 API Key"); + return false; + } + if (modelForm.extraParams.trim()) { + try { + const extraParams = JSON.parse(modelForm.extraParams); + if (!extraParams || Array.isArray(extraParams) || typeof extraParams !== "object") { + throw new Error("高级 JSON 参数必须是对象"); + } + } catch (error) { + ElMessage.error(error instanceof Error ? error.message : "高级 JSON 参数不是合法 JSON"); + return false; + } + } + return true; +} + async function saveConfig() { await api.saveConfig({ ...configForm }); ElMessage.success("配置已保存"); @@ -299,21 +515,117 @@ function buildChatQuery() { diff --git a/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts b/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts index 7ac7cfe..597f61e 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts +++ b/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts @@ -86,8 +86,15 @@ export const api = { models: () => request("/admin/model/list"), createModel: (payload: Record) => request("/admin/model", { method: "POST", body: JSON.stringify(payload) }), + updateModel: (id: number, payload: Record) => + request(`/admin/model/${id}`, { method: "PUT", body: JSON.stringify(payload) }), enableModel: (modelId: number) => request("/admin/model/enable", { method: "POST", body: JSON.stringify({ modelId }) }), + testModel: (modelId: number) => + request<{ ok: boolean; message: string; answer: string }>(`/admin/model/${modelId}/test`, { + method: "POST", + body: "{}", + }), configs: () => request[]>("/admin/config"), saveConfig: (payload: Record) => request>("/admin/config", { method: "PUT", body: JSON.stringify(payload) }), diff --git a/ai_knowledge_base_v2/apps/admin-web/src/styles.css b/ai_knowledge_base_v2/apps/admin-web/src/styles.css index 9fc5a4b..7d0cc20 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/styles.css +++ b/ai_knowledge_base_v2/apps/admin-web/src/styles.css @@ -185,6 +185,30 @@ textarea { grid-template-columns: repeat(5, minmax(0, 1fr)); } +.model-config-panel { + margin-bottom: 18px; + padding: 18px; + border: 1px solid #dfe8e5; + border-radius: 8px; + background: #ffffff; +} + +.model-config-grid { + display: grid; + grid-template-columns: repeat(4, minmax(180px, 1fr)); + gap: 12px 14px; +} + +.model-config-panel .el-form-item { + margin-bottom: 12px; +} + +.model-config-panel .el-select, +.model-config-panel .el-input-number, +.model-config-panel .el-input { + width: 100%; +} + .actions { margin-top: 12px; } diff --git a/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts b/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts index cb2d58e..0d32842 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts +++ b/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts @@ -42,11 +42,25 @@ export interface KnowledgeItem { export interface ModelItem { id: number; provider: string; + displayName?: string | null; + apiType: string; modelName: string; + baseUrl?: string | null; apiUrl: string; apiKeyMasked: string; + authType: string; + apiVersion?: string | null; temperature?: number | null; + topP?: number | null; + topK?: number | null; + presencePenalty?: number | null; + frequencyPenalty?: number | null; maxToken?: number | null; + contextWindow?: number | null; + streamEnabled: number; + responseFormat?: string | null; + extraParams?: string | null; + remark?: string | null; timeoutSecond: number; enabled: number; } diff --git a/ai_knowledge_base_v2/apps/backend/alembic/versions/0002_expand_model_config.py b/ai_knowledge_base_v2/apps/backend/alembic/versions/0002_expand_model_config.py new file mode 100644 index 0000000..68beb82 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/alembic/versions/0002_expand_model_config.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "0002_expand_model_config" +down_revision = "0001_initial_schema" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("sys_model", sa.Column("display_name", sa.String(length=100), nullable=True)) + op.add_column( + "sys_model", + sa.Column("api_type", sa.String(length=50), nullable=False, server_default="openai_compatible"), + ) + op.add_column("sys_model", sa.Column("base_url", sa.String(length=255), nullable=True)) + op.add_column( + "sys_model", + sa.Column("auth_type", sa.String(length=30), nullable=False, server_default="bearer"), + ) + op.add_column("sys_model", sa.Column("api_version", sa.String(length=50), nullable=True)) + op.add_column("sys_model", sa.Column("top_p", sa.Numeric(4, 2), nullable=True)) + op.add_column("sys_model", sa.Column("top_k", sa.Integer(), nullable=True)) + op.add_column("sys_model", sa.Column("presence_penalty", sa.Numeric(4, 2), nullable=True)) + op.add_column("sys_model", sa.Column("frequency_penalty", sa.Numeric(4, 2), nullable=True)) + op.add_column("sys_model", sa.Column("context_window", sa.Integer(), nullable=True)) + op.add_column( + "sys_model", + sa.Column("stream_enabled", sa.Integer(), nullable=False, server_default="1"), + ) + op.add_column("sys_model", sa.Column("response_format", sa.String(length=50), nullable=True)) + op.add_column("sys_model", sa.Column("extra_params", sa.Text(), nullable=True)) + op.add_column("sys_model", sa.Column("remark", sa.String(length=255), nullable=True)) + + op.alter_column("sys_model", "api_type", server_default=None) + op.alter_column("sys_model", "auth_type", server_default=None) + op.alter_column("sys_model", "stream_enabled", server_default=None) + + +def downgrade() -> None: + op.drop_column("sys_model", "remark") + op.drop_column("sys_model", "extra_params") + op.drop_column("sys_model", "response_format") + op.drop_column("sys_model", "stream_enabled") + op.drop_column("sys_model", "context_window") + op.drop_column("sys_model", "frequency_penalty") + op.drop_column("sys_model", "presence_penalty") + op.drop_column("sys_model", "top_k") + op.drop_column("sys_model", "top_p") + op.drop_column("sys_model", "api_version") + op.drop_column("sys_model", "auth_type") + op.drop_column("sys_model", "base_url") + op.drop_column("sys_model", "api_type") + op.drop_column("sys_model", "display_name") diff --git a/ai_knowledge_base_v2/apps/backend/app/api/admin_settings.py b/ai_knowledge_base_v2/apps/backend/app/api/admin_settings.py index c35687b..2f38278 100644 --- a/ai_knowledge_base_v2/apps/backend/app/api/admin_settings.py +++ b/ai_knowledge_base_v2/apps/backend/app/api/admin_settings.py @@ -11,6 +11,7 @@ from app.models.admin import Admin from app.models.ai_config import ModelConfig, Prompt, SystemConfig from app.schemas.admin import EnableModelRequest, ModelSaveRequest, PromptSaveRequest, SystemConfigSaveRequest from app.services.admin_service import OperationLogService +from app.services.model_service import ModelClientService router = APIRouter() @@ -59,13 +60,29 @@ def create_model( db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin), ) -> dict: + if not payload.apiKey: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="API Key 不能为空") model = ModelConfig( provider=payload.provider, + display_name=payload.displayName, + api_type=payload.apiType, model_name=payload.modelName, + base_url=payload.baseUrl, api_url=payload.apiUrl, api_key=payload.apiKey, + auth_type=payload.authType, + api_version=payload.apiVersion, temperature=payload.temperature, + top_p=payload.topP, + top_k=payload.topK, + presence_penalty=payload.presencePenalty, + frequency_penalty=payload.frequencyPenalty, max_token=payload.maxToken, + context_window=payload.contextWindow, + stream_enabled=payload.streamEnabled, + response_format=payload.responseFormat, + extra_params=payload.extraParams, + remark=payload.remark, timeout_second=payload.timeoutSecond, enabled=0, ) @@ -77,6 +94,46 @@ def create_model( return api_success(_model_dict(model)) +@router.put("/model/{model_id}") +def update_model( + model_id: int, + payload: ModelSaveRequest, + db: Session = Depends(get_db), + current_admin: Admin = Depends(get_current_admin), +) -> dict: + model = db.get(ModelConfig, model_id) + if model is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模型不存在") + + model.provider = payload.provider + model.display_name = payload.displayName + model.api_type = payload.apiType + model.model_name = payload.modelName + model.base_url = payload.baseUrl + model.api_url = payload.apiUrl + if payload.apiKey: + model.api_key = payload.apiKey + model.auth_type = payload.authType + model.api_version = payload.apiVersion + model.temperature = payload.temperature + model.top_p = payload.topP + model.top_k = payload.topK + model.presence_penalty = payload.presencePenalty + model.frequency_penalty = payload.frequencyPenalty + model.max_token = payload.maxToken + model.context_window = payload.contextWindow + model.stream_enabled = payload.streamEnabled + model.response_format = payload.responseFormat + model.extra_params = payload.extraParams + model.remark = payload.remark + model.timeout_second = payload.timeoutSecond + db.add(model) + OperationLogService.write(db, admin_id=current_admin.id, module="model", action="update", target_id=model.id) + db.commit() + db.refresh(model) + return api_success(_model_dict(model)) + + @router.post("/model/enable") def enable_model( payload: EnableModelRequest, @@ -109,6 +166,21 @@ def delete_model( return api_success() +@router.post("/model/{model_id}/test") +def test_model( + model_id: int, + db: Session = Depends(get_db), + current_admin: Admin = Depends(get_current_admin), +) -> dict: + model = db.get(ModelConfig, model_id) + if model is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模型不存在") + result = ModelClientService.test_model(model) + OperationLogService.write(db, admin_id=current_admin.id, module="model", action="test", target_id=model.id) + db.commit() + return api_success(result) + + @router.get("/config") def list_config(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict: configs = db.scalars(select(SystemConfig).order_by(SystemConfig.config_key.asc())).all() @@ -139,11 +211,25 @@ def _model_dict(model: ModelConfig) -> dict: return { "id": model.id, "provider": model.provider, + "displayName": model.display_name, + "apiType": model.api_type, "modelName": model.model_name, + "baseUrl": model.base_url, "apiUrl": model.api_url, "apiKeyMasked": "******" if model.api_key else "", + "authType": model.auth_type, + "apiVersion": model.api_version, "temperature": float(model.temperature) if model.temperature is not None else None, + "topP": float(model.top_p) if model.top_p is not None else None, + "topK": model.top_k, + "presencePenalty": float(model.presence_penalty) if model.presence_penalty is not None else None, + "frequencyPenalty": float(model.frequency_penalty) if model.frequency_penalty is not None else None, "maxToken": model.max_token, + "contextWindow": model.context_window, + "streamEnabled": model.stream_enabled, + "responseFormat": model.response_format, + "extraParams": model.extra_params, + "remark": model.remark, "timeoutSecond": model.timeout_second, "enabled": model.enabled, } diff --git a/ai_knowledge_base_v2/apps/backend/app/models/ai_config.py b/ai_knowledge_base_v2/apps/backend/app/models/ai_config.py index 0cfdf0e..5e0ed11 100644 --- a/ai_knowledge_base_v2/apps/backend/app/models/ai_config.py +++ b/ai_knowledge_base_v2/apps/backend/app/models/ai_config.py @@ -23,11 +23,25 @@ class ModelConfig(Base): id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) provider: Mapped[str] = mapped_column(String(50), nullable=False) + display_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + api_type: Mapped[str] = mapped_column(String(50), default="openai_compatible", nullable=False) model_name: Mapped[str] = mapped_column(String(100), nullable=False) + base_url: Mapped[str | None] = mapped_column(String(255), nullable=True) api_url: Mapped[str] = mapped_column(String(255), nullable=False) api_key: Mapped[str] = mapped_column(Text, nullable=False) + auth_type: Mapped[str] = mapped_column(String(30), default="bearer", nullable=False) + api_version: Mapped[str | None] = mapped_column(String(50), nullable=True) temperature: Mapped[Decimal | None] = mapped_column(Numeric(4, 2), nullable=True) + top_p: Mapped[Decimal | None] = mapped_column(Numeric(4, 2), nullable=True) + top_k: Mapped[int | None] = mapped_column(Integer, nullable=True) + presence_penalty: Mapped[Decimal | None] = mapped_column(Numeric(4, 2), nullable=True) + frequency_penalty: Mapped[Decimal | None] = mapped_column(Numeric(4, 2), nullable=True) max_token: Mapped[int | None] = mapped_column(Integer, nullable=True) + context_window: Mapped[int | None] = mapped_column(Integer, nullable=True) + stream_enabled: Mapped[int] = mapped_column(default=1, nullable=False) + response_format: Mapped[str | None] = mapped_column(String(50), nullable=True) + extra_params: Mapped[str | None] = mapped_column(Text, nullable=True) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) timeout_second: Mapped[int] = mapped_column(Integer, default=30, nullable=False) enabled: Mapped[int] = mapped_column(default=0, nullable=False) diff --git a/ai_knowledge_base_v2/apps/backend/app/schemas/admin.py b/ai_knowledge_base_v2/apps/backend/app/schemas/admin.py index 2c006d0..e62543a 100644 --- a/ai_knowledge_base_v2/apps/backend/app/schemas/admin.py +++ b/ai_knowledge_base_v2/apps/backend/app/schemas/admin.py @@ -53,11 +53,25 @@ class PromptSaveRequest(BaseModel): class ModelSaveRequest(BaseModel): provider: str = Field(min_length=1, max_length=50) + displayName: str | None = Field(default=None, max_length=100) + apiType: str = Field(default="openai_compatible", max_length=50) modelName: str = Field(min_length=1, max_length=100) - apiUrl: str = Field(min_length=1, max_length=255) - apiKey: str = Field(min_length=1) + baseUrl: str | None = Field(default=None, max_length=255) + apiUrl: str = Field(default="", max_length=255) + apiKey: str = "" + authType: str = Field(default="bearer", max_length=30) + apiVersion: str | None = Field(default=None, max_length=50) temperature: float | None = Field(default=0.2, ge=0, le=2) + topP: float | None = Field(default=None, ge=0, le=1) + topK: int | None = Field(default=None, ge=1, le=1000) + presencePenalty: float | None = Field(default=None, ge=-2, le=2) + frequencyPenalty: float | None = Field(default=None, ge=-2, le=2) maxToken: int | None = Field(default=1024, ge=1, le=100000) + contextWindow: int | None = Field(default=None, ge=1, le=2000000) + streamEnabled: int = Field(default=1, ge=0, le=1) + responseFormat: str | None = Field(default=None, max_length=50) + extraParams: str | None = None + remark: str | None = Field(default=None, max_length=255) timeoutSecond: int = Field(default=30, ge=1, le=300) diff --git a/ai_knowledge_base_v2/apps/backend/app/services/model_service.py b/ai_knowledge_base_v2/apps/backend/app/services/model_service.py index 8d2307e..cef61af 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/model_service.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/model_service.py @@ -1,7 +1,9 @@ from __future__ import annotations +import json from dataclasses import dataclass from typing import Any +from urllib.parse import urlencode import httpx from sqlalchemy import select @@ -37,7 +39,7 @@ class ModelClientService: answer = _mock_answer(rag_result) else: model_name = model.model_name - answer = _call_openai_compatible_model(model, rag_result) + answer = _call_configured_model(model, rag_result) return ModelCompletion( answer=answer, @@ -47,6 +49,16 @@ class ModelClientService: output_token=_rough_token_count(answer), ) + @staticmethod + def test_model(model: ModelConfig) -> dict[str, Any]: + test_prompt = "请回复 OK,用于测试模型配置是否可用。" + rag_result = RagResult(question=test_prompt, knowledge_scopes=[], chunks=[], prompt=test_prompt) + try: + answer = _call_configured_model(model, rag_result, allow_no_hit=True) + except ExternalServiceError as exc: + return {"ok": False, "message": str(exc), "answer": ""} + return {"ok": True, "message": "模型配置可用", "answer": answer[:500]} + def _mock_answer(rag_result: RagResult) -> str: if not rag_result.is_hit: @@ -67,40 +79,116 @@ def _rough_token_count(text: str) -> int: return max(1, len(text.strip()) // 2) -def _call_openai_compatible_model(model: ModelConfig, rag_result: RagResult) -> str: - if not rag_result.is_hit: +def _call_configured_model(model: ModelConfig, rag_result: RagResult, *, allow_no_hit: bool = False) -> str: + if not allow_no_hit and not rag_result.is_hit: return NO_HIT_ANSWER - if not model.api_url or not model.api_key: - raise ExternalServiceError("模型 API URL 或 API Key 未配置", provider="model") + if not (model.api_url or model.base_url) or not model.api_key: + raise ExternalServiceError("模型 Base URL/API URL 或 API Key 未配置", provider="model") + api_type = model.api_type or "openai_compatible" + if api_type == "anthropic_messages": + return _call_anthropic_messages(model, rag_result) + if api_type == "gemini_generate_content": + return _call_gemini_generate_content(model, rag_result) + return _call_openai_compatible_model(model, rag_result) + + +def _call_openai_compatible_model(model: ModelConfig, rag_result: RagResult) -> str: payload = { "model": model.model_name, "messages": [ {"role": "system", "content": "你是企业知识库问答助手,只能基于已提供的知识片段回答。"}, {"role": "user", "content": rag_result.prompt}, ], - "temperature": float(model.temperature) if model.temperature is not None else 0.2, - "max_tokens": model.max_token or 1024, "stream": False, + "max_tokens": model.max_token or 1024, } - headers = { - "Authorization": f"Bearer {model.api_key}", - "Content-Type": "application/json", - } + _put_if_not_none(payload, "temperature", _decimal_to_float(model.temperature)) + _put_if_not_none(payload, "top_p", _decimal_to_float(model.top_p)) + _put_if_not_none(payload, "presence_penalty", _decimal_to_float(model.presence_penalty)) + _put_if_not_none(payload, "frequency_penalty", _decimal_to_float(model.frequency_penalty)) + if model.response_format == "json_object": + payload["response_format"] = {"type": "json_object"} + payload.update(_load_extra_params(model.extra_params)) + + headers = _auth_headers(model) try: response = httpx.post( - model.api_url, + _resolve_openai_endpoint(model), json=payload, headers=headers, timeout=model.timeout_second, ) response.raise_for_status() - return _extract_answer(response.json()) + return _extract_openai_answer(response.json()) except (httpx.HTTPError, ValueError, KeyError, TypeError) as exc: raise ExternalServiceError(f"模型调用失败:{exc}", provider="model") from exc -def _extract_answer(data: dict[str, Any]) -> str: +def _call_anthropic_messages(model: ModelConfig, rag_result: RagResult) -> str: + payload = { + "model": model.model_name, + "max_tokens": model.max_token or 1024, + "system": "你是企业知识库问答助手,只能基于已提供的知识片段回答。", + "messages": [{"role": "user", "content": rag_result.prompt}], + "stream": False, + } + _put_if_not_none(payload, "temperature", _decimal_to_float(model.temperature)) + _put_if_not_none(payload, "top_p", _decimal_to_float(model.top_p)) + _put_if_not_none(payload, "top_k", model.top_k) + payload.update(_load_extra_params(model.extra_params)) + + headers = { + "x-api-key": model.api_key, + "anthropic-version": model.api_version or "2023-06-01", + "Content-Type": "application/json", + } + try: + response = httpx.post( + _resolve_anthropic_endpoint(model), + json=payload, + headers=headers, + timeout=model.timeout_second, + ) + response.raise_for_status() + return _extract_anthropic_answer(response.json()) + except (httpx.HTTPError, ValueError, KeyError, TypeError) as exc: + raise ExternalServiceError(f"模型调用失败:{exc}", provider="model") from exc + + +def _call_gemini_generate_content(model: ModelConfig, rag_result: RagResult) -> str: + generation_config: dict[str, Any] = {} + _put_if_not_none(generation_config, "temperature", _decimal_to_float(model.temperature)) + _put_if_not_none(generation_config, "topP", _decimal_to_float(model.top_p)) + _put_if_not_none(generation_config, "topK", model.top_k) + _put_if_not_none(generation_config, "maxOutputTokens", model.max_token) + if model.response_format == "json_object": + generation_config["responseMimeType"] = "application/json" + + payload = { + "systemInstruction": { + "parts": [{"text": "你是企业知识库问答助手,只能基于已提供的知识片段回答。"}] + }, + "contents": [{"role": "user", "parts": [{"text": rag_result.prompt}]}], + } + if generation_config: + payload["generationConfig"] = generation_config + payload.update(_load_extra_params(model.extra_params)) + + try: + response = httpx.post( + _resolve_gemini_endpoint(model), + json=payload, + headers={"Content-Type": "application/json"}, + timeout=model.timeout_second, + ) + response.raise_for_status() + return _extract_gemini_answer(response.json()) + except (httpx.HTTPError, ValueError, KeyError, TypeError) as exc: + raise ExternalServiceError(f"模型调用失败:{exc}", provider="model") from exc + + +def _extract_openai_answer(data: dict[str, Any]) -> str: choices = data.get("choices") if not isinstance(choices, list) or not choices: raise ValueError("模型响应缺少 choices") @@ -117,3 +205,80 @@ def _extract_answer(data: dict[str, Any]) -> str: return str(first_choice["text"]) raise ValueError("模型响应缺少回答内容") + + +def _extract_anthropic_answer(data: dict[str, Any]) -> str: + content = data.get("content") + if not isinstance(content, list): + raise ValueError("Anthropic 响应缺少 content") + texts = [item.get("text", "") for item in content if isinstance(item, dict) and item.get("type") == "text"] + answer = "".join(texts).strip() + if not answer: + raise ValueError("Anthropic 响应缺少文本内容") + return answer + + +def _extract_gemini_answer(data: dict[str, Any]) -> str: + candidates = data.get("candidates") + if not isinstance(candidates, list) or not candidates: + raise ValueError("Gemini 响应缺少 candidates") + parts = candidates[0].get("content", {}).get("parts", []) + texts = [item.get("text", "") for item in parts if isinstance(item, dict)] + answer = "".join(texts).strip() + if not answer: + raise ValueError("Gemini 响应缺少文本内容") + return answer + + +def _resolve_openai_endpoint(model: ModelConfig) -> str: + if model.api_url: + return model.api_url + base_url = (model.base_url or "").rstrip("/") + return f"{base_url}/chat/completions" + + +def _resolve_anthropic_endpoint(model: ModelConfig) -> str: + if model.api_url: + return model.api_url + base_url = (model.base_url or "https://api.anthropic.com").rstrip("/") + return f"{base_url}/v1/messages" + + +def _resolve_gemini_endpoint(model: ModelConfig) -> str: + if model.api_url: + return model.api_url + base_url = (model.base_url or "https://generativelanguage.googleapis.com/v1beta").rstrip("/") + query = urlencode({"key": model.api_key}) + return f"{base_url}/models/{model.model_name}:generateContent?{query}" + + +def _auth_headers(model: ModelConfig) -> dict[str, str]: + headers = {"Content-Type": "application/json"} + if model.auth_type == "api_key": + headers["x-api-key"] = model.api_key + else: + headers["Authorization"] = f"Bearer {model.api_key}" + if model.api_version: + headers["api-version"] = model.api_version + return headers + + +def _decimal_to_float(value: Any) -> float | None: + return float(value) if value is not None else None + + +def _put_if_not_none(target: dict[str, Any], key: str, value: Any) -> None: + if value is not None: + target[key] = value + + +def _load_extra_params(extra_params: str | None) -> dict[str, Any]: + if not extra_params: + return {} + try: + data = json.loads(extra_params) + except json.JSONDecodeError as exc: + raise ExternalServiceError(f"模型高级参数不是合法 JSON:{exc}", provider="model") from exc + if not isinstance(data, dict): + raise ExternalServiceError("模型高级参数必须是 JSON 对象", provider="model") + return data diff --git a/ai_knowledge_base_v2/development_records/2026-07-06-decision-register.md b/ai_knowledge_base_v2/development_records/2026-07-06-decision-register.md index e9e6676..c521007 100644 --- a/ai_knowledge_base_v2/development_records/2026-07-06-decision-register.md +++ b/ai_knowledge_base_v2/development_records/2026-07-06-decision-register.md @@ -35,6 +35,10 @@ | D-012 | 大模型真实调用先按 OpenAI 兼容 `chat/completions` 非流式响应接入。 | 默认采用 | 先打通配置化真实模型调用和失败日志,后续再升级为模型原生流式透传。 | | D-013 | 管理后台开发阶段支持首次登录自动创建默认管理员。 | 默认采用 | 降低本地初始化成本;生产环境必须修改默认账号密码。 | | D-014 | 管理后台 Element Plus 先整体引入,后续再做按需导入优化。 | 默认采用 | 阶段五优先保证后台功能闭环和交付速度,体积优化后续处理。 | +| D-015 | 模型管理按“OpenAI 兼容协议 + 原生协议”两类补全。 | 默认采用 | DeepSeek、通义千问兼容模式、Kimi、硅基流动、火山方舟优先走 OpenAI 兼容;Anthropic Messages、Gemini generateContent 保留原生协议适配。 | +| D-016 | 模型配置支持 `baseUrl` 自动拼接接口,同时保留 `apiUrl` 覆盖能力。 | 默认采用 | 降低普通配置成本,同时给特殊代理网关、私有化模型和供应商非标准路径保留出口。 | +| D-017 | 编辑模型时 API Key 留空表示保留旧密钥。 | 默认采用 | 减少密钥重复暴露,避免后台人员只改参数时误覆盖密钥。 | +| D-018 | 模型高级参数用 JSON 对象透传。 | 默认采用 | 用较低成本兼容供应商差异,避免数据库字段无限膨胀。 | ## 待确认决策 @@ -42,7 +46,7 @@ | --- | --- | --- | --- | | Q-001 | 管理后台是否最终采用 Vue 3 + Element Plus? | 默认采用 | 开始后台工程前。 | | Q-002 | 真实短信供应商是谁? | 先 mock | 接入真实登录前。 | -| Q-003 | 大模型供应商、API URL、模型名和鉴权方式是什么? | 先按 OpenAI 兼容接口 | 接入真实模型前。 | +| Q-003 | 大模型供应商、API URL、模型名和鉴权方式是什么? | 后台模型管理已支持多供应商配置,真实生产供应商仍待确认。 | 接入真实模型前。 | | Q-004 | 飞书知识库实时检索 API 是否满足 SpaceID/NodeID 检索要求? | 先做 mock + 技术验证 | 阶段二后端基础工程完成后尽快验证。 | | Q-005 | 模型 API Key 生产环境如何保存? | 优先环境变量引用或加密存储 | 做模型管理功能前。 | | Q-006 | 飞书检索是否直接调飞书原生 API,还是先由独立适配服务封装? | 当前先预留 `FEISHU_SEARCH_URL` 适配服务入口 | 飞书账号、权限和 API 返回结构确认后。 | diff --git a/ai_knowledge_base_v2/development_records/2026-07-07-model-management-completion.md b/ai_knowledge_base_v2/development_records/2026-07-07-model-management-completion.md new file mode 100644 index 0000000..1262881 --- /dev/null +++ b/ai_knowledge_base_v2/development_records/2026-07-07-model-management-completion.md @@ -0,0 +1,122 @@ +# 模型管理补全记录 + +日期:2026-07-07 + +## 本次目标 + +把管理后台的模型管理从“只保存一个 OpenAI 兼容地址”补全为可维护的第三方 LLM 配置能力,后续可以支持 OpenAI、DeepSeek、通义千问兼容模式、Kimi、硅基流动、火山方舟、Anthropic Claude、Google Gemini 等供应商。 + +## 参考口径 + +本次以各供应商公开接口文档的共同配置项为基础,不绑定某一个供应商 SDK: + +- OpenAI 兼容类:统一使用 `chat/completions` 风格,支持 `model`、`messages`、`temperature`、`top_p`、`max_tokens`、`stream`、`response_format` 等常见参数。 +- DeepSeek、硅基流动、通义千问兼容模式、Kimi、火山方舟:优先作为 OpenAI 兼容协议接入,通过不同 `Base URL` 和 `modelName` 区分。 +- Anthropic Claude:使用 Messages API 原生协议,鉴权使用 `x-api-key`,并支持 `anthropic-version`。 +- Google Gemini:使用 `generateContent` 原生协议,生成参数放在 `generationConfig` 下。 + +## 已完成 + +### 后端 + +- 扩展 `sys_model` 数据结构,新增: + - `display_name` + - `api_type` + - `base_url` + - `auth_type` + - `api_version` + - `top_p` + - `top_k` + - `presence_penalty` + - `frequency_penalty` + - `context_window` + - `stream_enabled` + - `response_format` + - `extra_params` + - `remark` +- 新增 Alembic 迁移 `0002_expand_model_config`。 +- 扩展模型创建接口,支持保存完整第三方 LLM 参数。 +- 新增模型更新接口: + - 编辑模型时如果 API Key 留空,则保留原 API Key。 + - 这样可以避免后台人员每次改参数都必须重新粘贴密钥。 +- 新增模型测试接口: + - 后台可直接发起一次轻量测试。 + - 返回可用状态、错误信息和部分模型回复。 +- 扩展 `ModelClientService`: + - `openai_compatible`:按 OpenAI 兼容 `chat/completions` 调用。 + - `anthropic_messages`:按 Anthropic Messages API 调用。 + - `gemini_generate_content`:按 Gemini generateContent 调用。 + - `extra_params` 支持 JSON 对象透传,用于补充供应商特有参数。 + +### 管理后台 + +- 模型管理表单改为完整配置面板。 +- 新增供应商预设: + - OpenAI + - DeepSeek + - 通义千问 DashScope + - Kimi / Moonshot + - 硅基流动 SiliconFlow + - 火山方舟 Ark + - Anthropic Claude + - Google Gemini +- 新增参数配置: + - 协议类型 + - Base URL + - API URL 覆盖 + - 鉴权方式 + - API Version + - Temperature + - Top P + - Top K + - Presence Penalty + - Frequency Penalty + - Max Tokens + - 上下文窗口 + - 流式输出开关 + - 响应格式 + - 高级 JSON 参数 + - 备注 +- 新增编辑模型、测试模型、启用模型操作。 +- 前端保存前增加基础校验: + - 新增模型必须填写 API Key。 + - 编辑模型允许 API Key 留空。 + - Base URL 和 API URL 至少填写一个。 + - 高级 JSON 参数必须是 JSON 对象。 + +## 默认工程决策 + +| 编号 | 决策 | 原因 | +| --- | --- | --- | +| M-001 | 第三方 LLM 分为 OpenAI 兼容协议和原生协议两类。 | 国内多数供应商提供 OpenAI 兼容模式,用统一协议可以减少重复代码;Anthropic、Gemini 保留原生协议以避免参数结构不匹配。 | +| M-002 | `apiUrl` 可以为空,优先由 `baseUrl + apiType` 自动拼接。 | 后台人员更容易配置,只要填供应商 Base URL 和模型名即可;高级场景仍可用 API URL 覆盖。 | +| M-003 | 编辑模型时 API Key 留空表示不修改。 | 避免密钥频繁暴露和重复粘贴,降低误改风险。 | +| M-004 | 高级参数使用 JSON 对象透传。 | 保留不同供应商的扩展能力,同时避免每个小参数都单独改数据库结构。 | +| M-005 | Anthropic、Gemini 预设默认不强行填采样参数。 | 部分模型对非默认采样参数较敏感,预设应先保证能通,再由后台人员按需调整。 | +| M-006 | 模型测试接口直接调用真实供应商。 | 这是后台配置验收动作,应该真实发现 Key、URL、模型名和参数错误。 | + +## 当前边界 + +- API Key 当前仍存储在数据库字段中,生产环境建议后续升级为加密存储或环境变量引用。 +- 流式输出字段已保存,但当前真实模型调用仍以非流式方式完成,用户端 SSE 分块由后端模拟输出。 +- 不同供应商的费用、限流、区域和模型上下文窗口仍需要运营配置时自行确认。 +- `extra_params` 由后台人员自行维护,系统只校验 JSON 对象格式,不校验供应商语义。 + +## 本次验证 + +- 后端编译检查:通过。 +- 后端 OpenAPI 检查:通过,当前 `paths` 数量为 34。 +- Alembic MySQL 静态 SQL 检查:通过,迁移 SQL 生成成功。 +- 用户端 H5 构建:通过。 +- 管理后台构建:通过。 +- Docker Compose 配置检查:通过。 +- `git diff --check`:通过。 + +说明:管理后台构建仍有 Vite chunk size 提示,原因是当前阶段 Element Plus 整体引入,属于已记录的阶段性取舍,不影响本次功能可用性。 + +## 后续建议 + +1. 接入生产前补充密钥加密或密钥引用机制。 +2. 增加“复制模型配置”能力,方便按环境复制供应商参数。 +3. 增加模型测试结果记录,便于排查某个供应商近期是否不稳定。 +4. 接入真实流式模型后,再把 `stream_enabled` 从配置字段升级为真实调用策略。 diff --git a/ai_knowledge_base_v2/development_records/README.md b/ai_knowledge_base_v2/development_records/README.md index d7c7245..792168d 100644 --- a/ai_knowledge_base_v2/development_records/README.md +++ b/ai_knowledge_base_v2/development_records/README.md @@ -33,4 +33,5 @@ | `2026-07-06-phase4-rag-external-services.md` | 阶段四 RAG 和外部服务接入骨架记录。 | | `2026-07-06-phase5-admin-console.md` | 阶段五管理后台记录。 | | `2026-07-07-admin-chat-records-completion.md` | 管理后台聊天记录补齐记录。 | +| `2026-07-07-model-management-completion.md` | 管理后台模型管理补全记录。 | | `../test_records/2026-07-06-phase6-verification.md` | 阶段六验证记录。 |