feat: add knowledge management functionality with CRUD operations and database schema

- Implemented KnowledgeHandler for managing knowledge groups and items, including listing, creating, updating, and deleting operations.
- Added database migration scripts to create necessary tables for knowledge management, including knowledge_groups, knowledge_items, knowledge_parse_tasks, and knowledge_chunks_meta.
- Introduced prompt_rule_knowledge_groups table to associate prompt rules with knowledge groups.
This commit is contained in:
2026-04-05 17:14:13 +08:00
parent 134dd063c3
commit 446f865cdf
62 changed files with 9503 additions and 2664 deletions
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { useQuery } from "@tanstack/vue-query";
import type { KnowledgeGroup } from "@geo/shared-types";
import { computed } from "vue";
import { knowledgeApi } from "@/lib/api";
interface TreeNode {
title: string;
value: number;
key: number;
children?: TreeNode[];
}
const props = withDefaults(
defineProps<{
modelValue?: number[];
placeholder?: string;
disabled?: boolean;
}>(),
{
modelValue: () => [],
placeholder: "请选择知识库分组",
disabled: false,
},
);
const emit = defineEmits<{
"update:modelValue": [value: number[]];
}>();
const groupsQuery = useQuery({
queryKey: ["knowledge", "groups", "selector"],
queryFn: () => knowledgeApi.listGroups(),
});
const treeData = computed(() => (groupsQuery.data.value ?? []).map((item) => toTreeNode(item)));
function toTreeNode(group: KnowledgeGroup): TreeNode {
return {
title: `${group.name}${group.item_count > 0 ? ` (${group.item_count})` : ""}`,
value: group.id,
key: group.id,
children: (group.children ?? []).map((item) => toTreeNode(item)),
};
}
function handleChange(values: Array<string | number>): void {
emit(
"update:modelValue",
values
.map((item) => Number(item))
.filter((item) => Number.isFinite(item) && item > 0),
);
}
</script>
<template>
<a-tree-select
:value="modelValue"
:tree-data="treeData"
tree-checkable
tree-default-expand-all
multiple
show-search
allow-clear
size="large"
style="width: 100%"
:placeholder="placeholder"
:disabled="disabled"
:loading="groupsQuery.isPending.value"
:max-tag-count="3"
@update:value="handleChange"
/>
</template>
@@ -6,6 +6,7 @@ import type { PromptRule, PromptRuleGroup } from "@geo/shared-types";
import { computed, reactive, watch } from "vue";
import { useI18n } from "vue-i18n";
import KnowledgeGroupSelect from "@/components/KnowledgeGroupSelect.vue";
import { promptRulesApi } from "@/lib/api";
import { formatError } from "@/lib/errors";
@@ -29,6 +30,7 @@ const form = reactive({
default_tone: "",
default_word_count: undefined as number | undefined,
group_id: undefined as number | undefined,
knowledge_group_ids: [] as number[],
});
const groupsQuery = useQuery({
@@ -50,6 +52,7 @@ watch(
form.default_tone = rule?.default_tone ?? "";
form.default_word_count = rule?.default_word_count ?? undefined;
form.group_id = rule?.group_id ?? undefined;
form.knowledge_group_ids = rule?.knowledge_group_ids ?? [];
},
);
@@ -62,6 +65,7 @@ const createMutation = useMutation({
scene: form.scene.trim() || undefined,
default_tone: form.default_tone.trim() || undefined,
default_word_count: form.default_word_count,
knowledge_group_ids: form.knowledge_group_ids,
}),
onSuccess: async (data) => {
message.success(t("custom.messages.ruleCreated"));
@@ -81,6 +85,7 @@ const updateMutation = useMutation({
scene: form.scene.trim() || undefined,
default_tone: form.default_tone.trim() || undefined,
default_word_count: form.default_word_count,
knowledge_group_ids: form.knowledge_group_ids,
}),
onSuccess: async () => {
message.success(t("custom.messages.ruleUpdated"));
@@ -197,6 +202,14 @@ async function handleSubmit(): Promise<void> {
/>
</div>
</div>
<div class="prompt-rule-drawer__field">
<label class="prompt-rule-drawer__label">知识库引用</label>
<KnowledgeGroupSelect
v-model="form.knowledge_group_ids"
placeholder="可选,用于补充生成内容的背景信息"
/>
</div>
</div>
<template #footer>
@@ -0,0 +1,209 @@
<template>
<div class="characters-container">
<!-- Purple -->
<div ref="purpleRef" class="char purple" :style="purpleStyle">
<div class="eyes" :style="purpleEyesStyle">
<EyeBall v-for="i in 2" :key="'p'+i" :size="18" :pupil-size="7" :max-distance="5"
eye-color="white" pupil-color="#2D2D2D" :is-blinking="isPurpleBlinking"
:force-look-x="purpleLookX" :force-look-y="purpleLookY" />
</div>
</div>
<!-- Black -->
<div ref="blackRef" class="char black" :style="blackStyle">
<div class="eyes" :style="blackEyesStyle">
<EyeBall v-for="i in 2" :key="'b'+i" :size="16" :pupil-size="6" :max-distance="4"
eye-color="white" pupil-color="#2D2D2D" :is-blinking="isBlackBlinking"
:force-look-x="blackLookX" :force-look-y="blackLookY" />
</div>
</div>
<!-- Orange -->
<div ref="orangeRef" class="char orange" :style="orangeStyle">
<div class="eyes" :style="orangeEyesStyle">
<Pupil v-for="i in 2" :key="'o'+i" :size="12" :max-distance="5" pupil-color="#2D2D2D"
:force-look-x="hiding ? -5 : undefined" :force-look-y="hiding ? -4 : undefined" />
</div>
</div>
<!-- Yellow -->
<div ref="yellowRef" class="char yellow" :style="yellowStyle">
<div class="eyes" :style="yellowEyesStyle">
<Pupil v-for="i in 2" :key="'y'+i" :size="12" :max-distance="5" pupil-color="#2D2D2D"
:force-look-x="hiding ? -5 : undefined" :force-look-y="hiding ? -4 : undefined" />
</div>
<div class="mouth" :style="yellowMouthStyle" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted, onUnmounted } from 'vue'
import EyeBall from './EyeBall.vue'
import Pupil from './Pupil.vue'
const props = withDefaults(defineProps<{
/** Whether the user is currently typing in an input */
isTyping?: boolean
/** Whether a secret field has content */
hasSecret?: boolean
/** Whether the secret is currently visible (e.g. password shown) */
secretVisible?: boolean
}>(), {
isTyping: false,
hasSecret: false,
secretVisible: false,
})
const mouseX = ref(0)
const mouseY = ref(0)
const isPurpleBlinking = ref(false)
const isBlackBlinking = ref(false)
const isLookingAtEachOther = ref(false)
const isPurplePeeking = ref(false)
const purpleRef = ref<HTMLDivElement | null>(null)
const blackRef = ref<HTMLDivElement | null>(null)
const yellowRef = ref<HTMLDivElement | null>(null)
const orangeRef = ref<HTMLDivElement | null>(null)
const purplePos = reactive({ faceX: 0, faceY: 0, bodySkew: 0 })
const blackPos = reactive({ faceX: 0, faceY: 0, bodySkew: 0 })
const yellowPos = reactive({ faceX: 0, faceY: 0, bodySkew: 0 })
const orangePos = reactive({ faceX: 0, faceY: 0, bodySkew: 0 })
// Derived state
const hiding = computed(() => props.hasSecret && props.secretVisible)
const leaning = computed(() => props.isTyping || (props.hasSecret && !props.secretVisible))
function calcPos(el: HTMLDivElement | null, target: { faceX: number; faceY: number; bodySkew: number }) {
if (!el) return
const r = el.getBoundingClientRect()
const dx = mouseX.value - (r.left + r.width / 2)
const dy = mouseY.value - (r.top + r.height / 3)
target.faceX = Math.max(-15, Math.min(15, dx / 20))
target.faceY = Math.max(-10, Math.min(10, dy / 30))
target.bodySkew = Math.max(-6, Math.min(6, -dx / 120))
}
let rafId = 0
function tick() {
calcPos(purpleRef.value, purplePos)
calcPos(blackRef.value, blackPos)
calcPos(yellowRef.value, yellowPos)
calcPos(orangeRef.value, orangePos)
rafId = requestAnimationFrame(tick)
}
function onMouseMove(e: MouseEvent) { mouseX.value = e.clientX; mouseY.value = e.clientY }
function setupBlink(target: { value: boolean }) {
let t: number
const go = () => {
t = window.setTimeout(() => {
target.value = true
window.setTimeout(() => { target.value = false; go() }, 150)
}, Math.random() * 4000 + 3000)
}
go()
return () => clearTimeout(t)
}
let stopP: (() => void) | undefined
let stopB: (() => void) | undefined
onMounted(() => {
window.addEventListener('mousemove', onMouseMove)
stopP = setupBlink(isPurpleBlinking)
stopB = setupBlink(isBlackBlinking)
rafId = requestAnimationFrame(tick)
})
onUnmounted(() => {
window.removeEventListener('mousemove', onMouseMove)
cancelAnimationFrame(rafId)
stopP?.(); stopB?.()
})
// Look at each other when typing starts
watch(() => props.isTyping, (v) => {
if (v) {
isLookingAtEachOther.value = true
setTimeout(() => { isLookingAtEachOther.value = false }, 800)
} else {
isLookingAtEachOther.value = false
}
})
// Purple peeks when secret is visible
let peekT: number | undefined
watch([() => props.hasSecret, () => props.secretVisible, isPurplePeeking], () => {
clearTimeout(peekT)
if (props.hasSecret && props.secretVisible) {
peekT = window.setTimeout(() => {
isPurplePeeking.value = true
setTimeout(() => { isPurplePeeking.value = false }, 800)
}, Math.random() * 3000 + 2000)
} else {
isPurplePeeking.value = false
}
})
// Computed styles
const purpleStyle = computed(() => ({
height: leaning.value ? '440px' : '400px',
transform: hiding.value ? 'skewX(0deg)'
: leaning.value ? `skewX(${purplePos.bodySkew - 12}deg) translateX(40px)`
: `skewX(${purplePos.bodySkew}deg)`,
}))
const purpleEyesStyle = computed(() => ({
left: hiding.value ? '20px' : isLookingAtEachOther.value ? '55px' : `${45 + purplePos.faceX}px`,
top: hiding.value ? '35px' : isLookingAtEachOther.value ? '65px' : `${40 + purplePos.faceY}px`,
gap: '32px',
}))
const purpleLookX = computed(() => hiding.value ? (isPurplePeeking.value ? 4 : -4) : isLookingAtEachOther.value ? 3 : undefined)
const purpleLookY = computed(() => hiding.value ? (isPurplePeeking.value ? 5 : -4) : isLookingAtEachOther.value ? 4 : undefined)
const blackStyle = computed(() => ({
transform: hiding.value ? 'skewX(0deg)'
: isLookingAtEachOther.value ? `skewX(${blackPos.bodySkew * 1.5 + 10}deg) translateX(20px)`
: leaning.value ? `skewX(${blackPos.bodySkew * 1.5}deg)`
: `skewX(${blackPos.bodySkew}deg)`,
}))
const blackEyesStyle = computed(() => ({
left: hiding.value ? '10px' : isLookingAtEachOther.value ? '32px' : `${26 + blackPos.faceX}px`,
top: hiding.value ? '28px' : isLookingAtEachOther.value ? '12px' : `${32 + blackPos.faceY}px`,
gap: '24px',
}))
const blackLookX = computed(() => hiding.value ? -4 : isLookingAtEachOther.value ? 0 : undefined)
const blackLookY = computed(() => hiding.value ? -4 : isLookingAtEachOther.value ? -4 : undefined)
const orangeStyle = computed(() => ({
transform: hiding.value ? 'skewX(0deg)' : `skewX(${orangePos.bodySkew}deg)`,
}))
const orangeEyesStyle = computed(() => ({
left: hiding.value ? '50px' : `${82 + orangePos.faceX}px`,
top: hiding.value ? '85px' : `${90 + orangePos.faceY}px`,
gap: '32px',
}))
const yellowStyle = computed(() => ({
transform: hiding.value ? 'skewX(0deg)' : `skewX(${yellowPos.bodySkew}deg)`,
}))
const yellowEyesStyle = computed(() => ({
left: hiding.value ? '20px' : `${52 + yellowPos.faceX}px`,
top: hiding.value ? '35px' : `${40 + yellowPos.faceY}px`,
gap: '24px',
}))
const yellowMouthStyle = computed(() => ({
left: hiding.value ? '10px' : `${40 + yellowPos.faceX}px`,
top: hiding.value ? '88px' : `${88 + yellowPos.faceY}px`,
}))
</script>
<style scoped>
.characters-container { position: relative; width: 550px; height: 400px; }
.char { position: absolute; bottom: 0; transition: all 0.7s ease-in-out; transform-origin: bottom center; }
.purple { left: 70px; width: 180px; background: #6C3FF5; border-radius: 10px 10px 0 0; z-index: 1; }
.black { left: 240px; width: 120px; height: 310px; background: #2D2D2D; border-radius: 8px 8px 0 0; z-index: 2; }
.orange { left: 0; width: 240px; height: 200px; background: #FF9B6B; border-radius: 120px 120px 0 0; z-index: 3; }
.yellow { left: 310px; width: 140px; height: 230px; background: #E8D754; border-radius: 70px 70px 0 0; z-index: 4; }
.eyes { position: absolute; display: flex; transition: all 0.7s ease-in-out; }
.mouth { position: absolute; width: 80px; height: 4px; background: #2D2D2D; border-radius: 4px; transition: all 0.2s ease-out; }
</style>
@@ -0,0 +1,55 @@
<template>
<div ref="eyeRef" class="eyeball" :style="{
width: size + 'px', height: isBlinking ? '2px' : size + 'px',
backgroundColor: eyeColor,
}">
<div v-if="!isBlinking" class="pupil-inner" :style="{
width: pupilSize + 'px', height: pupilSize + 'px',
backgroundColor: pupilColor,
transform: `translate(${pos.x}px, ${pos.y}px)`,
}" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
const props = withDefaults(defineProps<{
size?: number; pupilSize?: number; maxDistance?: number
eyeColor?: string; pupilColor?: string; isBlinking?: boolean
forceLookX?: number; forceLookY?: number
}>(), { size: 48, pupilSize: 16, maxDistance: 10, eyeColor: 'white', pupilColor: 'black', isBlinking: false })
const mx = ref(0), my = ref(0)
const eyeRef = ref<HTMLDivElement>()
const onMove = (e: MouseEvent) => { mx.value = e.clientX; my.value = e.clientY }
onMounted(() => window.addEventListener('mousemove', onMove))
onUnmounted(() => window.removeEventListener('mousemove', onMove))
const pos = computed(() => {
if (!eyeRef.value) return { x: 0, y: 0 }
if (props.forceLookX !== undefined && props.forceLookY !== undefined)
return { x: props.forceLookX, y: props.forceLookY }
const r = eyeRef.value.getBoundingClientRect()
const dx = mx.value - (r.left + r.width / 2)
const dy = my.value - (r.top + r.height / 2)
const d = Math.min(Math.sqrt(dx ** 2 + dy ** 2), props.maxDistance)
const a = Math.atan2(dy, dx)
return { x: Math.cos(a) * d, y: Math.sin(a) * d }
})
</script>
<style scoped>
.eyeball {
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
overflow: hidden;
}
.pupil-inner {
border-radius: 50%;
transition: transform 0.1s ease-out;
}
</style>
@@ -0,0 +1,38 @@
<template>
<div ref="pupilRef" class="pupil" :style="{
width: size + 'px', height: size + 'px',
backgroundColor: pupilColor,
transform: `translate(${pos.x}px, ${pos.y}px)`,
}" />
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
const props = withDefaults(defineProps<{
size?: number; maxDistance?: number; pupilColor?: string
forceLookX?: number; forceLookY?: number
}>(), { size: 12, maxDistance: 5, pupilColor: 'black' })
const mx = ref(0), my = ref(0)
const pupilRef = ref<HTMLDivElement>()
const onMove = (e: MouseEvent) => { mx.value = e.clientX; my.value = e.clientY }
onMounted(() => window.addEventListener('mousemove', onMove))
onUnmounted(() => window.removeEventListener('mousemove', onMove))
const pos = computed(() => {
if (!pupilRef.value) return { x: 0, y: 0 }
if (props.forceLookX !== undefined && props.forceLookY !== undefined)
return { x: props.forceLookX, y: props.forceLookY }
const r = pupilRef.value.getBoundingClientRect()
const dx = mx.value - (r.left + r.width / 2)
const dy = my.value - (r.top + r.height / 2)
const d = Math.min(Math.sqrt(dx ** 2 + dy ** 2), props.maxDistance)
const a = Math.atan2(dy, dx)
return { x: Math.cos(a) * d, y: Math.sin(a) * d }
})
</script>
<style scoped>
.pupil { border-radius: 50%; transition: transform 0.1s ease-out; }
</style>