commit 887c1f449eb7b19ab61f6fd5fd1a2eb460e2f06b Author: 陈歆 <2685475410@qq.com> Date: Fri Aug 21 16:00:13 2026 +0800 初始化提交 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8f468a3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,82 @@ +# ============================== +# Docker 构建忽略清单 +# build context 为项目根(docker-compose.yml 中 context: ..), +# 本文件同时影响 backend 与 frontend 镜像构建。 +# ============================== + +# ----- 版本控制 ----- +.git/ +.gitignore +.gitattributes + +# ----- Python / 后端 ----- +backend/.venv/ +backend/**/__pycache__/ +backend/**/*.py[cod] +backend/.pytest_cache/ +backend/.mypy_cache/ +backend/.ruff_cache/ +backend/.coverage +backend/coverage.xml +backend/htmlcov/ +backend/dist/ +backend/build/ +backend/*.egg-info/ + +# ----- Node / 前端 ----- +frontend/node_modules/ +frontend/.pnpm-store/ +frontend/.next/ +frontend/out/ +frontend/.vercel/ +frontend/.turbo/ +frontend/*.tsbuildinfo +frontend/.eslintcache +frontend/npm-debug.log* +frontend/pnpm-debug.log* + +# ----- 运行时数据与本地存储(禁止进镜像) ----- +db/*.sqlite +db/*.sqlite3 +db/*.db +db/*.db-shm +db/*.db-wal +db/chroma/ +storage/ +uploads/ +media/ +objects/ +logs/ +*.log +*.pid + +# ----- Rust / 安全沙箱 ----- +sandbox/target/ +sandbox/sandbox-work/ +**/*.rs.bk + +# ----- Docker 本地覆盖 ----- +docker/*.local.yml +docker-compose.override.yml + +# ----- 配置密钥(只允许 .example) ----- +.env +.env.* +!.env.example +config/models.yaml +frontend/.env.local + +# ----- IDE / OS ----- +.vscode/ +.idea/ +.DS_Store +Thumbs.db + +# ----- AI / 工具缓存 ----- +.claude/cache/ +.claude/tmp/ + +# ----- 文档(镜像运行不需要) ----- +docs/ +README.md +**/CLAUDE.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..396270b --- /dev/null +++ b/.env.example @@ -0,0 +1,667 @@ +# 魂镜 环境变量示例 +# 复制为 .env 后按需修改 +# +# 分层分组法排列:核心运行环境 → 应用配置 → 第三方服务 → 基础设施 +# 每组内按"同类"对齐:连接地址 → 端口 → 认证信息 → 开关/参数 +# 各组按"如果删除此功能能否一键删除整组"的组织原则排列 +# +# 标注了"必填"的变量在启动前必须设置, +# 标注"按需"的变量保留默认值即可正常工作。 +# 每个变量的默认值是本地开发推荐值。生产环境请根据实际部署调整。 + +# ===================================================================== +# 全局基础配置(所有子系统共用) +# ===================================================================== + +# 应用名称,仅用于 OpenAPI、日志和健康检查展示 +APP_NAME=魂镜 + +# 运行环境:dev / prod +# prod 环境下博闻的日志输出会切换为 JSON 格式,便于日志采集系统(如 Loki)解析 +APP_ENV=dev + +# 服务端默认时区,用于日志和数据库时间展示。格式:IANA Time Zone +# https://en.wikipedia.org/wiki/List_of_tz_database_time_zones +TIMEZONE=Asia/Shanghai + + +# ===================================================================== +# 后端 · 灵台(FastAPI) +# ===================================================================== + +# --- 部署与跨域 --- +# 后端监听地址与端口 +BACKEND_HOST=0.0.0.0 +BACKEND_PORT=8000 + +# 允许跨域来源,多个来源用逗号分隔 +# 生产环境前端独立域名部署时生效;本地开发浏览器经 next.config.ts 同源代理,不触发 CORS +CORS_ALLOW_ORIGINS=http://localhost:3000 + +# HTTP API 前缀 +API_PREFIX=/api/v1 + +# 是否开启 FastAPI debug。生产环境必须为 false +DEBUG=false + +# --- 数据库(金匮)--- +# 数据库类型:sqlite / pgsql +# - sqlite:本地开发默认,无需 Docker,数据库文件放在项目根目录 db/ 下 +# - pgsql:正式环境或模拟正式环境,使用 PostgreSQL / asyncpg +DATABASE_TYPE=sqlite + +# SQLite 配置。DATABASE_TYPE=sqlite 时使用 +# 后端会根据 SQLITE_DATABASE_PATH 拼接为:sqlite+aiosqlite:///<绝对路径> +SQLITE_DATABASE_PATH=../db/hunjing.sqlite3 + +# PostgreSQL 配置。DATABASE_TYPE=pgsql 时使用 +# 后端会根据下列字段拼接为:postgresql+asyncpg://:@:/ +POSTGRES_HOST=127.0.0.1 +POSTGRES_PORT=5432 +POSTGRES_DB=hunjing +POSTGRES_USER=hunjing +POSTGRES_PASSWORD=change-me +POSTGRES_SSL=false +POSTGRES_POOL_SIZE=10 +POSTGRES_MAX_OVERFLOW=20 + +# 可选覆盖项:如果显式设置 DATABASE_URL,则优先使用它,不再根据 DATABASE_TYPE 拼接 +DATABASE_URL= + +# --- 日志 --- +# 日志目录,默认放在项目根目录 logs/ 下 +LOG_DIR=../logs + +# 日志级别:DEBUG / INFO / WARNING / ERROR +LOG_LEVEL=INFO + +# 是否启用访问日志(每个请求的 method、path、status、duration) +LOG_ACCESS=true + +# 日志保留天数。超过此天数的日志文件自动清理 +LOG_RETENTION_DAYS=14 + +# 日志轮转大小。单个日志文件达到此大小后自动切分 +LOG_ROTATION_SIZE=10 MB + +# --- 默认人物与用户 --- +# 本地单用户范围标识,当前阶段不实现多用户、组织、租户、OAuth 或 SSO +DEFAULT_OWNER_SCOPE=local-user + +# 默认逻辑模型名。必须指向 config/models.yaml 中真实远程 provider 的逻辑模型 +DEFAULT_MODEL_HINT=deepseek-v4-flash + +# --- 管理端点和认证 --- + +# 司命台管理 API(/api/v1/admin/*)的访问凭证 +# 请求需在 Authorization 头携带 ``Bearer ``; +# 为空时管理端点 fail-closed(返回 503),强制部署时配置 +# 生成命令:python -c "import secrets; print(secrets.token_urlsafe(32))" +# 前端司命台管理控制台需在 frontend/.env.local 同步配置 NEXT_PUBLIC_ADMIN_TOKEN(同值) +ADMIN_TOKEN= + +# JWT 签名密钥。必须是一个至少 32 字符的随机字符串 +# 为空时 /auth/* 端点返回 503 fail-closed,强制部署时配置 +# 生成命令:python -c "import secrets; print(secrets.token_urlsafe(48))" +JWT_SECRET= + +# Access Token 有效期(秒)。默认 900(15 分钟) +ACCESS_TOKEN_TTL=900 + +# Refresh Token 有效期(秒)。默认 604800(7 天) +REFRESH_TOKEN_TTL=604800 + +# 种子初始管理员账户(仅首次启动时创建;生产环境部署后应立刻修改密码) +DEFAULT_ADMIN_USERNAME=admin +DEFAULT_ADMIN_EMAIL=admin@hunjing.local +DEFAULT_ADMIN_PASSWORD=admin123 + +# --- SSE 流式输出 --- +# 是否启用 SSE 流式输出 +ENABLE_SSE=true + +# SSE 轮询数据库新增事件的间隔秒数 +SSE_POLL_INTERVAL_SECONDS=0.01 + +# SSE 心跳间隔秒数 +SSE_HEARTBEAT_SECONDS=15 + +# --- 功能开关 --- +# 是否启用工具运行时(百工枢)。关闭时所有工具调用会被拒绝,但不会影响其他功能 +ENABLE_TOOL_RUNTIME=true + +# 是否启用自动记忆候选。关闭时不会从对话中抽取记忆候选,但手动导入记忆不受影响 +ENABLE_MEMORY_CANDIDATES=false + +# 是否启用内驱力扫描(太一枢的 curiosity / exploration) +ENABLE_INTRINSIC_DRIVE=false + +# 是否启用大周天自动触发(长期演化循环) +ENABLE_BIG_CYCLE=true + +# 是否启用洛书显式多步规划(Deliberative Planning) +ENABLE_DELIBERATIVE_PLANNING=false + +# 是否启用审计日志(司命枢·起居注) +ENABLE_AUDIT_LOG=true + +# 是否启用摄像头(烛照枢·视觉感知) +ENABLE_CAMERA=false + +# 是否启用麦克风(烛照枢·听觉感知) +ENABLE_MICROPHONE=false + +# 是否启用 ASR(语音转文字) +ENABLE_ASR=false + +# 是否启用 TTS(文字转语音) +ENABLE_TTS=false + +# 是否启用语音训练(基于导入的语音样本微调 TTS 音色) +ENABLE_VOICE_TRAINING=false + +# 是否启用实时语音对话(Voice Chat) +ENABLE_VOICE_CHAT=false + +# --- 运行时参数 --- +# 单条文本消息最大字节数 +MAX_TEXT_MESSAGE_BYTES=32768 + +# 单个 Run 超时秒数 +RUN_TIMEOUT_SECONDS=120 + +# Agent 循环最大轮数 +AGENT_MAX_ITERATIONS=8 + +# 单个 Run 最大事件数 +RUN_MAX_EVENTS=1000 + +# 事件 payload 最大字节数 +EVENT_PAYLOAD_MAX_BYTES=65536 + +# 单次高风险工具确认挂起超时秒数;须小于 RUN_TIMEOUT_SECONDS,超时按拒绝处理 +CONFIRMATION_TIMEOUT_SECONDS=60 + +# 是否启用数据库持久化的确认存储(用于多 worker 部署) +# 关闭时使用进程内 InMemoryConfirmationStore(默认),适合单进程开发模式 +USE_DB_CONFIRMATION_STORE=false + +# 每轮对话最多注入的记忆条数 +MEMORY_INJECTION_LIMIT=5 + +# 自动记忆候选重要性阈值(0.0 ~ 1.0)。低于此阈值的不生成候选 +MEMORY_CANDIDATE_MIN_IMPORTANCE=0.7 + +# 内驱力扫描间隔秒数 +DRIVE_SCAN_INTERVAL_SECONDS=3600 + +# 单次扫描最多好奇问题数 +DRIVE_MAX_CURIOSITY_PER_SCAN=5 + +# 是否允许内驱力中断当前任务(Agent 循环中插入探索) +DRIVE_ALLOW_INTERRUPT=false + +# 触发大周天的记忆数阈值。新增记忆数达到此值时触发一轮大周天 +BIG_CYCLE_MEMORY_THRESHOLD=20 + +# 两次大周天最小间隔(秒),避免频繁触发 +BIG_CYCLE_MIN_INTERVAL_SECONDS=300 + +# 大周天候选生成所用模型(config/models.yaml 的逻辑模型名) +BIG_CYCLE_MODEL=deepseek-v4-flash + +# 单次大周天生成的演化候选上限 +BIG_CYCLE_MAX_CANDIDATES=10 + +# 高风险动作是否总是确认(即使 permission_policy 设为 allow 也弹出确认框) +SAFETY_ALWAYS_CONFIRM_HIGH_RISK=true + +# --- 工具调用参数 --- +# 工具调用超时秒数(所有工具共享) +TOOL_TIMEOUT_SECONDS=60 + +# fetch_url 等 HTTP 工具的 HTTP 请求超时秒数 +TOOL_WEB_HTTP_TIMEOUT_SECONDS=15 + +# MCP Server tools/list 请求超时秒数 +MCP_LIST_TIMEOUT_SECONDS=10 + +# MCP Server tools/call 请求超时秒数 +MCP_CALL_TIMEOUT_SECONDS=30 + +# MCP stdio 子进程启动超时秒数 +MCP_STDIO_START_TIMEOUT_SECONDS=15 + +# MCP stdio 单次 tools/call 超时秒数 +MCP_STDIO_CALL_TIMEOUT_SECONDS=30 + +# MCP stdio SIGTERM → SIGKILL 等待秒数 +MCP_STDIO_STOP_TIMEOUT_SECONDS=5 + +# Embedding 模型逻辑名。指向 config/models.yaml 中已注册的 embedding 模型 +EMBEDDING_MODEL_HINT=bge-m3 + +# --- 感知与导入 --- +# 是否保存原始感知文件(摄像头 / 麦克风采集的原始文件) +PERCEPTION_SAVE_RAW_MEDIA=false + +# 感知文件最大大小(MB),超限的感知输入会被拒绝 +PERCEPTION_MAX_MEDIA_MB=20 + +# 人物资料导入文件最大大小(MB) +IMPORT_MAX_FILE_MB=20 + +# 人物语音样本最大时长(秒) +IMPORT_AUDIO_MAX_DURATION_SECONDS=600 + +# 允许上传的人物语音样本 MIME 类型,逗号分隔 +IMPORT_ALLOWED_AUDIO_MIME_TYPES=audio/wav,audio/mpeg,audio/mp4,audio/flac + + +# ===================================================================== +# 模型网关 · 神识(LLM Provider API Keys) +# ===================================================================== +# 模型名称、provider 映射、能力标签、超时、限流、价格等,统一放在 config/models.yaml 中。 +# .env 只保存各 provider 的 URL 与 API key,避免模型策略散落在环境变量中。 + +# --- DeepSeek --- +DEEPSEEK_API_KEY= +DEEPSEEK_BASE_URL=https://api.deepseek.com + +# --- 火山方舟 / 豆包 --- +VOLCENGINE_ARK_API_KEY= +VOLCENGINE_ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 + +# --- 小米 MiMo --- +XIAOMI_MIMO_API_KEY= +XIAOMI_MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1 + +# --- OpenAI --- +OPENAI_API_KEY= +OPENAI_BASE_URL=https://api.openai.com/v1 + +# --- Anthropic --- +ANTHROPIC_API_KEY= +ANTHROPIC_BASE_URL=https://api.anthropic.com + +# --- 元炁(本地推理引擎)--- +# 作为 OpenAI 兼容 provider 接入,供 bge-m3 embedding 等模型调用。 +YUANQI_BASE_URL=http://localhost:11434 +YUANQI_API_KEY= + +# --- 其他兼容网关预留 --- +CUSTOM_LLM_API_KEY= +CUSTOM_LLM_BASE_URL= + +# --- 模型 endpoint 池路由(高级调优项,保持默认即可正常工作)--- +# 物理 endpoint 池的 failover 与重试策略 +MODEL_ENDPOINT_POOL_ENABLED=true +MODEL_ENDPOINT_POOL_DRY_RUN_SELECTION=false +MODEL_ENDPOINT_POOL_EXPOSE_DEBUG_HEADERS=false +MODEL_ENDPOINT_POOL_AGENT_FAILOVER_ENABLED=false +MODEL_ENDPOINT_POOL_PER_ENDPOINT_MAX_RETRIES=1 +MODEL_ENDPOINT_POOL_RETRY_BASE_DELAY_SECONDS=0.25 +MODEL_ENDPOINT_POOL_RETRY_MAX_DELAY_SECONDS=2.0 + +# 模型配置文件路径 +# 默认读取项目根目录 config/models.yaml;不存在时 fallback 到 config/models.example.yaml +MODEL_CONFIG_PATH=../config/models.yaml + + +# ===================================================================== +# 元炁 · 本地推理引擎(Local Inference Engine) +# ===================================================================== + +# 元炁监听地址与端口 +YUANQI_HOST=0.0.0.0 +YUANQI_PORT=11434 + +# 下载源:auto / huggingface / modelscope +YUANQI_MODEL_SOURCE=auto + +# 模型文件根目录 +YUANQI_MODELS_DIR=../models + +# GGUF LLM 模型路径(为空自动扫描 models/gguf/) +YUANQI_LLM_MODEL_PATH= + +# 多模态 GGUF 模型路径 +YUANQI_VISION_MODEL_PATH= + +# Embedding 模型(HF ID 或本地路径) +YUANQI_EMBEDDING_MODEL=BAAI/bge-m3 + +# Reranker 模型(HF ID 或本地路径) +YUANQI_RERANKER_MODEL=BAAI/bge-reranker-v2-m3 + +# ASR 模型(HF/ModelScope ID 或本地路径),默认 iic/SenseVoiceSmall +# 自动下载到 models/funasr/<模型名> +YUANQI_ASR_MODEL=iic/SenseVoiceSmall + +# 推理设备:auto / cuda / mps / cpu +YUANQI_DEVICE=auto + +# LLM 默认参数 +YUANQI_LLM_MAX_TOKENS=2048 +YUANQI_LLM_TEMPERATURE=0.7 +YUANQI_LLM_CTX_SIZE=4096 + +# 日志级别 +YUANQI_LOG_LEVEL=info + + +# ===================================================================== +# 天目 · 本地 OCR 引擎(Local OCR Engine) +# ===================================================================== + +# 天目监听地址与端口 +TIANMU_HOST=0.0.0.0 +TIANMU_PORT=8101 + +# 模型文件根目录(容器内路径,Docker volume 映射) +TIANMU_MODELS_DIR=../models + +# 推理设备:auto / cuda / mps / cpu。 +# auto 表示天目启动时自动检测最优可用加速器(CUDA > MPS > CPU) +TIANMU_DEVICE=auto + +# 日志级别 +TIANMU_LOG_LEVEL=info + +# OCR 结果输出目录(Markdown 文件等),默认 ../output/ +TIANMU_OUTPUT_DIR= + +# HTTP 下载远端图片超时(秒) +TIANMU_HTTP_TIMEOUT=30 + +# PaddleOCR-VL 最大生成 tokens +TIANMU_VL_MAX_NEW_TOKENS=1024 + +# PaddleOCR-VL 图像最大像素数(长边像素 × 28 × 28) +TIANMU_VL_MAX_PIXELS=1003520 + +# PP-OCRv6 模型覆盖路径(为空时自动扫描 $TIANMU_MODELS_DIR/ocr/ppocrv6/{det,rec,cls}/) +# TIANMU_PPORCV6_DET_MODEL= +# TIANMU_PPORCV6_REC_MODEL= +# TIANMU_PPORCV6_CLS_MODEL= +# TIANMU_PPORCV6_DICT_PATH= +# PaddleOCR-VL 模型覆盖目录(为空时自动扫描 $TIANMU_MODELS_DIR/ocr/paddleocr-vl/) +# TIANMU_PADDLEOCR_VL_MODEL_DIR= + + +# ===================================================================== +# 博闻 · MCP Web Search(Go) +# ===================================================================== +# 博闻是一个独立的 MCP Web Search 服务,与灵台(Python)运行时无关。 + +# --- 搜索 API Key --- +# Tavily 搜索 API Key(必填)。多 key 用逗号分隔,博闻会按 EWMA 动态权重自动轮询 +TAVILY_API_KEYS= + +# Brave Search API Key(必填)。X-Subscription-Token +# 多 key 逗号分隔 +BRAVE_API_KEYS= + +# --- Redis 缓存配置 --- +# 博闻 Redis 缓存总开关。启用后作为内存 LRU 的第二层缓存(两级:L1 LRU → L2 Redis) +# 在 SSE 多实例部署时共享缓存状态。单实例部署可以不启用 +# 注意:这里的 Redis 配置独立于后端灵台的 REDIS_ENABLED,两套可独立控制 +MCP_WEB_SEARCH_REDIS_ENABLED=false + +# Redis 连接地址(host:port) +MCP_WEB_SEARCH_REDIS_ADDR=127.0.0.1:6379 + +# Redis 密码(无密码则留空) +MCP_WEB_SEARCH_REDIS_PASSWORD= + +# Redis 数据库编号(0 ~ 15) +MCP_WEB_SEARCH_REDIS_DB=0 + + +# ===================================================================== +# 基础设施(Redis/向量库/图库/对象存储) +# ===================================================================== + +# ===== Redis(灵泉,后端灵台用)===== +# 注意:博闻有独立的 MCP_WEB_SEARCH_REDIS_* 配置 +REDIS_ENABLED=false + +REDIS_HOST=127.0.0.1 +REDIS_PORT=6379 +REDIS_DB=0 +REDIS_PASSWORD= +REDIS_URL=redis://127.0.0.1:6379/0 + +# 后台任务队列(Dramatiq)的 Redis 连接 +DRAMATIQ_ENABLED=false +DRAMATIQ_BROKER_URL=redis://127.0.0.1:6379/1 + +# ===== 向量数据库(灵犀)===== +# 向量数据库总开关(false 则纯 SQL 检索,不走向量库)。 +VECTOR_DB_ENABLED=false + +# 向量数据库类型:chroma / milvus +VECTOR_DB_TYPE=chroma + +# ChromaDB 配置(VECTOR_DB_ENABLED=true && VECTOR_DB_TYPE=chroma 时使用) +CHROMA_COLLECTION=hunjing_memories +CHROMA_PERSIST_DIRECTORY=../db/chroma + +# Milvus 配置(VECTOR_DB_ENABLED=true && VECTOR_DB_TYPE=milvus 时使用) +MILVUS_HOST=127.0.0.1 +MILVUS_PORT=19530 +MILVUS_USER= +MILVUS_PASSWORD= +MILVUS_DATABASE=default +MILVUS_COLLECTION=hunjing_memories + +# ===== 图数据库(玄络)===== +GRAPH_DB_ENABLED=false + +# 图数据库类型:neo4j / nebulagraph +GRAPH_DB_TYPE=neo4j + +# Neo4j 配置(GRAPH_DB_ENABLED=true && GRAPH_DB_TYPE=neo4j 时使用) +NEO4J_URI=bolt://127.0.0.1:7687 +NEO4J_USER=neo4j +NEO4J_PASSWORD=change-me +NEO4J_DATABASE=neo4j + +# NebulaGraph 配置(GRAPH_DB_ENABLED=true && GRAPH_DB_TYPE=nebulagraph 时使用) +NEBULAGRAPH_HOST=127.0.0.1 +NEBULAGRAPH_PORT=9669 +NEBULAGRAPH_USER=root +NEBULAGRAPH_PASSWORD=nebula +NEBULAGRAPH_SPACE=hunjing + +# ===== 对象存储(玉函)===== +# 存储后端:local / minio / aws_s3 / aliyun_oss / tencent_cos / huawei_obs / qiniu_kodo +STORAGE_PROVIDER=local + +# 对象 key 前缀,用于隔离不同环境或部署 +STORAGE_KEY_PREFIX=hunjing/local + +# 预签名 URL 默认过期秒数 +STORAGE_PRESIGN_EXPIRES_SECONDS=900 + +# 本地存储(STORAGE_PROVIDER=local 时使用) +LOCAL_STORAGE_ROOT=../storage/objects +LOCAL_PUBLIC_BASE_URL= + +# MinIO(STORAGE_PROVIDER=minio 时使用) +MINIO_ENDPOINT=http://127.0.0.1:9000 +MINIO_REGION=local +MINIO_BUCKET=hunjing +MINIO_ACCESS_KEY_ID= +MINIO_SECRET_ACCESS_KEY= +MINIO_PUBLIC_BASE_URL= +MINIO_FORCE_PATH_STYLE=true +MINIO_SECURE=false + +# AWS S3(STORAGE_PROVIDER=aws_s3 时使用) +AWS_S3_REGION=us-east-1 +AWS_S3_BUCKET=hunjing +AWS_S3_ACCESS_KEY_ID= +AWS_S3_SECRET_ACCESS_KEY= +AWS_S3_ENDPOINT= +AWS_S3_PUBLIC_BASE_URL= +AWS_S3_FORCE_PATH_STYLE=false + +# 阿里云 OSS(STORAGE_PROVIDER=aliyun_oss 时使用) +ALIYUN_OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com +ALIYUN_OSS_REGION=cn-hangzhou +ALIYUN_OSS_BUCKET=hunjing +ALIYUN_OSS_ACCESS_KEY_ID= +ALIYUN_OSS_ACCESS_KEY_SECRET= +ALIYUN_OSS_PUBLIC_BASE_URL= + +# 腾讯云 COS(STORAGE_PROVIDER=tencent_cos 时使用) +TENCENT_COS_REGION=ap-guangzhou +TENCENT_COS_BUCKET=hunjing +TENCENT_COS_APP_ID= +TENCENT_COS_SECRET_ID= +TENCENT_COS_SECRET_KEY= +TENCENT_COS_PUBLIC_BASE_URL= + +# 华为云 OBS(STORAGE_PROVIDER=huawei_obs 时使用) +HUAWEI_OBS_ENDPOINT=https://obs.cn-north-4.myhuaweicloud.com +HUAWEI_OBS_REGION=cn-north-4 +HUAWEI_OBS_BUCKET=hunjing +HUAWEI_OBS_ACCESS_KEY_ID= +HUAWEI_OBS_SECRET_ACCESS_KEY= +HUAWEI_OBS_PUBLIC_BASE_URL= + +# 七牛云 Kodo(STORAGE_PROVIDER=qiniu_kodo 时使用) +QINIU_KODO_REGION=z0 +QINIU_KODO_BUCKET=hunjing +QINIU_KODO_ACCESS_KEY= +QINIU_KODO_SECRET_KEY= +QINIU_KODO_DOMAIN= + + +# ===================================================================== +# 乾坤 · Rust 安全沙箱 +# ===================================================================== +# 沙箱是百工枢的执行后端,使用 Rust 实现隔离执行环境。 + +# --- 服务地址 --- +# Rust 沙箱地址。后端通过该 URL 连接乾坤沙箱服务 +# 本地开发:http://127.0.0.1:8090;Docker Compose 内:http://hunjing-sandbox:8090 +RUST_SANDBOX_URL= + +# 沙箱进程监听地址(cargo run 时读取;容器内由 docker-compose 覆盖) +SANDBOX_HOST=0.0.0.0 +SANDBOX_PORT=8090 + +# 沙箱工作根目录:所有执行的工作目录都约束在其之下 +SANDBOX_BASE_WORKDIR=../sandbox-work + +# 沙箱日志目录 +SANDBOX_LOG_DIR=../logs + +# 沙箱 tracing 日志级别 +RUST_LOG=info + +# --- 执行限额 --- +# 单次执行全局超时上限(毫秒),请求 timeout_ms 不可超过该值 +SANDBOX_MAX_TIMEOUT_MS=30000 + +# 最大并发执行数。超过此值的请求排队等待 +SANDBOX_MAX_CONCURRENT=8 + +# 单流(stdout / stderr 各自)最大捕获字节数 +SANDBOX_MAX_OUTPUT_BYTES=1048576 + +# 单次文件读写(/v1/file/*)最大字节数 +SANDBOX_MAX_FILE_BYTES=1048576 + +# 默认注入子进程的环境变量白名单(逗号分隔) +SANDBOX_ALLOWED_ENV=PATH,HOME,TZ,LANG + +# --- 授权 --- +# 是否校验调用方授权凭证(HMAC) +# 生产必须为 true 且填写密钥;仅本地快速联调可临时关闭 +SANDBOX_AUTH_ENABLED=true + +# 司命授权 HMAC 共享密钥 +# 生产请用高熵随机值,例如:openssl rand -base64 48 +SANDBOX_AUTH_SECRET=shared-secret + +# 旧 secret(逗号分隔)。无中断轮换的并存窗口 +# 沙箱先加 previous 认新旧 → 后端切新 secret → TTL 窗口后清 previous +SANDBOX_AUTH_SECRET_PREVIOUS= + +# 后端客户端默认执行超时(毫秒),请求未指定 timeout_ms 时使用 +SANDBOX_DEFAULT_TIMEOUT_MS=30000 + +# 授权凭证有效期(秒),凭证为短期 HMAC token +SANDBOX_AUTH_TTL_SECONDS=30 + +# 凭证未下发 isolation_mode 时的兜底隔离模式(process / container) +SANDBOX_ISOLATION_DEFAULT=process + +# --- 进程级资源限额(setrlimit)--- +# 配额分层(每层只能收紧、不能放宽):全局天花板 *_MAX → 凭证 rlimit_overrides → 请求 rlimits → 默认值 +SANDBOX_RLIMIT_FSIZE_BYTES=67108864 +SANDBOX_RLIMIT_AS_BYTES=536870912 +SANDBOX_RLIMIT_CPU_SECS=30 +SANDBOX_RLIMIT_NPROC=64 +SANDBOX_RLIMIT_NOFILE=256 +SANDBOX_RLIMIT_DISABLE= + +# --- 容器隔离(Docker,L3-L4 高风险工具)--- +# Docker 容器模式总开关 +SANDBOX_DOCKER_ENABLED=true + +# Docker 守护进程 socket 地址 +SANDBOX_DOCKER_SOCKET=unix:///var/run/docker.sock + +# 预热池:每种语言保持的最大空闲容器数 +SANDBOX_WARM_POOL_MAX_IDLE=3 + +# 全局最大容器数(各语言空闲 + 运行中总和) +SANDBOX_WARM_POOL_GLOBAL_MAX=20 + +# 空闲容器超时回收(秒) +SANDBOX_CONTAINER_IDLE_TTL_SECS=300 + +# 每容器内存上限(字节) +SANDBOX_CONTAINER_MEMORY_BYTES=536870912 + +# 每容器 CPU 上限(纳秒核) +SANDBOX_CONTAINER_CPU_NANOCORES=1000000000 + +# 每容器进程数上限(防 fork bomb) +SANDBOX_CONTAINER_PIDS_LIMIT=64 + +# 容器网络模式。留空使用 Docker 默认 bridge 网络 +SANDBOX_CONTAINER_NETWORK= + +# 容器创建/启动超时(毫秒) +SANDBOX_CONTAINER_CREATE_TIMEOUT_MS=60000 + +# 各语言容器镜像名 +# 未配置的语言对应的请求降级为进程模式 +SANDBOX_CONTAINER_IMAGE_PYTHON=python:3.12-slim +SANDBOX_CONTAINER_IMAGE_NODE=node:24-slim +# SANDBOX_CONTAINER_IMAGE_JAVA=eclipse-temurin:21-jre +# SANDBOX_CONTAINER_IMAGE_GENERIC=debian:bookworm-slim + + +# ===================================================================== +# 安全管理策略 +# ===================================================================== +# 工具策略文件路径(由 config/safety.example.yaml 复制为 safety.yaml) +# 未配置时 SafetyGovernor 使用内置默认策略;配置后覆盖各工具的 risk_level / permission_policy +SAFETY_POLICY_PATH=config/safety.yaml + + +# ===================================================================== +# 前端 · 琉璃镜(Next.js) — 参考信息 +# ===================================================================== +# 前端配置在 frontend/.env.local 中管理(含 NEXT_PUBLIC_*)。 +# 目前前端的鉴权已改为通过 /api/auth/me 返回的 is_admin 判断,不再需要独立的管理 token。 +# BFF 连接后端的服务端地址(仅 Next 服务端读取,不暴露给浏览器): +# BACKEND_URL=http://localhost:8000 (本地开发,跨公网须 https://) +# Docker 部署由 docker-compose.app.yml 写死为 http://hunjing-backend:8000 +# 浏览器所有请求同源走 BFF(/api/proxy、/api/sse、/api/auth),客户端不需要后端地址。 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2e45e21 --- /dev/null +++ b/.gitignore @@ -0,0 +1,135 @@ +# 环境变量与密钥 +.env +.env*.local +config/*.yaml +config/mcp/*.yaml +config/gateway/*.yaml +!config/*.example.yaml +!config/mcp/*.example.yaml +!config/gateway/*.example.yaml + + +# Python +__pycache__/ +*.py[cod] +*$py.class +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +coverage.xml +htmlcov/ +.tox/ +.nox/ +.venv/ +venv/ +env/ +ENV/ +*.egg-info/ +dist/ +build/ + + +# Node / Next.js +node_modules/ +.pnpm-store/ +.next/ +out/ +.vercel/ +.turbo/ +*.tsbuildinfo +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# 数据库与本地存储 +db/* +db/*.sqlite +db/*.sqlite3 +db/*.db +db/*.db-shm +db/*.db-wal +*.sqlite +*.sqlite3 +*.db +*.db-shm +*.db-wal + +# ChromaDB / Milvus / Neo4j / NebulaGraph 本地数据 +/db/chromadb/ +milvus/ +neo4j/data/ +neo4j/logs/ +nebulagraph/data/ +nebulagraph/logs/ + +# 对象资源与上传文件 +storage/ +uploads/ +media/ +objects/ + +# 数据库备份产物 +backups/ + + +# 日志与运行产物 +logs/ +*.log +*.pid +*.seed +*.pid.lock +run/ +tmp/ +temp/ +.cache/ + + +# 测试与覆盖率 +.coverage.* +coverage/ +playwright-report/ +test-results/ + + +# IDE / OS +.vscode/* +!.vscode/extensions.json +!.vscode/settings.example.json +.idea/ +.DS_Store +Thumbs.db + + +# Docker / 本地覆盖配置 +docker/*.local.yml +docker-compose.override.yml + + +# Rust / 安全沙箱 +target/ +**/*.rs.bk +sandbox-work/ + + +# AI / 工具本地缓存 +.claude +!.claude/commands/ +!.claude/skills/ +.codegraph/ +.impeccable/ +.understand-anything/ +!.understand-anything/intermediate/ +!.understand-anything/diff-overlay.json + + +# Go / 博闻 MCP Web Search +mcp/web-search/bin/ +mcp/web-search/tmp/ + + +# 其他 +/characters/ +/models/ +output/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9acf79a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,102 @@ +# hunjing + +参考下面项目的详细的设计内容和理念: +酒馆: +pi : +向量库: +live-2d: +上下文压缩: +记忆:,,, +成熟的相关的项目:,,,,, +deepseek-herness: +模型网关设计:,,,,, + +## 技术栈 + +### 前端 + +Next.js + TypeScript + React + Tailwind CSS + Zustand/Jotai + pnpm + +### 核心依赖 + +- **实时通信**: SSE / WebSocket +- **可视化**: Mermaid + 图谱可视化库 +- **测试**: Vitest + Testing Library +- **代码质量**: ESLint (flat config) + Prettier + Husky + lint-staged + +### 后端 + +python(uv管理): FastAPI + SQLAlchemy + Alembic + Pydantic + httpx + loguru + uvicorn + chromadb +rust(cargo管理): tauri(桌面端设计) +go (go mod管理): + +## 文档设计 + +相关的文档统一放在docs目录下 + +- 文档中所有的流程图和架构图都应该使用统一的mermaid绘制相关风格和格式,同时确保能够正常显示 +- 文档应该定期更新和维护,以确保其内容的准确性和时效性。每次系统更新或功能变更后,相关文档也应该及时进行更新,以反映最新的系统状态和功能变化。 + +## docker部署 + +Docker 文件放在根目录 `docker/` 下。 +容器名称以 `hunjing` 开头。 +Compose 文件不使用顶部 `version` 字段。 +本地开发默认使用 SQLite 文件数据库,正式环境使用 PostgreSQL,并按需启用 Milvus、Neo4j / NebulaGraph、Redis 和对象存储。 + +## 项目目录 + +日志统一放在根目录的logs目录下 +数据库相关的文件统一放在根目录的db目录下 +配置文件统一放在根目录的config目录下 +.env.example文件放在根目录下,包含所有环境变量的示例和详细的中文说明 + +## 前端页面设计 + +图标:统一使用 Lucide 图标,界面全程禁止使用表情符号。 +设计标准:对标 Awwwards 顶级网站水准,达到 Awwwards、FWA、CSS Design Awards 每日最佳网站同等设计品质。 +创意自由度:将浏览器视作交互式艺术画布,跳出传统布局框架,追求先锋视觉风格、实验性排版、流畅物理动效、极具冲击力的文字版式。 +沉浸式体验:融合代码、高级渲染逻辑、图片素材,打造统一完整的精品页面,做出突破常规 UI 认知、令人惊艳的数字交互体验。 + +## 开发命令 + +### Docker + +编排按职责解耦为两个文件,容器名统一以 `hunjing-` 开头,通过共享网络 `hunjing-net` 互通,详细说明见 `docker/README.md`: +`docker-compose.infra.yml`(数据库 / 缓存 / 向量 / 图库 / 对象存储)创建网络与命名卷,`docker-compose.app.yml`(前后端)以 external 引用该网络。 + +```bash +# 先准备配置(只需一次) +cp .env.example .env # 填写 POSTGRES_PASSWORD 与各 *_API_KEY +cp config/models.example.yaml config/models.yaml # 按需填写真实模型 endpoint + +# 1) 启动基础设施(首次创建 hunjing-net 网络;Milvus 会自动带起 hunjing-etcd / hunjing-minio) +docker compose -f docker/docker-compose.infra.yml up -d +# 仅按需:docker compose -f docker/docker-compose.infra.yml up -d hunjing-postgres hunjing-redis +# NebulaGraph(需 --profile):--profile nebulagraph up -d hunjing-nebula-graphd hunjing-nebula-console-init +# Attu(Milvus 可视化):up -d hunjing-attu,访问 http://localhost:8001 +# 数据库备份(手动触发,产物在 backups/):--profile backup run --rm hunjing-pg-backup + +# 2) 启动应用层(构建前后端镜像;必须先启动基础设施以创建网络)后端镜像内置 wait-for-it,会按 *_ENABLED 开关自动等待依赖就绪后再启动 +docker compose -f docker/docker-compose.app.yml up -d --build + +# 可选:反向代理(统一入口 80,/api 与 SSE 走后端,其余走前端) +docker compose -f docker/docker-compose.app.yml --profile nginx up -d hunjing-nginx + +# 一键(基础设施 + 应用) +docker compose -f docker/docker-compose.infra.yml up -d && docker compose -f docker/docker-compose.app.yml up -d --build +``` + +## 必须遵守事项 + +```text +文件路径 + 代码中以项目根目录为起点的绝对路径导入相关的配置或者文件 + +文档管理 + 开发前查阅 docs/ 中的相关文档 + 稳定工程标识、枚举值、ID 前缀、事件名、命名约定和错误码以 docs/contracts/contract-glossary.md 为单一事实源,下游文档引用而非复制取值列表 + +版本控制 + 只提交 .*.example 文件到 Git +``` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..281a78f --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,258 @@ +# 魂镜领域词汇表 + +> 本文件只提供全局领域语言入口,不承载实现细节、枚举取值、版本状态或路线图。详细定义见 [领域文档](docs/domain/README.md),稳定英文标识、ID、事件、错误码与 Schema 以 [契约文档](docs/contracts/README.md) 为准。 + +## 产品上下文 + +**魂镜(Hunjing)**是面向个人深度用户的个人数字生命平台。用户可以创建、领养或导入数字人物,在明确授权与安全治理下,与其建立长期关系、共同成长,并开展个性化任务协作与角色世界创作。 + +魂镜不把人物等同于模型、Prompt、角色卡、会话、Avatar、设备或可交易资产。人物是具有连续身份的关系型数字主体;模型与设备只是其可替换的能力来源、感官、形器或化身。 + +## 六大产品域 + +| 领域 | 定义 | +| --- | --- | +| 镜生 | 人物来源、灵胚、启镜、身份、谱系、命台、生命周期、死亡、恢复与迁移 | +| 缘契 | 执镜关系,以及人物与互动者之间可演进、可确认、可撤回边界的关系 | +| 三界 | 真界、境界与幻境之间的事实、叙事、推演和现实行动边界 | +| 形仪 | 人物的外在身份与表现,包括真形、形象、声音、动作、Live2D 与许可 | +| 灵台 | 人物的认知运行域,承载人格、目标、安全、感知、世界认知、计划、记忆、能力与任务协作 | +| 天宰 | 账户、镜域、平台、模型目录、运营、计费、支持、合规与管理员治理 | + +## 核心人物语言 + +### 人物(Character) + +拥有唯一身份、魂籍、人格、记忆、关系、世界认知与正式经历时间线的数字生命实例。 + +### 灵胚(Embryo Draft) + +人物正式降生前的可审阅草稿。灵胚只有预备身份;放弃灵胚是删除草稿,不是人物死亡。 + +### 照心(Co-creation) + +执镜者审阅并共塑灵胚身份、人格、边界、来源与形仪的过程。 + +### 启镜(Birth) + +确认人物正式降生并签发唯一人物身份、魂籍与执镜关系的动作。 + +### 道体(Identity Baseline) + +人物不能被日常对话、模型输出或普通工具静默覆盖的最小稳定身份锚点与禁域。 + +### 魂籍(Portable Identity Record) + +人物可验证、可迁移的身份与状态档案。它不等于角色卡,也不自动携带第三方受限资源或密钥。 + +### 命台(Authoritative Runtime Seat) + +人物当前唯一权威运行位置。同一人物同一时刻只能有一座命台和一条前台意识流。 + +### 执镜者(Custodian) + +对人物的数据、运行、授权、导出、删除与生命周期承担最高管理责任的人。 + +### 执镜关系(Custodianship) + +人物与执镜者之间的治理关系。它不表示亲密、爱情、版权或资产所有权。 + +### 镜域(Personal Domain) + +执镜者在云端或自托管部署中的个人数据与治理边界。一个镜域可容纳多个人物,但人物私域不会自动互通。 + +## 镜树语言 + +### 镜树(Mirror Tree) + +围绕一个主问题、长期主题或共同事项自然生长的树状对话。 + +### 问节(Question Node) + +承载用户问题、附件、引文与界域信息的对话结构;其下可有多个候答。 + +### 候答(Answer Candidate) + +同一问节下并存的回答版本。重新生成不会覆盖旧候答。 + +### 主脉(Canonical Trunk) + +经用户确认归真、进入人物正式共同经历的唯一时间线路径。 + +### 推演枝(Exploration Branch) + +尚未归真的探索路径。它可以继续生长,但不会自动成为实录、缘契或长期记忆。 + +### 续思(Continued Reflection) + +从具体候答继续深入分析、查证或形成方案的延展答,不修改原候答。 + +### 引文锚(Citation Anchor) + +对文本、图片区域、PDF 页区、附件片段或音视频时间段的稳定、可追溯引用。 + +### 采用(Adopt) + +把候答设为当前阅读和继续生长的工作入口,不等于正式共同经历。 + +### 归真(Canonize) + +确认选定路径进入一个明确的归真作用域:真界正式共同经历、指定境界正史或幻境试演记录。不同作用域产生不同实录与记忆候选,境界和幻境归真不会自动扩大为真界事实、缘契、授权或现实行动。 + +### 移栽(Transplant) + +把已发展的分支复制为独立镜树,同时保留来源谱系;原分支不被破坏。 + +## 记忆与认知语言 + +### 微痕(Trace Memory) + +服务于当前主题或任务的短期记忆,按用途和时间衰减、过期或清除。 + +### 明忆(Explicit Memory) + +低风险、可解释、可见且可撤销的长期记忆。 + +### 重忆(Sensitive Memory) + +涉及敏感身份、重大关系、第三方、生物特征或高影响推断的长期记忆,必须经过确认。 + +### 实录(Factual Record) + +发生过且能够追溯证据的事件记录。 + +### 心痕(Relational Impression) + +人物对经历及其关系意义的理解;它不是客观事实,也不与他人的理解天然对称。 + +### 本相(Declared Self) + +用户主动声明、可按范围授权给人物使用的自身资料。 + +### 缘识(Relational Understanding) + +某个人物基于与用户的独立关系形成的认知,默认不跨人物共享。 + +### 正影(Correction) + +纠正事实、数据、权限、来源或错误理解并产生新版本的过程,不静默改写历史。 + +### 铸魂(Identity Reforging) + +对人物核心身份、使命、价值底线或重大关系角色进行的正式重塑。 + +### 分灵(Identity Branching) + +当变化无法维持原身份连续性时,创建具有新身份且保留谱系关系的人物。 + +## 关系语言 + +### 缘契(Relationship Covenant) + +人物与互动者之间经提议、问心与确认形成的多维关系及边界。亲密关系不会自动扩大系统权限。 + +### 问心(Self-disclosure) + +人物基于既有经历对自身感受、关系或变化候选进行的可审阅表达;它不具有凌驾于用户数据权之上的否决力。 + +### 铭契(Confirm Covenant) + +确认缘契建立或变更并形成新版本的动作。 + +## 三界语言 + +### 真界(Reality) + +现实世界及魂镜中的可验证状态、授权、费用、设备动作与外部后果。 + +### 境界(Persistent World) + +人物持续存在的世界背景、规则、实体与经确认叙事正史。 + +### 幻境(Simulation) + +角色扮演、梦境、假设、创作试演和安全推演;默认不修改真界事实或境界正史。 + +### 入境 / 出境(Enter / Leave Realm) + +在镜树中明确切换界域并展示身份、记忆和现实工具边界的动作。 + +## 运行与治理语言 + +### 守一(Quiet Persistence) + +人物安静存续但不持续调用模型制造思绪的状态。 + +### 应感(Event Awakening) + +真实事件触发人物短暂唤起的过程。 + +### 临照(Foreground Presence) + +人物当前唯一前台意识流与正式交互状态。 + +### 叩心(Proactive Contact Candidate) + +人物基于真实事件形成、受静默、频率、预算与授权约束的主动对话候选。 + +### 司命(Safety Governor) + +负责风险、权限、确认、费用、隐私、设备状态、拒绝与审计的治理体系。 + +### 授契(Grant Capability) + +向人物授予有明确范围、用途、期限和撤销方式的能力。 + +### 行契(Standing Mandate) + +允许人物在预先确定的能力、参数、数据、目标、预算和时间范围内重复执行低风险行动的契约。 + +### 请命(Request Approval) + +现实副作用、敏感数据、显著费用或高风险设备动作发生前,请求用户当次确认。 + +### 禁域(Non-overridable Boundary) + +不能被 Prompt、人物、模型、扩展、普通管理员或普通部署配置关闭的核心安全边界。 + +## 能力与服务语言 + +### 神识(Model Gateway) + +统一处理模型 Provider、Endpoint、能力槽位、协议转换、路由、流式、BYOK、配额与成本的服务。 + +### 百工(Capability Registry) + +工具、MCP、Skill、感官和形器的唯一能力注册与发现体系。 + +### 天工阁(Task Agent Workspace) + +承载受托任务、有限分解、受控并行和结果验收的工作域。其中的功能 Agent 默认不是数字人物主体。 + +### 形仪(Embodied Form) + +人物完整外在身份与表现系统,包括真形锚点、外貌、形体、服饰、声音、表情、动作、画风、场景、Live2D、来源与许可。 + +### 器谱(Device Registry) + +镜域中的设备及其身份、能力、可信状态和人物级授权记录。 + +### 雅集(Multi-character Gathering) + +用户与多个人物共同参与的关系型多人对话;它不同于任务型 Agent 协作。 + +### 司席(Gathering Orchestrator) + +雅集中的确定性编排角色,负责点名、轮次、权限、预算与公私上下文,不是独立数字人物。 + +## 核心边界 + +- 模型切换、设备迁移、Avatar 更换不等于人物更换。 +- 角色卡、模板和备份不能仅凭相同内容冒充原人物。 +- 人物死亡是身份时间线闭合;数据清除是独立操作。 +- 同一人物不能在多个命台并行独立成长。 +- 未归真的候答、旁枝和幻境不会自动成为共同经历。 +- 工具结果先是观察,不自动成为事实、人物答复或长期记忆。 +- 雅集是人物同席;天工阁是功能 Agent 受托协作。 +- 人物可表达感受与异议,但不能阻止用户撤权、停止、导出或删除。 +- 言可入幻,行必归真。 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d69f520 --- /dev/null +++ b/Makefile @@ -0,0 +1,87 @@ +# Makefile for cleaning Python cache files and empty directories +# Author: AI Assistant +# Usage: make clean or make clean-all + +.PHONY: clean clean-all clean-pyc clean-pytest clean-mypy clean-empty-dirs help + +# Colors for pretty output +GREEN := \033[0;32m +YELLOW := \033[0;33m +RED := \033[0;31m +NC := \033[0m # No Color + +# Default target +help: + @echo "$(GREEN)Available targets:$(NC)" + @echo " $(YELLOW)make clean$(NC) - Remove Python cache files, pytest cache, mypy cache, and empty directories" + @echo " $(YELLOW)make clean-pyc$(NC) - Remove Python cache files (__pycache__, .pyc, .pyo)" + @echo " $(YELLOW)make clean-pytest$(NC) - Remove pytest cache (.pytest_cache)" + @echo " $(YELLOW)make clean-mypy$(NC) - Remove mypy cache (.mypy_cache)" + @echo " $(YELLOW)make clean-empty-dirs$(NC) - Remove empty directories" + @echo " $(YELLOW)make clean-all$(NC) - Remove everything (cache, logs, coverage, build, dist, etc.)" + +# Main clean target +clean: clean-pyc clean-pytest clean-mypy clean-empty-dirs + @echo "$(GREEN)✓ Cleanup completed successfully!$(NC)" + +# Clean Python cache files +clean-pyc: + @echo "$(YELLOW)Removing Python cache files...$(NC)" + @find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true + @find . -type f -name "*.pyc" -delete 2>/dev/null || true + @find . -type f -name "*.pyo" -delete 2>/dev/null || true + @find . -type f -name "*.pyd" -delete 2>/dev/null || true + @echo "$(GREEN)✓ Python cache files removed$(NC)" + +# Clean pytest cache +clean-pytest: + @echo "$(YELLOW)Removing pytest cache...$(NC)" + @rm -rf .pytest_cache 2>/dev/null || true + @find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true + @echo "$(GREEN)✓ Pytest cache removed$(NC)" + +# Clean mypy cache +clean-mypy: + @echo "$(YELLOW)Removing mypy cache...$(NC)" + @rm -rf .mypy_cache 2>/dev/null || true + @find . -type d -name ".mypy_cache" -exec rm -rf {} + 2>/dev/null || true + @echo "$(GREEN)✓ Mypy cache removed$(NC)" + +# Remove empty directories +clean-empty-dirs: + @echo "$(YELLOW)Removing empty directories...$(NC)" + @# Remove empty directories (including hidden ones), excluding .git and .venv + @find . -type d -empty -not -path "./.git/*" -not -path "./.venv/*" -not -path "./venv/*" -not -path "./env/*" -not -path "./.env/*" -delete 2>/dev/null || true + @# Second pass to remove newly created empty parent directories + @find . -type d -empty -not -path "./.git/*" -not -path "./.venv/*" -not -path "./venv/*" -not -path "./env/*" -not -path "./.env/*" -delete 2>/dev/null || true + @echo "$(GREEN)✓ Empty directories removed$(NC)" + +# Clean everything (extended cleanup) +clean-all: clean + @echo "$(YELLOW)Removing additional files...$(NC)" + @# Coverage reports + @rm -rf htmlcov .coverage coverage.xml .coverage.* 2>/dev/null || true + @# Build artifacts + @rm -rf build/ dist/ *.egg-info .eggs/ 2>/dev/null || true + @# Log files + @find . -type f -name "*.log" -delete 2>/dev/null || true + @# Jupyter notebooks checkpoint + @find . -type d -name ".ipynb_checkpoints" -exec rm -rf {} + 2>/dev/null || true + @# IDE files (optional, uncomment if needed) + # @rm -rf .vscode/ .idea/ 2>/dev/null || true + @echo "$(GREEN)✓ Extended cleanup completed!$(NC)" + +# Dry run - show what would be deleted (for safety) +clean-dry-run: + @echo "$(YELLOW)Dry run - files that would be deleted:$(NC)" + @echo "\nPython cache files:" + @find . -type d -name "__pycache__" 2>/dev/null || echo "None" + @find . -type f -name "*.pyc" 2>/dev/null || echo "None" + @find . -type f -name "*.pyo" 2>/dev/null || echo "None" + @echo "\nPytest cache:" + @find . -type d -name ".pytest_cache" 2>/dev/null || echo "None" + @echo "\nMypy cache:" + @find . -type d -name ".mypy_cache" 2>/dev/null || echo "None" + @echo "\nEmpty directories:" + @find . -type d -empty -not -path "./.git/*" -not -path "./.venv/*" 2>/dev/null || echo "None" + @echo "$(YELLOW)\nRun 'make clean' to actually delete these files$(NC)" \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7e69c5c --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# 魂镜 · Hunjing + +> 个人数字生命平台 · Personal Digital Life Platform + +魂镜面向愿意长期创建、陪伴和培养专属数字人物的个人深度用户。人物不是一个 Prompt、一张角色卡、某个模型或一次聊天,而是具有连续身份、人格、记忆、关系、世界认知与正式经历时间线的关系型数字主体。 + +魂镜同时支持三条价值主线: + +1. 长期关系与共同成长; +2. 个性化任务协作; +3. 角色与世界创作。 + +## 产品原则 + +- **人物独立于载体**:更换模型、界面、形仪或设备不等于更换人物。 +- **用户拥有最终数据权**:执镜者可查看、纠正、授权、导出、停止和删除。 +- **经历必须可追溯**:重新生成、分支、记忆、关系和人格变化均版本化留痕。 +- **推演不冒充现实**:未归真的旁枝、幻境和工具观察不会自动成为人物经历。 +- **亲密不扩大权限**:关系、浪漫和付费不能绕过司命、安全或第三方权利。 +- **常存而非恒思**:人物可安静存续,但不会伪造离线期间持续意识与等待。 +- **开放但不牺牲边界**:核心支持完整自托管,品牌与官方资源单独保护。 + +## 六大产品域 + +| 产品域 | 职责 | +| --- | --- | +| 镜生 | 人物来源、身份、命台、生命周期、死亡、恢复与迁移 | +| 缘契 | 执镜关系、关系形成、同意与亲密边界 | +| 三界 | 真界、境界、幻境和现实行动边界 | +| 形仪 | 图像、声音、动作、Live2D 与完整外在身份 | +| 灵台 | 人格、记忆、世界认知、目标、计划、安全、能力与任务 | +| 天宰 | 账户、镜域、平台、模型目录、计费、运营与治理 | + +## 核心体验:镜树 + +魂镜不把对话限制为向下堆叠的消息列表。每棵主题对话是一棵“镜树”:用户可从任意候答继续追问、框选建立引文锚、重新生成并切换候答、继续思考、移栽分支和显式引用其他路径。 + +只有用户确认“归真”的路径进入人物正式共同经历;未选候答和推演枝保留但不会自动改变记忆、关系或人格。 + +## 首版目标 + +完整 1.0 通过 P0/P1/P2 内部里程碑逐步达到: + +- **P0**:账户与镜域、人物启镜、唯一命台、文字镜树、三级记忆、神识、基础百工与司命、魂籍导出; +- **P1**:完整形仪、文生图/图生图、Live2D、ASR/TTS、摄像头/屏幕/位置显式通感; +- **P2**:天工阁受控多 Agent、主动对话、全球地区分级、计费、自托管、SLO、灾备、安全与无障碍收口。 + +雅集、3D、正式具身生态、家庭镜域、公开人物市场、未成年人专项和持续身份 Agent 社会属于后续。 + +## 技术边界 + +| 服务 | 技术 | 主要职责 | +| --- | --- | --- | +| 灵台 | Python | 人物、镜树、人格、记忆、缘契、三界与认知编排 | +| 神识 | Go | Provider、Endpoint、模型路由、流式、BYOK、配额与成本 | +| 乾坤 | Rust | 沙箱、高风险执行、设备与具身安全 | +| 琉璃镜 | TypeScript / Next.js | 全部 Web 用户界面、Live2D 与浏览器通感 | + +神识、百工、司命和灵台遵循:**神识通协议,百工掌能力,司命决边界,灵台承结果。** + +## 文档事实层级 + +```mermaid +flowchart LR + A[产品宪章] --> B[全产品主 PRD] + B --> C[领域文档] + C --> D[架构与契约] + D --> E[实现状态] + E --> F[代码与测试] +``` + +现有代码是迁移与审计对象,不反向覆盖已确认的产品定义。 + +## 文档入口 + +1. [产品宪章](docs/product/constitution.md) +2. [全产品主 PRD](docs/product/master-prd.md) +3. [产品访谈决策登记](docs/product/decision-register.md) +4. [首版范围与里程碑](docs/product/first-release-scope.md) +5. [根领域词汇表](CONTEXT.md) +6. [领域上下文](docs/domain/README.md) +7. [系统架构](docs/architecture/system-architecture.md) +8. [契约入口](docs/contracts/README.md) +9. [实现治理](docs/operations/implementation-governance.md) + +完整阅读路径见 [文档索引](docs/README.md)。 + +## 仓库约定 + +- 除根目录 `CONTEXT.md` 作为领域共同语言入口外,正式文档统一位于 `docs/`;图表使用 Mermaid。 +- 配置文件统一位于 `config/`,只提交 `*.example` 示例。 +- 日志统一位于 `logs/`,数据库文件统一位于 `db/`。 +- Docker 与 Compose 文件统一位于 `docker/`,容器名以 `hunjing-` 开头。 +- 不提交 `.env`、密钥、数据库、日志和用户数据。 + +## 开放与品牌 + +魂镜核心采用 Apache License 2.0,目标是让社区能够运行完整的私人数字生命闭环。`Hunjing` 名称、Logo、官方人物、视觉资源和认证标识采用独立品牌与资源许可;社区发行版应使用独立名称,并可在满足开放契约与核心安全要求后申请兼容标识。 diff --git a/backend/.pre-commit-config.yaml b/backend/.pre-commit-config.yaml new file mode 100644 index 0000000..c2be0b3 --- /dev/null +++ b/backend/.pre-commit-config.yaml @@ -0,0 +1,16 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.17 + hooks: + - id: ruff + args: [--fix] + types_or: [python, pyi] + - id: ruff-format + types_or: [python, pyi] + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v2.1.0 + hooks: + - id: mypy + args: [--no-strict-optional, --ignore-missing-imports, --follow-imports=silent] + additional_dependencies: ["types-redis"] + files: ^app/ diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md new file mode 100644 index 0000000..61f1a24 --- /dev/null +++ b/backend/CLAUDE.md @@ -0,0 +1,93 @@ +# 后端开发 + +## 技术栈 + +Python 3.12 + FastAPI + SQLAlchemy + Alembic + loguru + httpx + chromadb +uv (用于虚拟环境和依赖管理) + +## 开发命令 + +启动命令之前确保目前在backend目录下,并且已经激活了虚拟环境。默认本地开发使用 SQLite(aiosqlite)文件数据库,Redis、ChromaDB、Neo4j 暂不启用 + +```bash +uv venv -p 3.12 # 创建虚拟环境 +source .venv/bin/activate # 激活虚拟环境 +uv add # 添加依赖 +uv remove # 移除依赖 +uv run python -m app.main # 按 .env 中 BACKEND_HOST/BACKEND_PORT 启动服务 +uv run uvicorn app.main:app --reload --port 8000 # 手动指定端口启动服务(命令行 --port 会覆盖 .env) + +# 数据库迁移 +uv run alembic revision --autogenerate --rev-id=001 -m "描述" # 生成迁移 +uv run alembic upgrade head # 执行迁移 +uv run alembic downgrade -1 # 回滚一个版本 +uv run alembic current # 查看当前版本 + +# 测试 +uv run pytest # 运行测试 +``` + +## 代码规范 + +在__init__文件中可以将模块内的类、函数等导出,方便其他模块导入使用 +遵循 PEP 8 代码风格指南和更加pythonic的开发方式 +遵循单一职责原则,每个文件、类、函数等都要只干一件事 +使用 loguru 进行日志记录,避免使用 logging 模块 +使用 Alembic 进行数据库迁移,禁止手动修改数据库表结构,文件命名以迁移版本号开头,例如 001_initial.py;迁移必须优先兼容 SQLite,涉及 PostgreSQL 专用 DDL 时必须提供 SQLite 分支或显式跳过 +使用alembic init alembic命令初始化迁移环境,使用命令生成相关的迁移文件,不能直接生成迁移文件 +使用 httpx 进行异步 HTTP 请求,禁止在路由函数内直接使用同步数据库会话或 httpx 客户端 +所有的测试代码都写在tests目录下,测试文件命名以test_开头,测试函数命名以test_开头 +所有敏感信息(如密钥、Token、数据库密码)必须通过环境变量或配置文件管理,禁止硬编码到代码中 + +## 目录结构与分层边界 + +后端采用务实分层,完整设计见 [后端分层架构设计](../docs/architecture/backend-layering-design.md),本节为开发速查。 + +### 顶层目录职责 + +| 目录 | 职责 | +| --- | --- | +| `api/v1` | `endpoints/`(路由)+ `deps.py`(依赖注入),只做 参数校验 → 调用 → 转响应 | +| `schemas` | Pydantic 请求/响应(API 边界) | +| `application` | 用例编排(跨多模块复杂流程) | +| `services` | 薄业务服务(CRUD 编排) | +| `modules` | 九枢领域逻辑(★ 纯函数/数据结构,禁止 IO) | +| `models` | SQLAlchemy ORM | +| `repositories` | 数据访问(返回 ORM,不暴露 Query/Session) | +| `ai_provider` | 统一模型接入层(chat/multimodal/embedding/rerank) | +| `integrations` | 外部系统适配(沙箱/向量/图/对象存储) | +| `core` | 配置集中(`core/config/`)+ 日志/错误/安全/ID/时间 | +| `db` | 数据库会话与初始化 | + +### 分层硬规矩 + +- `modules/` 禁止 import `repositories` / `db` / `integrations` / `ai_provider` / `httpx` / `sqlalchemy`——一旦需要 IO,立即上移到 `services` 或 `application`。 +- 路由函数禁止直接 `Depends(get_session)` 或写业务逻辑——改为注入 usecase / service。 +- `repositories/` 返回 ORM,禁止把 Query / Session 暴露到上层。 +- 模型能力(对话/多模态/嵌入/重排)一律进 `ai_provider/`;存储/检索/外部接入进 `integrations/`。 +- 配置一律进 `core/config/`,业务目录不持有 `settings.py` / `config.py`。 +- `.env` 只放项目根目录,`Settings` 以绝对路径 `_env_file` 初始化,不使用 `python-dotenv`。 + +## 代码审查 + +开发完成后必须使用以下命令进行代码检查: + +```bash +uv run ruff check # Lint 检查 +uv run ruff check --fix # 自动修复部分代码风格问题 +uv run pre-commit run --all-files # 可选:完整运行 CI 级检查(包括类型检查) +``` + +确保没有任何 lint 错误后再提交。 + +## 绝对禁止事项 + +直接修改 pyproject.toml 文件添加或者移除依赖(必须使用 uv add/remove 命令) +在路由函数内直接使用同步数据库会话(必须通过 Depends 注入) +在路由函数内直接 async with httpx.AsyncClient() +使用 logging 模块(统一用 loguru) +硬编码密钥、Token、数据库密码到代码 +手动创建/修改数据库表结构(必须使用 Alembic 迁移) +在 `modules/` 中 import `repositories` / `db` / `integrations` / `ai_provider` / `httpx` / `sqlalchemy`(领域层禁止 IO,需要 IO 时上移到 services/application) +在业务目录(`ai_provider` / `integrations` 等)中持有 `settings.py` / `config.py`(配置统一进 `core/config/`) +使用 `python-dotenv`(`.env` 由 `core/config` 以绝对路径 `_env_file` 加载) diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..10e1563 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = sqlite+aiosqlite:///../db/hunjing.sqlite3 + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/README b/backend/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/backend/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..d0adbca --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,65 @@ +"""Alembic 迁移环境。""" + +from sqlalchemy import pool +from sqlalchemy.engine import Connection, make_url +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context +from app.core.config import settings +from app.db.base import Base + +config = context.config +config.set_main_option("sqlalchemy.url", settings.resolved_database_url) +target_metadata = Base.metadata + + +def ensure_sqlite_parent_dir() -> None: + url = make_url(settings.resolved_database_url) + if url.drivername.startswith("sqlite") and url.database: + settings.resolve_from_backend(url.database).parent.mkdir(parents=True, exist_ok=True) + + +def run_migrations_offline() -> None: + ensure_sqlite_parent_dir() + context.configure( + url=settings.resolved_database_url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + ensure_sqlite_parent_dir() + section = config.get_section(config.config_ini_section, {}) + connectable = async_engine_from_config( + section, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + import asyncio + + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/001_initial_schema.py b/backend/alembic/versions/001_initial_schema.py new file mode 100644 index 0000000..bfbaa47 --- /dev/null +++ b/backend/alembic/versions/001_initial_schema.py @@ -0,0 +1,1225 @@ +"""initial_schema + +Revision ID: 001 +Revises: +Create Date: 2026-07-09 19:36:42.745763 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "001" +down_revision: str | Sequence[str] | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # 天工阁角色名册 + op.create_table( + "agent_roster", + sa.Column("role", sa.String(length=80), nullable=False), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("responsibility", sa.Text(), nullable=False), + sa.Column("enabled", sa.Boolean(), nullable=False), + sa.Column("last_active_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("role"), + ) + # 人物主表 + op.create_table( + "characters", + sa.Column("id", sa.String(length=80), nullable=False), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("display_name", sa.String(length=120), nullable=True), + sa.Column("status", sa.String(length=40), nullable=False), + sa.Column("language", sa.String(length=20), nullable=False), + sa.Column("profile_json", sa.Text(), nullable=False), + sa.Column("model_policy_json", sa.Text(), nullable=False), + sa.Column("safety_policy_id", sa.String(length=80), nullable=True), + sa.Column("current_personality_version_id", sa.String(length=80), nullable=True), + sa.Column("current_mood_id", sa.String(length=80), nullable=True), + sa.Column("privacy_level", sa.String(length=40), nullable=False), + sa.Column("owner_scope", sa.String(length=80), nullable=False), + sa.Column("is_system_template", sa.Boolean(), nullable=False), + sa.Column("source_template_id", sa.String(length=80), nullable=True), + sa.Column("template_key", sa.String(length=80), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("activated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_characters_is_system_template"), "characters", ["is_system_template"], unique=False + ) + op.create_index(op.f("ix_characters_owner_scope"), "characters", ["owner_scope"], unique=False) + op.create_index( + op.f("ix_characters_source_template_id"), "characters", ["source_template_id"], unique=False + ) + op.create_index(op.f("ix_characters_status"), "characters", ["status"], unique=False) + op.create_index( + op.f("ix_characters_template_key"), "characters", ["template_key"], unique=False + ) + # 生成偏好 + op.create_table( + "generation_preferences", + sa.Column("owner_scope", sa.String(length=80), nullable=False), + sa.Column("default_model_hint", sa.String(length=120), nullable=False), + sa.Column("temperature", sa.Float(), nullable=False), + sa.Column("top_p", sa.Float(), nullable=False), + sa.Column("max_output_tokens", sa.Integer(), nullable=False), + sa.Column("streaming", sa.Boolean(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("owner_scope"), + ) + # 幂等键 + op.create_table( + "idempotency_keys", + sa.Column("id", sa.String(length=80), nullable=False), + sa.Column("key", sa.String(length=200), nullable=False), + sa.Column("scope", sa.String(length=120), nullable=False), + sa.Column("method", sa.String(length=20), nullable=False), + sa.Column("path", sa.String(length=500), nullable=False), + sa.Column("request_hash", sa.String(length=128), nullable=False), + sa.Column("status", sa.String(length=40), nullable=False), + sa.Column("response_status_code", sa.Integer(), nullable=False), + sa.Column("response_body_json", sa.Text(), nullable=False), + sa.Column("resource_type", sa.String(length=80), nullable=True), + sa.Column("resource_id", sa.String(length=120), nullable=True), + sa.Column("trace_id", sa.String(length=80), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("scope", "key", name="uq_idempotency_scope_key"), + ) + op.create_index( + op.f("ix_idempotency_keys_expires_at"), "idempotency_keys", ["expires_at"], unique=False + ) + op.create_index(op.f("ix_idempotency_keys_key"), "idempotency_keys", ["key"], unique=False) + op.create_index(op.f("ix_idempotency_keys_scope"), "idempotency_keys", ["scope"], unique=False) + op.create_index( + op.f("ix_idempotency_keys_trace_id"), "idempotency_keys", ["trace_id"], unique=False + ) + # MCP 服务器注册 + op.create_table( + "mcp_servers", + sa.Column("id", sa.String(length=80), nullable=False), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("transport", sa.String(length=40), nullable=False), + sa.Column("endpoint_url", sa.String(length=500), nullable=True), + sa.Column("auth_token", sa.String(length=500), nullable=True), + sa.Column("command", sa.String(length=500), nullable=True), + sa.Column("args_json", sa.Text(), nullable=True), + sa.Column("env_json", sa.Text(), nullable=True), + sa.Column("cwd", sa.String(length=500), nullable=True), + sa.Column("status", sa.String(length=40), nullable=False), + sa.Column("tools_json", sa.Text(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + op.create_index(op.f("ix_mcp_servers_status"), "mcp_servers", ["status"], unique=False) + # 模型端点覆盖 + op.create_table( + "model_endpoint_overrides", + sa.Column("logical_model_name", sa.String(length=120), nullable=False), + sa.Column("endpoint_name", sa.String(length=120), nullable=False), + sa.Column("base_url", sa.String(length=500), nullable=True), + sa.Column("api_key", sa.String(length=500), nullable=True), + sa.Column("status", sa.String(length=40), nullable=True), + sa.Column("weight", sa.Integer(), nullable=True), + sa.Column("priority", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("logical_model_name", "endpoint_name"), + ) + # 司命待确认记录 + op.create_table( + "pending_confirmations", + sa.Column("id", sa.String(length=80), nullable=False), + sa.Column("tool", sa.String(length=120), nullable=False), + sa.Column("input_json", sa.Text(), nullable=False), + sa.Column("risk_level", sa.String(length=40), nullable=False), + sa.Column("reason", sa.Text(), nullable=False), + sa.Column("run_id", sa.String(length=80), nullable=True), + sa.Column("character_id", sa.String(length=80), nullable=True), + sa.Column("trace_id", sa.String(length=80), nullable=True), + sa.Column("status", sa.String(length=40), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_pending_confirmations_character_id"), + "pending_confirmations", + ["character_id"], + unique=False, + ) + op.create_index( + op.f("ix_pending_confirmations_run_id"), "pending_confirmations", ["run_id"], unique=False + ) + op.create_index( + op.f("ix_pending_confirmations_status"), "pending_confirmations", ["status"], unique=False + ) + # 烛照感知通道 + op.create_table( + "perception_channels", + sa.Column("channel", sa.String(length=80), nullable=False), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("description", sa.Text(), nullable=False), + sa.Column("enabled", sa.Boolean(), nullable=False), + sa.Column("consent_status", sa.String(length=40), nullable=False), + sa.Column("privacy_level", sa.String(length=40), nullable=False), + sa.Column("risk_level", sa.String(length=20), nullable=False), + sa.Column("last_active_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("channel"), + ) + # Skill 能力包 + op.create_table( + "skills", + sa.Column("id", sa.String(length=80), nullable=False), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("description", sa.Text(), nullable=False), + sa.Column("trigger", sa.Text(), nullable=False), + sa.Column("prompt_fragment", sa.Text(), nullable=False), + sa.Column("tool_bindings_json", sa.Text(), nullable=False), + sa.Column("risk_level", sa.String(length=8), nullable=False), + sa.Column("status", sa.String(length=40), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + op.create_index(op.f("ix_skills_status"), "skills", ["status"], unique=False) + # 工具安全策略 + op.create_table( + "tool_policies", + sa.Column("name", sa.String(length=80), nullable=False), + sa.Column("description", sa.Text(), nullable=False), + sa.Column("risk_level", sa.String(length=20), nullable=False), + sa.Column("permission_policy", sa.String(length=20), nullable=False), + sa.Column("side_effect", sa.Boolean(), nullable=False), + sa.Column("audit_required", sa.Boolean(), nullable=False), + sa.Column("source", sa.String(length=20), nullable=False), + sa.Column("enabled", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("name"), + ) + # 用户 + op.create_table( + "users", + sa.Column("id", sa.String(length=40), nullable=False), + sa.Column("username", sa.String(length=80), nullable=False), + sa.Column("email", sa.String(length=255), nullable=False), + sa.Column("password_hash", sa.String(length=255), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("is_admin", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("email"), + ) + op.create_index(op.f("ix_users_username"), "users", ["username"], unique=True) + # 河图世界实体 + op.create_table( + "world_entities", + sa.Column("id", sa.String(length=80), nullable=False), + sa.Column("type", sa.String(length=40), nullable=False), + sa.Column("name", sa.String(length=200), nullable=False), + sa.Column("attributes_json", sa.Text(), nullable=False), + sa.Column("state_type", sa.String(length=40), nullable=False), + sa.Column("confidence", sa.Float(), nullable=False), + sa.Column("sources_json", sa.Text(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_world_entities_type"), "world_entities", ["type"], unique=False) + # 大周天运行记录 + op.create_table( + "big_cycles", + sa.Column("id", sa.String(length=80), nullable=False), + sa.Column("character_id", sa.String(length=80), nullable=False), + sa.Column("trigger", sa.String(length=40), nullable=False), + sa.Column("status", sa.String(length=40), nullable=False), + sa.Column("phase", sa.String(length=40), nullable=True), + sa.Column("memory_count_at_trigger", sa.Integer(), nullable=False), + sa.Column("evaluation_snapshot_json", sa.Text(), nullable=True), + sa.Column("summary", sa.Text(), nullable=True), + sa.Column("error_code", sa.String(length=80), nullable=True), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["character_id"], + ["characters.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_big_cycles_character_id"), "big_cycles", ["character_id"], unique=False + ) + op.create_index( + op.f("ix_big_cycles_completed_at"), "big_cycles", ["completed_at"], unique=False + ) + op.create_index(op.f("ix_big_cycles_status"), "big_cycles", ["status"], unique=False) + # 灵核情绪状态 + op.create_table( + "character_emotion_states", + sa.Column("character_id", sa.String(length=80), nullable=False), + sa.Column("mood", sa.String(length=40), nullable=False), + sa.Column("intensity", sa.Float(), nullable=False), + sa.Column("baseline_mood", sa.String(length=40), nullable=False), + sa.Column("baseline_intensity", sa.Float(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("last_trigger_event_id", sa.String(length=80), nullable=True), + sa.ForeignKeyConstraint( + ["character_id"], + ["characters.id"], + ), + sa.PrimaryKeyConstraint("character_id"), + ) + # 资料导入任务 + op.create_table( + "character_import_jobs", + sa.Column("id", sa.String(length=80), nullable=False), + sa.Column("character_id", sa.String(length=80), nullable=False), + sa.Column("material_type", sa.String(length=40), nullable=False), + sa.Column("source_type", sa.String(length=40), nullable=False), + sa.Column("title", sa.String(length=200), nullable=True), + sa.Column("status", sa.String(length=40), nullable=False), + sa.Column("format", sa.String(length=40), nullable=False), + sa.Column("media_type", sa.String(length=40), nullable=True), + sa.Column("mime_type", sa.String(length=80), nullable=True), + sa.Column("source_description", sa.Text(), nullable=True), + sa.Column("speaker_label", sa.String(length=120), nullable=True), + sa.Column("consent_status", sa.String(length=40), nullable=False), + sa.Column("usage_scope", sa.String(length=40), nullable=False), + sa.Column("checksum", sa.String(length=120), nullable=True), + sa.Column("item_count", sa.Integer(), nullable=False), + sa.Column("storage_path", sa.String(length=500), nullable=True), + sa.Column("raw_content", sa.Text(), nullable=True), + sa.Column("error_code", sa.String(length=80), nullable=True), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["character_id"], + ["characters.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_character_import_jobs_character_id"), + "character_import_jobs", + ["character_id"], + unique=False, + ) + op.create_index( + op.f("ix_character_import_jobs_checksum"), + "character_import_jobs", + ["checksum"], + unique=False, + ) + op.create_index( + op.f("ix_character_import_jobs_material_type"), + "character_import_jobs", + ["material_type"], + unique=False, + ) + op.create_index( + op.f("ix_character_import_jobs_status"), "character_import_jobs", ["status"], unique=False + ) + # 人物视角世界实体 + op.create_table( + "character_world_entities", + sa.Column("id", sa.String(length=80), nullable=False), + sa.Column("character_id", sa.String(length=80), nullable=False), + sa.Column("type", sa.String(length=40), nullable=False), + sa.Column("name", sa.String(length=200), nullable=False), + sa.Column("attributes_json", sa.Text(), nullable=False), + sa.Column("state_type", sa.String(length=40), nullable=False), + sa.Column("status", sa.String(length=40), nullable=False), + sa.Column("confidence", sa.Float(), nullable=False), + sa.Column("evidence_ids_json", sa.Text(), nullable=False), + sa.Column("sources_json", sa.Text(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["character_id"], + ["characters.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_character_world_entities_character_id"), + "character_world_entities", + ["character_id"], + unique=False, + ) + op.create_index( + op.f("ix_character_world_entities_status"), + "character_world_entities", + ["status"], + unique=False, + ) + op.create_index( + op.f("ix_character_world_entities_type"), "character_world_entities", ["type"], unique=False + ) + # 对话会话 + op.create_table( + "chat_sessions", + sa.Column("id", sa.String(length=80), nullable=False), + sa.Column("character_id", sa.String(length=80), nullable=False), + sa.Column("title", sa.String(length=200), nullable=True), + sa.Column("status", sa.String(length=40), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["character_id"], + ["characters.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_chat_sessions_character_id"), "chat_sessions", ["character_id"], unique=False + ) + op.create_index(op.f("ix_chat_sessions_status"), "chat_sessions", ["status"], unique=False) + # 太一目标 + op.create_table( + "goals", + sa.Column("id", sa.String(length=80), nullable=False), + sa.Column("character_id", sa.String(length=80), nullable=True), + sa.Column("title", sa.String(length=300), nullable=False), + sa.Column("description", sa.Text(), nullable=False), + sa.Column("level", sa.String(length=40), nullable=False), + sa.Column("type", sa.String(length=40), nullable=False), + sa.Column("status", sa.String(length=40), nullable=False), + sa.Column("priority", sa.Integer(), nullable=False), + sa.Column("progress", sa.Float(), nullable=False), + sa.Column("constraints_json", sa.Text(), nullable=False), + sa.Column("parent_id", sa.String(length=80), nullable=True), + sa.Column("success_criteria_json", sa.Text(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["character_id"], + ["characters.id"], + ), + sa.ForeignKeyConstraint( + ["parent_id"], + ["goals.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_goals_character_id"), "goals", ["character_id"], unique=False) + op.create_index(op.f("ix_goals_level"), "goals", ["level"], unique=False) + op.create_index(op.f("ix_goals_status"), "goals", ["status"], unique=False) + # 息壤记忆 + op.create_table( + "memories", + sa.Column("id", sa.String(length=80), nullable=False), + sa.Column("character_id", sa.String(length=80), nullable=False), + sa.Column("type", sa.String(length=60), nullable=False), + sa.Column("title", sa.String(length=200), nullable=False), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("source", sa.String(length=80), nullable=False), + sa.Column("confidence", sa.Float(), nullable=False), + sa.Column("importance", sa.Float(), nullable=False), + sa.Column("sensitivity", sa.String(length=40), nullable=False), + sa.Column("evidence_ids_json", sa.Text(), nullable=False), + sa.Column("status", sa.String(length=40), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("temporal_context_json", sa.Text(), server_default="{}", nullable=False), + sa.ForeignKeyConstraint( + ["character_id"], + ["characters.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_memories_character_id"), "memories", ["character_id"], unique=False) + op.create_index(op.f("ix_memories_status"), "memories", ["status"], unique=False) + # 人格版本 + op.create_table( + "personality_versions", + sa.Column("id", sa.String(length=80), nullable=False), + sa.Column("character_id", sa.String(length=80), nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("identity_profile", sa.Text(), nullable=False), + sa.Column("role_destiny", sa.Text(), nullable=False), + sa.Column("personality_seed", sa.Text(), nullable=False), + sa.Column("traits", sa.Text(), nullable=False), + sa.Column("expression_style", sa.Text(), nullable=False), + sa.Column("values", sa.Text(), nullable=False), + sa.Column("relationship_pattern", sa.Text(), nullable=False), + sa.Column("emotion_baseline", sa.Text(), nullable=False), + sa.Column("reason", sa.Text(), nullable=False), + sa.Column("evidence_ids_json", sa.Text(), nullable=False), + sa.Column("created_by", sa.String(length=80), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("rolled_back_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint( + ["character_id"], + ["characters.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_personality_versions_character_id"), + "personality_versions", + ["character_id"], + unique=False, + ) + # 辰正纪念日 + op.create_table( + "time_anniversaries", + sa.Column("id", sa.String(length=80), nullable=False), + sa.Column("character_id", sa.String(length=80), nullable=False), + sa.Column("name", sa.String(length=200), nullable=False), + sa.Column("calendar", sa.String(length=20), nullable=False), + sa.Column("date", sa.String(length=20), nullable=False), + sa.Column("recurs_yearly", sa.Boolean(), nullable=False), + sa.Column("kind", sa.String(length=40), nullable=False), + sa.Column("subject_type", sa.String(length=40), nullable=False), + sa.Column("source", sa.String(length=40), nullable=False), + sa.Column("metadata_json", sa.Text(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["character_id"], + ["characters.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_time_anniversaries_character_id"), + "time_anniversaries", + ["character_id"], + unique=False, + ) + # 档案导入候选 + op.create_table( + "character_profile_candidates", + sa.Column("id", sa.String(length=80), nullable=False), + sa.Column("character_id", sa.String(length=80), nullable=False), + sa.Column("import_job_id", sa.String(length=80), nullable=True), + sa.Column("field", sa.String(length=60), nullable=False), + sa.Column("value", sa.Text(), nullable=False), + sa.Column("confidence", sa.Float(), nullable=False), + sa.Column("evidence_ids_json", sa.Text(), nullable=False), + sa.Column("status", sa.String(length=40), nullable=False), + sa.Column("reason", sa.Text(), nullable=True), + sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["character_id"], + ["characters.id"], + ), + sa.ForeignKeyConstraint( + ["import_job_id"], + ["character_import_jobs.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_character_profile_candidates_character_id"), + "character_profile_candidates", + ["character_id"], + unique=False, + ) + op.create_index( + op.f("ix_character_profile_candidates_import_job_id"), + "character_profile_candidates", + ["import_job_id"], + unique=False, + ) + op.create_index( + op.f("ix_character_profile_candidates_status"), + "character_profile_candidates", + ["status"], + unique=False, + ) + # 大周天内驱力·好奇问题 + op.create_table( + "curiosity_questions", + sa.Column("id", sa.String(length=80), nullable=False), + sa.Column("character_id", sa.String(length=80), nullable=False), + sa.Column("cycle_id", sa.String(length=80), nullable=False), + sa.Column("question", sa.Text(), nullable=False), + sa.Column("source", sa.String(length=60), nullable=False), + sa.Column("related_entities_json", sa.Text(), nullable=False), + sa.Column("expected_value", sa.Float(), nullable=False), + sa.Column("urgency", sa.Float(), nullable=False), + sa.Column("risk_level", sa.String(length=8), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["character_id"], + ["characters.id"], + ), + sa.ForeignKeyConstraint( + ["cycle_id"], + ["big_cycles.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_curiosity_questions_character_id"), + "curiosity_questions", + ["character_id"], + unique=False, + ) + op.create_index( + op.f("ix_curiosity_questions_cycle_id"), "curiosity_questions", ["cycle_id"], unique=False + ) + # 演化候选 + op.create_table( + "evolution_candidates", + sa.Column("id", sa.String(length=80), nullable=False), + sa.Column("character_id", sa.String(length=80), nullable=False), + sa.Column("cycle_id", sa.String(length=80), nullable=False), + sa.Column("type", sa.String(length=60), nullable=False), + sa.Column("source", sa.String(length=60), nullable=False), + sa.Column("level", sa.String(length=8), nullable=False), + sa.Column("scope", sa.String(length=40), nullable=False), + sa.Column("title", sa.String(length=200), nullable=False), + sa.Column("proposal", sa.Text(), nullable=False), + sa.Column("delta_json", sa.Text(), nullable=False), + sa.Column("evidence_ids_json", sa.Text(), nullable=False), + sa.Column("confidence", sa.Float(), nullable=False), + sa.Column("benefit_score", sa.Float(), nullable=False), + sa.Column("risk_score", sa.Float(), nullable=False), + sa.Column("drift_score", sa.Float(), nullable=False), + sa.Column("auto_apply_allowed", sa.Boolean(), nullable=False), + sa.Column("status", sa.String(length=40), nullable=False), + sa.Column("resulting_target_id", sa.String(length=80), nullable=True), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("reviewed_by", sa.String(length=80), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["character_id"], + ["characters.id"], + ), + sa.ForeignKeyConstraint( + ["cycle_id"], + ["big_cycles.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_evolution_candidates_character_id"), + "evolution_candidates", + ["character_id"], + unique=False, + ) + op.create_index( + op.f("ix_evolution_candidates_cycle_id"), "evolution_candidates", ["cycle_id"], unique=False + ) + op.create_index( + op.f("ix_evolution_candidates_status"), "evolution_candidates", ["status"], unique=False + ) + # 资料导入条目 + op.create_table( + "import_items", + sa.Column("id", sa.String(length=80), nullable=False), + sa.Column("import_job_id", sa.String(length=80), nullable=False), + sa.Column("character_id", sa.String(length=80), nullable=False), + sa.Column("item_type", sa.String(length=40), nullable=False), + sa.Column("role", sa.String(length=40), nullable=False), + sa.Column("sequence", sa.Integer(), nullable=False), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("metadata_json", sa.Text(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["character_id"], + ["characters.id"], + ), + sa.ForeignKeyConstraint( + ["import_job_id"], + ["character_import_jobs.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_import_items_character_id"), "import_items", ["character_id"], unique=False + ) + op.create_index( + op.f("ix_import_items_import_job_id"), "import_items", ["import_job_id"], unique=False + ) + # 记忆候选 + op.create_table( + "memory_candidates", + sa.Column("id", sa.String(length=80), nullable=False), + sa.Column("character_id", sa.String(length=80), nullable=False), + sa.Column("import_job_id", sa.String(length=80), nullable=True), + sa.Column("cycle_id", sa.String(length=80), nullable=True), + sa.Column("type", sa.String(length=60), nullable=False), + sa.Column("title", sa.String(length=200), nullable=False), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("confidence", sa.Float(), nullable=False), + sa.Column("importance", sa.Float(), nullable=False), + sa.Column("sensitivity", sa.String(length=40), nullable=False), + sa.Column("evidence_ids_json", sa.Text(), nullable=False), + sa.Column("status", sa.String(length=40), nullable=False), + sa.Column("reason", sa.Text(), nullable=True), + sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["character_id"], + ["characters.id"], + ), + sa.ForeignKeyConstraint( + ["cycle_id"], + ["big_cycles.id"], + ), + sa.ForeignKeyConstraint( + ["import_job_id"], + ["character_import_jobs.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_memory_candidates_character_id"), + "memory_candidates", + ["character_id"], + unique=False, + ) + op.create_index( + op.f("ix_memory_candidates_cycle_id"), "memory_candidates", ["cycle_id"], unique=False + ) + op.create_index( + op.f("ix_memory_candidates_import_job_id"), + "memory_candidates", + ["import_job_id"], + unique=False, + ) + op.create_index( + op.f("ix_memory_candidates_status"), "memory_candidates", ["status"], unique=False + ) + # 记忆关联 + op.create_table( + "memory_links", + sa.Column("id", sa.String(length=80), nullable=False), + sa.Column("character_id", sa.String(length=80), nullable=False), + sa.Column("source_memory_id", sa.String(length=80), nullable=False), + sa.Column("target_memory_id", sa.String(length=80), nullable=False), + sa.Column("relation", sa.String(length=80), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["character_id"], + ["characters.id"], + ), + sa.ForeignKeyConstraint( + ["source_memory_id"], + ["memories.id"], + ), + sa.ForeignKeyConstraint( + ["target_memory_id"], + ["memories.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_memory_links_character_id"), "memory_links", ["character_id"], unique=False + ) + op.create_index( + op.f("ix_memory_links_source_memory_id"), "memory_links", ["source_memory_id"], unique=False + ) + op.create_index( + op.f("ix_memory_links_target_memory_id"), "memory_links", ["target_memory_id"], unique=False + ) + # 人格建议 + op.create_table( + "personality_proposals", + sa.Column("id", sa.String(length=80), nullable=False), + sa.Column("character_id", sa.String(length=80), nullable=False), + sa.Column("fields_json", sa.Text(), nullable=False), + sa.Column("reason", sa.Text(), nullable=False), + sa.Column("status", sa.String(length=40), nullable=False), + sa.Column("resulting_version_id", sa.String(length=80), nullable=True), + sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("reviewed_by", sa.String(length=80), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["character_id"], + ["characters.id"], + ), + sa.ForeignKeyConstraint( + ["resulting_version_id"], + ["personality_versions.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_personality_proposals_character_id"), + "personality_proposals", + ["character_id"], + unique=False, + ) + op.create_index( + op.f("ix_personality_proposals_status"), "personality_proposals", ["status"], unique=False + ) + # 洛书计划 + op.create_table( + "plans", + sa.Column("id", sa.String(length=80), nullable=False), + sa.Column("goal_id", sa.String(length=80), nullable=True), + sa.Column("title", sa.String(length=300), nullable=False), + sa.Column("status", sa.String(length=40), nullable=False), + sa.Column("assumptions_json", sa.Text(), nullable=False), + sa.Column("risks_json", sa.Text(), nullable=False), + sa.Column("required_tools_json", sa.Text(), nullable=False), + sa.Column("success_criteria_json", sa.Text(), nullable=False), + sa.Column("step_count", sa.Integer(), nullable=False), + sa.Column("completed_steps", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["goal_id"], + ["goals.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_plans_goal_id"), "plans", ["goal_id"], unique=False) + op.create_index(op.f("ix_plans_status"), "plans", ["status"], unique=False) + # 运行记录 + op.create_table( + "runs", + sa.Column("id", sa.String(length=80), nullable=False), + sa.Column("character_id", sa.String(length=80), nullable=False), + sa.Column("session_id", sa.String(length=80), nullable=True), + sa.Column("status", sa.String(length=40), nullable=False), + sa.Column("goal", sa.String(length=300), nullable=True), + sa.Column("origin", sa.String(length=40), nullable=False), + sa.Column("model_hint", sa.String(length=120), nullable=True), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("error_code", sa.String(length=80), nullable=True), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["character_id"], + ["characters.id"], + ), + sa.ForeignKeyConstraint( + ["session_id"], + ["chat_sessions.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_runs_character_id"), "runs", ["character_id"], unique=False) + op.create_index(op.f("ix_runs_session_id"), "runs", ["session_id"], unique=False) + op.create_index(op.f("ix_runs_status"), "runs", ["status"], unique=False) + # 世界关系 + op.create_table( + "world_relations", + sa.Column("id", sa.String(length=80), nullable=False), + sa.Column("character_id", sa.String(length=80), nullable=False), + sa.Column("subject_id", sa.String(length=80), nullable=False), + sa.Column("predicate", sa.String(length=120), nullable=False), + sa.Column("object_id", sa.String(length=80), nullable=False), + sa.Column("confidence", sa.Float(), nullable=False), + sa.Column("evidence_ids_json", sa.Text(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["character_id"], + ["characters.id"], + ), + sa.ForeignKeyConstraint( + ["object_id"], + ["character_world_entities.id"], + ), + sa.ForeignKeyConstraint( + ["subject_id"], + ["character_world_entities.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_world_relations_character_id"), "world_relations", ["character_id"], unique=False + ) + op.create_index( + op.f("ix_world_relations_object_id"), "world_relations", ["object_id"], unique=False + ) + op.create_index( + op.f("ix_world_relations_subject_id"), "world_relations", ["subject_id"], unique=False + ) + # 起居注审计事件 + op.create_table( + "audit_events", + sa.Column("id", sa.String(length=80), nullable=False), + sa.Column("pivot", sa.String(length=40), nullable=True), + sa.Column("character_id", sa.String(length=80), nullable=True), + sa.Column("run_id", sa.String(length=80), nullable=True), + sa.Column("action", sa.String(length=120), nullable=False), + sa.Column("actor", sa.String(length=80), nullable=False), + sa.Column("risk_level", sa.String(length=20), nullable=False), + sa.Column("decision", sa.String(length=40), nullable=False), + sa.Column("reason", sa.Text(), nullable=True), + sa.Column("input_summary", sa.Text(), nullable=True), + sa.Column("output_summary", sa.Text(), nullable=True), + sa.Column("trace_id", sa.String(length=80), nullable=True), + sa.Column("resource_type", sa.String(length=80), nullable=True), + sa.Column("resource_id", sa.String(length=120), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["character_id"], + ["characters.id"], + ), + sa.ForeignKeyConstraint( + ["run_id"], + ["runs.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_audit_events_character_id"), "audit_events", ["character_id"], unique=False + ) + op.create_index(op.f("ix_audit_events_pivot"), "audit_events", ["pivot"], unique=False) + op.create_index(op.f("ix_audit_events_run_id"), "audit_events", ["run_id"], unique=False) + op.create_index(op.f("ix_audit_events_trace_id"), "audit_events", ["trace_id"], unique=False) + # 对话消息 + op.create_table( + "chat_messages", + sa.Column("id", sa.String(length=80), nullable=False), + sa.Column("character_id", sa.String(length=80), nullable=False), + sa.Column("session_id", sa.String(length=80), nullable=False), + sa.Column("role", sa.String(length=40), nullable=False), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("modality", sa.String(length=40), nullable=False), + sa.Column("run_id", sa.String(length=80), nullable=True), + sa.Column("metadata_json", sa.Text(), nullable=False), + sa.Column("sensitivity", sa.String(length=40), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint( + ["character_id"], + ["characters.id"], + ), + sa.ForeignKeyConstraint( + ["run_id"], + ["runs.id"], + ), + sa.ForeignKeyConstraint( + ["session_id"], + ["chat_sessions.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_chat_messages_character_id"), "chat_messages", ["character_id"], unique=False + ) + op.create_index(op.f("ix_chat_messages_run_id"), "chat_messages", ["run_id"], unique=False) + op.create_index( + op.f("ix_chat_messages_session_id"), "chat_messages", ["session_id"], unique=False + ) + # 一炁事件 + op.create_table( + "events", + sa.Column("id", sa.String(length=80), nullable=False), + sa.Column("character_id", sa.String(length=80), nullable=True), + sa.Column("run_id", sa.String(length=80), nullable=True), + sa.Column("type", sa.String(length=80), nullable=False), + sa.Column("source", sa.String(length=40), nullable=False), + sa.Column("payload_json", sa.Text(), nullable=False), + sa.Column("visibility", sa.String(length=40), nullable=False), + sa.Column("sensitivity", sa.String(length=40), nullable=False), + sa.Column("trace_id", sa.String(length=80), nullable=True), + sa.Column("parent_id", sa.String(length=80), nullable=True), + sa.Column("sequence", sa.Integer(), nullable=False), + sa.Column("evidence_ids_json", sa.Text(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["character_id"], + ["characters.id"], + ), + sa.ForeignKeyConstraint( + ["run_id"], + ["runs.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("run_id", "sequence", name="uq_events_run_sequence"), + ) + op.create_index(op.f("ix_events_character_id"), "events", ["character_id"], unique=False) + op.create_index(op.f("ix_events_run_id"), "events", ["run_id"], unique=False) + op.create_index(op.f("ix_events_trace_id"), "events", ["trace_id"], unique=False) + op.create_index(op.f("ix_events_type"), "events", ["type"], unique=False) + # 探索性次生目标 + op.create_table( + "exploratory_subgoals", + sa.Column("id", sa.String(length=80), nullable=False), + sa.Column("character_id", sa.String(length=80), nullable=False), + sa.Column("cycle_id", sa.String(length=80), nullable=False), + sa.Column("parent_goal_id", sa.String(length=80), nullable=True), + sa.Column("curiosity_question_id", sa.String(length=80), nullable=False), + sa.Column("title", sa.String(length=200), nullable=False), + sa.Column("description", sa.Text(), nullable=False), + sa.Column("expected_information_gain", sa.Float(), nullable=False), + sa.Column("interruption_cost", sa.Float(), nullable=False), + sa.Column("risk_level", sa.String(length=8), nullable=False), + sa.Column("status", sa.String(length=40), nullable=False), + sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("reviewed_by", sa.String(length=80), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["character_id"], + ["characters.id"], + ), + sa.ForeignKeyConstraint( + ["curiosity_question_id"], + ["curiosity_questions.id"], + ), + sa.ForeignKeyConstraint( + ["cycle_id"], + ["big_cycles.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_exploratory_subgoals_character_id"), + "exploratory_subgoals", + ["character_id"], + unique=False, + ) + op.create_index( + op.f("ix_exploratory_subgoals_curiosity_question_id"), + "exploratory_subgoals", + ["curiosity_question_id"], + unique=False, + ) + op.create_index( + op.f("ix_exploratory_subgoals_cycle_id"), "exploratory_subgoals", ["cycle_id"], unique=False + ) + op.create_index( + op.f("ix_exploratory_subgoals_status"), "exploratory_subgoals", ["status"], unique=False + ) + # RBAC 角色 + op.create_table( + "roles", + sa.Column("id", sa.String(40), nullable=False), + sa.Column("name", sa.String(80), nullable=False), + sa.Column("description", sa.Text(), nullable=False, server_default=""), + sa.Column("is_system", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_roles_name", "roles", ["name"], unique=True) + + # RBAC 权限 + op.create_table( + "permissions", + sa.Column("id", sa.String(40), nullable=False), + sa.Column("resource", sa.String(80), nullable=False), + sa.Column("action", sa.String(80), nullable=False), + sa.Column("description", sa.Text(), nullable=False, server_default=""), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_permissions_resource", "permissions", ["resource"]) + + # RBAC 用户-角色关联 + op.create_table( + "user_roles", + sa.Column("user_id", sa.String(40), nullable=False), + sa.Column("role_id", sa.String(40), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("user_id", "role_id"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["role_id"], ["roles.id"], ondelete="CASCADE"), + ) + + # RBAC 角色-权限关联 + op.create_table( + "role_permissions", + sa.Column("role_id", sa.String(40), nullable=False), + sa.Column("permission_id", sa.String(40), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("role_id", "permission_id"), + sa.ForeignKeyConstraint(["role_id"], ["roles.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["permission_id"], ["permissions.id"], ondelete="CASCADE"), + ) + # 消息版本历史 + op.add_column( + "chat_messages", + sa.Column( + "versions_json", + sa.Text, + nullable=False, + server_default="[]", + comment="版本历史 JSON 数组:[{content, created_at}],按生成顺序排列,末尾最新", + ), + ) + op.add_column( + "chat_messages", + sa.Column( + "active_version_idx", + sa.Integer, + nullable=False, + server_default=sa.text("0"), + comment="当前活跃版本在 versions_json 中的索引(0-based)", + ), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_column("chat_messages", "active_version_idx") + op.drop_column("chat_messages", "versions_json") + op.drop_table("role_permissions") + op.drop_table("user_roles") + op.drop_index("ix_permissions_resource", table_name="permissions") + op.drop_table("permissions") + op.drop_index("ix_roles_name", table_name="roles") + op.drop_table("roles") + op.drop_index(op.f("ix_exploratory_subgoals_status"), table_name="exploratory_subgoals") + op.drop_index(op.f("ix_exploratory_subgoals_cycle_id"), table_name="exploratory_subgoals") + op.drop_index( + op.f("ix_exploratory_subgoals_curiosity_question_id"), table_name="exploratory_subgoals" + ) + op.drop_index(op.f("ix_exploratory_subgoals_character_id"), table_name="exploratory_subgoals") + op.drop_table("exploratory_subgoals") + op.drop_index(op.f("ix_events_type"), table_name="events") + op.drop_index(op.f("ix_events_trace_id"), table_name="events") + op.drop_index(op.f("ix_events_run_id"), table_name="events") + op.drop_index(op.f("ix_events_character_id"), table_name="events") + op.drop_table("events") + op.drop_index(op.f("ix_chat_messages_session_id"), table_name="chat_messages") + op.drop_index(op.f("ix_chat_messages_run_id"), table_name="chat_messages") + op.drop_index(op.f("ix_chat_messages_character_id"), table_name="chat_messages") + op.drop_table("chat_messages") + op.drop_index(op.f("ix_audit_events_trace_id"), table_name="audit_events") + op.drop_index(op.f("ix_audit_events_run_id"), table_name="audit_events") + op.drop_index(op.f("ix_audit_events_pivot"), table_name="audit_events") + op.drop_index(op.f("ix_audit_events_character_id"), table_name="audit_events") + op.drop_table("audit_events") + op.drop_index(op.f("ix_world_relations_subject_id"), table_name="world_relations") + op.drop_index(op.f("ix_world_relations_object_id"), table_name="world_relations") + op.drop_index(op.f("ix_world_relations_character_id"), table_name="world_relations") + op.drop_table("world_relations") + op.drop_index(op.f("ix_runs_status"), table_name="runs") + op.drop_index(op.f("ix_runs_session_id"), table_name="runs") + op.drop_index(op.f("ix_runs_character_id"), table_name="runs") + op.drop_table("runs") + op.drop_index(op.f("ix_plans_status"), table_name="plans") + op.drop_index(op.f("ix_plans_goal_id"), table_name="plans") + op.drop_table("plans") + op.drop_index(op.f("ix_personality_proposals_status"), table_name="personality_proposals") + op.drop_index(op.f("ix_personality_proposals_character_id"), table_name="personality_proposals") + op.drop_table("personality_proposals") + op.drop_index(op.f("ix_memory_links_target_memory_id"), table_name="memory_links") + op.drop_index(op.f("ix_memory_links_source_memory_id"), table_name="memory_links") + op.drop_index(op.f("ix_memory_links_character_id"), table_name="memory_links") + op.drop_table("memory_links") + op.drop_index(op.f("ix_memory_candidates_status"), table_name="memory_candidates") + op.drop_index(op.f("ix_memory_candidates_import_job_id"), table_name="memory_candidates") + op.drop_index(op.f("ix_memory_candidates_cycle_id"), table_name="memory_candidates") + op.drop_index(op.f("ix_memory_candidates_character_id"), table_name="memory_candidates") + op.drop_table("memory_candidates") + op.drop_index(op.f("ix_import_items_import_job_id"), table_name="import_items") + op.drop_index(op.f("ix_import_items_character_id"), table_name="import_items") + op.drop_table("import_items") + op.drop_index(op.f("ix_evolution_candidates_status"), table_name="evolution_candidates") + op.drop_index(op.f("ix_evolution_candidates_cycle_id"), table_name="evolution_candidates") + op.drop_index(op.f("ix_evolution_candidates_character_id"), table_name="evolution_candidates") + op.drop_table("evolution_candidates") + op.drop_index(op.f("ix_curiosity_questions_cycle_id"), table_name="curiosity_questions") + op.drop_index(op.f("ix_curiosity_questions_character_id"), table_name="curiosity_questions") + op.drop_table("curiosity_questions") + op.drop_index( + op.f("ix_character_profile_candidates_status"), table_name="character_profile_candidates" + ) + op.drop_index( + op.f("ix_character_profile_candidates_import_job_id"), + table_name="character_profile_candidates", + ) + op.drop_index( + op.f("ix_character_profile_candidates_character_id"), + table_name="character_profile_candidates", + ) + op.drop_table("character_profile_candidates") + op.drop_index(op.f("ix_time_anniversaries_character_id"), table_name="time_anniversaries") + op.drop_table("time_anniversaries") + op.drop_index(op.f("ix_personality_versions_character_id"), table_name="personality_versions") + op.drop_table("personality_versions") + op.drop_index(op.f("ix_memories_status"), table_name="memories") + op.drop_index(op.f("ix_memories_character_id"), table_name="memories") + op.drop_table("memories") + op.drop_index(op.f("ix_goals_status"), table_name="goals") + op.drop_index(op.f("ix_goals_level"), table_name="goals") + op.drop_index(op.f("ix_goals_character_id"), table_name="goals") + op.drop_table("goals") + op.drop_index(op.f("ix_chat_sessions_status"), table_name="chat_sessions") + op.drop_index(op.f("ix_chat_sessions_character_id"), table_name="chat_sessions") + op.drop_table("chat_sessions") + op.drop_index(op.f("ix_character_world_entities_type"), table_name="character_world_entities") + op.drop_index(op.f("ix_character_world_entities_status"), table_name="character_world_entities") + op.drop_index( + op.f("ix_character_world_entities_character_id"), table_name="character_world_entities" + ) + op.drop_table("character_world_entities") + op.drop_index(op.f("ix_character_import_jobs_status"), table_name="character_import_jobs") + op.drop_index( + op.f("ix_character_import_jobs_material_type"), table_name="character_import_jobs" + ) + op.drop_index(op.f("ix_character_import_jobs_checksum"), table_name="character_import_jobs") + op.drop_index(op.f("ix_character_import_jobs_character_id"), table_name="character_import_jobs") + op.drop_table("character_import_jobs") + op.drop_table("character_emotion_states") + op.drop_index(op.f("ix_big_cycles_status"), table_name="big_cycles") + op.drop_index(op.f("ix_big_cycles_completed_at"), table_name="big_cycles") + op.drop_index(op.f("ix_big_cycles_character_id"), table_name="big_cycles") + op.drop_table("big_cycles") + op.drop_index(op.f("ix_world_entities_type"), table_name="world_entities") + op.drop_table("world_entities") + op.drop_index(op.f("ix_users_username"), table_name="users") + op.drop_table("users") + op.drop_table("tool_policies") + op.drop_index(op.f("ix_skills_status"), table_name="skills") + op.drop_table("skills") + op.drop_table("perception_channels") + op.drop_index(op.f("ix_pending_confirmations_status"), table_name="pending_confirmations") + op.drop_index(op.f("ix_pending_confirmations_run_id"), table_name="pending_confirmations") + op.drop_index(op.f("ix_pending_confirmations_character_id"), table_name="pending_confirmations") + op.drop_table("pending_confirmations") + op.drop_table("model_endpoint_overrides") + op.drop_index(op.f("ix_mcp_servers_status"), table_name="mcp_servers") + op.drop_table("mcp_servers") + op.drop_index(op.f("ix_idempotency_keys_trace_id"), table_name="idempotency_keys") + op.drop_index(op.f("ix_idempotency_keys_scope"), table_name="idempotency_keys") + op.drop_index(op.f("ix_idempotency_keys_key"), table_name="idempotency_keys") + op.drop_index(op.f("ix_idempotency_keys_expires_at"), table_name="idempotency_keys") + op.drop_table("idempotency_keys") + op.drop_table("generation_preferences") + op.drop_index(op.f("ix_characters_template_key"), table_name="characters") + op.drop_index(op.f("ix_characters_status"), table_name="characters") + op.drop_index(op.f("ix_characters_source_template_id"), table_name="characters") + op.drop_index(op.f("ix_characters_owner_scope"), table_name="characters") + op.drop_index(op.f("ix_characters_is_system_template"), table_name="characters") + op.drop_table("characters") + op.drop_table("agent_roster") + # ### end Alembic commands ### diff --git a/backend/alembic/versions/002_add_chat_session_owner_scope.py b/backend/alembic/versions/002_add_chat_session_owner_scope.py new file mode 100644 index 0000000..b9ac7df --- /dev/null +++ b/backend/alembic/versions/002_add_chat_session_owner_scope.py @@ -0,0 +1,56 @@ +"""add_chat_session_owner_scope + +Revision ID: 002 +Revises: 001 +Create Date: 2026-07-14 07:54:32.969191 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = '002' +down_revision: str | Sequence[str] | None = '001' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + conn = op.get_bind() + is_sqlite = conn.engine.dialect.name == "sqlite" + + if is_sqlite: + # SQLite:用 batch 模式加列(新增 NOT NULL 列需 batch recreating) + with op.batch_alter_table("chat_sessions") as batch_op: + batch_op.add_column( + sa.Column("owner_scope", sa.String(length=80), nullable=False, server_default=""), + ) + batch_op.create_index("ix_chat_sessions_owner_scope", ["owner_scope"]) + # 移除 server_default(仅 DDL 阶段需要) + with op.batch_alter_table("chat_sessions") as batch_op: + batch_op.alter_column("owner_scope", server_default=None) + else: + op.add_column( + "chat_sessions", + sa.Column("owner_scope", sa.String(length=80), nullable=False, server_default=""), + ) + op.create_index( + op.f("ix_chat_sessions_owner_scope"), "chat_sessions", ["owner_scope"], unique=False + ) + op.alter_column("chat_sessions", "owner_scope", server_default=None) + + +def downgrade() -> None: + conn = op.get_bind() + is_sqlite = conn.engine.dialect.name == "sqlite" + + if is_sqlite: + with op.batch_alter_table("chat_sessions") as batch_op: + batch_op.drop_index("ix_chat_sessions_owner_scope") + batch_op.drop_column("owner_scope") + else: + op.drop_index(op.f("ix_chat_sessions_owner_scope"), table_name="chat_sessions") + op.drop_column("chat_sessions", "owner_scope") diff --git a/backend/alembic/versions/003_add_daoti_soul_fields_to_character.py b/backend/alembic/versions/003_add_daoti_soul_fields_to_character.py new file mode 100644 index 0000000..b708c7e --- /dev/null +++ b/backend/alembic/versions/003_add_daoti_soul_fields_to_character.py @@ -0,0 +1,36 @@ +"""add daoti soul fields to character + +Revision ID: 003 +Revises: 002 +Create Date: 2026-07-24 10:47:40.993422 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = '003' +down_revision: str | Sequence[str] | None = '002' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('characters', sa.Column('personality_summary', sa.Text(), nullable=True)) + op.add_column('characters', sa.Column('mission', sa.Text(), nullable=True)) + op.add_column('characters', sa.Column('safety_guidelines', sa.Text(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('characters', 'safety_guidelines') + op.drop_column('characters', 'mission') + op.drop_column('characters', 'personality_summary') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/004_add_lorebook_tables.py b/backend/alembic/versions/004_add_lorebook_tables.py new file mode 100644 index 0000000..cd346ba --- /dev/null +++ b/backend/alembic/versions/004_add_lorebook_tables.py @@ -0,0 +1,81 @@ +"""add lorebook tables + +Revision ID: 004 +Revises: 003 +Create Date: 2026-07-24 12:20:38.453954 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = '004' +down_revision: str | Sequence[str] | None = '003' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('lorebooks', + sa.Column('id', sa.String(length=80), nullable=False), + sa.Column('character_id', sa.String(length=80), nullable=True), + sa.Column('name', sa.String(length=200), nullable=False), + sa.Column('description', sa.Text(), nullable=False), + sa.Column('scan_depth', sa.Integer(), nullable=True), + sa.Column('token_budget', sa.Integer(), nullable=True), + sa.Column('recursive_scanning', sa.Boolean(), nullable=False), + sa.Column('scope', sa.String(length=40), nullable=False), + sa.Column('status', sa.String(length=40), nullable=False), + sa.Column('owner_scope', sa.String(length=80), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_lorebooks_character_id'), 'lorebooks', ['character_id'], unique=False) + op.create_index(op.f('ix_lorebooks_owner_scope'), 'lorebooks', ['owner_scope'], unique=False) + op.create_index(op.f('ix_lorebooks_scope'), 'lorebooks', ['scope'], unique=False) + op.create_index(op.f('ix_lorebooks_status'), 'lorebooks', ['status'], unique=False) + op.create_table('lorebook_entries', + sa.Column('id', sa.String(length=80), nullable=False), + sa.Column('lorebook_id', sa.String(length=80), nullable=False), + sa.Column('keys_json', sa.Text(), nullable=False), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('secondary_keys_json', sa.Text(), nullable=False), + sa.Column('constant', sa.Boolean(), nullable=False), + sa.Column('selective', sa.Boolean(), nullable=False), + sa.Column('enabled', sa.Boolean(), nullable=False), + sa.Column('case_sensitive', sa.Boolean(), nullable=False), + sa.Column('use_regex', sa.Boolean(), nullable=False), + sa.Column('position', sa.String(length=40), nullable=False), + sa.Column('insertion_order', sa.Integer(), nullable=False), + sa.Column('priority', sa.Integer(), nullable=False), + sa.Column('comment', sa.String(length=500), nullable=False), + sa.Column('bound_entity_id', sa.String(length=80), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['lorebook_id'], ['lorebooks.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_lorebook_entries_bound_entity_id'), 'lorebook_entries', ['bound_entity_id'], unique=False) + op.create_index(op.f('ix_lorebook_entries_lorebook_id'), 'lorebook_entries', ['lorebook_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_lorebook_entries_lorebook_id'), table_name='lorebook_entries') + op.drop_index(op.f('ix_lorebook_entries_bound_entity_id'), table_name='lorebook_entries') + op.drop_table('lorebook_entries') + op.drop_index(op.f('ix_lorebooks_status'), table_name='lorebooks') + op.drop_index(op.f('ix_lorebooks_scope'), table_name='lorebooks') + op.drop_index(op.f('ix_lorebooks_owner_scope'), table_name='lorebooks') + op.drop_index(op.f('ix_lorebooks_character_id'), table_name='lorebooks') + op.drop_table('lorebooks') + # ### end Alembic commands ### diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..2ad1d65 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +"""魂镜后端应用包。""" diff --git a/backend/app/ai_provider/__init__.py b/backend/app/ai_provider/__init__.py new file mode 100644 index 0000000..7c38a32 --- /dev/null +++ b/backend/app/ai_provider/__init__.py @@ -0,0 +1,63 @@ +from app.ai_provider.config.loader import load_models_config, load_providers_config +from app.ai_provider.config.models import ModelConfig, ProviderConfig + +# 嵌入 / 重排序 +from app.ai_provider.embedding import ( + EmbeddingProtocol, + EmbeddingRequest, + EmbeddingResponse, + EmbeddingResult, + EmbeddingService, + EmbeddingServiceError, +) +from app.ai_provider.manager import ProviderManager, ProviderManagerFactory +from app.ai_provider.reranker import ( + RerankerProtocol, + RerankerService, + RerankerServiceError, + RerankRequest, + RerankResponse, + RerankResult, +) +from app.ai_provider.types.chat import ChatChunk, ChatResult, TokenUsage +from app.ai_provider.types.errors import AIProviderError +from app.ai_provider.types.messages import ( + AIMessage, + BaseMessage, + HumanMessage, + SystemMessage, + ToolMessage, +) + +__all__ = [ + # LLM + "ProviderManager", + "ProviderManagerFactory", + "BaseMessage", + "SystemMessage", + "HumanMessage", + "AIMessage", + "ToolMessage", + "ChatChunk", + "ChatResult", + "TokenUsage", + "AIProviderError", + "ModelConfig", + "ProviderConfig", + "load_models_config", + "load_providers_config", + # Embedding + "EmbeddingProtocol", + "EmbeddingRequest", + "EmbeddingResponse", + "EmbeddingResult", + "EmbeddingService", + "EmbeddingServiceError", + # Reranker + "RerankerProtocol", + "RerankRequest", + "RerankResponse", + "RerankResult", + "RerankerService", + "RerankerServiceError", +] diff --git a/backend/app/ai_provider/audio/__init__.py b/backend/app/ai_provider/audio/__init__.py new file mode 100644 index 0000000..1b3a934 --- /dev/null +++ b/backend/app/ai_provider/audio/__init__.py @@ -0,0 +1,197 @@ +"""音频服务 — ASR / TTS 适配器外观。 + +用法:: + + from app.ai_provider.audio import AudioService + + service = AudioService(factory) + result = await service.transcribe(audio_bytes, format="wav") + result = await service.synthesize("你好,魂镜", voice="default") + +``AudioService`` 通过注册表中带有 ``asr`` / ``tts`` 能力标签的模型来解析 +使用哪个模型,然后将创建委托给 ``ModelFactory.create_audio()``。 +""" + +from __future__ import annotations + +from app.ai_provider.audio.base import ( + ASRProtocol, + ASRRequest, + ASRResponse, + AudioServiceError, + TTSProtocol, + TTSRequest, + TTSResponse, +) + + +class AudioService: + """外观类,负责解析 ASR / TTS 模型并委托给 ProviderManager。 + + 接受 ProviderManager 或实现了以下接口的适配器: + - ``.registry`` → 具有 ``list_capability_models(capability: str)`` 方法 + - ``.create_audio(name: str)`` → 返回 ASRProtocol / TTSProtocol 实例 + """ + + def __init__(self, manager) -> None: + self._manager = manager + self._asr_adapter: ASRProtocol | None = None + self._tts_adapter: TTSProtocol | None = None + + # ── Registry 兼容适配 ───────────────────────────────────────── + + @property + def _registry(self): + """按需获取 registry,兼容 adapter 和裸 ProviderManager/ModelRegistry。""" + mgr = self._manager + if hasattr(mgr, "registry"): + return mgr.registry + if hasattr(mgr, "model_registry"): + return mgr.model_registry + return mgr + + def _list_capability_models(self, tag: str): + reg = self._registry + # 适配器接口(list_capability_models)vs ModelRegistry 接口(list_by_capability) + if hasattr(reg, "list_capability_models"): + return reg.list_capability_models(tag) + if hasattr(reg, "list_by_capability"): + return reg.list_by_capability(tag) + return [] + + @property + def _factory(self): + mgr = self._manager + if hasattr(mgr, "create_audio"): + return mgr + return None + + # ── ASR ────────────────────────────────────────────────────────────── + + @property + def asr(self) -> ASRProtocol: + """解析并缓存 ASR 适配器(取注册表中第一个活跃 ASR 模型)。""" + if self._asr_adapter is None: + self._asr_adapter = self._resolve_asr() + return self._asr_adapter + + def asr_for(self, model_name: str) -> ASRProtocol: + """解析并缓存指定模型名的 ASR 适配器(覆盖默认第一个)。""" + self._asr_adapter = self._factory.create_audio(model_name) + return self._asr_adapter + + async def transcribe( + self, + audio_data: bytes, + format: str = "wav", + language: str | None = None, + model_name: str | None = None, + ) -> ASRResponse: + """快捷方法:构造请求并调用 ASR 适配器。 + + Args: + model_name: 可选,指定使用哪个 ASR 模型(按注册表逻辑名)。 + 省略时取注册表中第一个活跃的 ASR 模型。 + """ + if model_name is not None: + adapter = self.asr_for(model_name) + else: + adapter = self.asr + request = ASRRequest( + audio_data=audio_data, + format=format, + language=language, + ) + return await adapter.transcribe(request) + + # ── TTS ────────────────────────────────────────────────────────────── + + @property + def tts(self) -> TTSProtocol: + """解析并缓存 TTS 适配器。""" + if self._tts_adapter is None: + self._tts_adapter = self._resolve_tts() + return self._tts_adapter + + async def synthesize( + self, + text: str, + voice: str = "default", + response_format: str = "wav", + extra: dict | None = None, + ) -> TTSResponse: + """快捷方法:构造请求并调用 TTS 适配器。""" + request = TTSRequest( + text=text, + voice=voice, + response_format=response_format, + extra=extra, + ) + return await self.tts.synthesize(request) + + # ── 解析 ─────────────────────────────────────────────────────── + + def _resolve_asr(self) -> ASRProtocol: + """查找第一个活跃的 ASR 模型并通过工厂创建。""" + models = self._list_capability_models("asr") + if not models: + raise AudioServiceError( + status_code=503, + code="audio_asr_no_model", + message="No ASR model configured in the model registry.", + ) + factory = self._factory + if factory is None: + raise AudioServiceError( + status_code=501, + code="audio_asr_no_factory", + message="Audio adapter factory not available (provider manager missing create_audio).", + ) + return factory.create_audio(models[0].name) + + def _resolve_tts(self) -> TTSProtocol: + """查找第一个活跃的 TTS 模型并通过工厂创建。""" + models = self._list_capability_models("tts") + if not models: + raise AudioServiceError( + status_code=503, + code="audio_tts_no_model", + message="No TTS model configured in the model registry.", + ) + factory = self._factory + if factory is None: + raise AudioServiceError( + status_code=501, + code="audio_tts_no_factory", + message="Audio adapter factory not available (provider manager missing create_audio).", + ) + return factory.create_audio(models[0].name) + + # ── 生命周期 ──────────────────────────────────────────────────────── + + async def close(self) -> None: + """释放适配器持有的 HTTP 连接。""" + for adapter in (self._asr_adapter, self._tts_adapter): + if adapter is None: + continue + close = getattr(adapter, "close", None) + if close is not None: + await close() + + async def __aenter__(self) -> AudioService: + return self + + async def __aexit__(self, *exc_info: object) -> None: + await self.close() + + +__all__ = [ + "ASRProtocol", + "ASRRequest", + "ASRResponse", + "AudioService", + "AudioServiceError", + "TTSProtocol", + "TTSRequest", + "TTSResponse", +] diff --git a/backend/app/ai_provider/audio/asr.py b/backend/app/ai_provider/audio/asr.py new file mode 100644 index 0000000..251a7b6 --- /dev/null +++ b/backend/app/ai_provider/audio/asr.py @@ -0,0 +1,263 @@ +"""MiMo ASR adapter — ``POST /v1/chat/completions`` with ``input_audio``. + +MiMo 的 ASR 不走独立音频端点,而是通过聊天补全接口扩展实现: + + POST {base_url}/chat/completions + Content-Type: application/json + Authorization: Bearer {api_key} + + { + "model": "mimo-v2.5-asr", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "input_audio", + "input_audio": { + "data": "data:audio/wav;base64," + } + } + ] + } + ], + "asr_options": { + "language": "auto" + } + } + +响应:: + + { + "choices": [{ + "message": { + "content": "转写后的文本" + } + }] + } +""" + +from __future__ import annotations + +import base64 +import hashlib +import time + +import httpx + +from app.ai_provider.audio.base import ( + ASRProtocol, + ASRRequest, + ASRResponse, + AudioAdapterBase, + AudioServiceError, +) +from app.core.logging import logger + +_MIME_MAP = { + "wav": "audio/wav", + "mp3": "audio/mpeg", + "m4a": "audio/mp4", + "flac": "audio/flac", +} + +# ASR 缓存:默认最多 128 条,TTL 1 小时 +_ASR_CACHE_MAX_SIZE = 128 +_ASR_CACHE_TTL_SECONDS = 3600 + + +class _ASRCacheEntry: + __slots__ = ("text", "stored_at") + + def __init__(self, text: str) -> None: + self.text = text + self.stored_at = time.monotonic() + + +_asr_cache: dict[str, _ASRCacheEntry] = {} + + +def _asr_cache_key(model: str, audio_data: bytes, language: str, fmt: str) -> str: + """生成 ASR 缓存键:SHA256(model + audio_bytes + language + format)。""" + raw = f"{model}\0{language}\0{fmt}\0" + raw_bytes = raw.encode() + audio_data + return hashlib.sha256(raw_bytes).hexdigest() + + +def _asr_cache_get(key: str) -> str | None: + entry = _asr_cache.get(key) + if entry is None: + return None + if time.monotonic() - entry.stored_at > _ASR_CACHE_TTL_SECONDS: + del _asr_cache[key] + return None + return entry.text + + +def _asr_cache_set(key: str, text: str) -> None: + if len(_asr_cache) >= _ASR_CACHE_MAX_SIZE: + sorted_keys = sorted(_asr_cache.keys(), key=lambda k: _asr_cache[k].stored_at) + for old_key in sorted_keys[: _ASR_CACHE_MAX_SIZE // 2]: + del _asr_cache[old_key] + _asr_cache[key] = _ASRCacheEntry(text) + + +class HunjingASROpenAI(AudioAdapterBase, ASRProtocol): + """MiMo ASR 适配器,通过 ``POST /chat/completions`` 携带 ``input_audio`` 实现。 + + 将音频字节包装为 chat 消息中的 ``input_audio`` 内容类型的 data URL, + 然后从响应中提取转写文本。 + """ + + _adapter_type = "asr" + _default_timeout = 300 + + def __init__( + self, + *, + base_url: str, + api_key: str, + model: str = "mimo-v2.5-asr", + request_timeout_seconds: int = 300, + ) -> None: + self._base_url = base_url.rstrip("/") + self._api_key = api_key + self._model = model + self._timeout = request_timeout_seconds + self._client: httpx.AsyncClient | None = None + + # ── 客户端 ─────────────────────────────────────────────────────────── + + @property + def _http(self) -> httpx.AsyncClient: + if self._client is None or self._client.is_closed: + self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout)) + return self._client + + async def close(self) -> None: + if self._client is not None and not self._client.is_closed: + await self._client.aclose() + + # ── ASRProtocol ────────────────────────────────────────────────────── + + async def transcribe(self, request: ASRRequest) -> ASRResponse: + """通过 ``POST /chat/completions`` 转写音频。 + + 将音频编码为 data URL 并以 ``input_audio`` 内容块的形式发送到 chat 消息中。 + """ + # ── 缓存查找 ────────────────────────────────────────────────── + cache_key = _asr_cache_key( + self._model, request.audio_data, request.language or "auto", request.format + ) + cached = _asr_cache_get(cache_key) + if cached is not None: + logger.info("audio_asr.cache_hit", model=self._model, text_length=len(cached)) + return ASRResponse(text=cached) + + # ── 构造请求 ────────────────────────────────────────────────── + mime = _MIME_MAP.get(request.format, "audio/wav") + b64_audio = base64.b64encode(request.audio_data).decode() + data_url = f"data:{mime};base64,{b64_audio}" + + payload: dict = { + "model": self._model, + "messages": [ + { + "role": "user", + "content": [ + { + "type": "input_audio", + "input_audio": { + "data": data_url, + }, + }, + ], + }, + ], + "asr_options": { + "language": request.language or "auto", + }, + } + + headers = { + "Authorization": f"Bearer {self._api_key}", + "Content-Type": "application/json", + } + + url = f"{self._base_url}/chat/completions" + + logger.info( + "audio_asr.request", + model=self._model, + format=request.format, + language=request.language, + url=url, + size_bytes=len(request.audio_data), + ) + + # ── API 调用 ──────────────────────────────────────────────── + try: + response = await self._http.post(url, json=payload, headers=headers) + except httpx.TimeoutException as exc: + raise AudioServiceError( + status_code=504, + code="audio_asr_timeout", + message="ASR request timed out calling chat completions.", + ) from exc + except httpx.RequestError as exc: + raise AudioServiceError( + status_code=503, + code="audio_asr_connection_error", + message=f"ASR connection error: {exc}", + ) from exc + + if response.status_code != 200: + body = response.text[:500] + raise AudioServiceError( + status_code=response.status_code, + code="audio_asr_api_error", + message=f"ASR API returned {response.status_code}: {body}", + ) + + # 解析响应,提取转写文本 + text = self._extract_text(response.json()) + if not text: + raise AudioServiceError( + status_code=500, + code="audio_asr_empty_response", + message="ASR API returned empty transcription.", + ) + + # ── 写入缓存 ──────────────────────────────────────────────── + _asr_cache_set(cache_key, text) + + logger.info( + "audio_asr.success", + model=self._model, + text_length=len(text), + cache_size=len(_asr_cache), + ) + return ASRResponse(text=text) + + # ── 辅助方法 ────────────────────────────────────────────────────────── + + @staticmethod + def _extract_text(body: dict) -> str: + """从 chat completion 响应中提取转写文本。""" + try: + choices = body.get("choices", []) + if not choices: + raise AudioServiceError( + status_code=500, + code="audio_asr_no_choices", + message="ASR response has no choices.", + ) + return (choices[0].get("message") or {}).get("content") or "" + except AudioServiceError: + raise + except Exception as exc: + raise AudioServiceError( + status_code=500, + code="audio_asr_response_parse_error", + message=f"Failed to parse ASR response: {exc}", + ) from exc diff --git a/backend/app/ai_provider/audio/base.py b/backend/app/ai_provider/audio/base.py new file mode 100644 index 0000000..28de0e7 --- /dev/null +++ b/backend/app/ai_provider/audio/base.py @@ -0,0 +1,245 @@ +"""音频服务类型 — 请求/响应模型和协议定义。 + +ASR(自动语音识别)和 TTS(文本转语音)适配器**不是** LangChain 的 +``BaseChatModel`` 子类 —— 它们是通过 HTTP 调用的服务,经由 ``AudioService`` +或直接从 ``ModelFactory`` 消费。 + +用法:: + + service = AudioService(factory) + result = await service.transcribe(audio_data) + result = await service.synthesize("你好") + + # 或通过工厂直接调用: + tts = factory.create_audio("mimo-v2.5-tts") + result = await tts.synthesize(TTSRequest(text="你好")) +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable + +from app.ai_provider.types.errors import AIProviderError + +if TYPE_CHECKING: + from app.ai_provider.config.models import ModelConfig, ModelEndpointConfig + + +class AudioServiceError(AIProviderError): + """ASR / TTS 服务失败的基础错误。""" + + +# ── ASR ─────────────────────────────────────────────────────────────────── + + +class ASRRequest: + """将音频转写为文本的请求。 + + 属性: + audio_data: 原始音频字节。 + format: 音频容器格式(如 ``"wav"``、``"mp3"``、``"m4a"``)。 + language: 可选的 ISO-639-1 语言提示(如 ``"zh"``、``"en"``)。 + """ + + __slots__ = ("audio_data", "format", "language") + + def __init__( + self, + audio_data: bytes, + format: str = "wav", + language: str | None = None, + ) -> None: + self.audio_data = audio_data + self.format = format + self.language = language + + +@dataclass(frozen=True) +class ASRResponse: + """成功转写的结果。""" + + text: str + + +@runtime_checkable +class ASRProtocol(Protocol): + """ASR 适配器的协议。""" + + async def transcribe(self, request: ASRRequest) -> ASRResponse: + """将音频字节转写为文本。 + + 异常: + AudioServiceError: API 调用失败或响应异常时抛出。 + """ + ... + + +# ── TTS ─────────────────────────────────────────────────────────────────── + + +class TTSRequest: + """将文本合成为语音的请求。 + + 属性: + text: 待朗读的文本。 + voice: 音色标识符(模型决定可用音色)。 + response_format: 期望的音频格式(``"wav"``、``"mp3"``、``"opus"``)。 + extra: 厂商特定的额外参数(如音色克隆/设计参数)。 + """ + + __slots__ = ("text", "voice", "response_format", "extra") + + def __init__( + self, + text: str, + voice: str = "default", + response_format: str = "wav", + extra: dict | None = None, + ) -> None: + self.text = text + self.voice = voice + self.response_format = response_format + self.extra = extra or {} + + +@dataclass(frozen=True) +class TTSResponse: + """成功合成的结果。""" + + audio_data: bytes + format: str + + +@runtime_checkable +class TTSProtocol(Protocol): + """TTS 适配器的协议。""" + + async def synthesize(self, request: TTSRequest) -> TTSResponse: + """将文本合成为音频字节。 + + 异常: + AudioServiceError: API 调用失败或响应异常时抛出。 + """ + ... + + +# ── 共享工厂基类 ────────────────────────────────────────────────────────── + + +class AudioAdapterBase: + """ASR/TTS 适配器的基类,提供共享的工厂方法。 + + 子类必须设置 ``_adapter_type``(如 ``"asr"``、``"tts"``),并在 + ``__init__`` 中接受 ``base_url``、``api_key``、``model``、 + ``request_timeout_seconds`` 参数。 + """ + + _adapter_type: str = "audio" + _default_timeout: int = 120 + + @classmethod + def from_endpoint( + cls, + endpoint: ModelEndpointConfig, + *, + provider: ProviderConfig | None = None, + timeout_seconds: int | None = None, + ) -> Any: + """从已解析的端点配置构建适配器。 + + 凭证解析优先级: + 1. endpoint 直填的 ``base_url`` / ``api_key`` + 2. endpoint 的 ``base_url_env`` / ``api_key_env`` 指向的 settings 字段 + 3. provider 级凭证(调用方按需传入 ProviderConfig) + """ + base_url = _resolve_base_url(endpoint, provider) + api_key = _resolve_api_key(endpoint, provider) + if not base_url: + raise AudioServiceError( + status_code=500, + code=f"audio_{cls._adapter_type}_endpoint_invalid", + message=( + f"{cls._adapter_type.upper()} endpoint '{endpoint.name}' " + "has no resolved base_url." + ), + ) + return cls( + base_url=base_url, + api_key=api_key, + model=endpoint.model, + request_timeout_seconds=timeout_seconds + or endpoint.request_timeout_seconds + or cls._default_timeout, + ) + + @classmethod + def from_model_config( + cls, + config: ModelConfig, + *, + provider: ProviderConfig | None = None, + timeout_seconds: int | None = None, + ) -> Any: + """从逻辑模型配置构建适配器(使用第一个活跃端点)。""" + for ep in config.endpoints: + if ep.status == "active": + return cls.from_endpoint( + ep, provider=provider, timeout_seconds=timeout_seconds + ) + raise AudioServiceError( + status_code=500, + code=f"audio_{cls._adapter_type}_no_endpoint", + message=f"{cls._adapter_type.upper()} model '{config.name}' has no active endpoint.", + ) + + +def _resolve_base_url( + endpoint: ModelEndpointConfig, + provider: ProviderConfig | None, +) -> str: + """端点 base_url → endpoint.base_url_env 对应 settings → provider.base_url(_env)。""" + if endpoint.base_url: + return endpoint.base_url + if endpoint.base_url_env: + val = _settings_get(endpoint.base_url_env) + if val: + return val + if provider is not None: + if provider.base_url: + return provider.base_url + if provider.base_url_env: + val = _settings_get(provider.base_url_env) + if val: + return val + return "" + + +def _resolve_api_key( + endpoint: ModelEndpointConfig, + provider: ProviderConfig | None, +) -> str: + """端点 api_key → endpoint.api_key_env 对应 settings → provider 级凭证。""" + if endpoint.api_key: + return endpoint.api_key + if endpoint.api_key_env: + val = _settings_get(endpoint.api_key_env) + if val: + return val + if provider is not None: + # ProviderConfig 的 api_key_env / base_url_env 也走 settings 解析 + val = _settings_get(provider.api_key_env) + if val: + return val + return "" + + +def _settings_get(env_name: str) -> str | None: + """按大写 env 名查 settings 上的小写字段(与现有 _resolve_credentials 约定一致)。""" + from app.core.config import settings + + key = env_name.lower() + if hasattr(settings, key): + value = getattr(settings, key) + return value if isinstance(value, str) else None + return None diff --git a/backend/app/ai_provider/audio/tts.py b/backend/app/ai_provider/audio/tts.py new file mode 100644 index 0000000..ac0bb90 --- /dev/null +++ b/backend/app/ai_provider/audio/tts.py @@ -0,0 +1,325 @@ +"""MiMo TTS adapter — ``POST /v1/chat/completions`` with ``audio`` parameter. + +MiMo 的 TTS 不走独立音频端点,而是通过聊天补全接口扩展实现: + + POST {base_url}/chat/completions + Content-Type: application/json + Authorization: Bearer {api_key} + + { + "model": "mimo-v2.5-tts", + "messages": [ + {"role": "user", "content": "(可选)音色风格描述,voicedesign 必填"}, + {"role": "assistant", "content": "要朗读的文本"} + ], + "audio": { + "format": "wav", + "voice": "mimo_default" + } + } + +响应:: + + { + "choices": [{ + "message": { + "audio": { + "data": "", + "id": "...", + "expires_at": null, + "transcript": null + } + } + }] + } + +模型差异: + +- ``mimo-v2.5-tts`` — ``voice`` 可选,默认 ``mimo_default``,仅支持预置音色 +- ``mimo-v2.5-tts-voiceclone`` — ``voice`` 必填,传入音频样本的 base64(mp3/wav) +- ``mimo-v2.5-tts-voicedesign`` — 不支持 ``voice``,\n ``user`` 消息必填(风格描述),支持 ``optimize_text_preview`` +""" + +from __future__ import annotations + +import base64 +import hashlib +import time + +import httpx + +from app.ai_provider.audio.base import ( + AudioAdapterBase, + AudioServiceError, + TTSProtocol, + TTSRequest, + TTSResponse, +) +from app.core.logging import logger + +# voicedesign 模型的默认风格描述(用户未提供时使用) +_DEFAULT_VOICE_DESIGN_PROMPT = "自然、清晰的朗读风格,语气平和,语速适中,适合日常对话场景。" + +# TTS 音频缓存:默认最多 256 条,TTL 1 小时 +_TTS_CACHE_MAX_SIZE = 256 +_TTS_CACHE_TTL_SECONDS = 3600 + + +class _TTSCacheEntry: + __slots__ = ("data", "format", "stored_at") + + def __init__(self, data: bytes, format: str) -> None: + self.data = data + self.format = format + self.stored_at = time.monotonic() + + +_tts_cache: dict[str, _TTSCacheEntry] = {} + + +def _tts_cache_key(model: str, voice: str, fmt: str, text: str, extra_json: str) -> str: + """生成 TTS 缓存键:SHA256(model + voice + format + text + extra)。""" + raw = f"{model}\0{voice}\0{fmt}\0{text}\0{extra_json}" + return hashlib.sha256(raw.encode()).hexdigest() + + +def _tts_cache_get(key: str) -> _TTSCacheEntry | None: + entry = _tts_cache.get(key) + if entry is None: + return None + if time.monotonic() - entry.stored_at > _TTS_CACHE_TTL_SECONDS: + del _tts_cache[key] + return None + return entry + + +def _tts_cache_set(key: str, entry: _TTSCacheEntry) -> None: + if len(_tts_cache) >= _TTS_CACHE_MAX_SIZE: + # 淘汰最旧的一半 + sorted_keys = sorted(_tts_cache.keys(), key=lambda k: _tts_cache[k].stored_at) + for old_key in sorted_keys[: _TTS_CACHE_MAX_SIZE // 2]: + del _tts_cache[old_key] + _tts_cache[key] = entry + + +class HunjingTTSOpenAI(AudioAdapterBase, TTSProtocol): + """MiMo TTS 适配器,通过 ``POST /chat/completions`` 携带 ``audio`` 参数实现。 + + 调用兼容 OpenAI 格式的 chat completions 端点,在请求体中添加 ``audio`` 字段, + 然后从响应中提取并解码 base64 音频数据。 + """ + + _adapter_type = "tts" + _default_timeout = 120 + + def __init__( + self, + *, + base_url: str, + api_key: str, + model: str = "mimo-v2.5-tts", + request_timeout_seconds: int = 120, + ) -> None: + self._base_url = base_url.rstrip("/") + self._api_key = api_key + self._model = model + self._timeout = request_timeout_seconds + self._client: httpx.AsyncClient | None = None + + # ── 客户端 ─────────────────────────────────────────────────────────── + + @property + def _http(self) -> httpx.AsyncClient: + if self._client is None or self._client.is_closed: + self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout)) + return self._client + + async def close(self) -> None: + if self._client is not None and not self._client.is_closed: + await self._client.aclose() + + # ── TTSProtocol ────────────────────────────────────────────────────── + + async def synthesize(self, request: TTSRequest) -> TTSResponse: + """通过 ``POST /chat/completions`` 将文本合成为音频。 + + 构造 chat 消息和 ``audio`` 参数,匹配 MiMo 兼容 OpenAI 格式的 TTS, + 然后从响应中解码 base64 音频。 + + 结果按 ``(model, voice, format, text)`` 在进程内缓存,避免重复 API 调用。 + """ + # ── 缓存查找 ────────────────────────────────────────────────────── + cache_key = _tts_cache_key( + model=self._model, + voice=request.voice, + fmt=request.response_format, + text=request.text, + extra_json=str(sorted((request.extra or {}).items())), + ) + cached = _tts_cache_get(cache_key) + if cached is not None: + logger.info("audio_tts.cache_hit", model=self._model, text_length=len(request.text)) + return TTSResponse(audio_data=cached.data, format=cached.format) + + # ── API 调用 ────────────────────────────────────────────────────── + url = f"{self._base_url}/chat/completions" + + # 构造 messages + messages = self._build_messages(request) + + # 构造 audio 参数 + audio_params = self._build_audio_params(request) + + payload: dict = { + "model": self._model, + "messages": messages, + "audio": audio_params, + "max_completion_tokens": 8192, + } + # 合并 extra(如 frequency_penalty, temperature 等) + if request.extra: + payload.update(request.extra) + + headers = { + "Authorization": f"Bearer {self._api_key}", + "Content-Type": "application/json", + } + + logger.info( + "audio_tts.request", + model=self._model, + voice=request.voice, + audio_format=request.response_format, + text_length=len(request.text), + url=url, + ) + + try: + response = await self._http.post(url, json=payload, headers=headers) + except httpx.TimeoutException as exc: + raise AudioServiceError( + status_code=504, + code="audio_tts_timeout", + message="TTS request timed out calling chat completions.", + ) from exc + except httpx.RequestError as exc: + raise AudioServiceError( + status_code=503, + code="audio_tts_connection_error", + message=f"TTS connection error: {exc}", + ) from exc + + if response.status_code != 200: + body = response.text[:500] + raise AudioServiceError( + status_code=response.status_code, + code="audio_tts_api_error", + message=f"TTS API returned {response.status_code}: {body}", + ) + + # 解析响应,提取 base64 音频 + audio_data = self._extract_audio(response.json()) + + # ── 写入缓存 ────────────────────────────────────────────────────── + _tts_cache_set(cache_key, _TTSCacheEntry(audio_data, request.response_format)) + + logger.info( + "audio_tts.success", + model=self._model, + audio_bytes=len(audio_data), + cache_size=len(_tts_cache), + ) + return TTSResponse(audio_data=audio_data, format=request.response_format) + + # ── 辅助方法 ────────────────────────────────────────────────────────── + + def _build_messages(self, request: TTSRequest) -> list[dict]: + """构造 chat messages 数组。 + + - voicedesign 模型:user 消息必填(风格描述) + - 其他模型:user 消息可选,assistant 消息存放待朗读文本 + """ + is_voicedesign = "voicedesign" in self._model + + if is_voicedesign: + # voicedesign:user 消息为风格提示,assistant 消息为朗读文本 + style_prompt = (request.extra or {}).pop("style_prompt", _DEFAULT_VOICE_DESIGN_PROMPT) + return [ + {"role": "user", "content": style_prompt}, + {"role": "assistant", "content": request.text}, + ] + # 基础 TTS / voiceclone:只传 assistant 消息 + return [ + {"role": "assistant", "content": request.text}, + ] + + def _build_audio_params(self, request: TTSRequest) -> dict: + """构造 ``audio`` 参数字段,按模型处理 voice/format/optimize_text_preview 等。""" + params: dict = { + "format": _resolve_api_format(request.response_format), + } + + is_voicedesign = "voicedesign" in self._model + + if is_voicedesign: + # voicedesign 不支持 voice 字段 + optimize = (request.extra or {}).pop("optimize_text_preview", None) + if optimize is not None: + params["optimize_text_preview"] = optimize + else: + # 基础 TTS / voiceclone:按需传 voice + if request.voice and request.voice != "default": + params["voice"] = request.voice + else: + params["voice"] = "mimo_default" + + return params + + @staticmethod + def _extract_audio(body: dict) -> bytes: + """从 chat completion 响应中提取并解码 base64 音频数据。""" + try: + choices = body.get("choices", []) + if not choices: + raise AudioServiceError( + status_code=500, + code="audio_tts_no_choices", + message="TTS response has no choices.", + ) + message = choices[0].get("message", {}) + audio_info = message.get("audio") + if not audio_info: + raise AudioServiceError( + status_code=500, + code="audio_tts_no_audio_field", + message="TTS response missing audio field in message.", + ) + raw = audio_info.get("data") + if not raw: + raise AudioServiceError( + status_code=500, + code="audio_tts_empty_audio_data", + message="TTS response has empty audio.data.", + ) + return base64.b64decode(raw) + except AudioServiceError: + raise + except Exception as exc: + raise AudioServiceError( + status_code=500, + code="audio_tts_response_parse_error", + message=f"Failed to parse TTS response: {exc}", + ) from exc + + +def _resolve_api_format(user_format: str) -> str: + """将用户层面的格式名映射为 API 期望的格式。 + + MiMo TTS API 接受:``wav``、``mp3``、``pcm``、``pcm16``。 + """ + fmt = user_format.lower() + if fmt in ("pcm", "pcm16"): + return "pcm16" + if fmt in ("wav", "mp3"): + return fmt + return "wav" diff --git a/backend/app/ai_provider/audio/yuanqi_asr.py b/backend/app/ai_provider/audio/yuanqi_asr.py new file mode 100644 index 0000000..4c19720 --- /dev/null +++ b/backend/app/ai_provider/audio/yuanqi_asr.py @@ -0,0 +1,231 @@ +"""yuanqi 本地 ASR 适配器 — ``POST /v1/audio/transcriptions`` (multipart)。 + +与 MiMo 走 ``/chat/completions`` + ``input_audio`` 不同,元炁本地推理引擎 +(yuanqi)对外暴露的是 OpenAI Whisper 兼容的 multipart 端点:: + + POST {base_url}/v1/audio/transcriptions + Content-Type: multipart/form-data + Authorization: Bearer {api_key} + + file: