Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@ QINIU_PRIVATE_SPACE=false
# 键名前缀必须是 AI_,由 framework/config/provider.py 的 env_prefix 决定。
AI_BASE_URL=https://api.qnaigc.com/v1
AI_API_KEY=your-ai-api-key
# 各能力分开配型号:三条能力同时在用不同模型,共用一个字段会换一个连带换全部。
# 取值即默认值——不写这两行时跑的就是它们。
# 各能力分开配型号:同时在用不同模型,共用一个字段会换一个连带换全部。
# 取值即默认值——不写这几行时跑的就是它们。
AI_CHAT_MODEL=gpt-4o-mini
AI_IMAGE_MODEL=gemini-2.5-flash-image
AI_VIDEO_MODEL=kling-v2-5-turbo

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""impl:CharacterGeneratorPort 的装配实现(串联 strategy + 最后一公里)。"""

from .character_generator import CharacterGenerator
from .character_namer import LangChainCharacterNamer

__all__ = ["CharacterGenerator"]
__all__ = ["CharacterGenerator", "LangChainCharacterNamer"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""用 LangChain Chat 模型从角色描述抽出短名称。"""

from __future__ import annotations

from typing import Any

from langchain_core.messages import HumanMessage, SystemMessage

from windup_framework.providers import create_chat_model

NAME_MAX_LEN = 20

_SYSTEM_PROMPT = (
"你从角色外观或人设描述中抽出一个适合资产库展示的称呼。"
"只输出名称本身,不要引号、标点或解释。"
f"名称不超过 {NAME_MAX_LEN} 个字,优先中文。"
)


def _clean_name(raw: str) -> str:
return raw.strip().strip("\"'“”‘’").strip()[:NAME_MAX_LEN]


class LangChainCharacterNamer:
"""``CharacterNamerPort`` 的 LangChain 实现。"""

def __init__(self, chat_model: Any | None = None) -> None:
# 装配期不创建 ChatOpenAI:CI / 本地无 AI_API_KEY 时 create_app 仍能起来。
self._model = chat_model

def _chat_model(self) -> Any:
if self._model is None:
self._model = create_chat_model()
return self._model

def name_from_description(self, description: str) -> str:
result = self._chat_model().invoke(
[
SystemMessage(content=_SYSTEM_PROMPT),
HumanMessage(content=description),
]
)
content = getattr(result, "content", result)
if not isinstance(content, str):
content = str(content or "")
return _clean_name(content)
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,10 @@ def generate(
progress: ProgressPort,
canvas: tuple[int, int] | None = None,
) -> GeneratedAction: ...


@runtime_checkable
class CharacterNamerPort(Protocol):
"""根据角色描述生成短名称。不是 Agent,只是一次 LLM 调用。"""

def name_from_description(self, description: str) -> str: ...
7 changes: 7 additions & 0 deletions backend/packages/app/src/windup_app/bootstrap/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
from windup_framework.db import Base, engine

# 模型导入:触发 Base.metadata 注册,确保 create_all 能发现所有表
from windup_ai_engine.impl.character_namer import LangChainCharacterNamer
from windup_app.server.character.model import Character # noqa: F401
from windup_app.server.character.service import service as character_service
from windup_app.server.orchestrator.dispatcher import GenerationDispatcher
from windup_app.server.project.model import Project # noqa: F401
from windup_app.server.quota.model import CreditAccount, CreditTransaction # noqa: F401
Expand Down Expand Up @@ -83,6 +85,11 @@ async def _lifespan(app: FastAPI):
def create_app() -> FastAPI:
app = FastAPI(title="windup", version="0.1.0", lifespan=_lifespan)
app.state.generation_dispatcher = GenerationDispatcher()
# 起名器在 composition root 注入,避免 web→character.service 碰到 ai_engine。
# LangChainCharacterNamer 构造期不创建 ChatOpenAI;缺 AI_API_KEY 时应用仍能启动。
# 测试若已注入假 namer,不要覆盖。
if character_service._namer is None:
character_service._namer = LangChainCharacterNamer()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] 避免在应用启动时强制创建 ChatOpenAI

create_app() 现在无条件构造 LangChainCharacterNamer,其构造函数会立即调用 ChatOpenAI;当本地、测试或仅使用非聊天 AI 能力的部署没有设置 AI_API_KEY 时,langchain-openai==1.4.0 会在这里直接抛出 OpenAIError: Missing credentials。这样应用连健康检查都无法启动,请求内 resolve_character_name 的异常兜底也没有机会执行。请延迟到实际起名调用时再创建模型(让该异常落入现有兜底),或在 bootstrap 中容忍未配置聊天 provider。


@app.get("/health", include_in_schema=False)
def health() -> dict[str, str]:
Expand Down
42 changes: 42 additions & 0 deletions backend/packages/app/src/windup_app/server/character/naming.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""创建角色时解析最终名称:用户输入优先,否则 LLM,再否则描述兜底。"""

from __future__ import annotations

from typing import Protocol

NAME_MAX_LEN = 20
FALLBACK_NAME = "未命名角色"


class CharacterNamer(Protocol):
"""server 侧起名器契约,避免 web→service 链路碰到 ai_engine。"""

def name_from_description(self, description: str) -> str: ...


def _clip(value: str) -> str:
return value[:NAME_MAX_LEN]


def resolve_character_name(
name: str | None,
description: str | None,
namer: CharacterNamer | None = None,
) -> str:
"""把可空的 name / description 收成入库用的非空短名称。"""
cleaned_name = (name or "").strip()
if cleaned_name:
return _clip(cleaned_name)

cleaned_description = (description or "").strip()
if cleaned_description and namer is not None:
try:
generated = (namer.name_from_description(cleaned_description) or "").strip()
except Exception:
generated = ""
if generated:
return _clip(generated)

if cleaned_description:
return _clip(cleaned_description)
return FALLBACK_NAME
17 changes: 17 additions & 0 deletions backend/packages/app/src/windup_app/server/character/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,29 @@

from windup_app.server.character.interface import CharacterService
from windup_app.server.character.model import Character
from windup_app.server.character.naming import CharacterNamer, resolve_character_name


class SqlAlchemyCharacterService(CharacterService):
"""基于 SQLAlchemy session 的角色 CRUD 实现。"""

def __init__(self, namer: CharacterNamer | None = None) -> None:
self._namer = namer

def create_character(self, session: Session, **fields) -> Character:
fields = dict(fields)
workflow_run_id = fields.get("workflow_run_id")
existing = (
self.get_character_by_workflow_run(session, workflow_run_id)
if workflow_run_id is not None
else None
)
if existing is not None and existing.project_id == fields.get("project_id"):
return existing
name = fields.get("name")
# 已有同 workflow_run(含跨项目冲突)不再打 LLM,插入交给唯一约束。
namer = None if (name or "").strip() or existing is not None else self._namer
fields["name"] = resolve_character_name(name, fields.get("description"), namer)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 在付费起名前先处理 workflow_run 幂等

POST /characters 的幂等语义是先插入,再在唯一约束冲突时返回已有角色;但这行现在会在 session.flush() 之前调用起名器。于是同一个 workflow_run_id 的正常重试只要 name 为空,就会先执行一次最长 120 秒且可能付费的 LLM 调用,随后才触发 IntegrityError 并丢弃生成结果。请在通常的重试路径上先查询已有角色/建立唯一性,再解析名称;并保留唯一约束处理并发竞态。

character = Character(**fields)
session.add(character)
session.flush()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,16 @@ class AIProviderSettings(BaseSettings):
chat_completions_path: str = "/chat/completions"

# ── 各能力用哪个模型 ──────────────────────────────────────────────────
# 分成三个字段而不是共用上面那个 ``model``:三条能力同时在用不同模型,共用一个
# 分成字段而不是共用上面那个 ``model``:各能力同时在用不同模型,共用一个
# 字段意味着换其中一个就把另外两个也换了。默认值即当前实测在用的型号,
# 部署侧可用 AI_VIDEO_MODEL / AI_IMAGE_MODEL 覆盖。
# 部署侧可用 AI_CHAT_MODEL / AI_VIDEO_MODEL / AI_IMAGE_MODEL 覆盖。
#
# **只有型号可配,请求形状不可配**:哪个模型吃 image_list、哪个吃
# input_reference、FAL 队列路径长什么样,都是该模型的 API 事实而非运行参数,
# 写在 providers.sufy 的映射表里。放进配置会把"填错了会怎样"从部署期推到
# 运行期 —— 字段塞错不会立刻报错,任务照常 queued,直到生成阶段才 failed,
# 而费用可能已经产生(2026-07-29 实测)。
chat_model: str = "gpt-4o-mini"
video_model: str = "kling-v2-5-turbo"
image_model: str = "gemini-2.5-flash-image"

Expand Down
13 changes: 11 additions & 2 deletions backend/packages/framework/src/windup_framework/providers/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,19 @@ def create_chat_model(

这里仅统一 Windup 配置到 LangChain 官方客户端的映射,不重新实现
``BaseChatModel``、消息转换、工具调用或结构化输出。

空 ``AI_API_KEY`` 或空型号直接拒绝,避免 langchain-openai 1.4 抛
``OpenAIError`` 或留下 ``ChatOpenAI(model="")``。
"""
model = (config.chat_model or config.model or "").strip()
api_key = (config.api_key or "").strip()
if not api_key:
raise ValueError("AI_API_KEY 未配置")
if not model:
raise ValueError("AI_CHAT_MODEL / AI_MODEL 未配置")
return ChatOpenAI(
model=config.model,
api_key=config.api_key or None,
model=model,
api_key=api_key,
base_url=config.normalized_base_url,
timeout=config.timeout,
max_retries=config.max_retries,
Expand Down
20 changes: 19 additions & 1 deletion backend/tests/test_character_api.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,27 @@
"""角色 CRUD API 集成测试。"""

import pytest

from windup_app.server.character.model import Character
from windup_app.server.character.service import service as character_service
from windup_common.enums.character import CharacterStatus


class _FakeNamer:
def name_from_description(self, description: str) -> str:
return f"名:{description}"[:20]


@pytest.fixture(autouse=True)
def _inject_fake_character_namer():
original = character_service._namer
character_service._namer = _FakeNamer()
try:
yield
finally:
character_service._namer = original


def _create_project(auth_client, name: str = "默认项目") -> dict:
"""创建一个项目并返回响应 data。"""
return auth_client.post("/projects", json={
Expand Down Expand Up @@ -81,7 +99,7 @@ def test_create_without_name(auth_client):
resp = auth_client.post("/characters", json=_payload(project["id"], name=None))

assert resp.json()["code"] == 200
assert resp.json()["data"]["name"] is None
assert resp.json()["data"]["name"] == "名:主角"


def test_create_name_roundtrip(auth_client):
Expand Down
38 changes: 38 additions & 0 deletions backend/tests/test_character_namer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""LangChain 角色起名器:注入假 chat model,不打真实 LLM。"""

from types import SimpleNamespace

from windup_ai_engine.impl.character_namer import LangChainCharacterNamer


class _FakeChat:
def __init__(self, content: object, error: Exception | None = None) -> None:
self.content = content
self.error = error
self.messages = None

def invoke(self, messages):
self.messages = messages
if self.error is not None:
raise self.error
return SimpleNamespace(content=self.content)


def test_namer_returns_cleaned_model_text():
chat = _FakeChat(' "赤发旅人" ')
namer = LangChainCharacterNamer(chat_model=chat)

assert namer.name_from_description("红发少年站在雾港") == "赤发旅人"
assert chat.messages is not None


def test_namer_truncates_to_20_chars():
chat = _FakeChat("风" * 25)
namer = LangChainCharacterNamer(chat_model=chat)
assert namer.name_from_description("一段描述") == "风" * 20


def test_namer_construction_does_not_touch_chat_provider():
"""装配应用时不能因为没有 AI_API_KEY 就炸。"""
namer = LangChainCharacterNamer()
assert namer._model is None
Loading
Loading