Add AI chat with skills management and model provider settings
- Model providers for OpenAI and Anthropic message formats (e.g. via Sub2API), with API keys encrypted at rest and never returned - Skills tree with markdown editing, priorities, drafts, publishing and rollback; only published skills enter the chat prompt - Chat page with tool calls over collected news and Binance USD-M klines, a persisted CONTEXT.md, reset, and stop-generation support - Chat may only create skill drafts and update CONTEXT.md; publishing, deleting, moving and priority changes stay with the user
This commit is contained in:
1 parent
f042f27013
commit
5db3ead883
34 files changed
+3196
-12
No files matched your search
@@ -0,0 +1,166 @@
|
||||
<script lang="ts" setup>
|
||||
import type Node from "element-plus/es/components/tree/src/model/node"
|
||||
import type { AllowDropType, NodeDropType } from "element-plus/es/components/tree/src/tree.type"
|
||||
import type { NodeKind, SkillNode } from "@/apis/skills"
|
||||
import { createSkillNode, deleteSkillNode, moveSkillNode, patchSkillNode } from "@/apis/skills"
|
||||
|
||||
/** skills 树:Skills 页可编辑(拖拽、增删改),Chat 侧边栏只读 */
|
||||
const props = defineProps<{
|
||||
nodes: SkillNode[]
|
||||
editable?: boolean
|
||||
selectedId?: number | null
|
||||
/** 需要高亮的节点(如 Chat 刚生成草稿的 skill) */
|
||||
highlightIds?: number[]
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
select: [node: SkillNode]
|
||||
changed: []
|
||||
}>()
|
||||
|
||||
interface TreeItem {
|
||||
id: number
|
||||
label: string
|
||||
data: SkillNode
|
||||
children: TreeItem[]
|
||||
}
|
||||
|
||||
const treeData = computed(() => {
|
||||
const items = new Map<number, TreeItem>(props.nodes.map(n => [n.id, { id: n.id, label: n.name, data: n, children: [] }]))
|
||||
const roots: TreeItem[] = []
|
||||
// 后端已按 parent_id、sort_order 排好序
|
||||
for (const n of props.nodes) {
|
||||
const item = items.get(n.id)!
|
||||
if (n.parent_id === null)
|
||||
roots.push(item)
|
||||
else
|
||||
items.get(n.parent_id)?.children.push(item)
|
||||
}
|
||||
return roots
|
||||
})
|
||||
|
||||
// 新出现的文件夹默认展开,保留用户手动折叠的状态
|
||||
const expanded = ref<number[]>([])
|
||||
const seenFolders = new Set<number>()
|
||||
watch(() => props.nodes, (nodes) => {
|
||||
for (const n of nodes) {
|
||||
if (n.kind === "folder" && !seenFolders.has(n.id)) {
|
||||
seenFolders.add(n.id)
|
||||
expanded.value.push(n.id)
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
function allowDrop(_dragging: Node, drop: Node, type: AllowDropType) {
|
||||
return type !== "inner" || (drop.data as TreeItem).data.kind === "folder"
|
||||
}
|
||||
|
||||
async function onDrop(dragging: Node, drop: Node, type: NodeDropType) {
|
||||
const dragged = (dragging.data as TreeItem).data
|
||||
const target = (drop.data as TreeItem).data
|
||||
const parentId = type === "inner" ? target.id : target.parent_id
|
||||
const siblings = props.nodes.filter(n => n.parent_id === parentId && n.id !== dragged.id)
|
||||
const index = type === "inner" ? siblings.length : siblings.findIndex(n => n.id === target.id) + (type === "after" ? 1 : 0)
|
||||
try {
|
||||
await moveSkillNode(dragged.id, parentId, index)
|
||||
} finally {
|
||||
// 失败时也刷新,让树回到服务端的真实顺序
|
||||
emit("changed")
|
||||
}
|
||||
}
|
||||
|
||||
const NAME_RULE = (v: string) => (!!v?.trim() && !v.includes("/")) || "名称不能为空,也不能包含 /"
|
||||
|
||||
async function create(parentId: number | null, kind: NodeKind) {
|
||||
const { value } = await ElMessageBox.prompt(kind === "folder" ? "文件夹名称" : "skill 名称", kind === "folder" ? "新建文件夹" : "新建 skill", {
|
||||
inputValidator: NAME_RULE
|
||||
})
|
||||
const node = await createSkillNode({ parent_id: parentId, kind, name: value.trim() })
|
||||
emit("changed")
|
||||
if (kind === "skill")
|
||||
emit("select", node)
|
||||
}
|
||||
|
||||
async function rename(node: SkillNode) {
|
||||
const { value } = await ElMessageBox.prompt("新名称", "重命名", { inputValue: node.name, inputValidator: NAME_RULE })
|
||||
await patchSkillNode(node.id, { name: value.trim() })
|
||||
emit("changed")
|
||||
}
|
||||
|
||||
async function remove(node: SkillNode) {
|
||||
const message = node.kind === "folder" ? `删除文件夹「${node.path}」?(必须为空)` : `删除 skill「${node.path}」及其全部版本?此操作不可恢复。`
|
||||
await ElMessageBox.confirm(message, "删除确认", { type: "warning" })
|
||||
await deleteSkillNode(node.id)
|
||||
ElMessage.success("已删除")
|
||||
emit("changed")
|
||||
}
|
||||
|
||||
function onExpand(item: TreeItem) {
|
||||
if (!expanded.value.includes(item.id))
|
||||
expanded.value.push(item.id)
|
||||
}
|
||||
|
||||
function onCollapse(item: TreeItem) {
|
||||
expanded.value = expanded.value.filter(id => id !== item.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col">
|
||||
<div v-if="editable" class="mb-2 flex gap-2">
|
||||
<el-button size="small" @click="create(null, 'folder')">
|
||||
新建文件夹
|
||||
</el-button>
|
||||
<el-button size="small" @click="create(null, 'skill')">
|
||||
新建 skill
|
||||
</el-button>
|
||||
</div>
|
||||
<el-scrollbar class="min-h-0 flex-1">
|
||||
<el-empty v-if="nodes.length === 0" description="还没有 skill" :image-size="60" />
|
||||
<el-tree
|
||||
v-else
|
||||
:data="treeData" node-key="id" :props="{ label: 'label', children: 'children' }"
|
||||
:default-expanded-keys="expanded" :current-node-key="selectedId ?? undefined" highlight-current
|
||||
:draggable="editable" :allow-drop="allowDrop" :expand-on-click-node="false"
|
||||
@node-drop="onDrop" @node-click="(item: TreeItem) => emit('select', item.data)"
|
||||
@node-expand="onExpand" @node-collapse="onCollapse"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<div
|
||||
class="group w-full flex items-center gap-1 overflow-hidden rounded pr-1 text-sm"
|
||||
:class="{ 'opacity-50': !data.data.enabled, 'bg-yellow-100': highlightIds?.includes(data.id) }"
|
||||
>
|
||||
<span>{{ data.data.kind === "folder" ? "📁" : "📄" }}</span>
|
||||
<span class="truncate">{{ data.data.name }}</span>
|
||||
<template v-if="data.data.kind === 'skill'">
|
||||
<el-tag size="small" effect="plain" class="shrink-0">
|
||||
P{{ data.data.priority }}
|
||||
</el-tag>
|
||||
<el-tag v-if="data.data.published_version_no === null" size="small" type="info" class="shrink-0">
|
||||
未发布
|
||||
</el-tag>
|
||||
<el-tag v-else-if="data.data.has_draft" size="small" type="warning" class="shrink-0">
|
||||
草稿
|
||||
</el-tag>
|
||||
</template>
|
||||
<div v-if="editable" class="ml-auto hidden shrink-0 gap-1 group-hover:flex" @click.stop>
|
||||
<template v-if="data.data.kind === 'folder'">
|
||||
<el-button link size="small" @click="create(data.id, 'skill')">
|
||||
+skill
|
||||
</el-button>
|
||||
<el-button link size="small" @click="create(data.id, 'folder')">
|
||||
+目录
|
||||
</el-button>
|
||||
</template>
|
||||
<el-button link size="small" @click="rename(data.data)">
|
||||
改名
|
||||
</el-button>
|
||||
<el-button link size="small" type="danger" @click="remove(data.data)">
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-tree>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</template>
|
||||
Reference in new issue
Block a user