Merge origin/main into feat/export-service

同步 main(054f704),解决 contracts.py / main.py / README.md / 技术栈说明 的合并冲突。
- contracts.py:保留 pydantic 多行导入并新增 RequestOverride
- main.py:合并 lifespan(导出孤儿清理 + 转写/本地模型生命周期)
- README.md / 技术栈说明:文档取 main 最新版本

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
yxx
2026-09-05 21:44:20 +08:00
co-authored by Claude Code
186 changed files with 14884 additions and 1665 deletions
+156 -5
View File
@@ -1,4 +1,5 @@
import asyncio
import json
from collections.abc import AsyncIterator
from contextlib import aclosing
from datetime import datetime, timezone
@@ -15,6 +16,10 @@ from app.contracts import (
AgentRunListResponse,
AgentTraceResponse,
ChatRequest,
ChatMessageListResponse,
Conversation,
ConversationCreateRequest,
ConversationListResponse,
BenchmarkDatasetListResponse,
BenchmarkEventType,
BenchmarkKind,
@@ -310,9 +315,58 @@ async def rename_note(note_id: str, request: NoteRenameRequest) -> Note:
# Retrieval and chat
@router.post("/search", response_model=SearchResponse, tags=["Search"])
async def search_notes(request: SearchRequest) -> SearchResponse:
from app.services import search_history
search_history.record(request.query)
return await engine.search(request)
@router.get("/search/history", tags=["Search"])
async def get_search_history() -> dict[str, list[str]]:
from app.services import search_history
return {"queries": search_history.list_queries()}
@router.delete("/search/history", tags=["Search"])
async def clear_search_history() -> dict[str, list[str]]:
from app.services import search_history
search_history.clear()
return {"queries": []}
@router.get("/chat/conversations", response_model=ConversationListResponse, tags=["Chat"])
async def list_chat_conversations(
limit: int = Query(default=50, ge=1, le=100), offset: int = Query(default=0, ge=0)
) -> ConversationListResponse:
from app.services import chat_history
items, total = chat_history.list_conversations(limit, offset)
return ConversationListResponse(items=items, page=PageMeta(total=total, limit=limit, offset=offset))
@router.post("/chat/conversations", response_model=Conversation, status_code=201, tags=["Chat"])
async def create_chat_conversation(request: ConversationCreateRequest) -> Conversation:
from app.services import chat_history
return chat_history.create(request.title, request.conversation_id)
@router.get("/chat/conversations/{conversation_id}/messages", response_model=ChatMessageListResponse, tags=["Chat"])
async def list_chat_messages(
conversation_id: str,
limit: int = Query(default=500, ge=1, le=1000),
offset: int = Query(default=0, ge=0),
) -> ChatMessageListResponse:
from app.services import chat_history
items, total = chat_history.list_messages(conversation_id, limit, offset)
return ChatMessageListResponse(items=items, page=PageMeta(total=total, limit=limit, offset=offset))
@router.delete("/chat/conversations/{conversation_id}", response_model=OperationResponse, tags=["Chat"])
async def delete_chat_conversation(conversation_id: str) -> OperationResponse:
from app.services import chat_history
if not chat_history.delete(conversation_id):
raise ApiError(404, "CONVERSATION_NOT_FOUND", "conversation not found", {"conversation_id": conversation_id})
return OperationResponse(status="completed", resource_id=conversation_id, message="deleted")
@router.post(
"/chat",
response_class=StreamingResponse,
@@ -325,20 +379,98 @@ async def search_notes(request: SearchRequest) -> SearchResponse:
tags=["Chat"],
)
async def chat(request: ChatRequest) -> StreamingResponse:
from app.services import chat_history
conversation_id = request.conversation_id
assistant_message_id = request.assistant_message_id or f"message_{uuid4().hex}"
if conversation_id:
user_message = next(
(message for message in reversed(request.messages) if message.role.value == "user" and message.content.strip()),
None,
)
if user_message is not None:
chat_history.append_message(
conversation_id,
message_id=request.user_message_id or f"message_{uuid4().hex}",
role="user",
content=user_message.content,
title=request.conversation_title or user_message.content[:30],
)
provider = provider_or_404(request.provider_id)
async def stream() -> AsyncIterator[str]:
sequence = 0
assistant_content = ""
assistant_thinking = ""
citations: list[dict] = []
tool_calls: list[dict] = []
argument_buffers: dict[str, str] = {}
usage: dict | None = None
try:
async with aclosing(provider.adapter.stream(request)) as events:
from app.services.chat_context import prepare
grounded_request, grounded_citations = await prepare(request)
for citation in grounded_citations:
citations.append(citation)
event = ModelEvent(event=ModelEventType.citation, sequence=sequence,
data=citation, timestamp=utc_now())
sequence += 1
yield as_sse(event.event.value, event.model_dump_json())
async with aclosing(provider.adapter.stream(grounded_request)) as events:
async for event in events:
sequence = event.sequence + 1
event = event.model_copy(update={"sequence": sequence})
sequence += 1
if event.event == ModelEventType.text_delta:
assistant_content += str(event.data.get("text", ""))
elif event.event == ModelEventType.thinking_delta:
assistant_thinking += str(event.data.get("text", ""))
elif event.event == ModelEventType.tool_call_start:
tool_calls.append({
"tool_call_id": str(event.data.get("tool_call_id", "")),
"name": str(event.data.get("name", "unknown")),
"parameters": event.data.get("arguments") if isinstance(event.data.get("arguments"), dict) else {},
"status": "running",
})
elif event.event == ModelEventType.tool_call_delta:
call_id = str(event.data.get("tool_call_id", ""))
call = next((item for item in tool_calls if item["tool_call_id"] == call_id), None)
if call is not None:
delta = event.data.get("arguments_delta")
if isinstance(delta, str):
argument_buffers[call_id] = argument_buffers.get(call_id, "") + delta
try:
parsed_arguments = json.loads(argument_buffers[call_id])
if isinstance(parsed_arguments, dict):
call["parameters"] = parsed_arguments
except ValueError:
pass
arguments = event.data.get("arguments")
if isinstance(arguments, dict):
call["parameters"].update(arguments)
elif event.event == ModelEventType.tool_call_end:
call_id = str(event.data.get("tool_call_id", ""))
call = next((item for item in tool_calls if item["tool_call_id"] == call_id), None)
if call is not None:
call["status"] = "completed"
elif event.event == ModelEventType.usage:
input_tokens = int(event.data.get("input_tokens", 0))
output_tokens = int(event.data.get("output_tokens", 0))
usage = {"input_tokens": input_tokens, "output_tokens": output_tokens,
"total_tokens": input_tokens + output_tokens}
elif event.event == ModelEventType.error:
if assistant_content:
assistant_content += "\n\n"
assistant_content += str(event.data.get("message", "Model generation failed."))
yield as_sse(event.event.value, event.model_dump_json())
except Exception:
except Exception as exc:
failure_message = exc.message if isinstance(exc, ApiError) else "知识库检索或模型生成失败,请检查服务状态。"
if assistant_content:
assistant_content += "\n\n"
assistant_content += failure_message
error = ModelEvent(
event=ModelEventType.error,
sequence=sequence,
data={"code": "PROVIDER_ERROR", "message": "Provider could not complete the request."},
data={"code": exc.code if isinstance(exc, ApiError) else "CHAT_FAILED",
"message": failure_message},
timestamp=utc_now(),
)
done = ModelEvent(
@@ -347,6 +479,18 @@ async def chat(request: ChatRequest) -> StreamingResponse:
)
yield as_sse(error.event.value, error.model_dump_json())
yield as_sse(done.event.value, done.model_dump_json())
finally:
if conversation_id and (assistant_content or assistant_thinking or citations or tool_calls):
chat_history.append_message(
conversation_id,
message_id=assistant_message_id,
role="assistant",
content=assistant_content,
thinking=assistant_thinking or None,
citations=citations,
tool_calls=tool_calls,
usage=usage,
)
return StreamingResponse(stream(), media_type="text/event-stream")
@@ -922,6 +1066,7 @@ async def create_provider(request: ProviderCreateRequest) -> ProviderConfig:
default_model=request.default_model,
credential_id=request.credential_id,
enabled=request.enabled,
request_overrides=request.request_overrides,
capabilities=container.provider_factory.capabilities(request.provider_type),
)
try:
@@ -950,8 +1095,12 @@ async def update_provider(
409, "BUILTIN_PROVIDER_IMMUTABLE", "Mock provider cannot be modified."
)
fields = request.model_fields_set
if request.version is not None and request.version != current.version:
raise ApiError(409, "PROVIDER_VERSION_CONFLICT", "提供商配置已变更,请重新加载后保存。")
if ("provider_type" in fields and request.provider_type is None) or ("name" in fields and request.name is None) or (
"enabled" in fields and request.enabled is None
) or (
"request_overrides" in fields and request.request_overrides is None
):
raise ApiError(
422,
@@ -959,6 +1108,7 @@ async def update_provider(
"provider_type, name and enabled cannot be null when explicitly provided.",
)
updates = {name: getattr(request, name) for name in fields}
updates["version"] = current.version + 1
if "credential_id" in fields:
validate_public_credential_id(request.credential_id)
config = ProviderConfig.model_validate(
@@ -1107,6 +1257,7 @@ async def create_embeddings(request: EmbeddingRequest) -> EmbeddingResult:
async def match_speakers(request: SpeakerMatchRequest) -> SpeakerMatchResult:
return await container.model_routing.match_speakers(
attachment_path(request.attachment_id), attachment_path(request.reference_attachment_id),
local_only=request.local_only,
)
@@ -1118,7 +1269,7 @@ async def match_speakers(request: SpeakerMatchRequest) -> SpeakerMatchResult:
)
async def create_transcription(request: TranscriptionRequest) -> TranscriptionJob:
return await transcription_service.create_transcription(
request.attachment_id, request.language, diarization=request.diarization
**request.model_dump(), wait=False
)