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:
eezd committed 2026-10-14 13:11:45 +08:00
1 parent f042f27013
commit 5db3ead883
34 files changed
+3196 -12

No files matched your search

+166
View File
@@ -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>
+97
View File
@@ -0,0 +1,97 @@
<script lang="ts" setup>
import type { SkillNode, SkillVersion } from "@/apis/skills"
import { listSkillVersions, publishSkillVersion } from "@/apis/skills"
import { diffLines, formatTime } from "@/utils/text"
/** 版本历史:与已发布版本逐行对比,可发布任意版本(发布旧版本即回退) */
const props = defineProps<{ skill: SkillNode | null }>()
const emit = defineEmits<{ published: [] }>()
const visible = defineModel<boolean>({ required: true })
const versions = ref<SkillVersion[]>([])
const selectedId = ref<number | null>(null)
const publishing = ref(false)
const published = computed(() => versions.value.find(v => v.version_no === props.skill?.published_version_no) ?? null)
const selected = computed(() => versions.value.find(v => v.id === selectedId.value) ?? null)
const diff = computed(() => selected.value ? diffLines((published.value?.content ?? "").split("\n"), selected.value.content.split("\n")) : [])
async function load() {
if (!props.skill)
return
versions.value = await listSkillVersions(props.skill.id)
// 默认选中最新版本,直接看到草稿相对已发布版本的改动
selectedId.value = versions.value[0]?.id ?? null
}
watch([visible, () => props.skill?.id, () => props.skill?.latest_version_no], ([open]) => {
if (open)
load()
})
async function publish(version: SkillVersion) {
const current = props.skill?.published_version_no
const action = current !== null && current !== undefined && version.version_no < current ? "回退到" : "发布"
await ElMessageBox.confirm(`${action} v${version.version_no}?发布后 Chat 下一轮起按此版本判断。`, "发布确认", { type: "warning" })
publishing.value = true
try {
await publishSkillVersion(props.skill!.id, version.id)
ElMessage.success(`已${action} v${version.version_no}`)
emit("published")
} finally {
publishing.value = false
}
}
</script>
<template>
<el-drawer v-model="visible" :title="`版本历史:${skill?.path ?? ''}`" size="60%">
<div class="h-full flex gap-3">
<el-scrollbar class="w-60 shrink-0">
<div
v-for="v in versions" :key="v.id"
class="mb-1 cursor-pointer border rounded border-solid px-2 py-1 text-sm"
:class="v.id === selectedId ? 'border-[var(--el-color-primary)] bg-[var(--el-color-primary-light-9)]' : 'border-[var(--el-border-color-lighter)]'"
@click="selectedId = v.id"
>
<div class="flex items-center gap-1">
<b>v{{ v.version_no }}</b>
<el-tag v-if="v.version_no === skill?.published_version_no" size="small" type="success">
已发布
</el-tag>
<el-tag size="small" :type="v.source === 'chat' ? 'warning' : 'info'">
{{ v.source === "chat" ? "Chat" : "我" }}
</el-tag>
</div>
<div class="text-xs text-[var(--el-text-color-secondary)]">
{{ formatTime(v.created_at, "MM-DD HH:mm:ss") }}
</div>
<div v-if="v.note" class="truncate text-xs" :title="v.note">
{{ v.note }}
</div>
</div>
</el-scrollbar>
<div v-if="selected" class="min-w-0 flex flex-1 flex-col gap-2">
<div class="flex items-center gap-2">
<span class="text-sm">v{{ selected.version_no }} 相对已发布版本{{ published ? ` v${published.version_no}` : "(暂无)" }}的改动:</span>
<el-button
v-if="selected.version_no !== skill?.published_version_no" type="primary" size="small" class="ml-auto"
:loading="publishing" @click="publish(selected)"
>
发布此版本
</el-button>
</div>
<el-scrollbar class="min-h-0 flex-1 border rounded border-solid border-[var(--el-border-color-lighter)]">
<div class="p-2 text-xs font-mono">
<div
v-for="(line, i) in diff" :key="i" class="whitespace-pre-wrap"
:class="{ 'bg-green-100': line.type === 'add', 'bg-red-100 line-through': line.type === 'del' }"
>
{{ line.type === "add" ? "+ " : line.type === "del" ? "- " : " " }}{{ line.text }}
</div>
</div>
</el-scrollbar>
</div>
</div>
</el-drawer>
</template>