mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:41:57 +00:00
e10c4f23fc
来自 2026-09 的 UI/UX 审查(142 条)。本提交是前两波,第三波(记录页接入时间轴、跨页互链、check-ui 更新、全站扫尾)另行提交。 地基 - 设计令牌:全局规则和 .btn/.field/.chip 放进 CSS 层,Tailwind 工具类重新生效,选中态、开启态不再被吞掉;字号阶梯; 辅助文字在所有底色上 >= 4.5:1;状态色专用,节点类型色与之拉开色相;「跟随系统」真的跟随,首帧不闪深色;新字标与 favicon - 基础组件:confirmDialog / promptDialog 取代原生对话框;toast 出错不自动消失、可复制、读屏可播报;离线横幅与断线恢复; 九种剪影的状态徽标;唯一一份状态文案(lib/status)、格式化(lib/format)、术语(lib/terms)、报错人话(lib/errors) - 运行态内核:事件折成航迹(run/trace.ts),可投影到任意时刻;取消 / 失败 / 服务重启后统一收尾,不再永远转圈; run.status 随事件回写;token 事件不再堆进 events - 后端:协作成员逐个完成即发事件,调度者决策有事件;时长分执行 / 墙钟 / 等人;API 时间带时区; 中断的运行也发 stream.end;运行列表可筛选翻页;删除运行 / 工作流有保护;运行默认值接通;可测未保存的配置; 报错说清发生了什么、为什么、怎么办 功能 - 画布:状态四重编码、固定高度遥测槽、按缩放三档、运行胶囊与 HUD、航迹时间轴与回放、执行轨迹边、 等待闸门、跟随执行、正式运行只读、一次性时刻(开场点亮、回扫、阻断级联、出具印章) - 编辑:撤销重做、版本历史、问题面板可定位、三栏可收起、模板与表达式字段区分、变量血缘、快捷键、带搜索的选择器 - 回答流 / 问数据:贴底才跟随滚动、工件下钻、出具横幅列出缺口、答案可复制导出、按会话的忙闲、 答案不再在 2000 字处截断、重启后这一轮会收尾 - 记录页:待审批 / 运行中 / 失败页签、筛选翻页、实时接流、排错路径指到节点、封存凭证常驻 - 管理页:新的「数据」页、写操作失败都有反馈、弹窗里先测连接、统一持久的连接状态 - 外壳:导航改版、⌘K 命令面板、? 快捷键帮助、404、页面标题与 favicon 状态点、后台提醒 验证(沙箱):tsc 干净,pytest 579 passed,14 个检查脚本中 13 个全绿;check-ui 剩 1 项旧断言(按旧日期格式找运行行),第三波更新。
182 lines
7.6 KiB
Python
182 lines
7.6 KiB
Python
from __future__ import annotations
|
||
|
||
import uuid
|
||
from datetime import datetime, timezone
|
||
from typing import Any, AsyncIterator
|
||
|
||
from sqlalchemy import JSON, DateTime, MetaData, event
|
||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||
from sqlalchemy.types import TypeDecorator
|
||
|
||
from app.core.config import settings
|
||
|
||
NAMING_CONVENTION = {
|
||
"ix": "ix_%(column_0_label)s",
|
||
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
||
"fk": "fk_%(table_name)s_%(column_0_name)s",
|
||
"pk": "pk_%(table_name)s",
|
||
}
|
||
|
||
|
||
def utcnow() -> datetime:
|
||
return datetime.now(timezone.utc)
|
||
|
||
|
||
def new_id() -> str:
|
||
return uuid.uuid4().hex
|
||
|
||
|
||
class UTCDateTime(TypeDecorator):
|
||
"""存 UTC、读出来也是带时区的 UTC。
|
||
|
||
SQLite 没有时区类型:DateTime(timezone=True) 写进去的只是一串不带偏移的
|
||
字面时间,读回来是 naive 的。API 于是吐出 '2026-09-26T01:11:19',浏览器把
|
||
不带偏移的 ISO 串当本地时间解析,运行列表整体慢了 8 小时。在这一层补上 UTC,
|
||
pydantic 序列化自然带 +00:00,所有接口一次修好。
|
||
|
||
写入前先换算成 UTC 再去掉时区:SQLite 方言格式化时直接丢弃 tzinfo,
|
||
一个 +08:00 的时间原样写进去,读回来就差出 8 小时。
|
||
"""
|
||
|
||
impl = DateTime
|
||
cache_ok = True
|
||
|
||
def process_bind_param(self, value: datetime | None, dialect: Any) -> datetime | None:
|
||
if value is None:
|
||
return None
|
||
if value.tzinfo is not None:
|
||
value = value.astimezone(timezone.utc).replace(tzinfo=None)
|
||
return value
|
||
|
||
def process_result_value(self, value: datetime | None, dialect: Any) -> datetime | None:
|
||
if value is None:
|
||
return None
|
||
if value.tzinfo is None:
|
||
return value.replace(tzinfo=timezone.utc)
|
||
return value.astimezone(timezone.utc)
|
||
|
||
|
||
class Base(DeclarativeBase):
|
||
metadata = MetaData(naming_convention=NAMING_CONVENTION)
|
||
|
||
# 图定义、节点配置、事件负载都是任意形状的 JSON,统一映射到 SQLite 的 JSON 列
|
||
type_annotation_map = {dict[str, Any]: JSON, list[Any]: JSON, datetime: UTCDateTime}
|
||
|
||
|
||
class TimestampMixin:
|
||
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
|
||
updated_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow, onupdate=utcnow)
|
||
|
||
|
||
def _stop_aiosqlite_once() -> None:
|
||
"""aiosqlite 连接的工作线程只停一次;之后再要求停的,拿到的是同一个结果。
|
||
|
||
aiosqlite 0.22 的 stop() 每调一次都往工作线程队列里放一个"关闭并退出线程"
|
||
的请求,而线程处理完第一个就退出了——之后放进去的请求没人处理,等它的一方
|
||
永远挂着。close() 的 finally 里会调 stop(),SQLAlchemy 的强制 terminate 也会
|
||
直接调;两个 close 并发、或者 close 撞上强制 terminate,就会卡死。实测同一
|
||
连接并发 close 两次,10 次卡 10 次。
|
||
|
||
在这里触发它的路径:并行的两条支路一条失败,LangGraph 取消另一条,而那一条
|
||
正在写工件。取消打进进行中的数据库操作,SQLAlchemy 在两条路径上各 terminate
|
||
一次同一条连接——节点任务取消不完,整个运行卡在 running,占着的并发名额也不
|
||
还。大约五次里有一次。连接池和 checkpointer 都走 aiosqlite,补在这一层都管得到。
|
||
"""
|
||
import weakref
|
||
|
||
import aiosqlite
|
||
|
||
original = aiosqlite.Connection.stop
|
||
if getattr(original, "_agentlab_once", False):
|
||
return
|
||
stopping: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary()
|
||
|
||
def stop(self): # type: ignore[no-untyped-def]
|
||
if self not in stopping:
|
||
stopping[self] = original(self)
|
||
return stopping[self]
|
||
|
||
stop._agentlab_once = True # type: ignore[attr-defined]
|
||
aiosqlite.Connection.stop = stop # type: ignore[method-assign]
|
||
|
||
|
||
_stop_aiosqlite_once()
|
||
# pool_pre_ping:取连接前先探一下活。取消异常打进 SQLAlchemy 的清理流程时
|
||
# (_close_connection 记完日志会把 CancelledError 原样抛出,跳过了把连接记录
|
||
# 清空的那一行),已经关掉的连接会留在池里,下一个取到它的人拿到的是
|
||
# "no active connection"。探活失败会被当成断开,换一条新的——SQLite 本地一次
|
||
# SELECT 1 的代价,换池子不被污染。
|
||
engine = create_async_engine(settings.db_url, echo=False, future=True, pool_pre_ping=True)
|
||
|
||
|
||
@event.listens_for(engine.sync_engine, "connect")
|
||
def _set_sqlite_pragma(dbapi_conn, _record): # pragma: no cover - 驱动回调
|
||
"""WAL + 外键约束。WAL 让读写不互相阻塞,事件写入和前端轮询能并行。"""
|
||
cur = dbapi_conn.cursor()
|
||
cur.execute("PRAGMA journal_mode=WAL")
|
||
cur.execute("PRAGMA foreign_keys=ON")
|
||
cur.execute("PRAGMA busy_timeout=5000")
|
||
cur.close()
|
||
|
||
|
||
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
||
|
||
|
||
async def get_session() -> AsyncIterator[AsyncSession]:
|
||
"""FastAPI 依赖注入用的会话。"""
|
||
async with SessionLocal() as session:
|
||
yield session
|
||
|
||
|
||
# 轻量迁移:create_all 只建新表,不给已有表加列。SQLite 的 ADD COLUMN
|
||
# 便宜且带默认值回填,够用;等真需要改列类型再考虑 alembic。
|
||
_COLUMN_MIGRATIONS: list[tuple[str, str, str]] = [
|
||
("runs", "run_class", "TEXT DEFAULT 'exploratory'"),
|
||
("runs", "version", "INTEGER"),
|
||
("runs", "version_hash", "VARCHAR(64)"),
|
||
("runs", "manifest_hash", "VARCHAR(64)"),
|
||
("runs", "manifest_seq", "INTEGER"),
|
||
("runs", "started_by", "VARCHAR(100)"),
|
||
# 续跑、恢复要沿用第一次发起时的记忆域、知识库和审批默认值;以前只在第一段
|
||
# _drive 里传过一次,恢复后回落成 "default",同一次运行前后两段查的不是同一个库
|
||
("runs", "memory_scope", "VARCHAR(100)"),
|
||
("runs", "collection", "VARCHAR(100)"),
|
||
("runs", "approval_default", "VARCHAR(20)"),
|
||
("runs", "error_node_id", "VARCHAR(64)"),
|
||
("workflows", "status", "TEXT DEFAULT 'draft'"),
|
||
("workflows", "published_version", "INTEGER"),
|
||
("workflows", "published_by", "VARCHAR(100)"),
|
||
("workflow_versions", "graph_hash", "VARCHAR(64)"),
|
||
("approvals", "resolved_by", "VARCHAR(100)"),
|
||
# 存量向量没有这两列,回填成空串 / 0 —— 正好表示"不知道是谁建的",
|
||
# 检索侧会把它们当成对不上当前 embedder 处理
|
||
("chunks", "embed_model", "VARCHAR(100) DEFAULT ''"),
|
||
("chunks", "embed_dim", "INTEGER DEFAULT 0"),
|
||
("memories", "embed_model", "VARCHAR(100) DEFAULT ''"),
|
||
("memories", "embed_dim", "INTEGER DEFAULT 0"),
|
||
("chunks", "token_len", "INTEGER DEFAULT 0"),
|
||
("documents", "status", "TEXT DEFAULT 'ready'"),
|
||
("documents", "error", "TEXT DEFAULT ''"),
|
||
# 存量轮次没有复核结论。NULL 正好表示"这一轮没复核过",和"复核过、没问题"
|
||
# (存 verdict=ok)是两回事,前端要分得开
|
||
("conversation_turns", "review", "JSON"),
|
||
]
|
||
|
||
|
||
def _migrate(conn) -> None: # pragma: no cover - 同步回调
|
||
from sqlalchemy import text
|
||
|
||
for table, column, ddl in _COLUMN_MIGRATIONS:
|
||
cols = {row[1] for row in conn.execute(text(f"PRAGMA table_info({table})"))}
|
||
if cols and column not in cols:
|
||
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {column} {ddl}"))
|
||
|
||
|
||
async def init_db() -> None:
|
||
from app.db import models # noqa: F401 确保模型已注册到 metadata
|
||
|
||
async with engine.begin() as conn:
|
||
await conn.run_sync(Base.metadata.create_all)
|
||
await conn.run_sync(_migrate)
|