Complete admin model management
This commit is contained in:
@@ -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 Messages、Gemini 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>
|
||||||
|
|||||||
@@ -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) }),
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 返回结构确认后。 |
|
||||||
|
|||||||
@@ -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` 从配置字段升级为真实调用策略。
|
||||||
@@ -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` | 阶段六验证记录。 |
|
||||||
|
|||||||
Reference in New Issue
Block a user