diff --git a/.gitignore b/.gitignore index 9ce08c6a..28988b55 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,7 @@ cover/ # Django stuff: *.log +backend/logs/ local_settings.py db.sqlite3 db.sqlite3-journal diff --git a/backend/alembic/versions/066_add_custom_prompt_is_full_template.py b/backend/alembic/versions/066_add_custom_prompt_is_full_template.py new file mode 100644 index 00000000..af4df0e0 --- /dev/null +++ b/backend/alembic/versions/066_add_custom_prompt_is_full_template.py @@ -0,0 +1,23 @@ +"""066_add_custom_prompt_is_full_template + +Revision ID: a1b2c3d4e5f6 +Revises: 8ff90df7871d +Create Date: 2026-03-09 + +""" +from alembic import op +import sqlalchemy as sa + +revision = 'a1b2c3d4e5f6' +down_revision = '8ff90df7871d' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('custom_prompt', sa.Column('is_full_template', sa.Boolean(), nullable=True)) + op.execute("UPDATE custom_prompt SET is_full_template = false WHERE is_full_template IS NULL") + + +def downgrade(): + op.drop_column('custom_prompt', 'is_full_template') diff --git a/backend/apps/api.py b/backend/apps/api.py index 49db3a79..70573257 100644 --- a/backend/apps/api.py +++ b/backend/apps/api.py @@ -7,7 +7,19 @@ from apps.datasource.api import datasource, table_relation, recommended_problem from apps.mcp import mcp from apps.openapi.demo import demo -from apps.system.api import login, user, aimodel, workspace, assistant, parameter, apikey, variable_api +from apps.system.api import ( + login, + user, + aimodel, + workspace, + assistant, + parameter, + apikey, + variable_api, + template_prompt, + custom_prompt, + statistics, +) from apps.terminology.api import terminology from apps.settings.api import base #from audit.api import audit_api @@ -35,5 +47,8 @@ api_router.include_router(recommended_problem.router) api_router.include_router(variable_api.router) +api_router.include_router(template_prompt.router) +api_router.include_router(custom_prompt.router) +api_router.include_router(statistics.router) #api_router.include_router(audit_api.router) diff --git a/backend/apps/chat/models/chat_model.py b/backend/apps/chat/models/chat_model.py index f096b073..9bd0a0d2 100755 --- a/backend/apps/chat/models/chat_model.py +++ b/backend/apps/chat/models/chat_model.py @@ -294,16 +294,22 @@ def sql_sys_question(self, db_type: Union[str, DB], enable_query_limit: bool = T 'example_answer_2'] _example_answer_3 = _sql_template['example_answer_3_with_limit'] if enable_query_limit else _sql_template[ 'example_answer_3'] - return _base_template['system'].format(engine=self.engine, schema=self.db_schema, question=self.question, - lang=self.lang, terminologies=self.terminologies, - data_training=self.data_training, custom_prompt=self.custom_prompt, - process_check=_process_check, - base_sql_rules=_base_sql_rules, - basic_sql_examples=_sql_examples, - example_engine=_example_engine, - example_answer_1=_example_answer_1, - example_answer_2=_example_answer_2, - example_answer_3=_example_answer_3) + return _base_template['system'].format( + engine=self.engine, + schema=self.db_schema, + question=self.question, + lang=self.lang, + terminologies=self.terminologies, + data_training=self.data_training, + custom_prompt=self.custom_prompt or "", + process_check=_process_check, + base_sql_rules=_base_sql_rules, + basic_sql_examples=_sql_examples, + example_engine=_example_engine, + example_answer_1=_example_answer_1, + example_answer_2=_example_answer_2, + example_answer_3=_example_answer_3, + ) def sql_user_question(self, current_time: str, change_title: bool): _question = self.question diff --git a/backend/apps/chat/task/llm.py b/backend/apps/chat/task/llm.py index a98b0ac4..649c16ac 100644 --- a/backend/apps/chat/task/llm.py +++ b/backend/apps/chat/task/llm.py @@ -40,6 +40,7 @@ from apps.datasource.embedding.ds_embedding import get_ds_embedding from apps.datasource.models.datasource import CoreDatasource from apps.db.db import exec_sql, get_version, check_connection +from apps.system.crud import custom_prompt as system_custom_prompt_crud from apps.system.crud.assistant import AssistantOutDs, AssistantOutDsFactory, get_assistant_ds from apps.system.crud.parameter_manage import get_groups from apps.system.schemas.system_schema import AssistantOutDsSchema @@ -295,24 +296,64 @@ def filter_terminology_template(self, _session: Session, oid: int = None, ds_id: def filter_custom_prompts(self, _session: Session, custom_prompt_type: CustomPromptTypeEnum, oid: int = None, ds_id: int = None): + calculate_oid = oid + calculate_ds_id = ds_id + if self.current_assistant: + calculate_oid = self.current_assistant.oid if self.current_assistant.type != 4 else self.current_user.oid + if self.current_assistant.type == 1: + calculate_ds_id = None + + self.current_logs[OperationEnum.FILTER_CUSTOM_PROMPT] = start_log( + session=_session, + operate=OperationEnum.FILTER_CUSTOM_PROMPT, + record_id=self.record.id, + local_operation=True, + ) + + # 1. 兼容 xpack 自定义提示词(如有 License) + xpack_prompt: str = "" + xpack_list: list = [] if SQLBotLicenseUtil.valid(): - calculate_oid = oid - calculate_ds_id = ds_id - if self.current_assistant: - calculate_oid = self.current_assistant.oid if self.current_assistant.type != 4 else self.current_user.oid - if self.current_assistant.type == 1: - calculate_ds_id = None - self.current_logs[OperationEnum.FILTER_CUSTOM_PROMPT] = start_log(session=_session, - operate=OperationEnum.FILTER_CUSTOM_PROMPT, - record_id=self.record.id, - local_operation=True) - self.chat_question.custom_prompt, prompt_list = find_custom_prompts(_session, custom_prompt_type, - calculate_oid, - calculate_ds_id) - self.current_logs[OperationEnum.FILTER_CUSTOM_PROMPT] = end_log(session=_session, - log=self.current_logs[ - OperationEnum.FILTER_CUSTOM_PROMPT], - full_message=prompt_list) + xpack_prompt, xpack_list = find_custom_prompts( + _session, + custom_prompt_type, + calculate_oid, + calculate_ds_id, + ) + + # 2. 系统内置 custom_prompt 作为补充规则,统一包成 片段 + system_rules_str: str = "" + system_rows = [] + if custom_prompt_type in ( + CustomPromptTypeEnum.GENERATE_SQL, + CustomPromptTypeEnum.ANALYSIS, + CustomPromptTypeEnum.PREDICT_DATA, + ): + system_rules_str, system_rows = system_custom_prompt_crud.build_rule_snippets( + _session, + calculate_oid or 1, + custom_prompt_type.value, + calculate_ds_id, + ) + + # 3. 合并结果:xpack 在前,系统规则在后 + pieces: list[str] = [] + if xpack_prompt: + pieces.append(xpack_prompt) + if system_rules_str: + pieces.append(system_rules_str) + self.chat_question.custom_prompt = "\n".join(pieces) if pieces else "" + + # 4. 记录日志,包含 xpack 与系统规则明细,便于排查 + full_message = { + "xpack": xpack_list, + "system": [r.prompt for r in system_rows], + } + self.current_logs[OperationEnum.FILTER_CUSTOM_PROMPT] = end_log( + session=_session, + log=self.current_logs[OperationEnum.FILTER_CUSTOM_PROMPT], + full_message=full_message, + ) def filter_training_template(self, _session: Session, oid: int = None, ds_id: int = None): self.current_logs[OperationEnum.FILTER_SQL_EXAMPLE] = start_log(session=_session, diff --git a/backend/apps/system/api/custom_prompt.py b/backend/apps/system/api/custom_prompt.py new file mode 100644 index 00000000..921a6559 --- /dev/null +++ b/backend/apps/system/api/custom_prompt.py @@ -0,0 +1,113 @@ +""" +自定义提示词 CRUD API:分页、增删改查、导出。 +与前端 /system/custom_prompt/* 约定一致,不依赖 xpack。 +""" +from typing import Optional, List +import io + +from fastapi import APIRouter, Query, Body +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field + +from apps.system.schemas.permission import SqlbotPermission, require_permissions +from apps.system.crud import custom_prompt as crud +from common.core.deps import SessionDep, CurrentUser + +router = APIRouter(tags=["System"], prefix="/system/custom_prompt") + + +class CustomPromptBody(BaseModel): + id: Optional[int] = None + type: Optional[str] = None + name: Optional[str] = None + prompt: Optional[str] = None + specific_ds: Optional[bool] = False + datasource_ids: Optional[List[int]] = Field(default_factory=list) + is_full_template: Optional[bool] = False + + +def _row_to_dict(row) -> dict: + return { + "id": row.id, + "oid": row.oid, + "type": row.type, + "create_time": row.create_time.isoformat() if row.create_time else None, + "name": row.name, + "prompt": row.prompt, + "specific_ds": row.specific_ds, + "datasource_ids": row.datasource_ids or [], + "is_full_template": getattr(row, "is_full_template", False) or False, + } + + +@router.get("/{type}/page/{page_num}/{page_size}", summary="分页查询自定义提示词") +@require_permissions(permission=SqlbotPermission(role=["ws_admin"])) +async def page( + session: SessionDep, + current_user: CurrentUser, + type: str, + page_num: int, + page_size: int, + name: Optional[str] = Query(None, description="名称筛选"), +): + data, total_count = crud.page_list( + session, current_user.oid, type, page_num, page_size, name=name + ) + return {"data": [_row_to_dict(d) for d in data], "total_count": total_count} + + +@router.get("/{type}/export", summary="导出自定义提示词") +@require_permissions(permission=SqlbotPermission(role=["ws_admin"])) +async def export_excel( + session: SessionDep, + current_user: CurrentUser, + type: str, + name: Optional[str] = Query(None), +): + rows = crud.list_for_export(session, current_user.oid, type, name=name) + try: + import pandas as pd + data_list = [ + {"name": r.name, "prompt": r.prompt or "", "specific_ds": r.specific_ds, "datasource_ids": r.datasource_ids or []} + for r in rows + ] + df = pd.DataFrame(data_list) + buf = io.BytesIO() + df.to_excel(buf, index=False) + buf.seek(0) + return StreamingResponse( + buf, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f"attachment; filename=custom_prompt_{type}.xlsx"}, + ) + except Exception: + from fastapi.responses import JSONResponse + return JSONResponse(content={"detail": "Export failed"}, status_code=500) + + +@router.get("/{id}", summary="获取单条自定义提示词") +@require_permissions(permission=SqlbotPermission(role=["ws_admin"])) +async def get_one(session: SessionDep, current_user: CurrentUser, id: str): + from fastapi import HTTPException + try: + id_int = int(id) + except ValueError: + raise HTTPException(status_code=404, detail="Not Found") + row = crud.get_one(session, id_int, current_user.oid) + if not row: + raise HTTPException(status_code=404, detail="Not Found") + return _row_to_dict(row) + + +@router.put("", summary="创建或更新自定义提示词") +@require_permissions(permission=SqlbotPermission(role=["ws_admin"])) +async def create_or_update(session: SessionDep, current_user: CurrentUser, body: CustomPromptBody): + data = body.model_dump() + row = crud.create_or_update(session, current_user.oid, data) + return _row_to_dict(row) + + +@router.delete("", summary="批量删除自定义提示词") +@require_permissions(permission=SqlbotPermission(role=["ws_admin"])) +async def delete_ids(session: SessionDep, current_user: CurrentUser, id_list: List[int] = Body(..., embed=False)): + crud.delete_by_ids(session, current_user.oid, id_list) diff --git a/backend/apps/system/api/statistics.py b/backend/apps/system/api/statistics.py new file mode 100644 index 00000000..7241153c --- /dev/null +++ b/backend/apps/system/api/statistics.py @@ -0,0 +1,219 @@ +"""System statistics API: overview, trend, datasource top, failure analysis, user detailed, records.""" + +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Query + +from apps.system.crud import statistics as stats_crud +from apps.system.schemas.permission import SqlbotPermission, require_permissions +from apps.system.schemas.statistics import ( + StatisticsOverviewResponse, + StatisticsTrendResponse, + StatisticsDatasourceTopResponse, + StatisticsDatasourceDetailedResponse, + StatisticsFailureAnalysisResponse, + StatisticsUserDetailedResponse, + RecordItem, +) +from apps.swagger.i18n import PLACEHOLDER_PREFIX +from common.core.deps import CurrentUser, SessionDep +from common.core.schemas import PaginatedResponse + + +router = APIRouter(tags=["system_statistics"], prefix="/system/statistics") + + +@router.get( + "/overview", + response_model=StatisticsOverviewResponse, + summary=f"{PLACEHOLDER_PREFIX}system_statistics_overview", +) +@require_permissions(permission=SqlbotPermission(role=["admin"])) +async def statistics_overview( + session: SessionDep, + current_user: CurrentUser, + start_time: Optional[datetime] = Query( + None, description=f"{PLACEHOLDER_PREFIX}statistics_start_time" + ), + end_time: Optional[datetime] = Query( + None, description=f"{PLACEHOLDER_PREFIX}statistics_end_time" + ), +) -> StatisticsOverviewResponse: + """管理员统计分析总览:概览指标、按数据源/用户聚合、每日趋势(含 token、耗时分位数)。""" + overview, daily_trend = stats_crud.get_overview( + session, current_user, start_time, end_time + ) + return StatisticsOverviewResponse( + overview=overview, + by_datasource=[], + by_user=[], + daily_trend=daily_trend, + ) + + +@router.get( + "/trend", + response_model=StatisticsTrendResponse, + summary=f"{PLACEHOLDER_PREFIX}system_statistics_trend", +) +@require_permissions(permission=SqlbotPermission(role=["admin"])) +async def statistics_trend( + session: SessionDep, + current_user: CurrentUser, + start_time: Optional[datetime] = Query(None), + end_time: Optional[datetime] = Query(None), +) -> StatisticsTrendResponse: + """多指标每日趋势:总问数、成功/失败、平均耗时、Token 消耗、成功率。""" + trend = stats_crud.get_trend(session, current_user, start_time, end_time) + return StatisticsTrendResponse(trend=trend) + + +@router.get( + "/datasource/top", + response_model=StatisticsDatasourceTopResponse, + summary=f"{PLACEHOLDER_PREFIX}system_statistics_datasource_top", +) +@require_permissions(permission=SqlbotPermission(role=["admin"])) +async def statistics_datasource_top( + session: SessionDep, + current_user: CurrentUser, + start_time: Optional[datetime] = Query(None), + end_time: Optional[datetime] = Query(None), + sort_by: str = Query( + "total_queries", + description="total_queries | failed_queries | avg_duration_seconds | total_tokens", + ), + limit: int = Query(10, ge=1, le=50), +) -> StatisticsDatasourceTopResponse: + """数据源 TOP 排行:按访问次数、失败次数、平均耗时、Token 消耗排序。""" + items = stats_crud.get_datasource_top( + session, current_user, start_time, end_time, sort_by=sort_by, limit=limit + ) + return StatisticsDatasourceTopResponse(items=items) + + +@router.get( + "/failure/analysis", + response_model=StatisticsFailureAnalysisResponse, + summary=f"{PLACEHOLDER_PREFIX}system_statistics_failure_analysis", +) +@require_permissions(permission=SqlbotPermission(role=["admin"])) +async def statistics_failure_analysis( + session: SessionDep, + current_user: CurrentUser, + start_time: Optional[datetime] = Query(None), + end_time: Optional[datetime] = Query(None), +) -> StatisticsFailureAnalysisResponse: + """失败分析:按原因分类、按数据源排行、按小时分布。""" + by_reason, by_datasource, by_hour = stats_crud.get_failure_analysis( + session, current_user, start_time, end_time + ) + return StatisticsFailureAnalysisResponse( + by_reason=by_reason, + by_datasource=by_datasource, + by_hour=by_hour, + ) + + +@router.get( + "/user/detailed", + response_model=StatisticsUserDetailedResponse, + summary=f"{PLACEHOLDER_PREFIX}system_statistics_user_detailed", +) +@require_permissions(permission=SqlbotPermission(role=["admin"])) +async def statistics_user_detailed( + session: SessionDep, + current_user: CurrentUser, + start_time: Optional[datetime] = Query(None), + end_time: Optional[datetime] = Query(None), + page: int = Query(1, ge=1), + size: int = Query(20, ge=1, le=100), + keyword: Optional[str] = Query(None, description="按用户名搜索"), + order_by: str = Query( + "total_queries", + description="total_queries | success_rate | avg_duration_seconds | total_tokens", + ), + desc: bool = Query(True), +) -> StatisticsUserDetailedResponse: + """用户维度详细统计(分页、排序)。""" + items, total = stats_crud.get_user_detailed( + session, current_user, start_time, end_time, + page=page, size=size, keyword=keyword, order_by=order_by, desc=desc, + ) + total_pages = (total + size - 1) // size + return StatisticsUserDetailedResponse( + items=items, + total=total, + page=page, + size=size, + total_pages=total_pages, + ) + + +@router.get( + "/datasource/detailed", + response_model=StatisticsDatasourceDetailedResponse, + summary=f"{PLACEHOLDER_PREFIX}system_statistics_datasource_detailed", +) +@require_permissions(permission=SqlbotPermission(role=["admin"])) +async def statistics_datasource_detailed( + session: SessionDep, + current_user: CurrentUser, + start_time: Optional[datetime] = Query(None), + end_time: Optional[datetime] = Query(None), + page: int = Query(1, ge=1), + size: int = Query(20, ge=1, le=100), + keyword: Optional[str] = Query(None, description="按数据源名称搜索"), + order_by: str = Query( + "total_queries", + description="total_queries | failed_queries | success_rate | avg_duration_seconds | total_tokens", + ), + desc: bool = Query(True), +) -> StatisticsDatasourceDetailedResponse: + """数据源维度详细统计(分页、排序、搜索)。""" + items, total = stats_crud.get_datasource_detailed( + session, current_user, start_time, end_time, + page=page, size=size, keyword=keyword, order_by=order_by, desc=desc, + ) + total_pages = (total + size - 1) // size + return StatisticsDatasourceDetailedResponse( + items=items, + total=total, + page=page, + size=size, + total_pages=total_pages, + ) + + +@router.get( + "/records", + response_model=PaginatedResponse[RecordItem], + summary=f"{PLACEHOLDER_PREFIX}system_statistics_records", +) +@require_permissions(permission=SqlbotPermission(role=["admin"])) +async def statistics_records( + session: SessionDep, + current_user: CurrentUser, + page: int = Query(1, ge=1), + size: int = Query(20, ge=1, le=100), + start_time: Optional[datetime] = Query(None), + end_time: Optional[datetime] = Query(None), + user_id: Optional[int] = Query(None, description="按用户筛选"), + datasource_id: Optional[int] = Query(None, description="按数据源筛选"), + failed_only: bool = Query(False, description="仅失败/异常"), +) -> PaginatedResponse[RecordItem]: + """问数明细(分页),含 error_type、duration_seconds、total_tokens。""" + items, total = stats_crud.get_records( + session, current_user, + start_time, end_time, user_id, datasource_id, failed_only, + page=page, size=size, + ) + total_pages = (total + size - 1) // size + return PaginatedResponse[RecordItem]( + items=items, + total=total, + page=page, + size=size, + total_pages=total_pages, + ) diff --git a/backend/apps/system/api/template_prompt.py b/backend/apps/system/api/template_prompt.py new file mode 100644 index 00000000..e52d66dc --- /dev/null +++ b/backend/apps/system/api/template_prompt.py @@ -0,0 +1,107 @@ +""" +默认提示词与 LLM 优化接口:供设置-自定义提示词页获取默认模板、变量说明及优化提示词。 +""" +from fastapi import APIRouter +from pydantic import BaseModel, Field + +from apps.template.generate_sql.generator import get_sql_template +from apps.template.generate_analysis.generator import get_analysis_template +from apps.template.generate_predict.generator import get_predict_template + +router = APIRouter(tags=["System"], prefix="/system", include_in_schema=False) + +# 三类与 chat_model 中 .format 占位符一致 +DEFAULT_VARIABLES = { + "GENERATE_SQL": [ + {"name": "engine", "description": "数据库引擎及版本"}, + {"name": "schema", "description": "M-Schema 表结构"}, + {"name": "question", "description": "用户问题"}, + {"name": "lang", "description": "回答语言"}, + {"name": "terminologies", "description": "术语表"}, + {"name": "data_training", "description": "数据训练/示例"}, + {"name": "custom_prompt", "description": "自定义提示词(本处注入)"}, + {"name": "process_check", "description": "SQL 生成检查步骤"}, + {"name": "base_sql_rules", "description": "SQL 规则"}, + {"name": "basic_sql_examples", "description": "SQL 示例"}, + {"name": "example_engine", "description": "示例引擎"}, + {"name": "example_answer_1", "description": "示例答案 1"}, + {"name": "example_answer_2", "description": "示例答案 2"}, + {"name": "example_answer_3", "description": "示例答案 3"}, + ], + "ANALYSIS": [ + {"name": "lang", "description": "回答语言"}, + {"name": "terminologies", "description": "术语表"}, + {"name": "custom_prompt", "description": "自定义提示词(本处注入)"}, + ], + "PREDICT_DATA": [ + {"name": "lang", "description": "回答语言"}, + {"name": "custom_prompt", "description": "自定义提示词(本处注入)"}, + ], +} + + +@router.get("/default-prompts", summary="获取三类默认提示词内容与可用变量") +async def get_default_prompts(): + """ + 返回智能问数(GENERATE_SQL)、数据分析(ANALYSIS)、数据预测(PREDICT_DATA) 的默认 system 模板内容及变量列表。 + 便于自定义提示词与默认提示词无缝切换时参考变量。 + """ + sql_tpl = get_sql_template() + analysis_tpl = get_analysis_template() + predict_tpl = get_predict_template() + return { + "GENERATE_SQL": { + "defaultContent": sql_tpl.get("system", ""), + "variables": DEFAULT_VARIABLES["GENERATE_SQL"], + }, + "ANALYSIS": { + "defaultContent": analysis_tpl.get("system", ""), + "variables": DEFAULT_VARIABLES["ANALYSIS"], + }, + "PREDICT_DATA": { + "defaultContent": predict_tpl.get("system", ""), + "variables": DEFAULT_VARIABLES["PREDICT_DATA"], + }, + } + + +class PromptOptimizeRequest(BaseModel): + type: str = Field(..., description="GENERATE_SQL | ANALYSIS | PREDICT_DATA") + prompt: str = Field(..., description="用户输入的提示词内容") + + +class PromptOptimizeResponse(BaseModel): + optimized: str = Field(..., description="优化后的提示词") + + +@router.post("/prompt/optimize", response_model=PromptOptimizeResponse, summary="使用 LLM 优化提示词") +async def optimize_prompt(body: PromptOptimizeRequest): + """ + 使用当前可用 LLM 对用户输入的提示词进行美化/优化,保持与默认提示词变量兼容。 + 返回优化后的纯文本提示词。 + """ + try: + from apps.openapi.llm.my_llm import LLMManager + from langchain_core.messages import SystemMessage, HumanMessage + except Exception: + return PromptOptimizeResponse(optimized=body.prompt) + try: + llm = await LLMManager.get_default_llm() + except Exception: + return PromptOptimizeResponse(optimized=body.prompt) + if llm is None: + return PromptOptimizeResponse(optimized=body.prompt) + system = ( + "你是一个提示词优化助手。用户会给你一段用于「智能问数 / 数据分析 / 数据预测」场景的提示词。" + "请在不改变用户意图的前提下,对提示词进行润色、结构化(如分点、加标题),使其更清晰、易读。" + "若提示词中涉及可替换变量(如 {lang}、{custom_prompt} 等),请保留这些占位符不变。" + "只输出优化后的提示词正文,不要输出解释。" + ) + user_msg = f"请优化以下提示词:\n\n{body.prompt}" + try: + messages = [SystemMessage(content=system), HumanMessage(content=user_msg)] + result = await llm.ainvoke(messages) + content = result.content if hasattr(result, "content") else str(result) + return PromptOptimizeResponse(optimized=(content or body.prompt).strip()) + except Exception: + return PromptOptimizeResponse(optimized=body.prompt) diff --git a/backend/apps/system/crud/custom_prompt.py b/backend/apps/system/crud/custom_prompt.py new file mode 100644 index 00000000..be76ba90 --- /dev/null +++ b/backend/apps/system/crud/custom_prompt.py @@ -0,0 +1,145 @@ +""" +自定义提示词 CRUD,使用表 custom_prompt,不依赖 xpack。 +""" +from datetime import datetime +from typing import List, Optional, Tuple + +from sqlmodel import Session, select, func +from sqlalchemy import and_ + +from apps.system.models.custom_prompt_model import CustomPrompt + + +def page_list( + session: Session, + oid: int, + type_: str, + page_num: int, + page_size: int, + name: Optional[str] = None, +) -> Tuple[List[CustomPrompt], int]: + """分页查询,返回 (data, total_count)。""" + base = select(CustomPrompt).where( + and_(CustomPrompt.oid == oid, CustomPrompt.type == type_) + ) + if name and name.strip(): + base = base.where(CustomPrompt.name.ilike(f"%{name.strip()}%")) + + count_stmt = select(func.count(CustomPrompt.id)).where( + and_(CustomPrompt.oid == oid, CustomPrompt.type == type_) + ) + if name and name.strip(): + count_stmt = count_stmt.where(CustomPrompt.name.ilike(f"%{name.strip()}%")) + total = session.exec(count_stmt).one() or 0 + + offset_val = max(0, (page_num - 1) * page_size) if page_num >= 1 else 0 + stmt = base.order_by(CustomPrompt.create_time.desc()).offset(offset_val).limit(page_size) + rows = list(session.exec(stmt).all()) + return rows, total + + +def get_one(session: Session, id_: int, oid: int) -> Optional[CustomPrompt]: + """按 id 和 oid 取一条。""" + stmt = select(CustomPrompt).where(and_(CustomPrompt.id == id_, CustomPrompt.oid == oid)) + return session.exec(stmt).first() + + +def create_or_update(session: Session, oid: int, data: dict) -> CustomPrompt: + """创建或更新一条,返回当前记录。""" + id_ = data.get("id") + if id_: + row = get_one(session, int(id_), oid) + if row: + row.name = data.get("name") or row.name + row.prompt = data.get("prompt") if data.get("prompt") is not None else row.prompt + row.specific_ds = data.get("specific_ds") if data.get("specific_ds") is not None else row.specific_ds + row.datasource_ids = data.get("datasource_ids") if data.get("datasource_ids") is not None else row.datasource_ids + row.type = data.get("type") or row.type + if "is_full_template" in data: + row.is_full_template = data.get("is_full_template", False) + session.add(row) + session.commit() + session.refresh(row) + return row + row = CustomPrompt( + oid=oid, + type=data.get("type"), + name=data.get("name"), + prompt=data.get("prompt"), + specific_ds=data.get("specific_ds", False), + datasource_ids=data.get("datasource_ids") or [], + is_full_template=data.get("is_full_template", False), + create_time=datetime.utcnow(), + ) + session.add(row) + session.commit() + session.refresh(row) + return row + + +def delete_by_ids(session: Session, oid: int, ids: List[int]) -> None: + """按 id 列表删除,仅允许删除本 oid 下的记录。""" + stmt = select(CustomPrompt).where( + and_(CustomPrompt.oid == oid, CustomPrompt.id.in_(ids)) + ) + for row in session.exec(stmt).all(): + session.delete(row) + session.commit() + + +def list_for_export(session: Session, oid: int, type_: str, name: Optional[str] = None) -> List[CustomPrompt]: + """导出用:按 type、可选 name 筛选。""" + stmt = select(CustomPrompt).where( + and_(CustomPrompt.oid == oid, CustomPrompt.type == type_) + ) + if name and name.strip(): + stmt = stmt.where(CustomPrompt.name.ilike(f"%{name.strip()}%")) + stmt = stmt.order_by(CustomPrompt.create_time.desc()) + return list(session.exec(stmt).all()) + + +def build_rule_snippets( + session: Session, + oid: int, + type_: str, + ds_id: Optional[int] = None, +) -> Tuple[str, List[CustomPrompt]]: + """ + 根据 oid / type / ds_id 汇总适用的自定义提示词,并拼接为 片段字符串。 + - specific_ds = False: 对工作空间下所有数据源生效 + - specific_ds = True: 仅当 ds_id 在 datasource_ids 中时生效 + 返回 (规则字符串, 参与拼接的记录列表) + """ + stmt = ( + select(CustomPrompt) + .where( + and_( + CustomPrompt.oid == oid, + CustomPrompt.type == type_, + ) + ) + .order_by(CustomPrompt.create_time.desc()) + ) + rows: List[CustomPrompt] = [] + for row in session.exec(stmt).all(): + # 过滤掉完整模板类型:在新逻辑中不再使用 + if getattr(row, "is_full_template", False): + continue + # 没有限定数据源,或者当前数据源命中列表时生效 + if not row.specific_ds: + rows.append(row) + elif ds_id is not None and row.datasource_ids and ds_id in row.datasource_ids: + rows.append(row) + + if not rows: + return "", [] + + rule_parts: List[str] = [] + for r in rows: + if not r.prompt: + continue + # 将每条提示词包裹成一个 块,避免破坏主模板结构 + rule_parts.append(f"{r.prompt}") + + rules_str = "\n".join(rule_parts) + "\n" if rule_parts else "" + return rules_str, rows diff --git a/backend/apps/system/crud/statistics.py b/backend/apps/system/crud/statistics.py new file mode 100644 index 00000000..7c893f98 --- /dev/null +++ b/backend/apps/system/crud/statistics.py @@ -0,0 +1,724 @@ +"""Statistics aggregation: overview, trend, top, failure analysis, user detailed, records.""" + +import json +from datetime import datetime +from typing import Any, List, Optional, Tuple + +from sqlalchemy import Integer, Text, and_, case, cast, func, or_, select +from sqlmodel import Session + +from apps.chat.models.chat_model import Chat, ChatRecord, ChatLog, OperationEnum +from apps.datasource.models.datasource import CoreDatasource +from apps.system.models.user import UserModel +from apps.system.schemas.statistics import ( + DatasourceDetailedItem, + OverviewMetrics, + DailyTrendPoint, + TrendPoint, + DatasourceTopItem, + FailureReasonItem, + FailureByDatasourceItem, + FailureByHourItem, + UserDetailedItem, + RecordItem, +) +from common.core.deps import CurrentUser + + +def _oid(current_user: CurrentUser) -> int: + return current_user.oid if current_user.oid is not None else 1 + + +def build_common_filters( + current_user: CurrentUser, + start_time: Optional[datetime], + end_time: Optional[datetime], +): + filters = [Chat.oid == _oid(current_user)] + if start_time is not None: + filters.append(ChatRecord.create_time >= start_time) + if end_time is not None: + filters.append(ChatRecord.create_time <= end_time) + return filters + + +success_cond = and_(ChatRecord.finish.is_(True), ChatRecord.error.is_(None)) +failed_cond = or_(ChatRecord.finish.is_(False), ChatRecord.error.is_not(None)) + + +def parse_error_type(error: Optional[str]) -> str: + if not error or not error.strip(): + return "unknown" + s = error.strip() + if s.startswith("{"): + try: + obj = json.loads(s) + if isinstance(obj, dict) and "type" in obj: + return str(obj["type"]) or "unknown" + except Exception: + pass + return "unknown" + + +def aggregate_tokens_for_record_ids(session: Session, record_ids: List[int]) -> dict: + if not record_ids: + return {} + log_stmt = select(ChatLog.pid, ChatLog.token_usage).where( + and_( + ChatLog.pid.in_(record_ids), + ChatLog.local_operation.is_(False), + ChatLog.operate != OperationEnum.GENERATE_RECOMMENDED_QUESTIONS, + ChatLog.token_usage.is_not(None), + ) + ) + rows = session.exec(log_stmt).all() + out = {} + for pid, token_usage in rows: + if not pid or token_usage is None: + continue + add = 0 + if isinstance(token_usage, dict) and token_usage and "total_tokens" in token_usage: + v = token_usage["total_tokens"] + if isinstance(v, (int, float)): + add = int(v) + elif isinstance(token_usage, (int, float)): + add = int(token_usage) + if add > 0: + out[pid] = out.get(pid, 0) + add + return out + + +def _percentile(sorted_values: List[float], p: float) -> Optional[float]: + if not sorted_values: + return None + k = (len(sorted_values) - 1) * p / 100.0 + f = int(k) + c = f + 1 if f + 1 < len(sorted_values) else f + return sorted_values[f] + (k - f) * (sorted_values[c] - sorted_values[f]) if c > f else sorted_values[f] + + +def _scalar(row: Any) -> int: + """Take first column from Row or return scalar as int.""" + if row is None: + return 0 + if hasattr(row, "__getitem__") and not isinstance(row, (str, bytes)): + return int(row[0] or 0) + return int(row) + +def get_total_users_and_datasources(session: Session, oid: int) -> Tuple[int, int]: + r1 = session.exec(select(func.count(UserModel.id)).where(UserModel.oid == oid)).one() + r2 = session.exec(select(func.count(CoreDatasource.id)).where(CoreDatasource.oid == oid)).one() + return _scalar(r1), _scalar(r2) + + +def _build_filtered_records_cte( + current_user: CurrentUser, + start_time: Optional[datetime], + end_time: Optional[datetime], +): + filters = build_common_filters(current_user, start_time, end_time) + return ( + select( + ChatRecord.id.label("record_id"), + ChatRecord.chat_id.label("chat_id"), + ChatRecord.create_by.label("user_id"), + ChatRecord.datasource.label("datasource_id"), + ChatRecord.create_time.label("create_time"), + ChatRecord.finish_time.label("finish_time"), + ChatRecord.finish.label("finish"), + ChatRecord.error.label("error"), + ) + .join(Chat, ChatRecord.chat_id == Chat.id) + .where(*filters) + .cte("filtered_records") + ) + + +def _token_value_expr(): + return case( + ( + func.jsonb_typeof(ChatLog.token_usage) == "object", + cast(func.coalesce(ChatLog.token_usage.op("->>")("total_tokens"), "0"), Integer), + ), + ( + func.jsonb_typeof(ChatLog.token_usage) == "number", + cast(cast(ChatLog.token_usage, Text), Integer), + ), + else_=0, + ) + + +def _build_tokens_by_record_cte(records_cte): + token_value = _token_value_expr() + return ( + select( + ChatLog.pid.label("record_id"), + func.coalesce(func.sum(token_value), 0).label("total_tokens"), + ) + .select_from(ChatLog) + .join(records_cte, ChatLog.pid == records_cte.c.record_id) + .where( + ChatLog.local_operation.is_(False), + ChatLog.operate != OperationEnum.GENERATE_RECOMMENDED_QUESTIONS, + ChatLog.token_usage.is_not(None), + ) + .group_by(ChatLog.pid) + .cte("tokens_by_record") + ) + + +def _duration_expr(records_cte): + return func.extract("epoch", records_cte.c.finish_time - records_cte.c.create_time) + + +def _build_order_expr(subquery, order_by: str, default_field: str, desc: bool, tie_breaker: str): + # 不能用 or 判断:SQLAlchemy 列对象在布尔上下文中会抛 TypeError + column = getattr(subquery.c, order_by, None) + if column is None: + column = getattr(subquery.c, default_field) + tie_breaker_column = getattr(subquery.c, tie_breaker) + return [column.desc() if desc else column.asc(), tie_breaker_column.asc()] + + +def _row_mapping(row: Any): + return row._mapping if hasattr(row, "_mapping") else row + + +def get_overview( + session: Session, + current_user: CurrentUser, + start_time: Optional[datetime], + end_time: Optional[datetime], +) -> Tuple[OverviewMetrics, List[DailyTrendPoint]]: + oid = _oid(current_user) + records_cte = _build_filtered_records_cte(current_user, start_time, end_time) + tokens_cte = _build_tokens_by_record_cte(records_cte) + duration_expr = _duration_expr(records_cte) + + # Base counts and avg duration + stmt = ( + select( + func.count(records_cte.c.record_id), + func.coalesce( + func.sum( + case( + (and_(records_cte.c.finish.is_(True), records_cte.c.error.is_(None)), 1), + else_=0, + ) + ), + 0, + ), + func.coalesce( + func.sum( + case( + (or_(records_cte.c.finish.is_(False), records_cte.c.error.is_not(None)), 1), + else_=0, + ) + ), + 0, + ), + func.count(func.distinct(records_cte.c.user_id)), + func.count(func.distinct(records_cte.c.datasource_id)), + func.count(func.distinct(records_cte.c.chat_id)), + func.avg(duration_expr), + ) + .select_from(records_cte) + ) + row = session.exec(stmt).one() + ( + total_queries, + success_queries, + failed_queries, + active_users, + active_datasources, + active_chats, + avg_duration_seconds, + ) = row + total_queries = int(total_queries or 0) + success_queries = int(success_queries or 0) + failed_queries = int(failed_queries or 0) + active_users = int(active_users or 0) + active_datasources = int(active_datasources or 0) + active_chats = int(active_chats or 0) + success_rate = float(success_queries) / float(total_queries) if total_queries else 0.0 + avg_dur = float(avg_duration_seconds) if avg_duration_seconds is not None else None + + total_users, total_datasources = get_total_users_and_datasources(session, oid) + + total_tokens = _scalar( + session.exec(select(func.coalesce(func.sum(tokens_cte.c.total_tokens), 0))).one() + ) + + # Duration percentiles: fetch durations for finished records + dur_stmt = ( + select(duration_expr.label("d")) + .select_from(records_cte) + .where(records_cte.c.finish_time.is_not(None), records_cte.c.create_time.is_not(None)) + ) + dur_rows = session.exec(dur_stmt).all() + durations = [float(r[0]) for r in dur_rows if r[0] is not None] + durations.sort() + p50 = _percentile(durations, 50) + p90 = _percentile(durations, 90) + p99 = _percentile(durations, 99) + + overview = OverviewMetrics( + total_queries=total_queries, + success_queries=success_queries, + failed_queries=failed_queries, + success_rate=success_rate, + active_users=active_users, + active_datasources=active_datasources, + active_chats=active_chats, + avg_duration_seconds=avg_dur, + total_users=total_users, + total_datasources=total_datasources, + total_tokens=total_tokens, + duration_p50_seconds=p50, + duration_p90_seconds=p90, + duration_p99_seconds=p99, + ) + + # Daily trend with avg_duration, total_tokens, success_rate + day_expr = func.date_trunc("day", records_cte.c.create_time).label("date") + trend_stmt = ( + select( + day_expr, + func.count(records_cte.c.record_id).label("total_queries"), + func.coalesce( + func.sum( + case( + (and_(records_cte.c.finish.is_(True), records_cte.c.error.is_(None)), 1), + else_=0, + ) + ), + 0, + ).label("success_queries"), + func.coalesce( + func.sum( + case( + (or_(records_cte.c.finish.is_(False), records_cte.c.error.is_not(None)), 1), + else_=0, + ) + ), + 0, + ).label("failed_queries"), + func.avg(duration_expr).label("avg_duration"), + func.coalesce(func.sum(func.coalesce(tokens_cte.c.total_tokens, 0)), 0).label("total_tokens"), + ) + .select_from(records_cte) + .join(tokens_cte, tokens_cte.c.record_id == records_cte.c.record_id, isouter=True) + .group_by(day_expr) + .order_by(day_expr.asc()) + ) + trend_rows = session.exec(trend_stmt).all() + daily_trend = [] + for r in trend_rows: + date_val, t, s, f, avg_d, total_tokens_day = r + t, s, f = int(t or 0), int(s or 0), int(f or 0) + sr = float(s) / float(t) if t else 0.0 + daily_trend.append( + DailyTrendPoint( + date=date_val, + total_queries=t, + success_queries=s, + failed_queries=f, + avg_duration_seconds=float(avg_d) if avg_d is not None else None, + total_tokens=int(total_tokens_day or 0), + success_rate=sr, + ) + ) + + return overview, daily_trend + + +def get_trend( + session: Session, + current_user: CurrentUser, + start_time: Optional[datetime], + end_time: Optional[datetime], +) -> List[TrendPoint]: + _, daily_trend = get_overview(session, current_user, start_time, end_time) + return [ + TrendPoint( + date=p.date, + total_queries=p.total_queries, + success_queries=p.success_queries, + failed_queries=p.failed_queries, + avg_duration_seconds=p.avg_duration_seconds, + total_tokens=p.total_tokens, + success_rate=p.success_rate, + ) + for p in daily_trend + ] + + +def get_datasource_top( + session: Session, + current_user: CurrentUser, + start_time: Optional[datetime], + end_time: Optional[datetime], + sort_by: str = "total_queries", + limit: int = 10, +) -> List[DatasourceTopItem]: + items, _ = get_datasource_detailed( + session, + current_user, + start_time, + end_time, + page=1, + size=limit, + keyword=None, + order_by=sort_by, + desc=True, + ) + top_items = [] + for item in items: + if sort_by == "failed_queries": + value = item.failed_queries + elif sort_by == "avg_duration_seconds": + value = int(item.avg_duration_seconds or 0) + elif sort_by == "total_tokens": + value = item.total_tokens + else: + value = item.total_queries + top_items.append( + DatasourceTopItem( + datasource_id=item.datasource_id, + datasource_name=item.datasource_name, + value=value, + sort_key=sort_by, + ) + ) + return top_items + + +def get_failure_analysis( + session: Session, + current_user: CurrentUser, + start_time: Optional[datetime], + end_time: Optional[datetime], +) -> Tuple[List[FailureReasonItem], List[FailureByDatasourceItem], List[FailureByHourItem]]: + filters = build_common_filters(current_user, start_time, end_time) + failed_filters = filters + [failed_cond] + + # By reason: group by parsed error_type + stmt = ( + select(ChatRecord.id, ChatRecord.error) + .join(Chat, ChatRecord.chat_id == Chat.id) + .where(*failed_filters) + ) + rows = session.exec(stmt).all() + reason_counts: dict = {} + reason_sample: dict = {} + for rid, err in rows: + et = parse_error_type(err) + reason_counts[et] = reason_counts.get(et, 0) + 1 + if et not in reason_sample and err: + reason_sample[et] = (err[:200] + "…") if len(err or "") > 200 else (err or "") + + by_reason = [ + FailureReasonItem(error_type=k, count=v, sample_message=reason_sample.get(k)) + for k, v in sorted(reason_counts.items(), key=lambda x: -x[1]) + ] + + # By datasource + ds_fail_stmt = ( + select( + ChatRecord.datasource.label("datasource_id"), + CoreDatasource.name.label("datasource_name"), + func.count(ChatRecord.id).label("failed_count"), + ) + .join(Chat, ChatRecord.chat_id == Chat.id) + .join(CoreDatasource, ChatRecord.datasource == CoreDatasource.id, isouter=True) + .where(*failed_filters) + .group_by(ChatRecord.datasource, CoreDatasource.name) + .order_by(func.count(ChatRecord.id).desc()) + ) + ds_fail_rows = session.exec(ds_fail_stmt).all() + by_datasource = [ + FailureByDatasourceItem( + datasource_id=r[0], + datasource_name=r[1], + failed_count=int(r[2] or 0), + ) + for r in ds_fail_rows + ] + + # By hour + hour_expr = func.extract("hour", ChatRecord.create_time).label("hour") + hour_stmt = ( + select(hour_expr, func.count(ChatRecord.id).label("failed_count")) + .join(Chat, ChatRecord.chat_id == Chat.id) + .where(*failed_filters) + .group_by(hour_expr) + .order_by(hour_expr.asc()) + ) + hour_rows = session.exec(hour_stmt).all() + by_hour = [FailureByHourItem(hour=int(r[0] or 0), failed_count=int(r[1] or 0)) for r in hour_rows] + + return by_reason, by_datasource, by_hour + + +def get_user_detailed( + session: Session, + current_user: CurrentUser, + start_time: Optional[datetime], + end_time: Optional[datetime], + page: int = 1, + size: int = 20, + keyword: Optional[str] = None, + order_by: str = "total_queries", + desc: bool = True, +) -> Tuple[List[UserDetailedItem], int]: + records_cte = _build_filtered_records_cte(current_user, start_time, end_time) + tokens_cte = _build_tokens_by_record_cte(records_cte) + duration_expr = _duration_expr(records_cte) + + aggregated_stmt = ( + select( + records_cte.c.user_id.label("user_id"), + UserModel.name.label("user_name"), + func.count(records_cte.c.record_id).label("total_queries"), + func.coalesce( + func.sum( + case( + (and_(records_cte.c.finish.is_(True), records_cte.c.error.is_(None)), 1), + else_=0, + ) + ), + 0, + ).label("success_queries"), + func.coalesce( + func.sum( + case( + (or_(records_cte.c.finish.is_(False), records_cte.c.error.is_not(None)), 1), + else_=0, + ) + ), + 0, + ).label("failed_queries"), + func.count(func.distinct(records_cte.c.datasource_id)).label("active_datasources"), + func.avg(duration_expr).label("avg_duration_seconds"), + func.coalesce(func.sum(func.coalesce(tokens_cte.c.total_tokens, 0)), 0).label("total_tokens"), + case( + (func.count(records_cte.c.record_id) > 0, + func.coalesce( + func.sum( + case( + (and_(records_cte.c.finish.is_(True), records_cte.c.error.is_(None)), 1), + else_=0, + ) + ), + 0, + ) * 1.0 / func.count(records_cte.c.record_id)), + else_=0.0, + ).label("success_rate"), + ) + .select_from(records_cte) + .join(UserModel, records_cte.c.user_id == UserModel.id, isouter=True) + .join(tokens_cte, tokens_cte.c.record_id == records_cte.c.record_id, isouter=True) + ) + if keyword: + aggregated_stmt = aggregated_stmt.where(UserModel.name.ilike(f"%{keyword.strip()}%")) + aggregated = aggregated_stmt.group_by(records_cte.c.user_id, UserModel.name).subquery() + + total = _scalar(session.exec(select(func.count()).select_from(aggregated)).one()) + order_expr = _build_order_expr(aggregated, order_by, "total_queries", desc, "user_name") + rows = session.exec( + select(aggregated) + .order_by(*order_expr) + .offset((page - 1) * size) + .limit(size) + ).all() + + items = [] + for row in rows: + mapping = _row_mapping(row) + total_queries = int(mapping["total_queries"] or 0) + success_queries = int(mapping["success_queries"] or 0) + items.append( + UserDetailedItem( + user_id=mapping["user_id"], + user_name=mapping["user_name"], + total_queries=total_queries, + success_queries=success_queries, + failed_queries=int(mapping["failed_queries"] or 0), + success_rate=float(success_queries) / float(total_queries) if total_queries else 0.0, + active_datasources=int(mapping["active_datasources"] or 0), + avg_duration_seconds=float(mapping["avg_duration_seconds"]) if mapping["avg_duration_seconds"] is not None else None, + total_tokens=int(mapping["total_tokens"] or 0), + ) + ) + return items, total + + +def get_datasource_detailed( + session: Session, + current_user: CurrentUser, + start_time: Optional[datetime], + end_time: Optional[datetime], + page: int = 1, + size: int = 20, + keyword: Optional[str] = None, + order_by: str = "total_queries", + desc: bool = True, +) -> Tuple[List[DatasourceDetailedItem], int]: + records_cte = _build_filtered_records_cte(current_user, start_time, end_time) + tokens_cte = _build_tokens_by_record_cte(records_cte) + duration_expr = _duration_expr(records_cte) + + aggregated_stmt = ( + select( + records_cte.c.datasource_id.label("datasource_id"), + CoreDatasource.name.label("datasource_name"), + func.count(records_cte.c.record_id).label("total_queries"), + func.coalesce( + func.sum( + case( + (and_(records_cte.c.finish.is_(True), records_cte.c.error.is_(None)), 1), + else_=0, + ) + ), + 0, + ).label("success_queries"), + func.coalesce( + func.sum( + case( + (or_(records_cte.c.finish.is_(False), records_cte.c.error.is_not(None)), 1), + else_=0, + ) + ), + 0, + ).label("failed_queries"), + func.count(func.distinct(records_cte.c.user_id)).label("active_users"), + func.avg(duration_expr).label("avg_duration_seconds"), + func.coalesce(func.sum(func.coalesce(tokens_cte.c.total_tokens, 0)), 0).label("total_tokens"), + case( + (func.count(records_cte.c.record_id) > 0, + func.coalesce( + func.sum( + case( + (and_(records_cte.c.finish.is_(True), records_cte.c.error.is_(None)), 1), + else_=0, + ) + ), + 0, + ) * 1.0 / func.count(records_cte.c.record_id)), + else_=0.0, + ).label("success_rate"), + ) + .select_from(records_cte) + .join(CoreDatasource, records_cte.c.datasource_id == CoreDatasource.id, isouter=True) + .join(tokens_cte, tokens_cte.c.record_id == records_cte.c.record_id, isouter=True) + ) + if keyword: + aggregated_stmt = aggregated_stmt.where(CoreDatasource.name.ilike(f"%{keyword.strip()}%")) + aggregated = aggregated_stmt.group_by(records_cte.c.datasource_id, CoreDatasource.name).subquery() + + total = _scalar(session.exec(select(func.count()).select_from(aggregated)).one()) + order_expr = _build_order_expr(aggregated, order_by, "total_queries", desc, "datasource_name") + rows = session.exec( + select(aggregated) + .order_by(*order_expr) + .offset((page - 1) * size) + .limit(size) + ).all() + + items = [] + for row in rows: + mapping = _row_mapping(row) + total_queries = int(mapping["total_queries"] or 0) + success_queries = int(mapping["success_queries"] or 0) + items.append( + DatasourceDetailedItem( + datasource_id=mapping["datasource_id"], + datasource_name=mapping["datasource_name"], + total_queries=total_queries, + success_queries=success_queries, + failed_queries=int(mapping["failed_queries"] or 0), + success_rate=float(success_queries) / float(total_queries) if total_queries else 0.0, + active_users=int(mapping["active_users"] or 0), + avg_duration_seconds=float(mapping["avg_duration_seconds"]) if mapping["avg_duration_seconds"] is not None else None, + total_tokens=int(mapping["total_tokens"] or 0), + ) + ) + return items, total + + +def get_records( + session: Session, + current_user: CurrentUser, + start_time: Optional[datetime], + end_time: Optional[datetime], + user_id: Optional[int], + datasource_id: Optional[int], + failed_only: bool, + page: int, + size: int, +) -> Tuple[List[RecordItem], int]: + filters = build_common_filters(current_user, start_time, end_time) + if user_id is not None: + filters.append(ChatRecord.create_by == user_id) + if datasource_id is not None: + filters.append(ChatRecord.datasource == datasource_id) + if failed_only: + filters.append(failed_cond) + + base = ( + select( + ChatRecord.id, + ChatRecord.chat_id, + ChatRecord.create_time, + ChatRecord.create_by, + UserModel.name.label("user_name"), + ChatRecord.datasource.label("datasource_id"), + CoreDatasource.name.label("datasource_name"), + ChatRecord.question, + ChatRecord.finish, + ChatRecord.error, + ChatRecord.finish_time, + ) + .join(Chat, ChatRecord.chat_id == Chat.id) + .join(UserModel, ChatRecord.create_by == UserModel.id, isouter=True) + .join(CoreDatasource, ChatRecord.datasource == CoreDatasource.id, isouter=True) + .where(*filters) + .order_by(ChatRecord.create_time.desc()) + ) + count_stmt = select(func.count(ChatRecord.id)).select_from(ChatRecord).join(Chat, ChatRecord.chat_id == Chat.id).where(*filters) + total = _scalar(session.exec(count_stmt).one()) + page_rows = list( + session.exec(base.offset((page - 1) * size).limit(size)).all() + ) + record_ids = [r[0] for r in page_rows] + token_map = aggregate_tokens_for_record_ids(session, record_ids) + + items = [] + for r in page_rows: + rid, chat_id, create_time, create_by, user_name, ds_id, ds_name, question, finish, error, finish_time = r + duration_seconds = None + if create_time and finish_time: + try: + duration_seconds = (finish_time - create_time).total_seconds() + except Exception: + pass + q = question or "" + e = error or "" + items.append( + RecordItem( + id=rid, + chat_id=chat_id, + create_time=create_time, + create_by=create_by, + user_name=user_name, + datasource_id=ds_id, + datasource_name=ds_name, + question=(q[:80] + "…") if len(q) > 80 else (q or None), + finish=bool(finish), + error=(e[:200] + "…") if len(e) > 200 else (e or None), + error_type=parse_error_type(error), + duration_seconds=duration_seconds, + total_tokens=token_map.get(rid), + ) + ) + return items, total diff --git a/backend/apps/system/models/custom_prompt_model.py b/backend/apps/system/models/custom_prompt_model.py new file mode 100644 index 00000000..5c72baf3 --- /dev/null +++ b/backend/apps/system/models/custom_prompt_model.py @@ -0,0 +1,31 @@ +""" +自定义提示词表模型,与 alembic 046_add_custom_prompt 一致。 +供设置-自定义提示词页 CRUD 使用,不依赖 xpack。 +""" +from datetime import datetime +from enum import Enum +from typing import Optional, List + +from sqlalchemy import Column, BigInteger, DateTime, Text, Boolean, String +from sqlalchemy.dialects.postgresql import JSONB +from sqlmodel import SQLModel, Field + + +class CustomPromptTypeEnum(str, Enum): + GENERATE_SQL = "GENERATE_SQL" + ANALYSIS = "ANALYSIS" + PREDICT_DATA = "PREDICT_DATA" + + +class CustomPrompt(SQLModel, table=True): + __tablename__ = "custom_prompt" + __table_args__ = {"extend_existing": True} + id: Optional[int] = Field(default=None, sa_column=Column(BigInteger(), primary_key=True, autoincrement=True)) + oid: Optional[int] = Field(default=None, sa_column=Column(BigInteger(), nullable=True)) + type: Optional[str] = Field(default=None, sa_column=Column(String(20), nullable=True)) + create_time: Optional[datetime] = Field(default=None, sa_column=Column(DateTime(), nullable=True)) + name: Optional[str] = Field(default=None, max_length=255, nullable=True) + prompt: Optional[str] = Field(default=None, sa_column=Column(Text(), nullable=True)) + specific_ds: Optional[bool] = Field(default=None, sa_column=Column(Boolean(), nullable=True)) + datasource_ids: Optional[List[int]] = Field(default=None, sa_column=Column(JSONB(), nullable=True)) + is_full_template: Optional[bool] = Field(default=False, sa_column=Column(Boolean(), nullable=True)) diff --git a/backend/apps/system/schemas/statistics.py b/backend/apps/system/schemas/statistics.py new file mode 100644 index 00000000..954ca8a9 --- /dev/null +++ b/backend/apps/system/schemas/statistics.py @@ -0,0 +1,175 @@ +"""Statistics API schemas: overview, trend, top, failure analysis, user detailed, records.""" + +from datetime import datetime +from typing import List, Optional + +from pydantic import BaseModel + + +# ----- Overview ----- +class OverviewMetrics(BaseModel): + total_queries: int = 0 + success_queries: int = 0 + failed_queries: int = 0 + success_rate: float = 0.0 + active_users: int = 0 + active_datasources: int = 0 + active_chats: int = 0 + avg_duration_seconds: Optional[float] = None + total_users: int = 0 + total_datasources: int = 0 + total_tokens: int = 0 + duration_p50_seconds: Optional[float] = None + duration_p90_seconds: Optional[float] = None + duration_p99_seconds: Optional[float] = None + + +class DatasourceStats(BaseModel): + datasource_id: Optional[int] = None + datasource_name: Optional[str] = None + total_queries: int = 0 + success_queries: int = 0 + failed_queries: int = 0 + success_rate: float = 0.0 + active_users: int = 0 + avg_duration_seconds: Optional[float] = None + total_tokens: int = 0 + + +class UserStats(BaseModel): + user_id: Optional[int] = None + user_name: Optional[str] = None + total_queries: int = 0 + success_queries: int = 0 + failed_queries: int = 0 + success_rate: float = 0.0 + active_datasources: int = 0 + avg_duration_seconds: Optional[float] = None + total_tokens: int = 0 + + +class DailyTrendPoint(BaseModel): + date: datetime + total_queries: int + success_queries: int + failed_queries: int + avg_duration_seconds: Optional[float] = None + total_tokens: int = 0 + success_rate: float = 0.0 + + +class StatisticsOverviewResponse(BaseModel): + overview: OverviewMetrics + by_datasource: List[DatasourceStats] + by_user: List[UserStats] + daily_trend: List[DailyTrendPoint] + + +# ----- Trend (standalone) ----- +class TrendPoint(BaseModel): + date: datetime + total_queries: int + success_queries: int + failed_queries: int + avg_duration_seconds: Optional[float] = None + total_tokens: int = 0 + success_rate: float = 0.0 + + +class StatisticsTrendResponse(BaseModel): + trend: List[TrendPoint] + + +# ----- Datasource TOP ----- +class DatasourceTopItem(BaseModel): + datasource_id: Optional[int] = None + datasource_name: Optional[str] = None + value: int = 0 + sort_key: str = "total_queries" + + +class StatisticsDatasourceTopResponse(BaseModel): + items: List[DatasourceTopItem] + + +# ----- Datasource detailed ----- +class DatasourceDetailedItem(BaseModel): + datasource_id: Optional[int] = None + datasource_name: Optional[str] = None + total_queries: int = 0 + success_queries: int = 0 + failed_queries: int = 0 + success_rate: float = 0.0 + active_users: int = 0 + avg_duration_seconds: Optional[float] = None + total_tokens: int = 0 + + +class StatisticsDatasourceDetailedResponse(BaseModel): + items: List[DatasourceDetailedItem] + total: int + page: int + size: int + total_pages: int + + +# ----- Failure analysis ----- +class FailureReasonItem(BaseModel): + error_type: str + count: int + sample_message: Optional[str] = None + + +class FailureByDatasourceItem(BaseModel): + datasource_id: Optional[int] = None + datasource_name: Optional[str] = None + failed_count: int = 0 + + +class FailureByHourItem(BaseModel): + hour: int + failed_count: int + + +class StatisticsFailureAnalysisResponse(BaseModel): + by_reason: List[FailureReasonItem] + by_datasource: List[FailureByDatasourceItem] + by_hour: List[FailureByHourItem] + + +# ----- User detailed ----- +class UserDetailedItem(BaseModel): + user_id: Optional[int] = None + user_name: Optional[str] = None + total_queries: int = 0 + success_queries: int = 0 + failed_queries: int = 0 + success_rate: float = 0.0 + active_datasources: int = 0 + avg_duration_seconds: Optional[float] = None + total_tokens: int = 0 + + +class StatisticsUserDetailedResponse(BaseModel): + items: List[UserDetailedItem] + total: int + page: int + size: int + total_pages: int + + +# ----- Records (enhanced) ----- +class RecordItem(BaseModel): + id: Optional[int] = None + chat_id: Optional[int] = None + create_time: Optional[datetime] = None + create_by: Optional[int] = None + user_name: Optional[str] = None + datasource_id: Optional[int] = None + datasource_name: Optional[str] = None + question: Optional[str] = None + finish: bool = False + error: Optional[str] = None + error_type: Optional[str] = None + duration_seconds: Optional[float] = None + total_tokens: Optional[int] = None diff --git a/backend/templates/template.yaml b/backend/templates/template.yaml index 6118a9b1..667bc7db 100644 --- a/backend/templates/template.yaml +++ b/backend/templates/template.yaml @@ -151,6 +151,7 @@ template: 提问中如果有涉及数据源名称或数据源描述的内容,则忽略数据源的信息,直接根据剩余内容生成SQL {base_sql_rules} + {custom_prompt} 如果生成SQL的字段内有时间格式的字段: - 若提问中没有指定查询顺序,则默认按时间升序排序 @@ -328,7 +329,6 @@ template: {terminologies} {data_training} - {custom_prompt} ### 响应, 请根据上述要求直接返回JSON结果: ```json @@ -574,12 +574,12 @@ template: 避免重复。如果输出内容重复,请立即停止并给出唯一的答案。 + {custom_prompt} {terminologies} - {custom_prompt} user: | {fields} @@ -616,8 +616,8 @@ template: 预测的数据不需要返回用户提供的原有数据,请直接返回你预测的部份 + {custom_prompt} - {custom_prompt} ### 响应, 请根据上述要求直接返回JSON结果: ```json diff --git a/build/README.md b/build/README.md index 075571e9..96778ec2 100644 --- a/build/README.md +++ b/build/README.md @@ -22,6 +22,7 @@ | `build-quick-arm64.sh` | 仅 ARM64 快速构建 | 本机 ARM64 开发 | | `build-multiplatform.sh` | 多平台基础镜像(x86 + arm64) | 需同时产出多架构镜像 | | `build-multiplatform-optimized.sh` | 多平台构建(优化缓存) | 多架构 + 利用本地缓存 | +| `build-k8s.sh` | K8s 构建(构建 → tag → 可选推送 → 可选 Helm 部署) | 需产出镜像并推送到仓库或在 K8s 部署时使用 | 根目录的 `quick.sh` 为单脚本一键构建当前平台最终镜像(不区分基础/快速,适合快速试跑)。 @@ -42,6 +43,7 @@ cd build - `quick-arm64` — 执行 `build-quick-arm64.sh` - `multiplatform` — 执行 `build-multiplatform.sh` - `multiplatform-optimized` — 执行 `build-multiplatform-optimized.sh` +- `k8s [--push] [--helm-install]` — 执行 `build-k8s.sh`,可选推送镜像、可选执行 Helm 安装/升级 - 不传参数 — 打印上述用法说明 ### 直接调用具体脚本 @@ -90,3 +92,40 @@ docker build -t zf-sqlbot:latest -f build/Dockerfile.update . - 多平台脚本依赖 `docker buildx`,未安装时需先安装对应插件。 更多安装与部署说明见项目根目录 [README.md](../README.md) 与 [docs/GUIDE.md](../docs/GUIDE.md)。 + +## K8s 构建与部署 + +`build-k8s.sh` 在现有构建流程上增加“打 tag → 可选推送 → 可选 Helm 安装/升级”,便于在 Kubernetes 集群部署。 + +### 用法 + +```bash +cd build +./build.sh k8s # 仅构建并打本地 tag +./build.sh k8s --push # 构建并推送到镜像仓库 +./build.sh k8s --push --helm-install # 推送后在当前 K8s 执行 helm upgrade --install +``` + +或直接调用: + +```bash +./build-k8s.sh +./build-k8s.sh --push +./build-k8s.sh --push --helm-install +``` + +### 环境变量 + +| 变量 | 说明 | 默认 | +|------|------|------| +| `REGISTRY` | 镜像仓库地址(如 `registry.cn-qingdao.aliyuncs.com`) | 空(仅本地 tag 时不需) | +| `IMAGE_NAME` | 镜像名 | `dataease/sqlbot` | +| `IMAGE_TAG` | 镜像 tag | `latest` 或当前 git short SHA | +| `HELM_NAMESPACE` | Helm 安装的命名空间 | `default` | +| `HELM_RELEASE_NAME` | Helm release 名称 | `sqlbot` | + +推送前需先 `docker login` 对应仓库。使用 `--helm-install` 前需已安装 [Helm 3](https://helm.sh/) 并配置好 kubeconfig。 + +### 与 Helm Chart 配合 + +Chart 位于 `deploy/helm/sqlbot/`。脚本在 `--helm-install` 时会自动设置 `image.repository` 与 `image.tag`,其余配置使用 Chart 默认值,可通过 `helm upgrade -f custom-values.yaml` 覆盖。 diff --git a/build/build-k8s.sh b/build/build-k8s.sh new file mode 100755 index 00000000..def52efe --- /dev/null +++ b/build/build-k8s.sh @@ -0,0 +1,123 @@ +#!/bin/bash +# K8s 构建脚本:构建镜像 → 打 tag → 可选推送 → 可选 Helm 安装/升级 +# 环境变量: REGISTRY, IMAGE_NAME, IMAGE_TAG, HELM_NAMESPACE, HELM_RELEASE_NAME + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +HELM_CHART_PATH="$ROOT_DIR/deploy/helm/sqlbot" + +# 颜色 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +# 默认值 +VERSION="${VERSION:-20251130}" +BASE_IMAGE="sqlbot-dev-${VERSION}:latest" +QUICK_IMAGE="zf-sqlbot:latest" +REGISTRY="${REGISTRY:-}" +IMAGE_NAME="${IMAGE_NAME:-dataease/sqlbot}" +IMAGE_TAG="${IMAGE_TAG:-latest}" +HELM_NAMESPACE="${HELM_NAMESPACE:-default}" +HELM_RELEASE_NAME="${HELM_RELEASE_NAME:-sqlbot}" + +DO_PUSH=false +DO_HELM_INSTALL=false + +usage() { + echo "用法: $0 [选项]" + echo "" + echo "选项:" + echo " --push 构建后打 tag 并推送到镜像仓库(需设置 REGISTRY/IMAGE_NAME/IMAGE_TAG)" + echo " --helm-install 构建(及推送)后执行 helm upgrade --install" + echo " -h, --help 显示此帮助" + echo "" + echo "环境变量:" + echo " REGISTRY 镜像仓库地址(如 registry.cn-qingdao.aliyuncs.com)" + echo " IMAGE_NAME 镜像名(如 dataease/sqlbot)" + echo " IMAGE_TAG 镜像 tag(默认 latest,可取自 git)" + echo " HELM_NAMESPACE Helm 安装的命名空间(默认 default)" + echo " HELM_RELEASE_NAME Helm release 名称(默认 sqlbot)" + exit 0 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --push) DO_PUSH=true; shift ;; + --helm-install) DO_HELM_INSTALL=true; shift ;; + -h|--help) usage ;; + *) echo "未知选项: $1"; usage ;; + esac +done + +# 可选:从 git 生成 tag +if [[ -z "$IMAGE_TAG" || "$IMAGE_TAG" == "latest" ]]; then + if command -v git &>/dev/null && [[ -d "$ROOT_DIR/.git" ]]; then + IMAGE_TAG="$(git rev-parse --short HEAD 2>/dev/null || echo latest)" + fi + IMAGE_TAG="${IMAGE_TAG:-latest}" +fi + +echo -e "${BLUE}🔧 SQLBot K8s 构建${NC}" +echo -e "${BLUE}===================${NC}" + +# 1. 构建镜像 +if docker images --format "{{.Repository}}:{{.Tag}}" | grep -q "^${BASE_IMAGE}$"; then + echo -e "${GREEN}📦 使用快速构建(基础镜像已存在)${NC}" + cd "$SCRIPT_DIR" && ./build-quick.sh + LOCAL_IMAGE="$QUICK_IMAGE" +else + echo -e "${GREEN}📦 使用完整构建(基础镜像不存在)${NC}" + cd "$SCRIPT_DIR" && ./build-base.sh + LOCAL_IMAGE="$BASE_IMAGE" +fi + +# 2. 打 tag +if [[ -n "$REGISTRY" ]]; then + FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}" +else + FULL_IMAGE="${IMAGE_NAME}:${IMAGE_TAG}" +fi + +echo -e "${GREEN}🏷️ 标记镜像: ${LOCAL_IMAGE} -> ${FULL_IMAGE}${NC}" +docker tag "$LOCAL_IMAGE" "$FULL_IMAGE" + +# 3. 推送 +if [[ "$DO_PUSH" == true ]]; then + if [[ -z "$REGISTRY" ]]; then + echo -e "${RED}❌ --push 需要设置 REGISTRY 环境变量${NC}" + exit 1 + fi + echo -e "${BLUE}📤 推送镜像: ${FULL_IMAGE}${NC}" + docker push "$FULL_IMAGE" || { echo -e "${RED}❌ 推送失败${NC}"; exit 1; } + echo -e "${GREEN}✅ 推送完成${NC}" +fi + +# 4. Helm 安装/升级 +if [[ "$DO_HELM_INSTALL" == true ]]; then + if ! command -v helm &>/dev/null; then + echo -e "${RED}❌ 未找到 helm,请先安装 Helm 3${NC}" + exit 1 + fi + if [[ ! -d "$HELM_CHART_PATH" ]]; then + echo -e "${RED}❌ Chart 路径不存在: $HELM_CHART_PATH${NC}" + exit 1 + fi + # Helm 中 image.repository 可含 registry,image.tag 单独 + REPO_FOR_HELM="${REGISTRY:+${REGISTRY}/}${IMAGE_NAME}" + echo -e "${BLUE}📋 Helm upgrade --install ${HELM_RELEASE_NAME} in ${HELM_NAMESPACE} (image: ${REPO_FOR_HELM}:${IMAGE_TAG})${NC}" + helm upgrade --install "$HELM_RELEASE_NAME" "$HELM_CHART_PATH" \ + --namespace "$HELM_NAMESPACE" \ + --set image.repository="$REPO_FOR_HELM" \ + --set image.tag="$IMAGE_TAG" \ + --create-namespace + echo -e "${GREEN}✅ Helm 部署完成${NC}" +fi + +echo -e "${GREEN}🎉 K8s 构建流程完成${NC}" +echo -e " 本地镜像: ${LOCAL_IMAGE}" +echo -e " 目标镜像: ${FULL_IMAGE}" diff --git a/build/build.sh b/build/build.sh index a1457b0f..6bfdb98d 100755 --- a/build/build.sh +++ b/build/build.sh @@ -14,8 +14,10 @@ usage() { echo " quick-arm64 ARM64 快速构建 (build-quick-arm64.sh)" echo " multiplatform 多平台基础镜像 (build-multiplatform.sh)" echo " multiplatform-optimized 多平台构建-优化版 (build-multiplatform-optimized.sh)" + echo " k8s K8s 构建 (build-k8s.sh),支持 --push --helm-install" echo "" echo "示例: $0 quick" + echo " $0 k8s --push --helm-install" exit 0 } @@ -26,6 +28,7 @@ case "${1:-}" in quick-arm64) ./build-quick-arm64.sh ;; multiplatform) ./build-multiplatform.sh ;; multiplatform-optimized) ./build-multiplatform-optimized.sh ;; + k8s) shift; ./build-k8s.sh "$@" ;; -h|--help|"") usage ;; *) echo "未知目标: $1"; usage ;; esac diff --git a/deploy/helm/sqlbot/Chart.yaml b/deploy/helm/sqlbot/Chart.yaml new file mode 100644 index 00000000..4dc80ed9 --- /dev/null +++ b/deploy/helm/sqlbot/Chart.yaml @@ -0,0 +1,14 @@ +apiVersion: v2 +name: sqlbot +description: SQLBot - AI-powered SQL assistant (all-in-one with embedded PostgreSQL) +type: application +version: 0.1.0 +appVersion: "1.0.0" +kubeVersion: ">=1.19.0" +keywords: + - sqlbot + - sql + - ai +home: https://github.com/dataease/sqlbot +sources: [] +maintainers: [] diff --git a/deploy/helm/sqlbot/README.md b/deploy/helm/sqlbot/README.md new file mode 100644 index 00000000..58bb1498 --- /dev/null +++ b/deploy/helm/sqlbot/README.md @@ -0,0 +1,99 @@ +# SQLBot Helm Chart + +在 Kubernetes 上部署 SQLBot(All-in-One:后端 + 前端 + g2-ssr + 内嵌 PostgreSQL)。 + +## 前置条件 + +- Kubernetes 1.19+ +- Helm 3 +- 可拉取的 SQLBot 镜像(如 `dataease/sqlbot:latest` 或自有仓库) + +## 安装 + +```bash +# 使用默认 values +helm install sqlbot ./deploy/helm/sqlbot --namespace sqlbot --create-namespace + +# 指定镜像 +helm install sqlbot ./deploy/helm/sqlbot --namespace sqlbot --create-namespace \ + --set image.repository=registry.cn-qingdao.aliyuncs.com/dataease/sqlbot \ + --set image.tag=v1.0.0 + +# 使用自定义 values 文件 +helm install sqlbot ./deploy/helm/sqlbot -f my-values.yaml --namespace sqlbot --create-namespace +``` + +## 升级 + +```bash +helm upgrade sqlbot ./deploy/helm/sqlbot --namespace sqlbot -f my-values.yaml +``` + +## 主要 values 说明 + +| 项 | 说明 | 默认 | +|----|------|------| +| `image.repository` | 镜像地址(可含 registry) | `dataease/sqlbot` | +| `image.tag` | 镜像 tag | `latest` | +| `image.pullPolicy` | 拉取策略 | `IfNotPresent` | +| `replicaCount` | 副本数(内嵌 DB 时建议保持 1) | `1` | +| `service.apiPort` / `service.mcpPort` | API 与 MCP 端口 | `8000` / `8001` | +| `persistence.enabled` | 是否启用持久化 | `true` | +| `persistence.storageClass` | StorageClass(空则用默认) | `""` | +| `persistence.postgresql.size` | PostgreSQL 数据盘大小 | `10Gi` | +| `persistence.data.size` | 应用数据(excel/file)大小 | `5Gi` | +| `config.*` | 应用非敏感配置(见 values.yaml) | 见文件 | +| `secret.POSTGRES_PASSWORD` | 数据库密码(生产请修改) | `Password123@pg` | +| `secret.SECRET_KEY` | 应用密钥(生产必须设置) | 未设置时使用占位符,需在 values 中设置 | +| `existingSecret` | 使用已有 Secret 名称(含 POSTGRES_PASSWORD、SECRET_KEY) | `""` | +| `ingress.enabled` | 是否创建 Ingress | `false` | +| `ingress.host` | Ingress 主机名 | `sqlbot.example.com` | +| `resources` | CPU/内存 requests 与 limits | 见 values.yaml | + +## 持久化 + +启用 `persistence.enabled` 后会创建 4 个 PVC: + +- `*-postgresql`:PostgreSQL 数据(`/var/lib/postgresql/data`) +- `*-data`:应用数据(`/opt/sqlbot/data`,含 excel、file) +- `*-images`:图片资源(`/opt/sqlbot/images`) +- `*-logs`:日志(`/opt/sqlbot/app/logs`) + +可按需在 values 中调整 `persistence.*.size` 和 `persistence.storageClass`。 + +## Ingress + +开启 Ingress 并配置 host/TLS: + +```yaml +ingress: + enabled: true + className: nginx + host: sqlbot.yourdomain.com + tls: + - secretName: sqlbot-tls + hosts: + - sqlbot.yourdomain.com +``` + +## 使用已有 Secret + +若敏感信息由外部系统管理,可指向已有 Secret(需包含 key:`POSTGRES_PASSWORD`、`SECRET_KEY`): + +```yaml +existingSecret: my-sqlbot-secret +``` + +此时 Chart 不会创建 Secret 资源。 + +## 与构建脚本配合 + +项目根目录 `build/build-k8s.sh` 支持构建镜像后执行 Helm 部署,例如: + +```bash +cd build +REGISTRY=registry.cn-qingdao.aliyuncs.com IMAGE_TAG=v1.0.0 \ + ./build-k8s.sh --push --helm-install +``` + +会使用当前构建的镜像执行 `helm upgrade --install`,并自动设置 `image.repository` 与 `image.tag`。 diff --git a/deploy/helm/sqlbot/templates/_helpers.tpl b/deploy/helm/sqlbot/templates/_helpers.tpl new file mode 100644 index 00000000..e588011b --- /dev/null +++ b/deploy/helm/sqlbot/templates/_helpers.tpl @@ -0,0 +1,60 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "sqlbot.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "sqlbot.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "sqlbot.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "sqlbot.labels" -}} +helm.sh/chart: {{ include "sqlbot.chart" . }} +{{ include "sqlbot.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "sqlbot.selectorLabels" -}} +app.kubernetes.io/name: {{ include "sqlbot.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "sqlbot.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "sqlbot.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/deploy/helm/sqlbot/templates/configmap.yaml b/deploy/helm/sqlbot/templates/configmap.yaml new file mode 100644 index 00000000..1725de26 --- /dev/null +++ b/deploy/helm/sqlbot/templates/configmap.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "sqlbot.fullname" . }}-config + labels: + {{- include "sqlbot.labels" . | nindent 4 }} +data: + {{- range $key, $value := .Values.config }} + {{ $key }}: {{ $value | quote }} + {{- end }} diff --git a/deploy/helm/sqlbot/templates/deployment.yaml b/deploy/helm/sqlbot/templates/deployment.yaml new file mode 100644 index 00000000..b7577f6b --- /dev/null +++ b/deploy/helm/sqlbot/templates/deployment.yaml @@ -0,0 +1,84 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "sqlbot.fullname" . }} + labels: + {{- include "sqlbot.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "sqlbot.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "sqlbot.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: api + containerPort: {{ .Values.service.apiPort }} + protocol: TCP + - name: mcp + containerPort: {{ .Values.service.mcpPort }} + protocol: TCP + {{- with .Values.livenessProbe }} + livenessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.readinessProbe }} + readinessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + envFrom: + - configMapRef: + name: {{ include "sqlbot.fullname" . }}-config + - secretRef: + name: {{ .Values.existingSecret | default (printf "%s-secret" (include "sqlbot.fullname" .)) }} + volumeMounts: + - name: postgresql-data + mountPath: /var/lib/postgresql/data + - name: data + mountPath: /opt/sqlbot/data + - name: images + mountPath: /opt/sqlbot/images + - name: logs + mountPath: /opt/sqlbot/app/logs + volumes: + - name: postgresql-data + {{- if .Values.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ include "sqlbot.fullname" . }}-postgresql + {{- else }} + emptyDir: {} + {{- end }} + - name: data + {{- if .Values.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ include "sqlbot.fullname" . }}-data + {{- else }} + emptyDir: {} + {{- end }} + - name: images + {{- if .Values.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ include "sqlbot.fullname" . }}-images + {{- else }} + emptyDir: {} + {{- end }} + - name: logs + {{- if .Values.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ include "sqlbot.fullname" . }}-logs + {{- else }} + emptyDir: {} + {{- end }} diff --git a/deploy/helm/sqlbot/templates/ingress.yaml b/deploy/helm/sqlbot/templates/ingress.yaml new file mode 100644 index 00000000..ddbe6f47 --- /dev/null +++ b/deploy/helm/sqlbot/templates/ingress.yaml @@ -0,0 +1,37 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "sqlbot.fullname" . }} + labels: + {{- include "sqlbot.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className | quote }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName | quote }} + {{- end }} + {{- end }} + rules: + - host: {{ .Values.ingress.host | quote }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ include "sqlbot.fullname" . }} + port: + number: {{ .Values.service.apiPort }} +{{- end }} diff --git a/deploy/helm/sqlbot/templates/pvc.yaml b/deploy/helm/sqlbot/templates/pvc.yaml new file mode 100644 index 00000000..6786bb85 --- /dev/null +++ b/deploy/helm/sqlbot/templates/pvc.yaml @@ -0,0 +1,66 @@ +{{- if .Values.persistence.enabled }} +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "sqlbot.fullname" . }}-postgresql + labels: + {{- include "sqlbot.labels" . | nindent 4 }} +spec: + accessModes: + - ReadWriteOnce + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.postgresql.size | quote }} +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "sqlbot.fullname" . }}-data + labels: + {{- include "sqlbot.labels" . | nindent 4 }} +spec: + accessModes: + - ReadWriteOnce + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.data.size | quote }} +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "sqlbot.fullname" . }}-images + labels: + {{- include "sqlbot.labels" . | nindent 4 }} +spec: + accessModes: + - ReadWriteOnce + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.images.size | quote }} +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "sqlbot.fullname" . }}-logs + labels: + {{- include "sqlbot.labels" . | nindent 4 }} +spec: + accessModes: + - ReadWriteOnce + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.logs.size | quote }} +{{- end }} diff --git a/deploy/helm/sqlbot/templates/secret.yaml b/deploy/helm/sqlbot/templates/secret.yaml new file mode 100644 index 00000000..fe3da1d3 --- /dev/null +++ b/deploy/helm/sqlbot/templates/secret.yaml @@ -0,0 +1,14 @@ +{{- if not .Values.existingSecret }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "sqlbot.fullname" . }}-secret + labels: + {{- include "sqlbot.labels" . | nindent 4 }} +type: Opaque +data: + {{- $secret := .Values.secret }} + POSTGRES_PASSWORD: {{ $secret.POSTGRES_PASSWORD | default "Password123@pg" | b64enc | quote }} + {{- $sk := $secret.SECRET_KEY | toString }} + SECRET_KEY: {{ if $sk }}{{ $sk | b64enc | quote }}{{ else }}{{ "CHANGE-ME-SECRET-KEY" | b64enc | quote }}{{ end }} +{{- end }} diff --git a/deploy/helm/sqlbot/templates/service.yaml b/deploy/helm/sqlbot/templates/service.yaml new file mode 100644 index 00000000..15488df6 --- /dev/null +++ b/deploy/helm/sqlbot/templates/service.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "sqlbot.fullname" . }} + labels: + {{- include "sqlbot.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.apiPort }} + targetPort: api + protocol: TCP + name: api + - port: {{ .Values.service.mcpPort }} + targetPort: mcp + protocol: TCP + name: mcp + selector: + {{- include "sqlbot.selectorLabels" . | nindent 4 }} diff --git a/deploy/helm/sqlbot/values.yaml b/deploy/helm/sqlbot/values.yaml new file mode 100644 index 00000000..3288c792 --- /dev/null +++ b/deploy/helm/sqlbot/values.yaml @@ -0,0 +1,105 @@ +# SQLBot Helm Chart default values +# All-in-one deployment (backend + frontend + g2-ssr + embedded PostgreSQL) + +# -- Image settings (repository can include registry, e.g. registry.cn-qingdao.aliyuncs.com/dataease/sqlbot) +image: + repository: dataease/sqlbot + tag: latest + pullPolicy: IfNotPresent + +# -- Replica count (must be 1 when using embedded PostgreSQL) +replicaCount: 1 + +# -- Application name +nameOverride: "" +fullnameOverride: "" + +# -- Pod security: set to true if you need privileged (e.g. matching docker-compose) +podSecurityContext: + privileged: false + runAsUser: 0 + runAsNonRoot: false + +securityContext: {} + +# -- Service configuration +service: + type: ClusterIP + apiPort: 8000 + mcpPort: 8001 + +# -- Ingress (optional) +ingress: + enabled: false + className: "" + annotations: {} + host: sqlbot.example.com + tls: [] + # - secretName: sqlbot-tls + # hosts: + # - sqlbot.example.com + +# -- Persistence (PostgreSQL + app data) +persistence: + enabled: true + storageClass: "" + postgresql: + size: 10Gi + data: + size: 5Gi + images: + size: 2Gi + logs: + size: 2Gi + +# -- Resource limits/requests +resources: + requests: + memory: "2Gi" + cpu: "500m" + limits: + memory: "4Gi" + cpu: "2000m" + +# -- Liveness/readiness probes +livenessProbe: + httpGet: + path: /openapi.json + port: api + initialDelaySeconds: 60 + periodSeconds: 30 + timeoutSeconds: 10 + failureThreshold: 3 + +readinessProbe: + httpGet: + path: /openapi.json + port: api + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + +# -- Use existing secret for sensitive env (keys: postgres-password, secret-key) +# If set, secret.yaml is not created and these keys are read from existingSecret +existingSecret: "" + +# -- Application config (non-sensitive, can override in ConfigMap) +config: + PROJECT_NAME: "SQLBot" + POSTGRES_SERVER: "localhost" + POSTGRES_PORT: "5432" + POSTGRES_DB: "sqlbot" + POSTGRES_USER: "root" + DEFAULT_PWD: "SQLBot@123456" + BACKEND_CORS_ORIGINS: "http://localhost,http://localhost:5173,https://localhost,https://localhost:5173" + SERVER_IMAGE_HOST: "http://YOUR_SERVE_IP:MCP_PORT/images/" + LOG_LEVEL: "INFO" + SQL_DEBUG: "false" + CACHE_TYPE: "memory" + ACCESS_TOKEN_EXPIRE_MINUTES: "11520" + +# -- Sensitive config (used only when existingSecret is not set) +secret: + POSTGRES_PASSWORD: "Password123@pg" + SECRET_KEY: "" # 生产环境请设置强密钥;不设置时使用占位符 diff --git a/frontend/QWEN.md b/frontend/QWEN.md new file mode 100644 index 00000000..7a903897 --- /dev/null +++ b/frontend/QWEN.md @@ -0,0 +1,206 @@ +# SQLBot Frontend 项目说明 + +## 项目概述 + +SQLBot 是一个基于 Vue 3 + TypeScript 构建的前端应用程序,使用 Vite 作为构建工具。该项目是一个现代化的单页应用 (SPA),提供 SQL 相关的功能界面,包括聊天、仪表盘、数据源管理、工作区等功能模块。 + +## 技术栈 + +### 核心框架 +- **Vue 3.5.13** - 渐进式 JavaScript 框架 +- **TypeScript 5.7.2** - 类型安全的 JavaScript 超集 +- **Vite 6.3.1** - 下一代前端构建工具 + +### 状态管理 +- **Pinia 3.0.2** - Vue 3 官方推荐的状态管理库 + +### UI 组件库 +- **Element Plus 2.10.1** - Vue 3 组件库 +- **Element Plus Secondary 1.0.0** - Element Plus 扩展组件 + +### 路由 +- **Vue Router 4.5.0** - Vue.js 官方路由 + +### 国际化 +- **Vue I18n 9.14.4** - 支持多语言(中文、英文、韩文) + +### 数据可视化 +- **@antv/g2 5.3.3** - 可视化图表库 +- **@antv/s2 2.4.3** - 多维表格分析组件 +- **@antv/x6 3.1.3** - 图编辑引擎 + +### 网络请求 +- **Axios 1.8.4** - HTTP 客户端 + +### 工具库 +- **Lodash / Lodash-es** - JavaScript 实用工具库 +- **Day.js 1.11.13** - 日期处理库 +- **@vueuse/core 14.1.0** - Vue 组合式 API 工具集 +- **Crypto-js 4.2.0** - 加密库 + +### 富文本与代码高亮 +- **TinyMCE 7.9.1** - 富文本编辑器 +- **Highlight.js 11.11.1** - 代码语法高亮 +- **Markdown-it 14.1.0** - Markdown 解析器 + +### 代码质量 +- **ESLint 9.28.0** - 代码检查 +- **Prettier 3.5.3** - 代码格式化 +- **TypeScript ESLint** - TypeScript 规则支持 + +## 项目结构 + +``` +frontend/ +├── public/ # 静态资源目录 +├── src/ +│ ├── api/ # API 接口定义 +│ │ ├── assistant.ts +│ │ ├── audit.ts +│ │ ├── auth.ts +│ │ ├── chat.ts +│ │ ├── dashboard.ts +│ │ ├── datasource.ts +│ │ ├── embedded.ts +│ │ ├── license.ts +│ │ ├── login.ts +│ │ ├── permissions.ts +│ │ ├── professional.ts +│ │ ├── prompt.ts +│ │ ├── recommendedApi.ts +│ │ ├── setting.ts +│ │ ├── system.ts +│ │ ├── training.ts +│ │ ├── user.ts +│ │ ├── variables.ts +│ │ └── workspace.ts +│ ├── assets/ # 资源文件(图片、样式等) +│ ├── components/ # 公共组件 +│ │ ├── about/ +│ │ ├── drawer-filter/ +│ │ ├── drawer-main/ +│ │ ├── filter-text/ +│ │ ├── icon-custom/ +│ │ ├── Language-selector/ +│ │ ├── layout/ +│ │ └── rich-text/ +│ ├── entity/ # 数据实体/类型定义 +│ ├── i18n/ # 国际化配置 +│ │ ├── en.json +│ │ ├── zh-CN.json +│ │ └── ko-KR.json +│ ├── router/ # 路由配置 +│ ├── stores/ # Pinia 状态管理 +│ │ ├── dashboard/ +│ │ ├── appearance.ts +│ │ ├── assistant.ts +│ │ ├── chatConfig.ts +│ │ ├── index.ts +│ │ └── user.ts +│ ├── utils/ # 工具函数 +│ ├── views/ # 页面视图组件 +│ │ ├── chat/ # 聊天模块 +│ │ ├── dashboard/ # 仪表盘模块 +│ │ ├── ds/ # 数据源模块 +│ │ ├── embedded/ # 嵌入模块 +│ │ ├── error/ # 错误页面 +│ │ ├── login/ # 登录模块 +│ │ ├── system/ # 系统设置 +│ │ ├── work/ # 工作区模块 +│ │ └── WelcomeView.vue +│ ├── App.vue # 根组件 +│ ├── main.ts # 应用入口 +│ └── style.less # 全局样式 +├── .env.development # 开发环境变量 +├── .env.production # 生产环境变量 +├── index.html # HTML 模板 +├── package.json # 项目依赖配置 +├── tsconfig.json # TypeScript 配置 +├── vite.config.ts # Vite 构建配置 +└── .prettierrc # Prettier 格式化配置 +``` + +## 构建与运行 + +### 环境要求 +- Node.js (推荐 v18+) +- npm 或 pnpm + +### 安装依赖 +```bash +npm install +``` + +### 开发模式 +```bash +npm run dev +``` +启动开发服务器,支持热重载。开发环境 API 地址:`http://localhost:8000/api/v1` + +### 生产构建 +```bash +npm run build +``` +构建生产版本,输出到 `dist/` 目录。生产环境 API 地址为相对路径 `./api/v1` + +### 预览构建结果 +```bash +npm run preview +``` + +### 代码检查 +```bash +npm run lint +``` +运行 ESLint 检查并自动修复问题 + +## 开发规范 + +### 代码风格 +- **单引号**:使用单引号 `'` 而非双引号 +- **无分号**:语句末尾不加分号 +- **行尾逗号**:ES5 兼容的多行对象/数组末尾加逗号 +- **行宽限制**:最大行宽 100 字符 +- **缩进**:2 空格缩进 + +### 路径别名 +项目中配置了 `@` 别名指向 `src/` 目录: +```typescript +import { xxx } from '@/utils/xxx' +``` + +### 自动导入 +项目使用 `unplugin-auto-import` 和 `unplugin-vue-components` 实现: +- Vue API 自动导入(如 `ref`, `computed` 等) +- Element Plus 组件自动导入 +- 无需手动导入常用组件和 API + +### 国际化 +支持三种语言: +- 中文 (zh-CN) - 默认语言 +- 英文 (en) +- 韩文 (ko-KR) + +使用 `vue-i18n` 进行国际化,语言设置会保存在缓存中。 + +### 状态管理 +使用 Pinia 进行状态管理,stores 目录包含: +- `user.ts` - 用户相关状态 +- `assistant.ts` - 助手相关状态 +- `chatConfig.ts` - 聊天配置状态 +- `appearance.ts` - 外观设置状态 +- `dashboard/` - 仪表盘相关状态 + +## 环境变量 + +| 变量名 | 开发环境 | 生产环境 | 说明 | +|--------|----------|----------|------| +| `VITE_API_BASE_URL` | `http://localhost:8000/api/v1` | `./api/v1` | API 基础地址 | +| `VITE_APP_TITLE` | `SQLBot (Development)` | `SQLBot` | 应用标题 | + +## 注意事项 + +1. **TypeScript 类型检查**:构建前会运行 `vue-tsc` 进行类型检查 +2. **代码分割**:生产构建配置了 `element-plus-secondary` 的单独 chunk +3. **SVG 支持**:使用 `vite-svg-loader` 直接导入 SVG 文件作为 Vue 组件 +4. **Less 预处理器**:全局样式使用 Less,配置了 `javascriptEnabled: true` diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 6d321dc8..e7787ec4 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -49,6 +49,7 @@ declare module '@vue/runtime-core' { ElPagination: typeof import('element-plus-secondary/es')['ElPagination'] ElPopover: typeof import('element-plus-secondary/es')['ElPopover'] ElRadio: typeof import('element-plus-secondary/es')['ElRadio'] + ElRadioButton: typeof import('element-plus-secondary/es')['ElRadioButton'] ElRadioGroup: typeof import('element-plus-secondary/es')['ElRadioGroup'] ElRow: typeof import('element-plus-secondary/es')['ElRow'] ElScrollbar: typeof import('element-plus-secondary/es')['ElScrollbar'] @@ -60,6 +61,7 @@ declare module '@vue/runtime-core' { ElTableColumn: typeof import('element-plus-secondary/es')['ElTableColumn'] ElTabPane: typeof import('element-plus-secondary/es')['ElTabPane'] ElTabs: typeof import('element-plus-secondary/es')['ElTabs'] + ElTag: typeof import('element-plus-secondary/es')['ElTag'] ElTooltip: typeof import('element-plus-secondary/es')['ElTooltip'] ElTree: typeof import('element-plus-secondary/es')['ElTree'] ElTreeSelect: typeof import('element-plus-secondary/es')['ElTreeSelect'] diff --git a/frontend/docs/menu-mechanism.md b/frontend/docs/menu-mechanism.md new file mode 100644 index 00000000..13d60ff1 --- /dev/null +++ b/frontend/docs/menu-mechanism.md @@ -0,0 +1,116 @@ +# 前端菜单管理机制 - 深度分析 + +## 1. 总体架构 + +### 1.1 双布局体系 + +| 布局组件 | 路径 | 使用场景 | 菜单数据来源 | +|----------|------|----------|--------------| +| **LayoutDsl.vue** | `components/layout/LayoutDsl.vue` | `/chat`、`/dashboard`、`/set`、`/ds`、`/as` 等 | **Menu.vue**:`router.getRoutes()` 过滤 + 对「设置」子项覆盖 | +| **index.vue** (Layout) | `components/layout/index.vue` | 当前路由表未使用该布局 | 自身 computed:`getRoutes().filter(!redirect)`,与侧边栏不同逻辑 | + +**实际生效的侧边栏**:仅 **LayoutDsl → Menu.vue**。因此「设置」下是否出现「自定义提示词」完全由 `Menu.vue` 的 `routerList` 决定。 + +### 1.2 路由定义(静态) + +- **router/index.ts** 中 `/set` 定义为: + - `path: '/set'`, `name: 'set'`, `redirect: '/set/member'` + - **children**:member、permission、professional、training、**prompt**(共 5 项) +- **router/dynamic.ts** 在 `isAdmin || isSpaceAdmin` 时向 router **addRoute** 增加 `/ds`、`/as`,否则 **removeRoute** 移除这些 name。 +- **router/watch.ts** 的 `beforeEach` 中会执行 **LicenseGenerator.generateRouters(router)**(来自 xpack 远程脚本),可能**动态增删或修改路由**,且脚本不在本仓库,无法保证 `/set` 或其 children 不被修改。 + +--- + +## 2. Menu.vue 数据流(当前逻辑) + +``` +routerList (computed) + │ + ├─ 若 route.path 包含 '/system' + │ → 走「系统」分支:getRoutes().filter(name==='system') → formatRoute → 返回 system 的 children + │ + └─ 否则(工作空间布局) + → list = getRoutes().filter( 一长串条件 ) + → setIndex = list.findIndex( name==='set' || path==='/set' ) + → 若 setIndex !== -1:list[setIndex] = { ...setRoute, children: SET_CHILDREN_SPEC.map(...) } // 5 项 + → return list +``` + +### 2.1 过滤条件(工作空间下)摘要 + +- **排除**:embeddedPage、assistant、canvas、**member**、**professional**、training、**permission**、embeddedCommon、preview、audit、login、admin-login、chatPreview、/system、dsTable、/:pathMatch(.*)* +- **保留「设置」**:`(path.includes('set') && userStore.isSpaceAdmin) || !route.redirect` + - 即:要么是「 set 且 空间管理员」,要么是「无 redirect 的一级路由」(如 chat、dashboard、ds、as 等)。 + +因此非 spaceAdmin 时整块「设置」不会出现在 list 中;能看到「设置」说明当前用户是 spaceAdmin,list 中会有一条 `name==='set'` / `path==='/set'` 的项。 + +### 2.2 Vue Router 4 的 getRoutes() 行为 + +- `getRoutes()` 返回的是**当前已注册**的 route 记录数组(标准化后的 RouteRecordNormalized)。 +- 若 **LicenseGenerator.generateRouters(router)** 或 **dynamic.ts** 的 addRoute/removeRoute 修改了 router,则下次 getRoutes() 会反映修改后的状态。 +- 对嵌套路由,父记录通常带有 `children` 数组;若 xpack 或其它逻辑删除了 `prompt` 子路由,则从 getRoutes() 拿到的「设置」可能只剩 4 个子项。当前做法是用 **SET_CHILDREN_SPEC** 覆盖该节点的 `children`,理论上可避免受 router 内部 children 影响。 + +### 2.3 渲染链路 + +- **Menu.vue**:`` +- **MenuItem.vue**: + - 若 `menu.hidden` → 不渲染 + - 若 `menu.children?.length` → 渲染为 **ElSubMenu**,子项为 `children.map(ele => h(MenuItem, { menu: ele }))` + - 否则 → 渲染为 **ElMenuItem** + +子项是否显示只依赖传入的 `menu.children` 和 `menu.hidden`,无其它过滤。 + +--- + +## 3. 为何「自定义提示词」可能不显示(根因归纳) + +1. **路由被外部修改** + `LicenseGenerator.generateRouters(router)` 在 beforeEach 中执行,可能移除或调整 `/set` 的 children。若在「替换 set 的 children」之前 getRoutes() 已被修改,且我们的替换逻辑未正确覆盖(见下),则仍会显示 4 项。 + +2. **替换的是「列表项」而非「路由表」** + 当前做法是:从 `list = getRoutes().filter(...)` 得到 list,再 `list[setIndex] = { ...setRoute, children: setChildren }`。若 `setRoute` 来自 getRoutes() 的引用且其 `children` 在别处被读(例如通过 getter),理论上存在仍读到旧 children 的可能;用新对象覆盖 list[setIndex] 后,MenuItem 拿到的应是新对象,一般可避免该问题。 + +3. **构建/缓存未更新** + 若线上或本地访问的是旧前端构建,则仍会是「只显示 4 项」的旧逻辑。 + +4. **存在其它菜单数据源** + 若还有其它组件或布局根据 getRoutes() 渲染「设置」子菜单且未做同样覆盖,则可能某处仍显示 4 项。当前代码中侧边栏仅 Menu.vue 一处。 + +--- + +## 4. 加固思路:与 getRoutes() 完全解耦 + +为保证「设置」下 5 项(含「自定义提示词」)在任何情况下都一致,建议: + +- **工作空间菜单**中,「设置」这一项**不依赖** `getRoutes()` 返回的 `/set` 的 `children`。 +- 用**固定配置**(如现有 SET_CHILDREN_SPEC)**单独构建「设置」菜单节点**,再与其它一级菜单(chat、dashboard、ds、as 等)拼成最终 `routerList`。 +- 这样无论 LicenseGenerator 或后续如何改动 router,侧边栏「设置」下始终是固定的 5 项。 + +实现上可: + +- 在 computed 中:先得到「除 set 外的一级菜单」列表,再若 `userStore.isSpaceAdmin` 则追加一条**完全由 SET_CHILDREN_SPEC 生成的「设置」节点**(path、name、meta、children 均来自常量),不再从 getRoutes() 的 set 项拷贝 children。 + +--- + +## 5. 关键文件索引 + +| 文件 | 作用 | +|------|------| +| `router/index.ts` | 静态路由定义,/set 及其 5 个子路由 | +| `router/dynamic.ts` | 动态 addRoute/removeRoute(/ds、/as) | +| `router/watch.ts` | beforeEach:xpack 脚本、LicenseGenerator.generateRouters(router) | +| `components/layout/LayoutDsl.vue` | 使用 Menu.vue 的布局 | +| `components/layout/Menu.vue` | 侧边栏菜单数据 routerList + SET_CHILDREN_SPEC 覆盖 | +| `components/layout/MenuItem.vue` | 递归渲染 ElSubMenu / ElMenuItem | + +--- + +## 6. 常见问题(Q&A) + +- **自定义菜单**、**为何侧栏会少项**、**如何绕过 xpack 控制**、**修改设置子项应改哪里**:**[qa-custom-menu-xpack.md](./qa-custom-menu-xpack.md)** +- **「统计分析」「自定义提示词」为何普通成员仍可见、与 xpack 的关系、根因反思与正确做法**:**[qa-set-menu-permission-and-xpack.md](./qa-set-menu-permission-and-xpack.md)** + +--- + +**文档版本**:v1 +**最后更新**:基于当前代码与对话摘要整理。 diff --git a/frontend/docs/qa-custom-menu-xpack.md b/frontend/docs/qa-custom-menu-xpack.md new file mode 100644 index 00000000..4ccf3c35 --- /dev/null +++ b/frontend/docs/qa-custom-menu-xpack.md @@ -0,0 +1,76 @@ +# 自定义菜单与绕过 xpack 控制 — 常见问题(Q&A) + +本文以问答形式说明:如何自定义侧边栏「设置」菜单、为何会出现少项、以及如何绕过 xpack 对路由的修改,保证菜单项稳定显示。 + +--- + +## Q1:侧边栏「设置」下为什么有时只有 4 项,没有「自定义提示词」? + +**A:** 侧边栏菜单来自 `Menu.vue` 的 `routerList`,而 `routerList` 基于 **Vue Router 的 `getRoutes()`**。在路由守卫 `router/watch.ts` 的 `beforeEach` 里会执行 **xpack 的 `LicenseGenerator.generateRouters(router)`**,该逻辑会**动态增删或修改路由**。若 xpack 把 `/set` 的 children 改少了(例如移除了 `prompt`),则从 `getRoutes()` 拿到的「设置」子项就只剩 4 个,侧栏也就只显示 4 项。 + +因此「少项」的根本原因是:**菜单直接依赖了被 xpack 修改后的路由表**。 + +--- + +## Q2:如何绕过 xpack,让「设置」下始终显示我们想要的菜单项? + +**A:** 让「设置」这一项**不再依赖** `getRoutes()` 里 `/set` 的 `children`,而是用**前端固定配置**单独生成「设置」节点,再拼进最终菜单列表。 + +当前实现方式: + +1. 在 `Menu.vue` 中定义 **SET_MENU_SPEC**:写死「设置」的 path、name、meta 以及 **children 列表**(含 member、permission、professional、training、**prompt** 共 5 项)。 +2. 用 **buildSetMenuNode(SET_MENU_SPEC, t)** 根据该配置生成一个「合成设置节点」(path、name、meta、children 均来自配置,不读 router)。 +3. 在 `routerList` 的 computed 里:先得到「除 set 外的一级菜单」列表,若是空间管理员则把**合成设置节点**插入/替换进去,作为「设置」菜单项返回。 + +这样无论 xpack 如何改 router,侧边栏「设置」下的 5 项都由 SET_MENU_SPEC 决定,与 `getRoutes()` 的 children 解耦。 + +--- + +## Q3:如何自定义「设置」下的菜单(增删改)? + +**A:** 需要动两处,保持一致即可: + +1. **菜单展示(侧栏项)** + 编辑 **`frontend/src/components/layout/Menu.vue`** 里的 **SET_MENU_SPEC**: + - 在 `children` 数组中增删或修改项,每项需包含 `name`、`path`、`titleKey`(i18n 键)。 + - 这样侧边栏「设置」下显示的条目和顺序由这里决定。 + +2. **路由(页面可访问性)** + 编辑 **`frontend/src/router/index.ts`**(或实际定义 `/set` 的地方): + - 在 `/set` 的 **children** 里对应地增删或修改路由(path、name、component 等)。 + - 否则只有菜单没有路由,点击会 404;只有路由没有菜单项,则侧栏不会出现该入口。 + +两处保持一致后,既能在侧栏看到目标项,又能正常打开对应页面。 + +--- + +## Q4:实现「绕过 xpack」时,具体改了哪些代码? + +**A:** 核心改动在 **Menu.vue**: + +- **SET_MENU_SPEC**:固定配置「设置」整节点及其 5 个子项(含 prompt)。 +- **buildSetMenuNode(spec, t)**:根据 spec 和国际化函数 `t` 生成一棵「设置」菜单树(path、name、meta、children)。 +- **routerList 的 computed**: + - 先按原逻辑用 `getRoutes().filter(...)` 得到一级列表,并找到「设置」在列表中的位置(若有)。 + - 若是空间管理员,则用 **buildSetMenuNode(SET_MENU_SPEC, t)** 得到 **syntheticSet**,用 syntheticSet 替换或追加到列表中,作为「设置」节点。 + - 这样最终渲染的「设置」及其子项完全来自 SET_MENU_SPEC,不依赖 `getRoutes()` 里 `/set` 的 children。 + +路由守卫 **router/watch.ts** 中仍有 xpack 的 `LicenseGenerator.generateRouters(router)`,我们**没有去掉**它,只是**不再用**它影响「设置」菜单的展示来源。 + +--- + +## Q5:调试时如何确认「设置」菜单来自配置而非路由? + +**A:** 若需排查,可在 **Menu.vue** 的 `routerList` 里临时打印:从 `list` 中取到的 set 节点的 `children`(即 getRoutes 的 /set 子项)与 `syntheticSet.children`(来自 SET_MENU_SPEC)对比。若前者少项而后者为 5 项且含 prompt,说明合成逻辑已生效。 + +--- + +## 相关文档 + +- 菜单数据流与 xpack 影响分析:**`frontend/docs/menu-mechanism.md`** +- 「设置」菜单权限与 xpack(普通成员为何仍看到统计/自定义提示词、根因反思):**`frontend/docs/qa-set-menu-permission-and-xpack.md`** + +--- + +**文档版本**:v1 +**最后更新**:基于自定义菜单与绕过 xpack 的实现整理。 diff --git a/frontend/docs/qa-set-menu-permission-and-xpack.md b/frontend/docs/qa-set-menu-permission-and-xpack.md new file mode 100644 index 00000000..412369ff --- /dev/null +++ b/frontend/docs/qa-set-menu-permission-and-xpack.md @@ -0,0 +1,92 @@ +# 「设置」菜单权限与 xpack 影响 — 问题反思与 Q&A + +本文总结:为何普通成员会看到「统计分析」「自定义提示词」、为何与 xpack 有关、以及如何避免类似问题。适合做事故复盘与新人理解菜单/权限设计。 + +--- + +## 一、现象与诉求 + +- **现象**:普通成员(非系统管理员)在侧边栏仍能看到「设置」下的「统计分析」和「自定义提示词」。 +- **诉求**:仅系统管理员(uid 为 1)可见这两项;普通成员不可见;且不受 xpack 对路由的修改影响。 + +--- + +## 二、根因反思:为什么会写成这样? + +### 2.1 菜单数据来源与 xpack 的耦合 + +| 层级 | 原实现 | 导致的问题 | +|------|--------|-------------| +| **数据来源** | `routerList` 用 `router.getRoutes().filter(...)` 得到一级菜单,其中包含 **router 里的「设置」节点**(`name === 'set'`) | 「设置」的 **children 来自路由表**,而路由表会在 `beforeEach` 里被 **xpack 的 `LicenseGenerator.generateRouters(router)` 动态修改** | +| **权限控制** | 对「自定义提示词」「统计分析」的可见性用 `includePrompt` / `includeStatistics` 控制,但**仅作用在我们「替换」进列表的 syntheticSet 上** | 若 list 里仍包含 **router 的 set**(从 getRoutes 来的那一棵),则可能出现:我们插入了 syntheticSet,但**列表里同时还有 router 的 set**,或 xpack 恢复/保留了 set 的 children,导致某处仍渲染了 router 的 set 子项 | + +也就是说:**既有「用固定配置生成 syntheticSet」的逻辑,又没有彻底弃用 router 里的 set」**,两套来源并存,就容易出现「你以为只渲染了 syntheticSet,实际某次渲染仍用到了 getRoutes() 的 set」。 + +### 2.2 为何会依赖 getRoutes() 的「设置」? + +- **历史原因**:侧栏菜单最初很自然地从「路由表」推导:路由里有 `/set` 及其 children,菜单就展示这些项,实现简单、与路由一致。 +- **xpack 介入**:后来引入 xpack,在 `beforeEach` 里调用 `LicenseGenerator.generateRouters(router)`,会增删或修改路由(例如删掉 `/set` 下的 `prompt` 以做能力控制)。为补救「自定义提示词」不显示,又加了 **ensureSetPromptRoute** 补回 prompt 路由,并增加了「用 SET_MENU_SPEC 生成 syntheticSet 再替换进列表」的逻辑。 +- **未彻底解耦**:替换逻辑是「在 list 里找到 set,删掉后插入 syntheticSet」,但 **list 的生成仍然把 router 里 path 含 `set` 的项放进 list**。一旦 xpack 或路由注册顺序有变化,list 里可能仍是「router 的 set」或混入其子项,**菜单展示与「仅用 syntheticSet」的预期不一致**,权限控制(只对 syntheticSet 做 includePrompt/includeStatistics)就无法完全生效。 + +### 2.3 权限判断本身 + +- 「仅系统管理员可见」依赖 `userStore.isAdmin` 或等价地 `String(userStore.uid) === '1'`。 +- 若菜单项来自 **router 的 set**,则根本没有走 `buildSetMenuNode(..., { includePrompt, includeStatistics })` 的过滤,**权限逻辑形同虚设**,所以会出现普通成员仍看到这两项。 + +--- + +## 三、正确做法(当前实现要点) + +1. **菜单与 router 的「设置」彻底解耦** + - 在 `router.getRoutes().filter(...)` 时**直接排除**「设置」相关路由:`name === 'set'`、`path === '/set'`、`path.startsWith('/set/')`。 + - 这样 **list 中不再包含任何来自 router 的 set**,xpack 对 router 的增删改都不会影响「设置」菜单由谁提供。 + +2. **「设置」只来自固定配置** + - 仅当 `userStore.isSpaceAdmin` 时,用 **buildSetMenuNode(SET_MENU_SPEC, t, { includePrompt, includeStatistics })** 生成 **syntheticSet**。 + - `includePrompt` / `includeStatistics` 仅依赖 **isSystemAdmin**(`String(userStore.uid) === '1'`),保证只有系统管理员能看到这两项。 + +3. **插入位置固定** + - 将 syntheticSet 插入到「仪表板」之后,不再依赖 `getRoutes()` 里 set 的位置,避免因路由顺序变化导致菜单位置或数量异常。 + +--- + +## 四、Q&A 速查 + +### Q1:为什么普通成员能看到「统计分析」「自定义提示词」? + +**A:** 菜单在部分情况下仍使用了 **router 里「设置」节点的 children**(来自 `getRoutes()`),而该树可能被 xpack 恢复或保留 prompt/statistics。权限过滤只作用在我们手写的 **syntheticSet** 上,没有作用在 router 的 set 上,所以会出现「权限写了但普通成员仍能看到」的现象。 + +### Q2:为什么说和 xpack 有关? + +**A:** `LicenseGenerator.generateRouters(router)` 会修改路由表;若菜单继续从 `getRoutes()` 里取「设置」或其子项,则展示结果就受 xpack 行为影响。只有**完全不从 getRoutes() 取「设置」**,改为只用固定配置生成的 syntheticSet,才能保证「谁可见」完全由我们自己的权限逻辑决定。 + +### Q3:修复后「设置」菜单还依赖路由吗? + +**A:** **展示上不依赖**。展示用的「设置」节点 100% 来自 **SET_MENU_SPEC + buildSetMenuNode**;路由表里仍保留 `/set` 及其 children 是为了**能打开对应页面**,不再参与「侧栏显示哪些子项」。 + +### Q4:以后要加/改「设置」下的菜单项要注意什么? + +**A:** +1. 在 **Menu.vue** 的 **SET_MENU_SPEC.children** 里增删或改项(控制侧栏显示与顺序)。 +2. 若某项需「仅系统管理员可见」,在 **buildSetMenuNode** 的 options 里增加对应 `includeXxx: isSystemAdmin.value`,并在 **buildSetMenuNode** 内对 `spec.children` 做过滤。 +3. 在 **router/index.ts** 的 `/set` 的 children 里同步路由,保证点击能打开页面。 +4. **不要**再依赖 `getRoutes()` 里 `/set` 的 children 来驱动「设置」子菜单。 + +### Q5:如何避免类似「权限写了却不生效」的问题? + +**A:** +- **单一数据源**:对「谁能看到」有要求的菜单,只从一处数据源生成(这里是 SET_MENU_SPEC + 权限),不要和「可能被外部改动的路由表」混用。 +- **显式排除**:若存在会修改路由的第三方(如 xpack),在构建菜单时**显式排除**这些路由节点,再插入自己控制的节点。 +- **权限与展示一致**:凡按角色/权限过滤的项,确保**最终渲染的菜单树**一定来自做过过滤的那份数据(例如只渲染 syntheticSet,不渲染 router 的 set)。 + +--- + +## 五、相关文档 + +- 菜单数据流与 xpack 影响:**`frontend/docs/menu-mechanism.md`** +- 自定义菜单与绕过 xpack(少项、如何改菜单):**`frontend/docs/qa-custom-menu-xpack.md`** + +--- + +**文档版本**:v1 +**最后更新**:基于「设置」下「统计分析」「自定义提示词」仅系统管理员可见的修复与 xpack 解耦实现整理。 diff --git a/frontend/src/api/prompt.ts b/frontend/src/api/prompt.ts index 1d98b2bf..9186346b 100644 --- a/frontend/src/api/prompt.ts +++ b/frontend/src/api/prompt.ts @@ -1,5 +1,16 @@ import { request } from '@/utils/request' +export interface DefaultPromptItem { + defaultContent: string + variables: { name: string; description: string }[] +} + +export interface DefaultPromptsResponse { + GENERATE_SQL: DefaultPromptItem + ANALYSIS: DefaultPromptItem + PREDICT_DATA: DefaultPromptItem +} + export const promptApi = { getList: (pageNum: any, pageSize: any, type: any, params: any) => request.get(`/system/custom_prompt/${type}/page/${pageNum}/${pageSize}${params}`), @@ -12,4 +23,7 @@ export const promptApi = { responseType: 'blob', requestOptions: { customError: true }, }), + getDefaultPrompts: () => request.get('/system/default-prompts'), + optimizePrompt: (type: string, prompt: string) => + request.post<{ optimized: string }>('/system/prompt/optimize', { type, prompt }), } diff --git a/frontend/src/api/statistics.ts b/frontend/src/api/statistics.ts new file mode 100644 index 00000000..bf39ca3d --- /dev/null +++ b/frontend/src/api/statistics.ts @@ -0,0 +1,229 @@ +import { request } from '@/utils/request' + +// ----- Overview ----- +export interface OverviewMetrics { + total_queries: number + success_queries: number + failed_queries: number + success_rate: number + active_users: number + active_datasources: number + active_chats: number + avg_duration_seconds: number | null + total_users?: number + total_datasources?: number + total_tokens?: number + duration_p50_seconds?: number | null + duration_p90_seconds?: number | null + duration_p99_seconds?: number | null +} + +export interface DatasourceStats { + datasource_id: number | null + datasource_name: string | null + total_queries: number + success_queries: number + failed_queries: number + success_rate: number + active_users: number + avg_duration_seconds?: number | null + total_tokens?: number +} + +export interface UserStats { + user_id: number | null + user_name: string | null + total_queries: number + success_queries: number + failed_queries: number + success_rate: number + active_datasources: number + avg_duration_seconds?: number | null + total_tokens?: number +} + +export interface DailyTrendPoint { + date: string + total_queries: number + success_queries: number + failed_queries: number + avg_duration_seconds?: number | null + total_tokens?: number + success_rate?: number +} + +export interface StatisticsOverviewResponse { + overview: OverviewMetrics + by_datasource: DatasourceStats[] + by_user: UserStats[] + daily_trend: DailyTrendPoint[] +} + +// ----- Trend ----- +export interface TrendPoint { + date: string + total_queries: number + success_queries: number + failed_queries: number + avg_duration_seconds: number | null + total_tokens: number + success_rate: number +} + +export interface StatisticsTrendResponse { + trend: TrendPoint[] +} + +// ----- Datasource TOP ----- +export interface DatasourceTopItem { + datasource_id: number | null + datasource_name: string | null + value: number + sort_key: string +} + +export interface StatisticsDatasourceTopResponse { + items: DatasourceTopItem[] +} + +// ----- Datasource detailed ----- +export interface DatasourceDetailedItem { + datasource_id: number | null + datasource_name: string | null + total_queries: number + success_queries: number + failed_queries: number + success_rate: number + active_users: number + avg_duration_seconds: number | null + total_tokens: number +} + +export interface StatisticsDatasourceDetailedResponse { + items: DatasourceDetailedItem[] + total: number + page: number + size: number + total_pages: number +} + +// ----- Failure analysis ----- +export interface FailureReasonItem { + error_type: string + count: number + sample_message?: string | null +} + +export interface FailureByDatasourceItem { + datasource_id: number | null + datasource_name: string | null + failed_count: number +} + +export interface FailureByHourItem { + hour: number + failed_count: number +} + +export interface StatisticsFailureAnalysisResponse { + by_reason: FailureReasonItem[] + by_datasource: FailureByDatasourceItem[] + by_hour: FailureByHourItem[] +} + +// ----- User detailed ----- +export interface UserDetailedItem { + user_id: number | null + user_name: string | null + total_queries: number + success_queries: number + failed_queries: number + success_rate: number + active_datasources: number + avg_duration_seconds: number | null + total_tokens: number +} + +export interface StatisticsUserDetailedResponse { + items: UserDetailedItem[] + total: number + page: number + size: number + total_pages: number +} + +// ----- Records ----- +export interface RecordItem { + id: number | null + chat_id: number | null + create_time: string | null + create_by: number | null + user_name: string | null + datasource_id: number | null + datasource_name: string | null + question: string | null + finish: boolean + error: string | null + error_type?: string | null + duration_seconds?: number | null + total_tokens?: number | null +} + +export interface StatisticsRecordsResponse { + items: RecordItem[] + total: number + page: number + size: number + total_pages: number +} + +const timeParams = (params?: { start_time?: string; end_time?: string }) => + params && params.start_time && params.end_time ? { params } : { params: params || {} } + +export const statisticsApi = { + getOverview: (params?: { start_time?: string; end_time?: string }) => + request.get('/system/statistics/overview', timeParams(params)), + + getTrend: (params?: { start_time?: string; end_time?: string }) => + request.get('/system/statistics/trend', timeParams(params)), + + getDatasourceTop: (params: { + start_time?: string + end_time?: string + sort_by?: 'total_queries' | 'failed_queries' | 'avg_duration_seconds' | 'total_tokens' + limit?: number + }) => request.get('/system/statistics/datasource/top', { params }), + + getDatasourceDetailed: (params: { + start_time?: string + end_time?: string + page?: number + size?: number + keyword?: string + order_by?: string + desc?: boolean + }) => request.get('/system/statistics/datasource/detailed', { params }), + + getFailureAnalysis: (params?: { start_time?: string; end_time?: string }) => + request.get('/system/statistics/failure/analysis', timeParams(params)), + + getUserDetailed: (params: { + start_time?: string + end_time?: string + page?: number + size?: number + keyword?: string + order_by?: string + desc?: boolean + }) => request.get('/system/statistics/user/detailed', { params }), + + getRecords: (params: { + page?: number + size?: number + start_time?: string + end_time?: string + user_id?: number + datasource_id?: number + failed_only?: boolean + }) => request.get('/system/statistics/records', { params }), +} diff --git a/frontend/src/components/layout/Menu.vue b/frontend/src/components/layout/Menu.vue index 9145f0e4..b690ca16 100644 --- a/frontend/src/components/layout/Menu.vue +++ b/frontend/src/components/layout/Menu.vue @@ -4,9 +4,61 @@ import { ElMenu } from 'element-plus-secondary' import { useRoute, useRouter } from 'vue-router' import MenuItem from './MenuItem.vue' import { useUserStore } from '@/stores/user' +import { i18n } from '@/i18n' // import { routes } from '@/router' const userStore = useUserStore() const router = useRouter() +const t = i18n.global.t + +/** 「设置」菜单与 getRoutes() 完全解耦:用固定配置生成整节点,避免 xpack/LicenseGenerator 修改路由后子项丢失 */ +const SET_MENU_SPEC = { + path: '/set', + name: 'set', + meta: { + titleKey: 'workspace.set', + iconActive: 'set', + iconDeActive: 'noSet', + }, + children: [ + { name: 'member', path: '/set/member', titleKey: 'workspace.member_management' }, + { name: 'permission', path: '/set/permission', titleKey: 'workspace.permission_configuration' }, + { name: 'professional', path: '/set/professional', titleKey: 'professional.professional_terminology' }, + { name: 'training', path: '/set/training', titleKey: 'training.data_training' }, + { name: 'prompt', path: '/set/prompt', titleKey: 'prompt.customize_prompt_words' }, + { name: 'statistics', path: '/set/statistics', titleKey: 'menu.statistics' }, + ], +} + +/** 根据固定配置生成「设置」菜单节点(与 getRoutes 解耦),供侧栏使用;「自定义提示词」「统计分析」仅系统管理员可见 */ +function buildSetMenuNode( + spec: typeof SET_MENU_SPEC, + tFn: (key: string) => string, + options?: { includePrompt?: boolean; includeStatistics?: boolean } +): { path: string; name: string; meta: { title: string; iconActive: string; iconDeActive: string }; children: any[] } { + let children = spec.children + if (options?.includePrompt === false) { + children = children.filter((item: any) => item.name !== 'prompt') + } + if (options?.includeStatistics === false) { + children = children.filter((item: any) => item.name !== 'statistics') + } + const setChildren = children.map((item: any) => ({ + name: item.name, + path: item.path, + meta: { title: tFn(item.titleKey) }, + })) + return { + path: spec.path, + name: spec.name, + meta: { + title: tFn(spec.meta.titleKey), + iconActive: spec.meta.iconActive, + iconDeActive: spec.meta.iconDeActive, + }, + children: setChildren, + } +} + defineProps({ collapse: Boolean, }) @@ -21,6 +73,8 @@ const activeMenu = computed(() => route.path) const showSysmenu = computed(() => { return route.path.includes('/system') }) +/** 仅系统管理员(uid 为 1):普通成员不可见「自定义提示词」「统计分析」 */ +const isSystemAdmin = computed(() => String(userStore.uid) === '1') const formatRoute = (arr: any, parentPath = '') => { return arr.map((element: any) => { @@ -42,13 +96,32 @@ const routerList = computed(() => { const [sysRouter] = formatRoute( router.getRoutes().filter((route: any) => route?.name === 'system') ) - return sysRouter.children + // 系统管理下不允许有「统计分析」:不把 statistics 放入侧栏 + const systemChildrenWithoutStatistics = (sysRouter?.children || []).filter( + (r: any) => r.name !== 'statistics' + ) + // 系统管理下的「系统设置」里不允许有「自定义提示词」 + const systemChildren = systemChildrenWithoutStatistics.map((r: any) => { + if (r.name === 'setting' && r.children?.length) { + return { + ...r, + children: r.children.filter( + (c: any) => c.name !== 'prompt' && c.name !== 'customPrompt' + ), + } + } + return r + }) + return systemChildren } + // 排除「设置」及 /set/*,避免 xpack 修改 router 后菜单仍用 getRoutes() 的 set 子项 const list = router.getRoutes().filter((route) => { + if (route.name === 'set' || route.path === '/set' || String(route.path || '').startsWith('/set/')) { + return false + } return ( !route.path.includes('embeddedPage') && !route.path.includes('assistant') && - !route.path.includes('embeddedPage') && !route.path.includes('canvas') && !route.path.includes('member') && !route.path.includes('professional') && @@ -68,19 +141,18 @@ const routerList = computed(() => { ) }) - // 确保「设置」下的「自定义提示词」子项出现在菜单中(与 member/training 等一致) - const setRoute = list.find((r: any) => r.name === 'set' || r.path === '/set') - if (setRoute?.children) { - const hasPrompt = setRoute.children.some((c: any) => c.name === 'prompt' || c.path?.includes('prompt')) - if (!hasPrompt) { - const promptRoute = router.getRoutes().find((r: any) => r.name === 'prompt') - if (promptRoute) { - setRoute.children = [...setRoute.children, promptRoute] - } - } + if (!userStore.isSpaceAdmin) { + return list } - return list + // 「设置」完全由固定配置生成,不受 xpack/LicenseGenerator.generateRouters 影响;「自定义提示词」「统计分析」仅系统管理员可见 + const syntheticSet = buildSetMenuNode(SET_MENU_SPEC, t, { + includePrompt: isSystemAdmin.value, + includeStatistics: isSystemAdmin.value, + }) + // 「设置」放到菜单最后一项 + return [...list, syntheticSet] }) +