1010from app .api .auth_bridge import get_current_auth_context
1111from app .integrations .account_sync import sync_twin_record
1212from app .services .profile_helper import agent as profile_agent
13+ from app .services .profile_helper import block_agent as profile_block_agent
1314from app .services .profile_helper import sessions as profile_sessions
1415
1516router = APIRouter ()
@@ -37,12 +38,18 @@ class PublishRequest(BaseModel):
3738 display_name : str | None = None
3839
3940
40- def _get_uid (auth_ctx : dict ) -> str :
41+ def _get_uid (auth_ctx : dict ) -> str | None :
4142 auth_context = auth_ctx .get ("auth_context" )
4243 if auth_context is not None :
43- return str (auth_context .subject )
44+ if getattr (auth_context , "is_anonymous" , False ):
45+ return None
46+ subject = str (auth_context .subject ).strip ()
47+ return subject or None
4448 user = auth_ctx .get ("user" ) or {}
45- return str (user .get ("id" , "anonymous" ))
49+ uid = str (user .get ("id" , "" )).strip ()
50+ if not uid or uid .lower () == "anonymous" :
51+ return None
52+ return uid
4653
4754
4855def _normalize_session_id (session_id : str | None ) -> str | None :
@@ -54,15 +61,17 @@ def _normalize_session_id(session_id: str | None) -> str | None:
5461 return sid
5562
5663
57- def _get_session_for_user (session_id : str , uid : str ) -> dict :
64+ def _get_session_for_user (session_id : str , uid : str | None ) -> dict :
5865 session = profile_sessions .get (session_id )
5966 if not session :
60- raise HTTPException (status_code = 404 , detail = "会话不存在或已过期" )
67+ # 会话不在内存中(常见于 uvicorn --reload 后热重载导致内存清空)
68+ # 尝试用 get_or_create 从磁盘恢复画像文件
69+ _ , session = profile_sessions .get_or_create (session_id , user_id = uid )
6170
6271 existing_uid = session .get ("user_id" )
6372 if existing_uid and str (existing_uid ) != str (uid ):
6473 raise HTTPException (status_code = 404 , detail = "会话不存在或已过期" )
65- if not existing_uid :
74+ if not existing_uid and uid :
6675 session ["user_id" ] = uid
6776 return session
6877
@@ -209,6 +218,46 @@ def generate():
209218 )
210219
211220
221+ @router .post ("/chat/blocks" , response_class = StreamingResponse )
222+ async def chat_blocks_stream (
223+ req : ChatRequest ,
224+ auth_ctx : dict = Depends (get_current_auth_context ),
225+ ):
226+ """Block 协议 SSE:每个 Block 作为一条 SSE 事件发送。"""
227+ uid = _get_uid (auth_ctx )
228+ normalized_session_id = _normalize_session_id (req .session_id )
229+ session_id , session = profile_sessions .get_or_create (
230+ normalized_session_id ,
231+ user_id = uid ,
232+ )
233+ if not req .message .strip ():
234+ raise HTTPException (status_code = 400 , detail = "消息不能为空" )
235+
236+ def generate ():
237+ try :
238+ blocks = profile_block_agent .run_block_agent (
239+ req .message , session , model = req .model
240+ )
241+ # 每轮对话结束后立即将消息历史持久化到磁盘
242+ # 确保 session 过期后仍可从磁盘恢复 AI 上下文
243+ profile_sessions .save_messages (session )
244+ for block in blocks :
245+ yield f"data: { json .dumps (block , ensure_ascii = False )} \n \n "
246+ except Exception as e :
247+ yield f"data: { json .dumps ({'type' : 'text' , 'content' : f'服务器错误: { e } ' }, ensure_ascii = False )} \n \n "
248+ yield "data: [DONE]\n \n "
249+
250+ return StreamingResponse (
251+ generate (),
252+ media_type = "text/event-stream" ,
253+ headers = {
254+ "Cache-Control" : "no-cache" ,
255+ "Connection" : "keep-alive" ,
256+ "X-Session-Id" : session_id ,
257+ },
258+ )
259+
260+
212261@router .get ("/profile/{session_id}" )
213262async def get_profile (
214263 session_id : str ,
@@ -223,42 +272,74 @@ async def get_profile(
223272 }
224273
225274
226- @router .get ("/profile /{session_id}/structured " )
227- async def get_structured_profile (
275+ @router .get ("/chat-history /{session_id}" )
276+ async def get_chat_history (
228277 session_id : str ,
229278 auth_ctx : dict = Depends (get_current_auth_context ),
230279):
280+ """返回 session 的用户可见对话历史(过滤 tool 角色,保留 user/assistant 可读消息)。"""
231281 uid = _get_uid (auth_ctx )
232- session = _get_session_for_user (session_id , uid )
233- from app . services . profile_helper . profile_parser import parse_profile
282+ normalized = _normalize_session_id (session_id )
283+ session = _get_session_for_user ( normalized , uid )
234284
235- return parse_profile (session ["profile" ])
285+ # 优先从内存 session 读取;内存中没有则从磁盘恢复
286+ raw_messages = session .get ("messages" ) or profile_sessions .load_messages (normalized , uid )
287+
288+ # 只保留用户可读的消息(user 角色 + 有文字内容的 assistant 角色)
289+ visible : list [dict ] = []
290+ for m in raw_messages :
291+ role = m .get ("role" )
292+ if role == "user" :
293+ content = m .get ("content" , "" )
294+ if content :
295+ visible .append ({"role" : "user" , "content" : content })
296+ elif role == "assistant" :
297+ content = m .get ("content" , "" )
298+ # 跳过纯工具调用中转消息(content 为空或仅是系统桥接短文本)
299+ if content and len (content ) > 5 and not content .startswith ("(" ):
300+ visible .append ({"role" : "assistant" , "content" : content })
301+
302+ return {"messages" : visible , "count" : len (visible )}
236303
237304
238305@router .get ("/profile/{session_id}/scientists/famous" )
239- async def get_famous_scientist_matches (
306+ async def get_famous_scientists (
240307 session_id : str ,
241308 auth_ctx : dict = Depends (get_current_auth_context ),
242309):
243- """Top famous-scientist matches + scatter plot data (placeholder until matcher is wired). """
310+ """知名科学家相似度匹配(纯计算,瞬间返回)。 """
244311 uid = _get_uid (auth_ctx )
245- _ = _get_session_for_user (session_id , uid )
246- return {
247- "top3" : [],
248- "scatter_data" : [],
249- "user_point" : {"csi" : 0.5 , "rai" : 0.5 },
250- }
312+ session = _get_session_for_user (session_id , uid )
313+ from app .services .profile_helper .profile_parser import parse_profile
314+ from app .services .profile_helper .scientist_match import match_famous_scientists
315+ parsed = parse_profile (session ["profile" ])
316+ return match_famous_scientists (parsed )
251317
252318
253319@router .get ("/profile/{session_id}/scientists/field" )
254320async def get_field_scientist_recommendations (
255321 session_id : str ,
256322 auth_ctx : dict = Depends (get_current_auth_context ),
257323):
258- """Same-field scholar recommendations (placeholder until recommender is wired). """
324+ """领域相关科学家推荐(LLM 调用,可能较慢)。 """
259325 uid = _get_uid (auth_ctx )
260- _ = _get_session_for_user (session_id , uid )
261- return {"recommendations" : []}
326+ session = _get_session_for_user (session_id , uid )
327+ from app .services .profile_helper .profile_parser import parse_profile
328+ from app .services .profile_helper .scientist_match import recommend_field_scientists
329+ parsed = parse_profile (session ["profile" ])
330+ return {"recommendations" : recommend_field_scientists (parsed )}
331+
332+
333+ @router .get ("/profile/{session_id}/structured" )
334+ async def get_structured_profile (
335+ session_id : str ,
336+ auth_ctx : dict = Depends (get_current_auth_context ),
337+ ):
338+ uid = _get_uid (auth_ctx )
339+ session = _get_session_for_user (session_id , uid )
340+ from app .services .profile_helper .profile_parser import parse_profile
341+
342+ return parse_profile (session ["profile" ])
262343
263344
264345@router .get ("/download/{session_id}" )
@@ -335,6 +416,8 @@ async def publish_to_library(
335416
336417 token = auth_ctx .get ("token" )
337418 uid = _get_uid (auth_ctx )
419+ if uid is None :
420+ raise HTTPException (status_code = 401 , detail = "请先登录后再发布数字分身" )
338421 session = _get_session_for_user (req .session_id , uid )
339422 full_profile = session .get ("profile" , "" )
340423 forum_profile = session .get ("forum_profile" , "" )
0 commit comments