diff --git a/api/apps/restful_apis/chat_api.py b/api/apps/restful_apis/chat_api.py index 263294b53fa..324da901993 100644 --- a/api/apps/restful_apis/chat_api.py +++ b/api/apps/restful_apis/chat_api.py @@ -20,6 +20,7 @@ import re import tempfile from copy import deepcopy +from types import SimpleNamespace from quart import Response, request @@ -30,7 +31,7 @@ ) from api.db.services.chunk_feedback_service import ChunkFeedbackService from api.db.services.conversation_service import ConversationService, structure_answer -from api.db.services.dialog_service import DialogService, async_ask, async_chat, gen_mindmap +from api.db.services.dialog_service import DialogService, async_chat, gen_mindmap from api.db.services.knowledgebase_service import KnowledgebaseService from api.db.services.llm_service import LLMBundle from api.db.services.search_service import SearchService @@ -67,6 +68,15 @@ "tts": False, "refine_multiturn": True, } +_DEFAULT_DIRECT_CHAT_PROMPT_CONFIG = { + "system": "", + "prologue": "", + "parameters": [], + "empty_response": "", + "quote": False, + "tts": False, + "refine_multiturn": True, +} _DEFAULT_RERANK_MODELS = {"BAAI/bge-reranker-v2-m3", "maidalun1020/bce-reranker-base_v1"} _READONLY_FIELDS = {"id", "tenant_id", "created_by", "create_time", "create_date", "update_time", "update_date"} _PERSISTED_FIELDS = set(DialogService.model._meta.fields) @@ -124,6 +134,39 @@ def _ensure_owned_chat(chat_id): ) +def _build_default_completion_dialog(): + return SimpleNamespace( + tenant_id=current_user.id, + llm_id="", + tenant_llm_id=None, + llm_setting={}, + prompt_config=deepcopy(_DEFAULT_DIRECT_CHAT_PROMPT_CONFIG), + kb_ids=[], + top_n=6, + top_k=1024, + rerank_id="", + similarity_threshold=0.1, + vector_similarity_weight=0.3, + meta_data_filter=None, + ) + + +def _create_session_for_completion(chat_id, dialog, user_id): + conv = { + "id": get_uuid(), + "dialog_id": chat_id, + "name": "New session", + "message": [{"role": "assistant", "content": dialog.prompt_config.get("prologue", "")}], + "user_id": user_id, + "reference": [], + } + ConversationService.save(**conv) + ok, conv_obj = ConversationService.get_by_id(conv["id"]) + if not ok: + raise LookupError("Fail to create a session!") + return conv_obj + + def _validate_llm_id(llm_id, tenant_id, llm_setting=None): if not llm_id: return None @@ -671,7 +714,7 @@ async def get_session(chat_id, session_id): return server_error_response(ex) -@manager.route("/chats//sessions/", methods=["PUT"]) # noqa: F821 +@manager.route("/chats//sessions/", methods=["PATCH"]) # noqa: F821 @login_required async def update_session(chat_id, session_id): if not _ensure_owned_chat(chat_id): @@ -829,7 +872,7 @@ async def update_message_feedback(chat_id, session_id, msg_id): return server_error_response(ex) -@manager.route("/chats/tts", methods=["POST"]) # noqa: F821 +@manager.route("/chat/audio/speech", methods=["POST"]) # noqa: F821 @login_required async def tts(): req = await get_request_json() @@ -857,9 +900,9 @@ def stream_audio(): return resp -@manager.route("/chats/transcriptions", methods=["POST"]) # noqa: F821 +@manager.route("/chat/audio/transcription", methods=["POST"]) # noqa: F821 @login_required -async def transcriptions(): +async def transcription(): req = await request.form stream_mode = req.get("stream", "false").lower() == "true" files = await request.files @@ -915,7 +958,7 @@ async def event_stream(): return Response(event_stream(), content_type="text/event-stream") -@manager.route("/chats/mindmap", methods=["POST"]) # noqa: F821 +@manager.route("/chat/mindmap", methods=["POST"]) # noqa: F821 @login_required @validate_request("question", "kb_ids") async def mindmap(): @@ -933,10 +976,10 @@ async def mindmap(): return get_json_result(data=mind_map) -@manager.route("/chats/related_questions", methods=["POST"]) # noqa: F821 +@manager.route("/chat/recommendation", methods=["POST"]) # noqa: F821 @login_required @validate_request("question") -async def related_questions(): +async def recommendation(): req = await get_request_json() search_id = req.get("search_id", "") @@ -971,10 +1014,10 @@ async def related_questions(): return get_json_result(data=[re.sub(r"^[0-9]\. ", "", a) for a in ans.split("\n") if re.match(r"^[0-9]\. ", a)]) -@manager.route("/chats//sessions//completions", methods=["POST"]) # noqa: F821 +@manager.route("/chat/completions", methods=["POST"]) # noqa: F821 @login_required @validate_request("messages") -async def session_completion(chat_id, session_id): +async def session_completion(): req = await get_request_json() msg = [] for m in req["messages"]: @@ -984,6 +1027,8 @@ async def session_completion(chat_id, session_id): continue msg.append(m) message_id = msg[-1].get("id") if msg else None + chat_id = req.pop("chat_id", "") or "" + session_id = req.pop("session_id", "") or "" chat_model_id = req.pop("llm_id", "") chat_model_config = {} @@ -993,21 +1038,41 @@ async def session_completion(chat_id, session_id): chat_model_config[model_config] = config try: - e, conv = ConversationService.get_by_id(session_id) - if not e: - return get_data_error_result(message="Session not found!") - if conv.dialog_id != chat_id: - return get_data_error_result(message="Session does not belong to this chat!") - conv.message = deepcopy(req["messages"]) - e, dia = DialogService.get_by_id(chat_id) - if not e: - return get_data_error_result(message="Chat not found!") + conv = None + if session_id and not chat_id: + return get_data_error_result(message="`chat_id` is required when `session_id` is provided.") + + if chat_id: + if not _ensure_owned_chat(chat_id): + return get_json_result( + data=False, + message="No authorization.", + code=RetCode.AUTHENTICATION_ERROR, + ) + e, dia = DialogService.get_by_id(chat_id) + if not e: + return get_data_error_result(message="Chat not found!") + if session_id: + e, conv = ConversationService.get_by_id(session_id) + if not e: + return get_data_error_result(message="Session not found!") + if conv.dialog_id != chat_id: + return get_data_error_result(message="Session does not belong to this chat!") + else: + conv = _create_session_for_completion(chat_id, dia, req.get("user_id", current_user.id)) + session_id = conv.id + conv.message = deepcopy(req["messages"]) + else: + dia = _build_default_completion_dialog() + dia.llm_setting = chat_model_config + del req["messages"] - if not conv.reference: - conv.reference = [] - conv.reference = [r for r in conv.reference if r] - conv.reference.append({"chunks": [], "doc_aggs": []}) + if conv is not None: + if not conv.reference: + conv.reference = [] + conv.reference = [r for r in conv.reference if r] + conv.reference.append({"chunks": [], "doc_aggs": []}) if chat_model_id: if not TenantLLMService.get_api_key(tenant_id=dia.tenant_id, model_name=chat_model_id): @@ -1015,16 +1080,21 @@ async def session_completion(chat_id, session_id): dia.llm_id = chat_model_id dia.llm_setting = chat_model_config - is_embedded = bool(chat_model_id) stream_mode = req.pop("stream", True) + def _format_answer(ans): + formatted = structure_answer(conv, ans, message_id, session_id) + if chat_id: + formatted["chat_id"] = chat_id + return formatted + async def stream(): nonlocal dia, msg, req, conv try: async for ans in async_chat(dia, msg, True, **req): - ans = structure_answer(conv, ans, message_id, conv.id) + ans = _format_answer(ans) yield "data:" + json.dumps({"code": 0, "message": "", "data": ans}, ensure_ascii=False) + "\n\n" - if not is_embedded: + if conv is not None: ConversationService.update_by_id(conv.id, conv.to_dict()) except Exception as ex: logging.exception(ex) @@ -1041,40 +1111,10 @@ async def stream(): answer = None async for ans in async_chat(dia, msg, **req): - answer = structure_answer(conv, ans, message_id, conv.id) - if not is_embedded: + answer = _format_answer(ans) + if conv is not None: ConversationService.update_by_id(conv.id, conv.to_dict()) break return get_json_result(data=answer) except Exception as ex: return server_error_response(ex) - - -@manager.route("/chats/ask", methods=["POST"]) # noqa: F821 -@login_required -@validate_request("question", "kb_ids") -async def ask(): - req = await get_request_json() - uid = current_user.id - - search_id = req.get("search_id", "") - search_config = {} - if search_id: - if search_app := SearchService.get_detail(search_id): - search_config = search_app.get("search_config", {}) - - async def stream(): - nonlocal req, uid - try: - async for ans in async_ask(req["question"], req["kb_ids"], uid, search_config=search_config): - yield "data:" + json.dumps({"code": 0, "message": "", "data": ans}, ensure_ascii=False) + "\n\n" - except Exception as ex: - yield "data:" + json.dumps({"code": 500, "message": str(ex), "data": {"answer": "**ERROR**: " + str(ex), "reference": []}}, ensure_ascii=False) + "\n\n" - yield "data:" + json.dumps({"code": 0, "message": "", "data": True}, ensure_ascii=False) + "\n\n" - - resp = Response(stream(), mimetype="text/event-stream") - resp.headers.add_header("Cache-control", "no-cache") - resp.headers.add_header("Connection", "keep-alive") - resp.headers.add_header("X-Accel-Buffering", "no") - resp.headers.add_header("Content-Type", "text/event-stream; charset=utf-8") - return resp diff --git a/api/apps/restful_apis/search_api.py b/api/apps/restful_apis/search_api.py index 82a357f306b..dfd3e7ed650 100644 --- a/api/apps/restful_apis/search_api.py +++ b/api/apps/restful_apis/search_api.py @@ -14,7 +14,10 @@ # limitations under the License. # -from quart import request +import json + +from quart import Response, request +from api.db.services.dialog_service import async_ask from api.apps import current_user, login_required from api.constants import DATASET_NAME_LIMIT @@ -168,3 +171,45 @@ def delete_search(search_id): return get_json_result(data=True) except Exception as e: return server_error_response(e) + + +@manager.route("/searches//completion", methods=["POST"]) # noqa: F821 +@login_required +@validate_request("question") +async def completion(search_id): + if not SearchService.accessible4deletion(search_id, current_user.id): + return get_json_result( + data=False, + message="No authorization.", + code=RetCode.AUTHENTICATION_ERROR, + ) + + req = await get_request_json() + uid = current_user.id + search_app = SearchService.get_detail(search_id) + if not search_app: + return get_data_error_result(message=f"Cannot find search {search_id}") + + search_config = search_app.get("search_config", {}) + kb_ids = search_config.get("kb_ids") or req.get("kb_ids") or [] + if not kb_ids: + return get_data_error_result(message="`kb_ids` is required.") + + async def stream(): + nonlocal req, uid, kb_ids, search_config + try: + async for ans in async_ask(req["question"], kb_ids, uid, search_config=search_config): + yield "data:" + json.dumps({"code": 0, "message": "", "data": ans}, ensure_ascii=False) + "\n\n" + except Exception as ex: + yield "data:" + json.dumps( + {"code": 500, "message": str(ex), "data": {"answer": "**ERROR**: " + str(ex), "reference": []}}, + ensure_ascii=False, + ) + "\n\n" + yield "data:" + json.dumps({"code": 0, "message": "", "data": True}, ensure_ascii=False) + "\n\n" + + resp = Response(stream(), mimetype="text/event-stream") + resp.headers.add_header("Cache-control", "no-cache") + resp.headers.add_header("Connection", "keep-alive") + resp.headers.add_header("X-Accel-Buffering", "no") + resp.headers.add_header("Content-Type", "text/event-stream; charset=utf-8") + return resp diff --git a/docs/guides/chat/set_chat_variables.md b/docs/guides/chat/set_chat_variables.md index a9bd9dcdcb8..8f396345b71 100644 --- a/docs/guides/chat/set_chat_variables.md +++ b/docs/guides/chat/set_chat_variables.md @@ -72,13 +72,19 @@ See [Converse with chat assistant](../../references/http_api_reference.md#conver ```json {9} curl --request POST \ - --url http://{address}/api/v1/chats/{chat_id}/completions \ + --url http://{address}/api/v1/chat/completions \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer ' \ --data-binary ' { - "question": "xxxxxxxxx", + "chat_id": "{chat_id}", "stream": true, + "messages": [ + { + "role": "user", + "content": "xxxxxxxxx" + } + ], "style":"hilarious" }' ``` @@ -109,4 +115,3 @@ while True: print(ans.content[len(cont):], end='', flush=True) cont = ans.content ``` - diff --git a/docs/references/http_api_reference.md b/docs/references/http_api_reference.md index 3688daad3da..d10397820ed 100644 --- a/docs/references/http_api_reference.md +++ b/docs/references/http_api_reference.md @@ -3470,13 +3470,13 @@ Failure: ### Update chat assistant's session -**PUT** `/api/v1/chats/{chat_id}/sessions/{session_id}` +**PATCH** `/api/v1/chats/{chat_id}/sessions/{session_id}` Updates a session of a specified chat assistant. #### Request -- Method: PUT +- Method: PATCH - URL: `/api/v1/chats/{chat_id}/sessions/{session_id}` - Headers: - `'content-Type: application/json'` @@ -3487,7 +3487,7 @@ Updates a session of a specified chat assistant. ##### Request example ```bash -curl --request PUT \ +curl --request PATCH \ --url http://{address}/api/v1/chats/{chat_id}/sessions/{session_id} \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer ' \ @@ -3895,9 +3895,13 @@ Failure: ### Converse with chat assistant -**POST** `/api/v1/chats/{chat_id}/completions` +**POST** `/api/v1/chat/completions` + +Starts a chat completion request. The same endpoint supports three modes: -Asks a specified chat assistant a question to start an AI-powered conversation. +- No `chat_id`: talk directly with the tenant's default chat model. +- With `chat_id` but no `session_id`: use that chat's configuration and automatically create a new session. +- With both `chat_id` and `session_id`: continue an existing chat session. :::tip NOTE @@ -3917,88 +3921,87 @@ Asks a specified chat assistant a question to start an AI-powered conversation. #### Request - Method: POST -- URL: `/api/v1/chats/{chat_id}/completions` +- URL: `/api/v1/chat/completions` - Headers: - `'content-Type: application/json'` - `'Authorization: Bearer '` - Body: - - `"question"`: `string` + - `"messages"`: `list[object]` - `"stream"`: `boolean` + - `"chat_id"`: `string` (optional) - `"session_id"`: `string` (optional) - - `"user_id`: `string` (optional) - - `"metadata_condition"`: `object` (optional) + - `"llm_id"`: `string` (optional) ##### Request example ```bash curl --request POST \ - --url http://{address}/api/v1/chats/{chat_id}/completions \ + --url http://{address}/api/v1/chat/completions \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer ' \ --data-binary ' { + "messages": [ + { + "role": "user", + "content": "Who are you?" + } + ] }' ``` ```bash curl --request POST \ - --url http://{address}/api/v1/chats/{chat_id}/completions \ + --url http://{address}/api/v1/chat/completions \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer ' \ --data-binary ' { - "question": "Who are you", + "chat_id": "{chat_id}", "stream": true, "session_id":"9fa7691cb85c11ef9c5f0242ac120005", - "metadata_condition": { - "logic": "and", - "conditions": [ + "messages": [ { - "name": "author", - "comparison_operator": "is", - "value": "bob" + "role": "assistant", + "content": "Hi! I'\''m your assistant. What can I do for you?" + }, + { + "role": "user", + "content": "Who are you?" } - ] - } + ] }' ``` ##### Request Parameters -- `chat_id`: (*Path parameter*) - The ID of the associated chat assistant. -- `"question"`: (*Body Parameter*), `string`, *Required* - The question to start an AI-powered conversation. +- `"messages"`: (*Body Parameter*), `list[object]`, *Required* + The conversation messages sent to the model. - `"stream"`: (*Body Parameter*), `boolean` Indicates whether to output responses in a streaming way: - `true`: Enable streaming (default). - `false`: Disable streaming. +- `"chat_id"`: (*Body Parameter*) + Optional chat assistant ID. If omitted, the tenant's default chat model is used directly. - `"session_id"`: (*Body Parameter*) - The ID of session. If it is not provided, a new session will be generated. -- `"user_id"`: (*Body parameter*), `string` - The optional user-defined ID. Valid *only* when no `session_id` is provided. -- `"metadata_condition"`: (*Body parameter*), `object` - Optional metadata filter conditions applied to retrieval results. - - `logic`: `string`, one of `and` / `or` - - `conditions`: `list[object]` where each condition contains: - - `name`: `string` metadata key - - `comparison_operator`: `string` (e.g. `is`, `not is`, `contains`, `not contains`, `start with`, `end with`, `empty`, `not empty`, `>`, `<`, `≥`, `≤`) - - `value`: `string|number|boolean` (optional for `empty`/`not empty`) + Optional session ID. If `chat_id` is provided but `session_id` is omitted, a new session will be generated automatically. +- `"llm_id"`: (*Body Parameter*), `string` + Optional model override when a specific chat model should be used for this request. #### Response -Success without `session_id`: +Success without `chat_id` or `session_id`: ```json data:{ "code": 0, "message": "", "data": { - "answer": "Hi! I'm your assistant. What can I do for you?", + "answer": "I am an assistant powered by the tenant's default chat model.", "reference": {}, "audio_binary": null, - "id": null, - "session_id": "b01eed84b85611efa0e90242ac120005" + "id": "b01eed84b85611efa0e90242ac120005", + "session_id": "" } } data:{ @@ -4008,7 +4011,7 @@ data:{ } ``` -Success with `session_id`: +Success with `chat_id` and `session_id`: ```json data:{ @@ -5276,14 +5279,14 @@ Failure: ### Text-to-speech -**POST** `/api/v1/chats/tts` +**POST** `/api/v1/chat/audio/speech` Converts text to speech audio using the tenant's default TTS model, returning a streaming audio response. #### Request - Method: POST -- URL: `/api/v1/chats/tts` +- URL: `/api/v1/chat/audio/speech` - Headers: - `'Content-Type: application/json'` - `'Authorization: Bearer '` @@ -5294,7 +5297,7 @@ Converts text to speech audio using the tenant's default TTS model, returning a ```bash curl --request POST \ - --url http://{address}/api/v1/chats/tts \ + --url http://{address}/api/v1/chat/audio/speech \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer ' \ --output audio.mp3 \ @@ -5318,14 +5321,14 @@ Failure: ### Speech-to-text -**POST** `/api/v1/chats/transcriptions` +**POST** `/api/v1/chat/audio/transcription` Transcribes an audio file using the tenant's default ASR (automatic speech recognition) model. #### Request - Method: POST -- URL: `/api/v1/chats/transcriptions` +- URL: `/api/v1/chat/audio/transcription` - Headers: - `'Authorization: Bearer '` - Body (multipart/form-data): @@ -5336,7 +5339,7 @@ Transcribes an audio file using the tenant's default ASR (automatic speech recog ```bash curl --request POST \ - --url http://{address}/api/v1/chats/transcriptions \ + --url http://{address}/api/v1/chat/audio/transcription \ --header 'Authorization: Bearer ' \ --form file=@recording.wav \ --form stream=false @@ -5370,14 +5373,14 @@ Failure: ### Generate mind map -**POST** `/api/v1/chats/mindmap` +**POST** `/api/v1/chat/mindmap` Generates a mind map from a question and a set of knowledge base IDs. #### Request - Method: POST -- URL: `/api/v1/chats/mindmap` +- URL: `/api/v1/chat/mindmap` - Headers: - `'Content-Type: application/json'` - `'Authorization: Bearer '` @@ -5390,7 +5393,7 @@ Generates a mind map from a question and a set of knowledge base IDs. ```bash curl --request POST \ - --url http://{address}/api/v1/chats/mindmap \ + --url http://{address}/api/v1/chat/mindmap \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer ' \ --data '{ @@ -5426,7 +5429,7 @@ Failure: ### Generate related questions -**POST** `/api/v1/chats/related_questions` +**POST** `/api/v1/chat/recommandation` Generates five to ten alternative question strings from the user's original query to retrieve more relevant search results. @@ -5441,7 +5444,7 @@ The chat model autonomously determines the number of questions to generate based #### Request - Method: POST -- URL: `/api/v1/chats/related_questions` +- URL: `/api/v1/chat/recommandation` - Headers: - `'content-Type: application/json'` - `'Authorization: Bearer '` @@ -5453,7 +5456,7 @@ The chat model autonomously determines the number of questions to generate based ```bash curl --request POST \ - --url http://{address}/api/v1/chats/related_questions \ + --url http://{address}/api/v1/chat/recommandation \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer ' \ --data '{ @@ -7947,3 +7950,62 @@ Failure: "message": "No authorization." } ``` + +--- + +### Search completion + +**POST** `/api/v1/searches/{search_id}/completion` + +Generates an answer using the saved search app configuration and returns the result as a Server-Sent Events stream. + +#### Request + +- Method: POST +- URL: `/api/v1/searches/{search_id}/completion` +- Headers: + - `'Content-Type: application/json'` + - `'Authorization: Bearer '` +- Body: + - `"question"`: `string` *(Required)* The user question. + - `"kb_ids"`: `list[string]` *(Optional)* Fallback dataset IDs. Used only when the search app config does not already define `kb_ids`. + +##### Request example + +```bash +curl --request POST \ + --url http://{address}/api/v1/searches/{search_id}/completion \ + --header 'Content-Type: application/json' \ + --header 'Authorization: Bearer ' \ + --data '{ + "question": "What is retrieval-augmented generation?" + }' +``` + +##### Request parameters + +- `search_id`: (*Path parameter*), `string`, *Required* + The ID of the search app. +- `"question"`: (*Body parameter*), `string`, *Required* + The user question. +- `"kb_ids"`: (*Body parameter*), `list[string]` + Optional fallback dataset IDs when the search app config does not define them. + +#### Response + +Success (streaming): + +```text +data: {"code": 0, "message": "", "data": {"answer": "...", "reference": {...}}} + +data: {"code": 0, "message": "", "data": true} +``` + +Failure: + +```json +{ + "code": 109, + "message": "No authorization." +} +``` diff --git a/sdk/python/ragflow_sdk/modules/session.py b/sdk/python/ragflow_sdk/modules/session.py index 2ea65d17afd..bc62f22833c 100644 --- a/sdk/python/ragflow_sdk/modules/session.py +++ b/sdk/python/ragflow_sdk/modules/session.py @@ -115,8 +115,8 @@ def _ask_agent(self, question: str, stream: bool, **kwargs): return res def update(self, update_message): - res = self.put(f"/chats/{self.chat_id}/sessions/{self.id}", - update_message) + res = self.patch(f"/chats/{self.chat_id}/sessions/{self.id}", + update_message) res = res.json() if res.get("code") != 0: raise Exception(res.get("message")) diff --git a/test/testcases/test_http_api/common.py b/test/testcases/test_http_api/common.py index 198090ee80e..fc8c1446648 100644 --- a/test/testcases/test_http_api/common.py +++ b/test/testcases/test_http_api/common.py @@ -267,7 +267,7 @@ def list_session_with_chat_assistants(auth, chat_assistant_id, params=None): def update_session_with_chat_assistant(auth, chat_assistant_id, session_id, payload=None): url = f"{HOST_ADDRESS}{SESSION_WITH_CHAT_ASSISTANT_API_URL}/{session_id}".format(chat_id=chat_assistant_id) - res = requests.put(url=url, headers=HEADERS, auth=auth, json=payload) + res = requests.patch(url=url, headers=HEADERS, auth=auth, json=payload) return res.json() @@ -395,7 +395,7 @@ def agent_completions(auth, agent_id, payload=None): return res.json() -def chat_completions(auth, chat_id, payload=None): +def chat_completions(auth, chat_id=None, payload=None): """ Send a question/message to a chat assistant and get completion. @@ -403,14 +403,19 @@ def chat_completions(auth, chat_id, payload=None): auth: Authentication object chat_id: Chat assistant ID payload: Dictionary containing: - - question: str (required) - The question to ask + - messages: list (required) - Conversation messages - stream: bool (optional) - Whether to stream responses, default False - session_id: str (optional) - Session ID for conversation context Returns: Response JSON with answer data """ - url = f"{HOST_ADDRESS}/api/{VERSION}/chats/{chat_id}/completions" + url = f"{HOST_ADDRESS}/api/{VERSION}/chat/completions" + payload = dict(payload or {}) + if chat_id: + payload.setdefault("chat_id", chat_id) + if "question" in payload and "messages" not in payload: + payload["messages"] = [{"role": "user", "content": payload.pop("question")}] res = requests.post(url=url, headers=HEADERS, auth=auth, json=payload) return res.json() diff --git a/test/testcases/test_http_api/test_session_management/test_chat_completions.py b/test/testcases/test_http_api/test_session_management/test_chat_completions.py index 000a9058568..0809dbeeebb 100644 --- a/test/testcases/test_http_api/test_session_management/test_chat_completions.py +++ b/test/testcases/test_http_api/test_session_management/test_chat_completions.py @@ -62,7 +62,11 @@ def test_chat_completion_stream_false_with_session(self, HttpApiAuth, add_datase res = chat_completions( HttpApiAuth, chat_id, - {"question": "hello", "stream": False, "session_id": session_id}, + { + "messages": [{"role": "user", "content": "hello"}], + "stream": False, + "session_id": session_id, + }, ) assert res["code"] == 0, res assert isinstance(res["data"], dict), res @@ -75,10 +79,14 @@ def test_chat_completion_invalid_chat(self, HttpApiAuth): res = chat_completions( HttpApiAuth, "invalid_chat_id", - {"question": "hello", "stream": False, "session_id": "invalid_session"}, + { + "messages": [{"role": "user", "content": "hello"}], + "stream": False, + "session_id": "invalid_session", + }, ) - assert res["code"] == 102, res - assert "You don't own the chat" in res.get("message", ""), res + assert res["code"] == 109, res + assert "No authorization." in res.get("message", ""), res @pytest.mark.p2 def test_chat_completion_invalid_session(self, HttpApiAuth, request): @@ -91,32 +99,44 @@ def test_chat_completion_invalid_session(self, HttpApiAuth, request): res = chat_completions( HttpApiAuth, chat_id, - {"question": "hello", "stream": False, "session_id": "invalid_session"}, + { + "messages": [{"role": "user", "content": "hello"}], + "stream": False, + "session_id": "invalid_session", + }, ) assert res["code"] == 102, res - assert "You don't own the session" in res.get("message", ""), res + assert "Session not found!" in res.get("message", ""), res @pytest.mark.p2 - def test_chat_completion_invalid_metadata_condition(self, HttpApiAuth, request): + def test_chat_completion_stream_false_with_chat_without_session(self, HttpApiAuth, request): res = create_chat_assistant(HttpApiAuth, {"name": "chat_completion_invalid_meta", "dataset_ids": []}) assert res["code"] == 0, res chat_id = res["data"]["id"] request.addfinalizer(lambda: delete_all_chat_assistants(HttpApiAuth)) request.addfinalizer(lambda: delete_all_sessions_with_chat_assistant(HttpApiAuth, chat_id)) - res = create_session_with_chat_assistant(HttpApiAuth, chat_id, {"name": "session_for_meta"}) + res = chat_completions( + HttpApiAuth, + chat_id, + { + "messages": [{"role": "user", "content": "hello"}], + "stream": False, + }, + ) assert res["code"] == 0, res - session_id = res["data"]["id"] + assert res["data"]["session_id"], res + @pytest.mark.p2 + def test_chat_completion_stream_false_without_chat(self, HttpApiAuth): res = chat_completions( HttpApiAuth, - chat_id, + None, { - "question": "hello", + "messages": [{"role": "user", "content": "hello"}], "stream": False, - "session_id": session_id, - "metadata_condition": "invalid", }, ) - assert res["code"] == 102, res - assert "metadata_condition" in res.get("message", ""), res + assert res["code"] == 0, res + assert isinstance(res["data"], dict), res + assert "answer" in res["data"], res diff --git a/test/testcases/test_web_api/test_search_app/test_search_routes_unit.py b/test/testcases/test_web_api/test_search_app/test_search_routes_unit.py index c755313b713..3de9f3c1565 100644 --- a/test/testcases/test_web_api/test_search_app/test_search_routes_unit.py +++ b/test/testcases/test_web_api/test_search_app/test_search_routes_unit.py @@ -40,6 +40,13 @@ def __exit__(self, _exc_type, _exc, _tb): return False +class _StubResponse: + def __init__(self, data=None, mimetype=None): + self.data = data + self.mimetype = mimetype + self.headers = {} + + class _Args(dict): def get(self, key, default=None): return super().get(key, default) @@ -111,6 +118,7 @@ def _load_search_api(monkeypatch): quart_mod = ModuleType("quart") quart_mod.request = SimpleNamespace(args=_Args()) + quart_mod.Response = _StubResponse monkeypatch.setitem(sys.modules, "quart", quart_mod) common_pkg = ModuleType("common") @@ -201,6 +209,15 @@ def delete_by_id(_search_id): search_service_mod.SearchService = _SearchService monkeypatch.setitem(sys.modules, "api.db.services.search_service", search_service_mod) + dialog_service_mod = ModuleType("api.db.services.dialog_service") + + async def _async_ask(*_args, **_kwargs): + if False: + yield None + + dialog_service_mod.async_ask = _async_ask + monkeypatch.setitem(sys.modules, "api.db.services.dialog_service", dialog_service_mod) + user_service_mod = ModuleType("api.db.services.user_service") class _TenantService: diff --git a/web/src/hooks/logic-hooks.ts b/web/src/hooks/logic-hooks.ts index d4a731c4677..1ef34170c0f 100644 --- a/web/src/hooks/logic-hooks.ts +++ b/web/src/hooks/logic-hooks.ts @@ -295,18 +295,17 @@ export const useSendMessageWithSse = () => { return { ...d, answer: newAnswer, - conversationId: body?.conversation_id, + conversationId: body?.session_id ?? body?.conversation_id, chatBoxId: body.chatBoxId, }; }); } - } catch (e) { + } catch { // Swallow parse errors silently } } - } catch (e) { - if (e instanceof DOMException && e.name === 'AbortError') { - console.log('Request was aborted by user or logic.'); + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { break; } } @@ -314,7 +313,7 @@ export const useSendMessageWithSse = () => { setDoneValue(body, true); resetAnswer(); return { data: await res, response }; - } catch (e) { + } catch { setDoneValue(body, true); resetAnswer(); @@ -357,7 +356,7 @@ export const useSpeechWithSse = (url: string = api.chatsTts) => { if (res?.code !== 0) { message.error(res?.message); } - } catch (error) { + } catch { // Swallow errors silently } return response; diff --git a/web/src/pages/next-chats/hooks/use-send-chat-message.ts b/web/src/pages/next-chats/hooks/use-send-chat-message.ts index 6997d577611..40f94c45505 100644 --- a/web/src/pages/next-chats/hooks/use-send-chat-message.ts +++ b/web/src/pages/next-chats/hooks/use-send-chat-message.ts @@ -98,8 +98,10 @@ export const useSendMessage = (controller: AbortController) => { } & NextMessageInputOnPressEnterParameter) => { const sessionId = currentConversationId ?? conversationId; const res = await send( - api.completionUrl(chatId!, sessionId), + api.completionUrl, { + chat_id: chatId, + session_id: sessionId, messages: [ ...(Array.isArray(messages) && messages?.length > 0 ? messages diff --git a/web/src/pages/next-chats/hooks/use-send-single-message.ts b/web/src/pages/next-chats/hooks/use-send-single-message.ts index 6dcf7d597b9..dba02f130ba 100644 --- a/web/src/pages/next-chats/hooks/use-send-single-message.ts +++ b/web/src/pages/next-chats/hooks/use-send-single-message.ts @@ -67,8 +67,10 @@ export function useSendSingleMessage({ } & NextMessageInputOnPressEnterParameter) => { const sessionId = currentConversationId ?? conversationId; const res = await send( - api.completionUrl(chatId!, sessionId), + api.completionUrl, { + chat_id: chatId, + session_id: sessionId, messages: [ ...(Array.isArray(messages) && messages?.length > 0 ? messages @@ -92,6 +94,7 @@ export function useSendSingleMessage({ [ derivedMessages, conversationId, + chatId, removeLatestMessage, setValue, send, diff --git a/web/src/pages/next-search/hooks.ts b/web/src/pages/next-search/hooks.ts index c34d7b830a8..3f47751d3a4 100644 --- a/web/src/pages/next-search/hooks.ts +++ b/web/src/pages/next-search/hooks.ts @@ -308,7 +308,11 @@ export const useSendQuestion = ( related_search: boolean = false, ) => { const { sharedId } = useGetSharedSearchParams(); - const askUrl = sharedId ? api.askShare : api.ask; + const askUrl = sharedId + ? api.askShare + : searchId + ? api.searchCompletion(searchId) + : ''; const { send, answer, done, stopOutputMessage } = useSendMessageWithSse(); const { testChunk, loading } = useTestChunkRetrieval(tenantId); @@ -331,12 +335,15 @@ export const useSendQuestion = ( setIsFirstRender(false); setCurrentAnswer({} as IAnswer); if (enableAI) { + if (!sharedId && !searchId) { + message.error('Search ID is required.'); + return; + } setSendingLoading(true); send(askUrl, { kb_ids: kbIds, question: q, tenantId, - search_id: searchId, }); } testChunk({ @@ -355,12 +362,14 @@ export const useSendQuestion = ( [ send, testChunk, + askUrl, kbIds, fetchRelatedQuestions, setPagination, pagination.pageSize, tenantId, searchId, + sharedId, related_search, ], ); diff --git a/web/src/services/next-chat-service.ts b/web/src/services/next-chat-service.ts index ee54dcf38f5..6f967fc55b9 100644 --- a/web/src/services/next-chat-service.ts +++ b/web/src/services/next-chat-service.ts @@ -17,7 +17,6 @@ const { deleteMessage, thumbup, chatsTts, - ask, chatsMindmap, chatsRelatedQuestions, uploadAndParse, @@ -67,7 +66,7 @@ const methods = { }, updateSession: { url: updateSession, - method: 'put', + method: 'patch', }, removeSessions: { url: removeSessions, @@ -79,16 +78,12 @@ const methods = { }, thumbup: { url: thumbup, - method: 'put', + method: 'patch', }, chatsTts: { url: chatsTts, method: 'post', }, - ask: { - url: ask, - method: 'post', - }, chatsMindmap: { url: chatsMindmap, method: 'post', diff --git a/web/src/utils/api.ts b/web/src/utils/api.ts index 0dcf5d8aa3d..8f51c15457f 100644 --- a/web/src/utils/api.ts +++ b/web/src/utils/api.ts @@ -52,7 +52,7 @@ export default { // plugin llmTools: `${webAPI}/plugin/llm_tools`, - chatsTranscriptions: `${restAPIv1}/chats/transcriptions`, + chatsTranscriptions: `${restAPIv1}/chat/audio/transcription`, // knowledge base @@ -147,12 +147,12 @@ export default { `${restAPIv1}/chats/${chatId}/sessions/${sessionId}/messages/${msgId}`, thumbup: (chatId: string, sessionId: string, msgId: string) => `${restAPIv1}/chats/${chatId}/sessions/${sessionId}/messages/${msgId}/feedback`, - completionUrl: (chatId: string, sessionId: string) => - `${restAPIv1}/chats/${chatId}/sessions/${sessionId}/completions`, - chatsTts: `${restAPIv1}/chats/tts`, - ask: `${restAPIv1}/chats/ask`, - chatsMindmap: `${restAPIv1}/chats/mindmap`, - chatsRelatedQuestions: `${restAPIv1}/chats/related_questions`, + completionUrl: `${restAPIv1}/chat/completions`, + chatsTts: `${restAPIv1}/chat/audio/speech`, + searchCompletion: (searchId: string) => + `${restAPIv1}/searches/${searchId}/completion`, + chatsMindmap: `${restAPIv1}/chat/mindmap`, + chatsRelatedQuestions: `${restAPIv1}/chat/recommandation`, // next chat fetchExternalChatInfo: (id: string) => `${restAPIv1}/chatbots/${id}/info`,