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

+104
View File
@@ -0,0 +1,104 @@
"""skills 树与版本的共用逻辑,供 skills 接口和 Chat 工具使用"""
from dataclasses import dataclass
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from alphapulse.models import SkillNode, SkillVersion
class SkillError(Exception):
"""可直接展示给用户(或模型)的业务错误"""
@dataclass
class NodeInfo:
node: SkillNode
path: str
published_version_no: int | None
latest_version_no: int | None
@property
def has_draft(self) -> bool:
# 存在比已发布版本更新的版本
return self.latest_version_no is not None and self.latest_version_no != self.published_version_no
async def load_tree(session: AsyncSession) -> list[NodeInfo]:
"""所有节点及其路径和版本状态,按父节点、sort_order 排序"""
nodes = (await session.scalars(select(SkillNode).order_by(SkillNode.parent_id.nulls_first(), SkillNode.sort_order, SkillNode.id))).all()
latest = dict((await session.execute(select(SkillVersion.skill_id, func.max(SkillVersion.version_no)).group_by(SkillVersion.skill_id))).all())
published = dict((await session.execute(
select(SkillNode.id, SkillVersion.version_no).join(SkillVersion, SkillVersion.id == SkillNode.published_version_id)
)).all())
by_id = {n.id: n for n in nodes}
def path(node: SkillNode) -> str:
parts = [node.name]
while node.parent_id is not None:
node = by_id[node.parent_id]
parts.append(node.name)
return "/".join(reversed(parts))
return [NodeInfo(n, path(n), published.get(n.id), latest.get(n.id)) for n in nodes]
async def find_by_path(session: AsyncSession, path: str, kind: str | None = None) -> SkillNode:
path = path.strip("/")
for info in await load_tree(session):
if info.path == path and (kind is None or info.node.kind == kind):
return info.node
raise SkillError(f"找不到{'文件夹' if kind == 'folder' else 'skill'}:{path}")
async def add_version(session: AsyncSession, skill: SkillNode, content: str, source: str, note: str) -> SkillVersion:
"""追加一个新版本(不发布);调用方负责 commit"""
if skill.kind != "skill":
raise SkillError("文件夹没有内容版本")
# 锁住 skill 行,避免并发保存得到相同的版本号
await session.execute(select(SkillNode.id).where(SkillNode.id == skill.id).with_for_update())
current = await session.scalar(select(func.max(SkillVersion.version_no)).where(SkillVersion.skill_id == skill.id))
version = SkillVersion(skill_id=skill.id, version_no=(current or 0) + 1, content=content, source=source, note=note)
session.add(version)
await session.flush()
return version
async def create_node(session: AsyncSession, parent_id: int | None, kind: str, name: str, priority: int = 0) -> SkillNode:
name = name.strip()
if not name or "/" in name:
raise SkillError("名称不能为空,也不能包含 /")
if parent_id is not None:
parent = await session.get(SkillNode, parent_id)
if parent is None or parent.kind != "folder":
raise SkillError("只能在文件夹下新建")
same_parent = SkillNode.parent_id.is_not_distinct_from(parent_id)
if await session.scalar(select(SkillNode.id).where(same_parent, SkillNode.name == name)):
raise SkillError(f"同一文件夹下已存在:{name}")
last = await session.scalar(select(func.max(SkillNode.sort_order)).where(same_parent))
node = SkillNode(parent_id=parent_id, kind=kind, name=name, priority=priority, sort_order=(last or 0) + 1, enabled=True)
session.add(node)
await session.flush()
return node
async def published_skills(session: AsyncSession) -> list[tuple[NodeInfo, str]]:
"""进入 prompt 的 skills:已发布、已启用、所有上级文件夹也未停用;按优先级从高到低"""
tree = await load_tree(session)
by_id = {i.node.id: i.node for i in tree}
def active(node: SkillNode) -> bool:
while True:
if not node.enabled:
return False
if node.parent_id is None:
return True
node = by_id[node.parent_id]
chosen = [i for i in tree if i.node.kind == "skill" and i.node.published_version_id is not None and active(i.node)]
contents = dict((await session.execute(
select(SkillVersion.id, SkillVersion.content).where(SkillVersion.id.in_([i.node.published_version_id for i in chosen]))
)).all())
chosen.sort(key=lambda i: (-i.node.priority, i.path))
return [(i, contents[i.node.published_version_id]) for i in chosen]