Implement open knowledge access scope
This commit is contained in:
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,49 +12,277 @@ 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"""
|
||||||
{
|
|
||||||
"title": "一期产品目标",
|
# 文档内容缓存: node_token -> (content, timestamp)
|
||||||
"keywords": {"一期", "目标", "问答", "登录", "知识库", "飞书", "用户端", "后台"},
|
_content_cache: dict[str, tuple[str, float]] = {}
|
||||||
"content": "一期要交付企业飞书知识库 AI 问答系统,核心包括用户登录、AI 问答、权限内知识库回答和后台管理能力。",
|
_CACHE_TTL = 300 # 缓存 5 分钟
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "权限过滤规则",
|
|
||||||
"keywords": {"权限", "授权", "越权", "用户", "知识库", "过期", "禁用"},
|
|
||||||
"content": "RAG 检索前必须先过滤用户授权知识库,只允许未禁用、未过期、当前用户有权限的知识库参与回答。",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "无命中兜底规则",
|
|
||||||
"keywords": {"无命中", "兜底", "编造", "检索", "答案", "命中"},
|
|
||||||
"content": "当知识库没有命中相关内容时,系统必须固定返回无命中兜底文案。",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "技术实现边界",
|
|
||||||
"keywords": {"模型", "prompt", "大模型", "日志", "sse", "流式", "追溯"},
|
|
||||||
"content": "后端需要组装系统 Prompt、检索片段、历史上下文和当前问题,通过 SSE 流式输出,并记录 AI 请求日志。",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def retrieve(cls, question: str, scopes: list[KnowledgeScope]) -> list["RetrievedChunk"]:
|
def retrieve(cls, question: str, scopes: list[KnowledgeScope]) -> list[RetrievedChunk]:
|
||||||
if not scopes:
|
if not scopes:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
if settings.feishu_mock_enabled:
|
if settings.feishu_mock_enabled:
|
||||||
return cls._retrieve_mock(question, scopes)
|
return cls._retrieve_mock(question, scopes)
|
||||||
return cls._retrieve_remote(question, scopes)
|
return cls._retrieve_from_feishu(question, scopes)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _retrieve_mock(cls, question: str, scopes: list[KnowledgeScope]) -> list["RetrievedChunk"]:
|
def _retrieve_mock(cls, question: str, scopes: list[KnowledgeScope]) -> list[RetrievedChunk]:
|
||||||
from app.services.rag_service import RetrievedChunk
|
from app.services.rag_service import RetrievedChunk
|
||||||
|
|
||||||
|
mock_documents = [
|
||||||
|
{
|
||||||
|
"title": "一期产品目标",
|
||||||
|
"keywords": {"一期", "目标", "问答", "登录", "知识库", "飞书", "用户端", "后台"},
|
||||||
|
"content": "一期要交付企业飞书知识库 AI 问答系统,核心包括用户登录、AI 问答、基于已开放知识库回答和后台管理能力。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "知识库开放过滤规则",
|
||||||
|
"keywords": {"权限", "开放", "用户", "知识库", "禁用", "可见"},
|
||||||
|
"content": "RAG 检索前必须先过滤知识库开放状态,只允许已开放、未禁用的知识库参与回答,已开放知识库默认所有用户可见。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "无命中兜底规则",
|
||||||
|
"keywords": {"无命中", "兜底", "编造", "检索", "答案", "命中"},
|
||||||
|
"content": "当知识库没有命中相关内容时,系统必须固定返回无命中兜底文案。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "技术实现边界",
|
||||||
|
"keywords": {"模型", "prompt", "大模型", "日志", "sse", "流式", "追溯"},
|
||||||
|
"content": "后端需要组装系统 Prompt、检索片段、历史上下文和当前问题,通过 SSE 流式输出,并记录 AI 请求日志。",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
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:
|
||||||
|
node_info = cls._get_node_info_with_cache(node_id)
|
||||||
|
except ExternalServiceError:
|
||||||
|
# 可能是普通 docx token,直接尝试读取
|
||||||
try:
|
try:
|
||||||
response = httpx.post(url, json=payload, timeout=settings.feishu_timeout_seconds)
|
content = cls._get_document_content_with_cache(node_id)
|
||||||
response.raise_for_status()
|
title = node_id
|
||||||
data = response.json()
|
return [(title, content, f"{base_url}/docx/{node_id}")]
|
||||||
if not isinstance(data, dict):
|
except ExternalServiceError:
|
||||||
raise ExternalServiceError("飞书检索响应格式不正确", provider="feishu")
|
return []
|
||||||
return data
|
|
||||||
except (httpx.HTTPError, ValueError, ExternalServiceError) as exc:
|
obj_type = node_info.get("obj_type", "")
|
||||||
last_error = exc
|
obj_token = node_info.get("obj_token", "")
|
||||||
raise ExternalServiceError(f"飞书检索失败:{last_error}", provider="feishu")
|
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]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user