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

+172
View File
@@ -0,0 +1,172 @@
<script lang="ts" setup>
import type { SkillNode, SkillVersion } from "@/apis/skills"
import { listSkillTree, listSkillVersions, patchSkillNode, publishSkillVersion, saveSkillDraft } from "@/apis/skills"
import SkillTree from "@/components/SkillTree.vue"
import SkillVersionDrawer from "@/components/SkillVersionDrawer.vue"
import { renderMarkdown } from "@/utils/markdown"
const nodes = ref<SkillNode[]>([])
const selectedId = ref<number | null>(null)
const selected = computed(() => nodes.value.find(n => n.id === selectedId.value) ?? null)
// 编辑区:以最新版本为底稿
const latest = ref<SkillVersion | null>(null)
const content = ref("")
const dirty = computed(() => selected.value?.kind === "skill" && content.value !== (latest.value?.content ?? ""))
const saving = ref(false)
const historyVisible = ref(false)
const preview = computed(() => renderMarkdown(content.value))
async function loadTree() {
nodes.value = await listSkillTree()
if (selectedId.value !== null && !selected.value)
selectedId.value = null
}
async function loadContent() {
if (selected.value?.kind !== "skill") {
latest.value = null
content.value = ""
return
}
latest.value = (await listSkillVersions(selected.value.id))[0] ?? null
content.value = latest.value?.content ?? ""
}
async function select(node: SkillNode) {
if (node.id === selectedId.value)
return
if (dirty.value)
await ElMessageBox.confirm("当前 skill 有未保存的修改,切换后会丢失。继续?", "未保存", { type: "warning" })
selectedId.value = node.id
await loadContent()
}
async function saveDraft(): Promise<SkillVersion | null> {
if (!selected.value || !dirty.value)
return latest.value
saving.value = true
try {
latest.value = await saveSkillDraft(selected.value.id, content.value)
ElMessage.success(`已保存草稿 v${latest.value.version_no}`)
await loadTree()
return latest.value
} finally {
saving.value = false
}
}
async function publishLatest() {
const version = await saveDraft()
if (!version || !selected.value)
return
await ElMessageBox.confirm(`发布 v${version.version_no}?发布后 Chat 下一轮起按此版本判断。`, "发布确认", { type: "warning" })
await publishSkillVersion(selected.value.id, version.id)
ElMessage.success(`已发布 v${version.version_no}`)
await loadTree()
}
async function updateMeta(body: { priority?: number, enabled?: boolean }) {
if (!selected.value)
return
await patchSkillNode(selected.value.id, body)
await loadTree()
}
async function onPublished() {
historyVisible.value = false
await loadTree()
}
// 离开页面前提醒未保存的修改
function beforeUnload(e: BeforeUnloadEvent) {
if (dirty.value)
e.preventDefault()
}
onMounted(() => window.addEventListener("beforeunload", beforeUnload))
onBeforeUnmount(() => window.removeEventListener("beforeunload", beforeUnload))
onBeforeRouteLeave(async () => {
if (dirty.value)
await ElMessageBox.confirm("当前 skill 有未保存的修改,离开后会丢失。继续?", "未保存", { type: "warning" })
})
loadTree()
</script>
<template>
<div class="h-full flex gap-3">
<el-card class="w-80 shrink-0" body-class="h-full box-border" shadow="never">
<SkillTree :nodes="nodes" :selected-id="selectedId" editable @select="select" @changed="loadTree" />
</el-card>
<el-card class="min-w-0 flex flex-1 flex-col" body-class="min-h-0 flex-1 flex flex-col gap-3" shadow="never">
<el-empty v-if="!selected" description="在左侧选择或新建一个 skill" />
<template v-else-if="selected.kind === 'folder'">
<div class="text-lg font-bold">
📁 {{ selected.path }}
</div>
<div class="flex items-center gap-2 text-sm">
启用
<el-switch :model-value="selected.enabled" @change="v => updateMeta({ enabled: Boolean(v) })" />
<span class="text-xs text-[var(--el-text-color-secondary)]">停用文件夹后,其中所有 skill 都不会进入 Chat 的判断</span>
</div>
</template>
<template v-else>
<div class="flex flex-wrap items-center gap-3">
<span class="text-lg font-bold">📄 {{ selected.path }}</span>
<el-tag v-if="selected.published_version_no === null" type="info">
未发布
</el-tag>
<el-tag v-else type="success">
已发布 v{{ selected.published_version_no }}
</el-tag>
<el-tag v-if="selected.has_draft" type="warning">
最新 v{{ selected.latest_version_no }} 未发布
</el-tag>
<el-tag v-if="dirty" type="danger">
有未保存的修改
</el-tag>
<div class="ml-auto flex items-center gap-2 text-sm">
优先级
<el-input-number
:model-value="selected.priority" :min="-100" :max="100" size="small" controls-position="right" class="w-24"
@change="v => v !== undefined && v !== null && updateMeta({ priority: v })"
/>
启用
<el-switch :model-value="selected.enabled" @change="v => updateMeta({ enabled: Boolean(v) })" />
</div>
</div>
<div class="text-xs text-[var(--el-text-color-secondary)]">
优先级越高越先读取,冲突时以优先级高的为准。只有「已发布」的版本会进入 Chat 的判断;保存只生成草稿。
</div>
<div class="min-h-0 flex flex-1 gap-3">
<textarea
v-model="content" placeholder="用 Markdown 写下投资经验、判断流程或输出规范"
class="box-border flex-1 resize-none border rounded border-solid border-[var(--el-border-color)] p-3 text-sm leading-6 font-mono outline-none focus:border-[var(--el-color-primary)]"
/>
<el-scrollbar class="flex-1 border rounded border-solid border-[var(--el-border-color-lighter)]">
<!-- markdown-it 已关闭 html,内容中的标签按文本显示 -->
<div class="markdown-body p-3" v-html="preview" />
</el-scrollbar>
</div>
<div class="flex gap-2">
<el-button :disabled="!dirty" :loading="saving" @click="saveDraft">
保存草稿
</el-button>
<el-button type="primary" :disabled="!dirty && !selected.has_draft" :loading="saving" @click="publishLatest">
{{ dirty ? "保存并发布" : "发布最新版本" }}
</el-button>
<el-button @click="historyVisible = true">
版本历史 / 回退
</el-button>
</div>
</template>
</el-card>
<SkillVersionDrawer v-model="historyVisible" :skill="selected" @published="onPublished" />
</div>
</template>