feat: M1 — 立项→写章草稿(SSE)→自动保存;连一家 provider

- 薄自建 LLM 网关:OpenAI 兼容适配器(DeepSeek) + instructor 结构化输出 + usage_ledger 记账 + 档位路由
- 记忆服务 assemble:确定性选择(显式+主角+近况) + 渲染卡 + 缓存断点(中性文本)
- LangGraph 写章节点 + Postgres checkpointer + SSE 归一(token/done/error)
- API:立项 + 写章 draft(SSE) + PUT 自动保存 + 提供商凭据(Fernet 加密/测试连接)
- 前端:AppShell + 作品库 + 5 步立项向导 + 写作工作台(流式打字机+自动保存) + 设置页
- M1 E2E:真实 DB + mock 网关零 token 走通闭环
This commit is contained in:
Yaojia Wang
2026-06-18 11:38:28 +02:00
parent d3dc620a71
commit b523b4fd21
70 changed files with 6642 additions and 0 deletions

View File

@@ -0,0 +1,93 @@
"""提供商凭据端点的依赖装配(运行时实现)。
把 `get_session` 装配成 `SqlCredentialStore`;把网关适配器装配成探测器。
测试经 `app.dependency_overrides` 注入内存替身——不联网。
"""
from __future__ import annotations
import uuid
from typing import Annotated
from fastapi import Depends
from openai import AsyncOpenAI
from sqlalchemy.ext.asyncio import AsyncSession
from ww_config import get_settings
from ww_db import get_session
from ww_llm_gateway.adapters.base import Capabilities
from ww_llm_gateway.adapters.openai_compat import OpenAICompatAdapter
from ww_shared import AppError, ErrorCode
from ww_api.security.credentials import (
CredentialKeyError,
decrypt_api_key,
)
from ww_api.services.credentials import (
CredentialStore,
SqlCredentialStore,
)
# 已知 OpenAI 兼容提供商 → base_urlARCH §4.2)。
_PROVIDER_BASE_URLS: dict[str, str] = {
"deepseek": "https://api.deepseek.com",
"kimi": "https://api.moonshot.cn/v1",
"qwen": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"glm": "https://open.bigmodel.cn/api/paas/v4",
"openai": "https://api.openai.com/v1",
}
def get_credential_store(
session: Annotated[AsyncSession, Depends(get_session)],
) -> CredentialStore:
return SqlCredentialStore(session)
class GatewayProviderProbe:
"""运行时探测:解密 Key→建 OpenAI 兼容适配器→最小请求验 Key→回能力矩阵。
依赖 store 取密文 + settings 取加密 key。绝不在日志/响应回显明文。
"""
def __init__(self, store: CredentialStore, enc_key: str) -> None:
self._store = store
self._enc_key = enc_key
async def probe(self, owner_id: uuid.UUID, provider: str) -> Capabilities:
cred = await self._store.get_credential(owner_id, provider)
if cred is None:
raise AppError(
ErrorCode.NOT_FOUND,
f"provider {provider} 未配置凭据",
{"provider": provider},
)
base_url = _PROVIDER_BASE_URLS.get(provider)
if base_url is None:
raise AppError(
ErrorCode.VALIDATION,
f"未知提供商 {provider}",
{"provider": provider},
)
try:
api_key = decrypt_api_key(cred.api_key_enc, key=self._enc_key)
except CredentialKeyError as exc:
raise AppError(ErrorCode.INTERNAL, str(exc)) from exc
client = AsyncOpenAI(api_key=api_key, base_url=base_url)
adapter = OpenAICompatAdapter(provider=provider, client=client)
try:
# 最小探测:列模型即可验证 Key 有效(不消耗生成额度)。
await client.models.list()
except Exception as exc: # noqa: BLE001 — 任一失败都映射为 LLM 不可用
raise AppError(
ErrorCode.LLM_UNAVAILABLE,
f"provider {provider} 连接探测失败",
{"provider": provider},
) from exc
return adapter.capabilities()
def get_provider_probe(
store: Annotated[CredentialStore, Depends(get_credential_store)],
) -> GatewayProviderProbe:
return GatewayProviderProbe(store, get_settings().credential_enc_key)