From c13b9cf17bcc5c54039fb244afdd345d5ebc86bb Mon Sep 17 00:00:00 2001 From: ulleo Date: Wed, 28 Jan 2026 15:59:40 +0800 Subject: [PATCH] feat: add chat log step display #324 #742 --- backend/apps/chat/api/chat.py | 10 +- backend/apps/chat/curd/chat.py | 161 +++++++++++++++++++++- backend/apps/chat/models/chat_model.py | 18 +++ backend/apps/swagger/locales/en.json | 1 + backend/apps/swagger/locales/zh.json | 1 + frontend/src/api/chat.ts | 120 +++++++++++++++- frontend/src/views/chat/ChatTokenTime.vue | 63 +++++++++ frontend/src/views/chat/index.vue | 19 ++- 8 files changed, 385 insertions(+), 8 deletions(-) create mode 100644 frontend/src/views/chat/ChatTokenTime.vue diff --git a/backend/apps/chat/api/chat.py b/backend/apps/chat/api/chat.py index 773bbcca..7fe8b5d6 100644 --- a/backend/apps/chat/api/chat.py +++ b/backend/apps/chat/api/chat.py @@ -14,7 +14,7 @@ list_chats, get_chat_with_records, create_chat, rename_chat, \ delete_chat, get_chat_chart_data, get_chat_predict_data, get_chat_with_records_with_data, get_chat_record_by_id, \ format_json_data, format_json_list_data, get_chart_config, list_recent_questions, get_chat as get_chat_exec, \ - rename_chat_with_user + rename_chat_with_user, get_chat_log_history from apps.chat.models.chat_model import CreateChat, ChatRecord, RenameChat, ChatQuestion, AxisObj, QuickCommand, \ ChatInfo, Chat, ChatFinishStep from apps.chat.task.llm import LLMService @@ -91,6 +91,14 @@ def inner(): return await asyncio.to_thread(inner) +@router.get("/record/{chat_record_id}/log", summary=f"{PLACEHOLDER_PREFIX}get_record_log") +async def chat_record_log(session: SessionDep, current_user: CurrentUser, chat_record_id: int): + def inner(): + return get_chat_log_history(session, chat_record_id, current_user) + + return await asyncio.to_thread(inner) + + """ @router.post("/rename", response_model=str, summary=f"{PLACEHOLDER_PREFIX}rename_chat") @system_log(LogConfig( operation_type=OperationType.UPDATE, diff --git a/backend/apps/chat/curd/chat.py b/backend/apps/chat/curd/chat.py index 1673bd2c..dc5baaa0 100644 --- a/backend/apps/chat/curd/chat.py +++ b/backend/apps/chat/curd/chat.py @@ -8,7 +8,7 @@ from sqlalchemy.orm import aliased from apps.chat.models.chat_model import Chat, ChatRecord, CreateChat, ChatInfo, RenameChat, ChatQuestion, ChatLog, \ - TypeEnum, OperationEnum, ChatRecordResult + TypeEnum, OperationEnum, ChatRecordResult, ChatLogHistory, ChatLogHistoryItem from apps.datasource.crud.recommended_problem import get_datasource_recommended_chart from apps.datasource.models.datasource import CoreDatasource from apps.db.constant import DB @@ -345,11 +345,60 @@ def get_chat_with_records(session: SessionDep, chart_id: int, current_user: Curr result = session.execute(stmt).all() record_list: list[ChatRecordResult] = [] + + # 批量获取所有ChatRecord的token消耗 + record_ids = [row.id for row in result] + token_usage_map = {} + + if record_ids: + # 查询所有相关ChatLog的token_usage + log_stmt = select(ChatLog.pid, ChatLog.token_usage).where( + and_( + ChatLog.pid.in_(record_ids), + ChatLog.local_operation == False, + ChatLog.token_usage.is_not(None) # 排除token_usage为空的记录 + ) + ) + log_results = session.execute(log_stmt).all() + + # 按pid分组计算total_tokens总和 + for pid, token_usage in log_results: + if pid and token_usage is not None: + tokens_to_add = 0 + + if isinstance(token_usage, dict): + # 处理字典类型: {"input_tokens": 961, "total_tokens": 1006, "output_tokens": 45} + if token_usage: # 非空字典 + if "total_tokens" in token_usage: + token_value = token_usage["total_tokens"] + if isinstance(token_value, (int, float)): + tokens_to_add = int(token_value) + elif isinstance(token_usage, (int, float)): + tokens_to_add = int(token_usage) + if tokens_to_add > 0: + if pid not in token_usage_map: + token_usage_map[pid] = 0 + token_usage_map[pid] += tokens_to_add + for row in result: + # 计算耗时 + duration = None + if row.create_time and row.finish_time: + try: + time_diff = row.finish_time - row.create_time + duration = time_diff.total_seconds() # 转换为秒 + except Exception: + duration = None + + # 获取token总消耗 + total_tokens = token_usage_map.get(row.id, 0) + if not with_data: record_list.append( ChatRecordResult(id=row.id, chat_id=row.chat_id, create_time=row.create_time, finish_time=row.finish_time, + duration=duration, + total_tokens=total_tokens, question=row.question, sql_answer=row.sql_answer, sql=row.sql, chart_answer=row.chart_answer, chart=row.chart, analysis=row.analysis, predict=row.predict, @@ -367,6 +416,8 @@ def get_chat_with_records(session: SessionDep, chart_id: int, current_user: Curr record_list.append( ChatRecordResult(id=row.id, chat_id=row.chat_id, create_time=row.create_time, finish_time=row.finish_time, + duration=duration, + total_tokens=total_tokens, question=row.question, sql_answer=row.sql_answer, sql=row.sql, chart_answer=row.chart_answer, chart=row.chart, analysis=row.analysis, predict=row.predict, @@ -437,6 +488,23 @@ def format_record(record: ChatRecordResult): _dict['sql'] = sqlparse.format(record.sql, reindent=True) except Exception: pass + + # 格式化duration字段,保留2位小数 + if 'duration' in _dict and _dict['duration'] is not None: + try: + # 可以格式化为更易读的形式 + _dict['duration'] = round(_dict['duration'], 2) # 保留2位小数 + except Exception: + pass + + # 格式化total_tokens字段 + if 'total_tokens' in _dict and _dict['total_tokens'] is not None: + try: + # 确保是整数类型 + _dict['total_tokens'] = int(_dict['total_tokens']) if _dict['total_tokens'] else 0 + except Exception: + _dict['total_tokens'] = 0 + # 去除返回前端多余的字段 _dict.pop('sql_reasoning_content', None) _dict.pop('chart_reasoning_content', None) @@ -446,6 +514,90 @@ def format_record(record: ChatRecordResult): return _dict +def get_chat_log_history(session: SessionDep, chat_record_id: int, current_user: CurrentUser) -> ChatLogHistory: + """ + 获取ChatRecord的详细历史记录 + + Args: + session: 数据库会话 + chat_record_id: ChatRecord的ID + current_user: 当前用户 + + Returns: + ChatLogHistory: 包含历史步骤和时间信息的对象 + """ + # 1. 首先验证ChatRecord存在且属于当前用户 + chat_record = session.get(ChatRecord, chat_record_id) + if not chat_record: + raise Exception(f"ChatRecord with id {chat_record_id} not found") + + if chat_record.create_by != current_user.id: + raise Exception(f"ChatRecord with id {chat_record_id} not owned by the current user") + + # 2. 查询与该ChatRecord相关的所有ChatLog记录 + chat_logs = session.query(ChatLog).filter( + ChatLog.pid == chat_record_id + ).order_by(ChatLog.start_time).all() + + # 3. 计算总的时间和token信息 + total_tokens = 0 + steps = [] + + for log in chat_logs: + # 计算单条记录的耗时 + duration = None + if log.start_time and log.finish_time: + try: + time_diff = log.finish_time - log.start_time + duration = time_diff.total_seconds() + except Exception: + duration = None + + # 计算单条记录的token消耗 + log_tokens = 0 + if log.token_usage is not None: + if isinstance(log.token_usage, dict): + if log.token_usage and "total_tokens" in log.token_usage: + token_value = log.token_usage["total_tokens"] + if isinstance(token_value, (int, float)): + log_tokens = int(token_value) + elif isinstance(log.token_usage, (int, float)): + log_tokens = log.token_usage + + # 累加到总token消耗 + total_tokens += log_tokens + + # 创建ChatLogHistoryItem + history_item = ChatLogHistoryItem( + start_time=log.start_time, + finish_time=log.finish_time, + duration=duration, + total_tokens=log_tokens, + operate=log.operate, + local_operation=log.local_operation + ) + steps.append(history_item) + + # 4. 计算总耗时(使用ChatRecord的时间) + total_duration = None + if chat_record.create_time and chat_record.finish_time: + try: + time_diff = chat_record.finish_time - chat_record.create_time + total_duration = time_diff.total_seconds() + except Exception: + total_duration = None + + # 5. 创建并返回ChatLogHistory对象 + chat_log_history = ChatLogHistory( + start_time=chat_record.create_time, # 使用ChatRecord的create_time + finish_time=chat_record.finish_time, # 使用ChatRecord的finish_time + duration=total_duration, + total_tokens=total_tokens, + steps=steps + ) + + return chat_log_history + def get_chat_brief_generate(session: SessionDep, chat_id: int): chat = get_chat(session=session, chat_id=chat_id) if chat is not None and chat.brief_generate is not None: @@ -877,7 +1029,12 @@ def save_error_message(session: SessionDep, record_id: int, message: str) -> Cha session.commit() - # todo log error finish + # log error finish + stmt = update(ChatLog).where(and_(ChatLog.pid == record.id, ChatLog.finish_time.is_(None))).values( + finish_time=record.finish_time + ) + session.execute(stmt) + session.commit() return result diff --git a/backend/apps/chat/models/chat_model.py b/backend/apps/chat/models/chat_model.py index 81081feb..011218d5 100644 --- a/backend/apps/chat/models/chat_model.py +++ b/backend/apps/chat/models/chat_model.py @@ -155,6 +155,8 @@ class ChatRecordResult(BaseModel): chart_reasoning_content: Optional[str] = None analysis_reasoning_content: Optional[str] = None predict_reasoning_content: Optional[str] = None + duration: Optional[float] = None # 耗时字段(单位:秒) + total_tokens: Optional[int] = None # token总消耗 class CreateChat(BaseModel): @@ -186,6 +188,22 @@ class ChatInfo(BaseModel): records: List[ChatRecord | dict] = [] +class ChatLogHistoryItem(BaseModel): + start_time: Optional[datetime] = None + finish_time: Optional[datetime] = None + duration: Optional[float] = None # 耗时字段(单位:秒) + total_tokens: Optional[int] = None # token总消耗 + operate: Optional[OperationEnum] = None + local_operation: Optional[bool] = False + +class ChatLogHistory(BaseModel): + start_time: Optional[datetime] = None + finish_time: Optional[datetime] = None + duration: Optional[float] = None # 耗时字段(单位:秒) + total_tokens: Optional[int] = None # token总消耗 + steps: List[ChatLogHistoryItem | dict] = [] + + class AiModelQuestion(BaseModel): question: str = None ai_modal_id: int = None diff --git a/backend/apps/swagger/locales/en.json b/backend/apps/swagger/locales/en.json index f4499c9a..837e5c84 100644 --- a/backend/apps/swagger/locales/en.json +++ b/backend/apps/swagger/locales/en.json @@ -141,6 +141,7 @@ "get_chat_with_data": "Get Chat Details (With Data)", "get_chart_data": "Get Chart Data", "get_chart_predict_data": "Get Chart Prediction Data", + "get_record_log": "Get Chart Record Log", "rename_chat": "Rename Chat", "delete_chat": "Delete Chat", "start_chat": "Create Chat", diff --git a/backend/apps/swagger/locales/zh.json b/backend/apps/swagger/locales/zh.json index 443dd473..59e3d139 100644 --- a/backend/apps/swagger/locales/zh.json +++ b/backend/apps/swagger/locales/zh.json @@ -141,6 +141,7 @@ "get_chat_with_data": "获取对话详情(带数据)", "get_chart_data": "获取图表数据", "get_chart_predict_data": "获取图表预测数据", + "get_record_log": "获取对话日志", "rename_chat": "重命名对话", "delete_chat": "删除对话", "start_chat": "创建对话", diff --git a/frontend/src/api/chat.ts b/frontend/src/api/chat.ts index 6da08c22..beea7937 100644 --- a/frontend/src/api/chat.ts +++ b/frontend/src/api/chat.ts @@ -52,6 +52,8 @@ export class ChatRecord { analysis_record_id?: number predict_record_id?: number regenerate_record_id?: number + duration?: number + total_tokens?: number constructor() constructor( @@ -77,7 +79,9 @@ export class ChatRecord { recommended_question: string | undefined, analysis_record_id: number | undefined, predict_record_id: number | undefined, - regenerate_record_id: number | undefined + regenerate_record_id: number | undefined, + duration: number | undefined, + total_tokens: number | undefined ) constructor( id?: number, @@ -102,7 +106,9 @@ export class ChatRecord { recommended_question?: string, analysis_record_id?: number, predict_record_id?: number, - regenerate_record_id?: number + regenerate_record_id?: number, + duration?: number, + total_tokens?: number ) { this.id = id this.chat_id = chat_id @@ -127,6 +133,8 @@ export class ChatRecord { this.analysis_record_id = analysis_record_id this.predict_record_id = predict_record_id this.regenerate_record_id = regenerate_record_id + this.duration = duration + this.total_tokens = total_tokens } } @@ -267,7 +275,9 @@ const toChatRecord = (data?: any): ChatRecord | undefined => { data.recommended_question, data.analysis_record_id, data.predict_record_id, - data.regenerate_record_id + data.regenerate_record_id, + data.duration, + data.total_tokens ) } const toChatRecordList = (list: any = []): ChatRecord[] => { @@ -281,6 +291,95 @@ const toChatRecordList = (list: any = []): ChatRecord[] => { return records } +export class ChatLogHistoryItem { + start_time?: Date | string + finish_time?: Date | string + duration?: number | undefined + total_tokens?: number | undefined + operate?: string | undefined + local_operation?: boolean | undefined + + constructor() + constructor( + start_time: Date | string, + finish_time: Date | string, + duration: number | undefined, + total_tokens: number | undefined, + operate: string | undefined, + local_operation: boolean | undefined + ) + constructor( + start_time?: Date | string, + finish_time?: Date | string, + duration?: number | undefined, + total_tokens?: number | undefined, + operate?: string | undefined, + local_operation?: boolean | undefined + ) { + this.start_time = getDate(start_time) + this.finish_time = getDate(finish_time) + this.duration = duration + this.total_tokens = total_tokens + this.operate = operate + this.local_operation = !!local_operation + } +} + +export class ChatLogHistory { + start_time?: Date | string + finish_time?: Date | string + duration?: number | undefined + total_tokens?: number | undefined + steps?: Array | undefined + + constructor() + constructor( + start_time: Date | string, + finish_time: Date | string, + duration: number | undefined, + total_tokens: number | undefined, + steps: Array | undefined + ) + constructor( + start_time?: Date | string, + finish_time?: Date | string, + duration?: number | undefined, + total_tokens?: number | undefined, + steps?: Array | undefined + ) { + this.start_time = getDate(start_time) + this.finish_time = getDate(finish_time) + this.duration = duration + this.total_tokens = total_tokens + this.steps = steps ? steps : [] + } +} + +const toChatLogHistoryItem = (data?: any): any | undefined => { + if (!data) { + return undefined + } + return new ChatLogHistoryItem( + data.start_time, + data.finish_time, + data.duration, + data.total_tokens, + data.operate, + data.local_operation + ) +} + +const toChatLogHistoryItemList = (list: any = []): ChatLogHistoryItem[] => { + const records: Array = [] + for (let i = 0; i < list.length; i++) { + const record = toChatLogHistoryItem(list[i]) + if (record) { + records.push(record) + } + } + return records +} + export const chatApi = { toChatInfo: (data?: any): ChatInfo | undefined => { if (!data) { @@ -312,6 +411,18 @@ export const chatApi = { } return infos }, + toChatLogHistory: (data?: any): ChatLogHistory | undefined => { + if (!data) { + return undefined + } + return new ChatLogHistory( + data.start_time, + data.finish_time, + data.duration, + data.total_tokens, + toChatLogHistoryItemList(data.steps) + ) + }, list: (): Promise> => { return request.get('/chat/list') }, @@ -327,6 +438,9 @@ export const chatApi = { get_chart_predict_data: (record_id?: number): Promise => { return request.get(`/chat/record/${record_id}/predict_data`) }, + get_chart_log_history: (record_id?: number): Promise => { + return request.get(`/chat/record/${record_id}/log`) + }, startChat: (data: any): Promise => { return request.post('/chat/start', data) }, diff --git a/frontend/src/views/chat/ChatTokenTime.vue b/frontend/src/views/chat/ChatTokenTime.vue new file mode 100644 index 00000000..3513ec33 --- /dev/null +++ b/frontend/src/views/chat/ChatTokenTime.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/frontend/src/views/chat/index.vue b/frontend/src/views/chat/index.vue index ef5ed626..598f145b 100644 --- a/frontend/src/views/chat/index.vue +++ b/frontend/src/views/chat/index.vue @@ -239,6 +239,11 @@ >