feat: 应用名称更新为灵可析并增强 Chat 功能

- 更新 Android/iOS 应用名称和图标为灵可析
- Chat 支持取消正在运行的 Agent 对话
- 改进 ChatBloc 状态管理(区分发送/等待/流式/取消状态)
- HomeScreen 支持外部注入 ChatBloc 和显示等待指示器
- 后端 Agent 运行服务优化(消息处理、usage 追踪)
- 补充相关单元测试和 Widget 测试
This commit is contained in:
qzl
2026-03-10 18:39:53 +08:00
parent b48f7abf72
commit 487405aa5b
50 changed files with 768 additions and 284 deletions
@@ -5,15 +5,14 @@ import pytest
from core.agent.infrastructure.litellm.usage_tracker import extract_usage_and_cost
def test_usage_tracker_extracts_tokens_and_cost(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"core.agent.infrastructure.litellm.usage_tracker.completion_cost",
lambda completion_response: 0.123,
)
def test_usage_tracker_uses_custom_pricing_for_qwen35() -> None:
response = {
"usage": {"prompt_tokens": 11, "completion_tokens": 7, "total_tokens": 18},
"model": "dashscope/qwen3.5-flash",
"usage": {
"prompt_tokens": 11,
"completion_tokens": 7,
"total_tokens": 18,
},
}
usage = extract_usage_and_cost(response)
@@ -21,7 +20,8 @@ def test_usage_tracker_extracts_tokens_and_cost(
assert usage.prompt_tokens == 11
assert usage.completion_tokens == 7
assert usage.total_tokens == 18
assert usage.cost == 0.123
assert usage.cost == pytest.approx(0.0000162)
assert usage.cost_source == "custom_pricing"
@pytest.mark.parametrize(
@@ -33,19 +33,10 @@ def test_usage_tracker_extracts_tokens_and_cost(
],
)
def test_usage_tracker_falls_back_to_local_qwen35_pricing_when_model_unmapped(
monkeypatch: pytest.MonkeyPatch,
prompt_tokens: int,
completion_tokens: int,
expected_cost: float,
) -> None:
def _raise_unmapped(*, completion_response): # type: ignore[no-untyped-def]
del completion_response
raise Exception("This model isn't mapped yet")
monkeypatch.setattr(
"core.agent.infrastructure.litellm.usage_tracker.completion_cost",
_raise_unmapped,
)
response = {
"model": "dashscope/qwen3.5-flash",
"usage": {
@@ -59,3 +50,22 @@ def test_usage_tracker_falls_back_to_local_qwen35_pricing_when_model_unmapped(
assert usage.cost == pytest.approx(expected_cost)
assert usage.cost_source == "custom_pricing"
def test_usage_tracker_uses_cached_pricing_for_deepseek_chat() -> None:
response = {
"model": "deepseek/deepseek-chat",
"usage": {
"prompt_tokens": 1_000_000,
"completion_tokens": 100_000,
"total_tokens": 1_100_000,
"prompt_tokens_details": {
"cached_tokens": 400_000,
},
},
}
usage = extract_usage_and_cost(response)
assert usage.cost == pytest.approx(1.58)
assert usage.cost_source == "custom_pricing"
@@ -1058,6 +1058,128 @@ async def test_run_service_executes_backend_calendar_tool_and_emits_result(
assert runtime_state["status"] == AgentChatSessionStatus.COMPLETED
@pytest.mark.asyncio
async def test_run_service_does_not_persist_model_code_for_user_message(
monkeypatch: pytest.MonkeyPatch,
) -> None:
session_id = uuid4()
user_id = uuid4()
captured: dict[str, object] = {}
message_calls: list[dict[str, object]] = []
class _FakeDbSession:
async def commit(self) -> None:
return None
class _FakeSessionFactory:
def __call__(self) -> "_FakeSessionFactory":
return self
async def __aenter__(self) -> _FakeDbSession:
return _FakeDbSession()
async def __aexit__(self, exc_type, exc, tb) -> bool:
del exc_type, exc, tb
return False
class _FakeSessionRepository:
def __init__(self, session: object) -> None:
del session
async def lock_session_for_update(self, *, session_id: object):
return SimpleNamespace(
id=session_id,
user_id=user_id,
status=AgentChatSessionStatus.PENDING,
message_count=0,
total_tokens=0,
total_cost=0,
state_snapshot=None,
)
async def next_message_seq(self, *, session_id: object):
del session_id
return 1
async def update_runtime_state(self, **kwargs) -> None:
captured["update_runtime_state"] = kwargs
class _FakeMessageRepository:
def __init__(self, session: object) -> None:
del session
async def append_message(self, **kwargs) -> None:
message_calls.append(kwargs)
class _FakeRuntime:
def execute(
self,
*,
user_input: str,
system_prompt: str | None = None,
tools: list[dict[str, object]] | None = None,
):
del user_input, system_prompt, tools
return {
"assistant_text": "ok",
"prompt_tokens": 1,
"completion_tokens": 1,
"total_tokens": 2,
"cost": "0.001",
"agui_events": [],
}
async def _fake_load_agent_model_selection(self, _session):
del self
return ("qwen3.5-flash", "dashscope", SystemAgentLLMConfig())
async def _fake_load_user_agent_context(self, session, session_id, user_id):
del self, session, session_id
return SimpleNamespace(
user_id=user_id,
username="demo-user",
bio=None,
settings=SimpleNamespace(
preferences=SimpleNamespace(
interface_language="zh-CN",
ai_language="zh-CN",
timezone="Asia/Shanghai",
country="CN",
)
),
)
monkeypatch.setattr(
"core.agent.application.run_service.SessionRepository",
_FakeSessionRepository,
)
monkeypatch.setattr(
"core.agent.application.run_service.MessageRepository",
_FakeMessageRepository,
)
monkeypatch.setattr(
"core.agent.application.run_service.create_runtime",
lambda **_kwargs: _FakeRuntime(),
)
monkeypatch.setattr(
"core.agent.application.run_service.RunService._load_agent_model_selection",
_fake_load_agent_model_selection,
)
monkeypatch.setattr(
"core.agent.application.run_service.RunService._load_user_agent_context",
_fake_load_user_agent_context,
)
service = RunService(session_factory=_FakeSessionFactory()) # type: ignore[arg-type]
await service.run(
run_input=_build_run_input(thread_id=str(session_id), text="hello")
)
user_message = message_calls[0]
assert user_message["role"] == AgentChatMessageRole.USER
assert "model_code" not in user_message
@pytest.mark.asyncio
async def test_load_user_agent_context_parses_profile_settings_v1() -> None:
session_id = uuid4()
@@ -0,0 +1,72 @@
from __future__ import annotations
from types import SimpleNamespace
import pytest
from core.agent.infrastructure.crewai.runtime_stage_runner import (
LiteLLMUsageCaptureCallback,
extract_usage_from_captured_payload,
extract_usage_from_crew_output,
)
def test_extract_usage_from_crew_output_uses_custom_deepseek_pricing() -> None:
output = SimpleNamespace(
token_usage=SimpleNamespace(
prompt_tokens=1_000_000,
completion_tokens=100_000,
total_tokens=1_100_000,
cached_prompt_tokens=400_000,
)
)
usage = extract_usage_from_crew_output(
output=output,
model="deepseek/deepseek-chat",
)
assert usage.prompt_tokens == 1_000_000
assert usage.completion_tokens == 100_000
assert usage.total_tokens == 1_100_000
assert usage.cost == pytest.approx(1.58)
def test_extract_usage_from_captured_payload_uses_custom_pricing() -> None:
usage = extract_usage_from_captured_payload(
captured_usage={
"prompt_tokens": 1_000_000,
"completion_tokens": 100_000,
"total_tokens": 1_100_000,
"prompt_tokens_details": {"cached_tokens": 400_000},
},
model="deepseek/deepseek-chat",
)
assert usage.prompt_tokens == 1_000_000
assert usage.completion_tokens == 100_000
assert usage.total_tokens == 1_100_000
assert usage.cost == pytest.approx(1.58)
def test_usage_capture_callback_extracts_nested_usage_payload() -> None:
callback = LiteLLMUsageCaptureCallback()
callback.log_success_event(
kwargs={},
response_obj={
"usage": {
"prompt_tokens": 15,
"completion_tokens": 9,
"total_tokens": 24,
}
},
start_time=0,
end_time=0,
)
assert callback.captured_usage == {
"prompt_tokens": 15,
"completion_tokens": 9,
"total_tokens": 24,
}