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,52 @@
|
||||
import { http } from "@/http"
|
||||
|
||||
export interface ToolCall {
|
||||
id: string
|
||||
name: string
|
||||
arguments: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: number
|
||||
role: "user" | "assistant" | "tool"
|
||||
/** user: {text};assistant: {text, tool_calls, stopped?, error?};tool: {tool_call_id, name, result} */
|
||||
content: Record<string, any>
|
||||
provider_id: number | null
|
||||
usage: { input_tokens?: number, output_tokens?: number } | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ChatSession {
|
||||
id: number
|
||||
created_at: string
|
||||
messages: ChatMessage[]
|
||||
estimated_tokens: number
|
||||
generating: boolean
|
||||
}
|
||||
|
||||
export interface ChatContext {
|
||||
id: number | null
|
||||
content: string
|
||||
source: "user" | "chat" | "reset" | null
|
||||
created_at: string | null
|
||||
}
|
||||
|
||||
export function getChatSession(): Promise<ChatSession> {
|
||||
return http.get("/chat/session")
|
||||
}
|
||||
|
||||
export function resetChat(clearContext: boolean): Promise<void> {
|
||||
return http.post("/chat/reset", { clear_context: clearContext })
|
||||
}
|
||||
|
||||
export function getChatContext(): Promise<ChatContext> {
|
||||
return http.get("/chat/context")
|
||||
}
|
||||
|
||||
export function saveChatContext(content: string): Promise<ChatContext> {
|
||||
return http.put("/chat/context", { content })
|
||||
}
|
||||
|
||||
export function listChatContextVersions(): Promise<ChatContext[]> {
|
||||
return http.get("/chat/context/versions")
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { http } from "@/http"
|
||||
|
||||
export type Protocol = "openai" | "anthropic"
|
||||
|
||||
export interface Provider {
|
||||
id: number
|
||||
name: string
|
||||
protocol: Protocol
|
||||
base_url: string
|
||||
api_key_hint: string
|
||||
model: string
|
||||
max_tokens: number
|
||||
context_window: number
|
||||
temperature: number | null
|
||||
is_default: boolean
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ProviderForm {
|
||||
name: string
|
||||
protocol: Protocol
|
||||
base_url: string
|
||||
/** 编辑时为空表示不修改 */
|
||||
api_key: string
|
||||
model: string
|
||||
max_tokens: number
|
||||
context_window: number
|
||||
temperature: number | null
|
||||
is_default: boolean
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
ok: boolean
|
||||
elapsed_ms: number
|
||||
reply: string | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export function listProviders(): Promise<Provider[]> {
|
||||
return http.get("/settings/providers")
|
||||
}
|
||||
|
||||
export function createProvider(body: ProviderForm): Promise<Provider> {
|
||||
return http.post("/settings/providers", body)
|
||||
}
|
||||
|
||||
export function patchProvider(id: number, body: Partial<ProviderForm>): Promise<Provider> {
|
||||
return http.patch(`/settings/providers/${id}`, body)
|
||||
}
|
||||
|
||||
export function deleteProvider(id: number): Promise<void> {
|
||||
return http.delete(`/settings/providers/${id}`)
|
||||
}
|
||||
|
||||
export function testProvider(id: number): Promise<TestResult> {
|
||||
// 模型接口超时 120s 且 SDK 会重试,前端放宽等待时间
|
||||
return http.post(`/settings/providers/${id}/test`, null, { timeout: 400000 })
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { http } from "@/http"
|
||||
|
||||
export type NodeKind = "folder" | "skill"
|
||||
|
||||
export interface SkillNode {
|
||||
id: number
|
||||
parent_id: number | null
|
||||
kind: NodeKind
|
||||
name: string
|
||||
path: string
|
||||
sort_order: number
|
||||
priority: number
|
||||
enabled: boolean
|
||||
published_version_no: number | null
|
||||
latest_version_no: number | null
|
||||
has_draft: boolean
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface SkillVersion {
|
||||
id: number
|
||||
version_no: number
|
||||
content: string
|
||||
source: "user" | "chat"
|
||||
note: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export function listSkillTree(): Promise<SkillNode[]> {
|
||||
return http.get("/skills/tree")
|
||||
}
|
||||
|
||||
export function createSkillNode(body: { parent_id: number | null, kind: NodeKind, name: string, priority?: number, content?: string }): Promise<SkillNode> {
|
||||
return http.post("/skills/nodes", body)
|
||||
}
|
||||
|
||||
export function patchSkillNode(id: number, body: { name?: string, priority?: number, enabled?: boolean }): Promise<SkillNode> {
|
||||
return http.patch(`/skills/nodes/${id}`, body)
|
||||
}
|
||||
|
||||
export function moveSkillNode(id: number, parentId: number | null, index: number): Promise<void> {
|
||||
return http.post(`/skills/nodes/${id}/move`, { parent_id: parentId, index })
|
||||
}
|
||||
|
||||
export function deleteSkillNode(id: number): Promise<void> {
|
||||
return http.delete(`/skills/nodes/${id}`)
|
||||
}
|
||||
|
||||
export function listSkillVersions(id: number): Promise<SkillVersion[]> {
|
||||
return http.get(`/skills/${id}/versions`)
|
||||
}
|
||||
|
||||
export function saveSkillDraft(id: number, content: string, note = ""): Promise<SkillVersion> {
|
||||
return http.post(`/skills/${id}/versions`, { content, note })
|
||||
}
|
||||
|
||||
export function publishSkillVersion(id: number, versionId: number): Promise<SkillNode> {
|
||||
return http.post(`/skills/${id}/publish`, { version_id: versionId })
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,66 @@
|
||||
import { router } from "@/router"
|
||||
|
||||
/** POST 方式的 SSE(EventSource 只支持 GET):逐个事件回调;stop() 断开连接,服务端随之停止生成 */
|
||||
export function useChatStream() {
|
||||
const streaming = ref(false)
|
||||
let controller: AbortController | null = null
|
||||
|
||||
async function send(body: unknown, onEvent: (name: string, data: any) => void) {
|
||||
controller = new AbortController()
|
||||
streaming.value = true
|
||||
try {
|
||||
const response = await fetch("/api/chat/messages", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal
|
||||
})
|
||||
if (response.status === 401) {
|
||||
router.replace({ name: "login", query: { redirect: router.currentRoute.value.fullPath } })
|
||||
return
|
||||
}
|
||||
if (!response.ok || !response.body) {
|
||||
const detail = await response.json().catch(() => null)
|
||||
onEvent("error", { message: detail?.detail ?? `请求失败:HTTP ${response.status}` })
|
||||
return
|
||||
}
|
||||
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader()
|
||||
let buffer = ""
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done)
|
||||
break
|
||||
buffer += value
|
||||
// 事件之间以空行分隔;保留最后一段不完整的
|
||||
const blocks = buffer.split("\n\n")
|
||||
buffer = blocks.pop() ?? ""
|
||||
for (const block of blocks) {
|
||||
let name = "message"
|
||||
let data = ""
|
||||
for (const line of block.split("\n")) {
|
||||
if (line.startsWith("event:"))
|
||||
name = line.slice(6).trim()
|
||||
else if (line.startsWith("data:"))
|
||||
data += line.slice(5).trim()
|
||||
}
|
||||
// ": ping" 之类的注释行没有 data
|
||||
if (data)
|
||||
onEvent(name, JSON.parse(data))
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if ((e as Error).name !== "AbortError")
|
||||
onEvent("error", { message: `连接中断:${(e as Error).message}` })
|
||||
} finally {
|
||||
streaming.value = false
|
||||
controller = null
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
controller?.abort()
|
||||
}
|
||||
|
||||
onBeforeUnmount(stop)
|
||||
return { streaming, send, stop }
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ChatContext } from "@/apis/chat"
|
||||
import { getChatContext, listChatContextVersions, saveChatContext } from "@/apis/chat"
|
||||
import { renderMarkdown } from "@/utils/markdown"
|
||||
import { formatTime } from "@/utils/text"
|
||||
|
||||
const SOURCE_LABELS = { user: "我", chat: "Chat", reset: "Reset 清空" } as const
|
||||
|
||||
const current = ref<ChatContext | null>(null)
|
||||
const content = ref("")
|
||||
const versions = ref<ChatContext[]>([])
|
||||
const saving = ref(false)
|
||||
const dirty = computed(() => content.value !== (current.value?.content ?? ""))
|
||||
const preview = computed(() => renderMarkdown(content.value))
|
||||
|
||||
async function load() {
|
||||
current.value = await getChatContext()
|
||||
content.value = current.value.content
|
||||
versions.value = await listChatContextVersions()
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await saveChatContext(content.value)
|
||||
ElMessage.success("已保存")
|
||||
await load()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function restore(version: ChatContext) {
|
||||
if (dirty.value)
|
||||
await ElMessageBox.confirm("当前有未保存的修改,载入旧版本会覆盖编辑区。继续?", "未保存", { type: "warning" })
|
||||
// 只载入编辑区,保存后才成为新的当前版本
|
||||
content.value = version.content
|
||||
}
|
||||
|
||||
/** Chat 更新了 CONTEXT.md:编辑区没有未保存修改时直接刷新 */
|
||||
async function refreshFromChat() {
|
||||
if (dirty.value) {
|
||||
versions.value = await listChatContextVersions()
|
||||
ElMessage.warning("Chat 更新了 CONTEXT.md,你有未保存的修改,未自动刷新")
|
||||
return
|
||||
}
|
||||
await load()
|
||||
}
|
||||
|
||||
defineExpose({ refreshFromChat, load, dirty })
|
||||
load()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-[var(--el-text-color-secondary)]">
|
||||
跨会话的上下文持久化:每轮都会放进 Chat 的 prompt,Chat 也会用 update_context 更新它。
|
||||
{{ current?.created_at ? `当前版本:${formatTime(current.created_at)}(${SOURCE_LABELS[current.source!]})` : "当前为空" }}
|
||||
</span>
|
||||
<el-tag v-if="dirty" type="danger" size="small">
|
||||
有未保存的修改
|
||||
</el-tag>
|
||||
<el-dropdown class="ml-auto" trigger="click" @command="restore">
|
||||
<el-button size="small">
|
||||
历史版本({{ versions.length }})
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu class="max-h-96 overflow-auto">
|
||||
<el-dropdown-item v-for="v in versions" :key="v.id!" :command="v">
|
||||
{{ formatTime(v.created_at, "MM-DD HH:mm:ss") }} · {{ SOURCE_LABELS[v.source!] }} · {{ v.content.length }} 字
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<el-button size="small" type="primary" :disabled="!dirty" :loading="saving" @click="save">
|
||||
保存
|
||||
</el-button>
|
||||
</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)]">
|
||||
<div class="markdown-body p-3" v-html="preview" />
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script lang="ts" setup>
|
||||
import { renderMarkdown } from "@/utils/markdown"
|
||||
|
||||
export interface ToolCard {
|
||||
id: string
|
||||
name: string
|
||||
arguments: Record<string, unknown>
|
||||
/** 未返回时为 null */
|
||||
result: string | null
|
||||
}
|
||||
|
||||
export interface DisplayItem {
|
||||
key: string
|
||||
kind: "user" | "assistant"
|
||||
text: string
|
||||
toolCalls: ToolCard[]
|
||||
stopped?: boolean
|
||||
error?: string
|
||||
/** 生成中:这次模型调用已结束,后续文字另起一条 */
|
||||
closed?: boolean
|
||||
}
|
||||
|
||||
defineProps<{ items: DisplayItem[] }>()
|
||||
|
||||
const TOOL_LABELS: Record<string, string> = {
|
||||
search_news: "检索新闻",
|
||||
get_news: "读取新闻正文",
|
||||
search_symbols: "查找交易对",
|
||||
get_klines: "获取 K 线",
|
||||
get_ticker: "获取 24h 行情",
|
||||
list_skills: "列出 skills",
|
||||
read_skill: "读取 skill",
|
||||
propose_skill_edit: "提交 skill 草稿",
|
||||
create_skill: "新建 skill(草稿)",
|
||||
update_context: "更新 CONTEXT.md"
|
||||
}
|
||||
|
||||
function prettyResult(result: string) {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(result), null, 2)
|
||||
} catch {
|
||||
// 前端拿到的是截断后的预览,可能不是完整 JSON
|
||||
return result
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div v-for="item in items" :key="item.key" :class="item.kind === 'user' ? 'self-end max-w-3/4' : 'self-stretch'">
|
||||
<div v-if="item.kind === 'user'" class="whitespace-pre-wrap rounded-lg bg-[var(--el-color-primary-light-9)] px-3 py-2 text-sm">
|
||||
{{ item.text }}
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<!-- markdown-it 已关闭 html,模型输出中的标签按文本显示 -->
|
||||
<div v-if="item.text" class="markdown-body" v-html="renderMarkdown(item.text)" />
|
||||
<el-collapse v-if="item.toolCalls.length" class="tool-cards">
|
||||
<el-collapse-item v-for="call in item.toolCalls" :key="call.id" :name="call.id">
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2 overflow-hidden text-xs">
|
||||
<el-tag size="small" :type="call.result === null ? 'warning' : 'info'">
|
||||
{{ call.result === null ? "执行中" : "工具" }}
|
||||
</el-tag>
|
||||
<span class="font-bold">{{ TOOL_LABELS[call.name] ?? call.name }}</span>
|
||||
<span class="truncate text-[var(--el-text-color-secondary)] font-mono">{{ JSON.stringify(call.arguments) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<pre class="m-0 max-h-80 overflow-auto whitespace-pre-wrap break-all rounded bg-[var(--el-fill-color-light)] p-2 text-xs">{{ call.result === null ? "等待结果…" : prettyResult(call.result) }}</pre>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
<div v-if="item.stopped" class="text-xs text-[var(--el-text-color-secondary)]">
|
||||
(已停止生成)
|
||||
</div>
|
||||
<el-alert v-if="item.error" :title="item.error" type="error" :closable="false" show-icon />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tool-cards :deep(.el-collapse-item__header) {
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,287 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ChatMessage } from "@/apis/chat"
|
||||
import type { Provider } from "@/apis/settings"
|
||||
import type { SkillNode } from "@/apis/skills"
|
||||
import type { DisplayItem } from "@/pages/chat/components/MessageList.vue"
|
||||
import { ElNotification } from "element-plus"
|
||||
import { getChatSession, resetChat } from "@/apis/chat"
|
||||
import { listProviders } from "@/apis/settings"
|
||||
import { listSkillTree } from "@/apis/skills"
|
||||
import SkillTree from "@/components/SkillTree.vue"
|
||||
import SkillVersionDrawer from "@/components/SkillVersionDrawer.vue"
|
||||
import { useChatStream } from "@/composables/useChatStream"
|
||||
import ContextTab from "@/pages/chat/components/ContextTab.vue"
|
||||
import MessageList from "@/pages/chat/components/MessageList.vue"
|
||||
import { usePersisted } from "@/utils/storage"
|
||||
|
||||
const router = useRouter()
|
||||
const activeTab = ref<"chat" | "context">("chat")
|
||||
const contextTab = ref<InstanceType<typeof ContextTab>>()
|
||||
|
||||
// ---------- 模型与会话 ----------
|
||||
const providers = ref<Provider[]>([])
|
||||
const providerId = usePersisted<number | null>("chat.providerId", null)
|
||||
const provider = computed(() => providers.value.find(p => p.id === providerId.value))
|
||||
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
const estimatedTokens = ref(0)
|
||||
// 正在生成的这一轮,完成后以服务端会话为准重新加载
|
||||
const liveItems = ref<DisplayItem[]>([])
|
||||
const input = ref("")
|
||||
const listBox = ref<HTMLElement>()
|
||||
const { streaming, send, stop } = useChatStream()
|
||||
|
||||
/** 把会话消息转成展示项:工具结果挂到对应的工具调用上 */
|
||||
function toItems(list: ChatMessage[]): DisplayItem[] {
|
||||
const items: DisplayItem[] = []
|
||||
const calls = new Map<string, DisplayItem["toolCalls"][number]>()
|
||||
for (const m of list) {
|
||||
if (m.role === "user") {
|
||||
items.push({ key: `m${m.id}`, kind: "user", text: m.content.text, toolCalls: [] })
|
||||
} else if (m.role === "assistant") {
|
||||
const toolCalls = (m.content.tool_calls ?? []).map((c: any) => ({ ...c, result: null }))
|
||||
toolCalls.forEach((c: any) => calls.set(c.id, c))
|
||||
items.push({ key: `m${m.id}`, kind: "assistant", text: m.content.text, toolCalls, stopped: m.content.stopped, error: m.content.error })
|
||||
} else {
|
||||
const call = calls.get(m.content.tool_call_id)
|
||||
if (call)
|
||||
call.result = m.content.result
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
const items = computed(() => [...toItems(messages.value), ...liveItems.value])
|
||||
const tokenRatio = computed(() => provider.value ? estimatedTokens.value / provider.value.context_window : 0)
|
||||
|
||||
async function scrollToBottom() {
|
||||
await nextTick()
|
||||
listBox.value?.scrollTo({ top: listBox.value.scrollHeight })
|
||||
}
|
||||
|
||||
const RELOAD_RETRY_MS = 300
|
||||
const RELOAD_RETRIES = 10
|
||||
|
||||
async function loadSession() {
|
||||
let session = await getChatSession()
|
||||
// 停止生成后服务端还要片刻才落库部分回复:等它结束再以服务端为准
|
||||
for (let i = 0; session.generating && i < RELOAD_RETRIES; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, RELOAD_RETRY_MS))
|
||||
session = await getChatSession()
|
||||
}
|
||||
messages.value = session.messages
|
||||
estimatedTokens.value = session.estimated_tokens
|
||||
liveItems.value = []
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
async function loadProviders() {
|
||||
providers.value = await listProviders()
|
||||
if (!provider.value)
|
||||
providerId.value = providers.value.find(p => p.is_default)?.id ?? providers.value[0]?.id ?? null
|
||||
}
|
||||
|
||||
// ---------- 发送与流式事件 ----------
|
||||
function currentAssistant(): DisplayItem {
|
||||
const last = liveItems.value.at(-1)
|
||||
if (last?.kind === "assistant" && !last.closed)
|
||||
return last
|
||||
const item: DisplayItem = { key: `live${Date.now()}${liveItems.value.length}`, kind: "assistant", text: "", toolCalls: [] }
|
||||
liveItems.value.push(item)
|
||||
return liveItems.value.at(-1)!
|
||||
}
|
||||
|
||||
function onEvent(name: string, data: any) {
|
||||
if (name === "prompt") {
|
||||
estimatedTokens.value = data.estimated_tokens
|
||||
} else if (name === "delta") {
|
||||
currentAssistant().text += data.text
|
||||
} else if (name === "usage") {
|
||||
// 一次模型调用结束;之后的工具调用挂在这条回复上,新的文字另起一条
|
||||
currentAssistant().closed = true
|
||||
} else if (name === "tool_call") {
|
||||
const last = [...liveItems.value].reverse().find(i => i.kind === "assistant") ?? currentAssistant()
|
||||
last.toolCalls.push({ id: data.id, name: data.name, arguments: data.arguments, result: null })
|
||||
} else if (name === "tool_result") {
|
||||
for (const item of liveItems.value) {
|
||||
const call = item.toolCalls.find(c => c.id === data.id)
|
||||
if (call)
|
||||
call.result = data.preview
|
||||
}
|
||||
} else if (name === "skill_draft") {
|
||||
highlightIds.value = [...new Set([...highlightIds.value, data.skill_id])]
|
||||
loadSkills()
|
||||
ElNotification({ title: "Chat 生成了 skill 草稿", message: `${data.path} v${data.version},需审阅并发布后才生效`, type: "warning" })
|
||||
} else if (name === "context_updated") {
|
||||
contextTab.value?.refreshFromChat()
|
||||
} else if (name === "error") {
|
||||
currentAssistant().error = data.message
|
||||
}
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const text = input.value.trim()
|
||||
if (!text || streaming.value)
|
||||
return
|
||||
if (!provider.value) {
|
||||
ElMessage.warning("请先到「环境变量」页面添加模型配置")
|
||||
return
|
||||
}
|
||||
input.value = ""
|
||||
liveItems.value = [{ key: `live-user${Date.now()}`, kind: "user", text, toolCalls: [] }]
|
||||
scrollToBottom()
|
||||
await send({ text, provider_id: provider.value.id }, onEvent)
|
||||
// 无论完成、出错还是停止,都以服务端保存的会话为准;保留本地的错误提示
|
||||
const errors = liveItems.value.filter(i => i.error)
|
||||
await loadSession()
|
||||
liveItems.value = errors.map(e => ({ ...e, text: "", toolCalls: [] }))
|
||||
}
|
||||
|
||||
function onKeydown(e: Event | KeyboardEvent) {
|
||||
if (e instanceof KeyboardEvent && e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Reset ----------
|
||||
const resetVisible = ref(false)
|
||||
const clearContext = ref(false)
|
||||
|
||||
async function doReset() {
|
||||
await resetChat(clearContext.value)
|
||||
resetVisible.value = false
|
||||
ElMessage.success(clearContext.value ? "已清空对话和 CONTEXT.md" : "已清空对话,CONTEXT.md 保留")
|
||||
clearContext.value = false
|
||||
await loadSession()
|
||||
await contextTab.value?.load()
|
||||
}
|
||||
|
||||
// ---------- 侧边栏 skills ----------
|
||||
const skills = ref<SkillNode[]>([])
|
||||
const highlightIds = ref<number[]>([])
|
||||
const drawerSkill = ref<SkillNode | null>(null)
|
||||
const drawerVisible = ref(false)
|
||||
|
||||
async function loadSkills() {
|
||||
skills.value = await listSkillTree()
|
||||
if (drawerSkill.value)
|
||||
drawerSkill.value = skills.value.find(s => s.id === drawerSkill.value!.id) ?? null
|
||||
}
|
||||
|
||||
function openSkill(node: SkillNode) {
|
||||
if (node.kind !== "skill")
|
||||
return
|
||||
drawerSkill.value = node
|
||||
drawerVisible.value = true
|
||||
highlightIds.value = highlightIds.value.filter(id => id !== node.id)
|
||||
}
|
||||
|
||||
async function onPublished() {
|
||||
drawerVisible.value = false
|
||||
await loadSkills()
|
||||
// 发布后 prompt 变了,刷新估算
|
||||
estimatedTokens.value = (await getChatSession()).estimated_tokens
|
||||
}
|
||||
|
||||
onBeforeRouteLeave(async () => {
|
||||
if (streaming.value)
|
||||
await ElMessageBox.confirm("正在生成,离开页面会停止生成。继续?", "提示", { type: "warning" })
|
||||
if (contextTab.value?.dirty)
|
||||
await ElMessageBox.confirm("CONTEXT.md 有未保存的修改,离开后会丢失。继续?", "未保存", { type: "warning" })
|
||||
})
|
||||
|
||||
loadProviders()
|
||||
loadSession()
|
||||
loadSkills()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col">
|
||||
<el-tabs v-model="activeTab" class="chat-tabs min-h-0 flex flex-1 flex-col">
|
||||
<el-tab-pane label="Chat" name="chat" class="h-full">
|
||||
<div class="h-full flex gap-3">
|
||||
<el-card class="w-72 shrink-0" body-class="h-full box-border flex flex-col gap-2" shadow="never">
|
||||
<div class="flex items-center justify-between text-sm font-bold">
|
||||
Skills
|
||||
<el-button link size="small" @click="router.push('/skills')">
|
||||
去管理
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="text-xs text-[var(--el-text-color-secondary)]">
|
||||
点击查看草稿与已发布版本的差异并发布;黄色为本次 Chat 刚改过的
|
||||
</div>
|
||||
<div class="min-h-0 flex-1">
|
||||
<SkillTree :nodes="skills" :highlight-ids="highlightIds" :selected-id="drawerSkill?.id" @select="openSkill" />
|
||||
</div>
|
||||
</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">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<el-select v-model="providerId" placeholder="选择模型配置" class="w-60" :disabled="streaming">
|
||||
<el-option v-for="p in providers" :key="p.id" :value="p.id" :label="`${p.name}(${p.model})`" />
|
||||
</el-select>
|
||||
<el-tooltip content="下一轮发给模型的 system prompt(含已发布 skills、CONTEXT.md)与历史消息的估算大小" placement="bottom">
|
||||
<span class="text-xs" :class="tokenRatio > 0.8 ? 'text-red-500 font-bold' : 'text-[var(--el-text-color-secondary)]'">
|
||||
上下文约 {{ estimatedTokens.toLocaleString() }}{{ provider ? ` / ${provider.context_window.toLocaleString()}` : "" }} token
|
||||
</span>
|
||||
</el-tooltip>
|
||||
<el-button class="ml-auto" type="danger" plain :disabled="streaming" @click="resetVisible = true">
|
||||
Reset
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div ref="listBox" class="min-h-0 flex-1 overflow-auto pr-2">
|
||||
<el-empty v-if="items.length === 0" description="提问,例如:长鑫 4F² 架构对三星、海力士有冲击吗?在哪里抄底、止损?" />
|
||||
<MessageList v-else :items="items" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-end gap-2">
|
||||
<el-input
|
||||
v-model="input" type="textarea" :autosize="{ minRows: 2, maxRows: 8 }" resize="none"
|
||||
placeholder="输入问题,Ctrl+Enter 发送" @keydown="onKeydown"
|
||||
/>
|
||||
<el-button v-if="streaming" type="warning" @click="stop">
|
||||
停止
|
||||
</el-button>
|
||||
<el-button v-else type="primary" :disabled="!input.trim()" @click="submit">
|
||||
发送
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="CONTEXT.md" name="context" class="h-full">
|
||||
<ContextTab ref="contextTab" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<SkillVersionDrawer v-model="drawerVisible" :skill="drawerSkill" @published="onPublished" />
|
||||
|
||||
<el-dialog v-model="resetVisible" title="Reset 清除上下文" width="440px">
|
||||
<p class="mt-0">
|
||||
清空当前对话,开始新的会话(旧对话保留在数据库中,不再展示)。skills 不受影响。
|
||||
</p>
|
||||
<el-checkbox v-model="clearContext">
|
||||
同时清空 CONTEXT.md(会保存为一个空版本,可在历史版本中找回)
|
||||
</el-checkbox>
|
||||
<template #footer>
|
||||
<el-button @click="resetVisible = false">
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="danger" @click="doReset">
|
||||
Reset
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chat-tabs :deep(.el-tabs__content) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,190 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Provider, ProviderForm } from "@/apis/settings"
|
||||
import { createProvider, deleteProvider, listProviders, patchProvider, testProvider } from "@/apis/settings"
|
||||
import { formatTime } from "@/utils/text"
|
||||
|
||||
const PROTOCOL_LABELS = { openai: "OpenAI 格式(/v1/chat/completions)", anthropic: "Claude 格式(/v1/messages)" } as const
|
||||
|
||||
const providers = ref<Provider[]>([])
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const saving = ref(false)
|
||||
const testing = ref<number | null>(null)
|
||||
|
||||
function emptyForm(): ProviderForm {
|
||||
return { name: "", protocol: "anthropic", base_url: "", api_key: "", model: "", max_tokens: 8192, context_window: 200000, temperature: null, is_default: false }
|
||||
}
|
||||
const form = ref<ProviderForm>(emptyForm())
|
||||
|
||||
async function load() {
|
||||
providers.value = await listProviders()
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
form.value = emptyForm()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(p: Provider) {
|
||||
editingId.value = p.id
|
||||
// key 不回显,留空表示不修改
|
||||
form.value = { ...p, api_key: "" }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value === null) {
|
||||
await createProvider(form.value)
|
||||
} else {
|
||||
const { api_key, ...rest } = form.value
|
||||
await patchProvider(editingId.value, api_key.trim() ? form.value : rest)
|
||||
}
|
||||
ElMessage.success("已保存")
|
||||
dialogVisible.value = false
|
||||
await load()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function setDefault(p: Provider) {
|
||||
await patchProvider(p.id, { is_default: true })
|
||||
await load()
|
||||
}
|
||||
|
||||
async function remove(p: Provider) {
|
||||
await ElMessageBox.confirm(`删除模型配置「${p.name}」?`, "删除确认", { type: "warning" })
|
||||
await deleteProvider(p.id)
|
||||
ElMessage.success("已删除")
|
||||
await load()
|
||||
}
|
||||
|
||||
async function test(p: Provider) {
|
||||
testing.value = p.id
|
||||
try {
|
||||
const result = await testProvider(p.id)
|
||||
if (result.ok)
|
||||
ElMessage.success(`连接成功,用时 ${result.elapsed_ms}ms,回复:${result.reply}`)
|
||||
else
|
||||
ElMessage.error({ message: `连接失败(${result.elapsed_ms}ms):${result.error}`, duration: 8000 })
|
||||
} finally {
|
||||
testing.value = null
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="text-lg font-bold">模型配置</span>
|
||||
<span class="ml-3 text-xs text-[var(--el-text-color-secondary)]">通过 Sub2API 等中转接入;API key 加密存储,页面只显示末 4 位</span>
|
||||
</div>
|
||||
<el-button type="primary" @click="openCreate">
|
||||
新增配置
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="providers" border>
|
||||
<el-table-column label="名称" min-width="120">
|
||||
<template #default="{ row }">
|
||||
{{ row.name }}
|
||||
<el-tag v-if="row.is_default" size="small" type="success" class="ml-1">
|
||||
默认
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="协议" min-width="200">
|
||||
<template #default="{ row }">
|
||||
{{ PROTOCOL_LABELS[row.protocol as keyof typeof PROTOCOL_LABELS] }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="base_url" label="Base URL" min-width="200" />
|
||||
<el-table-column prop="model" label="模型" min-width="150" />
|
||||
<el-table-column label="API Key" width="100">
|
||||
<template #default="{ row }">
|
||||
****{{ row.api_key_hint }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="更新时间" width="130">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.updated_at, "MM-DD HH:mm") }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="250" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" :loading="testing === row.id" @click="test(row as Provider)">
|
||||
测试连接
|
||||
</el-button>
|
||||
<el-button link @click="openEdit(row as Provider)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button v-if="!row.is_default" link @click="setDefault(row as Provider)">
|
||||
设为默认
|
||||
</el-button>
|
||||
<el-button link type="danger" @click="remove(row as Provider)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editingId === null ? '新增模型配置' : '编辑模型配置'" width="560px">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="名称" required>
|
||||
<el-input v-model="form.name" placeholder="如 Sub2API-Claude" />
|
||||
</el-form-item>
|
||||
<el-form-item label="协议" required>
|
||||
<el-radio-group v-model="form.protocol">
|
||||
<el-radio value="anthropic">
|
||||
Claude 格式
|
||||
</el-radio>
|
||||
<el-radio value="openai">
|
||||
OpenAI 格式
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
<div class="text-xs text-[var(--el-text-color-secondary)]">
|
||||
选 key 所在分组的原生协议;Sub2API 的协议转换会丢失部分信息
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="Base URL" required>
|
||||
<el-input v-model="form.base_url" placeholder="https://your-sub2api.example.com(带不带 /v1 都可以)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="API Key" :required="editingId === null">
|
||||
<el-input v-model="form.api_key" type="password" show-password autocomplete="new-password" :placeholder="editingId === null ? '' : '留空表示不修改'" />
|
||||
</el-form-item>
|
||||
<el-form-item label="模型" required>
|
||||
<el-input v-model="form.model" placeholder="如 claude-sonnet-4-5" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最大输出">
|
||||
<el-input-number v-model="form.max_tokens" :min="256" :max="128000" :step="1024" controls-position="right" />
|
||||
<span class="ml-2 text-xs">token</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="上下文上限">
|
||||
<el-input-number v-model="form.context_window" :min="4096" :max="2000000" :step="10000" controls-position="right" />
|
||||
<span class="ml-2 text-xs">token,超过时拒绝发送</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="Temperature">
|
||||
<el-input-number v-model="form.temperature" :min="0" :max="2" :step="0.1" :value-on-clear="null" controls-position="right" placeholder="默认" />
|
||||
<span class="ml-2 text-xs">留空使用模型默认值</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="设为默认">
|
||||
<el-switch v-model="form.is_default" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="save">
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -5,7 +5,10 @@ import { useUserStore } from "@/pinia/user"
|
||||
export const menuRoutes = [
|
||||
{ path: "/news", name: "news", component: () => import("@/pages/news/index.vue"), meta: { title: "新闻流" } },
|
||||
{ path: "/crawler", name: "crawler", component: () => import("@/pages/crawler/index.vue"), meta: { title: "采集控制台" } },
|
||||
{ path: "/market", name: "market", component: () => import("@/pages/market/index.vue"), meta: { title: "行情" } }
|
||||
{ path: "/market", name: "market", component: () => import("@/pages/market/index.vue"), meta: { title: "行情" } },
|
||||
{ path: "/chat", name: "chat", component: () => import("@/pages/chat/index.vue"), meta: { title: "Chat" } },
|
||||
{ path: "/skills", name: "skills", component: () => import("@/pages/skills/index.vue"), meta: { title: "Skills" } },
|
||||
{ path: "/settings", name: "settings", component: () => import("@/pages/settings/index.vue"), meta: { title: "环境变量" } }
|
||||
]
|
||||
|
||||
export const router = createRouter({
|
||||
|
||||
@@ -8,3 +8,70 @@ body,
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
/* Markdown 渲染(Chat 回复、skill 预览、CONTEXT.md 预览) */
|
||||
.markdown-body {
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
word-break: break-word;
|
||||
}
|
||||
.markdown-body > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.markdown-body h1,
|
||||
.markdown-body h2,
|
||||
.markdown-body h3,
|
||||
.markdown-body h4 {
|
||||
margin: 1em 0 0.5em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.markdown-body h1 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
.markdown-body h2 {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
.markdown-body h3 {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
.markdown-body p,
|
||||
.markdown-body ul,
|
||||
.markdown-body ol,
|
||||
.markdown-body pre,
|
||||
.markdown-body table,
|
||||
.markdown-body blockquote {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
.markdown-body ul,
|
||||
.markdown-body ol {
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
.markdown-body code {
|
||||
padding: 0.1em 0.3em;
|
||||
border-radius: 3px;
|
||||
background: var(--el-fill-color);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.markdown-body pre {
|
||||
padding: 0.6em 0.8em;
|
||||
overflow: auto;
|
||||
border-radius: 4px;
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
.markdown-body pre code {
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
.markdown-body blockquote {
|
||||
padding-left: 0.8em;
|
||||
border-left: 3px solid var(--el-border-color);
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.markdown-body table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.markdown-body th,
|
||||
.markdown-body td {
|
||||
padding: 0.3em 0.6em;
|
||||
border: 1px solid var(--el-border-color);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import MarkdownIt from "markdown-it"
|
||||
|
||||
// html: false —— 模型输出和 skill 内容里的 HTML 一律按文本显示,不执行
|
||||
const md = new MarkdownIt({ html: false, linkify: true, breaks: true })
|
||||
|
||||
// 链接一律新窗口打开
|
||||
const defaultLinkOpen = md.renderer.rules.link_open ?? ((tokens, idx, options, _env, self) => self.renderToken(tokens, idx, options))
|
||||
md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
|
||||
tokens[idx].attrSet("target", "_blank")
|
||||
tokens[idx].attrSet("rel", "noopener noreferrer")
|
||||
return defaultLinkOpen(tokens, idx, options, env, self)
|
||||
}
|
||||
|
||||
export function renderMarkdown(source: string): string {
|
||||
return md.render(source)
|
||||
}
|
||||
Reference in new issue
Block a user