feat: enable local docker demo mode
Some checks failed
Build and Push Docker Images / changes (push) Has been cancelled
Build and Push Docker Images / build-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (push) Has been cancelled
Build and Push Docker Images / build-admin-frontend (push) Has been cancelled
Some checks failed
Build and Push Docker Images / changes (push) Has been cancelled
Build and Push Docker Images / build-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (push) Has been cancelled
Build and Push Docker Images / build-admin-frontend (push) Has been cancelled
This commit is contained in:
12
admin-frontend/.dockerignore
Normal file
12
admin-frontend/.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.vite
|
||||
coverage
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
.DS_Store
|
||||
@@ -25,6 +25,12 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
location /auth/ {
|
||||
proxy_pass http://backend:8000/auth/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# 静态资源代理
|
||||
location /static/ {
|
||||
proxy_pass http://backend-admin:8001/static/;
|
||||
|
||||
17
backend/.dockerignore
Normal file
17
backend/.dockerignore
Normal file
@@ -0,0 +1,17 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.venv
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
.mypy_cache
|
||||
.coverage
|
||||
htmlcov
|
||||
dist
|
||||
build
|
||||
*.egg-info
|
||||
static/images
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -32,12 +32,17 @@ DEBUG=true
|
||||
# ----------------------------------------------
|
||||
# [策略配置]
|
||||
# 系统默认使用的供应商列表 (按优先级排序)
|
||||
# 本地求职演示可设置 ENABLE_DEMO_PROVIDERS=true,在没有真实 API Key 时使用确定性 Demo Provider。
|
||||
ENABLE_DEMO_PROVIDERS=false
|
||||
|
||||
# 文本生成: 优先 Gemini,其次 OpenAI
|
||||
TEXT_PROVIDERS=["gemini", "openai"]
|
||||
# 图片生成: 优先 CQTAI (Flux/NanoBanana)
|
||||
IMAGE_PROVIDERS=["cqtai"]
|
||||
# 语音生成: 优先 MiniMax,其次 ElevenLabs,最后 EdgeTTS(免费)
|
||||
TTS_PROVIDERS=["minimax", "elevenlabs", "edge_tts"]
|
||||
# 绘本结构生成: 默认复用 Gemini Storybook adapter
|
||||
STORYBOOK_PROVIDERS=["storybook_primary"]
|
||||
|
||||
# [模型参数]
|
||||
TEXT_MODEL=gemini-2.0-flash
|
||||
|
||||
@@ -255,8 +255,8 @@ async def get_session(user: User | None = Depends(get_current_user)):
|
||||
@router.get("/dev/signin")
|
||||
async def dev_signin(db: AsyncSession = Depends(get_db)):
|
||||
"""Developer backdoor login. Only works in DEBUG mode."""
|
||||
# if not settings.debug:
|
||||
# raise HTTPException(status_code=403, detail="Developer login disabled")
|
||||
if not settings.debug:
|
||||
raise HTTPException(status_code=403, detail="Developer login disabled")
|
||||
|
||||
try:
|
||||
return await _handle_oauth_user(
|
||||
|
||||
@@ -5,7 +5,11 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
class Settings(BaseSettings):
|
||||
"""应用全局配置"""
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# 应用基础配置
|
||||
app_name: str = "DreamWeaver"
|
||||
@@ -49,19 +53,24 @@ class Settings(BaseSettings):
|
||||
tts_edge_voice: str = "zh-CN-XiaoxiaoNeural"
|
||||
antigravity_model: str = "gemini-3-pro-image"
|
||||
|
||||
# Provider routing (ordered lists)
|
||||
text_providers: list[str] = Field(default_factory=lambda: ["gemini"])
|
||||
image_providers: list[str] = Field(default_factory=lambda: ["cqtai"])
|
||||
tts_providers: list[str] = Field(default_factory=lambda: ["minimax", "elevenlabs", "edge_tts"])
|
||||
story_audio_cache_dir: str = Field(
|
||||
"storage/audio",
|
||||
description="Directory for cached story audio files",
|
||||
)
|
||||
|
||||
# Celery (Redis)
|
||||
celery_broker_url: str = Field("redis://localhost:6379/0")
|
||||
# Provider routing (ordered lists)
|
||||
text_providers: list[str] = Field(default_factory=lambda: ["gemini"])
|
||||
image_providers: list[str] = Field(default_factory=lambda: ["cqtai"])
|
||||
tts_providers: list[str] = Field(default_factory=lambda: ["minimax", "elevenlabs", "edge_tts"])
|
||||
storybook_providers: list[str] = Field(default_factory=lambda: ["storybook_primary"])
|
||||
enable_demo_providers: bool = Field(
|
||||
False,
|
||||
description="Enable local deterministic demo providers for portfolio demos",
|
||||
)
|
||||
story_audio_cache_dir: str = Field(
|
||||
"storage/audio",
|
||||
description="Directory for cached story audio files",
|
||||
)
|
||||
|
||||
# Celery (Redis)
|
||||
celery_broker_url: str = Field("redis://localhost:6379/0")
|
||||
celery_result_backend: str = Field("redis://localhost:6379/0")
|
||||
|
||||
|
||||
# Generic Redis
|
||||
redis_url: str = Field("redis://localhost:6379/0", description="Redis connection URL")
|
||||
redis_sentinel_enabled: bool = Field(False, description="Whether to enable Redis Sentinel")
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""适配器模块 - 供应商平台化架构核心。"""
|
||||
|
||||
# Demo adapters
|
||||
from app.services.adapters import demo as _demo_adapters # noqa: F401
|
||||
from app.services.adapters.base import AdapterConfig, BaseAdapter
|
||||
|
||||
# Image adapters
|
||||
@@ -15,6 +17,7 @@ from app.services.adapters.text import gemini as _text_gemini_adapter # noqa: F
|
||||
from app.services.adapters.text import openai as _text_openai_adapter # noqa: F401
|
||||
|
||||
# TTS adapters
|
||||
from app.services.adapters.tts import edge_tts as _tts_edge_tts_adapter # noqa: F401
|
||||
from app.services.adapters.tts import elevenlabs as _tts_elevenlabs_adapter # noqa: F401
|
||||
from app.services.adapters.tts import minimax as _tts_minimax_adapter # noqa: F401
|
||||
|
||||
|
||||
151
backend/app/services/adapters/demo.py
Normal file
151
backend/app/services/adapters/demo.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Local deterministic demo adapters for portfolio Docker demos."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from app.services.adapters.base import BaseAdapter
|
||||
from app.services.adapters.registry import AdapterRegistry
|
||||
from app.services.adapters.storybook.primary import Storybook, StorybookPage
|
||||
from app.services.adapters.text.models import StoryOutput
|
||||
|
||||
|
||||
def _compact_topic(data: str) -> str:
|
||||
parts = [
|
||||
item.strip(" ,,。.!!??")
|
||||
for item in data.replace("\n", " ").split()
|
||||
if item.strip(" ,,。.!!??")
|
||||
]
|
||||
if parts:
|
||||
return "、".join(parts[:3])
|
||||
return data.strip()[:20] or "星光森林"
|
||||
|
||||
|
||||
def _demo_image_data_url(label: str) -> str:
|
||||
return (
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' "
|
||||
"viewBox='0 0 1200 800'%3E%3Crect width='1200' height='800' "
|
||||
"fill='%23fef3c7'/%3E%3Ccircle cx='250' cy='180' r='130' "
|
||||
"fill='%23f59e0b'/%3E%3Cpath d='M120 600C320 420 520 700 "
|
||||
"720 500S1020 420 1100 560' fill='none' stroke='%237c3aed' "
|
||||
"stroke-width='42'/%3E%3C/svg%3E"
|
||||
)
|
||||
|
||||
|
||||
@AdapterRegistry.register("text", "demo")
|
||||
class DemoTextAdapter(BaseAdapter[StoryOutput]):
|
||||
"""Generate a stable local story without external AI services."""
|
||||
|
||||
adapter_type = "text"
|
||||
adapter_name = "demo"
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
input_type: Literal["keywords", "full_story"],
|
||||
data: str,
|
||||
education_theme: str | None = None,
|
||||
memory_context: str | None = None,
|
||||
**kwargs,
|
||||
) -> StoryOutput:
|
||||
theme = education_theme or "勇气与想象力"
|
||||
topic = _compact_topic(data)
|
||||
title = f"{topic}的晚安冒险"
|
||||
protagonist = "小星"
|
||||
if memory_context and "名字" in memory_context:
|
||||
protagonist = "故事里的小朋友"
|
||||
|
||||
story_text = (
|
||||
f"睡前,{protagonist}把“{topic}”写在一张小纸条上,轻轻放进枕头下面。"
|
||||
f"月光刚好照进房间,纸条变成了一只会发光的小船,邀请{protagonist}去寻找"
|
||||
f"关于“{theme}”的答案。\n\n"
|
||||
f"小船穿过云朵河,来到一片会唱歌的森林。森林里的小鹿说:真正的勇敢不是"
|
||||
f"一点也不害怕,而是害怕的时候,还愿意牵住朋友的手往前走。{protagonist}"
|
||||
f"听完以后,把一颗星星种进土里,星星长成了照亮回家路的小灯。\n\n"
|
||||
f"回到房间时,纸条已经变成了一枚金色书签。{protagonist}把它夹进故事书,"
|
||||
f"决定明天也带着好奇心和温柔,继续新的冒险。"
|
||||
)
|
||||
|
||||
return StoryOutput(
|
||||
mode="generated" if input_type == "keywords" else "enhanced",
|
||||
title=title,
|
||||
story_text=story_text,
|
||||
cover_prompt_suggestion=f"温暖水彩儿童绘本封面,主题:{topic},{theme}",
|
||||
)
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def estimated_cost(self) -> float:
|
||||
return 0.0
|
||||
|
||||
|
||||
@AdapterRegistry.register("image", "demo")
|
||||
class DemoImageAdapter(BaseAdapter[str]):
|
||||
"""Return a compact SVG data URL as a generated-image placeholder."""
|
||||
|
||||
adapter_type = "image"
|
||||
adapter_name = "demo"
|
||||
|
||||
async def execute(self, prompt: str, **kwargs) -> str:
|
||||
return _demo_image_data_url(prompt)
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def estimated_cost(self) -> float:
|
||||
return 0.0
|
||||
|
||||
|
||||
@AdapterRegistry.register("storybook", "demo")
|
||||
class DemoStorybookAdapter(BaseAdapter[Storybook]):
|
||||
"""Generate a stable local storybook without external AI services."""
|
||||
|
||||
adapter_type = "storybook"
|
||||
adapter_name = "demo"
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
keywords: str,
|
||||
page_count: int = 6,
|
||||
education_theme: str | None = None,
|
||||
memory_context: str | None = None,
|
||||
**kwargs,
|
||||
) -> Storybook:
|
||||
theme = education_theme or "勇气"
|
||||
topic = _compact_topic(keywords)
|
||||
page_count = max(4, min(page_count, 8))
|
||||
page_texts = [
|
||||
f"小星在枕头下发现一张写着“{topic}”的星光地图。",
|
||||
"地图带他来到云朵河边,一只小船正在等他上船。",
|
||||
f"森林里的小鹿告诉他:{theme}藏在每一次温柔的选择里。",
|
||||
"小星把一颗星星种进土里,星星长成了回家的灯。",
|
||||
"他把今天的发现画进故事本,准备明天讲给家人听。",
|
||||
"月亮轻轻合上窗帘,房间里只剩下甜甜的梦。",
|
||||
"星光地图变成书签,陪他继续下一次冒险。",
|
||||
"每个勇敢的小问题,都能长出一个温暖的故事。",
|
||||
]
|
||||
pages = [
|
||||
StorybookPage(
|
||||
page_number=index + 1,
|
||||
text=page_texts[index],
|
||||
image_prompt=f"温暖儿童绘本插画,第 {index + 1} 页,{topic},{theme}",
|
||||
)
|
||||
for index in range(page_count)
|
||||
]
|
||||
|
||||
return Storybook(
|
||||
title=f"{topic}的星光绘本",
|
||||
main_character="小星",
|
||||
art_style="温暖水彩",
|
||||
pages=pages,
|
||||
cover_prompt=f"温暖水彩儿童绘本封面,{topic},{theme}",
|
||||
)
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def estimated_cost(self) -> float:
|
||||
return 0.0
|
||||
@@ -33,14 +33,13 @@ class RoutingStrategy(str, Enum):
|
||||
ROUND_ROBIN = "round_robin" # 轮询
|
||||
|
||||
|
||||
# 默认配置映射(当 DB 无配置时使用)
|
||||
# 默认配置映射(当 DB 无配置时使用)
|
||||
# 这是“代码级”的默认策略,对应 .env 为空的情况
|
||||
DEFAULT_PROVIDERS: dict[ProviderType, list[str]] = {
|
||||
"text": ["gemini", "openai"],
|
||||
"image": ["cqtai"],
|
||||
"tts": ["minimax", "elevenlabs", "edge_tts"],
|
||||
"storybook": ["gemini"],
|
||||
"storybook": ["storybook_primary"],
|
||||
}
|
||||
|
||||
# API Key 映射:adapter_name -> settings 属性名
|
||||
@@ -88,6 +87,13 @@ def _get_api_key(config_ref: str | None, adapter_name: str) -> str:
|
||||
def _get_default_config(adapter_name: str) -> AdapterConfig | None:
|
||||
"""获取适配器的默认配置(无 DB 记录时使用)。返回 None 表示未知适配器。"""
|
||||
|
||||
if adapter_name == "demo":
|
||||
return AdapterConfig(
|
||||
api_key="",
|
||||
model="demo",
|
||||
timeout_ms=1000,
|
||||
)
|
||||
|
||||
# --- Text Defaults ---
|
||||
if adapter_name in ("gemini", "text_primary"):
|
||||
return AdapterConfig(
|
||||
@@ -103,7 +109,7 @@ def _get_default_config(adapter_name: str) -> AdapterConfig | None:
|
||||
)
|
||||
|
||||
# --- Image Defaults ---
|
||||
if adapter_name in ("cqtai"):
|
||||
if adapter_name == "cqtai":
|
||||
return AdapterConfig(
|
||||
api_key=getattr(settings, "cqtai_api_key", ""),
|
||||
model=settings.image_model or "nano-banana-pro",
|
||||
@@ -194,8 +200,12 @@ async def _get_providers_with_config(
|
||||
"text": settings.text_providers,
|
||||
"image": settings.image_providers,
|
||||
"tts": settings.tts_providers,
|
||||
"storybook": settings.storybook_providers,
|
||||
}
|
||||
names = settings_map.get(provider_type) or DEFAULT_PROVIDERS[provider_type]
|
||||
if settings.enable_demo_providers and "demo" not in names:
|
||||
names = ["demo", *names]
|
||||
|
||||
result = []
|
||||
for name in names:
|
||||
config = _get_default_config(name)
|
||||
@@ -273,12 +283,17 @@ async def _route_with_failover(
|
||||
# 按策略排序
|
||||
sorted_providers = _sort_by_strategy(providers, strategy, provider_type)
|
||||
|
||||
# 如果有 db 会话,过滤掉熔断的供应商
|
||||
# 如果有 db 会话,过滤掉后台管理台中已熔断的供应商。
|
||||
# .env/default provider 没有 providers 表记录,不能写入带外键的健康表。
|
||||
if db:
|
||||
healthy_providers = []
|
||||
for item in sorted_providers:
|
||||
name, config, db_provider = item
|
||||
provider_id = db_provider.id if db_provider else name
|
||||
if db_provider is None:
|
||||
healthy_providers.append(item)
|
||||
continue
|
||||
|
||||
provider_id = db_provider.id
|
||||
if await health_checker.is_healthy(db, provider_id):
|
||||
healthy_providers.append(item)
|
||||
else:
|
||||
@@ -295,7 +310,7 @@ async def _route_with_failover(
|
||||
errors.append(f"{name}: 适配器未注册")
|
||||
continue
|
||||
|
||||
provider_id = db_provider.id if db_provider else name
|
||||
provider_id = db_provider.id if db_provider else None
|
||||
|
||||
try:
|
||||
logger.debug(
|
||||
@@ -315,8 +330,8 @@ async def _route_with_failover(
|
||||
# 更新延迟缓存
|
||||
_latency_cache[name] = latency_ms
|
||||
|
||||
# 记录成功指标
|
||||
if db:
|
||||
# 记录成功指标。Provider 指标/健康表带外键,只记录后台管理台里的真实 provider。
|
||||
if db and db_provider and provider_id:
|
||||
await metrics_collector.record_call(
|
||||
db,
|
||||
provider_id=provider_id,
|
||||
@@ -326,16 +341,16 @@ async def _route_with_failover(
|
||||
)
|
||||
await health_checker.record_call_result(db, provider_id, success=True)
|
||||
|
||||
# 记录用户成本
|
||||
if user_id:
|
||||
await cost_tracker.record_cost(
|
||||
db,
|
||||
user_id=user_id,
|
||||
provider_name=name,
|
||||
capability=provider_type,
|
||||
estimated_cost=adapter.estimated_cost,
|
||||
provider_id=provider_id if db_provider else None,
|
||||
)
|
||||
# 记录用户成本;环境变量/default provider 没有 provider_id,保留 provider_name 即可。
|
||||
if db and user_id:
|
||||
await cost_tracker.record_cost(
|
||||
db,
|
||||
user_id=user_id,
|
||||
provider_name=name,
|
||||
capability=provider_type,
|
||||
estimated_cost=adapter.estimated_cost,
|
||||
provider_id=provider_id,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"provider_success",
|
||||
@@ -355,8 +370,8 @@ async def _route_with_failover(
|
||||
)
|
||||
errors.append(f"{name}: {exc}")
|
||||
|
||||
# 记录失败指标
|
||||
if db:
|
||||
# 记录失败指标。Provider 指标/健康表带外键,只记录后台管理台里的真实 provider。
|
||||
if db and db_provider and provider_id:
|
||||
await metrics_collector.record_call(
|
||||
db,
|
||||
provider_id=provider_id,
|
||||
|
||||
@@ -125,6 +125,70 @@ class TestProviderFailover:
|
||||
data="测试",
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_provider_skips_fk_backed_metrics(self):
|
||||
"""环境变量/default provider 没有 providers 表记录,不写带外键的指标表。"""
|
||||
from app.services import provider_router
|
||||
|
||||
mock_result = StoryOutput(
|
||||
mode="generated",
|
||||
title="本地演示故事",
|
||||
story_text="内容",
|
||||
cover_prompt_suggestion="prompt",
|
||||
)
|
||||
|
||||
class MockAdapter:
|
||||
estimated_cost = 0.0
|
||||
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
|
||||
async def execute(self, **kwargs):
|
||||
return mock_result
|
||||
|
||||
with patch.object(
|
||||
provider_router,
|
||||
"_get_providers_with_config",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_providers:
|
||||
mock_providers.return_value = [("demo", AdapterConfig(api_key=""), None)]
|
||||
|
||||
with patch.object(provider_router.AdapterRegistry, "get", return_value=MockAdapter):
|
||||
with patch.object(
|
||||
provider_router.health_checker,
|
||||
"is_healthy",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_is_healthy:
|
||||
with patch.object(
|
||||
provider_router.metrics_collector,
|
||||
"record_call",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_record_call:
|
||||
with patch.object(
|
||||
provider_router.health_checker,
|
||||
"record_call_result",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_record_call_result:
|
||||
with patch.object(
|
||||
provider_router.cost_tracker,
|
||||
"record_cost",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_record_cost:
|
||||
result = await provider_router._route_with_failover(
|
||||
"text",
|
||||
db=AsyncMock(),
|
||||
user_id="user-1",
|
||||
input_type="keywords",
|
||||
data="测试",
|
||||
)
|
||||
|
||||
assert result == mock_result
|
||||
mock_is_healthy.assert_not_called()
|
||||
mock_record_call.assert_not_called()
|
||||
mock_record_call_result.assert_not_called()
|
||||
mock_record_cost.assert_awaited_once()
|
||||
assert mock_record_cost.await_args.kwargs["provider_id"] is None
|
||||
|
||||
|
||||
class TestProviderConfigFromDB:
|
||||
"""从 DB 加载 provider 配置测试。"""
|
||||
@@ -187,7 +251,10 @@ class TestProviderCacheStartup:
|
||||
mock_factory.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_factory.return_value.__aexit__ = AsyncMock()
|
||||
|
||||
with patch("app.services.provider_cache.reload_providers", new_callable=AsyncMock) as mock_reload:
|
||||
with patch(
|
||||
"app.services.provider_cache.reload_providers",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_reload:
|
||||
mock_reload.return_value = {"text": [], "image": [], "tts": []}
|
||||
|
||||
await _load_provider_cache()
|
||||
|
||||
12
frontend/.dockerignore
Normal file
12
frontend/.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.vite
|
||||
coverage
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
.DS_Store
|
||||
@@ -18,6 +18,14 @@ server {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# 登录/OAuth 回调代理
|
||||
location /auth/ {
|
||||
proxy_pass http://backend:8000/auth/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# 静态资源代理 (后端生成的图片)
|
||||
location /static/ {
|
||||
proxy_pass http://backend:8000/static/;
|
||||
|
||||
Reference in New Issue
Block a user