Implement open knowledge access scope

This commit is contained in:
2026-07-07 18:32:01 +08:00
parent 8d5d36c5eb
commit 03b1810a90
13 changed files with 587 additions and 382 deletions

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ElMessage } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import { api, clearToken, getToken, saveToken } from "./services/api"; import { api, clearToken, getToken, saveToken } from "./services/api";
import type { import type {
@@ -15,15 +15,6 @@ import type {
ModelItem, ModelItem,
} from "./types/api"; } from "./types/api";
type UserKnowledgePermissionRow = {
knowledgeId: number;
name: string;
status: number;
assigned: boolean;
effectiveAt: string;
expiredAt: string;
};
const admin = ref<AdminProfile | null>(null); const admin = ref<AdminProfile | null>(null);
const activeMenu = ref("dashboard"); const activeMenu = ref("dashboard");
const loading = ref(false); const loading = ref(false);
@@ -44,10 +35,6 @@ const chatDetailOpen = ref(false);
const recordTab = ref("chats"); const recordTab = ref("chats");
const editingModelId = ref<number | null>(null); const editingModelId = ref<number | null>(null);
const editingKnowledgeId = ref<number | null>(null); const editingKnowledgeId = ref<number | null>(null);
const userPermissionOpen = ref(false);
const userPermissionLoading = ref(false);
const permissionUser = ref<AdminUser | null>(null);
const userKnowledgePermissionRows = ref<UserKnowledgePermissionRow[]>([]);
const agentDebugging = ref(false); const agentDebugging = ref(false);
const agentPreviewMessages = ref<{ role: "user" | "assistant" | "system"; content: string }[]>([ const agentPreviewMessages = ref<{ role: "user" | "assistant" | "system"; content: string }[]>([
{ role: "assistant", content: "选择模型和知识库后,可以在这里调试 Agent 的真实问答效果。" }, { role: "assistant", content: "选择模型和知识库后,可以在这里调试 Agent 的真实问答效果。" },
@@ -111,8 +98,8 @@ const quickModelProviders = [
{ {
label: "MiniMax", label: "MiniMax",
provider: "minimax", provider: "minimax",
apiType: "anthropic_messages", apiType: "openai_compatible",
baseUrl: "https://api.minimax.io/anthropic", baseUrl: "https://api.minimaxi.com/v1",
authType: "bearer", authType: "bearer",
apiVersion: "", apiVersion: "",
temperature: null, temperature: null,
@@ -120,14 +107,8 @@ const quickModelProviders = [
maxToken: null, maxToken: null,
contextWindow: null, contextWindow: null,
models: [ models: [
{ label: "MiniMax-Text-01", value: "MiniMax-Text-01" },
{ label: "MiniMax-M3", value: "MiniMax-M3" }, { label: "MiniMax-M3", value: "MiniMax-M3" },
{ label: "MiniMax-M2.7", value: "MiniMax-M2.7" },
{ label: "MiniMax-M2.7-highspeed", value: "MiniMax-M2.7-highspeed" },
{ label: "MiniMax-M2.5", value: "MiniMax-M2.5" },
{ label: "MiniMax-M2.5-highspeed", value: "MiniMax-M2.5-highspeed" },
{ label: "MiniMax-M2.1", value: "MiniMax-M2.1" },
{ label: "MiniMax-M2.1-highspeed", value: "MiniMax-M2.1-highspeed" },
{ label: "MiniMax-M2", value: "MiniMax-M2" },
], ],
}, },
] as const; ] as const;
@@ -177,7 +158,8 @@ const chatFilters = reactive({
keyword: "", keyword: "",
userId: undefined as number | undefined, userId: undefined as number | undefined,
status: "", status: "",
dateRange: [] as string[], dateFrom: "",
dateTo: "",
}); });
const agentForm = reactive({ const agentForm = reactive({
@@ -266,45 +248,6 @@ async function deleteUser(row: AdminUser) {
await loadCurrentMenu(); await loadCurrentMenu();
} }
async function openUserPermissions(row: AdminUser) {
permissionUser.value = row;
userPermissionOpen.value = true;
userPermissionLoading.value = true;
try {
const [knowledgeRows, detail] = await Promise.all([api.knowledge(), api.userKnowledgePermissions(row.id)]);
knowledge.value = knowledgeRows;
const permissionMap = new Map(detail.permissions.map((item) => [item.knowledgeId, item]));
userKnowledgePermissionRows.value = knowledgeRows.map((item) => {
const permission = permissionMap.get(item.id);
return {
knowledgeId: item.id,
name: item.name,
status: item.status,
assigned: Boolean(permission),
effectiveAt: permission?.effectiveAt ?? "",
expiredAt: permission?.expiredAt ?? "",
};
});
} finally {
userPermissionLoading.value = false;
}
}
async function saveUserPermissions() {
if (!permissionUser.value) return;
await api.saveUserKnowledgePermissions(permissionUser.value.id, {
permissions: userKnowledgePermissionRows.value
.filter((item) => item.assigned)
.map((item) => ({
knowledgeId: item.knowledgeId,
effectiveAt: item.effectiveAt || null,
expiredAt: item.expiredAt || null,
})),
});
ElMessage.success("知识库权限已保存");
userPermissionOpen.value = false;
}
async function saveKnowledge() { async function saveKnowledge() {
if (!knowledgeForm.name || !knowledgeForm.feishuSpaceId || !knowledgeForm.feishuNodeId) { if (!knowledgeForm.name || !knowledgeForm.feishuSpaceId || !knowledgeForm.feishuNodeId) {
ElMessage.error("请填写知识库名称、SpaceID 和 NodeID"); ElMessage.error("请填写知识库名称、SpaceID 和 NodeID");
@@ -344,7 +287,7 @@ function editKnowledge(row: KnowledgeItem) {
} }
async function deleteKnowledge(row: KnowledgeItem) { async function deleteKnowledge(row: KnowledgeItem) {
if (!window.confirm(`确认删除知识库 ${row.name} 吗?已分配给用户的授权也会失效`)) return; if (!window.confirm(`确认删除知识库 ${row.name} 吗?删除后所有用户都无法再检索该知识库`)) return;
await api.deleteKnowledge(row.id); await api.deleteKnowledge(row.id);
ElMessage.success("知识库已删除"); ElMessage.success("知识库已删除");
if (editingKnowledgeId.value === row.id) resetKnowledgeForm(); if (editingKnowledgeId.value === row.id) resetKnowledgeForm();
@@ -404,8 +347,12 @@ function buildModelPayload() {
return payload; return payload;
} }
const selectedQuickProvider = computed(() => {
return quickModelProviders.find((item) => item.provider === quickModelForm.provider) ?? quickModelProviders[0];
});
async function quickAddModel() { async function quickAddModel() {
const provider = selectedQuickProvider(); const provider = selectedQuickProvider.value;
if (!provider) return; if (!provider) return;
if (!quickModelForm.apiKey) { if (!quickModelForm.apiKey) {
ElMessage.error("请填写 API Key"); ElMessage.error("请填写 API Key");
@@ -440,10 +387,6 @@ async function quickAddModel() {
await loadCurrentMenu(); await loadCurrentMenu();
} }
function selectedQuickProvider() {
return quickModelProviders.find((item) => item.provider === quickModelForm.provider) ?? quickModelProviders[0];
}
function applyQuickProvider(provider: string) { function applyQuickProvider(provider: string) {
const target = quickModelProviders.find((item) => item.provider === provider); const target = quickModelProviders.find((item) => item.provider === provider);
if (!target) return; if (!target) return;
@@ -456,6 +399,21 @@ async function enableModel(id: number) {
await loadCurrentMenu(); await loadCurrentMenu();
} }
async function deleteModel(id: number) {
try {
await ElMessageBox.confirm("确认删除该模型?删除后不可恢复。", "删除模型", {
confirmButtonText: "确认删除",
cancelButtonText: "取消",
type: "warning",
});
} catch {
return;
}
await api.deleteModel(id);
ElMessage.success("模型已删除");
await loadCurrentMenu();
}
async function testModel(id: number) { async function testModel(id: number) {
const result = await api.testModel(id); const result = await api.testModel(id);
if (result.ok) { if (result.ok) {
@@ -621,7 +579,7 @@ async function searchChats() {
} }
async function resetChatFilters() { async function resetChatFilters() {
Object.assign(chatFilters, { keyword: "", userId: undefined, status: "", dateRange: [] }); Object.assign(chatFilters, { keyword: "", userId: undefined, status: "", dateFrom: "", dateTo: "" });
await searchChats(); await searchChats();
} }
@@ -639,8 +597,8 @@ function buildChatQuery() {
keyword: chatFilters.keyword, keyword: chatFilters.keyword,
userId: chatFilters.userId, userId: chatFilters.userId,
status: chatFilters.status, status: chatFilters.status,
dateFrom: chatFilters.dateRange?.[0], dateFrom: chatFilters.dateFrom,
dateTo: chatFilters.dateRange?.[1], dateTo: chatFilters.dateTo,
}; };
} }
</script> </script>
@@ -717,10 +675,9 @@ function buildChatQuery() {
</el-table-column> </el-table-column>
<el-table-column prop="dailyChatUsed" label="已用" width="90" /> <el-table-column prop="dailyChatUsed" label="已用" width="90" />
<el-table-column prop="lastLoginAt" label="最近登录" width="180" /> <el-table-column prop="lastLoginAt" label="最近登录" width="180" />
<el-table-column label="操作" width="250" fixed="right"> <el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button size="small" @click="saveUser(row)">保存</el-button> <el-button size="small" @click="saveUser(row)">保存</el-button>
<el-button size="small" type="primary" @click="openUserPermissions(row)">知识库权限</el-button>
<el-button size="small" type="danger" @click="deleteUser(row)">删除</el-button> <el-button size="small" type="danger" @click="deleteUser(row)">删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
@@ -876,9 +833,9 @@ function buildChatQuery() {
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="模型"> <el-form-item label="模型">
<el-select v-model="quickModelForm.modelName"> <el-select v-model="quickModelForm.modelName" :key="quickModelForm.provider">
<el-option <el-option
v-for="model in selectedQuickProvider()?.models" v-for="model in selectedQuickProvider.models"
:key="model.value" :key="model.value"
:label="model.label" :label="model.label"
:value="model.value" :value="model.value"
@@ -911,6 +868,7 @@ function buildChatQuery() {
<el-select v-model="modelForm.apiType"> <el-select v-model="modelForm.apiType">
<el-option label="OpenAI 兼容" value="openai_compatible" /> <el-option label="OpenAI 兼容" value="openai_compatible" />
<el-option label="Anthropic Messages" value="anthropic_messages" /> <el-option label="Anthropic Messages" value="anthropic_messages" />
<el-option label="MiniMax 官方" value="minimax" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="模型名"> <el-form-item label="模型名">
@@ -959,6 +917,7 @@ function buildChatQuery() {
<el-button size="small" @click="editModel(row)">编辑</el-button> <el-button size="small" @click="editModel(row)">编辑</el-button>
<el-button size="small" @click="testModel(row.id)">测试</el-button> <el-button size="small" @click="testModel(row.id)">测试</el-button>
<el-button size="small" type="primary" @click="enableModel(row.id)">启用</el-button> <el-button size="small" type="primary" @click="enableModel(row.id)">启用</el-button>
<el-button size="small" type="danger" @click="deleteModel(row.id)">删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -994,13 +953,19 @@ function buildChatQuery() {
<el-option label="失败" value="FAILED" /> <el-option label="失败" value="FAILED" />
</el-select> </el-select>
<el-date-picker <el-date-picker
v-model="chatFilters.dateRange" v-model="chatFilters.dateFrom"
class="record-date-range" class="record-date-item"
type="datetimerange" type="datetime"
unlink-panels placeholder="开始时间"
start-placeholder="开始时间" value-format="YYYY-MM-DD HH:mm:ss"
end-placeholder="结束时间" />
value-format="YYYY-MM-DDTHH:mm:ss" <span class="record-date-sep"></span>
<el-date-picker
v-model="chatFilters.dateTo"
class="record-date-item"
type="datetime"
placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
/> />
<div class="record-filter-actions"> <div class="record-filter-actions">
<el-button type="primary" @click="searchChats">搜索</el-button> <el-button type="primary" @click="searchChats">搜索</el-button>
@@ -1047,62 +1012,6 @@ function buildChatQuery() {
</section> </section>
</section> </section>
<el-drawer v-model="userPermissionOpen" size="760px" title="知识库权限分配">
<template v-if="permissionUser">
<section class="permission-summary">
<div>
<span>用户</span>
<strong>{{ permissionUser.name || '-' }}</strong>
</div>
<div>
<span>手机号</span>
<strong>{{ permissionUser.phone }}</strong>
</div>
<div>
<span>每日额度</span>
<strong>{{ permissionUser.dailyChatLimit }}</strong>
</div>
</section>
<el-table v-loading="userPermissionLoading" :data="userKnowledgePermissionRows" stripe>
<el-table-column label="授权" width="90">
<template #default="{ row }">
<el-switch v-model="row.assigned" />
</template>
</el-table-column>
<el-table-column prop="name" label="知识库" min-width="170" />
<el-table-column label="状态" width="90">
<template #default="{ row }">{{ row.status === 1 ? "启用" : "禁用" }}</template>
</el-table-column>
<el-table-column label="生效时间" width="220">
<template #default="{ row }">
<el-date-picker
v-model="row.effectiveAt"
type="datetime"
value-format="YYYY-MM-DDTHH:mm:ss"
placeholder="不限制"
:disabled="!row.assigned"
/>
</template>
</el-table-column>
<el-table-column label="失效时间" width="220">
<template #default="{ row }">
<el-date-picker
v-model="row.expiredAt"
type="datetime"
value-format="YYYY-MM-DDTHH:mm:ss"
placeholder="不限制"
:disabled="!row.assigned"
/>
</template>
</el-table-column>
</el-table>
<div class="drawer-actions">
<el-button type="primary" @click="saveUserPermissions">保存权限</el-button>
<el-button @click="userPermissionOpen = false">取消</el-button>
</div>
</template>
</el-drawer>
<el-drawer v-model="chatDetailOpen" size="760px" title="聊天详情"> <el-drawer v-model="chatDetailOpen" size="760px" title="聊天详情">
<template v-if="chatDetail"> <template v-if="chatDetail">
<section class="chat-summary"> <section class="chat-summary">

View File

@@ -33,11 +33,13 @@ import "element-plus/theme-chalk/el-switch.css";
import "element-plus/theme-chalk/el-table.css"; import "element-plus/theme-chalk/el-table.css";
import "element-plus/theme-chalk/el-tabs.css"; import "element-plus/theme-chalk/el-tabs.css";
import { createApp } from "vue"; import { createApp } from "vue";
import zhCn from "element-plus/es/locale/lang/zh-cn";
import App from "./App.vue"; import App from "./App.vue";
import "./styles.css"; import "./styles.css";
const app = createApp(App); const app = createApp(App);
app.config.globalProperties.$ELEMENT = { locale: zhCn };
[ [
ElButton, ElButton,

View File

@@ -10,7 +10,6 @@ import type {
DashboardStats, DashboardStats,
KnowledgeItem, KnowledgeItem,
ModelItem, ModelItem,
UserKnowledgePermissionDetail,
} from "../types/api"; } from "../types/api";
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "http://127.0.0.1:8100/api"; const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "http://127.0.0.1:8100/api";
@@ -77,13 +76,6 @@ export const api = {
updateUser: (id: number, payload: Record<string, unknown>) => updateUser: (id: number, payload: Record<string, unknown>) =>
request<AdminUser>(`/admin/user/${id}`, { method: "PUT", body: JSON.stringify(payload) }), request<AdminUser>(`/admin/user/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
deleteUser: (id: number) => request<null>(`/admin/user/${id}`, { method: "DELETE" }), deleteUser: (id: number) => request<null>(`/admin/user/${id}`, { method: "DELETE" }),
userKnowledgePermissions: (id: number) =>
request<UserKnowledgePermissionDetail>(`/admin/user/${id}/knowledge-permissions`),
saveUserKnowledgePermissions: (id: number, payload: Record<string, unknown>) =>
request<UserKnowledgePermissionDetail>(`/admin/user/${id}/knowledge-permissions`, {
method: "PUT",
body: JSON.stringify(payload),
}),
knowledge: () => request<KnowledgeItem[]>("/admin/knowledge/list"), knowledge: () => request<KnowledgeItem[]>("/admin/knowledge/list"),
createKnowledge: (payload: Record<string, unknown>) => createKnowledge: (payload: Record<string, unknown>) =>
request<KnowledgeItem>("/admin/knowledge", { method: "POST", body: JSON.stringify(payload) }), request<KnowledgeItem>("/admin/knowledge", { method: "POST", body: JSON.stringify(payload) }),
@@ -103,6 +95,8 @@ export const api = {
request<ModelItem>(`/admin/model/${id}`, { method: "PUT", body: JSON.stringify(payload) }), 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 }) }),
deleteModel: (modelId: number) =>
request<null>(`/admin/model/${modelId}`, { method: "DELETE" }),
testModel: (modelId: number) => testModel: (modelId: number) =>
request<{ ok: boolean; message: string; answer: string }>(`/admin/model/${modelId}/test`, { request<{ ok: boolean; message: string; answer: string }>(`/admin/model/${modelId}/test`, {
method: "POST", method: "POST",

View File

@@ -377,42 +377,6 @@ textarea {
margin-top: 12px; margin-top: 12px;
} }
.permission-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-bottom: 14px;
}
.permission-summary div {
padding: 12px;
border: 1px solid #dfe8e5;
border-radius: 8px;
background: #f8fbfa;
}
.permission-summary span {
display: block;
color: #6b7d77;
font-size: 12px;
}
.permission-summary strong {
display: block;
margin-top: 6px;
overflow: hidden;
color: #1f2d2a;
text-overflow: ellipsis;
white-space: nowrap;
}
.drawer-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 16px;
}
.el-drawer .el-date-editor.el-input { .el-drawer .el-date-editor.el-input {
width: 100%; width: 100%;
} }
@@ -437,10 +401,16 @@ textarea {
width: 150px; width: 150px;
} }
.record-date-range { .record-date-item {
width: 580px !important; width: 180px;
max-width: 100%; }
flex: 0 0 580px;
.record-date-sep {
color: #909399;
font-size: 13px;
display: flex;
align-items: center;
padding: 0 2px;
} }
.record-filter-actions { .record-filter-actions {
@@ -465,8 +435,10 @@ textarea {
.chat-summary span { .chat-summary span {
display: block; display: block;
color: #6b7d77; font-size: 22px;
font-size: 12px; font-weight: 600;
color: #2a6b56;
margin-top: 4px;
} }
.chat-summary strong { .chat-summary strong {

View File

@@ -43,17 +43,6 @@ export interface KnowledgeItem {
updatedAt?: string | null; updatedAt?: string | null;
} }
export interface UserKnowledgePermissionItem {
knowledgeId: number;
effectiveAt?: string | null;
expiredAt?: string | null;
}
export interface UserKnowledgePermissionDetail {
user: AdminUser;
permissions: UserKnowledgePermissionItem[];
}
export interface ModelItem { export interface ModelItem {
id: number; id: number;
provider: string; provider: string;

View File

@@ -69,6 +69,7 @@ def delete_knowledge(
current_admin: Admin = Depends(get_current_admin), current_admin: Admin = Depends(get_current_admin),
) -> dict: ) -> dict:
knowledge = _get_knowledge(db, knowledge_id) knowledge = _get_knowledge(db, knowledge_id)
# 清理早期按用户授权口径留下的历史关联,避免外键阻止知识库删除。
permissions = db.scalars( permissions = db.scalars(
select(UserKnowledgePermission).where(UserKnowledgePermission.knowledge_id == knowledge.id) select(UserKnowledgePermission).where(UserKnowledgePermission.knowledge_id == knowledge.id)
).all() ).all()

View File

@@ -1,7 +1,5 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -10,9 +8,8 @@ from app.core.database import get_db
from app.core.dependencies import get_current_admin from app.core.dependencies import get_current_admin
from app.core.responses import api_success from app.core.responses import api_success
from app.models.admin import Admin from app.models.admin import Admin
from app.models.knowledge import Knowledge, UserKnowledgePermission
from app.models.user import User from app.models.user import User
from app.schemas.admin import AdminUserKnowledgePermissionSaveRequest, AdminUserUpdateRequest from app.schemas.admin import AdminUserUpdateRequest
from app.services.admin_service import OperationLogService from app.services.admin_service import OperationLogService
router = APIRouter() router = APIRouter()
@@ -63,72 +60,6 @@ def update_user(
return api_success(_user_dict(user)) return api_success(_user_dict(user))
@router.get("/user/{user_id}/knowledge-permissions")
def user_knowledge_permissions(
user_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
user = _get_user(db, user_id)
permissions = db.scalars(
select(UserKnowledgePermission)
.where(UserKnowledgePermission.user_id == user.id)
.order_by(UserKnowledgePermission.knowledge_id.asc())
).all()
return api_success(
{
"user": _user_dict(user),
"permissions": [_permission_dict(permission) for permission in permissions],
}
)
@router.put("/user/{user_id}/knowledge-permissions")
def save_user_knowledge_permissions(
user_id: int,
payload: AdminUserKnowledgePermissionSaveRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
user = _get_user(db, user_id)
normalized = {item.knowledgeId: item for item in payload.permissions}
if normalized:
existing_ids = set(db.scalars(select(Knowledge.id).where(Knowledge.id.in_(normalized.keys()))).all())
missing_ids = sorted(set(normalized.keys()) - existing_ids)
if missing_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"知识库不存在: {','.join(str(item) for item in missing_ids)}",
)
old_permissions = db.scalars(
select(UserKnowledgePermission).where(UserKnowledgePermission.user_id == user.id)
).all()
for permission in old_permissions:
db.delete(permission)
db.flush()
for item in normalized.values():
db.add(
UserKnowledgePermission(
user_id=user.id,
knowledge_id=item.knowledgeId,
effective_at=_without_timezone(item.effectiveAt),
expired_at=_without_timezone(item.expiredAt),
created_by=current_admin.id,
)
)
OperationLogService.write(
db,
admin_id=current_admin.id,
module="user",
action="knowledge_permission",
target_id=user.id,
)
db.commit()
return user_knowledge_permissions(user.id, db, current_admin)
@router.delete("/user/{user_id}") @router.delete("/user/{user_id}")
def delete_user( def delete_user(
user_id: int, user_id: int,
@@ -165,17 +96,3 @@ def _user_dict(user: User) -> dict:
"lastLoginAt": user.last_login_at, "lastLoginAt": user.last_login_at,
"createdAt": user.created_at, "createdAt": user.created_at,
} }
def _permission_dict(permission: UserKnowledgePermission) -> dict:
return {
"knowledgeId": permission.knowledge_id,
"effectiveAt": permission.effective_at,
"expiredAt": permission.expired_at,
}
def _without_timezone(value: datetime | None) -> datetime | None:
if value is None:
return None
return value.replace(tzinfo=None)

View File

@@ -37,7 +37,8 @@ class Settings(BaseSettings):
mock_rag_enabled: bool = True mock_rag_enabled: bool = True
mock_model_enabled: bool = True mock_model_enabled: bool = True
feishu_mock_enabled: bool = True feishu_mock_enabled: bool = True
feishu_search_url: str = "" feishu_app_id: str = ""
feishu_app_secret: str = ""
feishu_timeout_seconds: int = 20 feishu_timeout_seconds: int = 20
feishu_retry_count: int = 2 feishu_retry_count: int = 2

View File

@@ -39,16 +39,6 @@ class AdminUserUpdateRequest(BaseModel):
expiredAt: datetime | None = None expiredAt: datetime | None = None
class AdminUserKnowledgePermissionItem(BaseModel):
knowledgeId: int = Field(gt=0)
effectiveAt: datetime | None = None
expiredAt: datetime | None = None
class AdminUserKnowledgePermissionSaveRequest(BaseModel):
permissions: list[AdminUserKnowledgePermissionItem] = Field(default_factory=list)
class KnowledgeSaveRequest(BaseModel): class KnowledgeSaveRequest(BaseModel):
name: str = Field(min_length=1, max_length=100) name: str = Field(min_length=1, max_length=100)
feishuSpaceId: str = Field(min_length=1, max_length=100) feishuSpaceId: str = Field(min_length=1, max_length=100)

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import time
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import httpx import httpx
@@ -11,18 +12,260 @@ from app.services.knowledge_service import KnowledgeScope
if TYPE_CHECKING: if TYPE_CHECKING:
from app.services.rag_service import RetrievedChunk from app.services.rag_service import RetrievedChunk
# 飞书 API 基地址
FEISHU_OPEN_API = "https://open.feishu.cn"
# tenant_access_token 缓存
_token_cache: dict[str, Any] = {"token": "", "expires_at": 0.0}
def _get_tenant_token() -> str:
"""获取飞书 tenant_access_token自动缓存"""
settings = get_settings()
now = time.time()
if _token_cache["token"] and now < _token_cache["expires_at"]:
return _token_cache["token"]
url = f"{FEISHU_OPEN_API}/open-apis/auth/v3/tenant_access_token/internal"
payload = {
"app_id": settings.feishu_app_id,
"app_secret": settings.feishu_app_secret,
}
try:
resp = httpx.post(url, json=payload, timeout=settings.feishu_timeout_seconds)
resp.raise_for_status()
data = resp.json()
if data.get("code") != 0:
raise ExternalServiceError(
f"获取飞书Token失败{data.get('msg', '未知错误')}",
provider="feishu",
)
token = data["tenant_access_token"]
expire = data.get("expire", 7200)
_token_cache["token"] = token
_token_cache["expires_at"] = now + expire - 300 # 提前5分钟过期
return token
except httpx.HTTPError as exc:
raise ExternalServiceError(f"获取飞书Token网络错误{exc}", provider="feishu") from exc
def _feishu_get(path: str, params: dict | None = None) -> dict:
"""封装飞书 GET 请求"""
token = _get_tenant_token()
url = f"{FEISHU_OPEN_API}{path}"
headers = {"Authorization": f"Bearer {token}"}
settings = get_settings()
resp = httpx.get(url, headers=headers, params=params, timeout=settings.feishu_timeout_seconds)
resp.raise_for_status()
data = resp.json()
if data.get("code") != 0:
raise ExternalServiceError(
f"飞书API调用失败 [{path}]{data.get('msg', '未知错误')}",
provider="feishu",
)
return data
def _feishu_post(path: str, payload: dict | None = None) -> dict:
"""封装飞书 POST 请求"""
token = _get_tenant_token()
url = f"{FEISHU_OPEN_API}{path}"
headers = {"Authorization": f"Bearer {token}"}
settings = get_settings()
resp = httpx.post(url, headers=headers, json=payload, timeout=settings.feishu_timeout_seconds)
resp.raise_for_status()
data = resp.json()
if data.get("code") != 0:
raise ExternalServiceError(
f"飞书API调用失败 [{path}]{data.get('msg', '未知错误')}",
provider="feishu",
)
return data
def _extract_text(elements: list[dict]) -> str:
"""从 elements 列表中提取纯文本"""
texts = []
for elem in elements:
text_run = elem.get("text_run", {})
content = text_run.get("content", "")
if content:
texts.append(content)
return "".join(texts)
def _parse_docx_content(blocks: list[dict]) -> str:
"""将飞书文档 block 列表解析为纯文本"""
texts = []
for block in blocks:
block_type = block.get("block_type", 0)
# Page block (block_type=1): 标题可能在 page.elements
if block_type == 1:
elements = block.get("page", {}).get("elements", [])
content = _extract_text(elements)
if content:
texts.append(content)
texts.append("\n")
# 子内容在 children 里,但子 block 也会遍历到
# 文本块 (block_type=2 is text paragraph in new API)
elif block_type == 2:
elements = block.get("text", {}).get("elements", [])
content = _extract_text(elements)
if content:
texts.append(content)
texts.append("\n")
# 标题块 (3=heading1, 4=heading2, 5=heading3, 6=heading4, 7=heading5, 8=heading6)
elif 3 <= block_type <= 8:
heading_level = block_type - 2 # 3->h1, 4->h2, etc.
key = f"heading{heading_level}"
elements = block.get(key, {}).get("elements", [])
content = _extract_text(elements)
if content:
texts.append(f"{'#' * heading_level} {content}")
texts.append("\n")
# 引用块 (15=quote)
elif block_type == 15:
elements = block.get("quote", {}).get("elements", [])
content = _extract_text(elements)
if content:
texts.append(f" > {content}")
texts.append("\n")
# 列表块 - bullet (9), ordered (10)
elif block_type in (9, 10):
key = "bullet" if block_type == 9 else "ordered"
elements = block.get(key, {}).get("elements", [])
content = _extract_text(elements)
if content:
prefix = "-" if block_type == 9 else "1."
texts.append(f" {prefix} {content}")
texts.append("\n")
# 其他 block 类型也尝试提取文本
else:
for sub_key in ("text", "quote", "bullet", "ordered"):
if sub_key in block:
elements = block.get(sub_key, {}).get("elements", [])
content = _extract_text(elements)
if content:
texts.append(content)
texts.append("\n")
break
return "".join(texts).strip()
def _get_docx_content(doc_token: str) -> str:
"""读取单个飞书云文档内容"""
data = _feishu_get(f"/open-apis/docx/v1/documents/{doc_token}/blocks")
items = data.get("data", {}).get("items", [])
return _parse_docx_content(items)
def _get_wiki_node_info(node_token: str) -> dict:
"""获取 Wiki 节点信息,返回 space_id 和 obj_token"""
data = _feishu_get("/open-apis/wiki/v2/spaces/get_node", params={"token": node_token})
node = data.get("data", {}).get("node", {})
return {
"space_id": node.get("space_id", ""),
"obj_token": node.get("obj_token", ""), # 文档的实际 token
"obj_type": node.get("obj_type", ""), # docx / doc / sheet
"title": node.get("title", ""),
"node_token": node.get("node_token", ""),
}
def _get_wiki_children(node_token: str, page_size: int = 50) -> list[dict]:
"""获取 Wiki 节点的子节点列表"""
all_children = []
page_token = ""
while True:
params: dict[str, Any] = {
"page_size": page_size,
"parent_node_token": node_token,
}
if page_token:
params["page_token"] = page_token
data = _feishu_get("/open-apis/wiki/v2/spaces/nodes/get_child_nodes", params=params)
children = data.get("data", {}).get("items", [])
all_children.extend(children)
page_token = data.get("data", {}).get("page_token", "")
if not page_token:
break
return all_children
def _get_space_node_tree(space_id: str) -> list[dict]:
"""获取知识空间下所有根节点"""
all_nodes = []
page_token = ""
while True:
params: dict[str, Any] = {
"page_size": 50,
"space_id": space_id,
}
if page_token:
params["page_token"] = page_token
data = _feishu_get("/open-apis/wiki/v2/spaces/nodes", params=params)
items = data.get("data", {}).get("items", [])
all_nodes.extend(items)
page_token = data.get("data", {}).get("page_token", "")
if not page_token:
break
return all_nodes
class FeishuKnowledgeService: class FeishuKnowledgeService:
_mock_documents = [ """飞书知识库检索服务 - 直接调用飞书官方 API"""
# 文档内容缓存: node_token -> (content, timestamp)
_content_cache: dict[str, tuple[str, float]] = {}
_CACHE_TTL = 300 # 缓存 5 分钟
@classmethod
def retrieve(cls, question: str, scopes: list[KnowledgeScope]) -> list[RetrievedChunk]:
if not scopes:
return []
settings = get_settings()
if settings.feishu_mock_enabled:
return cls._retrieve_mock(question, scopes)
return cls._retrieve_from_feishu(question, scopes)
@classmethod
def _retrieve_mock(cls, question: str, scopes: list[KnowledgeScope]) -> list[RetrievedChunk]:
from app.services.rag_service import RetrievedChunk
mock_documents = [
{ {
"title": "一期产品目标", "title": "一期产品目标",
"keywords": {"一期", "目标", "问答", "登录", "知识库", "飞书", "用户端", "后台"}, "keywords": {"一期", "目标", "问答", "登录", "知识库", "飞书", "用户端", "后台"},
"content": "一期要交付企业飞书知识库 AI 问答系统核心包括用户登录、AI 问答、权限内知识库回答和后台管理能力。", "content": "一期要交付企业飞书知识库 AI 问答系统核心包括用户登录、AI 问答、基于已开放知识库回答和后台管理能力。",
}, },
{ {
"title": "权限过滤规则", "title": "知识库开放过滤规则",
"keywords": {"权限", "授权", "越权", "用户", "知识库", "过期", "禁用"}, "keywords": {"权限", "开放", "用户", "知识库", "禁用", "可见"},
"content": "RAG 检索前必须先过滤用户授权知识库,只允许未禁用、未过期、当前用户有权限的知识库参与回答", "content": "RAG 检索前必须先过滤知识库开放状态,只允许已开放、未禁用的知识库参与回答,已开放知识库默认所有用户可见",
}, },
{ {
"title": "无命中兜底规则", "title": "无命中兜底规则",
@@ -36,24 +279,10 @@ class FeishuKnowledgeService:
}, },
] ]
@classmethod
def retrieve(cls, question: str, scopes: list[KnowledgeScope]) -> list["RetrievedChunk"]:
if not scopes:
return []
settings = get_settings()
if settings.feishu_mock_enabled:
return cls._retrieve_mock(question, scopes)
return cls._retrieve_remote(question, scopes)
@classmethod
def _retrieve_mock(cls, question: str, scopes: list[KnowledgeScope]) -> list["RetrievedChunk"]:
from app.services.rag_service import RetrievedChunk
normalized_question = question.lower() normalized_question = question.lower()
matched_documents = [] matched_documents = []
for document in cls._mock_documents: for document in mock_documents:
if any(keyword.lower() in normalized_question for keyword in document["keywords"]): if any(keyword in normalized_question for keyword in document["keywords"]):
matched_documents.append(document) matched_documents.append(document)
if not matched_documents: if not matched_documents:
@@ -72,51 +301,195 @@ class FeishuKnowledgeService:
] ]
@classmethod @classmethod
def _retrieve_remote(cls, question: str, scopes: list[KnowledgeScope]) -> list["RetrievedChunk"]: def _retrieve_from_feishu(cls, question: str, scopes: list[KnowledgeScope]) -> list[RetrievedChunk]:
"""从飞书知识库检索文档内容"""
from app.services.rag_service import RetrievedChunk from app.services.rag_service import RetrievedChunk
settings = get_settings() # 收集所有文档片段
if not settings.feishu_search_url: all_chunks: list[tuple[KnowledgeScope, str, str, str | None]] = []
raise ExternalServiceError("飞书检索地址未配置", provider="feishu")
payload = { for scope in scopes:
"query": question, try:
"knowledgeScopes": [ documents = cls._load_documents(scope)
{ except ExternalServiceError:
"id": scope.id, continue
"spaceId": scope.feishu_space_id,
"nodeId": scope.feishu_node_id, for title, content, source_url in documents:
"name": scope.name, all_chunks.append((scope, title, content, source_url))
}
for scope in scopes if not all_chunks:
], return []
}
data = cls._post_with_retry(settings.feishu_search_url, payload) # 关键词匹配评分(简单但够用的检索方式)
chunks = data.get("chunks", []) scored_chunks = cls._score_and_rank(question, all_chunks)
# 返回 top 5 相关片段
return [ return [
RetrievedChunk( RetrievedChunk(
knowledge_id=int(item.get("knowledgeId") or 0), knowledge_id=scope.id,
knowledge_name=str(item.get("knowledgeName") or "飞书知识库"), knowledge_name=scope.name,
title=str(item.get("title") or "未命名片段"), title=title,
content=str(item.get("content") or ""), content=content,
source_url=item.get("sourceUrl"), source_url=source_url,
) )
for item in chunks for scope, title, content, source_url in scored_chunks[:5]
if item.get("content")
] ]
@staticmethod @classmethod
def _post_with_retry(url: str, payload: dict[str, Any]) -> dict[str, Any]: def _load_documents(cls, scope: KnowledgeScope) -> list[tuple[str, str, str | None]]:
settings = get_settings() """加载指定知识库 scope 下的所有文档"""
last_error: Exception | None = None node_id = scope.feishu_node_id
for _ in range(settings.feishu_retry_count + 1): base_url = f"https://zcn7hk6n047c.feishu.cn"
if not node_id:
return []
# 1. 判断 node 类型:先查 wiki 节点信息
try: try:
response = httpx.post(url, json=payload, timeout=settings.feishu_timeout_seconds) node_info = cls._get_node_info_with_cache(node_id)
response.raise_for_status() except ExternalServiceError:
data = response.json() # 可能是普通 docx token直接尝试读取
if not isinstance(data, dict): try:
raise ExternalServiceError("飞书检索响应格式不正确", provider="feishu") content = cls._get_document_content_with_cache(node_id)
return data title = node_id
except (httpx.HTTPError, ValueError, ExternalServiceError) as exc: return [(title, content, f"{base_url}/docx/{node_id}")]
last_error = exc except ExternalServiceError:
raise ExternalServiceError(f"飞书检索失败:{last_error}", provider="feishu") return []
obj_type = node_info.get("obj_type", "")
obj_token = node_info.get("obj_token", "")
title = node_info.get("title", node_id)
space_id = node_info.get("space_id", scope.feishu_space_id)
# 如果节点本身就是一个文档
if obj_type == "docx":
content = cls._get_document_content_with_cache(obj_token)
source_url = f"{base_url}/wiki/{node_id}"
return [(title, content, source_url)]
# 如果节点是文件夹,获取子节点
children = cls._get_wiki_children_with_cache(node_id)
documents = []
for child in children:
child_node_token = child.get("node_token", "")
child_obj_type = child.get("obj_type", "")
child_obj_token = child.get("obj_token", "")
child_title = child.get("title", "")
if child_obj_type == "docx" and child_obj_token:
try:
content = cls._get_document_content_with_cache(child_obj_token)
source_url = f"{base_url}/wiki/{child_node_token}"
documents.append((child_title, content, source_url))
except ExternalServiceError:
continue
# 也读取节点本身的文档(如果有)
if obj_type == "docx" and obj_token:
try:
content = cls._get_document_content_with_cache(obj_token)
source_url = f"{base_url}/wiki/{node_id}"
documents.insert(0, (title, content, source_url))
except ExternalServiceError:
pass
return documents
@classmethod
def _get_node_info_with_cache(cls, node_token: str) -> dict:
"""带缓存获取节点信息"""
cache_key = f"node_info:{node_token}"
now = time.time()
if cache_key in cls._content_cache:
cached_data, ts = cls._content_cache[cache_key]
if now - ts < cls._CACHE_TTL:
return eval(cached_data) # noqa: S307
info = _get_wiki_node_info(node_token)
cls._content_cache[cache_key] = (str(info), now)
return info
@classmethod
def _get_document_content_with_cache(cls, doc_token: str) -> str:
"""带缓存读取文档内容"""
now = time.time()
if doc_token in cls._content_cache:
content, ts = cls._content_cache[doc_token]
if now - ts < cls._CACHE_TTL:
return content
content = _get_docx_content(doc_token)
cls._content_cache[doc_token] = (content, now)
return content
@classmethod
def _get_wiki_children_with_cache(cls, node_token: str) -> list[dict]:
"""带缓存获取子节点"""
cache_key = f"children:{node_token}"
now = time.time()
if cache_key in cls._content_cache:
cached_data, ts = cls._content_cache[cache_key]
if now - ts < cls._CACHE_TTL:
return eval(cached_data) # noqa: S307
children = _get_wiki_children(node_token)
cls._content_cache[cache_key] = (str(children), now)
return children
@classmethod
def _score_and_rank(
cls,
question: str,
chunks: list[tuple[KnowledgeScope, str, str, str | None]],
) -> list[tuple[KnowledgeScope, str, str, str | None]]:
"""基于关键词匹配对文档片段打分排序"""
question_lower = question.lower()
# 提取问题中的关键词(按字/词拆分)
question_chars = set(question_lower)
# 2-gram
question_bigrams = set(question_lower[i : i + 2] for i in range(len(question_lower) - 1))
# 3-gram
question_trigrams = set(question_lower[i : i + 3] for i in range(len(question_lower) - 2))
scored = []
for scope, title, content, source_url in chunks:
content_lower = content.lower()
title_lower = title.lower()
# 计算匹配分
score = 0.0
# 标题匹配(权重高)
for char in question_chars:
if char in title_lower:
score += 3.0
for bigram in question_bigrams:
if bigram in title_lower:
score += 5.0
for trigram in question_trigrams:
if trigram in title_lower:
score += 8.0
# 内容匹配
for char in question_chars:
if char in content_lower:
score += 1.0
for bigram in question_bigrams:
if bigram in content_lower:
score += 2.0
for trigram in question_trigrams:
if trigram in content_lower:
score += 3.0
if score > 0:
scored.append((scope, title, content, source_url, score))
# 按分数降序排列
scored.sort(key=lambda x: x[4], reverse=True)
return [(s, t, c, u) for s, t, c, u, _ in scored]

View File

@@ -1,13 +1,12 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import get_settings from app.core.config import get_settings
from app.models.knowledge import Knowledge, UserKnowledgePermission from app.models.knowledge import Knowledge
from app.models.user import User from app.models.user import User
@@ -23,20 +22,7 @@ class KnowledgeScope:
class KnowledgeAccessService: class KnowledgeAccessService:
@staticmethod @staticmethod
def get_allowed_knowledge(db: Session, user: User) -> list[KnowledgeScope]: def get_allowed_knowledge(db: Session, user: User) -> list[KnowledgeScope]:
now = datetime.now(UTC).replace(tzinfo=None) rows = db.scalars(select(Knowledge).where(Knowledge.status == 1).order_by(Knowledge.id.asc()))
rows = db.execute(
select(Knowledge)
.join(UserKnowledgePermission, UserKnowledgePermission.knowledge_id == Knowledge.id)
.where(
UserKnowledgePermission.user_id == user.id,
Knowledge.status == 1,
(UserKnowledgePermission.effective_at.is_(None))
| (UserKnowledgePermission.effective_at <= now),
(UserKnowledgePermission.expired_at.is_(None))
| (UserKnowledgePermission.expired_at >= now),
)
.order_by(Knowledge.id.asc())
).scalars()
scopes = [ scopes = [
KnowledgeScope( KnowledgeScope(
id=knowledge.id, id=knowledge.id,

View File

@@ -85,7 +85,7 @@ def _mock_answer(rag_result: RagResult) -> str:
f"- {chunk.content}\n 来源:{chunk.knowledge_name} / {chunk.title}" for chunk in rag_result.chunks f"- {chunk.content}\n 来源:{chunk.knowledge_name} / {chunk.title}" for chunk in rag_result.chunks
) )
return ( return (
"根据当前已授权知识库,整理到的信息如下:\n\n" "根据当前已开放知识库,整理到的信息如下:\n\n"
f"{summaries}\n\n" f"{summaries}\n\n"
"当前仍是 mock RAG + mock 模型阶段,后续会把检索服务替换为飞书实时检索," "当前仍是 mock RAG + mock 模型阶段,后续会把检索服务替换为飞书实时检索,"
"把模型服务替换为后台启用的大模型配置。" "把模型服务替换为后台启用的大模型配置。"
@@ -136,6 +136,8 @@ def _call_configured_model(model: ModelConfig, rag_result: RagResult, *, allow_n
return _call_anthropic_messages(model, rag_result) return _call_anthropic_messages(model, rag_result)
if api_type == "gemini_generate_content": if api_type == "gemini_generate_content":
return _call_gemini_generate_content(model, rag_result) return _call_gemini_generate_content(model, rag_result)
if api_type == "minimax":
return _call_minimax(model, rag_result)
return _call_openai_compatible_model(model, rag_result) return _call_openai_compatible_model(model, rag_result)
@@ -166,7 +168,8 @@ def _call_openai_compatible_model(model: ModelConfig, rag_result: RagResult) ->
timeout=model.timeout_second, timeout=model.timeout_second,
) )
response.raise_for_status() response.raise_for_status()
return _extract_openai_answer(response.json()) data = response.json()
return _extract_openai_answer(data)
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
@@ -225,7 +228,15 @@ def _call_gemini_generate_content(model: ModelConfig, rag_result: RagResult) ->
timeout=model.timeout_second, timeout=model.timeout_second,
) )
response.raise_for_status() response.raise_for_status()
return _extract_gemini_answer(response.json()) data = response.json()
# Gemini 有时返回 HTTP 200 但 body 里有 error
if "error" in data:
err = data["error"]
msg = err.get("message", str(err))
raise ExternalServiceError(f"Gemini 调用失败:{msg}", provider="model")
return _extract_gemini_answer(data)
except ExternalServiceError:
raise
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
@@ -260,6 +271,64 @@ def _extract_anthropic_answer(data: dict[str, Any]) -> str:
return answer return answer
def _call_minimax(model: ModelConfig, rag_result: RagResult) -> str:
"""调用 MiniMax 官方 API (非 OpenAI 兼容协议)"""
import urllib.parse
extra = _load_extra_params(model.extra_params)
group_id = extra.pop("group_id", "")
if not group_id:
raise ExternalServiceError("MiniMax 调用需要 group_id请在模型高级参数中配置{\"group_id\": \"xxx\"}", provider="model")
base_url = (model.base_url or "https://api.minimaxi.com/v1/text/chatcompletion_pro").rstrip("/")
# MiniMax 的 baseUrl 在快速添加模板里已经包含完整路径,但如果用户自定义 baseUrl 没有 query需要拼接
if "?" not in base_url:
base_url = f"{base_url}?GroupId={urllib.parse.quote(group_id)}"
elif "GroupId" not in base_url:
base_url = f"{base_url}&GroupId={urllib.parse.quote(group_id)}"
payload: dict[str, Any] = {
"model": model.model_name,
"tokens_to_generate": model.max_token or 1024,
"reply_constraints": {"sender_type": "BOT", "sender_name": "AI知识库助手"},
"messages": [
{"sender_type": "USER", "sender_name": "用户", "text": rag_result.prompt},
],
"bot_setting": [
{
"bot_name": "AI知识库助手",
"content": "你是企业知识库问答助手,只能基于已提供的知识片段回答。",
}
],
}
_put_if_not_none(payload, "temperature", _decimal_to_float(model.temperature))
_put_if_not_none(payload, "top_p", _decimal_to_float(model.top_p))
headers = {"Authorization": f"Bearer {model.api_key}", "Content-Type": "application/json"}
try:
response = httpx.post(base_url, json=payload, headers=headers, timeout=model.timeout_second)
response.raise_for_status()
return _extract_minimax_answer(response.json())
except (httpx.HTTPError, ValueError, KeyError, TypeError) as exc:
raise ExternalServiceError(f"MiniMax 模型调用失败:{exc}", provider="model") from exc
def _extract_minimax_answer(data: dict[str, Any]) -> str:
choices = data.get("choices")
if not isinstance(choices, list) or not choices:
raise ValueError("MiniMax 响应缺少 choices")
first = choices[0]
messages = first.get("messages", [])
if not isinstance(messages, list):
raise ValueError("MiniMax 响应缺少 messages")
texts = [m.get("text", "") for m in messages if isinstance(m, dict)]
answer = "".join(texts).strip()
if not answer:
raise ValueError("MiniMax 响应缺少回答内容")
return answer
def _extract_gemini_answer(data: dict[str, Any]) -> str: def _extract_gemini_answer(data: dict[str, Any]) -> str:
candidates = data.get("candidates") candidates = data.get("candidates")
if not isinstance(candidates, list) or not candidates: if not isinstance(candidates, list) or not candidates:

View File

@@ -34,7 +34,9 @@ services:
MOCK_SMS_CODE: "123456" MOCK_SMS_CODE: "123456"
MOCK_RAG_ENABLED: "true" MOCK_RAG_ENABLED: "true"
MOCK_MODEL_ENABLED: "true" MOCK_MODEL_ENABLED: "true"
FEISHU_MOCK_ENABLED: "true" FEISHU_MOCK_ENABLED: "${FEISHU_MOCK_ENABLED:-true}"
FEISHU_APP_ID: "${FEISHU_APP_ID:-}"
FEISHU_APP_SECRET: "${FEISHU_APP_SECRET:-}"
AUTO_CREATE_TABLES: "false" AUTO_CREATE_TABLES: "false"
ports: ports:
- "8100:8100" - "8100:8100"