Complete admin model management

This commit is contained in:
2026-07-07 12:58:26 +08:00
parent 08c301c136
commit 55b7c5b0ea
12 changed files with 859 additions and 40 deletions

View File

@@ -32,6 +32,7 @@ const promptContent = ref("");
const chatDetail = ref<ChatDetail | null>(null); const chatDetail = ref<ChatDetail | null>(null);
const chatDetailOpen = ref(false); const chatDetailOpen = ref(false);
const recordTab = ref("chats"); const recordTab = ref("chats");
const editingModelId = ref<number | null>(null);
const knowledgeForm = reactive({ const knowledgeForm = reactive({
name: "", name: "",
@@ -42,15 +43,115 @@ const knowledgeForm = reactive({
}); });
const modelForm = reactive({ const modelForm = reactive({
provider: "openai-compatible", provider: "openai",
displayName: "",
apiType: "openai_compatible",
modelName: "", modelName: "",
baseUrl: "https://api.openai.com/v1",
apiUrl: "", apiUrl: "",
apiKey: "", 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, maxToken: 1024,
contextWindow: null as number | null,
streamEnabled: 1,
responseFormat: "",
extraParams: "",
remark: "",
timeoutSecond: 30, 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({ const configForm = reactive({
configKey: "daily_chat_limit", configKey: "daily_chat_limit",
configValue: "100", configValue: "100",
@@ -143,26 +244,141 @@ async function resetPrompt() {
} }
async function saveModel() { async function saveModel() {
await api.createModel({ ...modelForm }); if (!validateModelForm()) return;
const payload = buildModelPayload();
if (editingModelId.value) {
await api.updateModel(editingModelId.value, payload);
ElMessage.success("模型已更新");
} else {
await api.createModel(payload);
ElMessage.success("模型已新增"); ElMessage.success("模型已新增");
Object.assign(modelForm, { }
provider: "openai-compatible", resetModelForm();
modelName: "",
apiUrl: "",
apiKey: "",
temperature: 0.2,
maxToken: 1024,
timeoutSecond: 30,
});
await loadCurrentMenu(); await loadCurrentMenu();
} }
function buildModelPayload() {
const payload = { ...modelForm } as Record<string, unknown>;
nullableModelFields.forEach((field) => {
if (payload[field] === undefined || payload[field] === "") {
payload[field] = null;
}
});
return payload;
}
async function enableModel(id: number) { async function enableModel(id: number) {
await api.enableModel(id); await api.enableModel(id);
ElMessage.success("模型已启用"); ElMessage.success("模型已启用");
await loadCurrentMenu(); 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() { async function saveConfig() {
await api.saveConfig({ ...configForm }); await api.saveConfig({ ...configForm });
ElMessage.success("配置已保存"); ElMessage.success("配置已保存");
@@ -299,21 +515,117 @@ function buildChatQuery() {
</template> </template>
<template v-if="activeMenu === 'models'"> <template v-if="activeMenu === 'models'">
<div class="page-head"><h2>模型管理</h2><p>同一时间只启用一个模型</p></div> <div class="page-head inline">
<el-form class="model-form" label-position="top" :model="modelForm"> <div><h2>模型管理</h2><p>支持 OpenAI 兼容Anthropic MessagesGemini generateContent 和常见第三方 LLM</p></div>
<el-input v-model="modelForm.provider" placeholder="Provider" /> <el-button @click="resetModelForm">清空表单</el-button>
<el-input v-model="modelForm.modelName" placeholder="模型名" /> </div>
<el-input v-model="modelForm.apiUrl" placeholder="API URL" /> <el-form class="model-config-panel" label-position="top" :model="modelForm">
<el-input v-model="modelForm.apiKey" placeholder="API Key" show-password /> <div class="model-config-grid">
<el-button type="primary" @click="saveModel">新增模型</el-button> <el-form-item label="供应商预设">
<el-select placeholder="选择预设" clearable @change="applyModelPreset">
<el-option v-for="preset in modelPresets" :key="preset.label" :label="preset.label" :value="preset.label" />
</el-select>
</el-form-item>
<el-form-item label="供应商标识">
<el-input v-model="modelForm.provider" placeholder="openai / deepseek / qwen" />
</el-form-item>
<el-form-item label="显示名称">
<el-input v-model="modelForm.displayName" placeholder="例如DeepSeek V3 生产" />
</el-form-item>
<el-form-item label="协议类型">
<el-select v-model="modelForm.apiType">
<el-option label="OpenAI 兼容" value="openai_compatible" />
<el-option label="Anthropic Messages" value="anthropic_messages" />
<el-option label="Gemini generateContent" value="gemini_generate_content" />
</el-select>
</el-form-item>
<el-form-item label="模型名">
<el-input v-model="modelForm.modelName" placeholder="gpt-4.1 / deepseek-chat / claude..." />
</el-form-item>
<el-form-item label="Base URL">
<el-input v-model="modelForm.baseUrl" placeholder="https://api.example.com/v1" />
</el-form-item>
<el-form-item label="API URL可覆盖">
<el-input v-model="modelForm.apiUrl" placeholder="留空则按协议自动拼接" />
</el-form-item>
<el-form-item label="鉴权方式">
<el-select v-model="modelForm.authType">
<el-option label="Bearer Token" value="bearer" />
<el-option label="x-api-key" value="api_key" />
</el-select>
</el-form-item>
<el-form-item label="API Key">
<el-input v-model="modelForm.apiKey" placeholder="保存时会覆盖旧 Key" show-password />
</el-form-item>
<el-form-item label="API Version">
<el-input v-model="modelForm.apiVersion" placeholder="Anthropic 默认 2023-06-01" />
</el-form-item>
<el-form-item label="Temperature">
<el-input-number v-model="modelForm.temperature" :min="0" :max="2" :step="0.1" />
</el-form-item>
<el-form-item label="Top P">
<el-input-number v-model="modelForm.topP" :min="0" :max="1" :step="0.05" />
</el-form-item>
<el-form-item label="Top K">
<el-input-number v-model="modelForm.topK" :min="1" :max="1000" />
</el-form-item>
<el-form-item label="Max Tokens">
<el-input-number v-model="modelForm.maxToken" :min="1" :max="100000" />
</el-form-item>
<el-form-item label="上下文窗口">
<el-input-number v-model="modelForm.contextWindow" :min="1" :max="2000000" />
</el-form-item>
<el-form-item label="Presence Penalty">
<el-input-number v-model="modelForm.presencePenalty" :min="-2" :max="2" :step="0.1" />
</el-form-item>
<el-form-item label="Frequency Penalty">
<el-input-number v-model="modelForm.frequencyPenalty" :min="-2" :max="2" :step="0.1" />
</el-form-item>
<el-form-item label="流式输出">
<el-switch v-model="modelForm.streamEnabled" :active-value="1" :inactive-value="0" />
</el-form-item>
<el-form-item label="响应格式">
<el-select v-model="modelForm.responseFormat" clearable>
<el-option label="默认文本" value="" />
<el-option label="JSON Object" value="json_object" />
</el-select>
</el-form-item>
<el-form-item label="超时秒数">
<el-input-number v-model="modelForm.timeoutSecond" :min="1" :max="300" />
</el-form-item>
</div>
<el-form-item label="高级 JSON 参数">
<el-input
v-model="modelForm.extraParams"
type="textarea"
:rows="4"
placeholder='例如:{"stop":["###"],"seed":42}'
/>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="modelForm.remark" placeholder="用途、额度、注意事项等" />
</el-form-item>
<div class="actions">
<el-button type="primary" @click="saveModel">{{ editingModelId ? "保存模型" : "新增模型" }}</el-button>
<el-button @click="resetModelForm">取消编辑</el-button>
</div>
</el-form> </el-form>
<el-table :data="models" stripe> <el-table :data="models" stripe>
<el-table-column prop="displayName" label="显示名称" width="170" />
<el-table-column prop="provider" label="Provider" /> <el-table-column prop="provider" label="Provider" />
<el-table-column prop="apiType" label="协议" width="170" />
<el-table-column prop="modelName" label="模型" /> <el-table-column prop="modelName" label="模型" />
<el-table-column prop="apiUrl" label="API URL" /> <el-table-column prop="baseUrl" label="Base URL" min-width="220" show-overflow-tooltip />
<el-table-column prop="temperature" label="温度" width="80" />
<el-table-column prop="topP" label="Top P" width="80" />
<el-table-column prop="maxToken" label="Max" width="90" />
<el-table-column prop="enabled" label="启用" width="90" /> <el-table-column prop="enabled" label="启用" width="90" />
<el-table-column label="操作" width="110"> <el-table-column label="操作" width="210" fixed="right">
<template #default="{ row }"><el-button size="small" @click="enableModel(row.id)">启用</el-button></template> <template #default="{ row }">
<el-button size="small" @click="editModel(row)">编辑</el-button>
<el-button size="small" @click="testModel(row.id)">测试</el-button>
<el-button size="small" type="primary" @click="enableModel(row.id)">启用</el-button>
</template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</template> </template>

View File

@@ -86,8 +86,15 @@ export const api = {
models: () => request<ModelItem[]>("/admin/model/list"), models: () => request<ModelItem[]>("/admin/model/list"),
createModel: (payload: Record<string, unknown>) => createModel: (payload: Record<string, unknown>) =>
request<ModelItem>("/admin/model", { method: "POST", body: JSON.stringify(payload) }), request<ModelItem>("/admin/model", { method: "POST", body: JSON.stringify(payload) }),
updateModel: (id: number, payload: Record<string, unknown>) =>
request<ModelItem>(`/admin/model/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
enableModel: (modelId: number) => enableModel: (modelId: number) =>
request<null>("/admin/model/enable", { method: "POST", body: JSON.stringify({ modelId }) }), request<null>("/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<Record<string, unknown>[]>("/admin/config"), configs: () => request<Record<string, unknown>[]>("/admin/config"),
saveConfig: (payload: Record<string, unknown>) => saveConfig: (payload: Record<string, unknown>) =>
request<Record<string, unknown>>("/admin/config", { method: "PUT", body: JSON.stringify(payload) }), request<Record<string, unknown>>("/admin/config", { method: "PUT", body: JSON.stringify(payload) }),

View File

@@ -185,6 +185,30 @@ textarea {
grid-template-columns: repeat(5, minmax(0, 1fr)); 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 { .actions {
margin-top: 12px; margin-top: 12px;
} }

View File

@@ -42,11 +42,25 @@ export interface KnowledgeItem {
export interface ModelItem { export interface ModelItem {
id: number; id: number;
provider: string; provider: string;
displayName?: string | null;
apiType: string;
modelName: string; modelName: string;
baseUrl?: string | null;
apiUrl: string; apiUrl: string;
apiKeyMasked: string; apiKeyMasked: string;
authType: string;
apiVersion?: string | null;
temperature?: number | null; temperature?: number | null;
topP?: number | null;
topK?: number | null;
presencePenalty?: number | null;
frequencyPenalty?: number | null;
maxToken?: number | null; maxToken?: number | null;
contextWindow?: number | null;
streamEnabled: number;
responseFormat?: string | null;
extraParams?: string | null;
remark?: string | null;
timeoutSecond: number; timeoutSecond: number;
enabled: number; enabled: number;
} }

View File

@@ -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")

View File

@@ -11,6 +11,7 @@ from app.models.admin import Admin
from app.models.ai_config import ModelConfig, Prompt, SystemConfig from app.models.ai_config import ModelConfig, Prompt, SystemConfig
from app.schemas.admin import EnableModelRequest, ModelSaveRequest, PromptSaveRequest, SystemConfigSaveRequest from app.schemas.admin import EnableModelRequest, ModelSaveRequest, PromptSaveRequest, SystemConfigSaveRequest
from app.services.admin_service import OperationLogService from app.services.admin_service import OperationLogService
from app.services.model_service import ModelClientService
router = APIRouter() router = APIRouter()
@@ -59,13 +60,29 @@ def create_model(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin), current_admin: Admin = Depends(get_current_admin),
) -> dict: ) -> dict:
if not payload.apiKey:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="API Key 不能为空")
model = ModelConfig( model = ModelConfig(
provider=payload.provider, provider=payload.provider,
display_name=payload.displayName,
api_type=payload.apiType,
model_name=payload.modelName, model_name=payload.modelName,
base_url=payload.baseUrl,
api_url=payload.apiUrl, api_url=payload.apiUrl,
api_key=payload.apiKey, api_key=payload.apiKey,
auth_type=payload.authType,
api_version=payload.apiVersion,
temperature=payload.temperature, temperature=payload.temperature,
top_p=payload.topP,
top_k=payload.topK,
presence_penalty=payload.presencePenalty,
frequency_penalty=payload.frequencyPenalty,
max_token=payload.maxToken, 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, timeout_second=payload.timeoutSecond,
enabled=0, enabled=0,
) )
@@ -77,6 +94,46 @@ def create_model(
return api_success(_model_dict(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") @router.post("/model/enable")
def enable_model( def enable_model(
payload: EnableModelRequest, payload: EnableModelRequest,
@@ -109,6 +166,21 @@ def delete_model(
return api_success() 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") @router.get("/config")
def list_config(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict: 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() configs = db.scalars(select(SystemConfig).order_by(SystemConfig.config_key.asc())).all()
@@ -139,11 +211,25 @@ def _model_dict(model: ModelConfig) -> dict:
return { return {
"id": model.id, "id": model.id,
"provider": model.provider, "provider": model.provider,
"displayName": model.display_name,
"apiType": model.api_type,
"modelName": model.model_name, "modelName": model.model_name,
"baseUrl": model.base_url,
"apiUrl": model.api_url, "apiUrl": model.api_url,
"apiKeyMasked": "******" if model.api_key else "", "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, "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, "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, "timeoutSecond": model.timeout_second,
"enabled": model.enabled, "enabled": model.enabled,
} }

View File

@@ -23,11 +23,25 @@ class ModelConfig(Base):
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
provider: Mapped[str] = mapped_column(String(50), nullable=False) 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) 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_url: Mapped[str] = mapped_column(String(255), nullable=False)
api_key: Mapped[str] = mapped_column(Text, 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) 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) 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) timeout_second: Mapped[int] = mapped_column(Integer, default=30, nullable=False)
enabled: Mapped[int] = mapped_column(default=0, nullable=False) enabled: Mapped[int] = mapped_column(default=0, nullable=False)

View File

@@ -53,11 +53,25 @@ class PromptSaveRequest(BaseModel):
class ModelSaveRequest(BaseModel): class ModelSaveRequest(BaseModel):
provider: str = Field(min_length=1, max_length=50) 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) modelName: str = Field(min_length=1, max_length=100)
apiUrl: str = Field(min_length=1, max_length=255) baseUrl: str | None = Field(default=None, max_length=255)
apiKey: str = Field(min_length=1) 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) 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) 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) timeoutSecond: int = Field(default=30, ge=1, le=300)

View File

@@ -1,7 +1,9 @@
from __future__ import annotations from __future__ import annotations
import json
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from urllib.parse import urlencode
import httpx import httpx
from sqlalchemy import select from sqlalchemy import select
@@ -37,7 +39,7 @@ class ModelClientService:
answer = _mock_answer(rag_result) answer = _mock_answer(rag_result)
else: else:
model_name = model.model_name model_name = model.model_name
answer = _call_openai_compatible_model(model, rag_result) answer = _call_configured_model(model, rag_result)
return ModelCompletion( return ModelCompletion(
answer=answer, answer=answer,
@@ -47,6 +49,16 @@ class ModelClientService:
output_token=_rough_token_count(answer), 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: def _mock_answer(rag_result: RagResult) -> str:
if not rag_result.is_hit: if not rag_result.is_hit:
@@ -67,40 +79,116 @@ def _rough_token_count(text: str) -> int:
return max(1, len(text.strip()) // 2) return max(1, len(text.strip()) // 2)
def _call_openai_compatible_model(model: ModelConfig, rag_result: RagResult) -> str: def _call_configured_model(model: ModelConfig, rag_result: RagResult, *, allow_no_hit: bool = False) -> str:
if not rag_result.is_hit: if not allow_no_hit and not rag_result.is_hit:
return NO_HIT_ANSWER return NO_HIT_ANSWER
if not model.api_url or not model.api_key: if not (model.api_url or model.base_url) or not model.api_key:
raise ExternalServiceError("模型 API URL 或 API Key 未配置", provider="model") 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 = { payload = {
"model": model.model_name, "model": model.model_name,
"messages": [ "messages": [
{"role": "system", "content": "你是企业知识库问答助手,只能基于已提供的知识片段回答。"}, {"role": "system", "content": "你是企业知识库问答助手,只能基于已提供的知识片段回答。"},
{"role": "user", "content": rag_result.prompt}, {"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, "stream": False,
"max_tokens": model.max_token or 1024,
} }
headers = { _put_if_not_none(payload, "temperature", _decimal_to_float(model.temperature))
"Authorization": f"Bearer {model.api_key}", _put_if_not_none(payload, "top_p", _decimal_to_float(model.top_p))
"Content-Type": "application/json", _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: try:
response = httpx.post( response = httpx.post(
model.api_url, _resolve_openai_endpoint(model),
json=payload, json=payload,
headers=headers, headers=headers,
timeout=model.timeout_second, timeout=model.timeout_second,
) )
response.raise_for_status() response.raise_for_status()
return _extract_answer(response.json()) return _extract_openai_answer(response.json())
except (httpx.HTTPError, ValueError, KeyError, TypeError) as exc: except (httpx.HTTPError, ValueError, KeyError, TypeError) as exc:
raise ExternalServiceError(f"模型调用失败:{exc}", provider="model") from 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") choices = data.get("choices")
if not isinstance(choices, list) or not choices: if not isinstance(choices, list) or not choices:
raise ValueError("模型响应缺少 choices") raise ValueError("模型响应缺少 choices")
@@ -117,3 +205,80 @@ def _extract_answer(data: dict[str, Any]) -> str:
return str(first_choice["text"]) return str(first_choice["text"])
raise ValueError("模型响应缺少回答内容") 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

View File

@@ -35,6 +35,10 @@
| D-012 | 大模型真实调用先按 OpenAI 兼容 `chat/completions` 非流式响应接入。 | 默认采用 | 先打通配置化真实模型调用和失败日志,后续再升级为模型原生流式透传。 | | D-012 | 大模型真实调用先按 OpenAI 兼容 `chat/completions` 非流式响应接入。 | 默认采用 | 先打通配置化真实模型调用和失败日志,后续再升级为模型原生流式透传。 |
| D-013 | 管理后台开发阶段支持首次登录自动创建默认管理员。 | 默认采用 | 降低本地初始化成本;生产环境必须修改默认账号密码。 | | D-013 | 管理后台开发阶段支持首次登录自动创建默认管理员。 | 默认采用 | 降低本地初始化成本;生产环境必须修改默认账号密码。 |
| D-014 | 管理后台 Element Plus 先整体引入,后续再做按需导入优化。 | 默认采用 | 阶段五优先保证后台功能闭环和交付速度,体积优化后续处理。 | | 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-001 | 管理后台是否最终采用 Vue 3 + Element Plus | 默认采用 | 开始后台工程前。 |
| Q-002 | 真实短信供应商是谁? | 先 mock | 接入真实登录前。 | | Q-002 | 真实短信供应商是谁? | 先 mock | 接入真实登录前。 |
| Q-003 | 大模型供应商、API URL、模型名和鉴权方式是什么 | 先按 OpenAI 兼容接口 | 接入真实模型前。 | | Q-003 | 大模型供应商、API URL、模型名和鉴权方式是什么 | 后台模型管理已支持多供应商配置,真实生产供应商仍待确认。 | 接入真实模型前。 |
| Q-004 | 飞书知识库实时检索 API 是否满足 SpaceID/NodeID 检索要求? | 先做 mock + 技术验证 | 阶段二后端基础工程完成后尽快验证。 | | Q-004 | 飞书知识库实时检索 API 是否满足 SpaceID/NodeID 检索要求? | 先做 mock + 技术验证 | 阶段二后端基础工程完成后尽快验证。 |
| Q-005 | 模型 API Key 生产环境如何保存? | 优先环境变量引用或加密存储 | 做模型管理功能前。 | | Q-005 | 模型 API Key 生产环境如何保存? | 优先环境变量引用或加密存储 | 做模型管理功能前。 |
| Q-006 | 飞书检索是否直接调飞书原生 API还是先由独立适配服务封装 | 当前先预留 `FEISHU_SEARCH_URL` 适配服务入口 | 飞书账号、权限和 API 返回结构确认后。 | | Q-006 | 飞书检索是否直接调飞书原生 API还是先由独立适配服务封装 | 当前先预留 `FEISHU_SEARCH_URL` 适配服务入口 | 飞书账号、权限和 API 返回结构确认后。 |

View File

@@ -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` 从配置字段升级为真实调用策略。

View File

@@ -33,4 +33,5 @@
| `2026-07-06-phase4-rag-external-services.md` | 阶段四 RAG 和外部服务接入骨架记录。 | | `2026-07-06-phase4-rag-external-services.md` | 阶段四 RAG 和外部服务接入骨架记录。 |
| `2026-07-06-phase5-admin-console.md` | 阶段五管理后台记录。 | | `2026-07-06-phase5-admin-console.md` | 阶段五管理后台记录。 |
| `2026-07-07-admin-chat-records-completion.md` | 管理后台聊天记录补齐记录。 | | `2026-07-07-admin-chat-records-completion.md` | 管理后台聊天记录补齐记录。 |
| `2026-07-07-model-management-completion.md` | 管理后台模型管理补全记录。 |
| `../test_records/2026-07-06-phase6-verification.md` | 阶段六验证记录。 | | `../test_records/2026-07-06-phase6-verification.md` | 阶段六验证记录。 |