基于本地知识库的AI客服系统(支持转人工)开发和实施文档

阅读原文

建议阅读原文,始终查看最新文档版本,获得最佳阅读体验:《基于知识库的AI客服系统开发和实施文档》

前言

本文详细说明如何实施我开发的AI客服系统

项目总结报告

请点击:《基于知识库的AI客服系统 - 项目总结报告》

源代码

代码已开源,github项目网址为:iamtornado/AI-Assistant

开发环境

Windows11系统,用trae国内版(基于vs code)开发

python版本:3.13

技术选型

我选择了以下技术栈:

前端与应用框架: Chainlit

一个用于快速构建聊天应用的Python框架,提供了用户认证、会话管理和友好的UI。其支持实时渲染,比如,消息中如果有代码,则会自动显示代码块,方便阅读。

知识库与问答引擎: RAGFlow

领先的检索增强生成(RAG)平台,负责知识的存储、检索和智能问答。其还支持多语言、MCP、agent等。

人工客服平台: Rocket.Chat

一个功能强大的开源团队沟通协作平台,用作人工客服的工作台。

消息队列: Redis

利用其发布/订阅和Stream数据结构,实现各服务间的异步消息通信,确保系统的响应速度和稳定性。

Web服务: FastAPI

用于构建接收Rocket.Chat消息的Webhook服务,性能卓越。

身份认证: Keycloak

通过OAuth 2.0协议,为Chainlit提供统一、安全的用户身份认证服务。keycloak对接的是企业LDAP,因此用户可以直接通过现有的域账号登录AI客服系统。

系统架构图

总体架构图

image.png

时序图

2733bf3ff571d2f755f0bddab68bd68b.png

image.png

以上图形是利用 DiagramGPT(eraser) 生成的

前端(chainlit)

安装chainlit

参考资料:Installation - Chainlit

Linux系统

首先要安装python和pip,此过程省略

注意,下面几行命令是在ubuntu desktop 24.04系统上运行的,Windows系统命令有所区别

#创建虚拟环境
python3 -m venv chainlit
#激活虚拟环境
source ./chainlit/bin/activate
#用pip安装chainlit包
pip install -U chainlit

image.png

image.png

Windows系统,trae(vs code)

先通过vs code创建一个空的app.py文件,然后通过命令面板创建虚拟环境

image.png

成功创建虚拟环境后,就可以在资源管理中看到自动生成了.env文件夹

image.png

激活虚拟环境

.venv\Scripts\activate

image.png

检查当前python解释器是不是虚拟环境中的解释器,如果不是,务必选择虚拟环境中的解释器

image.png

用pip安装chainlit包,时间会比较长,因为国内下载包会比较慢

pip install --upgrade chainlit

初步了解chainlit–编写极简应用逻辑

参考资料:In Pure Python - Chainlit

如果你对chainlit已经有了初步了解,可以直接看下一小节。

这是一个非常简单的应用,就是会将用户的输入再返回,前面加上Received:

import chainlit as cl


@cl.on_message
async def main(message: cl.Message):
    # Your custom logic goes here...

    # Send a response back to the user
    await cl.Message(
        content=f"Received: {message.content}",
    ).send()

设置环境变量(API key)

下面这种方式是用于临时设置环境变量

$env:OPENAI_API_KEY = "<替换为你的API key>"

官方建议用.env文件设置环境变量,官方文档为Environment Variables - Chainlit

运行应用

在终端中导航到app.py文件所在的文件夹,然后运行命令:

chainlit run app.py -w

下图是Linux系统中的截图

image.png

会自动打开浏览器

image.png

下面是Windows系统中trae的截图,也会自动在Windows系统默认浏览器中打开网页

image.png

image.png

image.png

也可以通过trae的webview功能直接预览,更加方便开发调试

image.png

image.png

实现对接LDAP(存在问题,请勿阅读)

本小节的内容是我在做实验时记录的,存在问题,请直接阅读下一小节,了解如何将keycloak对接企业LDAP(Microsoft AD)

部署keycloak

参考资料:Docker - Keycloak

:::
我在k8s部署keycloak时(无论是helmkeycloak 24.4.12 · bitnami/bitnami,还是官方的教程Kubernetes - Keycloak),发现一个问题,用web登录,一直提示“We are sorry… HTTPS required”,但是用docker部署没有这个问题。

我想应该是这两种方式强制要求用https

官方还提供了通过operator的方式部署keycloak,我没有试过。Keycloak Operator Installation - Keycloak

https://operatorhub.io/operator/keycloak-operator

image.png
:::

用docker部署(无持久存储)

不建议用下面的方式部署keycloak,重启容器后,所有数据都会丢失

#注意,下行命令中的--dns=192.168.124.7参数含义为让容器使用此dns,如果不指定,则容器内的程序无法访问企业内部网络,从而导致无法进行dns解析,在连接LDAP时会报错
docker run -p 8080:8080 -e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin --dns=192.168.124.7 docker.1ms.run/keycloak/keycloak:26.2.5 start-dev

用docker部署(持久存储)

对于开发环境和测试环境,建议用下面的方法部署keycloak,数据不会丢失,保存在本地(以文件形式)

mkdir -p /opt/keycloak/data
sudo chmod -R a+rwx /opt/keycloak/data

docker run -d \
  --name keycloak \
  -p 8080:8080 \
  -e KC_DB=dev-file \
  -v /opt/keycloak/data:/opt/keycloak/data \
  -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
  -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
  docker.1ms.run/keycloak/keycloak:26.2.5 start-dev

:::
如果出现下面的错误日志,则说明容器或者说是keycloak程序无法解析域名解析,需要调整dns,使其能解析指定域名

2025-03-12 11:50:46,712 ERROR [org.keycloak.services] (executor-thread-17) KC-SERVICES0055: Error when connecting to LDAP: dc-t.dltornado2.com:389: javax.naming.CommunicationException: dc-t.dltornado2.com:389 [Root exception is java.net.UnknownHostException: dc-t.dltornado2.com]

    at java.naming/com.sun.jndi.ldap.Connection.<init>(Connection.java:251)

    at java.naming/com.sun.jndi.ldap.LdapClient.<init>(LdapClient.java:141)

    at java.naming/com.sun.jndi.ldap.LdapClient.getInstance(LdapClient.java:1620)

    at java.naming/com.sun.jndi.ldap.LdapCtx.connect(LdapCtx.java:2848)

    at java.naming/com.sun.jndi.ldap.LdapCtx.<init>(LdapCtx.java:349)

    at java.naming/com.sun.jndi.ldap.LdapCtxFactory.getLdapCtxFromUrl(LdapCtxFactory.java:229)

    at java.naming/com.sun.jndi.ldap.LdapCtxFactory.getUsingURL(LdapCtxFactory.java:189)

    at java.naming/com.sun.jndi.ldap.LdapCtxFactory.getUsingURLs(LdapCtxFactory.java:247)

    at java.naming/com.sun.jndi.ldap.LdapCtxFactory.getLdapCtxInstance(LdapCtxFactory.java:154)

    at java.naming/com.sun.jndi.ldap.LdapCtxFactory.getInitialContext(LdapCtxFactory.java:84)

    at java.naming/javax.naming.spi.NamingManager.getInitialContext(NamingManager.java:520)

    at java.naming/javax.naming.InitialContext.getDefaultInitCtx(InitialContext.java:305)

    at java.naming/javax.naming.InitialContext.init(InitialContext.java:236)

    at java.naming/javax.naming.ldap.InitialLdapContext.<init>(InitialLdapContext.java:154)

    at org.keycloak.storage.ldap.idm.store.ldap.LDAPContextManager.createLdapContext(LDAPContextManager.java:81)

    at org.keycloak.storage.ldap.idm.store.ldap.LDAPContextManager.getLdapContext(LDAPContextManager.java:106)

    at org.keycloak.services.managers.LDAPServerCapabilitiesManager.testLDAP(LDAPServerCapabilitiesManager.java:202)

    at org.keycloak.services.resources.admin.TestLdapConnectionResource.testLDAPConnection(TestLdapConnectionResource.java:92)

    at org.keycloak.services.resources.admin.TestLdapConnectionResource$quarkusrestinvoker$testLDAPConnection\_d65ae9343a71f2736595679e81594225f9de5d6f.invoke(Unknown Source)

    at org.jboss.resteasy.reactive.server.handlers.InvocationHandler.handle(InvocationHandler.java:29)

    at io.quarkus.resteasy.reactive.server.runtime.QuarkusResteasyReactiveRequestContext.invokeHandler(QuarkusResteasyReactiveRequestContext.java:141)

    at org.jboss.resteasy.reactive.common.core.AbstractResteasyReactiveContext.run(AbstractResteasyReactiveContext.java:147)

    at io.quarkus.vertx.core.runtime.VertxCoreRecorder$14.runWith(VertxCoreRecorder.java:635)

    at org.jboss.threads.EnhancedQueueExecutor$Task.doRunWith(EnhancedQueueExecutor.java:2516)

    at org.jboss.threads.EnhancedQueueExecutor$Task.run(EnhancedQueueExecutor.java:2495)

    at org.jboss.threads.EnhancedQueueExecutor$ThreadBody.run(EnhancedQueueExecutor.java:1521)

    at org.jboss.threads.DelegatingRunnable.run(DelegatingRunnable.java:11)

    at org.jboss.threads.ThreadLocalResettingRunnable.run(ThreadLocalResettingRunnable.java:11)

    at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)

    at java.base/java.lang.Thread.run(Thread.java:1583)

Caused by: java.net.UnknownHostException: dc-t.dltornado2.com

    at java.base/sun.nio.ch.NioSocketImpl.connect(NioSocketImpl.java:567)

    at java.base/java.net.SocksSocketImpl.connect(SocksSocketImpl.java:327)

    at java.base/java.net.Socket.connect(Socket.java:751)

    at java.naming/com.sun.jndi.ldap.Connection.createConnectionSocket(Connection.java:340)

    at java.naming/com.sun.jndi.ldap.Connection.createSocket(Connection.java:283)

    at java.naming/com.sun.jndi.ldap.Connection.<init>(Connection.java:230)

    ... 29 more

:::

然后通过web访问,网址是:http://server_ip:8080

需要登录,用户名是admin 密码是一样的,这是来自于上面的命令,设置了环境变量,可以更改环境变量。

image.png

添加一个provider

参考资料:Server Administration Guide

image.png

测试LDAP连接:

image.png

测试认证:

image.png

其它选项填写示例:

screencapture-172-16-0-9-8080-admin-master-console-2025-03-13-10_51_31.png

Keycloak OAuth客户端配置

在Realm中创建新Client

image.png

image.png

image.png

验证刚刚创建的client是否成功

参考资料:Docker - Keycloak

image.png

查询各个endpoint url的值,与keycloak连接的应用需要用到这些endpoint url

参考资料:OAuth - Chainlit

Invoke-RestMethod -Uri http://10.65.37.239:8080/realms/master/.well-known/openid-configuration
Invoke-RestMethod -Uri https://keycloak-admin.dltornado2.com/realms/master/.well-known/openid-configuration

image.png

image.png

从上面的输出,我们可以知道,对于chainlit,需要配置的环境变量的值为

OAUTH_AUTHORIZATION_URL=http://10.65.37.239:8080/realms/master/protocol/openid-connect/auth
OAUTH_TOKEN_URL=http://10.65.37.239:8080/realms/master/protocol/openid-connect/token
OAUTH_USERINFO_URL=http://10.65.37.239:8080/realms/master/protocol/openid-connect/userinfo

OAUTH_KEYCLOAK_CLIENT_ID=chainlit-client
#下方的环境变量的值可以通过chainlit.exe create-secret这个命令生成
CHAINLIT_AUTH_SECRET="t@k?3LolU1X%3X.PHL@_UDpQ6tf4Cg@wFbjwS^0VOy5iZl2>QP*KEikp:?Y6Pely"

image.png

设置环境变量

参考资料:OAuth - Chainlit

请使用.env文件设置环境变量,而不是通过终端命令

image.png

注意:下面第二行,等号两边不能有空格,否则执行程序时会将环境变量设置为none

# OAUTH_KEYCLOAK_BASE_URL = "http://10.65.37.239:8080"
OAUTH_KEYCLOAK_BASE_URL=http://10.65.37.239:8080
OAUTH_KEYCLOAK_REALM = "master"
OAUTH_KEYCLOAK_CLIENT_ID = "chainlit-client"
OAUTH_KEYCLOAK_CLIENT_SECRET = "t@k?3LolU1X%3X.PHL@_UDpQ6tf4Cg@wFbjwS^0VOy5iZl2>QP*KEikp:?Y6Pely"  # 这里需要你填入实际的客户端密钥
OAUTH_KEYCLOAK_NAME = "chainlit-Keycloak"  

OAUTH_KEYCLOAK_CLIENT_ID

OAUTH_KEYCLOAK_CLIENT_SECRET

OAUTH_KEYCLOAK_REALM

OAUTH_KEYCLOAK_BASE_URL

OAUTH_KEYCLOAK_NAME

实验时,我发现在输入用户名密码后,页面报错了,如下图:

分析日志后,是因为chainlit向keycloak发送请求时使用的是http而不是https,必须要使用https才行,因此需要对于keycloak配置证书以使用https

后续研究,发现,不是必须要用https,用http也是可以的,出现问题的原因其实是环境变量设置错误,请参考此文:《chainlit身份验证方案oauth2.0及常用社交应用账户集成》

image.png

以下是chainlit的部分日志:

 File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\uvicorn\middleware\proxy_headers.py", line 60, in __call__
    return await self.app(scope, receive, send)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\fastapi\applications.py", line 1054, in __call__
    await super().__call__(scope, receive, send)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\applications.py", line 113, in __call__
    await self.middleware_stack(scope, receive, send)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\middleware\errors.py", line 187, in __call__
    raise exc
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\middleware\errors.py", line 165, in __call__
    await self.app(scope, receive, _send)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\middleware\cors.py", line 85, in __call__
    await self.app(scope, receive, send)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\middleware\exceptions.py", line 62, in __call__
    await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
    raise exc
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\_exception_handler.py", line 42, in wrapped_app
    await app(scope, receive, sender)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\routing.py", line 715, in __call__
    await self.middleware_stack(scope, receive, send)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\routing.py", line 735, in app
    await route.handle(scope, receive, send)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\routing.py", line 288, in handle
    await self.app(scope, receive, send)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\routing.py", line 76, in app
    await wrap_app_handling_exceptions(app, request)(scope, receive, send)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
    raise exc
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\_exception_handler.py", line 42, in wrapped_app
    await app(scope, receive, sender)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\routing.py", line 73, in app
    response = await f(request)
               ^^^^^^^^^^^^^^^^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\fastapi\routing.py", line 301, in app
    raw_response = await run_endpoint_function(
                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    ...<3 lines>...
    )
    ^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\fastapi\routing.py", line 212, in run_endpoint_function
    return await dependant.call(**values)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\chainlit\server.py", line 637, in oauth_callback
    token = await provider.get_token(code, url)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\chainlit\oauth_providers.py", line 715, in get_token
    response = await client.post(
               ^^^^^^^^^^^^^^^^^^
    ...<2 lines>...
    )
    ^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpx\_client.py", line 1859, in post
    return await self.request(
           ^^^^^^^^^^^^^^^^^^^
    ...<13 lines>...
    )
    ^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpx\_client.py", line 1540, in request
    return await self.send(request, auth=auth, follow_redirects=follow_redirects)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpx\_client.py", line 1629, in send
    response = await self._send_handling_auth(
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    ...<4 lines>...
    )
    ^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpx\_client.py", line 1657, in _send_handling_auth
    response = await self._send_handling_redirects(
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    ...<3 lines>...
    )
    ^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpx\_client.py", line 1694, in _send_handling_redirects
    response = await self._send_single_request(request)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpx\_client.py", line 1730, in _send_single_request
    response = await transport.handle_async_request(request)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpx\_transports\default.py", line 393, in handle_async_request
    with map_httpcore_exceptions():
         ~~~~~~~~~~~~~~~~~~~~~~~^^
  File "C:\Program Files\Python313\Lib\contextlib.py", line 162, in __exit__
    self.gen.throw(value)
    ~~~~~~~~~~~~~~^^^^^^^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpx\_transports\default.py", line 118, in map_httpcore_exceptions
    raise mapped_exc(message) from exc
httpx.ReadTimeout
INFO:     192.168.124.11:61808 - "GET /favicon.ico HTTP/1.1" 200 OK
INFO:     192.168.124.11:65425 - "GET / HTTP/1.1" 200 OK
INFO:     192.168.124.11:65425 - "GET /auth/config HTTP/1.1" 200 OK
INFO:     192.168.124.11:65426 - "GET /user HTTP/1.1" 401 Unauthorized
INFO:     192.168.124.11:65429 - "GET /project/translations?language=zh-CN HTTP/1.1" 200 OK
INFO:     192.168.124.11:65429 - "GET /login HTTP/1.1" 200 OK
INFO:     192.168.124.11:65429 - "GET /user HTTP/1.1" 401 Unauthorized
INFO:     192.168.124.11:65426 - "GET /auth/config HTTP/1.1" 200 OK
INFO:     192.168.124.11:65425 - "GET /project/translations?language=zh-CN HTTP/1.1" 200 OK
INFO:     192.168.124.11:65425 - "GET /auth/oauth/chainlit-Keycloak HTTP/1.1" 307 Temporary Redirect
INFO:     192.168.124.11:65487 - "GET /user HTTP/1.1" 401 Unauthorized
2025-03-14 19:43:19 - HTTP Request: POST http://10.65.37.239:8080/realms/master/protocol/openid-connect/token "HTTP/1.1 502 Bad Gateway"
INFO:     192.168.124.11:65425 - "GET /auth/oauth/chainlit-Keycloak/callback?state=cY.Z%3AJl3eKdK%2Fg96248L8ef?lTXrEemD&session_state=fd05d8be-8824-423e-a46a-812dd3153207&iss=https%3A%2F%2Fptop.only.wip.la%3A443%2Fhttp%2F10.65.37.239%3A8080%2Frealms%2Fmaster&code=1e1ad80e-2a61-40e7-8d9a-af4c6b3890e1.fd05d8be-8824-423e-a46a-812dd3153207.5a90b396-a6c7-4708-a12e-b5351668252e HTTP/1.1" 500 Internal Server Error
ERROR:    Exception in ASGI application
Traceback (most recent call last):
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\uvicorn\protocols\http\h11_impl.py", line 403, in run_asgi
    result = await app(  # type: ignore[func-returns-value]
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        self.scope, self.receive, self.send
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    )
    ^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\uvicorn\middleware\proxy_headers.py", line 60, in __call__
    return await self.app(scope, receive, send)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\fastapi\applications.py", line 1054, in __call__
    await super().__call__(scope, receive, send)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\applications.py", line 113, in __call__
    await self.middleware_stack(scope, receive, send)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\middleware\errors.py", line 187, in __call__
    raise exc
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\middleware\errors.py", line 165, in __call__
    await self.app(scope, receive, _send)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\middleware\cors.py", line 85, in __call__
    await self.app(scope, receive, send)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\middleware\exceptions.py", line 62, in __call__
    await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
    raise exc
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\_exception_handler.py", line 42, in wrapped_app
    await app(scope, receive, sender)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\routing.py", line 715, in __call__
    await self.middleware_stack(scope, receive, send)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\routing.py", line 735, in app
    await route.handle(scope, receive, send)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\routing.py", line 288, in handle
    await self.app(scope, receive, send)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\routing.py", line 76, in app
    await wrap_app_handling_exceptions(app, request)(scope, receive, send)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
    raise exc
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\_exception_handler.py", line 42, in wrapped_app
    await app(scope, receive, sender)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\routing.py", line 73, in app
    response = await f(request)
               ^^^^^^^^^^^^^^^^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\fastapi\routing.py", line 301, in app
    raw_response = await run_endpoint_function(
                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    ...<3 lines>...
    )
    ^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\fastapi\routing.py", line 212, in run_endpoint_function
    return await dependant.call(**values)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\chainlit\server.py", line 637, in oauth_callback
    token = await provider.get_token(code, url)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\chainlit\oauth_providers.py", line 719, in get_token
    response.raise_for_status()
    ~~~~~~~~~~~~~~~~~~~~~~~~~^^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpx\_models.py", line 829, in raise_for_status
    raise HTTPStatusError(message, request=request, response=self)
httpx.HTTPStatusError: Server error '502 Bad Gateway' for url 'http://10.65.37.239:8080/realms/master/protocol/openid-connect/token'
For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/502

下面是keycloak中出现的日志:

Non-secure context detected; cookies are not secured

对接企业LDAP

将keycloak与LDAP集成后,用户就可以直接通过企业内部的域账号登录应用了。

详细步骤请参考此文:《chainlit身份验证方案oauth2.0及常用社交应用账户集成》

keycloak故障诊断

internal server error

用浏览器访问,登录账户后,提示“internal server error”,chainlit的日志如下,其中关键是“httpx.ConnectError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate”

这说明证书验证出现了问题,无法找到本地的root ca

 chainlit.exe run .\text_to_SQL.py -d --host 192.168.124.11                
2025-06-09 15:01:05 - Loaded .env file
https://keycloak-admin.dltornado2.com
master
chainlit-client
S=l@E.9ONQ~66XggeZZdhYn.ti=3%:kM5U7hGlH1y=T4gK1IoFBZgS@1metLI_T~
chainlit-Keycloak
INFO:     Started server process [4380]
INFO:     Waiting for application startup.
2025-06-09 15:01:08 - Your app is available at http://192.168.124.11:8000
INFO:     Application startup complete.
INFO:     Uvicorn running on http://192.168.124.11:8000 (Press CTRL+C to quit)
INFO:     192.168.124.11:63143 - "GET / HTTP/1.1" 200 OK
INFO:     192.168.124.11:63143 - "GET /user HTTP/1.1" 401 Unauthorized
INFO:     192.168.124.11:63144 - "GET /auth/config HTTP/1.1" 200 OK
INFO:     192.168.124.11:63161 - "GET /project/translations?language=zh-CN& HTTP/1.1" 200 OK
INFO:     192.168.124.11:63161 - "GET /login HTTP/1.1" 200 OK
INFO:     192.168.124.11:63161 - "GET /auth/config HTTP/1.1" 200 OK
INFO:     192.168.124.11:63144 - "GET /user HTTP/1.1" 401 Unauthorized
INFO:     192.168.124.11:63143 - "GET /project/translations?language=zh-CN& HTTP/1.1" 200 OK
INFO:     192.168.124.11:63251 - "GET /user HTTP/1.1" 401 Unauthorized
INFO:     192.168.124.11:63273 - "GET /user HTTP/1.1" 401 Unauthorized
INFO:     192.168.124.11:63274 - "GET /project/translations?language=zh-CN& HTTP/1.1" 200 OK
INFO:     192.168.124.11:63274 - "GET /auth/oauth/chainlit-Keycloak HTTP/1.1" 307 Temporary Redirect
INFO:     192.168.124.11:63274 - "GET /auth/oauth/chainlit-Keycloak/callback?state=h%25%2FcOw_eex9gdLW?7.agzb8?tVia4%25_%24&session_state=cf707a93-9532-4a49-adfc-f7650aa7cbaf&iss=https%3A%2F%2Fptop.only.wip.la%3A443%2Fhttps%2Fkeycloak-admin.dltornado2.com%2Frealms%2Fmaster&code=b0360efd-30fd-4d0b-a3fa-3a3fc4db9ede.cf707a93-9532-4a49-adfc-f7650aa7cbaf.6cc6b113-468a-4426-852a-b0cf586ed621 HTTP/1.1" 500 Internal Server Error
ERROR:    Exception in ASGI application
Traceback (most recent call last):
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpx\_transports\default.py", line 101, in map_httpcore_exceptions
    yield
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpx\_transports\default.py", line 394, in handle_async_request
    resp = await self._pool.handle_async_request(req)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpcore\_async\connection_pool.py", line 256, in handle_async_request
    raise exc from None
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpcore\_async\connection_pool.py", line 236, in handle_async_request
    response = await connection.handle_async_request(
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        pool_request.request
        ^^^^^^^^^^^^^^^^^^^^
    )
    ^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpcore\_async\connection.py", line 101, in handle_async_request
    raise exc
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpcore\_async\connection.py", line 78, in handle_async_request
    stream = await self._connect(request)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpcore\_async\connection.py", line 156, in _connect
    stream = await stream.start_tls(**kwargs)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpcore\_backends\anyio.py", line 67, in start_tls
    with map_exceptions(exc_map):
         ~~~~~~~~~~~~~~^^^^^^^^^
  File "C:\Program Files\Python313\Lib\contextlib.py", line 162, in __exit__
    self.gen.throw(value)
    ~~~~~~~~~~~~~~^^^^^^^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpcore\_exceptions.py", line 14, in map_exceptions
    raise to_exc(exc) from exc
httpcore.ConnectError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1018)

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\uvicorn\protocols\http\h11_impl.py", line 403, in run_asgi
    result = await app(  # type: ignore[func-returns-value]
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        self.scope, self.receive, self.send
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    )
    ^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\uvicorn\middleware\proxy_headers.py", line 60, in __call__
    return await self.app(scope, receive, send)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\fastapi\applications.py", line 1054, in __call__
    await super().__call__(scope, receive, send)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\applications.py", line 113, in __call__
    await self.middleware_stack(scope, receive, send)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\middleware\errors.py", line 187, in __call__
    raise exc
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\middleware\errors.py", line 165, in __call__
    await self.app(scope, receive, _send)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\middleware\cors.py", line 85, in __call__
    await self.app(scope, receive, send)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\middleware\exceptions.py", line 62, in __call__
    await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
    raise exc
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\_exception_handler.py", line 42, in wrapped_app
    await app(scope, receive, sender)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\routing.py", line 715, in __call__
    await self.middleware_stack(scope, receive, send)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\routing.py", line 735, in app
    await route.handle(scope, receive, send)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\routing.py", line 288, in handle
    await self.app(scope, receive, send)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\routing.py", line 76, in app
    await wrap_app_handling_exceptions(app, request)(scope, receive, send)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
    raise exc
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\_exception_handler.py", line 42, in wrapped_app
    await app(scope, receive, sender)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\routing.py", line 73, in app
    response = await f(request)
               ^^^^^^^^^^^^^^^^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\fastapi\routing.py", line 301, in app
    raw_response = await run_endpoint_function(
                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    ...<3 lines>...
    )
    ^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\fastapi\routing.py", line 212, in run_endpoint_function
    return await dependant.call(**values)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\chainlit\server.py", line 641, in oauth_callback
    token = await provider.get_token(code, url)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\chainlit\oauth_providers.py", line 715, in get_token
    response = await client.post(
               ^^^^^^^^^^^^^^^^^^
    ...<2 lines>...
    )
    ^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpx\_client.py", line 1859, in post
    return await self.request(
           ^^^^^^^^^^^^^^^^^^^
    ...<13 lines>...
    )
    ^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpx\_client.py", line 1540, in request
    return await self.send(request, auth=auth, follow_redirects=follow_redirects)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpx\_client.py", line 1629, in send
    response = await self._send_handling_auth(
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    ...<4 lines>...
    )
    ^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpx\_client.py", line 1657, in _send_handling_auth
    response = await self._send_handling_redirects(
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    ...<3 lines>...
    )
    ^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpx\_client.py", line 1694, in _send_handling_redirects
    response = await self._send_single_request(request)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpx\_client.py", line 1730, in _send_single_request
    response = await transport.handle_async_request(request)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpx\_transports\default.py", line 393, in handle_async_request
    with map_httpcore_exceptions():
         ~~~~~~~~~~~~~~~~~~~~~~~^^
  File "C:\Program Files\Python313\Lib\contextlib.py", line 162, in __exit__
    self.gen.throw(value)
    ~~~~~~~~~~~~~~^^^^^^^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpx\_transports\default.py", line 118, in map_httpcore_exceptions
    raise mapped_exc(message) from exc
httpx.ConnectError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1018)
INFO:     192.168.124.11:63273 - "GET /favicon.ico HTTP/1.1" 200 OK
INFO:     192.168.124.11:63409 - "GET / HTTP/1.1" 200 OK
INFO:     192.168.124.11:63409 - "GET /assets/index-B28WSRhf.js HTTP/1.1" 200 OK
INFO:     192.168.124.11:63411 - "GET /assets/index-BIhgNEQJ.css HTTP/1.1" 200 OK
INFO:     192.168.124.11:63467 - "GET /auth/config HTTP/1.1" 200 OK
INFO:     192.168.124.11:63468 - "GET /user HTTP/1.1" 401 Unauthorized
INFO:     192.168.124.11:63469 - "GET /project/translations?language=zh-CN& HTTP/1.1" 200 OK
INFO:     192.168.124.11:63469 - "GET /favicon HTTP/1.1" 200 OK
INFO:     192.168.124.11:63469 - "GET /login HTTP/1.1" 200 OK
INFO:     192.168.124.11:63469 - "GET /logo?theme=dark& HTTP/1.1" 200 OK
INFO:     192.168.124.11:63543 - "GET /user HTTP/1.1" 401 Unauthorized
INFO:     192.168.124.11:63544 - "GET /auth/config HTTP/1.1" 200 OK
INFO:     192.168.124.11:63545 - "GET /project/translations?language=zh-CN& HTTP/1.1" 200 OK
INFO:     192.168.124.11:63545 - "GET /user HTTP/1.1" 401 Unauthorized
INFO:     192.168.124.11:63544 - "GET /project/translations?language=zh-CN& HTTP/1.1" 200 OK
INFO:     192.168.124.11:63544 - "GET /user HTTP/1.1" 401 Unauthorized
INFO:     192.168.124.11:63544 - "GET /auth/oauth/chainlit-Keycloak HTTP/1.1" 307 Temporary Redirect
INFO:     192.168.124.11:63786 - "GET /auth/oauth/chainlit-Keycloak/callback?state=D8myarZNlKfEo%2CzX-0Ln%25FV9%5E%3A8WT%3APo&session_state=c9da7e34-505d-48f8-8aa5-8d47e1ebe757&iss=https%3A%2F%2Fptop.only.wip.la%3A443%2Fhttps%2Fkeycloak-admin.dltornado2.com%2Frealms%2Fmaster&code=12e9e7a0-1f84-4f8a-92e7-e296434c9983.c9da7e34-505d-48f8-8aa5-8d47e1ebe757.6cc6b113-468a-4426-852a-b0cf586ed621 HTTP/1.1" 500 Internal Server Error
ERROR:    Exception in ASGI application
Traceback (most recent call last):
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpx\_transports\default.py", line 101, in map_httpcore_exceptions
    yield
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpx\_transports\default.py", line 394, in handle_async_request
    resp = await self._pool.handle_async_request(req)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpcore\_async\connection_pool.py", line 256, in handle_async_request
    raise exc from None
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpcore\_async\connection_pool.py", line 236, in handle_async_request
    response = await connection.handle_async_request(
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        pool_request.request
        ^^^^^^^^^^^^^^^^^^^^
    )
    ^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpcore\_async\connection.py", line 101, in handle_async_request
    raise exc
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpcore\_async\connection.py", line 78, in handle_async_request
    stream = await self._connect(request)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpcore\_async\connection.py", line 156, in _connect
    stream = await stream.start_tls(**kwargs)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpcore\_backends\anyio.py", line 67, in start_tls
    with map_exceptions(exc_map):
         ~~~~~~~~~~~~~~^^^^^^^^^
  File "C:\Program Files\Python313\Lib\contextlib.py", line 162, in __exit__
    self.gen.throw(value)
    ~~~~~~~~~~~~~~^^^^^^^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpcore\_exceptions.py", line 14, in map_exceptions
    raise to_exc(exc) from exc
httpcore.ConnectError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1018)

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\uvicorn\protocols\http\h11_impl.py", line 403, in run_asgi
    result = await app(  # type: ignore[func-returns-value]
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        self.scope, self.receive, self.send
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    )
    ^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\uvicorn\middleware\proxy_headers.py", line 60, in __call__
    return await self.app(scope, receive, send)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\fastapi\applications.py", line 1054, in __call__
    await super().__call__(scope, receive, send)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\applications.py", line 113, in __call__
    await self.middleware_stack(scope, receive, send)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\middleware\errors.py", line 187, in __call__
    raise exc
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\middleware\errors.py", line 165, in __call__
    await self.app(scope, receive, _send)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\middleware\cors.py", line 85, in __call__
    await self.app(scope, receive, send)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\middleware\exceptions.py", line 62, in __call__
    await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
    raise exc
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\_exception_handler.py", line 42, in wrapped_app
    await app(scope, receive, sender)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\routing.py", line 715, in __call__
    await self.middleware_stack(scope, receive, send)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\routing.py", line 735, in app
    await route.handle(scope, receive, send)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\routing.py", line 288, in handle
    await self.app(scope, receive, send)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\routing.py", line 76, in app
    await wrap_app_handling_exceptions(app, request)(scope, receive, send)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
    raise exc
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\_exception_handler.py", line 42, in wrapped_app
    await app(scope, receive, sender)
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\starlette\routing.py", line 73, in app
    response = await f(request)
               ^^^^^^^^^^^^^^^^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\fastapi\routing.py", line 301, in app
    raw_response = await run_endpoint_function(
                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    ...<3 lines>...
    )
    ^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\fastapi\routing.py", line 212, in run_endpoint_function
    return await dependant.call(**values)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\chainlit\server.py", line 641, in oauth_callback
    token = await provider.get_token(code, url)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\chainlit\oauth_providers.py", line 715, in get_token
    response = await client.post(
               ^^^^^^^^^^^^^^^^^^
    ...<2 lines>...
    )
    ^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpx\_client.py", line 1859, in post
    return await self.request(
           ^^^^^^^^^^^^^^^^^^^
    ...<13 lines>...
    )
    ^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpx\_client.py", line 1540, in request
    return await self.send(request, auth=auth, follow_redirects=follow_redirects)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpx\_client.py", line 1629, in send
    response = await self._send_handling_auth(
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    ...<4 lines>...
    )
    ^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpx\_client.py", line 1657, in _send_handling_auth
    response = await self._send_handling_redirects(
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    ...<3 lines>...
    )
    ^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpx\_client.py", line 1694, in _send_handling_redirects
    response = await self._send_single_request(request)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpx\_client.py", line 1730, in _send_single_request
    response = await transport.handle_async_request(request)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpx\_transports\default.py", line 393, in handle_async_request
    with map_httpcore_exceptions():
         ~~~~~~~~~~~~~~~~~~~~~~~^^
  File "C:\Program Files\Python313\Lib\contextlib.py", line 162, in __exit__
    self.gen.throw(value)
    ~~~~~~~~~~~~~~^^^^^^^
  File "d:\tornadofiles\scripts_脚本\github_projects\DreamAI-AI助手\.venv\Lib\site-packages\httpx\_transports\default.py", line 118, in map_httpcore_exceptions
    raise mapped_exc(message) from exc
httpx.ConnectError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1018)
INFO:     192.168.124.11:63790 - "GET /favicon.ico HTTP/1.1" 200 OK

编写chainlit应用逻辑

主要是chainlit_ragflow_streaming.py文件,其完整代码如下:

#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Chainlit与RAGFlow集成的流式交互应用
功能:接收用户输入,通过RAGFlow API获取流式回答并实时展示
"""
import json
import os
import requests
import datetime
from typing import Dict, Optional
from pprint import pprint
from rocketchat_API.rocketchat import RocketChat
# from rocketchat_API import RocketChatException  # 新增异常处理
import chainlit as cl
from chainlit import run_sync
import asyncio
import chainlit as cl
from typing import Optional
import logging
import uuid
from message_queue import RedisQueue
from ragflow_client import RAGFlowClient
# import session_utils
# 配置日志
from logger_config import setup_logger
logger = setup_logger(__name__)

# 初始化Redis队列
redis_queue = RedisQueue()

# 全局消息队列: Chainlit会话ID -> 消息队列
message_queues = {}
message_queues_lock = asyncio.Lock()
# 下面部分的代码很重要,这部分代码的含义是允许所有经过身份验证的用户登录到chainlit
@cl.oauth_callback
def oauth_callback(
  provider_id: str,
  token: str,
  raw_user_data: Dict[str, str],
  default_user: cl.User,
) -> Optional[cl.User]:
  return default_user

# ==============================================
# 配置参数区域 - 根据实际环境修改以下参数
# ==============================================
# API密钥,从RAGFlow平台获取
API_KEY = os.getenv('RAGFLOW_API_KEY')
# RAGFlow服务基础URL(通常格式为http://ip:port)
BASE_URL = os.getenv('RAGFLOW_BASE_URL')
# 目标AI助手名称(需在RAGFlow平台提前创建)
CHAT_ASSISTANT_NAME = os.getenv('RAGFLOW_ASSISTANT_NAME', 'AI-assist')


# Log OAUTH Keycloak environment variables
logger.debug("【debug】OAUTH_KEYCLOAK_BASE_URL的值为: %s", os.getenv("OAUTH_KEYCLOAK_BASE_URL"))
logger.debug("【debug】OAUTH_KEYCLOAK_REALM的值为: %s", os.getenv("OAUTH_KEYCLOAK_REALM"))
logger.debug("【debug】OAUTH_KEYCLOAK_CLIENT_ID的值为: %s", os.getenv("OAUTH_KEYCLOAK_CLIENT_ID"))
logger.debug("【debug】OAUTH_KEYCLOAK_CLIENT_SECRET的值为: %s", os.getenv("OAUTH_KEYCLOAK_CLIENT_SECRET"))
logger.debug("【debug】OAUTH_KEYCLOAK_NAME的值为: %s", os.getenv("OAUTH_KEYCLOAK_NAME"))
logger.debug("【debug】CHAINLIT_AUTH_SECRET的值为: %s", os.getenv("CHAINLIT_AUTH_SECRET"))
# Log RAGFlow environment variables
logger.debug("【debug】RAGFLOW_API_KEY的值为: %s", os.getenv("RAGFLOW_API_KEY"))
logger.debug("【debug】RAGFLOW_BASE_URL的值为: %s", os.getenv("RAGFLOW_BASE_URL"))
logger.debug("【debug】RAGFLOW_ASSISTANT_NAME的值为: %s", os.getenv("RAGFLOW_ASSISTANT_NAME"))

logger.debug("【debug】IT_ENVIRONMENT的值为: %s", os.getenv("IT_ENVIRONMENT"))

""" @cl.action_callback("转人工")
async def on_action(action: cl.Action):
    # print(action.payload)
    app_user = cl.user_session.get("user")

    # pprint(rocket.me().json())
    # pprint(rocket.chat_post_message('good news everyone!', channel='GENERAL', alias='Farnsworth').json())
    # Get all the messages in the conversation in the OpenAI format
    # print(cl.chat_context.to_openai())
    # 将聊天历史格式化为人类易读的文本
    # 获取当前星期几(0=周一,6=周日)
    current_weekday = datetime.datetime.today().weekday()
    # 定义星期几对应的接收者
    weekday_users = ['bob', 'david', 'alice', 'tom', 'john', 'jerry', 'jerry']
    recipient = weekday_users[current_weekday]
    # 将聊天历史格式化为人类易读的文本
    formatted_history = '\n\n'.join([f'**{msg["role"].capitalize()}: **{msg["content"]}' for msg in cl.chat_context.to_openai()])
    # 在消息前添加用户标识,格式:[USER_ID:xxx]
    post_message_response = rocket.chat_post_message(
        f"[USER_ID:{app_user.identifier}]{formatted_history}",
        room_id=f'@{recipient}',
        alias=app_user.identifier
    )
    print("【debug】post_message_response的值为:", post_message_response.json()) """

@cl.action_callback("转人工")
async def on_action(action: cl.Action):
    # 获取当前用户信息
    app_user = cl.user_session.get("user")
    logger.info(f"【debug】chainlit用户名(邮箱)为: {app_user}")
    if not app_user:
        logger.error("【debug】未获取到当前用户信息")
        await cl.Message(content="无法获取用户信息,转人工失败").send()
        return

    # 从邮箱提取用户名(与Rocket.Chat用户名匹配)
    # username = app_user.identifier.split('@')[0]
    username = app_user.identifier
    # 将硬编码的Rocket.Chat服务器URL替换为环境变量
    server_url = os.getenv("ROCKETCHAT_SERVER_URL")
    
    # 将硬编码的LDAP密码替换为环境变量
    password = os.getenv("LDAP_PASSWORD")
    chainlit_session_id = cl.user_session.get("id")

    try:
        # 动态初始化Rocket.Chat客户端
        logger.info(f"【debug】用户 {username} 尝试登录Rocket.Chat")
        rocket = RocketChat(user=username, password=password, server_url=server_url)

        # 验证登录状态
        me = rocket.me().json()
        if "error" in me:
            logger.error(f"【debug】Rocket.Chat登录失败: {me['error']}")
            await cl.Message(content="转人工失败:身份验证错误").send()
            return

        # 获取星期几对应的客服接收者
        current_weekday = datetime.datetime.today().weekday()
        # Replace hardcoded list
        weekday_users = os.getenv("WEEKDAY_USERS", "bob,david,alice,tom,john,jerry,jerry").split(",")
        recipient = weekday_users[current_weekday]
        logger.info(f"【debug】当前客服接收者: {recipient}")
        """
        # 创建或获取与客服的直接聊天频道
        logger.info(f"【debug】尝试创建/获取与 {recipient} 的聊天频道")
        room_response = rocket.im_create(recipient).json()

        # 处理已有频道情况
        if "error" in room_response and room_response["error"] == "duplicate-channel":
            logger.info(f"【debug】聊天频道已存在,获取现有频道")
            rooms = rocket.im_list().json()
            room = next((r for r in rooms if recipient in [u['username'] for u in r.get('usernames', []) if u['username'] != username]), None)
            if not room:
                raise Exception("未找到现有聊天频道")
            room_id = room['_id']
        elif "error" in room_response:
            raise Exception(f"创建频道失败: {room_response['error']}")
        else:
            room_id = room_response['_id']

        logger.info(f"【debug】成功获取聊天频道ID: {room_id}")
        """


        # 格式化并发送聊天历史
        formatted_history = '\n\n'.join([f'**{msg["role"].capitalize()}: **{msg["content"]}' for msg in cl.chat_context.to_openai()])
        message_content = f"[CHAINLIT_USER_ID:{app_user.identifier}]\n{formatted_history}"

        # 发送消息到Rocket.Chat
        # pprint(rocket.chat_post_message(message_content, channel=f'@{recipient}').json())
        post_response = rocket.chat_post_message(
            message_content,
            channel=f'@{recipient}'
        ).json()
        # logger.info(f"【debug】post_response的值为:, {post_response}")
        post_data = post_response
        # logger.info(f"【debug】post_data的值为:, {post_data}")
        channel_id = post_data.get("message").get("rid")
        room_id = channel_id
        logger.info(f"【debug】channel_id的值为:, {channel_id}")
        logger.info(f"【debug】room_id的值为:, {room_id}")
        if post_data.get("success"):
            logger.info(f"【debug】聊天历史发送成功,消息ID: {post_data.get('message').get('_id')}, 频道ID: {channel_id}")
            await cl.Message(content="已成功转接至人工客服,请等待回复...,若要重新让AI回答,您可以直接新建对话").send()
        else:
            logger.error(f"【debug】消息发送失败: {post_data}")
            await cl.Message(content="转人工失败:消息发送失败").send()

        # 保存会话状态
        cl.user_session.set("is_human_session", True)
        cl.user_session.set("rocket_chat_recipient", recipient)
        cl.user_session.set("rocket_chat_room_id", room_id)
        cl.user_session.set("rocket_chat_client", rocket)

        # 更新Redis会话元数据(存储room_id用于消息路由)
        redis_queue.client.hset(
            f"chainlit_session:{username}:{chainlit_session_id}:metadata",
            mapping={
                "room_id": room_id,
                "status": "human_chat", 
                "support_agent": recipient, 
                "chainlit_session_id": chainlit_session_id, 
                "用户点击转人工的时间": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
                }
        )
        # 打印当前所有消息队列状态(调试用)
        all_queues = redis_queue.get_all_queues()
        # queue_status = {q: redis_queue.qsize(q) for q in all_queues}
        queue_status = {q: redis_queue.qsize(q) for q in all_queues}
        logger.info(f"【debug】当前消息队列状态: {queue_status}")
        logger.info(f"【debug】RedisQueue实例ID: {id(redis_queue)}")
        """
    except RocketChatException as e:
        logger.error(f"【debug】Rocket.Chat API错误: {str(e)}", exc_info=True)
        await cl.Message(content=f"转人工失败: Rocket.Chat通信错误").send()
        """
    except Exception as e:
        logger.error(f"【debug】转人工处理异常: {str(e)}", exc_info=True)
        await cl.Message(content=f"转人工失败: {str(e)}").send()

@cl.on_chat_start
async def on_chat_start():    
    """
    The on_chat_start decorator is used to define a hook that is called when a new chat session is created.
    """
    try:
        # 创建并存储RAGFlow客户端实例
        logger.info(f"【debug】CHAT_ASSISTANT_NAME的值为: {CHAT_ASSISTANT_NAME}")
        rag_client = RAGFlowClient(API_KEY, BASE_URL)
        rag_client.get_chat_id(CHAT_ASSISTANT_NAME)  # 验证助手是否存在
        cl.user_session.set("rag_client", rag_client)
        # 初始化会话状态变量
        cl.user_session.set("is_human_session", False)
        cl.user_session.set("rocket_chat_recipient", None)
        app_user = cl.user_session.get("user")
        # Replace print statements with logger.debug
        # Original: print("【debug】app_user变量的值为:", app_user)
        logger.debug(f"app_user变量的值为: {app_user}")
        """
        # Original: print("【debug】referenced_docs内容: ", json.dumps(referenced_docs, indent=2, ensure_ascii=False))
        logger.debug(f"referenced_docs内容: {json.dumps(referenced_docs, indent=2, ensure_ascii=False)}")
        
        # Original: print("【debug】变量doc_url的值为:", doc_url)
        logger.debug(f"变量doc_url的值为: {doc_url}")
        
        # Original: print("【debug】elements变量的值为:", elements)
        logger.debug(f"elements变量的值为: {elements}")
        """
        """
        # 设置会话映射关系
        session_id = cl.user_session.get("id")
        # 提取邮箱中的用户名部分作为会话映射键,与Rocket.Chat用户名匹配
        rocket_username = app_user.identifier
        # logger.info(f"【debug】提取邮箱用户名作为会话键: {rocket_username} (原始邮箱: {app_user.identifier}),会话ID: {session_id}")
        session_utils.set_chainlit_session_id(rocket_username, session_id)
        logger.info(f"【debug】已设置会话映射: {rocket_username} -> {session_id}")
        
        session_id = cl.user_session.get("id")
        async with message_queues_lock:
            message_queues[session_id] = asyncio.Queue()
            # 打印当前所有消息队列状态
            queue_status = {k: v.qsize() for k, v in message_queues.items()}
            logger.info(f"【debug】消息队列初始化状态: {queue_status}")
        logger.info(f"【debug】为会话 {session_id} 创建消息队列")
        """
        # 将会话ID与用户信息存入Redis,便于追踪管理
        session_id = cl.user_session.get("id")
        user_info = cl.user_session.get("user")
        user_id = user_info.identifier if user_info else "anonymous"
        redis_queue.client.hset(
            f"chainlit_session:{app_user.identifier}:{session_id}:metadata",
            mapping={
                "user_id": user_id,
                "mail": app_user.identifier,
                "chainlit_session_id": session_id,
                "created_at": datetime.datetime.now().isoformat(),
                "status": "active"
            }
        )
        # 设置会话过期时间(24小时)
        redis_queue.client.expire(f"chainlit_session:{user_id}:{session_id}:metadata", 86400)

        
        # 启动消息处理任务并保存引用
        message_task = asyncio.create_task(process_messages(session_id))
        cl.user_session.set("message_task", message_task)

    except Exception as e:
        await cl.Message(content=f"初始化失败: {str(e)}").send()

""" async def process_messages(session_id: str):
    queue_name = f"session:{session_id}"
    while True:
        try:
            # 使用线程池执行同步Redis操作,避免阻塞事件循环
            # 增加调试日志并调整超时时间
            logger.info(f"【debug】尝试从Redis队列 {queue_name} 读取消息")
            message = await asyncio.to_thread(
                redis_queue.dequeue,
                queue_name,
                block=True,
                timeout=30  # 延长超时时间以确保能接收到消息
            )
            if message is not None:
                logger.info(f"【debug】从队列 {queue_name} 读取到消息: {message[:50]}")
            else:
                logger.info(f"【debug】队列 {queue_name} 超时未读取到消息")
            if message is None:
                continue
            
            # 处理消息并发送到Chainlit
            await cl.Message(content=message).send()
        except asyncio.CancelledError:
            logger.info(f"【debug】会话 {session_id} 消息处理任务已取消")
            break
        except Exception as e:
            logger.error(f"Error processing message for session {session_id}: {str(e)}", exc_info=True)
            break
    """

async def process_messages(session_id: str):
    """
    这个函数主要负责从Redis队列中读取消息并发送到Chainlit。
    参数session_id指的是chainlit中的会话ID。
    它会根据会话ID动态获取对应的消息队列名称,并在队列中读取消息。
    如果读取到消息,会将其发送到Chainlit进行展示;如果读取超时或队列空,会进行适当的日志记录。
    这个函数被on_chat_start调用。
    """

    while True:
        try:
            # 动态获取当前room_id(支持转人工后更新)
            # current_room_id = redis_queue.client.hget(metadata_key, "room_id") or room_id
            # if current_room_id != room_id:
            #     logger.info(f"【debug】房间ID变更: {room_id} -> {current_room_id}")
            #     room_id = current_room_id
            #     queue_name = f"session:{room_id}:metadata"
            # if not room_id:
            #     # 未进入人工会话,使用默认队列
                
            #     queue_name = f"session:{session_id}"
            #     logger.info(f"【debug】未进入人工会话,使用默认队列: {queue_name}")
            #     await asyncio.sleep(5)  # 降低空轮询频率
            #     continue
               # 从Redis获取room_id
            metadata_queue_name = f"chainlit_session:{cl.user_session.get("user").identifier}:{session_id}:metadata"
            # logger.info(f"【debug】尝试从Redis获取会话元数据: {metadata_queue_name}")
            metadata = redis_queue.client.hgetall(metadata_queue_name)
            # logger.info(f"【debug】获取到的会话元数据: {metadata}")


            # Get room_id with proper error handling
            # room_id = metadata.get("room_id")
            # logger.info(f"【debug】从会话元数据中获取room_id: {room_id}")
            room_id = cl.user_session.get("rocket_chat_room_id")
            logger.info(f"【debug】从用户会话中获取rocket_chat_room_id: {room_id}")
            if room_id is None:
                logger.warning(f"【警告】会话 {session_id} 的元数据中未找到 room_id这个key,说明此用户没有选择人工客服。使用默认值")
                room_id = "不存在room_id,此用户没有选择人工客服"
            logger.info(f"【debug】消息处理初始化 - 会话ID: {session_id}, 房间ID: {room_id}")

            if room_id == "不存在room_id,此用户没有选择人工客服":
                # 未进入人工会话,使用默认队列
                logger.info(f"【debug】此用户 {cl.user_session.get("user").identifier}---{session_id} 没有选择人工客服,未进入人工会话,不读取消息")
                await asyncio.sleep(5)  # 降低空轮询频率
                continue
            else:
                queue_name = f"{os.getenv("IT_ENVIRONMENT")}:rocket.chat_session:{cl.user_session.get("rocket_chat_recipient")}:{room_id}:messages_queue"
                logging.info(f"【debug】此用户{cl.user_session.get("user").identifier}--- {session_id} 选择了人工客服,使用队列: {queue_name}")

            logger.info(f"【debug】尝试从Redis队列 {queue_name} 读取消息")

            # message = await asyncio.to_thread(
            #     redis_queue.dequeue,
            #     queue_name,
            #     block=True,
            #     timeout=30
            # )

            data = await asyncio.to_thread(
                redis_queue.stream_peek_latest,
                queue_name,
                block=True,
                timeout=30
            )
            

            if data is None:
                logger.info(f"【debug】队列 {queue_name} 超时未读取到消息")
                continue
            else:
                message = data['data']['data']
                logger.info(f"【debug】从队列 {queue_name} 读取到消息: {message}")
                await cl.Message(content=message).send()
                logger.info(f"【debug】消息已发送到Chainlit用户:{cl.user_session.get("user").identifier},人工客服:{cl.user_session.get("rocket_chat_recipient")} 房间ID为:{room_id},消息内容为:{message}")

            
        except asyncio.CancelledError:
            logger.info(f"【debug】会话 {session_id} 消息处理任务已取消")
            break
        except Exception as e:
            logger.error(f"Error processing message for session {session_id}: {str(e)}", exc_info=True)
            break

@cl.set_starters
async def set_starters():
    return [
        cl.Starter(
            label="AI助手使用手册",
            message="AI助手使用手册.",
            icon="http://10.64.160.146/arrow-right-circle-line.svg",
        ),
        cl.Starter(
            label="如何重置密码",
            message="如何重置密码.",
            icon="http://10.64.160.146/arrow-right-circle-line.svg",
        ),
        cl.Starter(
            label="公司食堂开餐时间",
            message="公司食堂开餐时间.",
            icon="http://10.64.160.146/arrow-right-circle-line.svg",
        ),
        cl.Starter(
            label="怎么查打卡时间",
            message="怎么查打卡时间.",
            icon="http://10.64.160.146/arrow-right-circle-line.svg",
        )
    ]

@cl.on_message
async def on_message(message: cl.Message):
    """
    The on_message decorator is used to define a hook that is called when a new message is received from the user.
    这个decorator主要是用来决定chainlit用户发送的消息是要发送给rocke.chat人工客服还是后端的ragflow
    """
    rag_client: RAGFlowClient = cl.user_session.get("rag_client")
    is_human_session = cl.user_session.get("is_human_session", False)
    rocket_chat_recipient = cl.user_session.get("rocket_chat_recipient")

    if not rag_client:
        await cl.Message(content="客户端未初始化,请刷新页面重试").send()
        return

    try:
        if is_human_session and rocket_chat_recipient:
            # 人工会话,直接发送到Rocket.Chat
            app_user = cl.user_session.get("user")
            # 添加用户标识和人工会话标记
            formatted_message = f"[CHAINLIT_USER_ID:{app_user.identifier}][HUMAN_SESSION]\n{message.content}"
            # Retrieve Rocket.Chat client from user session
            current_rocket = cl.user_session.get("rocket_chat_client")
            logger.info(f"【debug】当前Rocket.Chat客户端: {current_rocket}")
            if not current_rocket:
                logger.error("【debug】Rocket.Chat client not found in user session")
                await cl.Message(content="转人工会话已过期,请重新发起转人工请求").send()
                return
            logger.info(f"【debug】发送消息到Rocket.Chat房间(客服名称): {rocket_chat_recipient}")
            current_rocket.chat_post_message(
                formatted_message,
                room_id=f'@{rocket_chat_recipient}',
            )
            # 向用户确认消息已发送给人工代理
            await cl.Message(content=f"已发送给人工客服:{rocket_chat_recipient}\n消息内容为: {message.content}").send()
            logger.info(f"【debug】人工会话消息发送: {formatted_message}")
        else:
            # AI会话,使用RAGFlow处理
            msg = await cl.Message(content="").send()
            user_identifier = cl.user_session.get("user").identifier
            await rag_client.stream_chat_completion(message.content, msg)

    except Exception as e:
        error_msg = f"处理请求时出错: {str(e)}"
        await cl.Message(content=error_msg).send()
        logger.error(f"【debug】消息处理错误: {error_msg}", exc_info=True)

@cl.on_chat_end
async def on_chat_end():
    chainlit_session_id = cl.user_session.get("id")
    if not chainlit_session_id:
        return
    
    # 获取消息处理任务并取消
    message_task = cl.user_session.get("message_task")
    if message_task:
        message_task.cancel()
        try:
            await message_task
        except asyncio.CancelledError:
            pass
    
        # 清理Redis队列
        queue_name = f"chainlit_session:{cl.user_session.get("user").identifier}:{chainlit_session_id}:metadata"
        redis_queue.clear(queue_name)
        logger.info(f"chainlit Session {chainlit_session_id} ended. Redis queue {queue_name} cleared.")
    else:
        logger.info(f"【debug】会话 {chainlit_session_id} 结束,消息队列不存在")

实现data persistent(暂未实现)

后端–知识库引擎ragflow

参考资料:RAGFlow | RAGFlow

规划

将ragflow作为后端,专门处理rag任务

如何部署和配置ragflow

详细步骤请查看此文:《ragflow》

代码

主要是ragflow_client.py

import os
import requests
import aiohttp
import json
from typing import Optional
import chainlit as cl

from logger_config import setup_logger
logger = setup_logger(__name__)

CHAT_ASSISTANT_NAME = os.getenv('RAGFLOW_ASSISTANT_NAME', 'AI-assist')

class RAGFlowClient:
    """RAGFlow API客户端,处理与后端的交互"""
    def __init__(self, api_key: str, base_url: str):
        self.api_key = api_key
        self.base_url = base_url
        self.headers = {
            'Authorization': f'Bearer {self.api_key}',
            'Content-Type': 'application/json'
        }
        self.chat_id: Optional[str] = None

    def get_chat_id(self, assistant_name: str) -> str:
        """根据助手名称获取聊天ID"""
        url = f"{self.base_url}/api/v1/chats?name={assistant_name}"
        response = requests.get(url, headers=self.headers)
        response_data = response.json()

        if response_data['code'] != 0:
            raise ValueError(f"获取助手ID失败: {response_data['message']}")
        if not response_data['data']:
            raise ValueError(f"未找到名称为'{assistant_name}'的AI助手")

        return response_data['data'][0]['id']

    def create_chat_session(self) -> str:
        """创建新的聊天会话"""
        if not self.chat_id:
            self.chat_id = self.get_chat_id(CHAT_ASSISTANT_NAME)

        url = f"{self.base_url}/api/v1/chats/{self.chat_id}/sessions"
        session_name = cl.user_session.get("user").identifier
        response = requests.post(
            url, headers=self.headers, json={'name': f"chaint_session:{session_name}"}
        )
        response_data = response.json()

        if response_data['code'] != 0:
            raise ValueError(f"创建会话失败: {response_data['message']}")

        return response_data['data']['id']

    async def stream_chat_completion(self, question: str, msg: cl.Message):
        """流式获取聊天完成结果并直接发送到Chainlit前端"""
        session_id = self.create_chat_session()
        url = f"{self.base_url}/api/v1/chats/{self.chat_id}/completions"

        payload = {
            "question": question,
            "stream": True,
            "session_id": session_id,
            "user_id": cl.user_session.get("user").identifier
        }

        # 用于跟踪已发送的内容,避免重复
        sent_content = ""
        # 存储引用的文档
        referenced_docs = []

        with requests.post(
            url, headers=self.headers, json=payload, stream=True
        ) as response:
            for line in response.iter_lines():
                if line:
                    try:
                        # 处理SSE格式数据
                        line = line.decode('utf-8').lstrip('data: ').strip()
                        if not line:
                            continue
                        json_data = json.loads(line)

                        # 检查是否为结束标志
                        if json_data.get('code') == 0 and json_data.get('data') is True:
                            break

                        if (
                            isinstance(json_data, dict) and 
                            json_data.get('code') == 0 and 
                            isinstance(json_data.get('data'), dict)
                        ):
                            answer_chunk = json_data['data'].get('answer', '')

                            # 提取引用文档信息(使用doc_aggs聚合数据避免重复)
                            if 'reference' in json_data['data']:
                                reference_data = json_data['data']['reference']
                                # 优先使用doc_aggs获取聚合的引用文档信息
                                if 'doc_aggs' in reference_data and isinstance(reference_data['doc_aggs'], list):
                                    # 遍历聚合文档列表
                                    for doc_info in reference_data['doc_aggs']:
                                        doc_id = doc_info.get('doc_id')
                                        doc_name = doc_info.get('doc_name')
                                        # 检查文档ID和名称是否存在
                                        if doc_id and doc_name:
                                            referenced_docs.append((doc_id, doc_name))
                                # 兼容处理:如果没有doc_aggs则使用chunks(旧版API兼容)
                                elif 'chunks' in reference_data:
                                    seen_document_ids = set()
                                    for chunk in reference_data['chunks']:
                                        doc_id = chunk.get('document_id')
                                        doc_name = chunk.get('document_name')
                                        if doc_id and doc_name and doc_id not in seen_document_ids:
                                            referenced_docs.append((doc_id, doc_name))
                                            seen_document_ids.add(doc_id)

                            if answer_chunk and answer_chunk != sent_content:
                                # 计算新增内容
                                new_content = answer_chunk[len(sent_content):]
                                if new_content:
                                    await msg.stream_token(new_content)
                                    sent_content = answer_chunk
                    except json.JSONDecodeError:
                        continue

            # 使用Chainlit File元素展示引用文档
            if referenced_docs:
                # 调试:打印引用文档列表所有内容
                # Replace print statements with logger.debug
                # Original: print("【debug】referenced_docs内容: ", json.dumps(referenced_docs, indent=2, ensure_ascii=False))
                # logger.debug(f"referenced_docs内容: {json.dumps(referenced_docs, indent=2, ensure_ascii=False)}")
                logger.debug(f"referenced_docs内容: {referenced_docs}")
                elements = []
                for doc_id, doc_name in referenced_docs:
                    doc_url = f"{self.base_url}/document/{doc_id}?ext={doc_name.split('.')[-1]}&prefix=document"
                    logger.debug(f"变量doc_url的值为: {doc_url}")
                    elements.append(cl.File(name=doc_name, url=doc_url))
                # 打印elements变量的值到终端,方便诊断
                logger.debug(f"elements变量的值为: {elements}")
                actions = [
                cl.Action(
                name="转人工",
                icon="mouse-pointer-click",
                payload={"value": "example_value"},
                label="点我立即转人工"
            )
        ]
                # 发送包含文件元素的消息
                await cl.Message(content="\n\n### 引用文档", elements=elements, actions=actions).send()

            # 明确标记消息完成状态
            await msg.update()

fastapi–用于接收rocket.chat发送过来的数据

安装fastapi

pip install -U fastapi uvicorn

代码

主要是webhook_server.py

from fastapi import FastAPI, Request, HTTPException
import uvicorn
import json
import os
import chainlit as cl
from chainlit.utils import mount_chainlit

# import session_utils
# from chainlit_ragflow_streaming import message_queues, message_queues_lock

from message_queue import RedisQueue
from pydantic import BaseModel

class MessageRequest(BaseModel):
    session_id: str
    message: str

# 配置日志
# Replace current logging setup with:
from logger_config import setup_logger
logger = setup_logger(__name__)

app = FastAPI()
redis_queue = RedisQueue()
# 存储会话映射: Rocket.Chat用户ID -> Chainlit会话ID
session_mapping = {}

@app.post("/rocketchat-webhook")
async def rocketchat_webhook(request: Request):
    """
    处理Rocket.Chat发送过来的webhook消息。

    :param request: 包含Rocket.Chat消息的请求对象
    :type request: Request
    :return: 处理结果
    :rtype: dict
    """
    try:
        data = await request.json()
    except JSONDecodeError:
        logger.error("Invalid JSON format in request")
        raise HTTPException(status_code=400, detail="Invalid JSON format")
    except KeyError as e:
        logger.error(f"Missing required field: {str(e)}")
        raise HTTPException(status_code=400, detail=f"Missing required field: {str(e)}")
    except redis.ConnectionError:
        logger.error("Redis connection failed")
        raise HTTPException(status_code=503, detail="Service temporarily unavailable")
    except Exception as e:
        logger.error(f"Unexpected error: {str(e)}", exc_info=True)
        raise HTTPException(status_code=500, detail="An unexpected error occurred")
        
    logger.info(f"【debug】收到Rocket.Chat webhook: {json.dumps(data, ensure_ascii=False)}")

    # 验证webhook令牌(从body中获取token)
    token = data.get("token")
    expected_token = os.getenv("ROCKETCHAT_WEBHOOK_TOKEN")
    if not token:
        logger.warning("【debug】Webhook请求中未找到token")
        raise HTTPException(status_code=400, detail="Token not found in request body")
    if token != expected_token:
        logger.warning(f"【debug】Webhook令牌验证失败: 收到{token}, 预期{expected_token}")
        raise HTTPException(status_code=403, detail="Invalid token")

    # 提取消息内容和发送者(根据实际JSON结构调整)
    content = data.get("text", "")
    sender_username = data.get("user_name", "")
    room_id = data.get("channel_id", "")
    message_id = data.get("message_id", "")
    timestamp = data.get("timestamp", "")
    # 添加调试日志
    logger.info(f"【debug】提取消息: sender(Support agent)={sender_username}, content={content[:50]}, room_id={room_id}, message_id={message_id}, timestamp={timestamp}")

    # 验证Redis连接并检查队列
    try:
        # 检查Redis连接
        redis_queue.client.ping()
        logger.info("【debug】Redis连接正常")
        # 打印Redis实例基本信息
        logger.debug(f"【debug】Redis连接信息 - IP: {redis_queue.host}, 端口: {redis_queue.port}, 数据库索引: {redis_queue.db}")

    except Exception as e:
        logger.error(f"【debug】Redis连接失败: {str(e)}")
        raise HTTPException(status_code=500, detail="Redis connection failed")
        
    # 过滤掉系统消息和自动转发的消息
    if "[CHAINLIT_USER_ID:" in content or "[HUMAN_SESSION]" in content:
        logger.info(f"【debug】过滤掉自动转发的消息: {content}")
        return {"status": "ignored"}

    agent_reply = f"**人工客服 {sender_username}:\n {content}"
    # 消息队列名称
    queue_name = f"{os.getenv("IT_ENVIRONMENT")}:rocket.chat_session:{sender_username}:{room_id}:messages_queue"
    # 入队前检查队列是否存在
    # queue_type = redis_queue.client.type(queue_name)
    # if queue_type != 'list':
    #     logger.warning(f"【debug】队列 {queue_name} 不是列表类型,实际类型: {queue_type}")
    #     raise HTTPException(status_code=500, detail=f"Queue {queue_name} is not a list type")
    if redis_queue.qsize(queue_name) == 0 and queue_name not in redis_queue.get_all_queues(): 
        logger.warning(f"【debug】队列 {queue_name} 不存在,将创建新队列")
    # 消息入队,同时设置队列仅保留最近1000条消息,避免内存溢出
    redis_queue.enqueue_stream(queue_name, agent_reply)
    # 入队后验证
    new_size = redis_queue.qsize(queue_name)
    logger.info(f"【debug】消息入队成功,队列 {queue_name} 当前大小: {new_size}, 消息内容: {agent_reply}")
    return {"status": "success"}
    # logger.info(f"【debug】消息入队成功,队列名称: {queue_name} 当前大小: {redis_queue.qsize(queue_name)}")

    """
    # 查询会话映射前添加详细日志
    logger.info(f"【debug】准备查询会话映射: sender_username={sender_username}")
    # chainlit_session_id = session_utils.get_chainlit_session_id(sender_username)
    # all_mappings = session_utils.get_all_session_mappings()
    # 从Redis的session_mapping哈希表中获取会话ID
    chainlit_session_id = redis_queue.client.hget("session_mapping", sender_username)
    # 获取所有会话映射
    all_mappings = redis_queue.client.hgetall("session_mapping")
    logger.info(f"【debug】查询会话映射结果: username={sender_username}, session_id={chainlit_session_id}, 当前映射={all_mappings}")
    # 获取并记录当前所有会话映射状态
    
    logger.info(f"【debug】当前会话映射: {all_mappings}")
    """
    """
    if not chainlit_session_id:
        # 打印当前所有消息队列状态以辅助调试
        # queue_status = {k: v.qsize() for k, v in message_queues.items()}
        queue_status = {q: redis_queue.qsize(q) for q in redis_queue.get_all_queues()}
        logger.info(f"【debug】未找到会话,当前消息队列状态: {queue_status}")
        logger.warning(f"【debug】未找到对应的Chainlit会话: {sender_username}")
        return {"status": "session_not_found"}
    """

    """
    queue_name = f"session:{chainlit_session_id}"
    logger.info(f"【debug】准备入队消息到队列 {queue_name}, 会话ID: {chainlit_session_id}")
    """

# 挂载Chainlit应用
mount_chainlit(app=app, target="chainlit_ragflow_streaming.py", path="/")

"""
@app.post("/send-message")
async def send_message(data: MessageRequest):

    # 接收Rocket.Chat的webhook消息,处理并入队。

    # :param data: 包含会话ID和消息内容的请求体
    # :type data: MessageRequest
    # :return: 入队状态
    # :rtype: dict

    try:
        session_id = data.session_id
        message = data.message
        
        # 打印当前所有消息队列状态(调试用)
        all_queues = redis_queue.get_all_queues()
        # queue_status = {q: redis_queue.qsize(q) for q in all_queues}
        queue_status = {q: redis_queue.qsize(q) for q in redis_queue.get_all_queues()}
        logger.info(f"【debug】当前消息队列状态: {queue_status}")
        logger.info(f"【debug】RedisQueue实例ID: {id(redis_queue)}")
        
        # 检查会话队列(消息队列:真正存放消息的队列)是否存在
        queue_name = f"session:{session_id}"
        if redis_queue.qsize(queue_name) == 0 and queue_name not in all_queues:
            logger.warning(f"会话 {session_id} 的消息队列不存在")
            raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
        
        # 将消息放入对应会话的队列
        redis_queue.enqueue(queue_name, message)
        logger.info(f"【debug】消息已放入会话 {session_id} 的队列,当前队列大小: {redis_queue.qsize(queue_name)}")
        
        return {"status": "success", "message": "Message added to queue"}
    except Exception as e:
        logger.error(f"处理消息时出错: {str(e)}", exc_info=True)
        raise HTTPException(status_code=500, detail=f"Error processing message: {str(e)}")
"""

if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8000)

redis消息队列

安装redis

关于redis的介绍和详细安装教程,请查看:《redis》

安装redis模块

pip install -U redis

有关核心代码

主要是message_queue.py

import os
import redis
import time

from typing import Optional, Any
# Initialize logger for RedisQueue
from logger_config import setup_logger
logger = setup_logger(__name__)


class RedisQueue:
    def __init__(self, host: str = None, port: int = None, db: int = None, password: str = None):
        self.host = host or os.getenv("REDIS_HOST", "localhost")
        self.port = port or int(os.getenv("REDIS_PORT", 6379))
        # Get environment and set appropriate Redis DB index
        environment = os.getenv("IT_ENVIRONMENT", "dev")
        if environment == "dev":
            self.db = int(os.getenv("REDIS_DB_INDEX_DEV", 0))
        elif environment == "test":
            self.db = int(os.getenv("REDIS_DB_INDEX_TEST", 1))
        else:
            self.db = 0  # Default to dev DB if environment not specified
            logger.warning(f"Unknown environment {environment}, defaulting to development database (index 0)")
        self.password = password or os.getenv("REDIS_PASSWORD")
        # Load database index from environment configuration
        self.client = redis.Redis(
            host=self.host,
            port=self.port,
            db=self.db,
            password=self.password,
            decode_responses=True
        )
        try:
            self.client.ping()
        except redis.ConnectionError:
            raise Exception("Could not connect to Redis server. Please ensure Redis is running.")
    # async def __aenter__(self):
    #     self.client = await aioredis.from_url(self.connection_string)
    #     return self
    
    # async def __aexit__(self, exc_type, exc, tb):
    #     await self.client.close()
        
    def enqueue(self, queue_name: str, item: Any) -> int:
        """Add an item to the end of the queue"""
        return self.client.rpush(queue_name, item)

    def dequeue(self, queue_name: str, block: bool = True, timeout: int = 0) -> Optional[Any]:
        """Remove and return an item from the front of the queue"""
        if block:
            # Blocking mode: waits for item with timeout
            item = self.client.blpop(queue_name, timeout=timeout)
            logger.debug(f"Blocking dequeue from {queue_name}: {item}")
            return item[1] if item else None
        else:
            # Non-blocking mode: returns immediately
            logger.debug(f"Non-blocking dequeue from {queue_name}: {item}")
            return self.client.lpop(queue_name)

    def enqueue_stream(self, stream_name: str, item: any, maxlen: int = 1000) -> str:
        """
        Add an item to a Redis Stream with automatic trimming to avoid memory overuse.

        Args:
            stream_name: Name of the stream (queue name).
            item: A dictionary representing the fields of the message.
            maxlen: Maximum number of messages to retain in the stream.

        Returns:
            The ID of the added message.
        """
        try:
            return self.client.xadd(
                stream_name,
                fields={"data": str(item)},
                maxlen=maxlen,
                approximate=True
            )
        except redis.RedisError as e:
            logger.error(f"Failed to enqueue to stream '{stream_name}': {e}")
            return ""

    def stream_peek_latest(self, stream_name: str, block: bool = True, timeout: int = 0) -> Optional[dict]:
        """
        Blocking or non-blocking peek of the latest item from a Redis Stream without deleting it.
        Mimics the behavior of dequeue but doesn't remove the item.
        
        Args:
            stream_name: Name of the Redis stream (acts like a queue).
            block: Whether to block until a new item is available.
            timeout: Maximum blocking time in seconds (only if block=True).

        Returns:
            A dict with 'id' and 'data' keys if message exists, else None.
        """
        try:
            # Always read from the latest known ID to avoid re-reading
            last_id = "$"  # "$" = last inserted ID, means "only read new items after now"
            block_ms = int(timeout * 1000) if block else 0

            response = self.client.xread({stream_name: last_id}, count=1, block=block_ms)

            if not response:
                return None

            _, messages = response[0]
            message_id, message_data = messages[0]
            return {"id": message_id, "data": message_data}
        except redis.RedisError as e:
            logger.error(f"Failed to peek latest from stream '{stream_name}': {e}")
            return None

    def qsize(self, queue_name: str) -> int:
        """
        Return the size of the queue, supporting both List and Stream types"
        """
        try:
            # Get the type of the Redis key
            key_type = self.client.type(queue_name)
            
            if key_type == 'list':
                return self.client.llen(queue_name)
            elif key_type == 'stream':
                return self.client.xlen(queue_name)
            else:
                logger.warning(f"Unsupported queue type: {key_type} for queue {queue_name}")
                return 0
        except redis.RedisError as e:
            logger.error(f"Failed to get queue size for {queue_name}: {e}")
            return 0

    def clear(self, queue_name: str) -> int:
        """Clear all items from the queue"""
        return self.client.delete(queue_name)

    def get_all_queues(self, prefix: str = "session:") -> list:
        """Get all queue names with the given prefix that are lists"""
        keys = self.client.keys(f"{prefix}*")
        list_keys = []
        for key in keys:
            # 只返回列表类型的键
            if self.client.type(key) == 'list':
                list_keys.append(key)
        return list_keys


如何部署redis

《redis》

rocket.chat

详细的rocket.chat部署和配置教程请查看:《rocket.chat–开源的跨平台即时通讯软件》

安装rocket.chat Python SDK

关于rocket.chat Python SDK详细介绍请看此文:《rocket.chat python api wrapper示例》

pip install -U rocketchat_API

关于outgoing webhook的特别说明

事件触发后,rocket.chat会立即向目标url发生器默认的数据结构的数据(不是必须要启用script),但我在官网没有看到详细说明,为了搞清楚,其默认发送的数据结构是怎样的,可以做个简单的实验。

利用https://webhook.site/ 可以非常便捷的创建webhook目标url(打开网站后,会自动创建url),然后将生成的url填入rocket.chat outgoing webhook的url栏中。

下图红框中的url就是网站自动生成的,单击即可自动复制到剪切板

image.png

在rocket.chat的outgoing webhook中,创建一个新的outgoing webhook,最关键的是url,直接粘贴即可

image.png

script可以不用启用

image.png

现在向任何一个rocket.chat用户发送一条消息,观察webhook.site网站是否接受到了数据

image.png

可以看到,网站立即接受到了数据,可以查看详细的数据结构

text字段的值就是刚刚用rocket.chat发送的消息,这个一般是我们最关注的。需要注意的是,如果消息中包含了附件,如视频,图片等,则json格式可能会跟下面的不同,最好再测试下。

知道了数据结构,就知道要怎么解析rocket.chat发送的数据了。

image.png

{
  "token": ":r1qf:",
  "bot": false,
  "channel_id": "LhgrHveTi5eW4bBwcScQxnLPQs6Y7SqJ9R",
  "message_id": "rZkpM2rjmya56phSw",
  "timestamp": "2025-06-21T11:15:14.001Z",
  "user_id": "ScQxnLPQs6Y7SqJ9R",
  "user_name": "jerry",
  "text": "hello webhook site",
  "siteUrl": "http://10.65.37.237:3000"
}

日志处理

from doctest import debug
import logging
import sys
import os
from typing import Dict, Any

def setup_logger(name: str = __name__, level: int = logging.INFO) -> logging.Logger:
    """
    Set up standardized logger with both file and console handlers
    
    Args:
        name: Logger name
        level: Logging level (default: INFO)
    
    Returns:
        Configured logger instance
    """
    # Create or get logger
    logger = logging.getLogger(name)
    
    # Avoid reconfiguring existing logger
    if logger.handlers:
        return logger
    
    # Set level based on environment or configuration
    log_level = os.getenv('LOG_LEVEL', 'DEBUG').upper()
    logger.setLevel(getattr(logging, log_level, logging.INFO))
    
    # Make propagation configurable
    propagate_logs = os.getenv('PROPAGATE_LOGS', 'False').lower() == 'true'
    logger.propagate = propagate_logs
    
    # Add handlers (example for file and console)
    # [Handler configuration would go here]
    # Create formatters
    file_formatter = logging.Formatter(
        '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
    )
    console_formatter = logging.Formatter(
        '%(levelname)s - %(message)s'
    )

    # Create file handler with UTF-8 encoding
    file_handler = logging.FileHandler('app.log', encoding='utf-8')
    file_handler.setFormatter(file_formatter)

    # Create console handler
    console_handler = logging.StreamHandler(sys.stdout)
    console_handler.setFormatter(console_formatter)

    # Add handlers to logger
    logger.addHandler(file_handler)
    logger.addHandler(console_handler)

    return logger

环境变量(.env文件)

为了方便对程序进行调整,我创建了.env文件,这个文件你需要自行创建,根据你自己实际情况修改,以下是我的.env文件示例:

# API Keys 注意:本项目未使用到下面的api key
#DASHSCOPE_API_KEY=
#OPENAI_API_KEY=

# RAGFlow Configuration
RAGFLOW_API_KEY=ragflow-c3NmJlODE4NDc2NTExZjBiNGQ0MDY0Mz
RAGFLOW_BASE_URL=http://10.65.37.238
RAGFLOW_ASSISTANT_NAME=AI-assist

# RocketChat Configuration
# ROCKETCHAT_USER_ID=LhgrHveTi5eW4bBwc
# ROCKETCHAT_AUTH_TOKEN=sj7to84Zr1st8jefMquQfOBfOgQRNHQQRlwbz8bkmIg
ROCKETCHAT_SERVER_URL=http://10.65.37.237:3000
ROCKETCHAT_WEBHOOK_TOKEN=test-token
# 添加LDAP密码配置
LDAP_PASSWORD=1

# 指定rocket.chat中的人工客服用户名及排班(周一到周日)
WEEKDAY_USERS=bob,david,alice,tom,john,jerry,jerry

# OAuth Configuration (Keycloak)
OAUTH_KEYCLOAK_BASE_URL=http://10.65.37.239:8080
OAUTH_KEYCLOAK_REALM=master
OAUTH_KEYCLOAK_CLIENT_ID=chainlit-test-my
OAUTH_KEYCLOAK_CLIENT_SECRET=uuIlgNDCHbvKy0MNGXlSeOcxK3YtNVyf
OAUTH_KEYCLOAK_NAME=chainlit-keycloak
CHAINLIT_AUTH_SECRET=uuIlgNDCHbvKy0MNGXlSeOcxK3YtNVyf



# Redis Configuration
# IT_ENVIRONMENT这个环境变量主要是定义程序运行在何种环境下,可以填dev、test、production、demo等等,分别表示开发环境、测试环境、生产环境和演示环境
#REDIS_DB_INDEX_DEV和REDIS_DB_INDEX_TEST用于定义REDIS中的数据库索引,
#由于redis官方不建议通过数据库索引来区分环境,所以这里我们通过环境变量来区分环境,所有队列仍然在同一个数据库索引(即db 0)里面
IT_ENVIRONMENT=dev
REDIS_DB_INDEX_DEV=0
REDIS_DB_INDEX_TEST=0


# Redis Configuration
REDIS_HOST=10.65.37.237
REDIS_PORT=6379
REDIS_PASSWORD=<此处替换为你的redis密码>


# Logging Configuration
# Set log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
LOG_LEVEL=DEBUG

# Enable/disable log propagation to parent loggers
PROPAGATE_LOGS=False

验证

简单提问

image.png

点击上图的文件后,即可在线浏览原始文档,便于追溯,也方便用户查看详细的原始文档。

image.png

验证直接用英语提问,是否能正确回答:

从下图可知,虽然我没有在ragflow的知识库中存入英文版的文档,ragflow仍然能自动给出合适的英文回答,这对于外籍员工十分有用。

image.png

测试转人工功能

QQ截图20250628192520.pngimage.png

同一个用户在不同电脑上登录了chainlit并且点击了“转人工”

测试过,如果同一个用户,在不同电脑上登录了chainlit,并且都点击了“转人工”按钮,则后续,用户在哪个电脑上发送消息,rocket.chat人工客服回复的消息就会出现在哪个电脑的chainlit UI上。

故障诊断

KeyError:‘chunk_id’

与ragflow中的AI助理对话时,每次对话结束后,都会出现这个报错,我还将此问题反馈到了github上,issue链接为:[Bug]: KeyError: ‘chunk_id’ when streaming responses via ragflow-sdk session.ask() · Issue #7011 · infiniflow/ragflow

后来ragflow维护者答复我了,我反馈的问题是个bug,虽然他也告诉我解决方案了,但是我感觉比较麻烦,最终我还是没有使用ragflow python sdk,而是直接用的ragflow HTTP api将chainlit与ragflow集成。

image.png

后来我查阅了ragflow官方的http API文档,发现了这样一段提示,如下图,意思是说,在streaming模式下,最后一条消息是空消息,这让我发现了问题所在,因为最后一次响应中并没有chunk_id这个字段,所以报错了,所以我重修修改了代码,最终解决了此问题

image.png

以下是完整的python代码,最关键的是第36行后面的代码,其中的这三行是关键:

        `# 检查是否为结束消息`

        `elif isinstance(ans, dict) and ans.get('code') == 0 and ans.get('data') is True:`

            `break`
# Import RAGFlow SDK
from ragflow_sdk import RAGFlow
from chainlit.oauth_providers import OAuthProvider
import chainlit as cl

import httpx
from fastapi import HTTPException
from chainlit.user import User
# Initialize RAGFlow object
# api_key: Authentication API key
# base_url: Base URL of RAGFlow service
rag_object = RAGFlow(
    api_key="ragflow-c3NmJlODE4NDc2NTExZjBiNGQ0MDY0Mz", 
    base_url="http://10.65.37.238"
)

# Get specific assistant by name
assistant = rag_object.list_chats(name="AI-assist")[0]

# List all available assistants (for debugging)
""" for assistant in rag_object.list_chats():
    print(assistant) """

# Create new chat session
session = assistant.create_session()

# Print welcome message
print("\n==================== test-AI-assist =====================\n")
print("Hello. What can I do for you?")

# 在创建会话时设置stream=False
""" user_question = "如何重置OA密码?"
chat_assisitant_output = session.ask(question=user_question, stream=False)
print(chat_assisitant_output) """
# Main conversation loop
while True:
    # Get user input
    question = input("\nPlease question:\n> ")
    # question = "如何重置OA密码?"
    # Stream assistant's response
    # 初始化变量存储完整响应
    full_response = ""
    cont = ""
    print("\n==================== test-AI-assist =====================\n")
    try:
        # 处理流式响应
        for ans in session.ask(question=question, stream=True):
            # 检查是否为有效的Message对象且包含content
            if hasattr(ans, 'content') and ans.content:
                # 计算新增内容
                new_content = ans.content[len(full_response):]
                # 打印新增内容
                print(new_content, end='', flush=True)
                # 更新完整响应
                full_response = ans.content
            # 检查是否为结束消息
            elif isinstance(ans, dict) and ans.get('code') == 0 and ans.get('data') is True:
                break
        print()  # 添加换行
    except KeyError as e:
        # 忽略chunk_id相关的KeyError,不输出错误信息
        if 'chunk_id' not in str(e):
            print(f"\nUnexpected error: {e}")
    except Exception as e:
        print(f"\nUnexpected error: {e}")
    finally:
        print("\n\n==================== test-AI-assist =====================\n")


输出如下:

image.png

Chainlit context not found

完整报错如下:

image.png

出现这个问题的原因是,只能在chainlit上下文中调用chainlit中的函数,而上面的上面试图在fastapi中直接调用chainlit中的函数向chainlit UI发送消息,这是不行的。

这个问题困扰了好几天,最终通过redis解决了,详情可以查看本项目的源代码和项目总结报告

关于作者和DreamAI

https://docs.dingtalk.com/i/nodes/Amq4vjg890AlRbA6Td9ZvlpDJ3kdP0wQ?iframeQuery=utm_source=portal&utm_medium=portal_recent

关注微信公众号“AI发烧友”,获取更多IT开发运维实用工具与技巧,还有很多AI技术文档!

梦幻智能logo-01(无水印).png

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值