Implement Alembic for database migrations and update FastAPI initialization
- Integrate Alembic for managing database schema migrations, replacing the previous SQL schema management. - Update the FastAPI application to synchronize interface definitions at startup. - Modify the Docker Compose command to run Alembic migrations before starting the API. - Enhance the Makefile with new commands for database migration and revision management. - Remove outdated SQL schema and seed files, transitioning to a more dynamic migration approach. - Add initial migration scripts and configuration for Alembic, ensuring a structured database evolution.
This commit is contained in:
19
Makefile
19
Makefile
@@ -5,9 +5,10 @@
|
|||||||
|
|
||||||
# 容器内执行 psql 的固定前缀(postgres/postgres/postgres,见 docker-compose.yaml)
|
# 容器内执行 psql 的固定前缀(postgres/postgres/postgres,见 docker-compose.yaml)
|
||||||
PSQL = docker compose exec -T postgres psql -U postgres -d postgres
|
PSQL = docker compose exec -T postgres psql -U postgres -d postgres
|
||||||
|
UV = UV_CACHE_DIR=/private/tmp/ai-video-uv-cache uv run --with-requirements requirements.txt
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
.PHONY: help up down restart logs api-logs db db-list db-up db-schema db-seed-interface-definitions db-init db-seed db-seed-model-resources db-seed-assistants db-clear db-reset db-drop-schema db-reset-hard
|
.PHONY: help up down restart logs api-logs db db-list db-up db-migrate db-revision db-current db-sync-interface-definitions db-init db-seed db-seed-model-resources db-seed-assistants db-clear db-reset db-drop-schema db-reset-hard
|
||||||
|
|
||||||
help: ## 列出所有可用目标
|
help: ## 列出所有可用目标
|
||||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
|
||||||
@@ -40,13 +41,19 @@ db-list: ## 列出模型资源与助手
|
|||||||
db-up: ## 仅启动 postgres
|
db-up: ## 仅启动 postgres
|
||||||
docker compose up -d postgres
|
docker compose up -d postgres
|
||||||
|
|
||||||
db-schema: ## 用纯 SQL 建表/补齐开发库表结构
|
db-migrate: ## 执行 Alembic 迁移到最新版
|
||||||
$(PSQL) < backend/db/schema.sql
|
cd backend && $(UV) alembic upgrade head
|
||||||
|
|
||||||
db-seed-interface-definitions: ## 灌入接口定义种子(幂等)
|
db-revision: ## 自动生成 Alembic 迁移: make db-revision msg="add xxx"
|
||||||
$(PSQL) < backend/db/seed_interface_definitions.sql
|
cd backend && $(UV) alembic revision --autogenerate -m "$(msg)"
|
||||||
|
|
||||||
db-init: db-schema db-seed-interface-definitions ## 用纯 SQL 建表并同步接口定义
|
db-current: ## 查看当前数据库迁移版本
|
||||||
|
cd backend && $(UV) alembic current
|
||||||
|
|
||||||
|
db-sync-interface-definitions: ## 从 interface_catalog.py 同步接口定义(幂等)
|
||||||
|
cd backend && $(UV) python -m db.sync_interface_definitions
|
||||||
|
|
||||||
|
db-init: db-migrate db-sync-interface-definitions ## 用 Alembic 建表并同步接口定义
|
||||||
|
|
||||||
db-seed-model-resources: ## 灌入 12 条模型资源种子(幂等)
|
db-seed-model-resources: ## 灌入 12 条模型资源种子(幂等)
|
||||||
$(PSQL) < backend/db/seed_model_resources.sql
|
$(PSQL) < backend/db/seed_model_resources.sql
|
||||||
|
|||||||
@@ -98,6 +98,9 @@ uv pip install fastapi "uvicorn[standard]" sqlalchemy asyncpg greenlet python-do
|
|||||||
|
|
||||||
cp .env.example .env # CRUD 阶段只需 DATABASE_URL;语音再填模型 key
|
cp .env.example .env # CRUD 阶段只需 DATABASE_URL;语音再填模型 key
|
||||||
# 起 Postgres:在 ai-video/ 下 docker compose up -d postgres
|
# 起 Postgres:在 ai-video/ 下 docker compose up -d postgres
|
||||||
|
cd ..
|
||||||
|
make db-migrate # 首次或表结构变更后执行 Alembic 迁移
|
||||||
|
cd backend
|
||||||
uv run --with-requirements requirements.txt uvicorn app:app --reload --port 8000
|
uv run --with-requirements requirements.txt uvicorn app:app --reload --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -106,6 +109,21 @@ uv run --with-requirements requirements.txt uvicorn app:app --reload --port 8000
|
|||||||
>
|
>
|
||||||
> 交互式 API 文档:启动后访问 http://localhost:8000/docs(手动戳 CRUD、定 schema 用)。
|
> 交互式 API 文档:启动后访问 http://localhost:8000/docs(手动戳 CRUD、定 schema 用)。
|
||||||
|
|
||||||
|
## 数据库迁移
|
||||||
|
|
||||||
|
表结构由 Alembic 管理,SQLAlchemy 模型在 `db/models.py`,迁移文件在
|
||||||
|
`migrations/versions/`。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ai-video
|
||||||
|
make db-migrate # 升级到最新版
|
||||||
|
make db-revision msg="add xxx" # 根据模型差异生成迁移
|
||||||
|
make db-current # 查看当前库版本
|
||||||
|
```
|
||||||
|
|
||||||
|
`interface_definitions` 的默认数据由 `services/interface_catalog.py` 维护,并通过
|
||||||
|
启动时同步或 `make db-sync-interface-definitions` 写入数据库;模型资源、知识库和助手示例数据继续走 `db/seed_*.sql`。
|
||||||
|
|
||||||
## Docker(ai-video/docker-compose.yaml)—— 调试主路径
|
## Docker(ai-video/docker-compose.yaml)—— 调试主路径
|
||||||
|
|
||||||
api 服务挂了源码 + `--reload`,前端用 npm dev + HMR,改代码都即时生效。
|
api 服务挂了源码 + `--reload`,前端用 npm dev + HMR,改代码都即时生效。
|
||||||
|
|||||||
41
backend/alembic.ini
Normal file
41
backend/alembic.ini
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = migrations
|
||||||
|
prepend_sys_path = .
|
||||||
|
path_separator = os
|
||||||
|
sqlalchemy.url = postgresql+asyncpg://postgres:postgres@localhost:5432/postgres
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
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
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""FastAPI 入口。挂载路由,放行前端跨域,启动时建表。
|
"""FastAPI 入口。挂载路由,放行前端跨域,启动时同步接口定义。
|
||||||
|
|
||||||
启动: uv run --with-requirements requirements.txt uvicorn app:app --reload --port 8000
|
启动: uv run --with-requirements requirements.txt uvicorn app:app --reload --port 8000
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ from contextlib import asynccontextmanager
|
|||||||
|
|
||||||
import config
|
import config
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from db.session import init_db
|
from db.session import sync_interface_definitions
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ from routes import (
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(_app: FastAPI):
|
async def lifespan(_app: FastAPI):
|
||||||
await init_db() # MVP:启动建表;表稳定后切 alembic
|
await sync_interface_definitions()
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ class KnowledgeBase(Base):
|
|||||||
embedding_model_resource_id: Mapped[str | None] = mapped_column(
|
embedding_model_resource_id: Mapped[str | None] = mapped_column(
|
||||||
String(40),
|
String(40),
|
||||||
ForeignKey("model_resources.id", ondelete="SET NULL"),
|
ForeignKey("model_resources.id", ondelete="SET NULL"),
|
||||||
|
index=True,
|
||||||
nullable=True,
|
nullable=True,
|
||||||
)
|
)
|
||||||
status: Mapped[str] = mapped_column(String(16), default="active") # active|archived
|
status: Mapped[str] = mapped_column(String(16), default="active") # active|archived
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
-- Development schema bootstrap. Keep this in sync with backend/db/models.py.
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS interface_definitions (
|
|
||||||
interface_type VARCHAR(64) PRIMARY KEY,
|
|
||||||
name VARCHAR(128) NOT NULL,
|
|
||||||
capability VARCHAR(16) NOT NULL,
|
|
||||||
field_schema JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
version INTEGER NOT NULL DEFAULT 1,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS model_resources (
|
|
||||||
id VARCHAR(40) PRIMARY KEY,
|
|
||||||
name VARCHAR(128) NOT NULL DEFAULT '',
|
|
||||||
capability VARCHAR(16) NOT NULL,
|
|
||||||
interface_type VARCHAR(64) NOT NULL REFERENCES interface_definitions(interface_type) ON DELETE RESTRICT,
|
|
||||||
values JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
secrets JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
support_image_input BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS knowledge_bases (
|
|
||||||
id VARCHAR(40) PRIMARY KEY,
|
|
||||||
name VARCHAR(128) NOT NULL,
|
|
||||||
description VARCHAR(2048) NOT NULL DEFAULT '',
|
|
||||||
embedding_model_resource_id VARCHAR(40) REFERENCES model_resources(id) ON DELETE SET NULL,
|
|
||||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS assistants (
|
|
||||||
id VARCHAR(40) PRIMARY KEY,
|
|
||||||
name VARCHAR(128) NOT NULL,
|
|
||||||
type VARCHAR(16) NOT NULL DEFAULT 'prompt',
|
|
||||||
runtime_mode VARCHAR(16) NOT NULL DEFAULT 'pipeline',
|
|
||||||
greeting VARCHAR(2048) NOT NULL DEFAULT '',
|
|
||||||
enable_interrupt BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
vision_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
vision_model_resource_id VARCHAR(40) REFERENCES model_resources(id) ON DELETE SET NULL,
|
|
||||||
knowledge_base_id VARCHAR(40) REFERENCES knowledge_bases(id) ON DELETE RESTRICT,
|
|
||||||
prompt VARCHAR(8192) NOT NULL DEFAULT '',
|
|
||||||
api_url VARCHAR(512) NOT NULL DEFAULT '',
|
|
||||||
api_key VARCHAR(512) NOT NULL DEFAULT '',
|
|
||||||
app_id VARCHAR(128) NOT NULL DEFAULT '',
|
|
||||||
graph JSON NOT NULL DEFAULT '{}'::json,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS assistant_model_bindings (
|
|
||||||
assistant_id VARCHAR(40) NOT NULL REFERENCES assistants(id) ON DELETE CASCADE,
|
|
||||||
capability VARCHAR(16) NOT NULL,
|
|
||||||
model_resource_id VARCHAR(40) NOT NULL REFERENCES model_resources(id) ON DELETE RESTRICT,
|
|
||||||
config JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
PRIMARY KEY (assistant_id, capability)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Lightweight forward-compatible patches for existing dev databases.
|
|
||||||
ALTER TABLE interface_definitions
|
|
||||||
ALTER COLUMN field_schema TYPE JSONB USING field_schema::jsonb;
|
|
||||||
|
|
||||||
ALTER TABLE assistants
|
|
||||||
ADD COLUMN IF NOT EXISTS vision_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
ADD COLUMN IF NOT EXISTS vision_model_resource_id VARCHAR(40);
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM pg_constraint WHERE conname = 'assistants_vision_model_resource_id_fkey'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE assistants
|
|
||||||
ADD CONSTRAINT assistants_vision_model_resource_id_fkey
|
|
||||||
FOREIGN KEY (vision_model_resource_id) REFERENCES model_resources(id) ON DELETE SET NULL;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_interface_definitions_capability ON interface_definitions(capability);
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_model_resources_capability ON model_resources(capability);
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_model_resources_interface_type ON model_resources(interface_type);
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_knowledge_bases_embedding_model_resource_id ON knowledge_bases(embedding_model_resource_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_assistants_type ON assistants(type);
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_assistant_model_bindings_model_resource_id ON assistant_model_bindings(model_resource_id);
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
-- Built-in interface definitions. Mirrors backend/services/interface_catalog.py.
|
|
||||||
|
|
||||||
INSERT INTO interface_definitions
|
|
||||||
(interface_type, name, capability, field_schema, enabled, version)
|
|
||||||
VALUES
|
|
||||||
('openai-llm', 'OpenAI Compatible LLM', 'LLM',
|
|
||||||
$$ {"fields":[
|
|
||||||
{"key":"modelId","label":"Model ID","group":"values","type":"text","required":true},
|
|
||||||
{"key":"apiUrl","label":"API URL","group":"values","type":"url","required":true},
|
|
||||||
{"key":"apiKey","label":"API Key","group":"secrets","type":"password","required":true},
|
|
||||||
{"key":"temperature","label":"Temperature","group":"values","type":"number","required":false,"default":0.7}
|
|
||||||
]} $$::jsonb, TRUE, 1),
|
|
||||||
('openai-asr', 'OpenAI Compatible ASR', 'ASR',
|
|
||||||
$$ {"fields":[
|
|
||||||
{"key":"modelId","label":"Model ID","group":"values","type":"text","required":true},
|
|
||||||
{"key":"apiUrl","label":"API URL","group":"values","type":"url","required":true},
|
|
||||||
{"key":"apiKey","label":"API Key","group":"secrets","type":"password","required":true},
|
|
||||||
{"key":"language","label":"Language","group":"values","type":"text","required":false,"default":"zh"}
|
|
||||||
]} $$::jsonb, TRUE, 1),
|
|
||||||
('openai-tts', 'OpenAI Compatible TTS', 'TTS',
|
|
||||||
$$ {"fields":[
|
|
||||||
{"key":"modelId","label":"Model ID","group":"values","type":"text","required":true},
|
|
||||||
{"key":"apiUrl","label":"API URL","group":"values","type":"url","required":true},
|
|
||||||
{"key":"apiKey","label":"API Key","group":"secrets","type":"password","required":true},
|
|
||||||
{"key":"voice","label":"Voice","group":"values","type":"text","required":false},
|
|
||||||
{"key":"speed","label":"Speed","group":"values","type":"number","required":false,"default":1.0},
|
|
||||||
{"key":"sourceSampleRate","label":"Source Sample Rate","group":"values","type":"number","required":false,"default":24000}
|
|
||||||
]} $$::jsonb, TRUE, 1),
|
|
||||||
('openai-embedding', 'OpenAI Compatible Embedding', 'Embedding',
|
|
||||||
$$ {"fields":[
|
|
||||||
{"key":"modelId","label":"Model ID","group":"values","type":"text","required":true},
|
|
||||||
{"key":"apiUrl","label":"API URL","group":"values","type":"url","required":true},
|
|
||||||
{"key":"apiKey","label":"API Key","group":"secrets","type":"password","required":true},
|
|
||||||
{"key":"dimensions","label":"Dimensions","group":"values","type":"number","required":false}
|
|
||||||
]} $$::jsonb, TRUE, 1),
|
|
||||||
('openai-realtime', 'OpenAI Realtime', 'Realtime',
|
|
||||||
$$ {"fields":[
|
|
||||||
{"key":"modelId","label":"Model ID","group":"values","type":"text","required":true},
|
|
||||||
{"key":"apiUrl","label":"API URL","group":"values","type":"url","required":true},
|
|
||||||
{"key":"apiKey","label":"API Key","group":"secrets","type":"password","required":true},
|
|
||||||
{"key":"voice","label":"Voice","group":"values","type":"text","required":false}
|
|
||||||
]} $$::jsonb, TRUE, 1),
|
|
||||||
('dify', 'Dify Agent', 'Agent',
|
|
||||||
$$ {"fields":[
|
|
||||||
{"key":"apiUrl","label":"API URL","group":"values","type":"url","required":true},
|
|
||||||
{"key":"apiKey","label":"API Key","group":"secrets","type":"password","required":true}
|
|
||||||
]} $$::jsonb, TRUE, 1),
|
|
||||||
('fastgpt', 'FastGPT Agent', 'Agent',
|
|
||||||
$$ {"fields":[
|
|
||||||
{"key":"apiUrl","label":"API URL","group":"values","type":"url","required":true},
|
|
||||||
{"key":"apiKey","label":"API Key","group":"secrets","type":"password","required":true},
|
|
||||||
{"key":"appId","label":"App ID","group":"values","type":"text","required":true}
|
|
||||||
]} $$::jsonb, TRUE, 1),
|
|
||||||
('opencode', 'OpenCode Agent', 'Agent',
|
|
||||||
$$ {"fields":[
|
|
||||||
{"key":"apiUrl","label":"API URL","group":"values","type":"url","required":true},
|
|
||||||
{"key":"apiKey","label":"API Key","group":"secrets","type":"password","required":true}
|
|
||||||
]} $$::jsonb, TRUE, 1),
|
|
||||||
('stepfun-realtime', 'StepFun StepAudio Realtime', 'Realtime',
|
|
||||||
$$ {"fields":[
|
|
||||||
{"key":"modelId","label":"Model ID","group":"values","type":"text","required":true},
|
|
||||||
{"key":"apiUrl","label":"API URL","group":"values","type":"url","required":true},
|
|
||||||
{"key":"apiKey","label":"API Key","group":"secrets","type":"password","required":true},
|
|
||||||
{"key":"voice","label":"Voice","group":"values","type":"text","required":true,"default":"linjiajiejie"},
|
|
||||||
{"key":"inputSampleRate","label":"Input Sample Rate","group":"values","type":"number","required":false,"default":24000},
|
|
||||||
{"key":"outputSampleRate","label":"Output Sample Rate","group":"values","type":"number","required":false,"default":24000},
|
|
||||||
{"key":"prefixPaddingMs","label":"VAD Prefix Padding (ms)","group":"values","type":"number","required":false,"default":500},
|
|
||||||
{"key":"silenceDurationMs","label":"VAD Silence Duration (ms)","group":"values","type":"number","required":false,"default":300},
|
|
||||||
{"key":"energyAwakenessThreshold","label":"VAD Energy Threshold","group":"values","type":"number","required":false,"default":2500}
|
|
||||||
]} $$::jsonb, TRUE, 1),
|
|
||||||
('xfyun-asr', 'Xfyun Streaming ASR', 'ASR',
|
|
||||||
$$ {"fields":[
|
|
||||||
{"key":"apiUrl","label":"WebSocket URL","group":"values","type":"url","required":true},
|
|
||||||
{"key":"appId","label":"App ID","group":"secrets","type":"password","required":true},
|
|
||||||
{"key":"apiKey","label":"API Key","group":"secrets","type":"password","required":true},
|
|
||||||
{"key":"apiSecret","label":"API Secret","group":"secrets","type":"password","required":true},
|
|
||||||
{"key":"language","label":"Language","group":"values","type":"text","required":false,"default":"zh_cn"},
|
|
||||||
{"key":"domain","label":"Domain","group":"values","type":"text","required":false,"default":"iat"},
|
|
||||||
{"key":"accent","label":"Accent","group":"values","type":"text","required":false,"default":"mandarin"},
|
|
||||||
{"key":"dynamicCorrection","label":"Dynamic Correction","group":"values","type":"boolean","required":false,"default":false},
|
|
||||||
{"key":"frameSize","label":"Frame Size","group":"values","type":"number","required":false,"default":1280}
|
|
||||||
]} $$::jsonb, TRUE, 1),
|
|
||||||
('xfyun-tts', 'Xfyun TTS', 'TTS',
|
|
||||||
$$ {"fields":[
|
|
||||||
{"key":"apiUrl","label":"WebSocket URL","group":"values","type":"url","required":true},
|
|
||||||
{"key":"appId","label":"App ID","group":"secrets","type":"password","required":true},
|
|
||||||
{"key":"apiKey","label":"API Key","group":"secrets","type":"password","required":true},
|
|
||||||
{"key":"apiSecret","label":"API Secret","group":"secrets","type":"password","required":true},
|
|
||||||
{"key":"voice","label":"Voice","group":"values","type":"text","required":false},
|
|
||||||
{"key":"speed","label":"Speed","group":"values","type":"number","required":false,"default":50},
|
|
||||||
{"key":"volume","label":"Volume","group":"values","type":"number","required":false,"default":50},
|
|
||||||
{"key":"pitch","label":"Pitch","group":"values","type":"number","required":false,"default":50},
|
|
||||||
{"key":"sourceSampleRate","label":"Source Sample Rate","group":"values","type":"number","required":false,"default":16000}
|
|
||||||
]} $$::jsonb, TRUE, 1),
|
|
||||||
('xfyun-super-tts', 'Xfyun Super TTS', 'TTS',
|
|
||||||
$$ {"fields":[
|
|
||||||
{"key":"apiUrl","label":"WebSocket URL","group":"values","type":"url","required":true},
|
|
||||||
{"key":"appId","label":"App ID","group":"secrets","type":"password","required":true},
|
|
||||||
{"key":"apiKey","label":"API Key","group":"secrets","type":"password","required":true},
|
|
||||||
{"key":"apiSecret","label":"API Secret","group":"secrets","type":"password","required":true},
|
|
||||||
{"key":"voice","label":"Voice","group":"values","type":"text","required":false},
|
|
||||||
{"key":"speed","label":"Speed","group":"values","type":"number","required":false,"default":50},
|
|
||||||
{"key":"volume","label":"Volume","group":"values","type":"number","required":false,"default":50},
|
|
||||||
{"key":"pitch","label":"Pitch","group":"values","type":"number","required":false,"default":50},
|
|
||||||
{"key":"oralLevel","label":"Oral Level","group":"values","type":"text","required":false,"default":"mid"},
|
|
||||||
{"key":"sourceSampleRate","label":"Source Sample Rate","group":"values","type":"number","required":false,"default":24000},
|
|
||||||
{"key":"textAggregationMode","label":"Text Aggregation Mode","group":"values","type":"text","required":false,"default":"token"}
|
|
||||||
]} $$::jsonb, TRUE, 1),
|
|
||||||
('dashscope-asr', 'DashScope ASR', 'ASR',
|
|
||||||
$$ {"fields":[
|
|
||||||
{"key":"modelId","label":"Model ID","group":"values","type":"text","required":true},
|
|
||||||
{"key":"apiUrl","label":"API URL","group":"values","type":"url","required":true},
|
|
||||||
{"key":"apiKey","label":"API Key","group":"secrets","type":"password","required":true},
|
|
||||||
{"key":"language","label":"Language","group":"values","type":"text","required":false,"default":"zh"}
|
|
||||||
]} $$::jsonb, TRUE, 1),
|
|
||||||
('dashscope-tts', 'DashScope TTS', 'TTS',
|
|
||||||
$$ {"fields":[
|
|
||||||
{"key":"modelId","label":"Model ID","group":"values","type":"text","required":true},
|
|
||||||
{"key":"apiUrl","label":"API URL","group":"values","type":"url","required":true},
|
|
||||||
{"key":"apiKey","label":"API Key","group":"secrets","type":"password","required":true},
|
|
||||||
{"key":"voice","label":"Voice","group":"values","type":"text","required":false}
|
|
||||||
]} $$::jsonb, TRUE, 1),
|
|
||||||
('gemini-realtime', 'Gemini Realtime', 'Realtime',
|
|
||||||
$$ {"fields":[
|
|
||||||
{"key":"modelId","label":"Model ID","group":"values","type":"text","required":true},
|
|
||||||
{"key":"apiUrl","label":"API URL","group":"values","type":"url","required":true},
|
|
||||||
{"key":"apiKey","label":"API Key","group":"secrets","type":"password","required":true}
|
|
||||||
]} $$::jsonb, TRUE, 1)
|
|
||||||
ON CONFLICT (interface_type) DO UPDATE SET
|
|
||||||
name = EXCLUDED.name,
|
|
||||||
capability = EXCLUDED.capability,
|
|
||||||
field_schema = EXCLUDED.field_schema,
|
|
||||||
enabled = TRUE,
|
|
||||||
version = EXCLUDED.version,
|
|
||||||
updated_at = now();
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
-- 模型资源种子数据。依赖 seed_interface_definitions.sql。
|
-- 模型资源种子数据。依赖 interface_catalog.py 已同步到 interface_definitions。
|
||||||
|
|
||||||
INSERT INTO model_resources
|
INSERT INTO model_resources
|
||||||
(id, name, capability, interface_type, values, secrets, support_image_input, enabled, is_default)
|
(id, name, capability, interface_type, values, secrets, support_image_input, enabled, is_default)
|
||||||
|
|||||||
@@ -2,14 +2,13 @@
|
|||||||
|
|
||||||
- engine / SessionLocal:全局单例
|
- engine / SessionLocal:全局单例
|
||||||
- get_session:FastAPI 依赖,按请求注入一个会话
|
- get_session:FastAPI 依赖,按请求注入一个会话
|
||||||
- init_db:启动时建表(MVP 用 create_all;表结构稳定后切 alembic 迁移,对齐 dograh)
|
- sync_interface_definitions:启动时同步接口定义;表结构由 Alembic 管理
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
import json
|
import json
|
||||||
|
|
||||||
import config
|
import config
|
||||||
from db.models import Base
|
|
||||||
from services.interface_catalog import INTERFACE_DEFINITIONS
|
from services.interface_catalog import INTERFACE_DEFINITIONS
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import (
|
from sqlalchemy.ext.asyncio import (
|
||||||
@@ -27,15 +26,8 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
|||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
async def init_db() -> None:
|
async def sync_interface_definitions() -> None:
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
|
||||||
await conn.execute(
|
|
||||||
text(
|
|
||||||
"ALTER TABLE interface_definitions "
|
|
||||||
"ALTER COLUMN field_schema TYPE JSONB USING field_schema::jsonb"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for definition in INTERFACE_DEFINITIONS:
|
for definition in INTERFACE_DEFINITIONS:
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
text(
|
text(
|
||||||
|
|||||||
15
backend/db/sync_interface_definitions.py
Normal file
15
backend/db/sync_interface_definitions.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"""CLI entry for syncing built-in interface definitions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from db.session import sync_interface_definitions
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
asyncio.run(sync_interface_definitions())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
5
backend/migrations/README
Normal file
5
backend/migrations/README
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
Alembic migrations for the backend database.
|
||||||
|
|
||||||
|
Run from backend/:
|
||||||
|
|
||||||
|
alembic upgrade head
|
||||||
74
backend/migrations/env.py
Normal file
74
backend/migrations/env.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
|
||||||
|
import config as app_config
|
||||||
|
from db.models import Base
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def get_url() -> str:
|
||||||
|
return app_config.DATABASE_URL
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
context.configure(
|
||||||
|
url=get_url(),
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
compare_type=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def do_run_migrations(connection: Connection) -> None:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
compare_type=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_async_migrations() -> None:
|
||||||
|
configuration = config.get_section(config.config_ini_section, {})
|
||||||
|
configuration["sqlalchemy.url"] = get_url()
|
||||||
|
|
||||||
|
connectable = async_engine_from_config(
|
||||||
|
configuration,
|
||||||
|
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()
|
||||||
26
backend/migrations/script.py.mako
Normal file
26
backend/migrations/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: str | Sequence[str] | None = ${repr(down_revision)}
|
||||||
|
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
|
||||||
|
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
124
backend/migrations/versions/20260709_0001_initial_schema.py
Normal file
124
backend/migrations/versions/20260709_0001_initial_schema.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"""initial schema
|
||||||
|
|
||||||
|
Revision ID: 20260709_0001
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-07-09 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260709_0001"
|
||||||
|
down_revision: str | Sequence[str] | None = None
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"interface_definitions",
|
||||||
|
sa.Column("interface_type", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("capability", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"field_schema",
|
||||||
|
postgresql.JSONB(astext_type=sa.Text()),
|
||||||
|
server_default=sa.text("'{}'::jsonb"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("enabled", sa.Boolean(), server_default=sa.text("true"), nullable=False),
|
||||||
|
sa.Column("version", sa.Integer(), server_default=sa.text("1"), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("interface_type"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_interface_definitions_capability", "interface_definitions", ["capability"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"model_resources",
|
||||||
|
sa.Column("id", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=128), server_default="", nullable=False),
|
||||||
|
sa.Column("capability", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("interface_type", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("values", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
|
||||||
|
sa.Column("secrets", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
|
||||||
|
sa.Column("support_image_input", sa.Boolean(), server_default=sa.text("false"), nullable=False),
|
||||||
|
sa.Column("enabled", sa.Boolean(), server_default=sa.text("true"), nullable=False),
|
||||||
|
sa.Column("is_default", sa.Boolean(), server_default=sa.text("false"), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["interface_type"], ["interface_definitions.interface_type"], ondelete="RESTRICT"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_model_resources_capability", "model_resources", ["capability"])
|
||||||
|
op.create_index("ix_model_resources_interface_type", "model_resources", ["interface_type"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"knowledge_bases",
|
||||||
|
sa.Column("id", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("description", sa.String(length=2048), server_default="", nullable=False),
|
||||||
|
sa.Column("embedding_model_resource_id", sa.String(length=40), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=16), server_default="active", nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["embedding_model_resource_id"], ["model_resources.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_knowledge_bases_embedding_model_resource_id", "knowledge_bases", ["embedding_model_resource_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"assistants",
|
||||||
|
sa.Column("id", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("type", sa.String(length=16), server_default="prompt", nullable=False),
|
||||||
|
sa.Column("runtime_mode", sa.String(length=16), server_default="pipeline", nullable=False),
|
||||||
|
sa.Column("greeting", sa.String(length=2048), server_default="", nullable=False),
|
||||||
|
sa.Column("enable_interrupt", sa.Boolean(), server_default=sa.text("true"), nullable=False),
|
||||||
|
sa.Column("vision_enabled", sa.Boolean(), server_default=sa.text("false"), nullable=False),
|
||||||
|
sa.Column("vision_model_resource_id", sa.String(length=40), nullable=True),
|
||||||
|
sa.Column("knowledge_base_id", sa.String(length=40), nullable=True),
|
||||||
|
sa.Column("prompt", sa.String(length=8192), server_default="", nullable=False),
|
||||||
|
sa.Column("api_url", sa.String(length=512), server_default="", nullable=False),
|
||||||
|
sa.Column("api_key", sa.String(length=512), server_default="", nullable=False),
|
||||||
|
sa.Column("app_id", sa.String(length=128), server_default="", nullable=False),
|
||||||
|
sa.Column("graph", sa.JSON(), server_default=sa.text("'{}'::json"), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["knowledge_base_id"], ["knowledge_bases.id"], ondelete="RESTRICT"),
|
||||||
|
sa.ForeignKeyConstraint(["vision_model_resource_id"], ["model_resources.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_assistants_type", "assistants", ["type"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"assistant_model_bindings",
|
||||||
|
sa.Column("assistant_id", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("capability", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("model_resource_id", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("config", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["assistant_id"], ["assistants.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["model_resource_id"], ["model_resources.id"], ondelete="RESTRICT"),
|
||||||
|
sa.PrimaryKeyConstraint("assistant_id", "capability"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_assistant_model_bindings_model_resource_id", "assistant_model_bindings", ["model_resource_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_assistant_model_bindings_model_resource_id", table_name="assistant_model_bindings")
|
||||||
|
op.drop_table("assistant_model_bindings")
|
||||||
|
op.drop_index("ix_assistants_type", table_name="assistants")
|
||||||
|
op.drop_table("assistants")
|
||||||
|
op.drop_index("ix_knowledge_bases_embedding_model_resource_id", table_name="knowledge_bases")
|
||||||
|
op.drop_table("knowledge_bases")
|
||||||
|
op.drop_index("ix_model_resources_interface_type", table_name="model_resources")
|
||||||
|
op.drop_index("ix_model_resources_capability", table_name="model_resources")
|
||||||
|
op.drop_table("model_resources")
|
||||||
|
op.drop_index("ix_interface_definitions_capability", table_name="interface_definitions")
|
||||||
|
op.drop_table("interface_definitions")
|
||||||
@@ -18,5 +18,6 @@ websockets>=13
|
|||||||
|
|
||||||
# 存储:Postgres(SQLAlchemy 2.0 异步 + asyncpg 驱动)
|
# 存储:Postgres(SQLAlchemy 2.0 异步 + asyncpg 驱动)
|
||||||
sqlalchemy[asyncio]>=2.0
|
sqlalchemy[asyncio]>=2.0
|
||||||
|
alembic>=1.13
|
||||||
asyncpg
|
asyncpg
|
||||||
greenlet # SQLAlchemy 异步运行时必需(部分平台不会自动带上)
|
greenlet # SQLAlchemy 异步运行时必需(部分平台不会自动带上)
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ services:
|
|||||||
api:
|
api:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
# 调试:挂源码 + --reload,改代码即时生效,无需重建镜像
|
# 调试:挂源码 + --reload,改代码即时生效,无需重建镜像
|
||||||
command: uvicorn app:app --host 0.0.0.0 --port 8000 --reload
|
command: sh -c "alembic upgrade head && uvicorn app:app --host 0.0.0.0 --port 8000 --reload"
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- /app/.venv # 屏蔽宿主机的 venv(容器用镜像内的依赖)
|
- /app/.venv # 屏蔽宿主机的 venv(容器用镜像内的依赖)
|
||||||
|
|||||||
Reference in New Issue
Block a user