From 20154463edb3b2552587cd9a0de99de513c9805d Mon Sep 17 00:00:00 2001 From: huhuhuhr <757033301@qq.com> Date: Tue, 10 Mar 2026 00:24:36 +0800 Subject: [PATCH 1/8] feat(system): add custom prompt, statistics and Helm deployment - Add custom prompt API, model, CRUD and template prompt support - Add is_full_template migration for custom_prompt - Add statistics API and frontend statistics page - Add Helm chart for K8s deployment (deploy/helm/sqlbot) - Add build-k8s.sh and update build scripts - Update prompt management UI and menu (i18n, router, Menu.vue) - Add DsDetailPanel, update ChatList and Datasource views - Integrate custom prompt in chat LLM task and template.yaml - Add backend/logs/ to .gitignore Made-with: Cursor --- .gitignore | 1 + .../066_add_custom_prompt_is_full_template.py | 23 + backend/apps/api.py | 17 +- backend/apps/chat/models/chat_model.py | 26 +- backend/apps/chat/task/llm.py | 76 ++- backend/apps/system/api/custom_prompt.py | 109 ++++ backend/apps/system/api/statistics.py | 409 +++++++++++++++ backend/apps/system/api/template_prompt.py | 107 ++++ backend/apps/system/crud/custom_prompt.py | 145 ++++++ .../apps/system/models/custom_prompt_model.py | 31 ++ backend/templates/template.yaml | 6 +- build/README.md | 39 ++ build/build-k8s.sh | 123 +++++ build/build.sh | 3 + deploy/helm/sqlbot/Chart.yaml | 14 + deploy/helm/sqlbot/README.md | 99 ++++ deploy/helm/sqlbot/templates/_helpers.tpl | 60 +++ deploy/helm/sqlbot/templates/configmap.yaml | 10 + deploy/helm/sqlbot/templates/deployment.yaml | 84 ++++ deploy/helm/sqlbot/templates/ingress.yaml | 37 ++ deploy/helm/sqlbot/templates/pvc.yaml | 66 +++ deploy/helm/sqlbot/templates/secret.yaml | 14 + deploy/helm/sqlbot/templates/service.yaml | 19 + deploy/helm/sqlbot/values.yaml | 105 ++++ frontend/QWEN.md | 206 ++++++++ frontend/components.d.ts | 1 + frontend/docs/menu-mechanism.md | 117 +++++ frontend/docs/qa-custom-menu-xpack.md | 75 +++ frontend/src/api/prompt.ts | 14 + frontend/src/api/statistics.ts | 83 ++++ frontend/src/components/layout/Menu.vue | 72 ++- frontend/src/i18n/en.json | 37 +- frontend/src/i18n/ko-KR.json | 37 +- frontend/src/i18n/zh-CN.json | 37 +- frontend/src/router/index.ts | 29 +- frontend/src/router/watch.ts | 17 + frontend/src/views/chat/ChatList.vue | 267 ++++++++++ frontend/src/views/ds/Datasource.vue | 168 ++++++- frontend/src/views/ds/DsDetailPanel.vue | 263 ++++++++++ frontend/src/views/system/prompt/index.vue | 292 +++++++++-- .../src/views/system/statistics/index.vue | 469 ++++++++++++++++++ 41 files changed, 3697 insertions(+), 110 deletions(-) create mode 100644 backend/alembic/versions/066_add_custom_prompt_is_full_template.py create mode 100644 backend/apps/system/api/custom_prompt.py create mode 100644 backend/apps/system/api/statistics.py create mode 100644 backend/apps/system/api/template_prompt.py create mode 100644 backend/apps/system/crud/custom_prompt.py create mode 100644 backend/apps/system/models/custom_prompt_model.py create mode 100755 build/build-k8s.sh create mode 100644 deploy/helm/sqlbot/Chart.yaml create mode 100644 deploy/helm/sqlbot/README.md create mode 100644 deploy/helm/sqlbot/templates/_helpers.tpl create mode 100644 deploy/helm/sqlbot/templates/configmap.yaml create mode 100644 deploy/helm/sqlbot/templates/deployment.yaml create mode 100644 deploy/helm/sqlbot/templates/ingress.yaml create mode 100644 deploy/helm/sqlbot/templates/pvc.yaml create mode 100644 deploy/helm/sqlbot/templates/secret.yaml create mode 100644 deploy/helm/sqlbot/templates/service.yaml create mode 100644 deploy/helm/sqlbot/values.yaml create mode 100644 frontend/QWEN.md create mode 100644 frontend/docs/menu-mechanism.md create mode 100644 frontend/docs/qa-custom-menu-xpack.md create mode 100644 frontend/src/api/statistics.ts create mode 100644 frontend/src/views/ds/DsDetailPanel.vue create mode 100644 frontend/src/views/system/statistics/index.vue diff --git a/.gitignore b/.gitignore index 9ce08c6ad..28988b551 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 000000000..af4df0e0a --- /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 49db3a79c..70573257f 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 f096b0732..9bd0a0d25 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 a98b0ac40..79ee65fc1 100644 --- a/backend/apps/chat/task/llm.py +++ b/backend/apps/chat/task/llm.py @@ -40,7 +40,9 @@ 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 custom_prompt_crud from apps.system.crud.assistant import AssistantOutDs, AssistantOutDsFactory, get_assistant_ds +from apps.system.crud import custom_prompt as system_custom_prompt_crud from apps.system.crud.parameter_manage import get_groups from apps.system.schemas.system_schema import AssistantOutDsSchema from apps.terminology.curd.terminology import get_terminology_template @@ -295,24 +297,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 000000000..47202941a --- /dev/null +++ b/backend/apps/system/api/custom_prompt.py @@ -0,0 +1,109 @@ +""" +自定义提示词 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("/{id}", summary="获取单条自定义提示词") +@require_permissions(permission=SqlbotPermission(role=["ws_admin"])) +async def get_one(session: SessionDep, current_user: CurrentUser, id: int): + row = crud.get_one(session, id, current_user.oid) + if not row: + from fastapi import HTTPException + 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) + + +@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) diff --git a/backend/apps/system/api/statistics.py b/backend/apps/system/api/statistics.py new file mode 100644 index 000000000..283dc414c --- /dev/null +++ b/backend/apps/system/api/statistics.py @@ -0,0 +1,409 @@ +from datetime import datetime +from typing import List, Optional + +from fastapi import APIRouter, Query +from pydantic import BaseModel +from sqlalchemy import and_, case, func, or_, select + +from apps.chat.models.chat_model import Chat, ChatRecord +from apps.datasource.models.datasource import CoreDatasource +from apps.system.models.user import UserModel +from apps.system.schemas.permission import SqlbotPermission, require_permissions +from apps.swagger.i18n import PLACEHOLDER_PREFIX +from common.core.deps import CurrentUser, SessionDep +from common.core.pagination import Paginator +from common.core.schemas import PaginationParams, PaginatedResponse + + +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 + + +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 + + +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 + + +class DailyTrendPoint(BaseModel): + date: datetime + total_queries: int + success_queries: int + failed_queries: int + + +class StatisticsOverviewResponse(BaseModel): + overview: OverviewMetrics + by_datasource: List[DatasourceStats] + by_user: List[UserStats] + daily_trend: List[DailyTrendPoint] + + +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 + + +router = APIRouter(tags=["system_statistics"], prefix="/system/statistics") + + +def _build_common_filters( + current_user: CurrentUser, + start_time: Optional[datetime], + end_time: Optional[datetime], +): + filters = [Chat.oid == current_user.oid] + 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 + + +@router.get( + "/overview", + response_model=StatisticsOverviewResponse, + summary=f"{PLACEHOLDER_PREFIX}system_statistics_overview", +) +@require_permissions(permission=SqlbotPermission(role=["admin", "ws_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: + """ + 管理员统计分析总览: + - 基于当前工作空间(oid)的 chat / chat_record + - 可按时间范围过滤 + """ + filters = _build_common_filters(current_user, start_time, end_time) + + # 概览指标 + success_cond = and_(ChatRecord.finish.is_(True), ChatRecord.error.is_(None)) + failed_cond = or_(ChatRecord.finish.is_(False), ChatRecord.error.is_not(None)) + + overview_stmt = ( + select( + func.count(ChatRecord.id), + func.coalesce(func.sum(case((success_cond, 1), else_=0)), 0), + func.coalesce(func.sum(case((failed_cond, 1), else_=0)), 0), + func.count(func.distinct(ChatRecord.create_by)), + func.count(func.distinct(ChatRecord.datasource)), + func.count(func.distinct(ChatRecord.chat_id)), + func.avg( + func.extract( + "epoch", ChatRecord.finish_time - ChatRecord.create_time + ) + ), + ) + .join(Chat, ChatRecord.chat_id == Chat.id) + .where(*filters) + ) + + ( + total_queries, + success_queries, + failed_queries, + active_users, + active_datasources, + active_chats, + avg_duration_seconds, + ) = session.exec(overview_stmt).one() + + 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) + avg_duration_val: Optional[float] = ( + float(avg_duration_seconds) if avg_duration_seconds is not None else None + ) + + success_rate = float(success_queries) / float(total_queries) if total_queries else 0.0 + + 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_duration_val, + ) + + # 按数据源聚合 + ds_stmt = ( + select( + ChatRecord.datasource.label("datasource_id"), + CoreDatasource.name.label("datasource_name"), + func.count(ChatRecord.id).label("total_queries"), + func.coalesce( + func.sum(case((success_cond, 1), else_=0)), 0 + ).label("success_queries"), + func.coalesce( + func.sum(case((failed_cond, 1), else_=0)), 0 + ).label("failed_queries"), + func.count(func.distinct(ChatRecord.create_by)).label("active_users"), + ) + .join(Chat, ChatRecord.chat_id == Chat.id) + .join( + CoreDatasource, + ChatRecord.datasource == CoreDatasource.id, + isouter=True, + ) + .where(*filters) + .group_by(ChatRecord.datasource, CoreDatasource.name) + .order_by(func.count(ChatRecord.id).desc()) + ) + + ds_rows = session.exec(ds_stmt).all() + by_datasource: List[DatasourceStats] = [] + for row in ds_rows: + ( + datasource_id, + datasource_name, + ds_total, + ds_success, + ds_failed, + ds_active_users, + ) = row + ds_total = int(ds_total or 0) + ds_success = int(ds_success or 0) + ds_failed = int(ds_failed or 0) + ds_active_users = int(ds_active_users or 0) + ds_success_rate = float(ds_success) / float(ds_total) if ds_total else 0.0 + by_datasource.append( + DatasourceStats( + datasource_id=datasource_id, + datasource_name=datasource_name, + total_queries=ds_total, + success_queries=ds_success, + failed_queries=ds_failed, + success_rate=ds_success_rate, + active_users=ds_active_users, + ) + ) + + # 按用户聚合 + user_stmt = ( + select( + ChatRecord.create_by.label("user_id"), + UserModel.name.label("user_name"), + func.count(ChatRecord.id).label("total_queries"), + func.coalesce( + func.sum(case((success_cond, 1), else_=0)), 0 + ).label("success_queries"), + func.coalesce( + func.sum(case((failed_cond, 1), else_=0)), 0 + ).label("failed_queries"), + func.count(func.distinct(ChatRecord.datasource)).label( + "active_datasources" + ), + ) + .join(Chat, ChatRecord.chat_id == Chat.id) + .join(UserModel, ChatRecord.create_by == UserModel.id, isouter=True) + .where(*filters) + .group_by(ChatRecord.create_by, UserModel.name) + .order_by(func.count(ChatRecord.id).desc()) + ) + + user_rows = session.exec(user_stmt).all() + by_user: List[UserStats] = [] + for row in user_rows: + ( + user_id, + user_name, + u_total, + u_success, + u_failed, + u_active_ds, + ) = row + u_total = int(u_total or 0) + u_success = int(u_success or 0) + u_failed = int(u_failed or 0) + u_active_ds = int(u_active_ds or 0) + u_success_rate = float(u_success) / float(u_total) if u_total else 0.0 + by_user.append( + UserStats( + user_id=user_id, + user_name=user_name, + total_queries=u_total, + success_queries=u_success, + failed_queries=u_failed, + success_rate=u_success_rate, + active_datasources=u_active_ds, + ) + ) + + # 按天趋势 + day_expr = func.date_trunc("day", ChatRecord.create_time).label("date") + trend_stmt = ( + select( + day_expr, + func.count(ChatRecord.id).label("total_queries"), + func.coalesce( + func.sum(case((success_cond, 1), else_=0)), 0 + ).label("success_queries"), + func.coalesce( + func.sum(case((failed_cond, 1), else_=0)), 0 + ).label("failed_queries"), + ) + .join(Chat, ChatRecord.chat_id == Chat.id) + .where(*filters) + .group_by(day_expr) + .order_by(day_expr.asc()) + ) + + trend_rows = session.exec(trend_stmt).all() + daily_trend: List[DailyTrendPoint] = [ + DailyTrendPoint( + date=row.date, + total_queries=int(row.total_queries or 0), + success_queries=int(row.success_queries or 0), + failed_queries=int(row.failed_queries or 0), + ) + for row in trend_rows + ] + + return StatisticsOverviewResponse( + overview=overview, + by_datasource=by_datasource, + by_user=by_user, + daily_trend=daily_trend, + ) + + +def _record_filters( + current_user: CurrentUser, + start_time: Optional[datetime], + end_time: Optional[datetime], + user_id: Optional[int], + datasource_id: Optional[int], + failed_only: bool, +): + filters = [Chat.oid == current_user.oid] + 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) + 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(or_(ChatRecord.finish.is_(False), ChatRecord.error.is_not(None))) + return filters + + +@router.get( + "/records", + response_model=PaginatedResponse[RecordItem], + summary=f"{PLACEHOLDER_PREFIX}system_statistics_records", +) +@require_permissions(permission=SqlbotPermission(role=["admin", "ws_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]: + filters = _record_filters( + current_user, start_time, end_time, user_id, datasource_id, failed_only + ) + stmt = ( + 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, + ) + .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()) + ) + pagination = PaginationParams(page=page, size=size) + paginator = Paginator(session) + page_result = await paginator.get_paginated_response(stmt, pagination) + # Truncate question/error and map to RecordItem + items = [] + for row in page_result.items: + if isinstance(row, dict): + q = row.get("question") or "" + e = row.get("error") or "" + items.append( + RecordItem( + id=row.get("id"), + chat_id=row.get("chat_id"), + create_time=row.get("create_time"), + create_by=row.get("create_by"), + user_name=row.get("user_name"), + datasource_id=row.get("datasource_id"), + datasource_name=row.get("datasource_name"), + question=(q[:80] + "…") if len(q) > 80 else (q or None), + finish=bool(row.get("finish")), + error=(e[:200] + "…") if len(e) > 200 else (e or None), + ) + ) + else: + items.append(RecordItem.model_validate(row)) + return PaginatedResponse[RecordItem]( + items=items, + total=page_result.total, + page=page_result.page, + size=page_result.size, + total_pages=page_result.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 000000000..a732c2529 --- /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 = llm.invoke(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 000000000..be76ba909 --- /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/models/custom_prompt_model.py b/backend/apps/system/models/custom_prompt_model.py new file mode 100644 index 000000000..5c72baf38 --- /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/templates/template.yaml b/backend/templates/template.yaml index 6118a9b1f..667bc7db1 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 075571e9b..96778ec2e 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 000000000..def52efed --- /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 a1457b0f2..6bfdb98d8 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 000000000..4dc80ed93 --- /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 000000000..58bb14988 --- /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 000000000..e588011b1 --- /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 000000000..1725de26b --- /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 000000000..b7577f6bd --- /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 000000000..ddbe6f479 --- /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 000000000..6786bb853 --- /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 000000000..fe3da1d33 --- /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 000000000..15488df62 --- /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 000000000..3288c7927 --- /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 000000000..7a903897b --- /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 6d321dc89..be03350a4 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -60,6 +60,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 000000000..4baa3fb80 --- /dev/null +++ b/frontend/docs/menu-mechanism.md @@ -0,0 +1,117 @@ +# 前端菜单管理机制 - 深度分析 + +## 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 控制**以及**修改设置子项应改哪里**,见专门问答文档: + +- **[自定义菜单与绕过 xpack — 常见问题(Q&A)](./qa-custom-menu-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 000000000..efcf74246 --- /dev/null +++ b/frontend/docs/qa-custom-menu-xpack.md @@ -0,0 +1,75 @@ +# 自定义菜单与绕过 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`** + +--- + +**文档版本**:v1 +**最后更新**:基于自定义菜单与绕过 xpack 的实现整理。 diff --git a/frontend/src/api/prompt.ts b/frontend/src/api/prompt.ts index 1d98b2bf6..9186346ba 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 000000000..e24bb2bc7 --- /dev/null +++ b/frontend/src/api/statistics.ts @@ -0,0 +1,83 @@ +import { request } from '@/utils/request' + +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 +} + +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 +} + +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 +} + +export interface DailyTrendPoint { + date: string + total_queries: number + success_queries: number + failed_queries: number +} + +export interface StatisticsOverviewResponse { + overview: OverviewMetrics + by_datasource: DatasourceStats[] + by_user: UserStats[] + daily_trend: DailyTrendPoint[] +} + +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 +} + +export interface StatisticsRecordsResponse { + items: RecordItem[] + total: number + page: number + size: number + total_pages: number +} + +export const statisticsApi = { + getOverview: (params?: { start_time?: string; end_time?: string }) => + request.get('/system/statistics/overview', { 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 9145f0e44..011806a9e 100644 --- a/frontend/src/components/layout/Menu.vue +++ b/frontend/src/components/layout/Menu.vue @@ -4,9 +4,52 @@ 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' }, + ], +} + +/** 根据固定配置生成「设置」菜单节点(与 getRoutes 解耦),供侧栏使用 */ +function buildSetMenuNode( + spec: typeof SET_MENU_SPEC, + tFn: (key: string) => string +): { path: string; name: string; meta: { title: string; iconActive: string; iconDeActive: string }; children: any[] } { + const setChildren = spec.children.map((item) => ({ + 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, }) @@ -68,18 +111,25 @@ 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] - } - } + // 「设置」节点与 getRoutes() 完全解耦:用固定配置生成,不读 router 的 children,避免 xpack 修改路由后少项 + const setIndex = list.findIndex((r: any) => r.name === 'set' || r.path === '/set') + if (!userStore.isSpaceAdmin) { + return list.filter((r: any) => r.name !== 'set' && r.path !== '/set') + } + const syntheticSet = buildSetMenuNode(SET_MENU_SPEC, t) + // 只保留「设置」下的入口:排除 /set 及其子路径(如 /set/prompt),避免出现两个「自定义提示词」 + const listWithoutSet = list.filter( + (r: any) => + r.name !== 'set' && + r.path !== '/set' && + !String(r.path || '').startsWith('/set/') + ) + if (setIndex === -1) { + return [...listWithoutSet, syntheticSet] as any[] } - return list + const result: any[] = [...listWithoutSet] + result.splice(setIndex, 0, syntheticSet) + return result }) diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 0b88ee07f..589450c71 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -93,7 +93,24 @@ "export_hint": "Export all {type} prompts?", "prompt_word_name_de": "Do you want to delete the prompt word: {msg}?", "disable_field": "Disable Field", - "to_disable_it": "This field has a configured table relationship. If disabled, the relationship will be invalid. Are you sure you want to disable it?" + "to_disable_it": "This field has a configured table relationship. If disabled, the relationship will be invalid. Are you sure you want to disable it?", + "default_prompt": "Default prompt", + "default_tag": "Default", + "default_prompt_and_variables": "Default prompt and variables", + "available_variables": "Available variables", + "default_content": "Default prompt content", + "llm_optimize": "LLM optimize", + "please_enter_prompt_first": "Please enter prompt content first", + "optimize_success": "Optimized successfully", + "optimize_failed": "Optimization failed, please try again later", + "append_hint": "This content is inserted at the custom prompt position in the default template. Add rules or notes only; no need to write the full template.", + "use_full_template": "Use full template (override default)", + "full_template_hint": "The content above will be used as the full system prompt. Keep placeholders such as {engine}, {schema}, {custom_prompt}. You can copy from default template then edit.", + "copy_from_default_template": "Copy from default template", + "placeholder_full_template": "Paste or edit the full system prompt, keep placeholders like {engine}, {schema}", + "full_template_tag": "Full template", + "load_default_first": "Please load default prompt first", + "filled_from_default": "Filled from default template; edit and save" }, "training": { "effective_data_sources": "Effective data sources", @@ -322,10 +339,26 @@ "copied": "Copied", "ask_failed": "Q&A failed", "enhanced_think": "Deep thinking", - "enhanced_think_tooltip": "When on, the model does a deep thinking step before generating SQL; may improve accuracy but takes longer" + "enhanced_think_tooltip": "When on, the model does a deep thinking step before generating SQL; may improve accuracy but takes longer", + "clear_history": "Clear history", + "clear_history_before_today": "Delete all conversations before today", + "clear_history_before_7_days": "Delete all conversations before 7 days ago", + "clear_history_all": "Delete all conversations", + "clear_history_confirm": "This will delete conversation history in the selected range and cannot be undone. Continue?", + "selected_chats_delete_confirm": "Delete {msg} selected conversations?" }, "ds": { "title": "Data Sources", + "data_center": "Data Center", + "tab_datasource": "Data Sources", + "tab_uploaded_data": "Uploaded Data", + "search_placeholder": "Search", + "column_info": "Column Info", + "table_preview": "Table Preview", + "go_to_analysis": "Go to Analysis", + "select_one_hint": "Select an item from the left to view details", + "upload_drag_hint": "Click or drag file here to upload", + "upload_format_hint": "Supports xlsx, xls, csv", "add": "Add Data Source", "delete": "Delete Data Source", "name": "Data Source Name", diff --git a/frontend/src/i18n/ko-KR.json b/frontend/src/i18n/ko-KR.json index e735d9f52..90a9e315a 100644 --- a/frontend/src/i18n/ko-KR.json +++ b/frontend/src/i18n/ko-KR.json @@ -93,7 +93,24 @@ "export_hint": "모든 {type} 프롬프트를 내보내시겠습니까?", "prompt_word_name_de": "프롬프트를 삭제하시겠습니까: {msg}?", "disable_field": "필드 비활성화", - "to_disable_it": "이 필드에는 테이블 관계가 설정되어 있습니다. 비활성화하면 관계가 유효하지 않게 됩니다. 비활성화하시겠습니까?" + "to_disable_it": "이 필드에는 테이블 관계가 설정되어 있습니다. 비활성화하면 관계가 유효하지 않게 됩니다. 비활성화하시겠습니까?", + "default_prompt": "기본 프롬프트", + "default_tag": "기본", + "default_prompt_and_variables": "기본 프롬프트 및 변수", + "available_variables": "사용 가능한 변수", + "default_content": "기본 프롬프트 내용", + "llm_optimize": "LLM 최적화", + "please_enter_prompt_first": "먼저 프롬프트 내용을 입력하세요", + "optimize_success": "최적화 완료", + "optimize_failed": "최적화 실패, 나중에 다시 시도하세요", + "append_hint": "이 내용은 기본 템플릿의 사용자 정의 위치에 삽입됩니다. 규칙이나 설명만 추가하면 되며 전체 템플릿을 작성할 필요가 없습니다.", + "use_full_template": "전체 템플릿 사용(기본 덮어쓰기)", + "full_template_hint": "위 내용이 전체 시스템 프롬프트로 사용됩니다. {engine}, {schema}, {custom_prompt} 등 자리 표시자를 유지하세요. 기본 템플릿에서 복사한 후 수정할 수 있습니다.", + "copy_from_default_template": "기본 템플릿에서 복사", + "placeholder_full_template": "전체 시스템 프롬프트를 붙여넣거나 편집하고, {engine}, {schema} 등 자리 표시자 유지", + "full_template_tag": "전체 템플릿", + "load_default_first": "먼저 기본 프롬프트를 로드하세요", + "filled_from_default": "기본 템플릿에서 채움; 수정 후 저장하세요" }, "training": { "effective_data_sources": "유효한 데이터 소스", @@ -322,10 +339,26 @@ "copied": "복사됨", "ask_failed": "데이터 조회 실패", "enhanced_think": "심층 사고", - "enhanced_think_tooltip": "켜면 SQL 생성 전에 심층 사고를 한 번 수행합니다. 정확도가 올라갈 수 있으나 시간이 더 걸립니다" + "enhanced_think_tooltip": "켜면 SQL 생성 전에 심층 사고를 한 번 수행합니다. 정확도가 올라갈 수 있으나 시간이 더 걸립니다", + "clear_history": "히스토리 비우기", + "clear_history_before_today": "오늘 이전의 모든 대화 삭제", + "clear_history_before_7_days": "7일 이전의 모든 대화 삭제", + "clear_history_all": "모든 대화 삭제", + "clear_history_confirm": "선택한 범위의 대화 기록이 삭제되며, 이 작업은 되돌릴 수 없습니다. 계속하시겠습니까?", + "selected_chats_delete_confirm": "선택한 {msg}개의 대화를 삭제하시겠습니까?" }, "ds": { "title": "데이터 소스", + "data_center": "데이터 센터", + "tab_datasource": "데이터 소스", + "tab_uploaded_data": "업로드된 데이터", + "search_placeholder": "검색", + "column_info": "열 정보", + "table_preview": "테이블 미리보기", + "go_to_analysis": "분석으로", + "select_one_hint": "왼쪽에서 항목을 선택하여 세부 정보를 확인하세요", + "upload_drag_hint": "클릭하거나 파일을 여기로 끌어다 놓으세요", + "upload_format_hint": "xlsx, xls, csv 지원", "local_excelcsv": "로컬 Excel/CSV", "add": "데이터 소스 추가", "delete": "데이터 소스 삭제", diff --git a/frontend/src/i18n/zh-CN.json b/frontend/src/i18n/zh-CN.json index ec20ddc84..77ffd85b3 100644 --- a/frontend/src/i18n/zh-CN.json +++ b/frontend/src/i18n/zh-CN.json @@ -93,7 +93,24 @@ "export_hint": "是否导出全部{type}提示词?", "prompt_word_name_de": "是否删除提示词:{msg}?", "disable_field": "禁用字段", - "to_disable_it": "该字段已配置表关联关系,若禁用后,关联关系将失效,确定禁用?" + "to_disable_it": "该字段已配置表关联关系,若禁用后,关联关系将失效,确定禁用?", + "default_prompt": "默认提示词", + "default_tag": "默认", + "default_prompt_and_variables": "默认提示词与可用变量", + "available_variables": "可用变量", + "default_content": "默认提示词内容", + "llm_optimize": "LLM 优化", + "please_enter_prompt_first": "请先输入提示词内容", + "optimize_success": "优化成功", + "optimize_failed": "优化失败,请稍后重试", + "append_hint": "此处内容会插入到默认提示词中的「自定义」位置,用于补充规则或说明;无需写完整模板。", + "use_full_template": "使用完整模板(覆盖默认)", + "full_template_hint": "将使用上述内容作为完整系统提示词,需保留占位符如 {engine}、{schema}、{custom_prompt} 等。可从下方「从默认模板复制」后修改。", + "copy_from_default_template": "从默认模板复制", + "placeholder_full_template": "粘贴或编辑完整系统提示词,保留 {engine}、{schema} 等占位符", + "full_template_tag": "完整模板", + "load_default_first": "请先加载默认提示词", + "filled_from_default": "已从默认模板填充,可在此基础上修改后保存" }, "training": { "effective_data_sources": "生效数据源", @@ -322,10 +339,26 @@ "copied": "已复制", "ask_failed": "问数失败", "enhanced_think": "深度思考", - "enhanced_think_tooltip": "开启后在生成 SQL 前会先进行一步深度思考,可能提高准确率但会增加耗时" + "enhanced_think_tooltip": "开启后在生成 SQL 前会先进行一步深度思考,可能提高准确率但会增加耗时", + "clear_history": "清空历史", + "clear_history_before_today": "删除今天之前的所有对话", + "clear_history_before_7_days": "删除 7 天前的所有对话", + "clear_history_all": "删除全部历史对话", + "clear_history_confirm": "将删除所选范围内的历史对话,且无法恢复,是否继续?", + "selected_chats_delete_confirm": "是否删除选中的 {msg} 条对话?" }, "ds": { "title": "数据源", + "data_center": "数据中心", + "tab_datasource": "数据源", + "tab_uploaded_data": "上传的数据", + "search_placeholder": "搜索", + "column_info": "列信息", + "table_preview": "表预览", + "go_to_analysis": "去分析", + "select_one_hint": "请从左侧选择一项查看详情", + "upload_drag_hint": "点击或将文件拖拽至此上传", + "upload_format_hint": "支持 xlsx、xls、csv 格式", "local_excelcsv": "本地 Excel/CSV", "add": "添加数据源", "delete": "删除数据源", diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index b1c266752..2beb631d0 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -30,6 +30,7 @@ import Platform from '@/views/system/platform/index.vue' import Permission from '@/views/system/permission/index.vue' import User from '@/views/system/user/User.vue' import Workspace from '@/views/system/workspace/index.vue' +import Statistics from '@/views/system/statistics/index.vue' import Page401 from '@/views/error/index.vue' import ChatPreview from '@/views/chat/preview.vue' @@ -110,13 +111,13 @@ export const routes = [ meta: { title: t('workspace.set'), iconActive: 'set', iconDeActive: 'noSet' }, children: [ { - path: '/set/member', + path: 'member', name: 'member', component: Member, meta: { title: t('workspace.member_management') }, }, { - path: '/set/permission', + path: 'permission', name: 'permission', component: Permission, meta: { title: t('workspace.permission_configuration') }, @@ -128,19 +129,19 @@ export const routes = [ meta: { title: t('embedded.assistant_app') }, }, */ { - path: '/set/professional', + path: 'professional', name: 'professional', component: Professional, meta: { title: t('professional.professional_terminology') }, }, { - path: '/set/training', + path: 'training', name: 'training', component: Training, meta: { title: t('training.data_training') }, }, { - path: '/set/prompt', + path: 'prompt', name: 'prompt', component: Prompt, meta: { title: t('prompt.customize_prompt_words') }, @@ -172,6 +173,16 @@ export const routes = [ component: User, meta: { title: t('user.user_management'), iconActive: 'user', iconDeActive: 'noUser' }, }, + { + path: 'statistics', + name: 'statistics', + component: Statistics, + meta: { + title: '统计分析', + iconActive: 'dashboard', + iconDeActive: 'noDashboard', + }, + }, { path: 'workspace', name: 'workspace', @@ -205,7 +216,7 @@ export const routes = [ { path: 'setting', meta: { title: t('system.system_settings'), iconActive: 'set', iconDeActive: 'noSet' }, - redirect: 'system_/appearance', + redirect: { name: 'appearance' }, name: 'setting', children: [ { @@ -238,6 +249,12 @@ export const routes = [ component: Platform, meta: { title: t('platform.title') }, }, + { + path: 'prompt', + name: 'customPrompt', + component: Prompt, + meta: { title: t('prompt.customize_prompt_words') }, + }, ], }, { diff --git a/frontend/src/router/watch.ts b/frontend/src/router/watch.ts index e862d286f..ea2849808 100644 --- a/frontend/src/router/watch.ts +++ b/frontend/src/router/watch.ts @@ -6,6 +6,7 @@ import { request } from '@/utils/request' import type { Router } from 'vue-router' import { generateDynamicRouters } from './dynamic' import { toLoginPage } from '@/utils/utils' +import { i18n } from '@/i18n' const appearanceStore = useAppearanceStoreWithOut() const userStore = useUserStore() @@ -14,11 +15,27 @@ const whiteList = ['/login', '/admin-login'] const assistantWhiteList = ['/assistant', '/embeddedPage', '/embeddedCommon', '/401'] const wsAdminRouterList = ['/ds/index', '/as/index'] + +/** xpack 会删掉 /set 下的 prompt 子路由,导致点击「自定义提示词」白屏。此处在其执行后补回。 */ +function ensureSetPromptRoute(router: Router) { + const setRoute = router.getRoutes().find((r: any) => r.name === 'set') + const hasPrompt = setRoute?.children?.some((c: any) => c.name === 'prompt') + if (setRoute && !hasPrompt) { + router.addRoute('set', { + path: 'prompt', + name: 'prompt', + component: () => import('@/views/system/prompt/index.vue'), + meta: { title: i18n.global.t('prompt.customize_prompt_words') }, + }) + } +} + export const watchRouter = (router: Router) => { router.beforeEach(async (to: any, from: any, next: any) => { await loadXpackStatic() await appearanceStore.setAppearance() LicenseGenerator.generateRouters(router) + ensureSetPromptRoute(router) if (to.path.startsWith('/login') && userStore.getUid) { next(to?.query?.redirect || '/') return diff --git a/frontend/src/views/chat/ChatList.vue b/frontend/src/views/chat/ChatList.vue index c2a98aa3b..c8fe67a88 100644 --- a/frontend/src/views/chat/ChatList.vue +++ b/frontend/src/views/chat/ChatList.vue @@ -88,6 +88,28 @@ const computedChatList = computed(() => { return _list }) +const selectedIds = ref([]) + +const hasSelection = computed(() => selectedIds.value.length > 0) + +const allVisibleIds = computed(() => { + const ids: number[] = [] + computedChatList.value.forEach((group: { list: Chat[] }) => { + group.list?.forEach((chat: Chat) => { + if (chat.id !== undefined) { + ids.push(chat.id) + } + }) + }) + return ids +}) + +const allSelected = computed( + () => + allVisibleIds.value.length > 0 && + allVisibleIds.value.every((id) => selectedIds.value.includes(id)) +) + const emits = defineEmits(['chatSelected', 'chatRenamed', 'chatDeleted', 'update:loading']) const _loading = computed({ @@ -99,10 +121,168 @@ const _loading = computed({ }, }) +function isSelected(id?: number) { + if (id === undefined) return false + return selectedIds.value.includes(id) +} + +function toggleSelect(chat: Chat) { + if (chat.id === undefined) return + if (isSelected(chat.id)) { + selectedIds.value = selectedIds.value.filter((id) => id !== chat.id) + } else { + selectedIds.value = [...selectedIds.value, chat.id] + } +} + +function clearSelection() { + selectedIds.value = [] +} + +function toggleSelectAll() { + if (allSelected.value) { + clearSelection() + return + } + selectedIds.value = [...allVisibleIds.value] +} + function onClickHistory(chat: Chat) { emits('chatSelected', chat) } +async function handleBulkDeleteSelected() { + if (!selectedIds.value.length) return + try { + await ElMessageBox.confirm( + t('qa.selected_chats_delete_confirm', { msg: selectedIds.value.length }), + t('qa.clear_history'), + { + confirmButtonType: 'danger', + tip: t('common.proceed_with_caution'), + confirmButtonText: t('dashboard.delete'), + cancelButtonText: t('common.cancel'), + customClass: 'confirm-no_icon', + autofocus: false, + } + ) + } catch { + return + } + + const ids = [...selectedIds.value] + if (!ids.length) return + + const chatMap = new Map() + props.chatList.forEach((c: Chat) => { + if (c.id !== undefined) { + chatMap.set(c.id, c) + } + }) + + _loading.value = true + try { + await Promise.all( + ids.map((id) => { + const chat = chatMap.get(id) + return chatApi.deleteChat(id, chat?.brief) + }) + ) + ids.forEach((id) => emits('chatDeleted', id)) + ElMessage({ + type: 'success', + message: t('dashboard.delete_success'), + }) + } catch (err: any) { + ElMessage({ + type: 'error', + message: err?.message || '删除失败', + }) + } finally { + _loading.value = false + clearSelection() + } +} + +type ClearScope = 'before_today' | 'before_7_days' | 'all' + +function getIdsByScope(scope: ClearScope): number[] { + const todayStart = dayjs(dayjs().format('YYYY-MM-DD') + ' 00:00:00').toDate() + const weekStart = dayjs(dayjs().subtract(7, 'day').format('YYYY-MM-DD') + ' 00:00:00').toDate() + + const ids: number[] = [] + props.chatList.forEach((chat: Chat) => { + if (chat.id === undefined) return + const time = getDate(chat.create_time) + if (!time) return + if (scope === 'all') { + ids.push(chat.id) + return + } + if (scope === 'before_today' && time < todayStart) { + ids.push(chat.id) + return + } + if (scope === 'before_7_days' && time < weekStart) { + ids.push(chat.id) + } + }) + return ids +} + +async function handleClearByScope(scope: ClearScope) { + const ids = getIdsByScope(scope) + if (!ids.length) { + ElMessage({ + type: 'info', + message: t('workspace.historical_dialogue'), + }) + return + } + + try { + await ElMessageBox.confirm(t('qa.clear_history_confirm'), t('qa.clear_history'), { + confirmButtonType: 'danger', + confirmButtonText: t('dashboard.delete'), + cancelButtonText: t('common.cancel'), + customClass: 'confirm-no_icon', + autofocus: false, + }) + } catch { + return + } + + const chatMap = new Map() + props.chatList.forEach((c: Chat) => { + if (c.id !== undefined) { + chatMap.set(c.id, c) + } + }) + + _loading.value = true + try { + await Promise.all( + ids.map((id) => { + const chat = chatMap.get(id) + return chatApi.deleteChat(id, chat?.brief) + }) + ) + ids.forEach((id) => emits('chatDeleted', id)) + ElMessage({ + type: 'success', + message: t('dashboard.delete_success'), + }) + } catch (err: any) { + ElMessage({ + type: 'error', + message: err?.message || '删除失败', + }) + } finally { + _loading.value = false + clearSelection() + } +} + function handleCommand(command: string | number | object, chat: Chat) { if (chat && chat.id !== undefined) { switch (command) { @@ -201,6 +381,51 @@ const handleConfirmPassword = () => {