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

@@ -1,9 +1,14 @@
from alphapulse.core.db import Base
from alphapulse.models.ai import AiProvider, ChatMessage, ChatSession, ContextVersion, SkillNode, SkillVersion
from alphapulse.models.market import Kline, KlineSyncState, MarketSymbol
from alphapulse.models.news import CrawlLog, CrawlRun, NewsItem, NewsRevision, RawBody, RawResponse, Source
__all__ = [
"AiProvider",
"Base",
"ChatMessage",
"ChatSession",
"ContextVersion",
"CrawlLog",
"CrawlRun",
"Kline",
@@ -13,5 +18,7 @@ __all__ = [
"NewsRevision",
"RawBody",
"RawResponse",
"SkillNode",
"SkillVersion",
"Source",
]
+102
View File
@@ -0,0 +1,102 @@
from datetime import datetime
from sqlalchemy import BigInteger, Boolean, DateTime, Float, ForeignKey, Integer, LargeBinary, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from alphapulse.core.db import Base
class AiProvider(Base):
"""模型接入配置;api_key 只以密文存储"""
__tablename__ = "ai_providers"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(64), unique=True)
# openai:/v1/chat/completions;anthropic:/v1/messages
protocol: Mapped[str] = mapped_column(String(16))
base_url: Mapped[str] = mapped_column(String(255))
api_key_encrypted: Mapped[bytes] = mapped_column(LargeBinary)
api_key_hint: Mapped[str] = mapped_column(String(8))
model: Mapped[str] = mapped_column(String(128))
max_tokens: Mapped[int] = mapped_column(Integer, default=8192)
# 上下文上限(token),超过时拒绝发送而不是静默截断
context_window: Mapped[int] = mapped_column(Integer, default=200000)
temperature: Mapped[float | None] = mapped_column(Float)
is_default: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class SkillNode(Base):
"""skills 树:文件夹或 skill;内容在 skill_versions,生效的是 published_version_id 指向的版本"""
__tablename__ = "skill_nodes"
__table_args__ = (UniqueConstraint("parent_id", "name", postgresql_nulls_not_distinct=True),)
id: Mapped[int] = mapped_column(primary_key=True)
parent_id: Mapped[int | None] = mapped_column(ForeignKey("skill_nodes.id"))
# folder / skill
kind: Mapped[str] = mapped_column(String(8))
name: Mapped[str] = mapped_column(String(128))
sort_order: Mapped[int] = mapped_column(Integer, default=0)
priority: Mapped[int] = mapped_column(Integer, default=0)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
published_version_id: Mapped[int | None] = mapped_column(ForeignKey("skill_versions.id", use_alter=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class SkillVersion(Base):
"""只追加不修改;回退即重新发布旧版本"""
__tablename__ = "skill_versions"
__table_args__ = (UniqueConstraint("skill_id", "version_no"),)
id: Mapped[int] = mapped_column(primary_key=True)
skill_id: Mapped[int] = mapped_column(ForeignKey("skill_nodes.id", ondelete="CASCADE"))
version_no: Mapped[int] = mapped_column(Integer)
content: Mapped[str] = mapped_column(Text)
# user / chat
source: Mapped[str] = mapped_column(String(8))
note: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class ContextVersion(Base):
"""Chat 的 CONTEXT.md;最新一行即当前内容"""
__tablename__ = "context_versions"
id: Mapped[int] = mapped_column(primary_key=True)
content: Mapped[str] = mapped_column(Text)
# user / chat / reset
source: Mapped[str] = mapped_column(String(8))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class ChatSession(Base):
__tablename__ = "chat_sessions"
id: Mapped[int] = mapped_column(primary_key=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
# Reset 时结束;未结束的只有一个,即当前会话
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class ChatMessage(Base):
"""content 为与协议无关的格式:
user: {"text"};assistant: {"text", "tool_calls": [{"id","name","arguments"}]};tool: {"tool_call_id","name","result"}
"""
__tablename__ = "chat_messages"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
session_id: Mapped[int] = mapped_column(ForeignKey("chat_sessions.id"), index=True)
# user / assistant / tool
role: Mapped[str] = mapped_column(String(16))
content: Mapped[dict] = mapped_column(JSONB)
provider_id: Mapped[int | None] = mapped_column(ForeignKey("ai_providers.id", ondelete="SET NULL"))
usage: Mapped[dict | None] = mapped_column(JSONB)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())