feat: initial commit

This commit is contained in:
qzl
2026-03-31 13:32:22 +08:00
commit 695adb7d6f
49 changed files with 2990 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
from .settings import Settings, config
__all__ = ["Settings", "config"]
@@ -0,0 +1,234 @@
from __future__ import annotations
import uuid
from pathlib import Path
from typing import Any
import yaml
from pydantic import BaseModel, ValidationError
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from schemas.agent.system_agent import SystemAgentLLMConfig
from core.db.session import AsyncSessionLocal
from core.logging import get_logger
from models.llm import Llm
from models.llm_factory import LlmFactory
from models.system_agents import SystemAgents
logger = get_logger("core.config.initial.init_data")
class LlmFactorySeed(BaseModel):
name: str
request_url: str
avatar: str | None = None
class LlmSeed(BaseModel):
model_code: str
factory_name: str
pricing_tiers: list[dict[str, float | int]]
class LlmCatalogSeed(BaseModel):
factories: list[LlmFactorySeed]
llms: list[LlmSeed]
class SystemAgentsSeed(BaseModel):
agent_type: str
llm_model_code: str
status: str
config: SystemAgentLLMConfig | None = None
class SystemAgentsYaml(BaseModel):
agents: list[SystemAgentsSeed]
def _default_catalog_path() -> Path:
return (
Path(__file__).resolve().parents[1] / "static" / "database" / "llm_catalog.yaml"
)
def load_llm_catalog(catalog_path: Path | None = None) -> dict[str, Any]:
path = catalog_path or _default_catalog_path()
with path.open("r", encoding="utf-8") as file:
loaded = yaml.safe_load(file) or {}
if not isinstance(loaded, dict):
raise ValueError(f"Invalid LLM catalog format: {path}")
raw_factories = loaded.get("factories", [])
raw_llms = loaded.get("llms", [])
if not isinstance(raw_factories, list) or not isinstance(raw_llms, list):
raise ValueError(f"Invalid LLM catalog sections: {path}")
try:
parsed = LlmCatalogSeed.model_validate(
{
"factories": list(raw_factories),
"llms": list(raw_llms),
}
)
except ValidationError as exc:
raise ValueError(f"Invalid LLM catalog data: {path}") from exc
return parsed.model_dump()
def _default_system_agents_path() -> Path:
return (
Path(__file__).resolve().parents[1]
/ "static"
/ "database"
/ "system_agents.yaml"
)
def load_system_agents(catalog_path: Path | None = None) -> dict[str, Any]:
path = catalog_path or _default_system_agents_path()
with path.open("r", encoding="utf-8") as file:
loaded = yaml.safe_load(file) or {}
if not isinstance(loaded, dict):
raise ValueError(f"Invalid system agents format: {path}")
raw_agents = loaded.get("agents", [])
if not isinstance(raw_agents, list):
raise ValueError(f"Invalid system agents agents section: {path}")
try:
parsed = SystemAgentsYaml.model_validate({"agents": list(raw_agents)})
except ValidationError as exc:
raise ValueError(f"Invalid system agents data: {path}") from exc
return parsed.model_dump()
async def _upsert_factory(
session: AsyncSession,
*,
name: str,
request_url: str,
avatar: str | None,
) -> uuid.UUID:
result = await session.execute(select(LlmFactory).where(LlmFactory.name == name))
factory = result.scalar_one_or_none()
if factory is None:
factory = LlmFactory(name=name, request_url=request_url, avatar=avatar)
session.add(factory)
await session.flush()
else:
factory.request_url = request_url
factory.avatar = avatar
return factory.id
async def _upsert_llm(
session: AsyncSession,
*,
model_code: str,
factory_id: uuid.UUID,
) -> None:
result = await session.execute(select(Llm).where(Llm.model_code == model_code))
llm = result.scalar_one_or_none()
if llm is None:
session.add(Llm(model_code=model_code, factory_id=factory_id))
return
llm.factory_id = factory_id
async def _upsert_system_agents(
session: AsyncSession,
*,
agent_type: str,
llm_id: uuid.UUID,
status: str,
config: dict[str, Any],
) -> None:
result = await session.execute(
select(SystemAgents).where(SystemAgents.agent_type == agent_type)
)
catalog_entry = result.scalar_one_or_none()
if catalog_entry is None:
session.add(
SystemAgents(
agent_type=agent_type,
llm_id=llm_id,
status=status,
config=config,
)
)
else:
catalog_entry.llm_id = llm_id
catalog_entry.status = status
catalog_entry.config = config
async def initialize_system_agents() -> None:
"""Initialize system agents from YAML."""
catalog = load_system_agents()
async with AsyncSessionLocal() as session:
async with session.begin():
for agent in catalog["agents"]:
result = await session.execute(
select(Llm).where(Llm.model_code == agent["llm_model_code"])
)
llm = result.scalar_one_or_none()
if llm is None:
raise RuntimeError(
f"LLM model '{agent['llm_model_code']}' not found for agent type '{agent['agent_type']}'"
)
await _upsert_system_agents(
session,
agent_type=agent["agent_type"],
llm_id=llm.id,
status=agent["status"],
config=SystemAgentLLMConfig.model_validate(
agent.get("config") or {}
).model_dump(),
)
logger.info("Initialized system agents")
async def initialize_llm_catalog() -> None:
"""Initialize LLM catalog from YAML."""
catalog = load_llm_catalog()
async with AsyncSessionLocal() as session:
async with session.begin():
factory_id_by_name: dict[str, uuid.UUID] = {}
for factory in catalog["factories"]:
factory_id = await _upsert_factory(
session,
name=factory["name"],
request_url=factory["request_url"],
avatar=factory.get("avatar"),
)
factory_id_by_name[factory["name"]] = factory_id
for llm in catalog["llms"]:
factory_name = llm["factory_name"]
resolved_factory_id = factory_id_by_name.get(factory_name)
if resolved_factory_id is None:
raise RuntimeError(
f"Factory '{factory_name}' not found for model '{llm['model_code']}'"
)
await _upsert_llm(
session,
model_code=llm["model_code"],
factory_id=resolved_factory_id,
)
logger.info("Initialized LLM factory/model seed data")
async def initialize_data() -> bool:
"""Initialize bootstrap data."""
await initialize_llm_catalog()
await initialize_system_agents()
return True
+263
View File
@@ -0,0 +1,263 @@
from __future__ import annotations
from pathlib import Path
from typing import ClassVar, Literal
from urllib.parse import quote
from pydantic import (
AnyHttpUrl,
BaseModel,
Field,
computed_field,
field_validator,
model_validator,
)
from pydantic_settings import BaseSettings, SettingsConfigDict
def _resolve_project_root() -> Path:
current = Path(__file__).resolve()
for parent in current.parents:
if (
(parent / "pyproject.toml").is_file()
and (parent / "backend").is_dir()
and (parent / "infra").is_dir()
):
return parent
for parent in current.parents:
if parent.name == "backend":
return parent.parent
return Path.cwd().resolve()
class RuntimeSettings(BaseModel):
environment: Literal["dev", "test", "prod"] = "dev"
service_name: str = "app"
debug: bool = True
log_level: str = "INFO"
log_json: bool = True
log_rotation: Literal["time", "size", "none"] = "time"
log_rotation_when: str = "midnight"
log_rotation_interval: int = 1
log_rotation_backup_count: int = 14
log_rotation_max_bytes: int = 10_000_000
log_dir: str = "logs"
log_error_dir: str = "logs/errors"
log_file_name: str = ""
log_error_file_name: str = ""
log_sensitive_fields: list[str] = Field(
default_factory=lambda: [
"password",
"secret",
"token",
"api_key",
"authorization",
"cookie",
"client_ip",
"user_id",
]
)
sql_log_queries: bool = False
trusted_proxy_ips: list[str] = Field(default_factory=list)
@field_validator("log_dir", mode="before")
@classmethod
def lock_log_dir(cls, _: object) -> str:
return "logs"
@field_validator("log_error_dir", mode="before")
@classmethod
def lock_log_error_dir(cls, _: object) -> str:
return "logs/errors"
@model_validator(mode="after")
def ensure_service_scoped_log_file_names(self) -> "RuntimeSettings":
service = "".join(
char if char.isalnum() or char in {"-", "_"} else "-"
for char in self.service_name
).strip("-_")
service_name = service or "app"
if not self.log_file_name.strip():
self.log_file_name = f"{service_name}.log"
if not self.log_error_file_name.strip():
self.log_error_file_name = f"{service_name}.error.log"
return self
class CorsSettings(BaseModel):
allow_origins: list[str] = Field(
default_factory=lambda: [
"http://localhost",
"http://localhost:3000",
]
)
allow_credentials: bool = True
allow_methods: list[str] = Field(default_factory=lambda: ["*"])
allow_headers: list[str] = Field(default_factory=lambda: ["*"])
class RedisSettings(BaseModel):
host: str = "redis"
port: int = 6379
password: str | None = None
db: int = 0
socket_connect_timeout: float = 1.0
socket_timeout: float = 1.0
max_connections: int = 10
@computed_field
@property
def url(self) -> str:
if self.password:
password = quote(self.password, safe="")
return f"redis://:{password}@{self.host}:{self.port}/{self.db}"
return f"redis://{self.host}:{self.port}/{self.db}"
class DatabaseSettings(BaseModel):
host: str = "localhost"
port: int = 3306
name: str = "eryao"
user: str = "root"
password: str = "CHANGE_ME"
@computed_field
@property
def url(self) -> str:
password = quote(self.password, safe="")
return (
f"mysql+aiomysql://{self.user}:{password}"
f"@{self.host}:{self.port}/{self.name}"
)
class AppVersionSettings(BaseModel):
manifest_path: str = Field(
default="deploy/static/releases/manifest.json",
description="发布清单文件路径,相对于项目根目录",
)
release_path_prefix: str = Field(
default="releases",
description="下载 URL 中文件目录前缀",
)
download_base_url: AnyHttpUrl | None = Field(
default=None,
description="下载链接基础域名,如 https://your-domain.com",
)
@field_validator("download_base_url", mode="before")
@classmethod
def empty_download_base_url_to_none(cls, value: object) -> object:
if value == "":
return None
return value
@field_validator("manifest_path")
@classmethod
def validate_manifest_path(cls, value: str) -> str:
normalized = Path(value)
if normalized.is_absolute() or ".." in normalized.parts:
raise ValueError("manifest_path must be a safe relative path")
return value
class AliyunSmsSettings(BaseModel):
access_key_id: str = "CHANGE_ME"
access_key_secret: str = "CHANGE_ME"
sign_name: str = "CHANGE_ME"
template_code: str = "CHANGE_ME"
region_id: str = "cn-hangzhou"
endpoint: str = "dysmsapi.aliyuncs.com"
test_mode: bool = False
class AliyunContentSecuritySettings(BaseModel):
access_key_id: str = "CHANGE_ME"
access_key_secret: str = "CHANGE_ME"
endpoint: str = "green-cip.cn-shenzhen.aliyuncs.com"
class AlipaySettings(BaseModel):
app_id: str = "CHANGE_ME"
merchant_id: str = "CHANGE_ME"
public_key: str = "CHANGE_ME"
private_key: str = "CHANGE_ME"
sign_type: str = "RSA2"
notify_url: str = ""
timeout_express: str = "30m"
sandbox: bool = False
class DeepSeekSettings(BaseModel):
api_key: str = "CHANGE_ME"
class AuthSettings(BaseModel):
token_expiration_days: int = 7
token_refresh_threshold_hours: int = 2
class VerificationSettings(BaseModel):
code_length: int = 6
expiration_minutes: int = 5
test_mode: bool = False
class SensitiveWordSettings(BaseModel):
use_aliyun: bool = True
fallback_to_local: bool = True
class TestSettings(BaseModel):
phone: str = ""
password: str = ""
def _resolve_env_file() -> str:
current = Path(__file__).resolve()
for parent in [current, *current.parents]:
candidate = parent / ".env"
if candidate.is_file():
return str(candidate)
return ".env"
PROJECT_ROOT = _resolve_project_root()
class Settings(BaseSettings):
runtime: RuntimeSettings = RuntimeSettings()
cors: CorsSettings = CorsSettings()
redis: RedisSettings = RedisSettings()
database: DatabaseSettings = DatabaseSettings()
app_version: AppVersionSettings = AppVersionSettings()
aliyun_sms: AliyunSmsSettings = AliyunSmsSettings()
aliyun_content_security: AliyunContentSecuritySettings = (
AliyunContentSecuritySettings()
)
alipay: AlipaySettings = AlipaySettings()
deepseek: DeepSeekSettings = DeepSeekSettings()
auth: AuthSettings = AuthSettings()
verification: VerificationSettings = VerificationSettings()
sensitive_word: SensitiveWordSettings = SensitiveWordSettings()
test: TestSettings = Field(default_factory=TestSettings)
@computed_field
@property
def database_url(self) -> str:
return self.database.url
model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict(
env_file=_resolve_env_file(),
env_prefix="ERYAO_",
env_nested_delimiter="__",
case_sensitive=False,
extra="ignore",
)
config = Settings() # type: ignore[reportCallIssue]
@@ -0,0 +1,34 @@
input_template: |
你正在执行一次"自动化记忆回顾与整理"任务。
任务目标:
1) 回顾最近两天的聊天与上下文,识别用户长期偏好、习惯和关键事实的变化。
2) 对已经失效、被否定或明显过期的信息执行遗忘。
3) 对新增且有证据支持的信息执行写入。
4) 严禁编造;没有证据就不要写入。
5) 只更新最小必要字段,避免过度覆盖。
输出要求:
- 必须使用以下固定格式输出:
<----------【周期任务输出】---------->
【记忆回顾】<一句人性化总结,说明今天主要发生了什么>
【新增记忆】<按"X条:要点1;要点2"描述;没有则写"0条">
【遗忘记忆】<按"X条:要点1;要点2"描述;没有则写"0条">
【未来展望】<基于本次记忆变化,给出1-2条温和、可执行的后续建议;若暂无建议则说明"可继续观察">
表达风格:
- 语言自然、温和、可读,像助理在做每日回顾。
- 结论先行,避免空话,不要输出与任务无关的闲聊内容。
enabled_tools:
- memory.write
- memory.forget
context:
source: latest_chat
window_mode: day
window_count: 2
schedule:
type: daily
run_at:
hour: 8
minute: 0
weekdays: null
@@ -0,0 +1,70 @@
factories:
- name: dashscope
request_url: https://dashscope.aliyuncs.com/compatible-mode/v1
avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/qwen-color.png
- name: minimax
request_url: https://api.minimaxi.com/v1
avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/minimax-color.png
- name: moonshot
request_url: https://api.moonshot.cn/v1
avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/moonshot.png
- name: deepseek
request_url: https://api.deepseek.com/v1
avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/deepseek-color.png
- name: volcengine
request_url: https://ark.cn-beijing.volces.com/api/v3
avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/doubao-color.png
- name: zai
request_url: https://api.z.ai/api/paas/v4
avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/zai.png
llms:
# qwen3.5-flash (3 tiers: 128K, 256K, 1M)
- model_code: qwen3.5-flash
factory_name: dashscope
pricing_tiers:
- max_prompt_tokens: 128000
input_cost_per_token: 0.0000002
output_cost_per_token: 0.000002
cache_hit_cost_per_token: 0.00000002
- max_prompt_tokens: 256000
input_cost_per_token: 0.0000008
output_cost_per_token: 0.000008
cache_hit_cost_per_token: 0.00000008
- max_prompt_tokens: 1000000
input_cost_per_token: 0.0000012
output_cost_per_token: 0.000012
cache_hit_cost_per_token: 0.00000012
- model_code: qwen3.5-35b-a3b
factory_name: dashscope
pricing_tiers:
- max_prompt_tokens: 128000
input_cost_per_token: 0.0000004
output_cost_per_token: 0.0000032
- max_prompt_tokens: 256000
input_cost_per_token: 0.0000016
output_cost_per_token: 0.0000128
- model_code: deepseek-chat
factory_name: deepseek
pricing_tiers:
- max_prompt_tokens: 128000
input_cost_per_token: 0.000002
output_cost_per_token: 0.000003
cache_hit_cost_per_token: 0.0000002
- model_code: qwen3.5-27b
factory_name: dashscope
pricing_tiers:
- max_prompt_tokens: 128000
input_cost_per_token: 0.0000006
output_cost_per_token: 0.0000048
- max_prompt_tokens: 256000
input_cost_per_token: 0.0000018
output_cost_per_token: 0.0000144
@@ -0,0 +1,28 @@
agents:
- agent_type: router
llm_model_code: qwen3.5-flash
status: active
config:
temperature: 0.7
max_tokens: null
timeout_seconds: 30
context_messages:
mode: day
count: 2
enabled_tools: []
- agent_type: worker
llm_model_code: qwen3.5-flash
status: active
config:
temperature: 0.7
max_tokens: null
timeout_seconds: 30
context_messages:
mode: number
count: 20
enabled_tools:
- calendar.read
- calendar.write
- calendar.share
- user.lookup
@@ -0,0 +1,158 @@
version: "1.0"
routes:
- route_id: auth.boot
path: /boot
description: Bootstraps auth session and redirects to login or home.
category: auth
auth_required: false
- route_id: auth.login
path: /login
description: Login entry for unauthenticated users.
category: auth
auth_required: false
- route_id: home.main
path: /
description: Main assistant home screen.
category: home
auth_required: true
- route_id: message.invite_list
path: /messages/invites
description: Lists message invitations.
category: messages
auth_required: true
- route_id: message.invite_detail
path: /messages/invites/{id}
description: Shows details for a single invitation.
category: messages
auth_required: true
path_params:
- id
- route_id: contacts.list
path: /contacts
description: Contact list and quick relationship actions.
category: contacts
auth_required: true
- route_id: contacts.add
path: /contacts/add
description: Create or edit a contact profile.
category: contacts
auth_required: true
- route_id: calendar.dayweek
path: /calendar/dayweek
description: Day and week calendar view.
category: calendar
auth_required: true
query_params:
- date
- from
- route_id: calendar.month
path: /calendar/month
description: Month calendar overview.
category: calendar
auth_required: true
query_params:
- from
- route_id: calendar.event_detail
path: /calendar/events/{id}
description: Detail page for one calendar event.
category: calendar
auth_required: true
path_params:
- id
- route_id: calendar.event_create
path: /calendar/events/new
description: Create page for one calendar event.
category: calendar
auth_required: true
query_params:
- date
- route_id: calendar.event_edit
path: /calendar/events/{id}/edit
description: Edit page for one calendar event.
category: calendar
auth_required: true
path_params:
- id
- route_id: calendar.event_share
path: /calendar/events/{id}/share
description: Share settings page for one calendar event.
category: calendar
auth_required: true
path_params:
- id
- route_id: todo.list
path: /todo
description: Todo quadrants and backlog overview.
category: todo
auth_required: true
- route_id: todo.create
path: /todo/new
description: Create page for one todo item.
category: todo
auth_required: true
- route_id: todo.detail
path: /todo/{id}
description: Detail page for one todo item.
category: todo
auth_required: true
path_params:
- id
- route_id: todo.edit
path: /todo/{id}/edit
description: Dedicated subpage for editing one todo item (not an in-page modal).
category: todo
auth_required: true
path_params:
- id
- route_id: settings.main
path: /settings
description: Settings hub page.
category: settings
auth_required: true
- route_id: settings.features
path: /settings/features
description: Automation job list page.
category: settings
auth_required: true
- route_id: settings.job_new
path: /settings/job/new
description: Create page for one automation job.
category: settings
auth_required: true
- route_id: settings.job_detail
path: /settings/job/{id}
description: Detail page for one automation job.
category: settings
auth_required: true
path_params:
- id
- route_id: settings.memory
path: /settings/memory
description: Memory preferences and controls.
category: settings
auth_required: true
- route_id: settings.memory_user
path: /settings/memory/user
description: User memory summary view.
category: settings
auth_required: true
- route_id: settings.memory_work
path: /settings/memory/work
description: Work memory summary view.
category: settings
auth_required: true
- route_id: settings.memory_user_edit
path: /settings/memory/user/edit
description: Edit user memory details.
category: settings
auth_required: true
- route_id: settings.memory_work_edit
path: /settings/memory/work/edit
description: Edit work memory details.
category: settings
auth_required: true
- route_id: settings.edit_profile
path: /edit-profile
description: Profile editing page.
category: settings
auth_required: true