diff --git a/app/api/conversations.py b/app/api/conversations.py index 013787a..d57f9bc 100644 --- a/app/api/conversations.py +++ b/app/api/conversations.py @@ -1,11 +1,17 @@ """会话创建与历史查询接口""" -from fastapi import APIRouter, Depends, Request +from typing import Annotated + +from fastapi import APIRouter, Depends, Query, Request from app.common.response import ApiResponse, success_response from app.core.auth import get_current_user from app.core.exceptions import BizException -from app.schemas.conversation import ConversationCreated, ConversationDetail +from app.schemas.conversation import ( + ConversationCreated, + ConversationDetail, + ConversationList, +) from app.schemas.user import CurrentUser router = APIRouter(prefix="/conversations", tags=["Conversation"]) @@ -21,6 +27,19 @@ async def create_conversation( ) +@router.get("") +async def list_conversations( + request: Request, + user: Annotated[CurrentUser, Depends(get_current_user)], + limit: Annotated[int, Query(ge=1, le=100)] = 20, + offset: Annotated[int, Query(ge=0)] = 0, +) -> ApiResponse[ConversationList]: + store = request.app.state.conversation_store + return success_response( + ConversationList(conversations=await store.list_by_user(user.id, limit, offset)) + ) + + @router.get("/{conversation_id}") async def get_conversation( conversation_id: str, diff --git a/app/schemas/conversation.py b/app/schemas/conversation.py index 665f861..2f4fd6e 100644 --- a/app/schemas/conversation.py +++ b/app/schemas/conversation.py @@ -25,3 +25,15 @@ class ConversationCreated(BaseModel): class ConversationDetail(BaseModel): conversation_id: str turns: list[Turn] + + +class ConversationSummary(BaseModel): + """历史会话列表项,只包含已产生对话的会话""" + + conversation_id: str + title: str # 取自首轮用户输入,过长时截断 + updated_at: datetime # 最近一轮对话的创建时间 + + +class ConversationList(BaseModel): + conversations: list[ConversationSummary] diff --git a/app/services/conversation.py b/app/services/conversation.py index 9c57ce2..68f737b 100644 --- a/app/services/conversation.py +++ b/app/services/conversation.py @@ -5,7 +5,17 @@ from typing import Protocol from uuid import uuid4 from app.config import settings -from app.schemas.conversation import Turn +from app.schemas.conversation import ConversationSummary, Turn + +TITLE_MAX_LENGTH = 30 + + +def make_title(prompt: str) -> str: + """会话标题取首轮用户输入,合并空白后截断""" + title = " ".join(prompt.split()) + if len(title) <= TITLE_MAX_LENGTH: + return title + return title[:TITLE_MAX_LENGTH] + "…" class ConversationStore(Protocol): @@ -23,6 +33,12 @@ class ConversationStore(Protocol): async def save_turn(self, turn: Turn) -> None: ... + async def list_by_user( + self, user_id: int, limit: int, offset: int + ) -> list[ConversationSummary]: + """该用户已产生对话的会话,按最近更新时间倒序;没有对话的空会话不返回""" + ... + def _now() -> datetime: return datetime.now(UTC) @@ -77,3 +93,26 @@ class InMemoryConversationStore: if existing is not None: turn = turn.model_copy(update={"created_at": existing.created_at}) self._turns[turn.conversation_id][turn.thread_id] = turn + + async def list_by_user( + self, user_id: int, limit: int, offset: int + ) -> list[ConversationSummary]: + summaries = [] + for conversation_id, owner in self._owner.items(): + turns = sorted( + self._turns.get(conversation_id, {}).values(), + key=lambda turn: turn.created_at, + ) + if owner != user_id or not turns: + continue + summaries.append( + ConversationSummary( + conversation_id=conversation_id, + title=make_title(turns[0].prompt), + updated_at=turns[-1].created_at, + ) + ) + summaries.sort( + key=lambda item: (item.updated_at, item.conversation_id), reverse=True + ) + return summaries[offset : offset + limit] diff --git a/app/services/conversation_mongo.py b/app/services/conversation_mongo.py index 35c565a..3b2e31a 100644 --- a/app/services/conversation_mongo.py +++ b/app/services/conversation_mongo.py @@ -8,7 +8,8 @@ from pymongo import ASCENDING, DESCENDING from pymongo.asynchronous.database import AsyncDatabase from app.config import settings -from app.schemas.conversation import Turn +from app.schemas.conversation import ConversationSummary, Turn +from app.services.conversation import make_title # 单次清理的空会话上限,避免积压时一次请求扫描过多 PURGE_BATCH = 200 @@ -119,3 +120,42 @@ class MongoConversationStore: {"$set": data, "$setOnInsert": {"created_at": created_at}}, upsert=True, ) + + async def list_by_user( + self, user_id: int, limit: int, offset: int + ) -> list[ConversationSummary]: + # $unwind 会丢弃没有任何轮次的空会话 + pipeline = [ + {"$match": {"user_id": user_id}}, + { + "$lookup": { + "from": self._turns.name, + "localField": "_id", + "foreignField": "conversation_id", + "pipeline": [ + {"$sort": {"created_at": 1, "_id": 1}}, + { + "$group": { + "_id": None, + "first_prompt": {"$first": "$prompt"}, + "updated_at": {"$last": "$created_at"}, + } + }, + ], + "as": "stats", + } + }, + {"$unwind": "$stats"}, + {"$sort": {"stats.updated_at": -1, "_id": -1}}, + {"$skip": offset}, + {"$limit": limit}, + ] + cursor = await self._conversations.aggregate(pipeline) + return [ + ConversationSummary( + conversation_id=item["_id"], + title=make_title(item["stats"]["first_prompt"]), + updated_at=item["stats"]["updated_at"], + ) + for item in await cursor.to_list() + ] diff --git a/pyproject.toml b/pyproject.toml index 127ed94..f84a65b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,6 @@ dependencies = [ "httpx>=0.28.1", "mcp>=1.28.1", "langchain-mcp-adapters>=0.3.0", - "deepagents>=0.7.21", "langchain-core>=1.6.6", "python-calamine>=0.8.2", "pyarrow>=25.0.1", diff --git a/uv.lock b/uv.lock index 05c4f66..d249ea4 100644 --- a/uv.lock +++ b/uv.lock @@ -38,24 +38,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, ] -[[package]] -name = "anthropic" -version = "1.11.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "docstring-parser" }, - { name = "httpx2" }, - { name = "jiter" }, - { name = "pydantic" }, - { name = "sniffio" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ad/12/9a6ffa397b172adb040008d934a1dfd85d0e4cefc77489416db294ffc880/anthropic-1.11.0.tar.gz", hash = "sha256:3906fabac7ad7b5b46c6186040398fc7826885c77ce34e4dd7849de16fc8d0f8", size = 1390390, upload-time = "2026-09-30T22:58:21.098Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/6f/20edcd2956cac68042b1bcdd13ab3311447519f86b26929ff268dc74c650/anthropic-1.11.0-py3-none-any.whl", hash = "sha256:52f97b2c485cca7ac66058374f5073a3febb7e6849b16989c145d602a3efee21", size = 1681654, upload-time = "2026-09-30T22:58:22.925Z" }, -] - [[package]] name = "anyio" version = "4.15.1" @@ -87,15 +69,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, ] -[[package]] -name = "bracex" -version = "3.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ac/01/5f394b8bcd6e5b92f73130990960423bbb19711f906bd9fe9ea5557c667c/bracex-3.0.1.tar.gz", hash = "sha256:4e38e32392e4a4780fe15d644bfc7c8514057cfc3861e060b11814ce829c25e4", size = 44019, upload-time = "2026-07-20T13:43:00.335Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/8f/6f7273a7adb8d73fc8d21ede4376a3e475e52f98435c6007f69100dec8ca/bracex-3.0.1-py3-none-any.whl", hash = "sha256:6523ad83aeb5098a4ee597cff0f964442ff74e460bd3fafaffab6a013ff2288c", size = 11940, upload-time = "2026-07-20T13:42:59.268Z" }, -] - [[package]] name = "certifi" version = "2026.7.22" @@ -437,24 +410,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/1d/1271f287ff7170ddafc2aad36260c4eec20ccd2fea70f38455e9d56d427b/cryptography-50.0.2-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:7b75de3c8b3be1cdb1052747c929440c3eea46c1bc2cb8a6e3a48388e9b7b452", size = 3805376, upload-time = "2026-09-30T15:29:58.729Z" }, ] -[[package]] -name = "deepagents" -version = "0.7.21" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "langchain" }, - { name = "langchain-anthropic" }, - { name = "langchain-core" }, - { name = "langchain-google-genai" }, - { name = "langsmith" }, - { name = "packaging" }, - { name = "wcmatch" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/59/6a/1dcd74589ece546a16280c33646c45439666aa40755f6e6c7bbb13ca86db/deepagents-0.7.21.tar.gz", hash = "sha256:49a594e9727c82e2354ed33bb4e656f94ec30715d1803de886792e8a6e2854e6", size = 313698, upload-time = "2026-09-30T19:28:11.383Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/8d/5ad6f7271abe3d3b584c05661bc2fb0e75be7847581659e5de83c44f4592/deepagents-0.7.21-py3-none-any.whl", hash = "sha256:c8e6a89c75c62c232f6197a21af5f1298fda7eb7f1bfbc321bbabf1fa70eefed", size = 341937, upload-time = "2026-09-30T19:28:10.09Z" }, -] - [[package]] name = "detect-installer" version = "0.2.1" @@ -482,15 +437,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, ] -[[package]] -name = "docstring-parser" -version = "0.18.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, -] - [[package]] name = "duckdb" version = "1.5.6" @@ -752,54 +698,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/86/62c8477e775e1c035b817fd64964de3f8bd5743a53ca547c656484f5c356/fastar-0.12.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:70077a6d327ffa5d54f9d7bad6e6583c010530a5b120f5ad71a83b0a14d26ad9", size = 1025083, upload-time = "2026-08-20T09:11:23.724Z" }, ] -[[package]] -name = "filetype" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bb/29/745f7d30d47fe0f251d3ad3dc2978a23141917661998763bebb6da007eb1/filetype-1.2.0.tar.gz", hash = "sha256:66b56cd6474bf41d8c54660347d37afcc3f7d1970648de365c102ef77548aadb", size = 998020, upload-time = "2022-11-02T17:34:04.141Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/79/1b8fa1bb3568781e84c9200f951c735f3f157429f44be0495da55894d620/filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25", size = 19970, upload-time = "2022-11-02T17:34:01.425Z" }, -] - -[[package]] -name = "google-auth" -version = "2.59.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, - { name = "pyasn1-modules" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/81/e3/9f752a968e487fbc10b2c2a1dd561b62489f923972911009577bb88fe7bb/google_auth-2.59.1.tar.gz", hash = "sha256:ce50fc533ac02f489a2b183a0c156672c376ecb2091b1127bc7efba2975fff27", size = 384298, upload-time = "2026-09-30T17:26:25.738Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/38/311ac322f46ecd789f1342bb7adb0a14b940cf54b5db0e92d4f449aa6cf8/google_auth-2.59.1-py3-none-any.whl", hash = "sha256:89c3f931683a482ac97e61df7eb9da5e08a91703f3c752b2377d72cfb7d69e6a", size = 263519, upload-time = "2026-09-30T17:26:24.017Z" }, -] - -[package.optional-dependencies] -requests = [ - { name = "requests" }, -] - -[[package]] -name = "google-genai" -version = "2.27.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "distro" }, - { name = "google-auth", extra = ["requests"] }, - { name = "httpx" }, - { name = "pydantic" }, - { name = "requests" }, - { name = "sniffio" }, - { name = "tenacity" }, - { name = "typing-extensions" }, - { name = "websockets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6e/28/22267bc20b240f8ee05302b3e8d5a5bd37f4ae236f468639390b4f988063/google_genai-2.27.0.tar.gz", hash = "sha256:7827330be282b3337c220f5d90c501b316c1ca0ee89358afb15f673dfcdab4fb", size = 705814, upload-time = "2026-10-01T22:41:28.049Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/a5/5255377cf8cdac7201f74ade6e70a17f0fe8fb0f65d06b0b857b2bf592ba/google_genai-2.27.0-py3-none-any.whl", hash = "sha256:44940e99d5d49432bb5e4fe3bd8ac9906e75162d051ca74af63b733fb5632382", size = 1167536, upload-time = "2026-10-01T22:41:26.071Z" }, -] - [[package]] name = "googleapis-common-protos" version = "1.75.4" @@ -1136,20 +1034,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/03/8cc027f9d297473de94f134546dae2bfca255161ebbda988522fc509e1d5/langchain-1.4.3-py3-none-any.whl", hash = "sha256:0334066939dd1c7551889727ed2d09a72dea7a6aed2193ffb61817fb658d8921", size = 164708, upload-time = "2026-09-28T20:17:22.978Z" }, ] -[[package]] -name = "langchain-anthropic" -version = "1.7.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anthropic" }, - { name = "langchain-core" }, - { name = "pydantic" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/91/e6/45ead93ef6d9f244b502a6a32bf9a185b3da1cc7fa8003896a102dadac09/langchain_anthropic-1.7.5.tar.gz", hash = "sha256:6016f38e5c4bf12c16ecfa76abf133a978f459f4d2887b8e28435ae1ff00beb3", size = 764625, upload-time = "2026-09-29T15:22:07.106Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/a8/c507f2c1859b584843983e98bc71e0066c16a77e6c181921c928e0fdbcf7/langchain_anthropic-1.7.5-py3-none-any.whl", hash = "sha256:b21ef0e0c2d6175235a5b7eef4f5f89057df92d42dbfab66920a05605ef7173a", size = 65239, upload-time = "2026-09-29T15:22:05.832Z" }, -] - [[package]] name = "langchain-classic" version = "1.0.8" @@ -1189,21 +1073,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/90/10/8c00b4dc094886314c907ec7ead258dfee28948cf44175976cc4fba9aba8/langchain_core-1.6.6-py3-none-any.whl", hash = "sha256:d9a71e312ab7443701238750e04a57aefc037c4f1ec82837c8a1609d9a14a9b1", size = 572080, upload-time = "2026-09-29T14:52:53.213Z" }, ] -[[package]] -name = "langchain-google-genai" -version = "4.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "filetype" }, - { name = "google-genai" }, - { name = "langchain-core" }, - { name = "pydantic" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cf/4e/41798c80b574d958d189e049f13d64eb9246a66623465290f2cbfd641759/langchain_google_genai-4.4.0.tar.gz", hash = "sha256:7871beec56ac07b719f77c46997845db6ff2267b817bffb0f97877053b0895d7", size = 378415, upload-time = "2026-09-01T20:15:45.816Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/10/83bb535e78c2cf767a6193c2f3b00b2894c1581a36ef85911ebaa3b9a891/langchain_google_genai-4.4.0-py3-none-any.whl", hash = "sha256:8e23a1307bd2158590bbf9d99f1d658fc84a9d9ddb77fb1b372f8875a2bafbbf", size = 81617, upload-time = "2026-09-01T20:15:44.533Z" }, -] - [[package]] name = "langchain-mcp-adapters" version = "0.3.2" @@ -1543,7 +1412,6 @@ name = "my-v0-server" version = "0.1.0" source = { virtual = "." } dependencies = [ - { name = "deepagents" }, { name = "duckdb" }, { name = "fastapi", extra = ["standard"] }, { name = "httpx" }, @@ -1572,7 +1440,6 @@ dev = [ [package.metadata] requires-dist = [ - { name = "deepagents", specifier = ">=0.7.21" }, { name = "duckdb", specifier = ">=1.2" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.141.1" }, { name = "httpx", specifier = ">=0.28.1" }, @@ -2078,27 +1945,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/93/c0/37d4a7e8e2f7a6076283673d5298018ca26478b934c6ee369e10505ab32c/pyarrow-25.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4288f27577352d608ca08553b0865e4a9b3aa14820c5d95b53337218d609835b", size = 28753071, upload-time = "2026-08-10T12:40:46.623Z" }, ] -[[package]] -name = "pyasn1" -version = "0.6.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" }, -] - -[[package]] -name = "pyasn1-modules" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyasn1" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, -] - [[package]] name = "pycparser" version = "3.0" @@ -3449,18 +3295,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/96/165c6e540bf765ca1115062017adf861d0816525f02464ba71313fe7cf92/watchfiles-1.3.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ec46a1ff4c1d81405a5fc645abdbff5e4e9388f7c9a6cdb392316a7c29b884f", size = 456919, upload-time = "2026-09-21T09:08:54.372Z" }, ] -[[package]] -name = "wcmatch" -version = "11.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "bracex" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/57/43/30e407989e313677dbb9d5f045f966549a7254834571e342eaa4b55cc67b/wcmatch-11.0.1.tar.gz", hash = "sha256:1ea2b4fa678b8ca268253798d5963935df39132d47c3e241c0a0732224005e7d", size = 144662, upload-time = "2026-08-14T15:20:40.477Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/77/7a02b0f05b3ffcdbef9719ce3ee0b508d6a29b58e95299f1580055671db3/wcmatch-11.0.1-py3-none-any.whl", hash = "sha256:fd149ecddb9f0a88ea780017d6dde17c994e494e7f7303d4e3c9d6251f978f4b", size = 43449, upload-time = "2026-08-14T15:20:39.379Z" }, -] - [[package]] name = "websockets" version = "16.1.1"