diff --git a/.gitignore b/.gitignore index fab542482..c53da76a2 100644 --- a/.gitignore +++ b/.gitignore @@ -189,3 +189,7 @@ test.py !/.venv/ + +sqlbot-xpack + +.claude diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..a45f1a63d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,30 @@ +# Contributing + +As a contributor, you should agree that: + +a. The producer can adjust the open-source agreement to be more strict or relaxed as deemed necessary. +b. Your contributed code may be used for commercial purposes, including but not limited to its cloud business operations. + +## Create pull request +PR are always welcome, even if they only contain small fixes like typos or a few lines of code. If there will be a significant effort, please document it as an issue and get a discussion going before starting to work on it. + +Please submit a PR broken down into small changes' bit by bit. A PR consisting of a lot of features and code changes may be hard to review. It is recommended to submit PRs in an incremental fashion. + +This [development guideline](https://sqlbot.org/docs/v1/installation/source_run/) contains information about repository structure, how to set up development environment, how to run it, and more. + +Note: If you split your pull request to small changes, please make sure any of the changes goes to master will not break anything. Otherwise, it can not be merged until this feature complete. + +## Report issues +It is a great way to contribute by reporting an issue. Well-written and complete bug reports are always welcome! Please open an issue and follow the template to fill in required information. + +Before opening any issue, please look up the existing issues to avoid submitting a duplication. +If you find a match, you can "subscribe" to it to get notified on updates. If you have additional helpful information about the issue, please leave a comment. + +When reporting issues, always include: + +* Which version you are using. +* Steps to reproduce the issue. +* Snapshots or log files if needed + +Because the issues are open to the public, when submitting files, be sure to remove any sensitive information, e.g. username, password, IP address, and company name. You can +replace those parts with "REDACTED" or other strings like "****". diff --git a/Dockerfile b/Dockerfile index b1c1e9256..c8727b40a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -95,6 +95,6 @@ EXPOSE 3000 8000 8001 5432 # Add health check HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD curl -f http://localhost:8000 || exit 1 + CMD python3 -c "import os, urllib.request; urllib.request.urlopen(f'http://localhost:8000/{os.environ.get(\"CONTEXT_PATH\", \"\")}', timeout=3)" || exit 1 ENTRYPOINT ["sh", "start.sh"] diff --git a/README.md b/README.md index 078478e34..c5a258844 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,23 @@ SQLBot 是一款基于大语言模型和 RAG 的智能问数系统,由 DataEas - **易于集成**:支持多种集成方式,提供 Web 嵌入、弹窗嵌入、MCP 调用等能力;能够快速嵌入到 n8n、Dify、MaxKB、DataEase 等应用,让各类应用快速拥有智能问数能力。 - **越问越准**:支持自定义提示词、术语库配置,可维护 SQL 示例校准逻辑,精准匹配业务场景;高效运营,基于用户交互数据持续迭代优化,问数效果随使用逐步提升,越问越准。 +## 支持的大模型服务商 + +| 服务商 | API 兼容 | +|--------|----------| +| 阿里云百炼 | OpenAI 兼容 | +| 千帆大模型 | OpenAI 兼容 | +| DeepSeek | OpenAI 兼容 | +| 腾讯混元 | OpenAI 兼容 | +| 讯飞星火 | OpenAI 兼容 | +| Gemini | OpenAI 兼容 | +| OpenAI | 原生 | +| Kimi | OpenAI 兼容 | +| 腾讯云 | OpenAI 兼容 | +| 火山引擎 | OpenAI 兼容 | +| MiniMax | OpenAI 兼容 | +| 通用 OpenAI 兼容 | 自定义 | + ## 快速开始 ### 安装部署 @@ -66,7 +83,7 @@ docker run -d \ 如你有更多问题,可以加入我们的技术交流群与我们交流。 -contact_me_qr +contact_me_qr ## UI 展示 diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 0ae300dd7..19e19fce6 100755 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -24,13 +24,14 @@ # from apps.system.models.user import SQLModel # noqa # from apps.settings.models.setting_models import SQLModel -from apps.chat.models.chat_model import SQLModel +#from apps.chat.models.chat_model import SQLModel from apps.terminology.models.terminology_model import SQLModel -#from apps.custom_prompt.models.custom_prompt_model import SQLModel -from apps.data_training.models.data_training_model import SQLModel +from sqlbot_xpack.custom_prompt.models.custom_prompt_model import SQLModel +#from apps.data_training.models.data_training_model import SQLModel # from apps.dashboard.models.dashboard_model import SQLModel from common.core.config import settings # noqa #from apps.datasource.models.datasource import SQLModel +from apps.system.models.system_model import SQLModel target_metadata = SQLModel.metadata diff --git a/backend/alembic/versions/066_update_assistant_model.py b/backend/alembic/versions/066_update_assistant_model.py new file mode 100644 index 000000000..9e7ea17ce --- /dev/null +++ b/backend/alembic/versions/066_update_assistant_model.py @@ -0,0 +1,30 @@ +"""066_update_assistant_model + +Revision ID: 8adc3a4919be +Revises: 8ff90df7871d +Create Date: 2026-04-28 15:55:42.757276 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '8adc3a4919be' +down_revision = '8ff90df7871d' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('sys_assistant', sa.Column('enable_custom_model', sa.Boolean(), nullable=True)) + op.add_column('sys_assistant', sa.Column('custom_model', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + op.drop_column('sys_assistant', 'custom_model') + op.drop_column('sys_assistant', 'enable_custom_model') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/067_ai_model_workspace_mapping.py b/backend/alembic/versions/067_ai_model_workspace_mapping.py new file mode 100644 index 000000000..aa558ba25 --- /dev/null +++ b/backend/alembic/versions/067_ai_model_workspace_mapping.py @@ -0,0 +1,34 @@ +"""067_ai_model_workspace_mapping + +Revision ID: e51127e9aa4a +Revises: 8adc3a4919be +Create Date: 2026-06-01 14:14:23.112843 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'e51127e9aa4a' +down_revision = '8adc3a4919be' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('ai_model_workspace_mapping', + sa.Column('id', sa.BigInteger(), nullable=False), + sa.Column('ai_model_id', sa.BigInteger(), nullable=True), + sa.Column('workspace_id', sa.BigInteger(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('ai_model_workspace_mapping') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/068_alter_sys_logs_user_agent_length.py b/backend/alembic/versions/068_alter_sys_logs_user_agent_length.py new file mode 100644 index 000000000..2ecbf966a --- /dev/null +++ b/backend/alembic/versions/068_alter_sys_logs_user_agent_length.py @@ -0,0 +1,29 @@ +"""068_alter_sys_logs_user_agent_length + +Revision ID: a1b2c3d4e5f6 +Revises: e51127e9aa4a +Create Date: 2026-06-09 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = 'a1b2c3d4e5f6' +down_revision = 'e51127e9aa4a' +branch_labels = None +depends_on = None + + +def upgrade(): + op.alter_column('sys_logs', 'user_agent', + existing_type=sa.VARCHAR(length=255), + type_=sa.VARCHAR(length=500), + existing_nullable=True) + + +def downgrade(): + op.alter_column('sys_logs', 'user_agent', + existing_type=sa.VARCHAR(length=500), + type_=sa.VARCHAR(length=255), + existing_nullable=True) diff --git a/backend/alembic/versions/069_term_custom_prompt.py b/backend/alembic/versions/069_term_custom_prompt.py new file mode 100644 index 000000000..e02aaac37 --- /dev/null +++ b/backend/alembic/versions/069_term_custom_prompt.py @@ -0,0 +1,31 @@ +"""069_term_custom_prompt + +Revision ID: 1f82cad3546e +Revises: a1b2c3d4e5f6 +Create Date: 2026-06-15 14:51:12.280391 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '1f82cad3546e' +down_revision = 'a1b2c3d4e5f6' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('custom_prompt', sa.Column('advanced_application', sa.BigInteger(), nullable=True)) + op.add_column('terminology', sa.Column('advanced_application', sa.BigInteger(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('terminology', 'advanced_application') + op.drop_column('custom_prompt', 'advanced_application') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/070_add_table_column_comments.py b/backend/alembic/versions/070_add_table_column_comments.py new file mode 100644 index 000000000..2b0aa4e5a --- /dev/null +++ b/backend/alembic/versions/070_add_table_column_comments.py @@ -0,0 +1,440 @@ +"""070_add_table_column_comments + +为所有数据库表和字段添加中文备注(COMMENT ON)。 +Revision ID: 070a1b2c3d4e5 +Revises: 1f82cad3546e +Create Date: 2026-06-25 00:00:00.000000 + +""" +from alembic import op + +# revision identifiers, used by Alembic. +revision = '070a1b2c3d4e5' +down_revision = '1f82cad3546e' +branch_labels = None +depends_on = None + +# 表和列备注定义:key 为 "表名" 或 "表名.列名",value 为备注文本 +_COMMENTS = { + # ========== alembic_version 迁移版本记录表 ========== + 'alembic_version': 'Alembic迁移版本记录表', + 'alembic_version.version_num': '当前已应用的迁移版本号', + + # ========== sys_user 用户表 ========== + 'sys_user': '系统用户表', + 'sys_user.id': '用户ID', + 'sys_user.account': '用户账号', + 'sys_user.name': '用户名称', + 'sys_user.password': '用户密码', + 'sys_user.email': '用户邮箱', + 'sys_user.oid': '组织ID', + 'sys_user.status': '用户状态', + 'sys_user.origin': '用户来源', + 'sys_user.create_time': '创建时间', + 'sys_user.language': '用户语言偏好', + 'sys_user.system_variables': '用户自定义系统变量', + + # ========== sys_user_platform 用户平台关联表 ========== + 'sys_user_platform': '用户平台关联表', + 'sys_user_platform.id': '主键ID', + 'sys_user_platform.uid': '用户ID', + 'sys_user_platform.origin': '平台来源', + 'sys_user_platform.platform_uid': '平台用户ID', + + # ========== ai_model AI模型表 ========== + 'ai_model': 'AI模型配置表', + 'ai_model.id': '模型ID', + 'ai_model.supplier': '模型供应商', + 'ai_model.name': '模型名称', + 'ai_model.model_type': '模型类型', + 'ai_model.base_model': '基础模型标识', + 'ai_model.default_model': '是否默认模型', + 'ai_model.api_key': 'API密钥', + 'ai_model.api_domain': 'API域名地址', + 'ai_model.protocol': '通信协议', + 'ai_model.config': '模型配置信息', + 'ai_model.status': '模型状态', + 'ai_model.create_time': '创建时间', + + # ========== ai_model_workspace_mapping 模型工作空间映射表 ========== + 'ai_model_workspace_mapping': 'AI模型与工作空间映射表', + 'ai_model_workspace_mapping.id': '主键ID', + 'ai_model_workspace_mapping.ai_model_id': 'AI模型ID', + 'ai_model_workspace_mapping.workspace_id': '工作空间ID', + + # ========== sys_workspace 工作空间表 ========== + 'sys_workspace': '工作空间表', + 'sys_workspace.id': '工作空间ID', + 'sys_workspace.name': '工作空间名称', + 'sys_workspace.create_time': '创建时间', + + # ========== sys_user_ws 用户工作空间关联表 ========== + 'sys_user_ws': '用户工作空间权限表', + 'sys_user_ws.id': '主键ID', + 'sys_user_ws.uid': '用户ID', + 'sys_user_ws.oid': '组织ID', + 'sys_user_ws.weight': '权重值', + + # ========== sys_assistant AI助手表 ========== + 'sys_assistant': 'AI助手配置表', + 'sys_assistant.id': '助手ID', + 'sys_assistant.name': '助手名称', + 'sys_assistant.type': '助手类型', + 'sys_assistant.domain': '助手适用领域', + 'sys_assistant.description': '助手描述信息', + 'sys_assistant.configuration': '助手配置', + 'sys_assistant.create_time': '创建时间', + 'sys_assistant.app_id': '应用ID', + 'sys_assistant.app_secret': '应用密钥', + 'sys_assistant.oid': '组织ID', + 'sys_assistant.enable_custom_model': '是否启用自定义模型', + 'sys_assistant.custom_model': '自定义模型名称', + + # ========== sys_authentication 认证配置表 ========== + 'sys_authentication': '认证配置表', + 'sys_authentication.id': '认证配置ID', + 'sys_authentication.name': '认证配置名称', + 'sys_authentication.type': '认证类型', + 'sys_authentication.config': '认证配置详情', + 'sys_authentication.create_time': '创建时间', + 'sys_authentication.enable': '是否启用', + 'sys_authentication.valid': '配置是否有效', + + # ========== sys_apikey API密钥表 ========== + 'sys_apikey': 'API密钥表', + 'sys_apikey.id': '密钥ID', + 'sys_apikey.access_key': '访问密钥', + 'sys_apikey.secret_key': '密钥', + 'sys_apikey.create_time': '创建时间', + 'sys_apikey.uid': '绑定用户ID', + 'sys_apikey.status': '密钥状态', + + # ========== system_variable 系统变量表 ========== + 'system_variable': '系统变量表', + 'system_variable.id': '变量ID', + 'system_variable.name': '变量名称', + 'system_variable.var_type': '变量数据类型', + 'system_variable.type': '变量分类', + 'system_variable.value': '变量值', + 'system_variable.create_time': '创建时间', + 'system_variable.create_by': '创建人ID', + + # ========== terms 术语设置表 ========== + 'terms': '术语设置表', + 'terms.id': '术语ID', + 'terms.term': '术语名称', + 'terms.definition': '术语定义', + 'terms.domain': '所属领域', + 'terms.create_time': '创建时间', + + # ========== custom_prompt 自定义提示词表 ========== + 'custom_prompt': '自定义提示词表', + 'custom_prompt.id': '提示词ID', + 'custom_prompt.oid': '组织ID', + 'custom_prompt.type': '提示词类型', + 'custom_prompt.create_time': '创建时间', + 'custom_prompt.name': '提示词名称', + 'custom_prompt.prompt': '提示词内容', + 'custom_prompt.specific_ds': '是否关联特定数据源', + 'custom_prompt.datasource_ids': '关联数据源ID列表', + 'custom_prompt.advanced_application': '高级应用ID', + + # ========== core_datasource 数据源表 ========== + 'core_datasource': '数据源配置表', + 'core_datasource.id': '数据源ID', + 'core_datasource.name': '数据源名称', + 'core_datasource.description': '数据源描述', + 'core_datasource.type': '数据源类型', + 'core_datasource.type_name': '数据源类型名称', + 'core_datasource.configuration': '连接配置信息', + 'core_datasource.create_time': '创建时间', + 'core_datasource.create_by': '创建人ID', + 'core_datasource.status': '连接状态', + 'core_datasource.num': '数据源编号', + 'core_datasource.oid': '组织ID', + 'core_datasource.table_relation': '表关系配置', + 'core_datasource.embedding': '向量嵌入信息', + 'core_datasource.recommended_config': '推荐问题配置', + + # ========== core_table 数据源表信息 ========== + 'core_table': '数据源表信息', + 'core_table.id': '表记录ID', + 'core_table.ds_id': '所属数据源ID', + 'core_table.checked': '是否启用', + 'core_table.table_name': '表名称', + 'core_table.table_comment': '表注释', + 'core_table.custom_comment': '自定义表注释', + 'core_table.embedding': '表向量嵌入信息', + + # ========== ds_recommended_problem 推荐问题表 ========== + 'ds_recommended_problem': '数据源推荐问题表', + 'ds_recommended_problem.id': '问题ID', + 'ds_recommended_problem.datasource_id': '数据源ID', + 'ds_recommended_problem.question': '推荐问题内容', + 'ds_recommended_problem.remark': '问题备注', + 'ds_recommended_problem.sort': '排序序号', + 'ds_recommended_problem.create_time': '创建时间', + 'ds_recommended_problem.create_by': '创建人ID', + + # ========== core_field 数据源字段信息 ========== + 'core_field': '数据源字段信息表', + 'core_field.id': '字段记录ID', + 'core_field.ds_id': '所属数据源ID', + 'core_field.table_id': '所属表ID', + 'core_field.checked': '是否启用', + 'core_field.field_name': '字段名称', + 'core_field.field_type': '字段类型', + 'core_field.field_comment': '字段注释', + 'core_field.custom_comment': '自定义字段注释', + 'core_field.field_index': '字段序号', + + # ========== data_training 数据训练(示例库)表 ========== + 'data_training': 'SQL示例训练库表', + 'data_training.id': '训练记录ID', + 'data_training.oid': '组织ID', + 'data_training.datasource': '关联数据源ID', + 'data_training.create_time': '创建时间', + 'data_training.question': '训练问题', + 'data_training.description': '训练描述', + 'data_training.embedding': '向量嵌入数据', + 'data_training.enabled': '是否启用', + 'data_training.advanced_application': '高级应用ID', + + # ========== terminology 术语表 ========== + 'terminology': '术语管理表', + 'terminology.id': '术语ID', + 'terminology.oid': '组织ID', + 'terminology.pid': '父级术语ID', + 'terminology.create_time': '创建时间', + 'terminology.word': '术语词条', + 'terminology.description': '术语描述', + 'terminology.embedding': '向量嵌入数据', + 'terminology.specific_ds': '是否关联特定数据源', + 'terminology.datasource_ids': '关联数据源ID列表', + 'terminology.enabled': '是否启用', + 'terminology.advanced_application': '高级应用ID', + + # ========== core_dashboard 仪表板表 ========== + 'core_dashboard': '仪表板表', + 'core_dashboard.id': '仪表板ID', + 'core_dashboard.name': '仪表板名称', + 'core_dashboard.pid': '父级仪表板ID', + 'core_dashboard.workspace_id': '所属工作空间ID', + 'core_dashboard.org_id': '组织ID', + 'core_dashboard.level': '层级', + 'core_dashboard.node_type': '节点类型', + 'core_dashboard.type': '仪表板类型', + 'core_dashboard.canvas_style_data': '画布样式数据', + 'core_dashboard.component_data': '组件数据', + 'core_dashboard.canvas_view_info': '画布视图信息', + 'core_dashboard.mobile_layout': '是否移动端布局', + 'core_dashboard.status': '状态', + 'core_dashboard.self_watermark_status': '水印状态', + 'core_dashboard.sort': '排序序号', + 'core_dashboard.create_time': '创建时间', + 'core_dashboard.create_by': '创建人', + 'core_dashboard.update_time': '更新时间', + 'core_dashboard.update_by': '更新人', + 'core_dashboard.remark': '备注', + 'core_dashboard.source': '来源标识', + 'core_dashboard.delete_flag': '删除标记', + 'core_dashboard.delete_time': '删除时间', + 'core_dashboard.delete_by': '删除人', + 'core_dashboard.version': '版本号', + 'core_dashboard.content_id': '内容ID', + 'core_dashboard.check_version': '检查版本', + + # ========== chat_log 聊天日志表 ========== + 'chat_log': 'AI聊天执行日志表', + 'chat_log.id': '日志ID', + 'chat_log.type': '聊天类型', + 'chat_log.operate': '操作类型', + 'chat_log.pid': '父级日志ID', + 'chat_log.ai_modal_id': '使用的AI模型ID', + 'chat_log.base_modal': '基础模型标识', + 'chat_log.messages': '消息内容', + 'chat_log.reasoning_content': '推理内容', + 'chat_log.start_time': '开始时间', + 'chat_log.finish_time': '结束时间', + 'chat_log.token_usage': 'Token消耗统计', + 'chat_log.local_operation': '是否本地操作', + 'chat_log.error': '是否发生错误', + + # ========== chat 聊天主表 ========== + 'chat': 'AI聊天会话表', + 'chat.id': '聊天会话ID', + 'chat.oid': '组织ID', + 'chat.create_time': '创建时间', + 'chat.create_by': '创建人ID', + 'chat.brief': '会话摘要', + 'chat.chat_type': '聊天类型', + 'chat.datasource': '关联数据源ID', + 'chat.engine_type': '数据引擎类型', + 'chat.origin': '会话来源', + 'chat.brief_generate': '是否已生成摘要', + 'chat.recommended_question_answer': '推荐问题回答', + 'chat.recommended_question': '推荐问题内容', + 'chat.recommended_generate': '是否已生成推荐问题', + + # ========== chat_record 聊天记录表 ========== + 'chat_record': 'AI聊天记录表', + 'chat_record.id': '记录ID', + 'chat_record.chat_id': '关联聊天会话ID', + 'chat_record.ai_modal_id': '使用的AI模型ID', + 'chat_record.first_chat': '是否首轮对话', + 'chat_record.create_time': '创建时间', + 'chat_record.finish_time': '完成时间', + 'chat_record.create_by': '创建人ID', + 'chat_record.datasource': '关联数据源ID', + 'chat_record.engine_type': '数据引擎类型', + 'chat_record.question': '用户问题内容', + 'chat_record.sql_answer': 'SQL生成回答', + 'chat_record.sql': '生成的SQL语句', + 'chat_record.sql_exec_result': 'SQL执行结果', + 'chat_record.data': '查询返回数据', + 'chat_record.chart_answer': '图表生成回答', + 'chat_record.chart': '图表配置信息', + 'chat_record.analysis': '分析结果内容', + 'chat_record.predict': '预测结果内容', + 'chat_record.predict_data': '预测数据', + 'chat_record.recommended_question_answer': '推荐问题回答', + 'chat_record.recommended_question': '推荐问题列表', + 'chat_record.datasource_select_answer': '数据源选择回答', + 'chat_record.finish': '是否已完成', + 'chat_record.error': '错误信息', + 'chat_record.analysis_record_id': '关联分析记录ID', + 'chat_record.predict_record_id': '关联预测记录ID', + 'chat_record.regenerate_record_id': '关联重新生成记录ID', + + # ========== sys_logs 系统操作日志表 ========== + 'sys_logs': '系统操作日志表', + 'sys_logs.id': '日志ID', + 'sys_logs.operation_type': '操作类型', + 'sys_logs.operation_detail': '操作详情', + 'sys_logs.user_id': '操作用户ID', + 'sys_logs.operation_status': '操作状态', + 'sys_logs.ip_address': '操作IP地址', + 'sys_logs.user_agent': '用户代理信息', + 'sys_logs.execution_time': '执行耗时', + 'sys_logs.error_message': '错误信息', + 'sys_logs.create_time': '创建时间', + 'sys_logs.module': '操作模块', + 'sys_logs.oid': '组织ID', + 'sys_logs.resource_id': '操作资源ID', + 'sys_logs.request_method': '请求方法', + 'sys_logs.request_path': '请求路径', + 'sys_logs.remark': '备注', + 'sys_logs.user_name': '操作用户名称', + 'sys_logs.resource_name': '操作资源名称', + + # ========== sys_logs_resource 日志关联资源表 ========== + 'sys_logs_resource': '操作日志关联资源表', + 'sys_logs_resource.id': '主键ID', + 'sys_logs_resource.log_id': '关联日志ID', + 'sys_logs_resource.resource_id': '资源ID', + 'sys_logs_resource.resource_name': '资源名称', + 'sys_logs_resource.module': '资源模块', + + # ========== ds_permission 数据权限表 ========== + 'ds_permission': '数据权限配置表', + 'ds_permission.id': '权限ID', + 'ds_permission.enable': '是否启用', + 'ds_permission.name': '权限名称', + 'ds_permission.auth_target_type': '授权对象类型', + 'ds_permission.auth_target_id': '授权对象ID', + 'ds_permission.type': '权限类型', + 'ds_permission.ds_id': '数据源ID', + 'ds_permission.table_id': '表ID', + 'ds_permission.expression_tree': '权限表达式树', + 'ds_permission.permissions': '权限配置', + 'ds_permission.white_list_user': '白名单用户列表', + 'ds_permission.create_time': '创建时间', + + # ========== ds_rules 数据规则表 ========== + 'ds_rules': '数据规则组表', + 'ds_rules.id': '规则组ID', + 'ds_rules.enable': '是否启用', + 'ds_rules.name': '规则组名称', + 'ds_rules.description': '规则组描述', + 'ds_rules.permission_list': '权限列表', + 'ds_rules.user_list': '用户列表', + 'ds_rules.white_list_user': '白名单用户列表', + 'ds_rules.oid': '组织ID', + 'ds_rules.create_time': '创建时间', + + # ========== license 许可证表 ========== + 'license': '系统许可证表', + 'license.id': '许可证ID', + 'license.license_key': '许可证密钥', + 'license.f2c_license': 'F2C许可证信息', + 'license.create_time': '创建时间', + 'license.update_time': '更新时间', + + # ========== rsa 密钥表 ========== + 'rsa': 'RSA密钥存储表', + 'rsa.id': '密钥ID', + 'rsa.private_key': 'RSA私钥', + 'rsa.public_key': 'RSA公钥', + 'rsa.salt': '加密盐值', + 'rsa.create_time': '创建时间', + 'rsa.update_time': '更新时间', + + # ========== sys_arg 系统参数表 ========== + 'sys_arg': '系统参数配置表', + 'sys_arg.id': '参数ID', + 'sys_arg.pkey': '参数键', + 'sys_arg.pval': '参数值', + 'sys_arg.ptype': '参数类型', + 'sys_arg.sort_no': '排序序号', + + # ========== sys_platform_token 平台令牌表 ========== + 'sys_platform_token': '平台认证令牌表', + 'sys_platform_token.id': '令牌ID', + 'sys_platform_token.token': '令牌值', + 'sys_platform_token.create_time': '创建时间', + 'sys_platform_token.exp_time': '过期时间', +} + + +def _escape_comment(comment): + return comment.replace("'", "''") + + +def _apply_table_comment(table, comment): + if comment is None: + op.execute(f"COMMENT ON TABLE {table} IS NULL") + else: + op.execute(f"COMMENT ON TABLE {table} IS '{_escape_comment(comment)}'") + + +def _apply_column_comment(table, column, comment): + if comment is None: + op.execute(f"COMMENT ON COLUMN {table}.{column} IS NULL") + else: + op.execute(f"COMMENT ON COLUMN {table}.{column} IS '{_escape_comment(comment)}'") + + +def upgrade(): + """为所有表和字段添加中文备注""" + table_keys = sorted(k for k in _COMMENTS if '.' not in k) + column_keys = sorted(k for k in _COMMENTS if '.' in k) + + for table in table_keys: + _apply_table_comment(table, _COMMENTS[table]) + + for key in column_keys: + table, column = key.split('.', 1) + _apply_column_comment(table, column, _COMMENTS[key]) + + +def downgrade(): + """移除所有表和字段的备注""" + table_keys = sorted((k for k in _COMMENTS if '.' not in k), reverse=True) + column_keys = sorted((k for k in _COMMENTS if '.' in k), reverse=True) + + for key in column_keys: + table, column = key.split('.', 1) + _apply_column_comment(table, column, None) + + for table in table_keys: + _apply_table_comment(table, None) diff --git a/backend/alembic/versions/071_modify_permission_jsonb.py b/backend/alembic/versions/071_modify_permission_jsonb.py new file mode 100644 index 000000000..cac4c64b9 --- /dev/null +++ b/backend/alembic/versions/071_modify_permission_jsonb.py @@ -0,0 +1,45 @@ +"""071_modify_permission_jsonb + +Revision ID: a2e2ecfa5a9c +Revises: 070a1b2c3d4e5 +Create Date: 2026-07-16 16:56:47.093944 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'a2e2ecfa5a9c' +down_revision = '070a1b2c3d4e5' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('ds_rules', 'permission_list', + existing_type=sa.Text(), + type_=postgresql.JSONB(astext_type=sa.Text()), + existing_nullable=True, + postgresql_using='permission_list::jsonb') + op.alter_column('ds_rules', 'user_list', + existing_type=sa.Text(), + type_=postgresql.JSONB(astext_type=sa.Text()), + existing_nullable=True, + postgresql_using='user_list::jsonb') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('ds_rules', 'permission_list', + existing_type=postgresql.JSONB(astext_type=sa.Text()), + type_=sa.Text(), + existing_nullable=True) + op.alter_column('ds_rules', 'user_list', + existing_type=postgresql.JSONB(astext_type=sa.Text()), + type_=sa.Text(), + existing_nullable=True) + # ### end Alembic commands ### diff --git a/backend/apps/ai_model/model_factory.py b/backend/apps/ai_model/model_factory.py index 03479fd8e..887005ce7 100644 --- a/backend/apps/ai_model/model_factory.py +++ b/backend/apps/ai_model/model_factory.py @@ -14,6 +14,8 @@ from common.utils.utils import prepare_model_arg from langchain_community.llms import VLLMOpenAI from langchain_openai import AzureChatOpenAI + + # from langchain_community.llms import Tongyi, VLLM class LLMConfig(BaseModel): @@ -24,16 +26,17 @@ class LLMConfig(BaseModel): api_key: Optional[str] = None api_base_url: Optional[str] = None additional_params: Dict[str, Any] = {} + class Config: frozen = True def __hash__(self): if hasattr(self, 'additional_params') and isinstance(self.additional_params, dict): - hashable_params = frozenset((k, tuple(v) if isinstance(v, (list, dict)) else v) - for k, v in self.additional_params.items()) + hashable_params = frozenset((k, tuple(v) if isinstance(v, (list, dict)) else v) + for k, v in self.additional_params.items()) else: hashable_params = None - + return hash(( self.model_id, self.model_type, @@ -61,6 +64,7 @@ def llm(self) -> BaseChatModel: """Return the langchain LLM instance""" return self._llm + class OpenAIvLLM(BaseLLM): def _init_llm(self) -> VLLMOpenAI: return VLLMOpenAI( @@ -71,6 +75,7 @@ def _init_llm(self) -> VLLMOpenAI: **self.config.additional_params, ) + class OpenAIAzureLLM(BaseLLM): def _init_llm(self) -> AzureChatOpenAI: api_version = self.config.additional_params.get("api_version") @@ -88,6 +93,8 @@ def _init_llm(self) -> AzureChatOpenAI: streaming=True, **self.config.additional_params, ) + + class OpenAILLM(BaseLLM): def _init_llm(self) -> BaseChatModel: return BaseChatOpenAI( @@ -138,11 +145,15 @@ def register_llm(cls, model_type: str, llm_class: Type[BaseLLM]): return config """ -async def get_default_config() -> LLMConfig: +async def get_default_config(custom_model_id: Optional[int] = None) -> LLMConfig: with Session(engine) as session: - db_model = session.exec( - select(AiModelDetail).where(AiModelDetail.default_model == True) - ).first() + db_model: AiModelDetail | None = None + if custom_model_id: + db_model = session.get(AiModelDetail, custom_model_id) + if not db_model: + db_model = session.exec( + select(AiModelDetail).where(AiModelDetail.default_model == True) + ).first() if not db_model: raise Exception("The system default model has not been set") @@ -150,14 +161,14 @@ async def get_default_config() -> LLMConfig: if db_model.config: try: config_raw = json.loads(db_model.config) - additional_params = {item["key"]: prepare_model_arg(item.get('val')) for item in config_raw if "key" in item and "val" in item} + additional_params = {item["key"]: prepare_model_arg(item.get('val')) for item in config_raw if + "key" in item and "val" in item} except Exception: pass if not db_model.api_domain.startswith("http"): db_model.api_domain = await sqlbot_decrypt(db_model.api_domain) if db_model.api_key: db_model.api_key = await sqlbot_decrypt(db_model.api_key) - # 构造 LLMConfig return LLMConfig( diff --git a/backend/apps/ai_model/openai/llm.py b/backend/apps/ai_model/openai/llm.py index a03693c66..603fd50ea 100644 --- a/backend/apps/ai_model/openai/llm.py +++ b/backend/apps/ai_model/openai/llm.py @@ -91,19 +91,20 @@ def _default_params(self) -> dict[str, Any]: if max_tokens: params["max_tokens"] = max_tokens return params - + def _get_request_payload( - self, - input_: LanguageModelInput, - *, - stop: Optional[list[str]] = None, - **kwargs: Any, + self, + input_: LanguageModelInput, + *, + stop: Optional[list[str]] = None, + **kwargs: Any, ) -> dict: max_tokens = self.max_tokens payload = super()._get_request_payload(input_, stop=stop, **kwargs) if max_tokens: payload["max_tokens"] = max_tokens return payload + usage_metadata: dict = {} # custom_get_token_ids = custom_get_token_ids @@ -119,10 +120,10 @@ def _stream(self, *args: Any, **kwargs: Any) -> Iterator[ChatGenerationChunk]: yield chunk def _convert_chunk_to_generation_chunk( - self, - chunk: dict, - default_chunk_class: type, - base_generation_info: dict | None, + self, + chunk: dict, + default_chunk_class: type, + base_generation_info: dict | None, ) -> ChatGenerationChunk | None: if chunk.get("type") == "content.delta": # from beta.chat.completions.stream return None @@ -174,12 +175,12 @@ def _convert_chunk_to_generation_chunk( return generation_chunk def invoke( - self, - input: LanguageModelInput, - config: RunnableConfig | None = None, - *, - stop: list[str] | None = None, - **kwargs: Any, + self, + input: LanguageModelInput, + config: RunnableConfig | None = None, + *, + stop: list[str] | None = None, + **kwargs: Any, ) -> BaseMessage: config = ensure_config(config) chat_result = cast( diff --git a/backend/apps/chat/api/chat.py b/backend/apps/chat/api/chat.py index f7f812bb4..8a2bed721 100644 --- a/backend/apps/chat/api/chat.py +++ b/backend/apps/chat/api/chat.py @@ -11,20 +11,20 @@ from starlette.responses import JSONResponse from apps.chat.curd.chat import delete_chat_with_user, get_chart_data_with_user, get_chat_predict_data_with_user, \ - list_chats, get_chat_with_records, create_chat, rename_chat, \ - delete_chat, get_chat_chart_data, get_chat_predict_data, get_chat_with_records_with_data, get_chat_record_by_id, \ - format_json_data, format_json_list_data, get_chart_config, list_recent_questions, get_chat as get_chat_exec, \ - rename_chat_with_user, get_chat_log_history, get_chart_data_with_user_live + list_chats, get_chat_with_records, create_chat, get_chat_chart_data, get_chat_predict_data, \ + get_chat_with_records_with_data, get_chat_record_by_id, \ + format_json_data, format_json_list_data, get_chart_config, list_recent_questions, rename_chat_with_user, \ + get_chat_log_history, get_chart_data_with_user_live from apps.chat.models.chat_model import CreateChat, ChatRecord, RenameChat, ChatQuestion, AxisObj, QuickCommand, \ - ChatInfo, Chat, ChatFinishStep + ChatInfo, Chat, ChatFinishStep, ChatQuestionBase, SimpleChat from apps.chat.task.llm import LLMService from apps.swagger.i18n import PLACEHOLDER_PREFIX from apps.system.schemas.permission import SqlbotPermission, require_permissions +from common.audit.models.log_model import OperationType, OperationModules +from common.audit.schemas.logger_decorator import LogConfig, system_log from common.core.deps import CurrentAssistant, SessionDep, CurrentUser, Trans from common.utils.command_utils import parse_quick_command from common.utils.data_format import DataFormat -from common.audit.models.log_model import OperationType, OperationModules -from common.audit.schemas.logger_decorator import LogConfig, system_log router = APIRouter(tags=["Data Q&A"], prefix="/chat") @@ -80,6 +80,7 @@ def inner(): return await asyncio.to_thread(inner) + @router.get("/record/{chat_record_id}/data_live", summary=f"{PLACEHOLDER_PREFIX}get_chart_data_live") async def chat_record_data_live(session: SessionDep, current_user: CurrentUser, chat_record_id: int): def inner(): @@ -88,6 +89,7 @@ def inner(): return await asyncio.to_thread(inner) + @router.get("/record/{chat_record_id}/predict_data", summary=f"{PLACEHOLDER_PREFIX}get_chart_predict_data") async def chat_predict_data(session: SessionDep, current_user: CurrentUser, chat_record_id: int): def inner(): @@ -114,22 +116,6 @@ def inner(): return await asyncio.to_thread(inner) -""" @router.post("/rename", response_model=str, summary=f"{PLACEHOLDER_PREFIX}rename_chat") -@system_log(LogConfig( - operation_type=OperationType.UPDATE, - module=OperationModules.CHAT, - resource_id_expr="chat.id" -)) -async def rename(session: SessionDep, chat: RenameChat): - try: - return rename_chat(session=session, rename_object=chat) - except Exception as e: - raise HTTPException( - status_code=500, - detail=str(e) - ) """ - - @router.post("/rename", response_model=str, summary=f"{PLACEHOLDER_PREFIX}rename_chat") @system_log(LogConfig( operation_type=OperationType.UPDATE, @@ -146,31 +132,14 @@ async def rename(session: SessionDep, current_user: CurrentUser, chat: RenameCha ) -""" @router.delete("/{chart_id}/{brief}", response_model=str, summary=f"{PLACEHOLDER_PREFIX}delete_chat") -@system_log(LogConfig( - operation_type=OperationType.DELETE, - module=OperationModules.CHAT, - resource_id_expr="chart_id", - remark_expr="brief" -)) -async def delete(session: SessionDep, chart_id: int, brief: str): - try: - return delete_chat(session=session, chart_id=chart_id) - except Exception as e: - raise HTTPException( - status_code=500, - detail=str(e) - ) """ - - -@router.delete("/{chart_id}/{brief}", response_model=str, summary=f"{PLACEHOLDER_PREFIX}delete_chat") +@router.delete("/{chart_id}", response_model=str, summary=f"{PLACEHOLDER_PREFIX}delete_chat") @system_log(LogConfig( operation_type=OperationType.DELETE, module=OperationModules.CHAT, resource_id_expr="chart_id", - remark_expr="brief" + remark_expr="chat.brief" )) -async def delete(session: SessionDep, current_user: CurrentUser, chart_id: int, brief: str): +async def delete(session: SessionDep, current_user: CurrentUser, chart_id: int, chat: SimpleChat): try: return delete_chat_with_user(session=session, current_user=current_user, chart_id=chart_id) except Exception as e: @@ -267,15 +236,17 @@ def find_base_question(record_id: int, session: SessionDep): @router.post("/question", summary=f"{PLACEHOLDER_PREFIX}ask_question") @require_permissions(permission=SqlbotPermission(type='chat', keyExpression="request_question.chat_id")) -async def question_answer(session: SessionDep, current_user: CurrentUser, request_question: ChatQuestion, +async def question_answer(session: SessionDep, current_user: CurrentUser, request_question: ChatQuestionBase, current_assistant: CurrentAssistant): - return await question_answer_inner(session, current_user, request_question, current_assistant, embedding=True) + question = ChatQuestion(chat_id=request_question.chat_id, question=request_question.question) + return await question_answer_inner(session, current_user, question, current_assistant, embedding=True) async def question_answer_inner(session: SessionDep, current_user: CurrentUser, request_question: ChatQuestion, current_assistant: Optional[CurrentAssistant] = None, in_chat: bool = True, stream: bool = True, - finish_step: ChatFinishStep = ChatFinishStep.GENERATE_CHART, embedding: bool = False): + finish_step: ChatFinishStep = ChatFinishStep.GENERATE_CHART, embedding: bool = False, + return_img: bool = True): try: command, text_before_command, record_id, warning_info = parse_quick_command(request_question.question) if command: @@ -331,7 +302,7 @@ async def question_answer_inner(session: SessionDep, current_user: CurrentUser, request_question.question = text_before_command request_question.regenerate_record_id = rec_id return await stream_sql(session, current_user, request_question, current_assistant, in_chat, stream, - finish_step, embedding) + finish_step, embedding, return_img) elif command == QuickCommand.ANALYSIS: return await analysis_or_predict(session, current_user, rec_id, 'analysis', current_assistant, in_chat, @@ -344,7 +315,7 @@ async def question_answer_inner(session: SessionDep, current_user: CurrentUser, raise Exception(f'Unknown command: {command.value}') else: return await stream_sql(session, current_user, request_question, current_assistant, in_chat, stream, - finish_step, embedding) + finish_step, embedding, return_img) except Exception as e: traceback.print_exc() @@ -366,12 +337,13 @@ def _err(_e: Exception): async def stream_sql(session: SessionDep, current_user: CurrentUser, request_question: ChatQuestion, current_assistant: Optional[CurrentAssistant] = None, in_chat: bool = True, stream: bool = True, - finish_step: ChatFinishStep = ChatFinishStep.GENERATE_CHART, embedding: bool = False): + finish_step: ChatFinishStep = ChatFinishStep.GENERATE_CHART, embedding: bool = False, + return_img: bool = True): try: llm_service = await LLMService.create(session, current_user, request_question, current_assistant, embedding=embedding) llm_service.init_record(session=session) - llm_service.run_task_async(in_chat=in_chat, stream=stream, finish_step=finish_step) + llm_service.run_task_async(in_chat=in_chat, stream=stream, finish_step=finish_step, return_img=return_img) except Exception as e: traceback.print_exc() @@ -536,7 +508,8 @@ async def export_excel(session: SessionDep, current_user: CurrentUser, chat_reco def inner(): - data_list = DataFormat.convert_large_numbers_in_object_array(obj_array=_data + _predict_data, int_threshold=1e11) + data_list = DataFormat.convert_large_numbers_in_object_array(obj_array=_data + _predict_data, + int_threshold=1e11) md_data, _fields_list = DataFormat.convert_object_array_for_pandas(fields, data_list) diff --git a/backend/apps/chat/curd/chat.py b/backend/apps/chat/curd/chat.py index 1f8befdf5..e261c2b6f 100644 --- a/backend/apps/chat/curd/chat.py +++ b/backend/apps/chat/curd/chat.py @@ -1,4 +1,5 @@ import datetime +from decimal import Decimal from typing import List, Optional, Union, Dict, Any import orjson @@ -186,7 +187,8 @@ def get_last_execute_sql_error(session: SessionDep, chart_id: int): def format_json_data(origin_data: dict): - result = {'fields': origin_data.get('fields') if origin_data.get('fields') else []} + result = {'fields': origin_data.get('fields') if origin_data.get('fields') else [], + 'fields_info': origin_data.get('fields_info') if origin_data.get('fields_info') else None} _list = origin_data.get('data') if origin_data.get('data') else [] data = format_json_list_data(_list) result['data'] = data @@ -207,11 +209,11 @@ def format_json_list_data(origin_data: list[dict]): value = str(value) # 小数且超过15位有效数字 → 转字符串并标记为文本列 elif isinstance(value, float): - decimal_str = format(value, '.16f').rstrip('0').rstrip('.') + decimal_str = str(Decimal(str(value))).rstrip('0').rstrip('.') if len(decimal_str) > 15: value = str(value) _row[key] = value - data.append(_row) + data.append(DataFormat.normalize_qualified_sql_column_keys(_row)) return data @@ -237,22 +239,26 @@ def get_chart_data_with_user(session: SessionDep, current_user: CurrentUser, cha pass return {} + def get_chart_data_with_user_live(session: SessionDep, current_user: CurrentUser, chat_record_id: int): - stmt = select(ChatRecord.datasource,ChatRecord.sql).where(and_(ChatRecord.id == chat_record_id, ChatRecord.create_by == current_user.id)) + stmt = select(ChatRecord.datasource, ChatRecord.sql).where( + and_(ChatRecord.id == chat_record_id, ChatRecord.create_by == current_user.id)) row = session.execute(stmt).first() - return get_chart_data_ds(session,row.datasource, row.sql) + return get_chart_data_ds(session, row.datasource, row.sql) -def get_chart_data_ds(session: SessionDep,ds_id,sql): - json_result: Dict[str, Any] = {'status': 'success','data':[],'message':''} + +def get_chart_data_ds(session: SessionDep, ds_id, sql): + json_result: Dict[str, Any] = {'status': 'success', 'data': [], 'message': ''} try: - datasource = get_ds(session,ds_id) + datasource = get_ds(session, ds_id) if datasource is None: json_result['status'] = 'failed' json_result['message'] = 'Datasource not found' return json_result else: - result = exec_sql(ds=datasource,sql=sql, origin_column=False) + result = exec_sql(ds=datasource, sql=sql, origin_column=False) _data = DataFormat.convert_large_numbers_in_object_array(result.get('data')) + _data = DataFormat.normalize_qualified_sql_column_keys_in_object_array(_data) json_result['data'] = _data return json_result except Exception as e: @@ -262,6 +268,7 @@ def get_chart_data_ds(session: SessionDep,ds_id,sql): pass return json_result + def get_chat_chart_data(session: SessionDep, chat_record_id: int): stmt = select(ChatRecord.data).where(and_(ChatRecord.id == chat_record_id)) res = session.execute(stmt) @@ -334,7 +341,7 @@ def get_chat_with_records(session: SessionDep, chart_id: int, current_user: Curr predict_alias_log = aliased(ChatLog) stmt = (select(ChatRecord.id, ChatRecord.chat_id, ChatRecord.create_time, ChatRecord.finish_time, - ChatRecord.question, ChatRecord.sql_answer, ChatRecord.sql,ChatRecord.datasource, + ChatRecord.question, ChatRecord.sql_answer, ChatRecord.sql, ChatRecord.datasource, ChatRecord.chart_answer, ChatRecord.chart, ChatRecord.analysis, ChatRecord.predict, ChatRecord.datasource_select_answer, ChatRecord.analysis_record_id, ChatRecord.predict_record_id, ChatRecord.regenerate_record_id, @@ -361,7 +368,7 @@ def get_chat_with_records(session: SessionDep, chart_id: int, current_user: Curr ChatRecord.create_time)) if with_data: stmt = select(ChatRecord.id, ChatRecord.chat_id, ChatRecord.create_time, ChatRecord.finish_time, - ChatRecord.question, ChatRecord.sql_answer, ChatRecord.sql,ChatRecord.datasource, + ChatRecord.question, ChatRecord.sql_answer, ChatRecord.sql, ChatRecord.datasource, ChatRecord.chart_answer, ChatRecord.chart, ChatRecord.analysis, ChatRecord.predict, ChatRecord.datasource_select_answer, ChatRecord.analysis_record_id, ChatRecord.predict_record_id, ChatRecord.regenerate_record_id, @@ -427,7 +434,8 @@ def get_chat_with_records(session: SessionDep, chart_id: int, current_user: Curr finish_time=row.finish_time, duration=duration, total_tokens=total_tokens, - question=row.question, sql_answer=row.sql_answer, sql=row.sql, datasource=row.datasource, + question=row.question, sql_answer=row.sql_answer, sql=row.sql, + datasource=row.datasource, chart_answer=row.chart_answer, chart=row.chart, analysis=row.analysis, predict=row.predict, datasource_select_answer=row.datasource_select_answer, @@ -446,7 +454,8 @@ def get_chat_with_records(session: SessionDep, chart_id: int, current_user: Curr finish_time=row.finish_time, duration=duration, total_tokens=total_tokens, - question=row.question, sql_answer=row.sql_answer, sql=row.sql, datasource=row.datasource, + question=row.question, sql_answer=row.sql_answer, sql=row.sql, + datasource=row.datasource, chart_answer=row.chart_answer, chart=row.chart, analysis=row.analysis, predict=row.predict, datasource_select_answer=row.datasource_select_answer, diff --git a/backend/apps/chat/models/chat_model.py b/backend/apps/chat/models/chat_model.py index effc313a2..6e550afb5 100644 --- a/backend/apps/chat/models/chat_model.py +++ b/backend/apps/chat/models/chat_model.py @@ -1,8 +1,9 @@ from datetime import datetime from enum import Enum -from typing import List, Optional, Union +from typing import List, Optional, Any, Union from fastapi import Body +from langchain_core.messages import SystemMessage, HumanMessage, AIMessage from pydantic import BaseModel from sqlalchemy import Column, Integer, Text, BigInteger, DateTime, Identity, Boolean from sqlalchemy import Enum as SQLAlchemyEnum @@ -174,6 +175,9 @@ class RenameChat(BaseModel): brief: str = '' brief_generate: bool = True +class SimpleChat(BaseModel): + id: int = None + brief: str = '' class ChatInfo(BaseModel): id: Optional[int] = None @@ -228,8 +232,11 @@ class AiModelQuestion(BaseModel): custom_prompt: str = "" error_msg: str = "" regenerate_record_id: Optional[int] = None + sample_data: str = "" + sqlbot_name: str = "SQLBot" def sql_sys_question(self, db_type: Union[str, DB], enable_query_limit: bool = True): + templates: dict[str, str] = {} _sql_template = get_sql_example_template(db_type) _base_template = get_sql_template() _process_check = _sql_template.get('process_check') if _sql_template.get('process_check') else _base_template[ @@ -245,66 +252,91 @@ 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) + + templates['system'] = _base_template['system'].format(lang=self.lang, process_check=_process_check, + sqlbot_name=self.sqlbot_name) + templates['rules'] = _base_template['generate_rules'].format(lang=self.lang, + sqlbot_name=self.sqlbot_name, + 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) + templates['schema'] = _base_template['generate_basic_info'].format(engine=self.engine, schema=self.db_schema, + sample_data=self.sample_data) + + if self.terminologies: + templates['terminologies'] = _base_template['generate_terminologies_info'].format( + terminologies=self.terminologies) + + if self.data_training: + templates['data_training'] = _base_template['generate_data_training_info'].format( + data_training=self.data_training) + + if self.custom_prompt: + templates['custom_prompt'] = _base_template['generate_custom_prompt_info'].format( + custom_prompt=self.custom_prompt) + + return templates def sql_user_question(self, current_time: str, change_title: bool): _question = self.question if self.regenerate_record_id: _question = get_sql_template()['regenerate_hint'] + self.question - return get_sql_template()['user'].format(engine=self.engine, schema=self.db_schema, question=_question, + return get_sql_template()['user'].format(lang=self.lang, engine=self.engine, schema=self.db_schema, + question=_question, rule=self.rule, current_time=current_time, error_msg=self.error_msg, change_title=change_title) def chart_sys_question(self): - return get_chart_template()['system'].format(sql=self.sql, question=self.question, lang=self.lang) + templates: dict[str, str] = { + 'system': get_chart_template()['system'].format(lang=self.lang, sqlbot_name=self.sqlbot_name), + 'rules': get_chart_template()['generate_rules'].format(lang=self.lang) + } + return templates def chart_user_question(self, chart_type: Optional[str] = '', schema: Optional[str] = ''): - return get_chart_template()['user'].format(sql=self.sql, question=self.question, rule=self.rule, + return get_chart_template()['user'].format(lang=self.lang, sql=self.sql, question=self.question, rule=self.rule, chart_type=chart_type, schema=schema) def analysis_sys_question(self): return get_analysis_template()['system'].format(lang=self.lang, terminologies=self.terminologies, - custom_prompt=self.custom_prompt) + custom_prompt=self.custom_prompt, sqlbot_name=self.sqlbot_name) def analysis_user_question(self): return get_analysis_template()['user'].format(fields=self.fields, data=self.data) def predict_sys_question(self): - return get_predict_template()['system'].format(lang=self.lang, custom_prompt=self.custom_prompt) + return get_predict_template()['system'].format(lang=self.lang, custom_prompt=self.custom_prompt, + sqlbot_name=self.sqlbot_name) def predict_user_question(self): return get_predict_template()['user'].format(fields=self.fields, data=self.data) def datasource_sys_question(self): - return get_datasource_template()['system'].format(lang=self.lang) + return get_datasource_template()['system'].format(lang=self.lang, sqlbot_name=self.sqlbot_name) def datasource_user_question(self, datasource_list: str = "[]"): - return get_datasource_template()['user'].format(question=self.question, data=datasource_list) + return get_datasource_template()['user'].format(lang=self.lang, question=self.question, data=datasource_list) def guess_sys_question(self, articles_number: int = 4): - return get_guess_question_template()['system'].format(lang=self.lang, articles_number=articles_number) + return get_guess_question_template()['system'].format(lang=self.lang, articles_number=articles_number, + sqlbot_name=self.sqlbot_name) def guess_user_question(self, old_questions: str = "[]"): return get_guess_question_template()['user'].format(question=self.question, schema=self.db_schema, old_questions=old_questions) def filter_sys_question(self): - return get_permissions_template()['system'].format(lang=self.lang, engine=self.engine) + return get_permissions_template()['system'].format(lang=self.lang, engine=self.engine, + sqlbot_name=self.sqlbot_name) def filter_user_question(self): return get_permissions_template()['user'].format(sql=self.sql, filter=self.filter) def dynamic_sys_question(self): - return get_dynamic_template()['system'].format(lang=self.lang, engine=self.engine) + return get_dynamic_template()['system'].format(lang=self.lang, engine=self.engine, sqlbot_name=self.sqlbot_name) def dynamic_user_question(self): return get_dynamic_template()['user'].format(sql=self.sql, sub_query=self.sub_query) @@ -319,18 +351,35 @@ class ChatMcp(ChatQuestion): token: str -class ChatStart(BaseModel): +class McpDs(BaseModel): + token: str = Body(description='用户token') + oid: Optional[str] = Body(description='组织ID,如果不传则为最后一次登录SQLBot时所使用的组织ID', default=None) + + +class ChatToken(BaseModel): username: str = Body(description='用户名') password: str = Body(description='密码') -class McpQuestion(BaseModel): +class ChatStart(BaseModel): + username: str = Body(description='用户名', default=None) + password: str = Body(description='密码', default=None) + token: str = Body(description='token', default=None) + oid: Optional[str] = Body( + description='组织ID,仅当数据源ID为空时有效,如果不传则为最后一次登录SQLBot时所使用的组织ID', default=None) + + +class ChatQuestionBase(BaseModel): question: str = Body(description='用户提问') chat_id: int = Body(description='会话ID') + + +class McpQuestion(ChatQuestionBase): token: str = Body(description='token') stream: Optional[bool] = Body(description='是否流式输出,默认为true开启, 关闭false则返回JSON对象', default=True) - lang: Optional[str] = Body(description='语言:zh-CN|en|ko-KR', default='zh-CN') + lang: Optional[str] = Body(description='语言:zh-CN|zh-TW|en|ko-KR', default='zh-CN') datasource_id: Optional[int | str] = Body(description='数据源ID,仅当当前对话没有确定数据源时有效', default=None) + return_img: Optional[bool] = Body(description='是否返回图表,默认为true开启, 关闭false则仅返回数据', default=True) class AxisObj(BaseModel): @@ -350,3 +399,30 @@ class McpAssistant(BaseModel): url: str = Body(description='第三方数据接口') authorization: str = Body(description='第三方接口凭证') stream: Optional[bool] = Body(description='是否流式输出,默认为true开启, 关闭false则返回JSON对象', default=True) + + +class SystemPromptMessage(SystemMessage): + sqlbot_system: bool = True + + def __init__( + self, content: Union[str, list[Union[str, dict]]], **kwargs: Any + ) -> None: + super().__init__(content=content, **kwargs) + + +class HumanPromptMessage(HumanMessage): + sqlbot_system: bool = True + + def __init__( + self, content: Union[str, list[Union[str, dict]]], **kwargs: Any + ) -> None: + super().__init__(content=content, **kwargs) + + +class AIPromptMessage(AIMessage): + sqlbot_system: bool = True + + def __init__( + self, content: Union[str, list[Union[str, dict]]], **kwargs: Any + ) -> None: + super().__init__(content=content, **kwargs) diff --git a/backend/apps/chat/task/llm.py b/backend/apps/chat/task/llm.py index 6b6865a89..e9ef80420 100644 --- a/backend/apps/chat/task/llm.py +++ b/backend/apps/chat/task/llm.py @@ -11,16 +11,18 @@ import orjson import pandas as pd import requests +import sqlglot import sqlparse from langchain.chat_models.base import BaseChatModel from langchain_community.utilities import SQLDatabase -from langchain_core.messages import BaseMessage, SystemMessage, HumanMessage, AIMessage, BaseMessageChunk +from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, BaseMessageChunk from sqlalchemy import and_, select from sqlalchemy.orm import sessionmaker, scoped_session from sqlbot_xpack.config.model import SysArgModel from sqlbot_xpack.custom_prompt.curd.custom_prompt import find_custom_prompts from sqlbot_xpack.custom_prompt.models.custom_prompt_model import CustomPromptTypeEnum from sqlbot_xpack.license.license_manage import SQLBotLicenseUtil +from sqlglot import exp from sqlmodel import Session from apps.ai_model.model_factory import LLMConfig, LLMFactory, get_default_config @@ -33,15 +35,17 @@ get_last_execute_sql_error, format_json_data, format_chart_fields, get_chat_brief_generate, get_chat_predict_data, \ get_chat_chart_config, trigger_log_error from apps.chat.models.chat_model import ChatQuestion, ChatRecord, Chat, RenameChat, ChatLog, OperationEnum, \ - ChatFinishStep, AxisObj + ChatFinishStep, AxisObj, SystemPromptMessage, HumanPromptMessage, AIPromptMessage from apps.data_training.curd.data_training import get_training_template -from apps.datasource.crud.datasource import get_table_schema +from apps.datasource.crud.datasource import get_table_schema, get_tables_sample_data from apps.datasource.crud.permission import get_row_permission_filters, is_normal_user 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.db.db import exec_sql, get_version, check_connection, get_sqlglot_dialect +from apps.system.crud.aimodel_manage import get_ai_model_list_by_workspace from apps.system.crud.assistant import AssistantOutDs, AssistantOutDsFactory, get_assistant_ds from apps.system.crud.parameter_manage import get_groups +from apps.system.crud.user import user_ws_list from apps.system.schemas.system_schema import AssistantOutDsSchema from apps.terminology.curd.terminology import get_terminology_template from common.core.config import settings @@ -64,9 +68,31 @@ i18n = I18n() +def extract_tables_from_sql(sql: str, ds_type: str = None) -> set: + """从 SQL 中提取真实表名(排除 CTE 别名)""" + tables = set() + dialect = get_sqlglot_dialect(ds_type) + try: + statements = sqlglot.parse(sql, dialect=dialect) + for stmt in statements: + if stmt: + # 收集 CTE 别名,排除嵌套 CTE + cte_names = set() + for cte in stmt.find_all(exp.CTE): + if cte.alias: + cte_names.add(cte.alias) + for table in stmt.find_all(exp.Table): + if table.name and table.name not in cte_names: + tables.add(table.name) + except Exception: + pass + return tables + + class LLMService: ds: CoreDatasource chat_question: ChatQuestion + oid: int record: ChatRecord config: LLMConfig llm: BaseChatModel @@ -104,15 +130,33 @@ def __init__(self, session: Session, current_user: CurrentUser, chat_question: C self.chunk_list = [] self.current_user = current_user self.current_assistant = current_assistant + + self.table_name_list = [] + + chat_question.lang = get_lang_name(current_user.language) + self.trans = i18n(lang=current_user.language) + chat_id = chat_question.chat_id chat: Chat | None = session.get(Chat, chat_id) if not chat: raise SingleMessageError(f"Chat with id {chat_id} not found") + self.oid = chat.oid + + if self.oid and not current_assistant: + w_list = user_ws_list(session, self.current_user.id) + oid_list = [item.id for item in w_list] + if int(self.oid) not in oid_list: + raise SingleMessageError("Current user cannot not access this chat") + if self.oid and current_assistant: + if self.oid != self.current_user.oid: + raise SingleMessageError("Current assistant user cannot not access this chat") + + self.current_user.oid = chat.oid ds: CoreDatasource | AssistantOutDsSchema | None = None if not chat.datasource and chat_question.datasource_id: _ds = session.get(CoreDatasource, chat_question.datasource_id) if _ds: - if _ds.oid != current_user.oid: + if _ds.oid != self.oid: raise SingleMessageError( f"Datasource with id {chat_question.datasource_id} does not belong to current workspace") chat.datasource = _ds.id @@ -142,9 +186,6 @@ def __init__(self, session: Session, current_user: CurrentUser, chat_question: C self.change_title = not get_chat_brief_generate(session=session, chat_id=chat_id) - chat_question.lang = get_lang_name(current_user.language) - self.trans = i18n(lang=current_user.language) - self.ds = ( ds if isinstance(ds, AssistantOutDsSchema) else CoreDatasource(**ds.model_dump())) if ds else None self.chat_question = chat_question @@ -174,11 +215,25 @@ def __init__(self, session: Session, current_user: CurrentUser, chat_question: C @classmethod async def create(cls, *args, **kwargs): - config: LLMConfig = await get_default_config() + specialized_model_id = None + _ai_model_list = [] + if args[3]: + if args[1]: + ws_id = args[1].oid + _ai_model_list = get_ai_model_list_by_workspace(args[0], ws_id) + if args[3].enable_custom_model: + if args[3].custom_model: + if any(str(model.id) == str(args[3].custom_model) for model in _ai_model_list): + specialized_model_id = args[3].custom_model + print("use custom model: id[" + specialized_model_id + "]") + config: LLMConfig = await get_default_config(specialized_model_id) instance = cls(*args, **kwargs, config=config) chat_params: list[SysArgModel] = await get_groups(args[0], "chat") for config in chat_params: + if config.pkey == 'chat.sqlbot_name': + if config.pval.strip(): + instance.chat_question.sqlbot_name = config.pval if config.pkey == 'chat.limit_rows': if config.pval.lower().strip() == 'true': instance.enable_sql_row_limit = True @@ -206,7 +261,7 @@ def is_running(self, timeout=0.5): def init_messages(self, session: Session): - self.choose_table_schema(session) + self.table_name_list = self.choose_table_schema(session) last_sql_messages: List[dict[str, Any]] = self.generate_sql_logs[-1].messages if len( self.generate_sql_logs) > 0 else [] @@ -216,12 +271,31 @@ def init_messages(self, session: Session): filter(lambda obj: obj.pid == self.chat_question.regenerate_record_id, self.generate_sql_logs), None) last_sql_messages: List[dict[str, Any]] = _temp_log.messages if _temp_log else [] + # 排除所有的系统提示词 + last_sql_messages = [obj for obj in last_sql_messages if obj.get("sqlbot_system") != True] + count_limit = self.base_message_round_count_limit self.sql_message = [] # add sys prompt - self.sql_message.append(SystemMessage( - content=self.chat_question.sql_sys_question(self.ds.type, self.enable_sql_row_limit))) + _system_templates = self.chat_question.sql_sys_question(self.ds.type, self.enable_sql_row_limit) + self.sql_message.append(SystemPromptMessage(content=_system_templates['system'])) + self.sql_message.append(HumanPromptMessage(content=_system_templates['rules'])) + self.sql_message.append( + AIPromptMessage(content='我已掌握所有规则,包括表结构、SQL规范、安全限制和输出格式,我会严格遵守这些规则。')) + self.sql_message.append(HumanPromptMessage(content=_system_templates['schema'])) + self.sql_message.append( + AIPromptMessage(content='我已确认您提供的数据库信息与表结构schema,我生成的SQL不会超出您提供的范围。')) + if _system_templates.get('custom_prompt'): + self.sql_message.append(HumanPromptMessage(content=_system_templates['custom_prompt'])) + self.sql_message.append(AIPromptMessage(content='我已确认您提供的额外信息,我会进行参考。')) + if _system_templates.get('terminologies'): + self.sql_message.append(HumanPromptMessage(content=_system_templates['terminologies'])) + self.sql_message.append(AIPromptMessage(content='我已确认您提供的术语信息,我会进行参考。')) + if _system_templates.get('data_training'): + self.sql_message.append(HumanPromptMessage(content=_system_templates['data_training'])) + self.sql_message.append(AIPromptMessage(content='我已确认您提供的SQL示例,我会进行参考。')) + if last_sql_messages is not None and len(last_sql_messages) > 0: last_rounds = get_last_conversation_rounds(last_sql_messages, rounds=count_limit) @@ -242,11 +316,17 @@ def init_messages(self, session: Session): filter(lambda obj: obj.pid == self.chat_question.regenerate_record_id, self.generate_chart_logs), None) last_chart_messages: List[dict[str, Any]] = _temp_log.messages if _temp_log else [] + # 排除所有的系统提示词 + last_chart_messages = [obj for obj in last_chart_messages if obj.get("sqlbot_system") != True] + count_chart_limit = self.base_message_round_count_limit self.chart_message = [] # add sys prompt - self.chart_message.append(SystemMessage(content=self.chat_question.chart_sys_question())) + _chart_system_templates = self.chat_question.chart_sys_question() + self.chart_message.append(SystemPromptMessage(content=_chart_system_templates['system'])) + self.chart_message.append(HumanPromptMessage(content=_chart_system_templates['rules'])) + self.chart_message.append(AIPromptMessage(content='我已掌握所有规则,我会严格遵守这些规则来生成符合要求的JSON。')) if last_chart_messages is not None and len(last_chart_messages) > 0: last_rounds = get_last_conversation_rounds(last_chart_messages, rounds=count_chart_limit) @@ -277,18 +357,26 @@ def get_fields_from_chart(self, _session: Session): return format_chart_fields(chart_info) def filter_terminology_template(self, _session: Session, oid: int = None, ds_id: int = None): + self.current_logs[OperationEnum.FILTER_TERMS] = start_log(session=_session, + operate=OperationEnum.FILTER_TERMS, + record_id=self.record.id, local_operation=True) 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 + calculate_oid = self.current_assistant.oid if self.current_assistant.type != 4 else self.oid if self.current_assistant.type == 1: calculate_ds_id = None - self.current_logs[OperationEnum.FILTER_TERMS] = start_log(session=_session, - operate=OperationEnum.FILTER_TERMS, - record_id=self.record.id, local_operation=True) + if self.current_assistant and self.current_assistant.type == 1: + self.chat_question.terminologies, term_list = get_terminology_template(_session, + self.chat_question.question, + calculate_oid, + None, self.current_assistant.id) + else: + self.chat_question.terminologies, term_list = get_terminology_template(_session, + self.chat_question.question, + calculate_oid, + calculate_ds_id) - self.chat_question.terminologies, term_list = get_terminology_template(_session, self.chat_question.question, - calculate_oid, calculate_ds_id) self.current_logs[OperationEnum.FILTER_TERMS] = end_log(session=_session, log=self.current_logs[OperationEnum.FILTER_TERMS], full_message=term_list) @@ -296,19 +384,26 @@ 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): if SQLBotLicenseUtil.valid(): + self.current_logs[OperationEnum.FILTER_CUSTOM_PROMPT] = start_log(session=_session, + operate=OperationEnum.FILTER_CUSTOM_PROMPT, + record_id=self.record.id, + local_operation=True) 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 + calculate_oid = self.current_assistant.oid if self.current_assistant.type != 4 else self.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) + if self.current_assistant and self.current_assistant.type == 1: + self.chat_question.custom_prompt, prompt_list = find_custom_prompts(_session, + custom_prompt_type, + calculate_oid, + None, self.current_assistant.id) + else: + 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], @@ -322,7 +417,7 @@ def filter_training_template(self, _session: Session, oid: int = None, ds_id: in 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 + calculate_oid = self.current_assistant.oid if self.current_assistant.type != 4 else self.oid if self.current_assistant.type == 1: calculate_ds_id = None if self.current_assistant and self.current_assistant.type == 1: @@ -345,16 +440,25 @@ def choose_table_schema(self, _session: Session): operate=OperationEnum.CHOOSE_TABLE, record_id=self.record.id, local_operation=True) - self.chat_question.db_schema = self.out_ds_instance.get_db_schema( + self.chat_question.db_schema, tables = self.out_ds_instance.get_db_schema( self.ds.id, self.chat_question.question) if self.out_ds_instance else get_table_schema( session=_session, current_user=self.current_user, ds=self.ds, question=self.chat_question.question) + # Get sample data for all tables + if not self.out_ds_instance: + self.chat_question.sample_data = get_tables_sample_data( + session=_session, + current_user=self.current_user, + ds=self.ds, + table_list=tables) + self.current_logs[OperationEnum.CHOOSE_TABLE] = end_log(session=_session, log=self.current_logs[OperationEnum.CHOOSE_TABLE], full_message=self.chat_question.db_schema) + return tables def generate_analysis(self, _session: Session): fields = self.get_fields_from_chart(_session) @@ -365,11 +469,11 @@ def generate_analysis(self, _session: Session): ds_id = self.ds.id if isinstance(self.ds, CoreDatasource) else None - self.filter_terminology_template(_session, self.current_user.oid, ds_id) + self.filter_terminology_template(_session, self.oid, ds_id) - self.filter_custom_prompts(_session, CustomPromptTypeEnum.ANALYSIS, self.current_user.oid, ds_id) + self.filter_custom_prompts(_session, CustomPromptTypeEnum.ANALYSIS, self.oid, ds_id) - analysis_msg.append(SystemMessage(content=self.chat_question.analysis_sys_question())) + analysis_msg.append(SystemPromptMessage(content=self.chat_question.analysis_sys_question())) analysis_msg.append(HumanMessage(content=self.chat_question.analysis_user_question())) self.current_logs[OperationEnum.ANALYSIS] = start_log(session=_session, @@ -379,6 +483,8 @@ def generate_analysis(self, _session: Session): record_id=self.record.id, full_message=[ {'type': msg.type, + 'sqlbot_system': getattr(msg, 'sqlbot_system', + False) is True, 'content': msg.content} for msg in analysis_msg]) @@ -400,6 +506,8 @@ def generate_analysis(self, _session: Session): OperationEnum.ANALYSIS], full_message=[ {'type': msg.type, + 'sqlbot_system': getattr(msg, 'sqlbot_system', + False) is True, 'content': msg.content} for msg in analysis_msg], reasoning_content=full_thinking_text, @@ -414,10 +522,10 @@ def generate_predict(self, _session: Session): self.chat_question.data = orjson.dumps(data.get('data')).decode() ds_id = self.ds.id if isinstance(self.ds, CoreDatasource) else None - self.filter_custom_prompts(_session, CustomPromptTypeEnum.PREDICT_DATA, self.current_user.oid, ds_id) + self.filter_custom_prompts(_session, CustomPromptTypeEnum.PREDICT_DATA, self.oid, ds_id) predict_msg: List[Union[BaseMessage, dict[str, Any]]] = [] - predict_msg.append(SystemMessage(content=self.chat_question.predict_sys_question())) + predict_msg.append(SystemPromptMessage(content=self.chat_question.predict_sys_question())) predict_msg.append(HumanMessage(content=self.chat_question.predict_user_question())) self.current_logs[OperationEnum.PREDICT_DATA] = start_log(session=_session, @@ -427,6 +535,8 @@ def generate_predict(self, _session: Session): record_id=self.record.id, full_message=[ {'type': msg.type, + 'sqlbot_system': getattr(msg, 'sqlbot_system', + False) is True, 'content': msg.content} for msg in predict_msg]) @@ -449,6 +559,8 @@ def generate_predict(self, _session: Session): OperationEnum.PREDICT_DATA], full_message=[ {'type': msg.type, + 'sqlbot_system': getattr(msg, 'sqlbot_system', + False) is True, 'content': msg.content} for msg in predict_msg], reasoning_content=full_thinking_text, @@ -458,15 +570,22 @@ def generate_recommend_questions_task(self, _session: Session): # get schema if self.ds and not self.chat_question.db_schema: - self.chat_question.db_schema = self.out_ds_instance.get_db_schema( + self.chat_question.db_schema, tables = self.out_ds_instance.get_db_schema( self.ds.id, self.chat_question.question) if self.out_ds_instance else get_table_schema( session=_session, current_user=self.current_user, ds=self.ds, question=self.chat_question.question, embedding=False) + # Get sample data for all tables + # if not self.out_ds_instance: + # self.chat_question.sample_data = get_tables_sample_data( + # session=_session, + # current_user=self.current_user, + # ds=self.ds) + guess_msg: List[Union[BaseMessage, dict[str, Any]]] = [] - guess_msg.append(SystemMessage(content=self.chat_question.guess_sys_question(self.articles_number))) + guess_msg.append(SystemPromptMessage(content=self.chat_question.guess_sys_question(self.articles_number))) old_questions = list(map(lambda q: q.strip(), get_old_questions(_session, self.record.datasource))) guess_msg.append( @@ -479,6 +598,9 @@ def generate_recommend_questions_task(self, _session: Session): record_id=self.record.id, full_message=[ {'type': msg.type, + 'sqlbot_system': getattr(msg, + 'sqlbot_system', + False) is True, 'content': msg.content} for msg in guess_msg]) @@ -500,6 +622,9 @@ def generate_recommend_questions_task(self, _session: Session): OperationEnum.GENERATE_RECOMMENDED_QUESTIONS], full_message=[ {'type': msg.type, + 'sqlbot_system': getattr(msg, + 'sqlbot_system', + False) is True, 'content': msg.content} for msg in guess_msg], reasoning_content=full_thinking_text, @@ -512,12 +637,12 @@ def generate_recommend_questions_task(self, _session: Session): def select_datasource(self, _session: Session): datasource_msg: List[Union[BaseMessage, dict[str, Any]]] = [] - datasource_msg.append(SystemMessage(self.chat_question.datasource_sys_question())) + datasource_msg.append(SystemPromptMessage(self.chat_question.datasource_sys_question())) if self.current_assistant and self.current_assistant.type != 4: _ds_list = get_assistant_ds(session=_session, llm_service=self) else: stmt = select(CoreDatasource.id, CoreDatasource.name, CoreDatasource.description).where( - and_(CoreDatasource.oid == self.current_user.oid)) + and_(CoreDatasource.oid == self.oid)) _ds_list = [ { "id": ds.id, @@ -536,7 +661,7 @@ def select_datasource(self, _session: Session): if not ignore_auto_select: if settings.TABLE_EMBEDDING_ENABLED and ( not self.current_assistant or (self.current_assistant and self.current_assistant.type != 1)): - _ds_list = get_ds_embedding(_session, self.current_user, _ds_list, self.out_ds_instance, + _ds_list = get_ds_embedding(_session, _ds_list, self.out_ds_instance, self.chat_question.question, self.current_assistant) # yield {'content': '{"id":' + str(ds.get('id')) + '}'} @@ -552,6 +677,9 @@ def select_datasource(self, _session: Session): operate=OperationEnum.CHOOSE_DATASOURCE, record_id=self.record.id, full_message=[{'type': msg.type, + 'sqlbot_system': getattr(msg, + 'sqlbot_system', + False) is True, 'content': msg.content} for msg in datasource_msg]) @@ -571,6 +699,9 @@ def select_datasource(self, _session: Session): OperationEnum.CHOOSE_DATASOURCE], full_message=[ {'type': msg.type, + 'sqlbot_system': getattr(msg, + 'sqlbot_system', + False) is True, 'content': msg.content} for msg in datasource_msg], reasoning_content=full_thinking_text, @@ -661,7 +792,10 @@ def generate_sql(self, _session: Session): operate=OperationEnum.GENERATE_SQL, record_id=self.record.id, full_message=[ - {'type': msg.type, 'content': msg.content} for msg + {'type': msg.type, + 'sqlbot_system': getattr(msg, 'sqlbot_system', + False) is True, + 'content': msg.content} for msg in self.sql_message]) full_thinking_text = '' full_sql_text = '' @@ -678,7 +812,11 @@ def generate_sql(self, _session: Session): self.current_logs[OperationEnum.GENERATE_SQL] = end_log(session=_session, log=self.current_logs[OperationEnum.GENERATE_SQL], - full_message=[{'type': msg.type, 'content': msg.content} + full_message=[{'type': msg.type, + 'sqlbot_system': getattr(msg, + 'sqlbot_system', + False) is True, + 'content': msg.content} for msg in self.sql_message], reasoning_content=full_thinking_text, token_usage=token_usage) @@ -690,7 +828,7 @@ def generate_with_sub_sql(self, session: Session, sql, sub_mappings: list): self.chat_question.sql = sql self.chat_question.sub_query = sub_query dynamic_sql_msg: List[Union[BaseMessage, dict[str, Any]]] = [] - dynamic_sql_msg.append(SystemMessage(content=self.chat_question.dynamic_sys_question())) + dynamic_sql_msg.append(SystemPromptMessage(content=self.chat_question.dynamic_sys_question())) dynamic_sql_msg.append(HumanMessage(content=self.chat_question.dynamic_user_question())) self.current_logs[OperationEnum.GENERATE_DYNAMIC_SQL] = start_log(session=session, @@ -699,6 +837,9 @@ def generate_with_sub_sql(self, session: Session, sql, sub_mappings: list): operate=OperationEnum.GENERATE_DYNAMIC_SQL, record_id=self.record.id, full_message=[{'type': msg.type, + 'sqlbot_system': getattr(msg, + 'sqlbot_system', + False) is True, 'content': msg.content} for msg in dynamic_sql_msg]) @@ -720,6 +861,9 @@ def generate_with_sub_sql(self, session: Session, sql, sub_mappings: list): OperationEnum.GENERATE_DYNAMIC_SQL], full_message=[ {'type': msg.type, + 'sqlbot_system': getattr(msg, + 'sqlbot_system', + False) is True, 'content': msg.content} for msg in dynamic_sql_msg], reasoning_content=full_thinking_text, @@ -748,7 +892,7 @@ def build_table_filter(self, session: Session, sql: str, filters: list): self.chat_question.sql = sql self.chat_question.filter = filter permission_sql_msg: List[Union[BaseMessage, dict[str, Any]]] = [] - permission_sql_msg.append(SystemMessage(content=self.chat_question.filter_sys_question())) + permission_sql_msg.append(SystemPromptMessage(content=self.chat_question.filter_sys_question())) permission_sql_msg.append(HumanMessage(content=self.chat_question.filter_user_question())) self.current_logs[OperationEnum.GENERATE_SQL_WITH_PERMISSIONS] = start_log(session=session, @@ -758,6 +902,9 @@ def build_table_filter(self, session: Session, sql: str, filters: list): record_id=self.record.id, full_message=[ {'type': msg.type, + 'sqlbot_system': getattr(msg, + 'sqlbot_system', + False) is True, 'content': msg.content} for msg in permission_sql_msg]) @@ -778,6 +925,9 @@ def build_table_filter(self, session: Session, sql: str, filters: list): OperationEnum.GENERATE_SQL_WITH_PERMISSIONS], full_message=[ {'type': msg.type, + 'sqlbot_system': getattr(msg, + 'sqlbot_system', + False) is True, 'content': msg.content} for msg in permission_sql_msg], reasoning_content=full_thinking_text, @@ -813,7 +963,10 @@ def generate_chart(self, _session: Session, chart_type: Optional[str] = '', sche operate=OperationEnum.GENERATE_CHART, record_id=self.record.id, full_message=[ - {'type': msg.type, 'content': msg.content} for + {'type': msg.type, + 'sqlbot_system': getattr(msg, 'sqlbot_system', + False) is True, + 'content': msg.content} for msg in self.chart_message]) full_thinking_text = '' @@ -834,7 +987,10 @@ def generate_chart(self, _session: Session, chart_type: Optional[str] = '', sche self.current_logs[OperationEnum.GENERATE_CHART] = end_log(session=_session, log=self.current_logs[OperationEnum.GENERATE_CHART], full_message=[ - {'type': msg.type, 'content': msg.content} + {'type': msg.type, + 'sqlbot_system': getattr(msg, 'sqlbot_system', + False) is True, + 'content': msg.content} for msg in self.chart_message], reasoning_content=full_thinking_text, token_usage=token_usage) @@ -1059,18 +1215,18 @@ def await_result(self): yield chunk def run_task_async(self, in_chat: bool = True, stream: bool = True, - finish_step: ChatFinishStep = ChatFinishStep.GENERATE_CHART): + finish_step: ChatFinishStep = ChatFinishStep.GENERATE_CHART, return_img: bool = True): if in_chat: stream = True - self.future = executor.submit(self.run_task_cache, in_chat, stream, finish_step) + self.future = executor.submit(self.run_task_cache, in_chat, stream, finish_step, return_img) def run_task_cache(self, in_chat: bool = True, stream: bool = True, - finish_step: ChatFinishStep = ChatFinishStep.GENERATE_CHART): - for chunk in self.run_task(in_chat, stream, finish_step): + finish_step: ChatFinishStep = ChatFinishStep.GENERATE_CHART, return_img: bool = True): + for chunk in self.run_task(in_chat, stream, finish_step, return_img): self.chunk_list.append(chunk) def run_task(self, in_chat: bool = True, stream: bool = True, - finish_step: ChatFinishStep = ChatFinishStep.GENERATE_CHART): + finish_step: ChatFinishStep = ChatFinishStep.GENERATE_CHART, return_img: bool = True): json_result: Dict[str, Any] = {'success': True} _session = None try: @@ -1165,6 +1321,22 @@ def run_task(self, in_chat: bool = True, stream: bool = True, sql_operate = OperationEnum.GENERATE_SQL sql, tables = self.check_sql(session=_session, res=full_sql_text, operate=sql_operate) + + # 表名安全检查:用 sqlglot 解析真实 SQL,不信任 AI 返回的 tables + actual_tables = extract_tables_from_sql(sql, ds_type=self.ds.type) + if not actual_tables: + raise SingleMessageError( + "SQL parsing failed: unable to extract table names. " + "This may indicate an unsupported SQL syntax or a security issue." + ) + allowed_tables = set(self.table_name_list) + unauthorized_tables = actual_tables - allowed_tables + if unauthorized_tables: + raise SingleMessageError( + f"SQL contains unauthorized tables: {', '.join(unauthorized_tables)}. " + f"Allowed tables: {', '.join(allowed_tables)}" + ) + if ((not self.current_assistant or is_page_embedded) and is_normal_user( self.current_user)) or use_dynamic_ds: sql_result = None @@ -1227,6 +1399,7 @@ def run_task(self, in_chat: bool = True, stream: bool = True, 'count': len(result.get('data'))}) _data = DataFormat.convert_large_numbers_in_object_array(result.get('data')) + _data = DataFormat.normalize_qualified_sql_column_keys_in_object_array(_data) result["data"] = _data self.save_sql_data(session=_session, data_obj=result) @@ -1261,7 +1434,7 @@ def run_task(self, in_chat: bool = True, stream: bool = True, return # generate chart - used_tables_schema = self.out_ds_instance.get_db_schema( + used_tables_schema, used_tables = self.out_ds_instance.get_db_schema( self.ds.id, self.chat_question.question, embedding=False, table_list=tables) if self.out_ds_instance else get_table_schema( session=_session, @@ -1311,7 +1484,7 @@ def run_task(self, in_chat: bool = True, stream: bool = True, else: # generate picture try: - if chart.get('type') != 'table': + if chart.get('type') != 'table' and return_img: # yield '### generated chart picture\n\n' self.current_logs[OperationEnum.GENERATE_PICTURE] = start_log(session=_session, operate=OperationEnum.GENERATE_PICTURE, @@ -1762,6 +1935,8 @@ def get_lang_name(lang: str): if not lang: return '简体中文' normalized = lang.lower() + if normalized.startswith('zh-tw'): + return '繁体中文' if normalized.startswith('en'): return '英文' if normalized.startswith('ko'): diff --git a/backend/apps/dashboard/crud/dashboard_service.py b/backend/apps/dashboard/crud/dashboard_service.py index c9c836502..ff33f8352 100644 --- a/backend/apps/dashboard/crud/dashboard_service.py +++ b/backend/apps/dashboard/crud/dashboard_service.py @@ -50,7 +50,7 @@ def load_resource(session: SessionDep, dashboard: QueryDashboard): result_dict = dict(result) canvas_view_obj = orjson.loads(result_dict['canvas_view_info']) for item in canvas_view_obj.values(): - if all(key in item for key in ['datasource', 'sql']) and item['datasource'] is not None: + if all(key in item for key in ['datasource', 'sql']) and item['datasource'] is not None and item['sql'] is not None: data_result = get_chart_data_ds(session, item['datasource'], item['sql']) item['data']['data'] = data_result['data'] item['status'] = data_result['status'] diff --git a/backend/apps/data_training/api/data_training.py b/backend/apps/data_training/api/data_training.py index 2e5bf77b4..d3e662cfb 100644 --- a/backend/apps/data_training/api/data_training.py +++ b/backend/apps/data_training/api/data_training.py @@ -45,7 +45,8 @@ async def pager(session: SessionDep, current_user: CurrentUser, current_page: in @router.put("", response_model=int, summary=f"{PLACEHOLDER_PREFIX}create_or_update_dt") @require_permissions(permission=SqlbotPermission(role=['ws_admin'], type='ds', keyExpression="info.datasource")) -@system_log(LogConfig(operation_type=OperationType.CREATE_OR_UPDATE, module=OperationModules.DATA_TRAINING,resource_id_expr='info.id', result_id_expr="result_self")) +@system_log(LogConfig(operation_type=OperationType.CREATE_OR_UPDATE, module=OperationModules.DATA_TRAINING, + resource_id_expr='info.id', result_id_expr="result_self")) async def create_or_update(session: SessionDep, current_user: CurrentUser, trans: Trans, info: DataTrainingInfo): oid = current_user.oid if info.id: @@ -55,14 +56,16 @@ async def create_or_update(session: SessionDep, current_user: CurrentUser, trans @router.delete("", summary=f"{PLACEHOLDER_PREFIX}delete_dt") -@system_log(LogConfig(operation_type=OperationType.DELETE, module=OperationModules.DATA_TRAINING,resource_id_expr='id_list')) +@system_log( + LogConfig(operation_type=OperationType.DELETE, module=OperationModules.DATA_TRAINING, resource_id_expr='id_list')) @require_permissions(permission=SqlbotPermission(role=['ws_admin'])) async def delete(session: SessionDep, id_list: list[int]): delete_training(session, id_list) @router.get("/{id}/enable/{enabled}", summary=f"{PLACEHOLDER_PREFIX}enable_dt") -@system_log(LogConfig(operation_type=OperationType.UPDATE, module=OperationModules.DATA_TRAINING,resource_id_expr='id')) +@system_log( + LogConfig(operation_type=OperationType.UPDATE, module=OperationModules.DATA_TRAINING, resource_id_expr='id')) @require_permissions(permission=SqlbotPermission(role=['ws_admin'])) async def enable(session: SessionDep, id: int, enabled: bool, trans: Trans): enable_training(session, id, enabled, trans) @@ -89,9 +92,7 @@ def inner(): fields.append(AxisObj(name=trans('i18n_data_training.problem_description'), value='question')) fields.append(AxisObj(name=trans('i18n_data_training.sample_sql'), value='description')) fields.append(AxisObj(name=trans('i18n_data_training.effective_data_sources'), value='datasource_name')) - if current_user.oid == 1: - fields.append( - AxisObj(name=trans('i18n_data_training.advanced_application'), value='advanced_application_name')) + fields.append(AxisObj(name=trans('i18n_data_training.advanced_application'), value='advanced_application_name')) md_data, _fields_list = DataFormat.convert_object_array_for_pandas(fields, data_list) @@ -127,10 +128,9 @@ def inner(): fields.append(AxisObj(name=trans('i18n_data_training.sample_sql_template'), value='description')) fields.append( AxisObj(name=trans('i18n_data_training.effective_data_sources_template'), value='datasource_name')) - if current_user.oid == 1: - fields.append( - AxisObj(name=trans('i18n_data_training.advanced_application_template'), - value='advanced_application_name')) + fields.append( + AxisObj(name=trans('i18n_data_training.advanced_application_template'), + value='advanced_application_name')) md_data, _fields_list = DataFormat.convert_object_array_for_pandas(fields, data_list) @@ -160,6 +160,7 @@ def inner(): @router.post("/uploadExcel", summary=f"{PLACEHOLDER_PREFIX}upload_excel_dt") @system_log(LogConfig(operation_type=OperationType.IMPORT, module=OperationModules.DATA_TRAINING)) +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) async def upload_excel(trans: Trans, current_user: CurrentUser, file: UploadFile = File(...)): ALLOWED_EXTENSIONS = {"xlsx", "xls"} if not file.filename.lower().endswith(tuple(ALLOWED_EXTENSIONS)): @@ -174,10 +175,7 @@ async def upload_excel(trans: Trans, current_user: CurrentUser, file: UploadFile oid = current_user.oid - use_cols = [0, 1, 2] # 问题, 描述, 数据源名称 - # 根据oid确定要读取的列 - if oid == 1: - use_cols = [0, 1, 2, 3] # 问题, 描述, 数据源名称, 高级应用名称 + use_cols = [0, 1, 2, 3] # 问题, 描述, 数据源名称, 高级应用名称 def inner(): @@ -210,19 +208,14 @@ def inner(): description = row[1].strip() if pd.notna(row[1]) and row[1].strip() else '' datasource_name = row[2].strip() if pd.notna(row[2]) and row[2].strip() else '' - advanced_application_name = '' - if oid == 1 and len(row) > 3: + advanced_application_name = None + if len(row) > 3: advanced_application_name = row[3].strip() if pd.notna(row[3]) and row[3].strip() else '' - if oid == 1: - import_data.append( - DataTrainingInfo(oid=oid, question=question, description=description, - datasource_name=datasource_name, - advanced_application_name=advanced_application_name)) - else: - import_data.append( - DataTrainingInfo(oid=oid, question=question, description=description, - datasource_name=datasource_name)) + import_data.append( + DataTrainingInfo(oid=oid, question=question, description=description, + datasource_name=datasource_name, + advanced_application_name=advanced_application_name)) res = batch_create_training(session, import_data, oid, trans) @@ -246,9 +239,8 @@ def inner(): fields.append(AxisObj(name=trans('i18n_data_training.problem_description'), value='question')) fields.append(AxisObj(name=trans('i18n_data_training.sample_sql'), value='description')) fields.append(AxisObj(name=trans('i18n_data_training.effective_data_sources'), value='datasource_name')) - if current_user.oid == 1: - fields.append( - AxisObj(name=trans('i18n_data_training.advanced_application'), value='advanced_application_name')) + fields.append( + AxisObj(name=trans('i18n_data_training.advanced_application'), value='advanced_application_name')) fields.append(AxisObj(name=trans('i18n_data_training.error_info'), value='errors')) md_data, _fields_list = DataFormat.convert_object_array_for_pandas(fields, data_list) diff --git a/backend/apps/datasource/api/datasource.py b/backend/apps/datasource/api/datasource.py index 309d3a71e..90801fdf2 100644 --- a/backend/apps/datasource/api/datasource.py +++ b/backend/apps/datasource/api/datasource.py @@ -4,33 +4,34 @@ import os import traceback import uuid +import re from io import StringIO from typing import List from urllib.parse import quote -import orjson import pandas as pd -from psycopg2 import sql from fastapi import APIRouter, File, UploadFile, HTTPException, Path from fastapi.responses import StreamingResponse +from psycopg2 import sql from sqlalchemy import and_ from apps.db.db import get_schema from apps.db.engine import get_engine_conn from apps.swagger.i18n import PLACEHOLDER_PREFIX from apps.system.schemas.permission import SqlbotPermission, require_permissions +from common.audit.models.log_model import OperationType, OperationModules +from common.audit.schemas.logger_decorator import LogConfig, system_log from common.core.config import settings from common.core.deps import SessionDep, CurrentUser, Trans from common.utils.utils import SQLBotLogUtil from ..crud.datasource import get_datasource_list, check_status, create_ds, update_ds, delete_ds, getTables, getFields, \ - execSql, update_table_and_fields, getTablesByDs, chooseTables, preview, updateTable, updateField, get_ds, fieldEnum, \ + update_table_and_fields, getTablesByDs, chooseTables, preview, updateTable, updateField, get_ds, fieldEnum, \ check_status_by_id, sync_single_fields from ..crud.field import get_fields_by_table_id from ..crud.table import get_tables_by_ds_id from ..models.datasource import CoreDatasource, CreateDatasource, TableObj, CoreTable, CoreField, FieldObj, \ - TableSchemaResponse, ColumnSchemaResponse, PreviewResponse -from common.audit.models.log_model import OperationType, OperationModules -from common.audit.schemas.logger_decorator import LogConfig, system_log + TableSchemaResponse, ColumnSchemaResponse, PreviewResponse, ImportRequest +from ..utils.excel import parse_excel_preview, USER_TYPE_TO_PANDAS router = APIRouter(tags=["Datasource"], prefix="/datasource") path = settings.EXCEL_PATH @@ -64,6 +65,7 @@ def inner(): @router.get("/check/{ds_id}", response_model=bool, summary=f"{PLACEHOLDER_PREFIX}ds_check") +@require_permissions(permission=SqlbotPermission(type='ds', keyExpression="ds_id")) async def check_by_id(session: SessionDep, trans: Trans, ds_id: int = Path(..., description=f"{PLACEHOLDER_PREFIX}ds_id")): def inner(): @@ -76,25 +78,11 @@ def inner(): @system_log(LogConfig(operation_type=OperationType.CREATE, module=OperationModules.DATASOURCE, result_id_expr="id")) @require_permissions(permission=SqlbotPermission(role=['ws_admin'])) async def add(session: SessionDep, trans: Trans, user: CurrentUser, ds: CreateDatasource): - """ def inner(): - return create_ds(session, trans, user, ds) - - return await asyncio.to_thread(inner) """ - loop = asyncio.get_event_loop() - - def sync_wrapper(): - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - return loop.run_until_complete(create_ds(session, trans, user, ds)) - finally: - loop.close() - - return await loop.run_in_executor(None, sync_wrapper) + return await create_ds(session, trans, user, ds) @router.post("/chooseTables/{id}", response_model=None, summary=f"{PLACEHOLDER_PREFIX}ds_choose_tables") -@require_permissions(permission=SqlbotPermission(role=['ws_admin'], permission=SqlbotPermission(type='ds', keyExpression="id"))) +@require_permissions(permission=SqlbotPermission(role=['ws_admin'], type='ds', keyExpression="id")) async def choose_tables(session: SessionDep, trans: Trans, tables: List[CoreTable], id: int = Path(..., description=f"{PLACEHOLDER_PREFIX}ds_id")): def inner(): @@ -104,7 +92,7 @@ def inner(): @router.post("/update", response_model=CoreDatasource, summary=f"{PLACEHOLDER_PREFIX}ds_update") -@require_permissions(permission=SqlbotPermission(role=['ws_admin'], permission=SqlbotPermission(type='ds', keyExpression="ds.id"))) +@require_permissions(permission=SqlbotPermission(role=['ws_admin'], type='ds', keyExpression="ds.id")) @system_log( LogConfig(operation_type=OperationType.UPDATE, module=OperationModules.DATASOURCE, resource_id_expr="ds.id")) async def update(session: SessionDep, trans: Trans, user: CurrentUser, ds: CoreDatasource): @@ -176,6 +164,7 @@ async def get_fields(session: SessionDep, @router.post("/syncFields/{id}", response_model=None, summary=f"{PLACEHOLDER_PREFIX}ds_sync_fields") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) async def sync_fields(session: SessionDep, trans: Trans, id: int = Path(..., description=f"{PLACEHOLDER_PREFIX}ds_table_id")): return sync_single_fields(session, trans, id) @@ -237,6 +226,7 @@ async def edit_field(session: SessionDep, field: CoreField): @router.post("/previewData/{id}", response_model=PreviewResponse, summary=f"{PLACEHOLDER_PREFIX}ds_preview_data") +@require_permissions(permission=SqlbotPermission(type='ds', keyExpression="id")) async def preview_data(session: SessionDep, trans: Trans, current_user: CurrentUser, data: TableObj, id: int = Path(..., description=f"{PLACEHOLDER_PREFIX}ds_id")): def inner(): @@ -254,8 +244,9 @@ def inner(): # not used -@router.post("/fieldEnum/{id}", include_in_schema=False) -async def field_enum(session: SessionDep, id: int): +@router.post("/fieldEnum/{ds_id}/{id}", include_in_schema=False) +@require_permissions(permission=SqlbotPermission(type='ds', keyExpression="ds_id")) +async def field_enum(session: SessionDep, ds_id: int, id: int): def inner(): return fieldEnum(session, id) @@ -326,7 +317,9 @@ def inner(): # return await asyncio.to_thread(inner) +# deprecated @router.post("/uploadExcel", response_model=None, summary=f"{PLACEHOLDER_PREFIX}ds_upload_excel") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) async def upload_excel(session: SessionDep, file: UploadFile = File(..., description=f"{PLACEHOLDER_PREFIX}ds_excel")): ALLOWED_EXTENSIONS = {"xlsx", "xls", "csv"} if not file.filename.lower().endswith(tuple(ALLOWED_EXTENSIONS)): @@ -405,6 +398,7 @@ def insert_pg(df, tableName, engine): @router.get("/exportDsSchema/{id}", response_model=None, summary=f"{PLACEHOLDER_PREFIX}ds_export_ds_schema") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'], type='ds', keyExpression="id")) async def export_ds_schema(session: SessionDep, id: int = Path(..., description=f"{PLACEHOLDER_PREFIX}ds_id")): # { # 'sheet':'', sheet name @@ -479,6 +473,7 @@ def inner(): @router.post("/uploadDsSchema/{id}", response_model=None, summary=f"{PLACEHOLDER_PREFIX}ds_upload_ds_schema") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'], type='ds', keyExpression="id")) async def upload_ds_schema(session: SessionDep, id: int = Path(..., description=f"{PLACEHOLDER_PREFIX}ds_id"), file: UploadFile = File(...)): ALLOWED_EXTENSIONS = {"xlsx", "xls"} @@ -537,3 +532,97 @@ async def upload_ds_schema(session: SessionDep, id: int = Path(..., description= return True except Exception as e: raise HTTPException(status_code=500, detail=f"Parse Excel Failed: {str(e)}") + + +@router.post("/parseExcel", response_model=None, summary=f"{PLACEHOLDER_PREFIX}ds_parse_excel") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) +async def parse_excel(file: UploadFile = File(..., description=f"{PLACEHOLDER_PREFIX}ds_excel")): + ALLOWED_EXTENSIONS = {".xlsx", ".xls", ".csv"} + if not file.filename.lower().endswith(tuple(ALLOWED_EXTENSIONS)): + raise HTTPException(400, "Only support .xlsx/.xls/.csv") + + os.makedirs(path, exist_ok=True) + filename = f"{file.filename.split('.')[0].split('/')[-1]}_{hashlib.sha256(uuid.uuid4().bytes).hexdigest()[:10]}.{file.filename.split('.')[-1]}" + save_path = os.path.join(path, filename) + with open(save_path, "wb") as f: + f.write(await file.read()) + + def inner(): + sheets_data = parse_excel_preview(save_path) + return { + "filePath": filename, + "data": sheets_data + } + + return await asyncio.to_thread(inner) + + +@router.post("/importToDb", response_model=None, summary=f"{PLACEHOLDER_PREFIX}ds_import_to_db") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) +async def import_to_db(session: SessionDep, trans: Trans, import_req: ImportRequest): + save_path = os.path.join(path, import_req.filePath) + if not os.path.exists(save_path): + raise HTTPException(400, "File not found") + + def inner(): + engine = get_engine_conn() + results = [] + + for sheet_info in import_req.sheets: + sheet_name = sheet_info.sheetName + table_name = f"excel_{filter_string(sheet_name)}_{hashlib.sha256(uuid.uuid4().bytes).hexdigest()[:10]}" + fields = sheet_info.fields + + field_mapping = {f.fieldName: f.fieldType for f in fields} + dtype_dict = { + col: USER_TYPE_TO_PANDAS.get(field_mapping.get(col, 'string'), 'string') + for col in field_mapping.keys() + } + + try: + if save_path.endswith(".csv"): + df = pd.read_csv(save_path, engine='c', dtype=dtype_dict) + sheet_name = "Sheet1" + else: + df = pd.read_excel(save_path, sheet_name=sheet_name, engine='calamine', dtype=dtype_dict) + except Exception as e: + raise HTTPException(500, f"{trans('i18n_ds_upload_error')}: {str(e)}") + + conn = engine.raw_connection() + cursor = conn.cursor() + try: + df.to_sql( + table_name, + engine, + if_exists='replace', + index=False + ) + output = StringIO() + df.to_csv(output, sep='\t', header=False, index=False) + + query = sql.SQL("COPY {} FROM STDIN WITH CSV DELIMITER E'\t'").format( + sql.Identifier(table_name) + ) + cursor.copy_expert(sql=query.as_string(cursor.connection), file=output) + conn.commit() + results.append({ + "sheetName": sheet_name, + "tableName": table_name, + "tableComment": "", + "rows": len(df) + }) + except Exception as e: + raise HTTPException(500, f"Insert data failed for {table_name}: {str(e)}") + finally: + cursor.close() + conn.close() + + return {"filename": import_req.filePath, "sheets": results} + + return await asyncio.to_thread(inner) + + +# only allow chinese, a-z, A-Z, 0-9 +def filter_string(text): + pattern = r'[^\u4e00-\u9fa5a-zA-Z0-9]' + return re.sub(pattern, '', text) diff --git a/backend/apps/datasource/crud/datasource.py b/backend/apps/datasource/crud/datasource.py index 6c722244c..acd7522e5 100644 --- a/backend/apps/datasource/crud/datasource.py +++ b/backend/apps/datasource/crud/datasource.py @@ -17,7 +17,7 @@ from common.core.config import settings from common.core.deps import SessionDep, CurrentUser, Trans from common.utils.embedding_threads import run_save_table_embeddings, run_save_ds_embeddings -from common.utils.utils import SQLBotLogUtil, deepcopy_ignore_extra +from common.utils.utils import SQLBotLogUtil, deepcopy_ignore_extra, equals_ignore_case from common.core.sqlbot_cache import cache, clear_cache from .table import get_tables_by_ds_id from ..crud.field import delete_field_by_ds_id, update_field @@ -31,7 +31,7 @@ def get_datasource_list(session: SessionDep, user: CurrentUser, oid: Optional[in if user.isAdmin and oid: current_oid = oid return session.exec( - select(CoreDatasource).where(CoreDatasource.oid == current_oid).order_by(CoreDatasource.name)).all() + select(CoreDatasource).where(CoreDatasource.oid == int(current_oid)).order_by(CoreDatasource.name)).all() def get_ds(session: SessionDep, id: int): @@ -65,6 +65,7 @@ def check_name(session: SessionDep, trans: Trans, user: CurrentUser, ds: CoreDat if ds_list is not None and len(ds_list) > 0: raise HTTPException(status_code=500, detail=trans('i18n_ds_name_exist')) + @clear_cache(namespace=CacheNamespace.AUTH_INFO, cacheName=CacheName.DS_ID_LIST, keyExpression="user.oid") async def create_ds(session: SessionDep, trans: Trans, user: CurrentUser, create_ds: CreateDatasource): ds = CoreDatasource() @@ -327,18 +328,19 @@ def preview(session: SessionDep, current_user: CurrentUser, id: int, data: Table if fields is None or len(fields) == 0: return {"fields": [], "data": [], "sql": ''} + table = session.query(CoreTable).filter(CoreTable.id == data.table.id).first() conf = DatasourceConf(**json.loads(aes_decrypt(ds.configuration))) if ds.type != "excel" else get_engine_config() sql: str = "" - if ds.type == "mysql" or ds.type == "doris" or ds.type == "starrocks": - sql = f"""SELECT `{"`, `".join(fields)}` FROM `{data.table.table_name}` + if ds.type == "mysql" or ds.type == "doris" or ds.type == "starrocks" or ds.type == "hive": + sql = f"""SELECT `{"`, `".join(fields)}` FROM `{table.table_name}` {where} LIMIT 100""" elif ds.type == "sqlServer": - sql = f"""SELECT TOP 100 [{"], [".join(fields)}] FROM [{conf.dbSchema}].[{data.table.table_name}] + sql = f"""SELECT TOP 100 [{"], [".join(fields)}] FROM [{conf.dbSchema}].[{table.table_name}] {where} """ elif ds.type == "pg" or ds.type == "excel" or ds.type == "redshift" or ds.type == "kingbase": - sql = f"""SELECT "{'", "'.join(fields)}" FROM "{conf.dbSchema}"."{data.table.table_name}" + sql = f"""SELECT "{'", "'.join(fields)}" FROM "{conf.dbSchema}"."{table.table_name}" {where} LIMIT 100""" elif ds.type == "oracle": @@ -347,22 +349,22 @@ def preview(session: SessionDep, current_user: CurrentUser, id: int, data: Table # ORDER BY "{fields[0]}" # OFFSET 0 ROWS FETCH NEXT 100 ROWS ONLY""" sql = f"""SELECT * FROM - (SELECT "{'", "'.join(fields)}" FROM "{conf.dbSchema}"."{data.table.table_name}" + (SELECT "{'", "'.join(fields)}" FROM "{conf.dbSchema}"."{table.table_name}" {where} ORDER BY "{fields[0]}") WHERE ROWNUM <= 100 """ elif ds.type == "ck": - sql = f"""SELECT "{'", "'.join(fields)}" FROM "{data.table.table_name}" + sql = f"""SELECT "{'", "'.join(fields)}" FROM "{table.table_name}" {where} LIMIT 100""" elif ds.type == "dm": - sql = f"""SELECT "{'", "'.join(fields)}" FROM "{conf.dbSchema}"."{data.table.table_name}" - {where} + sql = f"""SELECT "{'", "'.join(fields)}" FROM "{conf.dbSchema}"."{table.table_name}" + {where} LIMIT 100""" elif ds.type == "es": - sql = f"""SELECT "{'", "'.join(fields)}" FROM "{data.table.table_name}" - {where} + sql = f"""SELECT "{'", "'.join(fields)}" FROM "{table.table_name}" + {where} LIMIT 100""" return exec_sql(ds, sql, True) @@ -430,23 +432,98 @@ def get_table_obj_by_ds(session: SessionDep, current_user: CurrentUser, ds: Core return _list +def get_table_sample_data(ds: CoreDatasource, table_name: str, fields: list) -> str: + """Get 3 sample rows from a table in JSON format to help AI understand the data""" + if not fields: + return "" + + db = DB.get_db(ds.type) + # Get prefix/suffix for identifier quoting + prefix = db.prefix if hasattr(db, 'prefix') else '"' + suffix = db.suffix if hasattr(db, 'suffix') else '"' + + # Build field list with proper quoting + field_names = [] + for field in fields[:10]: # Limit to first 10 fields to avoid too wide results + field_name = f"{prefix}{field.field_name}{suffix}" + field_names.append(field_name) + + # Build LIMIT query based on database type + if equals_ignore_case(ds.type, "sqlServer"): + query = f"SELECT TOP 3 {','.join(field_names)} FROM {prefix}{table_name}{suffix}" + elif equals_ignore_case(ds.type, "ck"): + query = f"SELECT {','.join(field_names)} FROM {table_name} LIMIT 3" + elif equals_ignore_case(ds.type, "hive"): + query = f"SELECT {','.join(field_names)} FROM {table_name} LIMIT 3" + elif equals_ignore_case(ds.type, "oracle"): + query = f"SELECT {','.join(field_names)} FROM \"{table_name}\" WHERE ROWNUM <= 3" + elif equals_ignore_case(ds.type, "dm"): + query = f"SELECT {','.join(field_names)} FROM \"{table_name}\" WHERE ROWNUM <= 3" + else: + query = f"SELECT {','.join(field_names)} FROM {prefix}{table_name}{suffix} LIMIT 3" + + try: + result = exec_sql(ds=ds, sql=query, origin_column=True) + if result and result.get('data') and len(result['data']) > 0: + import json + # Truncate long string values for readability + json_rows = [] + for row in result['data'][:3]: + truncated_row = {} + for key, value in row.items(): + if value is None: + truncated_row[key] = None + elif isinstance(value, str): + # Truncate long strings + if len(value) > 100: + value = value[:100] + '...' + truncated_row[key] = value.replace('\n', ' ').replace('\r', ' ') + else: + truncated_row[key] = value + json_rows.append(truncated_row) + return json.dumps(json_rows, ensure_ascii=False, indent=2) + except Exception: + pass + return "" + + +def get_tables_sample_data(session: SessionDep, current_user: CurrentUser, ds: CoreDatasource, + table_list: list[str] = None) -> str: + """Get sample data (3 rows) for all tables to help AI understand the data""" + table_objs = get_table_obj_by_ds(session=session, current_user=current_user, ds=ds) + if len(table_objs) == 0: + return "" + + sample_data_parts = [] + for obj in table_objs: + if table_list is not None and obj.table.table_name not in table_list: + continue + if obj.fields: + sample = get_table_sample_data(ds, obj.table.table_name, obj.fields) + if sample: + sample_data_parts.append(f"# Table: {obj.table.table_name}\n{sample}") + return "\n".join(sample_data_parts) + + def get_table_schema(session: SessionDep, current_user: CurrentUser, ds: CoreDatasource, question: str, - embedding: bool = True, table_list: list[str] = None) -> str: + embedding: bool = True, table_list: list[str] = None) -> tuple[str, list]: schema_str = "" table_objs = get_table_obj_by_ds(session=session, current_user=current_user, ds=ds) if len(table_objs) == 0: - return schema_str + return schema_str, [] db_name = table_objs[0].schema schema_str += f"【DB_ID】 {db_name}\n【Schema】\n" tables = [] all_tables = [] # temp save all tables + table_name_list = [] for obj in table_objs: # 如果传入了table_list,则只处理在列表中的表 if table_list is not None and obj.table.table_name not in table_list: continue schema_table = '' - schema_table += f"# Table: {db_name}.{obj.table.table_name}" if ds.type != "mysql" and ds.type != "es" else f"# Table: {obj.table.table_name}" + no_schema_types = ["mysql", "es", "sqlite", "hive", "doris", "starrocks"] + schema_table += f"# Table: {db_name}.{obj.table.table_name}" if ds.type not in no_schema_types and db_name else f"# Table: {obj.table.table_name}" table_comment = '' if obj.table.custom_comment: table_comment = obj.table.custom_comment.strip() @@ -468,13 +545,14 @@ def get_table_schema(session: SessionDep, current_user: CurrentUser, ds: CoreDat schema_table += ",\n".join(field_list) schema_table += '\n]\n' - t_obj = {"id": obj.table.id, "schema_table": schema_table, "embedding": obj.table.embedding} + t_obj = {"id": obj.table.id, "table_name": obj.table.table_name, "schema_table": schema_table, + "embedding": obj.table.embedding} tables.append(t_obj) all_tables.append(t_obj) # 如果没有符合过滤条件的表,直接返回 if not tables: - return schema_str + return schema_str, [] # do table embedding if embedding and tables and settings.TABLE_EMBEDDING_ENABLED: @@ -483,6 +561,7 @@ def get_table_schema(session: SessionDep, current_user: CurrentUser, ds: CoreDat if tables: for s in tables: schema_str += s.get('schema_table') + table_name_list.append(s.get('table_name')) # field relation if tables and ds.table_relation: @@ -514,6 +593,7 @@ def get_table_schema(session: SessionDep, current_user: CurrentUser, ds: CoreDat if lost_tables: for s in lost_tables: schema_str += s.get('schema_table') + table_name_list.append(s.get('table_name')) # get field dict relation_field_ids = [] @@ -531,13 +611,16 @@ def get_table_schema(session: SessionDep, current_user: CurrentUser, ds: CoreDat for ele in all_relations: schema_str += f"{table_dict.get(int(ele.get('source').get('cell')))}.{field_dict.get(int(ele.get('source').get('port')))}={table_dict.get(int(ele.get('target').get('cell')))}.{field_dict.get(int(ele.get('target').get('port')))}\n" - return schema_str + return schema_str, table_name_list + @cache(namespace=CacheNamespace.AUTH_INFO, cacheName=CacheName.DS_ID_LIST, keyExpression="oid") async def get_ws_ds(session, oid) -> list: stmt = select(CoreDatasource.id).distinct().where(CoreDatasource.oid == oid) db_list = session.exec(stmt).all() return db_list + + @clear_cache(namespace=CacheNamespace.AUTH_INFO, cacheName=CacheName.DS_ID_LIST, keyExpression="oid") async def clear_ws_ds_cache(oid): - SQLBotLogUtil.info(f"ds cache for ws [{oid}] has been cleaned") \ No newline at end of file + SQLBotLogUtil.info(f"ds cache for ws [{oid}] has been cleaned") diff --git a/backend/apps/datasource/crud/permission.py b/backend/apps/datasource/crud/permission.py index 3e38d0447..dfa56c75f 100644 --- a/backend/apps/datasource/crud/permission.py +++ b/backend/apps/datasource/crud/permission.py @@ -1,7 +1,7 @@ import json from typing import List, Optional -from sqlalchemy import and_ +from sqlalchemy import and_, cast, or_ from sqlbot_xpack.permissions.api.permission import transRecord2DTO from sqlbot_xpack.permissions.models.ds_permission import DsPermission, PermissionDTO from sqlbot_xpack.permissions.models.ds_rules import DsRules @@ -9,6 +9,7 @@ from apps.datasource.crud.row_permission import transFilterTree from apps.datasource.models.datasource import CoreDatasource, CoreField, CoreTable from common.core.deps import CurrentUser, SessionDep +from sqlalchemy.dialects.postgresql import JSONB def get_row_permission_filters(session: SessionDep, current_user: CurrentUser, ds: CoreDatasource, @@ -22,7 +23,7 @@ def get_row_permission_filters(session: SessionDep, current_user: CurrentUser, d filters = [] if is_normal_user(current_user): - contain_rules = session.query(DsRules).all() + # contain_rules = session.query(DsRules).all() for table in table_list: row_permissions = session.query(DsPermission).filter( and_(DsPermission.table_id == table.id, DsPermission.type == 'row')).all() @@ -30,16 +31,24 @@ def get_row_permission_filters(session: SessionDep, current_user: CurrentUser, d if row_permissions is not None: for permission in row_permissions: # check permission and user in same rules - flag = False - for r in contain_rules: - p_list = json.loads(r.permission_list) - u_list = json.loads(r.user_list) - if p_list is not None and u_list is not None and permission.id in p_list and ( - current_user.id in u_list or f'{current_user.id}' in u_list): - flag = True - break - if flag: + obj = session.query(DsRules).filter( + and_(DsRules.permission_list.op('@>')(cast([permission.id], JSONB)), + or_(DsRules.user_list.op('@>')(cast([f'{current_user.id}'], JSONB)), + DsRules.user_list.op('@>')(cast([current_user.id], JSONB)))) + ).first() + if obj is not None: res.append(transRecord2DTO(session, permission)) + + # flag = False + # for r in contain_rules: + # p_list = json.loads(r.permission_list) + # u_list = json.loads(r.user_list) + # if p_list is not None and u_list is not None and permission.id in p_list and ( + # current_user.id in u_list or f'{current_user.id}' in u_list): + # flag = True + # break + # if flag: + # res.append(transRecord2DTO(session, permission)) where_str = transFilterTree(session, current_user, res, ds) if where_str: filters.append({"table": table.table_name, "filter": where_str}) @@ -54,22 +63,26 @@ def get_column_permission_fields(session: SessionDep, current_user: CurrentUser, if column_permissions is not None: for permission in column_permissions: # check permission and user in same rules - # obj = session.query(DsRules).filter( - # and_(DsRules.permission_list.op('@>')(cast([permission.id], JSONB)), - # or_(DsRules.user_list.op('@>')(cast([f'{current_user.id}'], JSONB)), - # DsRules.user_list.op('@>')(cast([current_user.id], JSONB)))) - # ).first() - flag = False - for r in contain_rules: - p_list = json.loads(r.permission_list) - u_list = json.loads(r.user_list) - if p_list is not None and u_list is not None and permission.id in p_list and ( - current_user.id in u_list or f'{current_user.id}' in u_list): - flag = True - break - if flag: + obj = session.query(DsRules).filter( + and_(DsRules.permission_list.op('@>')(cast([permission.id], JSONB)), + or_(DsRules.user_list.op('@>')(cast([f'{current_user.id}'], JSONB)), + DsRules.user_list.op('@>')(cast([current_user.id], JSONB)))) + ).first() + if obj is not None: permission_list = json.loads(permission.permissions) fields = filter_list(fields, permission_list) + + # flag = False + # for r in contain_rules: + # p_list = json.loads(r.permission_list) + # u_list = json.loads(r.user_list) + # if p_list is not None and u_list is not None and permission.id in p_list and ( + # current_user.id in u_list or f'{current_user.id}' in u_list): + # flag = True + # break + # if flag: + # permission_list = json.loads(permission.permissions) + # fields = filter_list(fields, permission_list) return fields diff --git a/backend/apps/datasource/crud/row_permission.py b/backend/apps/datasource/crud/row_permission.py index 86fbc9b76..b19063af5 100644 --- a/backend/apps/datasource/crud/row_permission.py +++ b/backend/apps/datasource/crud/row_permission.py @@ -9,6 +9,21 @@ from common.core.deps import SessionDep, CurrentUser +def _escape_sql_value(value: str) -> str: + """Escape a string value for safe inclusion in a SQL literal. + + Replaces single quotes with two single quotes (standard SQL escaping) + and strips characters that could break out of the string context. + """ + if value is None: + return value + # Standard SQL escaping: double any embedded single-quote characters + escaped = str(value).replace("'", "''") + # Remove backslashes that some drivers interpret as escape characters + escaped = escaped.replace("\\", "\\\\") + return escaped + + def transFilterTree(session: SessionDep, current_user: CurrentUser, tree_list: List[any], ds: CoreDatasource) -> str | None: if tree_list is None: @@ -24,10 +39,16 @@ def transFilterTree(session: SessionDep, current_user: CurrentUser, tree_list: L return " AND ".join(res) +_VALID_LOGIC_OPS = {"AND", "OR"} + + def transTreeToWhere(session: SessionDep, current_user: CurrentUser, tree: any, ds: CoreDatasource) -> str | None: if tree is None: return None logic = tree['logic'] + # Validate the logic operator to prevent injection via this field + if logic.upper() not in _VALID_LOGIC_OPS: + return None items = tree['items'] list: List[str] = [] @@ -56,11 +77,12 @@ def transTreeItem(session: SessionDep, current_user: CurrentUser, item: Dict, ds if item['filter_type'] == 'enum': if len(item['enum_value']) > 0: + escaped_values = [_escape_sql_value(v) for v in item['enum_value']] if ds['type'] == 'sqlServer' and ( field.field_type == 'nchar' or field.field_type == 'NCHAR' or field.field_type == 'nvarchar' or field.field_type == 'NVARCHAR'): - res = "(" + whereName + " IN (N'" + "',N'".join(item['enum_value']) + "'))" + res = "(" + whereName + " IN (N'" + "',N'".join(escaped_values) + "'))" else: - res = "(" + whereName + " IN ('" + "','".join(item['enum_value']) + "'))" + res = "(" + whereName + " IN ('" + "','".join(escaped_values) + "'))" else: # if system variable, do check and get value # new field: value_type(variable or normal), variable_id @@ -119,23 +141,26 @@ def transTreeItem(session: SessionDep, current_user: CurrentUser, item: Dict, ds elif item['term'] == 'not_empty': whereValue = "''" elif item['term'] == 'in' or item['term'] == 'not in': + escaped_values = [_escape_sql_value(v) for v in values] if ds.type == 'sqlServer' and ( field.field_type == 'nchar' or field.field_type == 'NCHAR' or field.field_type == 'nvarchar' or field.field_type == 'NVARCHAR'): - whereValue = "(N'" + "', N'".join(values) + "')" + whereValue = "(N'" + "', N'".join(escaped_values) + "')" else: - whereValue = "('" + "', '".join(values) + "')" + whereValue = "('" + "', '".join(escaped_values) + "')" elif item['term'] == 'like' or item['term'] == 'not like': + escaped_v = _escape_sql_value(values[0]) if ds.type == 'sqlServer' and ( field.field_type == 'nchar' or field.field_type == 'NCHAR' or field.field_type == 'nvarchar' or field.field_type == 'NVARCHAR'): - whereValue = f"N'%{values[0]}%'" + whereValue = f"N'%{escaped_v}%'" else: - whereValue = f"'%{values[0]}%'" + whereValue = f"'%{escaped_v}%'" else: + escaped_v = _escape_sql_value(values[0]) if ds.type == 'sqlServer' and ( field.field_type == 'nchar' or field.field_type == 'NCHAR' or field.field_type == 'nvarchar' or field.field_type == 'NVARCHAR'): - whereValue = f"N'{values[0]}'" + whereValue = f"N'{escaped_v}'" else: - whereValue = f"'{values[0]}'" + whereValue = f"'{escaped_v}'" res = whereName + whereTerm + whereValue else: @@ -153,23 +178,26 @@ def transTreeItem(session: SessionDep, current_user: CurrentUser, item: Dict, ds elif item['term'] == 'not_empty': whereValue = "''" elif item['term'] == 'in' or item['term'] == 'not in': + escaped_values = [_escape_sql_value(v) for v in value.split(",")] if ds.type == 'sqlServer' and ( field.field_type == 'nchar' or field.field_type == 'NCHAR' or field.field_type == 'nvarchar' or field.field_type == 'NVARCHAR'): - whereValue = "(N'" + "', N'".join(value.split(",")) + "')" + whereValue = "(N'" + "', N'".join(escaped_values) + "')" else: - whereValue = "('" + "', '".join(value.split(",")) + "')" + whereValue = "('" + "', '".join(escaped_values) + "')" elif item['term'] == 'like' or item['term'] == 'not like': + escaped_v = _escape_sql_value(value) if ds.type == 'sqlServer' and ( field.field_type == 'nchar' or field.field_type == 'NCHAR' or field.field_type == 'nvarchar' or field.field_type == 'NVARCHAR'): - whereValue = f"N'%{value}%'" + whereValue = f"N'%{escaped_v}%'" else: - whereValue = f"'%{value}%'" + whereValue = f"'%{escaped_v}%'" else: + escaped_v = _escape_sql_value(value) if ds.type == 'sqlServer' and ( field.field_type == 'nchar' or field.field_type == 'NCHAR' or field.field_type == 'nvarchar' or field.field_type == 'NVARCHAR'): - whereValue = f"N'{value}'" + whereValue = f"N'{escaped_v}'" else: - whereValue = f"'{value}'" + whereValue = f"'{escaped_v}'" res = whereName + whereTerm + whereValue return res @@ -226,6 +254,8 @@ def getSysVariableValue(sys_variable: SystemVariable, current_user: CurrentUser, if sys_variable.value[0] == 'email': v = current_user.email + escaped_v = _escape_sql_value(v) if v is not None else v + whereValue = '' if item['term'] == 'null': whereValue = '' @@ -238,20 +268,20 @@ def getSysVariableValue(sys_variable: SystemVariable, current_user: CurrentUser, elif item['term'] == 'in' or item['term'] == 'not in': if ds.type == 'sqlServer' and ( field.field_type == 'nchar' or field.field_type == 'NCHAR' or field.field_type == 'nvarchar' or field.field_type == 'NVARCHAR'): - whereValue = f"(N'{v}')" + whereValue = f"(N'{escaped_v}')" else: - whereValue = f"('{v}')" + whereValue = f"('{escaped_v}')" elif item['term'] == 'like' or item['term'] == 'not like': if ds.type == 'sqlServer' and ( field.field_type == 'nchar' or field.field_type == 'NCHAR' or field.field_type == 'nvarchar' or field.field_type == 'NVARCHAR'): - whereValue = f"N'%{v}%'" + whereValue = f"N'%{escaped_v}%'" else: - whereValue = f"'%{v}%'" + whereValue = f"'%{escaped_v}%'" else: if ds.type == 'sqlServer' and ( field.field_type == 'nchar' or field.field_type == 'NCHAR' or field.field_type == 'nvarchar' or field.field_type == 'NVARCHAR'): - whereValue = f"N'{v}'" + whereValue = f"N'{escaped_v}'" else: - whereValue = f"'{v}'" + whereValue = f"'{escaped_v}'" return whereValue diff --git a/backend/apps/datasource/embedding/ds_embedding.py b/backend/apps/datasource/embedding/ds_embedding.py index 19feada2d..f75ee7df5 100644 --- a/backend/apps/datasource/embedding/ds_embedding.py +++ b/backend/apps/datasource/embedding/ds_embedding.py @@ -11,11 +11,11 @@ from apps.system.crud.assistant import AssistantOutDs from common.core.config import settings from common.core.deps import CurrentAssistant -from common.core.deps import SessionDep, CurrentUser +from common.core.deps import SessionDep from common.utils.utils import SQLBotLogUtil -def get_ds_embedding(session: SessionDep, current_user: CurrentUser, _ds_list, out_ds: AssistantOutDs, +def get_ds_embedding(session: SessionDep, _ds_list, out_ds: AssistantOutDs, question: str, current_assistant: Optional[CurrentAssistant] = None): _list = [] @@ -23,7 +23,7 @@ def get_ds_embedding(session: SessionDep, current_user: CurrentUser, _ds_list, o if out_ds.ds_list: for _ds in out_ds.ds_list: ds = out_ds.get_ds(_ds.id) - table_schema = out_ds.get_db_schema(_ds.id, question, embedding=False) + table_schema, tables = out_ds.get_db_schema(_ds.id, question, embedding=False) ds_info = f"{ds.name}, {ds.description}\n" ds_schema = ds_info + table_schema _list.append({"id": ds.id, "ds_schema": ds_schema, "cosine_similarity": 0.0, "ds": ds}) diff --git a/backend/apps/datasource/embedding/table_embedding.py b/backend/apps/datasource/embedding/table_embedding.py index c467ecd8d..186debec4 100644 --- a/backend/apps/datasource/embedding/table_embedding.py +++ b/backend/apps/datasource/embedding/table_embedding.py @@ -45,7 +45,7 @@ def calc_table_embedding(tables: list[dict], question: str): for table in tables: _list.append( {"id": table.get('id'), "schema_table": table.get('schema_table'), "embedding": table.get('embedding'), - "cosine_similarity": 0.0}) + "cosine_similarity": 0.0, "table_name": table.get('table_name')}) if _list: try: @@ -70,7 +70,7 @@ def calc_table_embedding(tables: list[dict], question: str): end_time = time.time() SQLBotLogUtil.info(str(end_time - start_time)) SQLBotLogUtil.info(json.dumps([{"id": ele.get('id'), "schema_table": ele.get('schema_table'), - "cosine_similarity": ele.get('cosine_similarity')} + "cosine_similarity": ele.get('cosine_similarity'), "table_name": ele.get('table_name')} for ele in _list])) return _list except Exception: diff --git a/backend/apps/datasource/models/datasource.py b/backend/apps/datasource/models/datasource.py index 2f5f4a452..42f1dcd25 100644 --- a/backend/apps/datasource/models/datasource.py +++ b/backend/apps/datasource/models/datasource.py @@ -121,6 +121,8 @@ class DatasourceConf(BaseModel): mode: str = '' timeout: int = 30 lowVersion: bool = False + ssl: bool = False + poolSize: int = 5 def to_dict(self): return { @@ -136,12 +138,14 @@ def to_dict(self): "sheets": self.sheets, "mode": self.mode, "timeout": self.timeout, - "lowVersion": self.lowVersion + "lowVersion": self.lowVersion, + "ssl": self.ssl, + "poolSize": self.poolSize } class TableSchema: - def __init__(self, attr1, attr2): + def __init__(self, attr1, attr2=None): self.tableName = attr1 self.tableComment = attr2 if attr2 is None or isinstance(attr2, str) else attr2.decode("utf-8") @@ -190,3 +194,18 @@ class PreviewResponse(BaseModel): fields: List | None = [] data: List | None = [] sql: str | None = '' + + +class FieldInfo(BaseModel): + fieldName: object + fieldType: str + + +class SheetFields(BaseModel): + sheetName: str + fields: List[FieldInfo] + + +class ImportRequest(BaseModel): + filePath: str + sheets: List[SheetFields] diff --git a/backend/apps/datasource/utils/__init__.py b/backend/apps/datasource/utils/__init__.py index e69de29bb..5c7cf4850 100644 --- a/backend/apps/datasource/utils/__init__.py +++ b/backend/apps/datasource/utils/__init__.py @@ -0,0 +1,6 @@ +from .excel import ( + FIELD_TYPE_MAP, + USER_TYPE_TO_PANDAS, + infer_field_type, + parse_excel_preview, +) diff --git a/backend/apps/datasource/utils/excel.py b/backend/apps/datasource/utils/excel.py new file mode 100644 index 000000000..dd4044358 --- /dev/null +++ b/backend/apps/datasource/utils/excel.py @@ -0,0 +1,64 @@ +import pandas as pd + +FIELD_TYPE_MAP = { + 'int64': 'int', + 'int32': 'int', + 'float64': 'float', + 'float32': 'float', + 'datetime64': 'datetime', + 'datetime64[ns]': 'datetime', + 'object': 'string', + 'string': 'string', + 'bool': 'string', +} + +USER_TYPE_TO_PANDAS = { + 'int': 'int64', + 'float': 'float64', + 'datetime': 'datetime64[ns]', + 'string': 'string', +} + + +def infer_field_type(dtype) -> str: + dtype_str = str(dtype) + return FIELD_TYPE_MAP.get(dtype_str, 'string') + + +def parse_excel_preview(save_path: str, max_rows: int = 10): + sheets_data = [] + if save_path.endswith(".csv"): + df = pd.read_csv(save_path, engine='c') + fields = [] + for col in df.columns: + fields.append({ + "fieldName": col, + "fieldType": infer_field_type(df[col].dtype) + }) + preview_df = df.head(max_rows).replace({pd.NA: None, float('nan'): None}) + preview_data = preview_df.to_dict(orient='records') + sheets_data.append({ + "sheetName": "Sheet1", + "fields": fields, + "data": preview_data, + "rows": len(df) + }) + else: + sheet_names = pd.ExcelFile(save_path).sheet_names + for sheet_name in sheet_names: + df = pd.read_excel(save_path, sheet_name=sheet_name, engine='calamine') + fields = [] + for col in df.columns: + fields.append({ + "fieldName": col, + "fieldType": infer_field_type(df[col].dtype) + }) + preview_df = df.head(max_rows).replace({pd.NA: None, float('nan'): None}) + preview_data = preview_df.to_dict(orient='records') + sheets_data.append({ + "sheetName": sheet_name, + "fields": fields, + "data": preview_data, + "rows": len(df) + }) + return sheets_data diff --git a/backend/apps/db/constant.py b/backend/apps/db/constant.py index 1509fcf47..dcec81901 100644 --- a/backend/apps/db/constant.py +++ b/backend/apps/db/constant.py @@ -28,6 +28,7 @@ class DB(Enum): oracle = ('oracle', 'Oracle', '"', '"', ConnectType.sqlalchemy, 'Oracle', []) pg = ('pg', 'PostgreSQL', '"', '"', ConnectType.sqlalchemy, 'PostgreSQL', []) starrocks = ('starrocks', 'StarRocks', '`', '`', ConnectType.py_driver, 'StarRocks', []) + hive = ('hive', 'Apache Hive', '`', '`', ConnectType.py_driver, 'Hive', []) def __init__(self, type, db_name, prefix, suffix, connect_type: ConnectType, template_name: str, illegalParams: List[str]): diff --git a/backend/apps/db/db.py b/backend/apps/db/db.py index a2aab2a73..738dd0526 100644 --- a/backend/apps/db/db.py +++ b/backend/apps/db/db.py @@ -34,7 +34,9 @@ from common.core.config import settings import sqlglot from sqlglot import expressions as exp +from pyhive import hive from sqlalchemy.pool import NullPool +from dbutils.pooled_db import PooledDB try: if os.path.exists(settings.ORACLE_CLIENT_PATH): @@ -134,7 +136,7 @@ def get_origin_connect(type: str, conf: DatasourceConf): # use sqlalchemy -def get_engine(ds: CoreDatasource, timeout: int = 0) -> Engine: +def get_engine(ds: CoreDatasource, timeout: int = 0, use_pool: bool = False) -> Engine: conf = DatasourceConf(**json.loads(aes_decrypt(ds.configuration))) if not equals_ignore_case(ds.type, "excel") else get_engine_config() if conf.timeout is None: @@ -142,22 +144,32 @@ def get_engine(ds: CoreDatasource, timeout: int = 0) -> Engine: if timeout > 0: conf.timeout = timeout + db_config = { + 'pool_size': conf.poolSize if conf.poolSize else 5, + 'max_overflow': 20, + 'pool_recycle': 3600 + } if use_pool else { + 'poolclass': NullPool + } + if equals_ignore_case(ds.type, "pg"): if conf.dbSchema is not None and conf.dbSchema != "": engine = create_engine(get_uri(ds), connect_args={"options": f"-c search_path={urllib.parse.quote(conf.dbSchema)}", - "connect_timeout": conf.timeout}, poolclass=NullPool) + "connect_timeout": conf.timeout}, **db_config) else: - engine = create_engine(get_uri(ds), connect_args={"connect_timeout": conf.timeout}, poolclass=NullPool) + engine = create_engine(get_uri(ds), connect_args={"connect_timeout": conf.timeout}, **db_config) elif equals_ignore_case(ds.type, 'sqlServer'): engine = create_engine('mssql+pymssql://', creator=lambda: get_origin_connect(ds.type, conf), - poolclass=NullPool) + **db_config) elif equals_ignore_case(ds.type, 'oracle'): - engine = create_engine(get_uri(ds), poolclass=NullPool) - elif equals_ignore_case(ds.type, 'mysql'): # mysql - engine = create_engine(get_uri(ds), connect_args={"connect_timeout": conf.timeout, "ssl": {"require": True}}, poolclass=NullPool) + engine = create_engine(get_uri(ds), **db_config) + elif equals_ignore_case(ds.type, 'mysql'): # mysql + ssl_mode = {"require": True} if conf.ssl else None + engine = create_engine(get_uri(ds), connect_args={"connect_timeout": conf.timeout, "ssl": ssl_mode}, + **db_config) else: # ck - engine = create_engine(get_uri(ds), connect_args={"connect_timeout": conf.timeout}, poolclass=NullPool) + engine = create_engine(get_uri(ds), connect_args={"connect_timeout": conf.timeout}, **db_config) return engine @@ -167,10 +179,114 @@ def get_session(ds: CoreDatasource | AssistantOutDsSchema): out_conf = get_out_ds_conf(ds, 30) ds.configuration = out_conf - engine = get_engine(ds) - session_maker = sessionmaker(bind=engine) - session = session_maker() - return session + # engine = get_engine(ds) + # session_maker = sessionmaker(bind=engine) + + # get session from pool + session = pool_manager.get_pool(ds=ds, **{}) + return session() + + +def get_driver_connection(ds: CoreDatasource | AssistantOutDsSchema, db_config: dict = {}, use_pool: bool = False): + conf = DatasourceConf(**json.loads(aes_decrypt(ds.configuration))) + extra_config_dict = get_extra_config(conf) + + pool_config = { + 'maxconnections': conf.poolSize if conf.poolSize else 5, + 'mincached': 5, + 'maxcached': 10, + 'blocking': True, + 'maxusage': 100, + 'ping': 1, + } if use_pool else {} + conn_conf = extra_config_dict | db_config | pool_config + + conn = None + if equals_ignore_case(ds.type, 'dm'): + if not use_pool: + conn = dmPython.connect(user=conf.username, password=conf.password, server=conf.host, + port=conf.port, **conn_conf) + else: + conn = PooledDB( + creator=dmPython, + user=conf.username, + password=conf.password, + server=conf.host, + port=conf.port, + **conn_conf + ) + elif equals_ignore_case(ds.type, 'doris', 'starrocks'): + ssl_args = {'ssl': {'ssl_mode': 'REQUIRE'}} if conf.ssl else {} + args = conn_conf | ssl_args + if not use_pool: + conn = pymysql.connect(user=conf.username, passwd=conf.password, host=conf.host, + port=conf.port, db=conf.database, connect_timeout=conf.timeout, + read_timeout=conf.timeout, **conn_conf, + **args) + else: + conn = PooledDB( + creator=pymysql, + user=conf.username, + passwd=conf.password, + host=conf.host, + port=conf.port, + db=conf.database, + connect_timeout=conf.timeout, + read_timeout=conf.timeout, + **args + ) + elif equals_ignore_case(ds.type, 'redshift'): + if not use_pool: + conn = redshift_connector.connect(host=conf.host, port=conf.port, database=conf.database, user=conf.username, + password=conf.password, + timeout=conf.timeout, **conn_conf) + else: + conn = PooledDB( + creator=redshift_connector, + host=conf.host, + port=conf.port, + database=conf.database, + user=conf.username, + password=conf.password, + timeout=conf.timeout, + **conn_conf + ) + elif equals_ignore_case(ds.type, 'kingbase'): + if not use_pool: + conn = psycopg2.connect(host=conf.host, port=conf.port, database=conf.database, user=conf.username, + password=conf.password, + options=f"-c statement_timeout={conf.timeout * 1000}", + **conn_conf) + else: + conn = PooledDB( + creator=psycopg2, + host=conf.host, + port=conf.port, + database=conf.database, + user=conf.username, + password=conf.password, + options=f"-c statement_timeout={conf.timeout * 1000}", + **conn_conf + ) + elif equals_ignore_case(ds.type, 'hive'): + if not use_pool: + conn = hive.connect(host=conf.host, port=conf.port, username=conf.username, + database=conf.database, **conn_conf) + else: + conn = PooledDB( + creator=hive, + host=conf.host, + port=conf.port, + username=conf.username, + database=conf.database, **conn_conf + ) + + return conn + + +def get_driver_pool(ds: CoreDatasource | AssistantOutDsSchema, db_config: dict = {}): + pool = driver_pool_manager.get_pool(ds, db_config) + return pool def check_connection(trans: Optional[Trans], ds: CoreDatasource | AssistantOutDsSchema, is_raise: bool = False): @@ -206,9 +322,10 @@ def check_connection(trans: Optional[Trans], ds: CoreDatasource | AssistantOutDs raise HTTPException(status_code=500, detail=trans('i18n_ds_invalid') + f': {e.args}') return False elif equals_ignore_case(ds.type, 'doris', 'starrocks'): + ssl_args = {'ssl': {'ssl_mode': 'REQUIRE'}} if conf.ssl else {} with pymysql.connect(user=conf.username, passwd=conf.password, host=conf.host, port=conf.port, db=conf.database, connect_timeout=10, - read_timeout=10, **extra_config_dict) as conn, conn.cursor() as cursor: + read_timeout=10, **extra_config_dict, **ssl_args) as conn, conn.cursor() as cursor: try: cursor.execute('select 1') SQLBotLogUtil.info("success") @@ -246,6 +363,19 @@ def check_connection(trans: Optional[Trans], ds: CoreDatasource | AssistantOutDs if is_raise: raise HTTPException(status_code=500, detail=trans('i18n_ds_invalid') + f': {e.args}') return False + elif equals_ignore_case(ds.type, 'hive'): + with hive.connect(host=conf.host, port=conf.port, username=conf.username, + database=conf.database, **extra_config_dict) as conn, conn.cursor() as cursor: + try: + cursor.execute('select 1') + SQLBotLogUtil.info("success") + return True + except Exception as e: + SQLBotLogUtil.error(f"Datasource {ds.id} connection failed: {e}") + if is_raise: + raise HTTPException(status_code=500, detail=trans('i18n_ds_invalid') + f': {e.args}') + return False + elif equals_ignore_case(ds.type, 'es'): es_conn = get_es_connect(conf) if es_conn.ping(): @@ -288,6 +418,8 @@ def get_version(ds: CoreDatasource | AssistantOutDsSchema): # conf.timeout = 10 db = DB.get_db(ds.type) sql = get_version_sql(ds, conf) + if not sql: + return '' try: if db.connect_type == ConnectType.sqlalchemy: with get_session(ds) as session: @@ -297,19 +429,17 @@ def get_version(ds: CoreDatasource | AssistantOutDsSchema): else: extra_config_dict = get_extra_config(conf) if equals_ignore_case(ds.type, 'dm'): - with dmPython.connect(user=conf.username, password=conf.password, server=conf.host, - port=conf.port) as conn, conn.cursor() as cursor: + with get_driver_pool(ds).connection() as conn, conn.cursor() as cursor: cursor.execute(sql, timeout=10, **extra_config_dict) res = cursor.fetchall() version = res[0][0] elif equals_ignore_case(ds.type, 'doris', 'starrocks'): - with pymysql.connect(user=conf.username, passwd=conf.password, host=conf.host, - port=conf.port, db=conf.database, connect_timeout=10, - read_timeout=10, **extra_config_dict) as conn, conn.cursor() as cursor: + t_conf = {'connect_timeout': 10, 'read_timeout': 10} + with get_driver_pool(ds, t_conf).connection() as conn, conn.cursor() as cursor: cursor.execute(sql) res = cursor.fetchall() version = res[0][0] - elif equals_ignore_case(ds.type, 'redshift', 'es'): + elif equals_ignore_case(ds.type, 'redshift', 'es', 'hive'): version = '' except Exception as e: print(e) @@ -321,7 +451,7 @@ def get_schema(ds: CoreDatasource): conf = DatasourceConf(**json.loads(aes_decrypt(ds.configuration))) if ds.type != "excel" else get_engine_config() db = DB.get_db(ds.type) if db.connect_type == ConnectType.sqlalchemy: - with get_session(ds) as session: + with sessionmaker(bind=get_engine(ds))() as session: sql: str = '' if equals_ignore_case(ds.type, "sqlServer"): sql = """select name @@ -337,30 +467,24 @@ def get_schema(ds: CoreDatasource): res_list = [item[0] for item in res] return res_list else: - extra_config_dict = get_extra_config(conf) + # extra_config_dict = get_extra_config(conf) if equals_ignore_case(ds.type, 'dm'): - with dmPython.connect(user=conf.username, password=conf.password, server=conf.host, - port=conf.port, **extra_config_dict) as conn, conn.cursor() as cursor: + with get_driver_connection(ds) as conn, conn.cursor() as cursor: cursor.execute("""select OBJECT_NAME - from dba_objects + from all_objects where object_type = 'SCH'""", timeout=conf.timeout) res = cursor.fetchall() res_list = [item[0] for item in res] return res_list elif equals_ignore_case(ds.type, 'redshift'): - with redshift_connector.connect(host=conf.host, port=conf.port, database=conf.database, user=conf.username, - password=conf.password, - timeout=conf.timeout, **extra_config_dict) as conn, conn.cursor() as cursor: + with get_driver_connection(ds) as conn, conn.cursor() as cursor: cursor.execute("""SELECT nspname FROM pg_namespace""") res = cursor.fetchall() res_list = [item[0] for item in res] return res_list elif equals_ignore_case(ds.type, 'kingbase'): - with psycopg2.connect(host=conf.host, port=conf.port, database=conf.database, user=conf.username, - password=conf.password, - options=f"-c statement_timeout={conf.timeout * 1000}", - **extra_config_dict) as conn, conn.cursor() as cursor: + with get_driver_connection(ds) as conn, conn.cursor() as cursor: cursor.execute("""SELECT nspname FROM pg_namespace""") res = cursor.fetchall() @@ -374,41 +498,34 @@ def get_tables(ds: CoreDatasource): db = DB.get_db(ds.type) sql, sql_param = get_table_sql(ds, conf, get_version(ds)) if db.connect_type == ConnectType.sqlalchemy: - with get_session(ds) as session: + with sessionmaker(bind=get_engine(ds))() as session: with session.execute(text(sql), {"param": sql_param}) as result: res = result.fetchall() res_list = [TableSchema(*item) for item in res] return res_list else: - extra_config_dict = get_extra_config(conf) + # extra_config_dict = get_extra_config(conf) if equals_ignore_case(ds.type, 'dm'): - with dmPython.connect(user=conf.username, password=conf.password, server=conf.host, - port=conf.port, **extra_config_dict) as conn, conn.cursor() as cursor: + with get_driver_connection(ds) as conn, conn.cursor() as cursor: cursor.execute(sql, {"param": sql_param}, timeout=conf.timeout) res = cursor.fetchall() res_list = [TableSchema(*item) for item in res] return res_list elif equals_ignore_case(ds.type, 'doris', 'starrocks'): - with pymysql.connect(user=conf.username, passwd=conf.password, host=conf.host, - port=conf.port, db=conf.database, connect_timeout=conf.timeout, - read_timeout=conf.timeout, **extra_config_dict) as conn, conn.cursor() as cursor: + # ssl_args = {'ssl': {'ssl_mode': 'REQUIRE'}} if conf.ssl else {} + with get_driver_connection(ds) as conn, conn.cursor() as cursor: cursor.execute(sql, (sql_param,)) res = cursor.fetchall() res_list = [TableSchema(*item) for item in res] return res_list elif equals_ignore_case(ds.type, 'redshift'): - with redshift_connector.connect(host=conf.host, port=conf.port, database=conf.database, user=conf.username, - password=conf.password, - timeout=conf.timeout, **extra_config_dict) as conn, conn.cursor() as cursor: + with get_driver_connection(ds) as conn, conn.cursor() as cursor: cursor.execute(sql, (sql_param,)) res = cursor.fetchall() res_list = [TableSchema(*item) for item in res] return res_list elif equals_ignore_case(ds.type, 'kingbase'): - with psycopg2.connect(host=conf.host, port=conf.port, database=conf.database, user=conf.username, - password=conf.password, - options=f"-c statement_timeout={conf.timeout * 1000}", - **extra_config_dict) as conn, conn.cursor() as cursor: + with get_driver_connection(ds) as conn, conn.cursor() as cursor: cursor.execute(sql.format(sql_param)) res = cursor.fetchall() res_list = [TableSchema(*item) for item in res] @@ -417,6 +534,12 @@ def get_tables(ds: CoreDatasource): res = get_es_index(conf) res_list = [TableSchema(*item) for item in res] return res_list + elif equals_ignore_case(ds.type, 'hive'): + with get_driver_connection(ds) as conn, conn.cursor() as cursor: + cursor.execute(sql) + res = cursor.fetchall() + res_list = [TableSchema(*item) for item in res] + return res_list def get_fields(ds: CoreDatasource, table_name: str = None): @@ -431,35 +554,28 @@ def get_fields(ds: CoreDatasource, table_name: str = None): res_list = [ColumnSchema(*item) for item in res] return res_list else: - extra_config_dict = get_extra_config(conf) + # extra_config_dict = get_extra_config(conf) if equals_ignore_case(ds.type, 'dm'): - with dmPython.connect(user=conf.username, password=conf.password, server=conf.host, - port=conf.port, **extra_config_dict) as conn, conn.cursor() as cursor: + with get_driver_pool(ds).connection() as conn, conn.cursor() as cursor: cursor.execute(sql, {"param1": p1, "param2": p2}, timeout=conf.timeout) res = cursor.fetchall() res_list = [ColumnSchema(*item) for item in res] return res_list elif equals_ignore_case(ds.type, 'doris', 'starrocks'): - with pymysql.connect(user=conf.username, passwd=conf.password, host=conf.host, - port=conf.port, db=conf.database, connect_timeout=conf.timeout, - read_timeout=conf.timeout, **extra_config_dict) as conn, conn.cursor() as cursor: + # ssl_args = {'ssl': {'ssl_mode': 'REQUIRE'}} if conf.ssl else {} + with get_driver_pool(ds).connection() as conn, conn.cursor() as cursor: cursor.execute(sql, (p1, p2)) res = cursor.fetchall() res_list = [ColumnSchema(*item) for item in res] return res_list elif equals_ignore_case(ds.type, 'redshift'): - with redshift_connector.connect(host=conf.host, port=conf.port, database=conf.database, user=conf.username, - password=conf.password, - timeout=conf.timeout, **extra_config_dict) as conn, conn.cursor() as cursor: + with get_driver_pool(ds).connection() as conn, conn.cursor() as cursor: cursor.execute(sql, (p1, p2)) res = cursor.fetchall() res_list = [ColumnSchema(*item) for item in res] return res_list elif equals_ignore_case(ds.type, 'kingbase'): - with psycopg2.connect(host=conf.host, port=conf.port, database=conf.database, user=conf.username, - password=conf.password, - options=f"-c statement_timeout={conf.timeout * 1000}", - **extra_config_dict) as conn, conn.cursor() as cursor: + with get_driver_pool(ds).connection() as conn, conn.cursor() as cursor: cursor.execute(sql.format(p1, p2)) res = cursor.fetchall() res_list = [ColumnSchema(*item) for item in res] @@ -468,6 +584,12 @@ def get_fields(ds: CoreDatasource, table_name: str = None): res = get_es_fields(conf, table_name) res_list = [ColumnSchema(*item) for item in res] return res_list + elif equals_ignore_case(ds.type, 'hive'): + with get_driver_pool(ds).connection() as conn, conn.cursor() as cursor: + cursor.execute(sql) + res = cursor.fetchall() + res_list = [ColumnSchema(*item) for item in res] + return res_list def convert_value(value, datetime_format='space'): @@ -543,145 +665,463 @@ def convert_value(value, datetime_format='space'): return value +def is_numeric_type_code(type_code, dialect_name: str) -> bool: + """ + 根据数据库方言和 type_code 判断是否为数值类型 + + Args: + type_code: cursor.description[col_idx][1] 的值 + dialect_name: SQLAlchemy dialect name (mysql/postgresql/mssql/oracle/sqlite 等) + + Returns: + bool: 是否为数值类型 + """ + dialect_name = dialect_name.lower() + + # ---------- MySQL (pymysql) ---------- + if dialect_name == 'mysql': + if isinstance(type_code, int): + return type_code in { + 1, # TINYINT + 2, # SMALLINT + 3, # INT + 4, # FLOAT + 5, # DOUBLE + 8, # BIGINT + 9, # MEDIUMINT + 16, # BIT + 246, # DECIMAL/NEWDECIMAL + } + return False + + # ---------- PostgreSQL (psycopg2) ---------- + if dialect_name == 'postgresql': + if isinstance(type_code, int): + return type_code in { + 20, # int8 + 21, # int2 + 23, # int4 + 700, # float4 + 701, # float8 + 1700, # numeric + 16, # boolean + } + return False + + # ---------- Oracle (cx_Oracle / oracledb) ---------- + if dialect_name == 'oracle': + type_str = str(type_code).upper() + return any(kw in type_str for kw in ['NUMBER', 'FLOAT', 'INTEGER', 'BINARY_FLOAT', 'BINARY_DOUBLE']) + + if dialect_name == 'clickhouse': + if isinstance(type_code, str): + upper_type = type_code.upper() + # 数值类型关键字 + numeric_prefixes = ( + 'INT', # Int8/16/32/64 + 'UINT', # UInt8/16/32/64 ✅ 加上 UINT + 'FLOAT', # Float32, Float64 + 'DECIMAL', # Decimal, Decimal32/64/128 + 'BOOL', # Bool + 'BIT', # 极少数场景 + ) + return any(upper_type.startswith(p) for p in numeric_prefixes) + + # ---------- SQL Server (pyodbc / pymssql) ---------- + if dialect_name == 'mssql': + if isinstance(type_code, int): + # SQL Server (pyodbc / ODBC) 数值类型码 + return type_code in { + 2, # smallint + 3, # int + 4, # tinyint + 5, # float / real / decimal / numeric / money / smallmoney + 6, # bit + 7, # bigint + } + + # ---------- SQLite ---------- + if dialect_name == 'sqlite': + if isinstance(type_code, int): + return type_code in {1, 2, 3, 4, 5} # INTEGER, FLOAT, NUMERIC, etc. + return False + + # ---------- 未知数据库,保守返回 False ---------- + return False + + def exec_sql(ds: CoreDatasource | AssistantOutDsSchema, sql: str, origin_column=False): while sql.endswith(';'): sql = sql[:-1] # check execute sql only contain read operations - if not check_sql_read(sql, ds): - raise ValueError(f"SQL can only contain read operations") + is_safe, error_reason = check_sql_read(sql, ds) + if not is_safe: + raise ValueError(f"SQL can only contain read operations: {error_reason}") db = DB.get_db(ds.type) if db.connect_type == ConnectType.sqlalchemy: with get_session(ds) as session: + # 获取当前数据库方言 + dialect_name = session.bind.dialect.name + with session.execute(text(sql)) as result: try: columns = result.keys()._keys if origin_column else [item.lower() for item in result.keys()._keys] + + fields_info = [] + + for col_idx, col_name in enumerate(columns): + is_numeric = False + try: + type_code = result.cursor.description[col_idx][1] + is_numeric = is_numeric_type_code(type_code, dialect_name) + except (IndexError, AttributeError): + pass + + fields_info.append({ + "name": col_name, + "is_numeric": is_numeric + }) + res = result.fetchall() result_list = [ {str(columns[i]): convert_value(value) for i, value in enumerate(tuple_item)} for tuple_item in res ] - return {"fields": columns, "data": result_list, + return {"fields": columns, "data": result_list, "fields_info": fields_info, "sql": bytes.decode(base64.b64encode(bytes(sql, 'utf-8')))} except Exception as ex: raise ParseSQLResultError(str(ex)) else: conf = DatasourceConf(**json.loads(aes_decrypt(ds.configuration))) - extra_config_dict = get_extra_config(conf) + # extra_config_dict = get_extra_config(conf) if equals_ignore_case(ds.type, 'dm'): - with dmPython.connect(user=conf.username, password=conf.password, server=conf.host, - port=conf.port, **extra_config_dict) as conn, conn.cursor() as cursor: + with get_driver_pool(ds).connection() as conn, conn.cursor() as cursor: try: cursor.execute(sql, timeout=conf.timeout) res = cursor.fetchall() columns = [field[0] for field in cursor.description] if origin_column else [field[0].lower() for field in cursor.description] + fields_info = build_fields_info_from_cursor(cursor, origin_column, 'dm') result_list = [ {str(columns[i]): convert_value(value) for i, value in enumerate(tuple_item)} for tuple_item in res ] - return {"fields": columns, "data": result_list, + return {"fields": columns, "data": result_list, "fields_info": fields_info, "sql": bytes.decode(base64.b64encode(bytes(sql, 'utf-8')))} except Exception as ex: raise ParseSQLResultError(str(ex)) elif equals_ignore_case(ds.type, 'doris', 'starrocks'): - with pymysql.connect(user=conf.username, passwd=conf.password, host=conf.host, - port=conf.port, db=conf.database, connect_timeout=conf.timeout, - read_timeout=conf.timeout, **extra_config_dict) as conn, conn.cursor() as cursor: + with get_driver_pool(ds).connection() as conn, conn.cursor() as cursor: try: cursor.execute(sql) res = cursor.fetchall() columns = [field[0] for field in cursor.description] if origin_column else [field[0].lower() for field in cursor.description] + fields_info = build_fields_info_from_cursor(cursor, origin_column, 'mysql') result_list = [ {str(columns[i]): convert_value(value) for i, value in enumerate(tuple_item)} for tuple_item in res ] - return {"fields": columns, "data": result_list, + return {"fields": columns, "data": result_list, "fields_info": fields_info, "sql": bytes.decode(base64.b64encode(bytes(sql, 'utf-8')))} except Exception as ex: raise ParseSQLResultError(str(ex)) elif equals_ignore_case(ds.type, 'redshift'): - with redshift_connector.connect(host=conf.host, port=conf.port, database=conf.database, user=conf.username, - password=conf.password, - timeout=conf.timeout, **extra_config_dict) as conn, conn.cursor() as cursor: + with get_driver_pool(ds).connection() as conn, conn.cursor() as cursor: try: cursor.execute(sql) res = cursor.fetchall() columns = [field[0] for field in cursor.description] if origin_column else [field[0].lower() for field in cursor.description] + fields_info = build_fields_info_from_cursor(cursor, origin_column, 'postgresql') result_list = [ {str(columns[i]): convert_value(value) for i, value in enumerate(tuple_item)} for tuple_item in res ] - return {"fields": columns, "data": result_list, + return {"fields": columns, "data": result_list, "fields_info": fields_info, "sql": bytes.decode(base64.b64encode(bytes(sql, 'utf-8')))} except Exception as ex: raise ParseSQLResultError(str(ex)) elif equals_ignore_case(ds.type, 'kingbase'): - with psycopg2.connect(host=conf.host, port=conf.port, database=conf.database, user=conf.username, - password=conf.password, - options=f"-c statement_timeout={conf.timeout * 1000}", - **extra_config_dict) as conn, conn.cursor() as cursor: + with get_driver_pool(ds).connection() as conn, conn.cursor() as cursor: try: cursor.execute(sql) res = cursor.fetchall() columns = [field[0] for field in cursor.description] if origin_column else [field[0].lower() for field in cursor.description] + fields_info = build_fields_info_from_cursor(cursor, origin_column, 'postgresql') result_list = [ {str(columns[i]): convert_value(value) for i, value in enumerate(tuple_item)} for tuple_item in res ] - return {"fields": columns, "data": result_list, + return {"fields": columns, "data": result_list, "fields_info": fields_info, "sql": bytes.decode(base64.b64encode(bytes(sql, 'utf-8')))} except Exception as ex: raise ParseSQLResultError(str(ex)) elif equals_ignore_case(ds.type, 'es'): try: - res, columns = get_es_data_by_http(conf, sql) - columns = [field.get('name') for field in columns] if origin_column else [field.get('name').lower() for - field in - columns] + res, raw_columns = get_es_data_by_http(conf, sql) + columns = [field.get('name') for field in raw_columns] if origin_column else [field.get('name').lower() + for + field in + raw_columns] + fields_info = build_fields_info_from_es(raw_columns, origin_column) result_list = [ {str(columns[i]): convert_value(value) for i, value in enumerate(tuple_item)} for tuple_item in res ] - return {"fields": columns, "data": result_list, + return {"fields": columns, "data": result_list, "fields_info": fields_info, "sql": bytes.decode(base64.b64encode(bytes(sql, 'utf-8')))} except Exception as ex: raise Exception(str(ex)) + elif equals_ignore_case(ds.type, 'hive'): + with get_driver_pool(ds).connection() as conn, conn.cursor() as cursor: + try: + # Hive uses backticks for identifiers; normalize quoted identifiers as a compatibility fallback. + hive_sql = re.sub(r'"([A-Za-z_][A-Za-z0-9_]*)"', r'`\1`', sql) + cursor.execute(hive_sql) + res = cursor.fetchall() + columns = [field[0] for field in cursor.description] if origin_column else [field[0].lower() for + field in + cursor.description] + fields_info = build_fields_info_from_cursor(cursor, origin_column, 'hive') + result_list = [ + {str(columns[i]): convert_value(value) for i, value in enumerate(tuple_item)} for tuple_item in + res + ] + return {"fields": columns, "data": result_list, "fields_info": fields_info, + "sql": bytes.decode(base64.b64encode(bytes(hive_sql, 'utf-8')))} + except Exception as ex: + raise ParseSQLResultError(str(ex)) + + +def build_fields_info_from_cursor(cursor, origin_column, db_type='postgresql'): + """ + 根据数据库游标的 description 构建字段信息列表 + + Args: + cursor: 数据库游标对象 + origin_column: 是否保留原始列名大小写 + db_type: 数据库类型,支持 'mysql', 'postgresql', 'redshift', 'kingbase', 'dm', 'hive' + + Returns: + list: 包含字段名和是否数值类型的字典列表 + """ + fields_info = [] + + for col_info in cursor.description: + col_name = col_info[0] + + if db_type in ('mysql', 'mariadb', 'doris', 'starrocks'): + # MySQL/pymysql 类型码 + is_numeric = col_info[1] in ( + 1, # TINYINT + 2, # SMALLINT + 3, # INT + 4, # FLOAT + 5, # DOUBLE + 8, # BIGINT + 9, # MEDIUMINT + 16, # BIT + 246, # DECIMAL/NEWDECIMAL + ) + elif db_type in ('postgresql', 'redshift', 'kingbase'): + # PostgreSQL/psycopg2 类型 OID + is_numeric = col_info[1] in ( + 16, # bool + 20, # int8 + 21, # int2 + 23, # int4 + 700, # float4 + 701, # float8 + 790, # money + 1700, # numeric + ) + elif db_type == 'dm': + # 达梦数据库类型码 + # 获取类型类名 + type_name = col_info[1].__name__.upper() if hasattr(col_info[1], '__name__') else str(col_info[1]).upper() + + is_numeric = type_name in { + 'INT', 'INTEGER', + 'BIGINT', 'SMALLINT', 'TINYINT', + 'NUMBER', 'NUMERIC', 'DECIMAL', + 'FLOAT', 'DOUBLE', 'REAL', + 'BIT', 'BOOLEAN', + } + elif db_type == 'hive': + # Hive 类型对象转字符串判断 + type_str = str(col_info[1]).lower() + NUMERIC_PREFIXES = ('tinyint', 'smallint', 'int', 'bigint', 'float', 'double', 'decimal', 'numeric') + is_numeric = type_str == 'boolean' or any(type_str.startswith(p) for p in NUMERIC_PREFIXES) + else: + is_numeric = False + + fields_info.append({ + "name": col_name if origin_column else col_name.lower(), + "is_numeric": is_numeric + }) + + return fields_info + + +def build_fields_info_from_es(raw_columns, origin_column): + """ + 专门为 Elasticsearch 构建字段信息 + + Args: + raw_columns: ES 返回的列信息列表 + origin_column: 是否保留原始列名大小写 + + Returns: + list: 包含字段名和是否数值类型的字典列表 + """ + fields_info = [] + + for field in raw_columns: + field_name = field.get('name') if origin_column else field.get('name').lower() + field_type = field.get('type', '').lower() + + is_numeric = field_type in ( + 'long', 'integer', 'short', 'byte', + 'double', 'float', 'half_float', 'scaled_float', + 'unsigned_long', 'boolean' + ) + + fields_info.append({ + "name": field_name, + "is_numeric": is_numeric + }) + + return fields_info + + +def get_sqlglot_dialect(ds_type: str) -> str: + """根据数据源类型获取 sqlglot dialect""" + if equals_ignore_case(ds_type, 'mysql', 'doris', 'starrocks'): + return 'mysql' + elif equals_ignore_case(ds_type, 'sqlServer'): + return 'tsql' + elif equals_ignore_case(ds_type, 'hive'): + return 'hive' + return None + + +# 通用危险函数(适用于所有数据库) +COMMON_DANGEROUS_FUNCTIONS = {'version', 'current_user', 'user', 'database'} + +# 特定数据库的危险函数 +DS_SPECIFIC_DANGEROUS_FUNCTIONS = { + 'mysql': {'LOAD_FILE', 'INTO OUTFILE', 'INTO DUMPFILE'}, + 'doris': {'LOAD_FILE', 'INTO OUTFILE', 'INTO DUMPFILE'}, + 'starrocks': {'LOAD_FILE', 'INTO OUTFILE', 'INTO DUMPFILE'}, + 'postgresql': {'pg_read_file', 'pg_write_file', 'lo_import', 'lo_export'}, + 'sqlserver': {'EXEC', 'xp_cmdshell', 'sp_executesql'}, + 'oracle': {'UTL_FILE', 'DBMS_PIPE', 'DBMS_LOCK'}, + 'hive': {'ADD FILE', 'ADD JAR'}, +} + +# 危险模式正则表达式(用于检查特殊语法) +import re + +DANGEROUS_PATTERNS = [ + r'\bINTO\s+OUTFILE\b', + r'\bINTO\s+DUMPFILE\b', + r'\bEXEC\s*\(', + r'\bCOPY\s+.*\bTO\s+PROGRAM\b', +] -def check_sql_read(sql: str, ds: CoreDatasource | AssistantOutDsSchema): +def get_dangerous_functions(ds_type: str) -> set: + """获取危险函数(通用 + 特定数据源)""" + functions = COMMON_DANGEROUS_FUNCTIONS.copy() + ds_key = ds_type.lower() if ds_type else '' + if ds_key in DS_SPECIFIC_DANGEROUS_FUNCTIONS: + functions.update(DS_SPECIFIC_DANGEROUS_FUNCTIONS[ds_key]) + return functions + + +def check_dangerous_functions(statements: list, ds_type: str) -> bool: + """检查是否使用了危险函数,返回 True 表示安全""" + dangerous_functions = get_dangerous_functions(ds_type) + dangerous_functions_upper = {f.upper() for f in dangerous_functions} + + for stmt in statements: + if stmt: + for func in stmt.find_all(exp.Anonymous): + if func.name.upper() in dangerous_functions_upper: + return False + return True + + +def check_sql_read(sql: str, ds: CoreDatasource | AssistantOutDsSchema) -> tuple[bool, str]: + """ + 检查 SQL 是否为安全的只读查询 + 返回: (是否安全, 错误原因) + """ try: - dialect = None - if equals_ignore_case(ds.type, 'mysql', 'doris', 'starrocks'): - dialect = 'mysql' - elif equals_ignore_case(ds.type, 'sqlServer'): - dialect = 'tsql' + normalized_sql = sql.strip().lstrip("(").strip() + first_keyword = normalized_sql.split(None, 1)[0].upper() if normalized_sql else "" + + # 根据配置决定是否允许元数据查询 + if settings.SQLBOT_ALLOW_METADATA_QUERIES: + allowed_read_commands = {"SELECT", "WITH", "SHOW", "DESCRIBE", "DESC", "EXPLAIN"} + else: + allowed_read_commands = {"SELECT", "WITH"} + + denied_write_commands = { + "INSERT", "UPDATE", "DELETE", "CREATE", "DROP", "ALTER", + "TRUNCATE", "MERGE", "COPY", "REPLACE", "GRANT", "REVOKE", + "USE", "SET", "CALL" + } + if not first_keyword: + raise ValueError("Parse SQL Error") + if first_keyword in denied_write_commands: + return False, f"Write operation '{first_keyword}' is not allowed" + + # 1. 使用正则检查特殊模式 + for pattern in DANGEROUS_PATTERNS: + if re.search(pattern, sql, re.IGNORECASE): + return False, f"SQL contains dangerous pattern: {pattern}" + + dialect = get_sqlglot_dialect(ds.type) statements = sqlglot.parse(sql, dialect=dialect) if not statements: raise ValueError("Parse SQL Error") + # 2. 使用 sqlglot 检查函数调用 + dangerous_functions = get_dangerous_functions(ds.type) + dangerous_functions_upper = {f.upper() for f in dangerous_functions} + for stmt in statements: + if stmt: + for func in stmt.find_all(exp.Anonymous): + if func.name.upper() in dangerous_functions_upper: + return False, f"SQL contains dangerous function: {func.name}" + + # 3. 检查写操作类型 write_types = ( exp.Insert, exp.Update, exp.Delete, exp.Create, exp.Drop, exp.Alter, - exp.Merge, exp.Command, exp.Copy + exp.Merge, exp.Copy ) for stmt in statements: if stmt is None: continue if isinstance(stmt, write_types): - return False + return False, f"SQL contains write operation: {type(stmt).__name__}" - return True + if first_keyword not in allowed_read_commands: + return False, f"SQL command '{first_keyword}' is not allowed. Only SELECT and WITH are permitted" + + return True, "" except Exception as e: raise ValueError(f"Parse SQL Error: {e}") @@ -694,3 +1134,98 @@ def checkParams(extraParams: str, illegalParams: List[str]): k, v = kv.split('=') if k in illegalParams: raise HTTPException(status_code=500, detail=f'Illegal Parameter: {k}') + + +import threading +from collections import OrderedDict + + +class ConnectionPoolManager: + def __init__(self, max_pools=500): + """ + init + :param max_pools: max pool + """ + self.max_pools = max_pools + self._pools = OrderedDict() # 使用有序字典实现 LRU + self._lock = threading.Lock() # 保证多线程安全 + + def get_pool(self, ds: CoreDatasource | AssistantOutDsSchema, **db_config): + """ + get connection pool(lazy load + LRU update) + """ + with self._lock: + if ds.id: + # 1. 如果连接池已存在,将其移动到字典末尾(标记为最近使用) + if ds.id in self._pools: + self._pools.move_to_end(ds.id) + print(f"[LRU] return: {ds.id}") + return self._pools[ds.id] + + # 2. 如果连接池不存在,检查是否达到上限,若达到则淘汰最久未使用的(字典头部) + if len(self._pools) >= self.max_pools: + oldest_id, oldest_pool = self._pools.popitem(last=False) + oldest_pool.close() # 安全关闭被驱逐的连接池 + print(f"[LRU] remove oldest: {oldest_id}") + + # 3. 创建新连接池并放入字典末尾 + engine = get_engine(ds, use_pool=True) + new_pool = sessionmaker(bind=engine) + self._pools[ds.id] = new_pool + print(f"[LRU] create: {ds.id}") + return new_pool + + def close_all(self): + """stop""" + with self._lock: + for pool in self._pools.values(): + pool.close() + self._pools.clear() + + +pool_manager = ConnectionPoolManager(max_pools=500) + + +class DriverConnectionPoolManager: + def __init__(self, max_pools=500): + """ + init + :param max_pools: max pool + """ + self.max_pools = max_pools + self._pools = OrderedDict() # 使用有序字典实现 LRU + self._lock = threading.Lock() # 保证多线程安全 + + def get_pool(self, ds: CoreDatasource | AssistantOutDsSchema, db_config): + """ + get connection pool(lazy load + LRU update) + """ + with self._lock: + if ds.id: + # 1. 如果连接池已存在,将其移动到字典末尾(标记为最近使用) + if ds.id in self._pools: + self._pools.move_to_end(ds.id) + print(f"[LRU] return: {ds.id}") + return self._pools[ds.id] + + # 2. 如果连接池不存在,检查是否达到上限,若达到则淘汰最久未使用的(字典头部) + if len(self._pools) >= self.max_pools: + oldest_id, oldest_pool = self._pools.popitem(last=False) + oldest_pool.close() # 安全关闭被驱逐的连接池 + print(f"[LRU] remove oldest: {oldest_id}") + + # 3. 创建新连接池并放入字典末尾 + new_pool = get_driver_connection(ds, db_config, use_pool=True) + self._pools[ds.id] = new_pool + print(f"[LRU] create: {ds.id}") + return new_pool + + def close_all(self): + """stop""" + with self._lock: + for pool in self._pools.values(): + pool.close() + self._pools.clear() + + +driver_pool_manager = DriverConnectionPoolManager(max_pools=500) diff --git a/backend/apps/db/db_sql.py b/backend/apps/db/db_sql.py index 8f0710fa2..496075378 100644 --- a/backend/apps/db/db_sql.py +++ b/backend/apps/db/db_sql.py @@ -29,7 +29,7 @@ def get_version_sql(ds: CoreDatasource, conf: DatasourceConf): return """ SELECT * FROM v$version """ - elif equals_ignore_case(ds.type, "redshift"): + elif equals_ignore_case(ds.type, "redshift", "sqlite", "hive"): return '' @@ -162,6 +162,10 @@ def get_table_sql(ds: CoreDatasource, conf: DatasourceConf, db_version: str = '' """, conf.dbSchema elif equals_ignore_case(ds.type, "es"): return "", None + elif equals_ignore_case(ds.type, "hive"): + return """ + SHOW TABLES + """, None def get_field_sql(ds: CoreDatasource, conf: DatasourceConf, table_name: str = None): @@ -271,11 +275,10 @@ def get_field_sql(ds: CoreDatasource, conf: DatasourceConf, table_name: str = No c.DATA_TYPE AS "DATA_TYPE", COALESCE(com.COMMENTS, '') AS "COMMENTS" FROM - ALL_TAB_COLS c + ALL_TAB_COLUMNS c LEFT JOIN ALL_COL_COMMENTS com - ON c.OWNER = com.OWNER - AND c.TABLE_NAME = com.TABLE_NAME + ON c.TABLE_NAME = com.TABLE_NAME AND c.COLUMN_NAME = com.COLUMN_NAME WHERE c.OWNER = :param1 @@ -313,3 +316,6 @@ def get_field_sql(ds: CoreDatasource, conf: DatasourceConf, table_name: str = No return sql1 + sql2, conf.dbSchema, table_name elif equals_ignore_case(ds.type, "es"): return "", None, None + elif equals_ignore_case(ds.type, "hive"): + sql1 = f"DESCRIBE {table_name}" + return sql1, None, None diff --git a/backend/apps/db/es_engine.py b/backend/apps/db/es_engine.py index 27f46b766..9e147d63f 100644 --- a/backend/apps/db/es_engine.py +++ b/backend/apps/db/es_engine.py @@ -113,13 +113,13 @@ def get_es_data_by_http(conf: DatasourceConf, sql: str): # Security improvement: Enable SSL certificate verification # Note: In production, always set verify=True or provide path to CA bundle # If using self-signed certificates, provide the cert path: verify='/path/to/cert.pem' - verify_ssl = True if not url.startswith('https://localhost') else False + # verify_ssl = True if not url.startswith('https://localhost') else False response = requests.post( host, data=json.dumps({"query": sql}), headers=get_es_auth(conf), - verify=verify_ssl, + verify=False, timeout=30 # Add timeout to prevent hanging ) diff --git a/backend/apps/mcp/mcp.py b/backend/apps/mcp/mcp.py index e8cec6de0..2be45074c 100644 --- a/backend/apps/mcp/mcp.py +++ b/backend/apps/mcp/mcp.py @@ -13,17 +13,20 @@ from apps.chat.api.chat import create_chat, question_answer_inner from apps.chat.models.chat_model import ChatMcp, CreateChat, ChatStart, McpQuestion, McpAssistant, ChatQuestion, \ - ChatFinishStep + ChatFinishStep, McpDs, ChatToken from apps.datasource.crud.datasource import get_datasource_list -from apps.system.crud.user import authenticate +from apps.system.crud.user import authenticate, user_ws_options from apps.system.crud.user import get_db_user from apps.system.models.system_model import UserWsModel from apps.system.models.user import UserModel from apps.system.schemas.system_schema import BaseUserDTO, AssistantHeader from apps.system.schemas.system_schema import UserInfoDTO +from common.audit.models.log_model import OperationType, OperationModules +from common.audit.schemas.logger_decorator import LogConfig, system_log +from common.audit.schemas.request_context import RequestContext from common.core import security from common.core.config import settings -from common.core.deps import SessionDep +from common.core.deps import SessionDep, Trans from common.core.schemas import TokenPayload, XOAuth2PasswordBearer, Token from common.core.security import create_access_token @@ -34,19 +37,21 @@ router = APIRouter(tags=["mcp"], prefix="/mcp") -# @router.post("/access_token", operation_id="access_token") -# def local_login( -# session: SessionDep, -# form_data: Annotated[OAuth2PasswordRequestForm, Depends()] -# ) -> Token: -# user = authenticate(session=session, account=form_data.username, password=form_data.password) -# if not user: -# raise HTTPException(status_code=400, detail="Incorrect account or password") -# access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) -# user_dict = user.to_dict() -# return Token(access_token=create_access_token( -# user_dict, expires_delta=access_token_expires -# )) +@router.post("/access_token", operation_id="access_token") +async def access_token(session: SessionDep, chat: ChatToken): + user: BaseUserDTO = authenticate(session=session, account=chat.username, password=chat.password) + if not user: + raise HTTPException(status_code=400, detail="Incorrect account or password") + + if not user.oid or user.oid == 0: + raise HTTPException(status_code=400, detail="No associated workspace, Please contact the administrator") + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + user_dict = user.to_dict() + t = Token(access_token=create_access_token( + user_dict, expires_delta=access_token_expires + )) + # c = create_chat(session, user, CreateChat(origin=1), False) + return {"access_token": t.access_token} def get_user(session: SessionDep, token: str): @@ -81,9 +86,64 @@ def get_user(session: SessionDep, token: str): return session_user -@router.post("/mcp_ds_list", operation_id="mcp_datasource_list") -async def datasource_list(session: SessionDep, token: str): +@router.post("/mcp_start", operation_id="mcp_start") +@system_log(LogConfig( + operation_type=OperationType.CREATE, + module=OperationModules.CHAT, + result_id_expr="id", + save_on_success_only=True +)) +async def mcp_start(session: SessionDep, trans: Trans, chat: ChatStart): + res_token = None + user = None + if chat.token: + res_token = chat.token + user = get_user(session, chat.token) + else: + user = authenticate(session=session, account=chat.username, password=chat.password) + if not user: + raise HTTPException(status_code=400, detail="Incorrect account or password") + + if not user.oid or user.oid == 0: + raise HTTPException(status_code=400, detail="No associated workspace, Please contact the administrator") + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + user_dict = user.to_dict() + t = Token(access_token=create_access_token( + user_dict, expires_delta=access_token_expires + )) + res_token = t.access_token + + if chat.oid: + w_list = await user_ws_options(session, user.id, trans) + oid_list = [item.id for item in w_list] + if int(chat.oid) not in oid_list: + raise HTTPException(status_code=400, detail="The current user is not in the selected workspace") + + user.oid = int(chat.oid) + + request = RequestContext.get_request() + request.state.current_user = user + + c = create_chat(session, user, CreateChat(origin=1), False) + return {"access_token": res_token, "chat_id": c.id} + + +@router.post("/mcp_ws_list", operation_id="mcp_ws_list") +async def ws_list(session: SessionDep, trans: Trans, token: str): session_user = get_user(session, token) + return await user_ws_options(session, session_user.id, trans) + + +@router.post("/mcp_ds_list", operation_id="mcp_datasource_list") +async def datasource_list(session: SessionDep, trans: Trans, mcp_ds: McpDs): + session_user = get_user(session, mcp_ds.token) + if mcp_ds.oid: + w_list = await user_ws_options(session, session_user.id, trans) + oid_list = [item.id for item in w_list] + if int(mcp_ds.oid) not in oid_list: + raise HTTPException(status_code=400, detail="The current user is not in the selected workspace") + + session_user.oid = int(mcp_ds.oid) ds_list = get_datasource_list(session=session, user=session_user) result = [] for item in ds_list: @@ -103,26 +163,19 @@ async def datasource_list(session: SessionDep, token: str): # return session.query(AiModelDetail).all() -@router.post("/mcp_start", operation_id="mcp_start") -async def mcp_start(session: SessionDep, chat: ChatStart): - user: BaseUserDTO = authenticate(session=session, account=chat.username, password=chat.password) - if not user: - raise HTTPException(status_code=400, detail="Incorrect account or password") - - if not user.oid or user.oid == 0: - raise HTTPException(status_code=400, detail="No associated workspace, Please contact the administrator") - access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) - user_dict = user.to_dict() - t = Token(access_token=create_access_token( - user_dict, expires_delta=access_token_expires - )) - c = create_chat(session, user, CreateChat(origin=1), False) - return {"access_token": t.access_token, "chat_id": c.id} - - @router.post("/mcp_question", operation_id="mcp_question") -async def mcp_question(session: SessionDep, chat: McpQuestion): +async def mcp_question(session: SessionDep, trans: Trans, chat: McpQuestion): session_user = get_user(session, chat.token) + lang = chat.lang + if lang in ["zh-CN", "zh-TW", "en", "ko-KR"]: + session_user.language = lang + # if chat.oid: + # w_list = await user_ws_options(session, session_user.id, trans) + # oid_list = [item.id for item in w_list] + # if int(chat.oid) not in oid_list: + # raise HTTPException(status_code=400, detail="The current user is not in the selected workspace") + # + # session_user.oid = int(chat.oid) ds_id: Optional[int] = None if chat.datasource_id: if isinstance(chat.datasource_id, str): @@ -141,9 +194,10 @@ async def mcp_question(session: SessionDep, chat: McpQuestion): mcp_chat = ChatMcp(token=chat.token, chat_id=chat.chat_id, question=chat.question, datasource_id=ds_id) return await question_answer_inner(session=session, current_user=session_user, request_question=mcp_chat, - in_chat=False, stream=chat.stream) + in_chat=False, stream=chat.stream, return_img=chat.return_img) +# Cordys crm @router.post("/mcp_assistant", operation_id="mcp_assistant") async def mcp_assistant(session: SessionDep, chat: McpAssistant): session_user = BaseUserDTO(**{ diff --git a/backend/apps/swagger/locales/en.json b/backend/apps/swagger/locales/en.json index 9564bbac3..5d84258f4 100644 --- a/backend/apps/swagger/locales/en.json +++ b/backend/apps/swagger/locales/en.json @@ -22,6 +22,8 @@ "ds_preview_data": "Preview Data", "ds_upload_excel": "Upload Excel", "ds_excel": "File", + "ds_parse_excel": "Parse Excel and Preview Data", + "ds_import_to_db": "Import Data to Database", "ds_export_ds_schema": "Export Comment", "ds_upload_ds_schema": "Upload Comment", @@ -90,6 +92,13 @@ "system_model_create": "Save Model", "system_model_update": "Update Model", "system_model_del": "Delete Model", + "enable_custom_model": "Enable Custom Model", + "custom_model": "Custom Model", + "system_model_ws_mapping": "Query Model-Workspace Authorization Relationships", + "system_model_ws_mapping_update": "Update Model-Workspace Authorization Relationships", + "system_model_ws_mapping_add": "Add Model-Workspace Authorization Relationships", + "system_model_ws_mapping_delete": "Delete Model-Workspace Authorization Relationships", + "system_model_list_by_ws": "Get Model List by Workspace", "model_name": "Name", "model_type": "Type", "base_model": "Base Model", @@ -115,6 +124,8 @@ "assistant_type": "Assistant Type (0: Basic, 1: Advanced, 4: Page)", "assistant_configuration": "Configuration", "assistant_description": "Description", + "assistant_enableCustomModel": "Use specified model", + "assistant_customModel": "Large Language Model", "system_embedded_api": "Page Embedded API", "embedded_resetsecret_api": "Reset Secret", diff --git a/backend/apps/swagger/locales/zh.json b/backend/apps/swagger/locales/zh.json index 8b9b818a7..397fda5e3 100644 --- a/backend/apps/swagger/locales/zh.json +++ b/backend/apps/swagger/locales/zh.json @@ -22,6 +22,8 @@ "ds_preview_data": "预览数据", "ds_upload_excel": "上传Excel", "ds_excel": "文件", + "ds_parse_excel": "解析Excel并预览数据", + "ds_import_to_db": "导入数据到数据库", "ds_export_ds_schema": "导出备注信息", "ds_upload_ds_schema": "导入备注信息", @@ -90,6 +92,13 @@ "system_model_create": "保存模型", "system_model_update": "更新模型", "system_model_del": "删除模型", + "enable_custom_model": "启用自定义模型", + "custom_model": "自定义模型", + "system_model_ws_mapping": "查询模型授权工作空间的关联关系", + "system_model_ws_mapping_update": "更新模型授权工作空间的关联关系", + "system_model_ws_mapping_add": "新增模型授权工作空间的关联关系", + "system_model_ws_mapping_delete": "删除模型授权工作空间的关联关系", + "system_model_list_by_ws": "根据工作空间获取模型列表", "model_name": "名称", "model_type": "类型", "base_model": "基础模型", @@ -115,6 +124,8 @@ "assistant_type": "助手类型(0: 基础, 1: 高级, 4: 页面)", "assistant_configuration": "配置", "assistant_description": "描述", + "assistant_enableCustomModel": "使用指定大模型", + "assistant_customModel": "大语言模型", "system_embedded_api": "页面嵌入式api", "embedded_resetsecret_api": "重置 Secret", diff --git a/backend/apps/system/api/aimodel.py b/backend/apps/system/api/aimodel.py index ce37229e2..620adac7b 100644 --- a/backend/apps/system/api/aimodel.py +++ b/backend/apps/system/api/aimodel.py @@ -1,16 +1,17 @@ import json from typing import List, Union +from fastapi import APIRouter, Path, Query, Body from fastapi.responses import StreamingResponse +from sqlmodel import func, select, update, delete + from apps.ai_model.model_factory import LLMConfig, LLMFactory from apps.swagger.i18n import PLACEHOLDER_PREFIX +from apps.system.crud.aimodel_manage import get_ai_model_list_by_workspace +from apps.system.models.system_model import AiModelDetail, AiModelWorkspaceMapping, AiModelBrief from apps.system.schemas.ai_model_schema import AiModelConfigItem, AiModelCreator, AiModelEditor, AiModelGridItem -from fastapi import APIRouter, Path, Query -from sqlmodel import func, select, update - -from apps.system.models.system_model import AiModelDetail from apps.system.schemas.permission import SqlbotPermission, require_permissions -from common.core.deps import SessionDep, Trans +from common.core.deps import SessionDep, Trans, CurrentUser from common.utils.crypto import sqlbot_decrypt from common.utils.time import get_timestamp from common.utils.utils import SQLBotLogUtil, prepare_model_arg @@ -19,12 +20,14 @@ from common.audit.models.log_model import OperationType, OperationModules from common.audit.schemas.logger_decorator import LogConfig, system_log + @router.post("/status", include_in_schema=False) -@require_permissions(permission=SqlbotPermission(role=['admin'])) +@require_permissions(permission=SqlbotPermission(role=['admin'])) async def check_llm(info: AiModelCreator, trans: Trans): async def generate(): try: - additional_params = {item.key: prepare_model_arg(item.val) for item in info.config_list if item.key and item.val} + additional_params = {item.key: prepare_model_arg(item.val) for item in info.config_list if + item.key and item.val} config = LLMConfig( model_type="openai" if info.protocol == 1 else "vllm", model_name=info.base_model, @@ -39,14 +42,15 @@ async def generate(): yield json.dumps({"content": chunk}) + "\n" if chunk and isinstance(chunk, dict) and chunk.content: yield json.dumps({"content": chunk.content}) + "\n" - + except Exception as e: SQLBotLogUtil.error(f"Error checking LLM: {e}") error_msg = trans('i18n_llm.validate_error', msg=str(e)) yield json.dumps({"error": error_msg}) + "\n" - + return StreamingResponse(generate(), media_type="application/x-ndjson") + @router.get("/default", include_in_schema=False) async def check_default(session: SessionDep, trans: Trans): db_model = session.exec( @@ -54,8 +58,10 @@ async def check_default(session: SessionDep, trans: Trans): ).first() if not db_model: raise Exception(trans('i18n_llm.miss_default')) - -@router.put("/default/{id}", summary=f"{PLACEHOLDER_PREFIX}system_model_default", description=f"{PLACEHOLDER_PREFIX}system_model_default") + + +@router.put("/default/{id}", summary=f"{PLACEHOLDER_PREFIX}system_model_default", + description=f"{PLACEHOLDER_PREFIX}system_model_default") @require_permissions(permission=SqlbotPermission(role=['admin'])) @system_log(LogConfig(operation_type=OperationType.UPDATE, module=OperationModules.AI_MODEL, resource_id_expr="id")) async def set_default(session: SessionDep, id: int = Path(description="ID")): @@ -76,27 +82,46 @@ async def set_default(session: SessionDep, id: int = Path(description="ID")): session.rollback() raise e -@router.get("", response_model=list[AiModelGridItem], summary=f"{PLACEHOLDER_PREFIX}system_model_grid", description=f"{PLACEHOLDER_PREFIX}system_model_grid") -@require_permissions(permission=SqlbotPermission(role=['admin'])) + +@router.get("", response_model=list[AiModelGridItem], summary=f"{PLACEHOLDER_PREFIX}system_model_grid", + description=f"{PLACEHOLDER_PREFIX}system_model_grid") +@require_permissions(permission=SqlbotPermission(role=['admin'])) async def query( session: SessionDep, keyword: Union[str, None] = Query(default=None, max_length=255, description=f"{PLACEHOLDER_PREFIX}keyword") ): - statement = select(AiModelDetail.id, - AiModelDetail.name, - AiModelDetail.model_type, - AiModelDetail.base_model, - AiModelDetail.supplier, - AiModelDetail.protocol, - AiModelDetail.default_model) + # 子查询:统计每个 model 绑定的 workspace 数量 + count_sub = ( + select( + AiModelWorkspaceMapping.ai_model_id, + func.count().label("ws_mapping_count") + ) + .group_by(AiModelWorkspaceMapping.ai_model_id) + .subquery() + ) + statement = ( + select( + AiModelDetail.id, + AiModelDetail.name, + AiModelDetail.model_type, + AiModelDetail.base_model, + AiModelDetail.supplier, + AiModelDetail.protocol, + AiModelDetail.default_model, + func.coalesce(count_sub.c.ws_mapping_count, 0).label("ws_mapping_count"), + ) + .outerjoin(count_sub, AiModelDetail.id == count_sub.c.ai_model_id) + ) if keyword is not None: statement = statement.where(AiModelDetail.name.like(f"%{keyword}%")) statement = statement.order_by(AiModelDetail.default_model.desc(), AiModelDetail.name, AiModelDetail.create_time) items = session.exec(statement).all() return items -@router.get("/{id}", response_model=AiModelEditor, summary=f"{PLACEHOLDER_PREFIX}system_model_query", description=f"{PLACEHOLDER_PREFIX}system_model_query") -@require_permissions(permission=SqlbotPermission(role=['admin'])) + +@router.get("/{id}", response_model=AiModelEditor, summary=f"{PLACEHOLDER_PREFIX}system_model_query", + description=f"{PLACEHOLDER_PREFIX}system_model_query") +@require_permissions(permission=SqlbotPermission(role=['admin'])) async def get_model_by_id( session: SessionDep, id: int = Path(description="ID") @@ -124,7 +149,9 @@ async def get_model_by_id( data["config_list"] = config_list return AiModelEditor(**data) -@router.post("", summary=f"{PLACEHOLDER_PREFIX}system_model_create", description=f"{PLACEHOLDER_PREFIX}system_model_create") + +@router.post("", summary=f"{PLACEHOLDER_PREFIX}system_model_create", + description=f"{PLACEHOLDER_PREFIX}system_model_create") @require_permissions(permission=SqlbotPermission(role=['admin'])) @system_log(LogConfig(operation_type=OperationType.CREATE, module=OperationModules.AI_MODEL, result_id_expr="id")) async def add_model( @@ -143,9 +170,12 @@ async def add_model( session.commit() return detail -@router.put("", summary=f"{PLACEHOLDER_PREFIX}system_model_update", description=f"{PLACEHOLDER_PREFIX}system_model_update") + +@router.put("", summary=f"{PLACEHOLDER_PREFIX}system_model_update", + description=f"{PLACEHOLDER_PREFIX}system_model_update") @require_permissions(permission=SqlbotPermission(role=['admin'])) -@system_log(LogConfig(operation_type=OperationType.UPDATE, module=OperationModules.AI_MODEL, resource_id_expr="editor.id")) +@system_log( + LogConfig(operation_type=OperationType.UPDATE, module=OperationModules.AI_MODEL, resource_id_expr="editor.id")) async def update_model( session: SessionDep, editor: AiModelEditor @@ -155,12 +185,14 @@ async def update_model( data["config"] = json.dumps([item.model_dump(exclude_unset=True) for item in editor.config_list]) data.pop("config_list", None) db_model = session.get(AiModelDetail, id) - #update_data = AiModelDetail.model_validate(data) + # update_data = AiModelDetail.model_validate(data) db_model.sqlmodel_update(data) session.add(db_model) session.commit() -@router.delete("/{id}", summary=f"{PLACEHOLDER_PREFIX}system_model_del", description=f"{PLACEHOLDER_PREFIX}system_model_del") + +@router.delete("/{id}", summary=f"{PLACEHOLDER_PREFIX}system_model_del", + description=f"{PLACEHOLDER_PREFIX}system_model_del") @require_permissions(permission=SqlbotPermission(role=['admin'])) @system_log(LogConfig(operation_type=OperationType.DELETE, module=OperationModules.AI_MODEL, resource_id_expr="id")) async def delete_model( @@ -170,9 +202,161 @@ async def delete_model( ): item = session.get(AiModelDetail, id) if item.default_model: - raise Exception(trans('i18n_llm.delete_default_error', key = item.name)) + raise Exception(trans('i18n_llm.delete_default_error', key=item.name)) session.delete(item) session.commit() - - \ No newline at end of file + +@router.get("/{id}/ws_mapping", response_model=List[str], summary=f"{PLACEHOLDER_PREFIX}system_model_ws_mapping", + description=f"{PLACEHOLDER_PREFIX}system_model_ws_mapping") +@require_permissions(permission=SqlbotPermission(role=['admin'])) +async def get_model_ws_mapping_by_id( + session: SessionDep, + id: int = Path(description="ID") +): + db_model = session.get(AiModelDetail, id) + if not db_model: + raise ValueError(f"AiModelDetail with id {id} not found") + + # 根据 ai_model_id 查询关联的 workspace_id 列表 + stmt = ( + select(AiModelWorkspaceMapping.workspace_id) + .where(AiModelWorkspaceMapping.ai_model_id == id) + .distinct() + ) + ws_ids: List[int] = session.exec(stmt).all() + + return [str(ws_id) for ws_id in ws_ids] + + +@router.put("/{id}/ws_mapping", response_model=List[str], summary=f"{PLACEHOLDER_PREFIX}system_model_ws_mapping_update", + description=f"{PLACEHOLDER_PREFIX}system_model_ws_mapping_update") +@require_permissions(permission=SqlbotPermission(role=['admin'])) +async def update_model_ws_mapping_by_id( + session: SessionDep, + id: int = Path(description="ID"), + ws_ids: List[str] = Body(description="workspace id list"), +): + if ws_ids is None: + ws_ids = [] + # 提前去重 + ws_ids = list({int(ws_id) for ws_id in ws_ids}) + + db_model = session.get(AiModelDetail, id) + if not db_model: + raise ValueError(f"AiModelDetail with id {id} not found") + + # 根据 ai_model_id 更新关联的 workspace_id 列表 + # 1. 批量删除旧映射 + session.execute( + delete(AiModelWorkspaceMapping) + .where(AiModelWorkspaceMapping.ai_model_id == id) + ) + + # 2. 插入去重后的映射关系 + for ws_id in ws_ids: + session.add( + AiModelWorkspaceMapping(ai_model_id=id, workspace_id=ws_id) + ) + + session.commit() + + return [str(ws_id) for ws_id in ws_ids] + + +# 新增映射(在已有基础上追加) +@router.post("/{id}/ws_mapping", response_model=List[str], summary=f"{PLACEHOLDER_PREFIX}system_model_ws_mapping_add", + description=f"{PLACEHOLDER_PREFIX}system_model_ws_mapping_add") +@require_permissions(permission=SqlbotPermission(role=['admin'])) +async def add_model_ws_mapping_by_id( + session: SessionDep, + id: int = Path(description="ID"), + ws_ids: List[str] = Body(description="workspace id list"), +): + if ws_ids is None: + ws_ids = [] + ws_ids = list({int(ws_id) for ws_id in ws_ids}) + + db_model = session.get(AiModelDetail, id) + if not db_model: + raise ValueError(f"AiModelDetail with id {id} not found") + + # 查询已存在的映射,过滤掉重复的 + existing_stmt = ( + select(AiModelWorkspaceMapping.workspace_id) + .where( + AiModelWorkspaceMapping.ai_model_id == id, + AiModelWorkspaceMapping.workspace_id.in_(ws_ids), + ) + ) + existing_ws_ids = set(session.exec(existing_stmt).all()) + + # 只插入不存在的映射 + new_ws_ids = [ws_id for ws_id in ws_ids if ws_id not in existing_ws_ids] + for ws_id in new_ws_ids: + session.add( + AiModelWorkspaceMapping(ai_model_id=id, workspace_id=ws_id) + ) + + session.commit() + + # 返回完整的映射列表 + all_stmt = ( + select(AiModelWorkspaceMapping.workspace_id) + .where(AiModelWorkspaceMapping.ai_model_id == id) + .distinct() + ) + all_ws_ids: List[int] = session.exec(all_stmt).all() + + return [str(ws_id) for ws_id in all_ws_ids] + + +# 删除指定映射 +@router.delete("/{id}/ws_mapping", response_model=List[str], + summary=f"{PLACEHOLDER_PREFIX}system_model_ws_mapping_delete", + description=f"{PLACEHOLDER_PREFIX}system_model_ws_mapping_delete") +@require_permissions(permission=SqlbotPermission(role=['admin'])) +async def delete_model_ws_mapping_by_id( + session: SessionDep, + id: int = Path(description="ID"), + ws_ids: List[str] = Body(description="workspace id list"), +): + if ws_ids is None: + ws_ids = [] + ws_ids = list({int(ws_id) for ws_id in ws_ids}) + + db_model = session.get(AiModelDetail, id) + if not db_model: + raise ValueError(f"AiModelDetail with id {id} not found") + + # 只删除指定的映射 + if ws_ids: + session.execute( + delete(AiModelWorkspaceMapping) + .where( + AiModelWorkspaceMapping.ai_model_id == id, + AiModelWorkspaceMapping.workspace_id.in_(ws_ids), + ) + ) + + session.commit() + + # 返回剩余的映射列表 + stmt = ( + select(AiModelWorkspaceMapping.workspace_id) + .where(AiModelWorkspaceMapping.ai_model_id == id) + .distinct() + ) + remaining_ws_ids: List[int] = session.exec(stmt).all() + + return [str(ws_id) for ws_id in remaining_ws_ids] + + +@router.get("/list/by_ws", response_model=List[AiModelBrief], summary=f"{PLACEHOLDER_PREFIX}system_model_list_by_ws", + description=f"{PLACEHOLDER_PREFIX}system_model_list_by_ws") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) +async def get_model_by_ws( + session: SessionDep, + current_user: CurrentUser +): + return get_ai_model_list_by_workspace(session, current_user.oid, False) diff --git a/backend/apps/system/api/assistant.py b/backend/apps/system/api/assistant.py index 5db3f4032..138e534c2 100644 --- a/backend/apps/system/api/assistant.py +++ b/backend/apps/system/api/assistant.py @@ -1,6 +1,7 @@ import json import os -from datetime import timedelta +from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo from typing import List, Optional from fastapi import APIRouter, Form, HTTPException, Path, Query, Request, Response, UploadFile @@ -22,30 +23,83 @@ from common.core.security import create_access_token from common.core.sqlbot_cache import clear_cache from common.utils.utils import get_origin_from_referer, origin_match_domain - router = APIRouter(tags=["system_assistant"], prefix="/system/assistant") from common.audit.models.log_model import OperationType, OperationModules from common.audit.schemas.logger_decorator import LogConfig, system_log - +from sqlbot_xpack.core import decrypt_embedded_sign @router.get("/info/{id}", include_in_schema=False) -async def info(request: Request, response: Response, session: SessionDep, trans: Trans, id: int) -> AssistantModel: +async def info(request: Request, response: Response, session: SessionDep, trans: Trans, id: int, virtual: Optional[int] = Query(None)): if not id: raise Exception('miss assistant id') db_model = await get_assistant_info(session=session, assistant_id=id) if not db_model: raise RuntimeError(f"assistant application not exist") db_model = AssistantModel.model_validate(db_model) - - origin = request.headers.get("origin") or get_origin_from_referer(request) - if not origin: - raise RuntimeError(trans('i18n_embedded.invalid_origin', origin=origin or '')) - origin = origin.rstrip('/') - if not origin_match_domain(origin, db_model.domain): - raise RuntimeError(trans('i18n_embedded.invalid_origin', origin=origin or '')) - + + # 校验 SQLBOT-EMBEDDED-SIGN 请求头 + sign_header = request.headers.get("SQLBOT-EMBEDDED-SIGN") + if not sign_header: + raise RuntimeError(trans('i18n_embedded.invalid_origin', origin='')) + + sign_data = await decrypt_embedded_sign(sign_header) + + # 校验 assistant_id 与 id 参数一致 + if str(sign_data.get("assistant_id")) != str(id): + raise RuntimeError(trans('i18n_embedded.invalid_origin', origin='')) + + # 校验 target(来源域名)是否合法 + target = sign_data.get("target", "") + if not origin_match_domain(target, db_model.domain): + raise RuntimeError(trans('i18n_embedded.invalid_origin', origin=target or '')) + + # 校验 sign_time 是否在 10 秒内 + sign_time_str = sign_data.get("sign_time", "") + sign_time = datetime.fromisoformat(sign_time_str) + now_utc = datetime.now(timezone.utc) + sign_time_utc = sign_time.astimezone(timezone.utc) + if abs((now_utc - sign_time_utc).total_seconds()) > 10: + raise RuntimeError(trans('i18n_embedded.invalid_origin', origin=target or '')) + + # 校验是否为真实浏览器请求(非自动化工具) + if sign_data.get("webdriver", False): + raise RuntimeError(trans('i18n_embedded.invalid_origin', origin=target or '')) + + # 校验 User-Agent 一致性(签名中的 navigator.userAgent 与请求头一致) + sign_user_agent = sign_data.get("user_agent", "") + request_user_agent = request.headers.get("User-Agent", "") + if sign_user_agent != request_user_agent: + raise RuntimeError(trans('i18n_embedded.invalid_origin', origin=target or '')) + + # 校验 timezone 与 sign_time 偏移一致性(防时区伪造) + tz_name = sign_data.get("timezone", "") + tz = ZoneInfo(tz_name) + sign_time_naive = sign_time.replace(tzinfo=None) + if tz.utcoffset(sign_time_naive) != sign_time.utcoffset(): + raise RuntimeError(trans('i18n_embedded.invalid_origin', origin=target or '')) + + origin = target.rstrip('/') + response.headers["Access-Control-Allow-Origin"] = origin - return db_model + + + assistant_oid = 1 + if (db_model.type == 0): + configuration = db_model.configuration + config_obj = json.loads(configuration) if configuration else {} + assistant_oid = config_obj.get('oid', 1) + + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + assistantDict = { + "id": virtual, "account": 'sqlbot-inner-assistant', "oid": assistant_oid, "assistant_id": id + } + access_token = create_access_token( + assistantDict, expires_delta=access_token_expires + ) + + result = db_model.model_dump() + result["token"] = access_token + return result @router.get("/app/{appId}", include_in_schema=False) @@ -67,7 +121,7 @@ async def getApp(request: Request, response: Response, session: SessionDep, tran return db_model -@router.get("/validator", response_model=AssistantValidator, include_in_schema=False) +""" @router.get("/validator", response_model=AssistantValidator, include_in_schema=False) async def validator(session: SessionDep, id: int, virtual: Optional[int] = Query(None)): if not id: raise Exception('miss assistant id') @@ -89,7 +143,7 @@ async def validator(session: SessionDep, id: int, virtual: Optional[int] = Query access_token = create_access_token( assistantDict, expires_delta=access_token_expires ) - return AssistantValidator(True, True, True, access_token) + return AssistantValidator(True, True, True, access_token) """ @router.get('/picture/{file_id}', summary=f"{PLACEHOLDER_PREFIX}assistant_picture_api", description=f"{PLACEHOLDER_PREFIX}assistant_picture_api") @@ -111,6 +165,7 @@ def iterfile(): @router.patch('/ui', summary=f"{PLACEHOLDER_PREFIX}assistant_ui_api", description=f"{PLACEHOLDER_PREFIX}assistant_ui_api") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) @system_log(LogConfig(operation_type=OperationType.UPDATE, module=OperationModules.APPLICATION, result_id_expr="id")) async def ui(session: SessionDep, data: str = Form(), files: List[UploadFile] = []): json_data = json.loads(data) @@ -130,7 +185,7 @@ async def ui(session: SessionDep, data: str = Form(), files: List[UploadFile] = file.filename = file_name if flag_name == 'logo' or flag_name == 'float_icon': try: - SQLBotFileUtils.check_file(file=file, file_types=[".jpg", ".png", ".svg"], + SQLBotFileUtils.check_file(file=file, file_types=[".jpg", ".jpeg", ".png"], limit_file_size=(10 * 1024 * 1024)) except ValueError as e: error_msg = str(e) @@ -222,6 +277,8 @@ def get_db_type(type): async def query(session: SessionDep, current_user: CurrentUser): list_result = session.exec(select(AssistantModel).where(AssistantModel.oid == current_user.oid, AssistantModel.type != 4).order_by(AssistantModel.name, AssistantModel.create_time)).all() + for model in list_result: + model.enable_custom_model = model.enable_custom_model or False return list_result @@ -257,13 +314,13 @@ async def update(request: Request, session: SessionDep, editor: AssistantDTO): dynamic_upgrade_cors(request=request, session=session) -@router.get("/{id}", response_model=AssistantModel, summary=f"{PLACEHOLDER_PREFIX}assistant_query_api", description=f"{PLACEHOLDER_PREFIX}assistant_query_api") +""" @router.get("/{id}", response_model=AssistantModel, summary=f"{PLACEHOLDER_PREFIX}assistant_query_api", description=f"{PLACEHOLDER_PREFIX}assistant_query_api") async def get_one(session: SessionDep, id: int = Path(description="ID")): db_model = await get_assistant_info(session=session, assistant_id=id) if not db_model: raise ValueError(f"AssistantModel with id {id} not found") db_model = AssistantModel.model_validate(db_model) - return db_model + return db_model """ @router.delete("/{id}", summary=f"{PLACEHOLDER_PREFIX}assistant_del_api", description=f"{PLACEHOLDER_PREFIX}assistant_del_api") diff --git a/backend/apps/system/api/user.py b/backend/apps/system/api/user.py index 84e729623..20b379ef7 100644 --- a/backend/apps/system/api/user.py +++ b/backend/apps/system/api/user.py @@ -58,17 +58,34 @@ async def pager( status: Optional[int] = Query(None, description=f"{PLACEHOLDER_PREFIX}status"), origins: Optional[list[int]] = Query(None, description=f"{PLACEHOLDER_PREFIX}origin"), oidlist: Optional[list[int]] = Query(None, description=f"{PLACEHOLDER_PREFIX}oid"), + order_by: Optional[str] = Query(None, description="排序字段"), + desc: Optional[bool] = Query(False, description="是否降序"), ): pagination = PaginationParams(page=pageNum, size=pageSize) paginator = Paginator(session) - filters = {} - + + # 允许排序的字段白名单(防止 SQL 注入) + SORT_COLUMNS = { + 'account': UserModel.account, + 'create_time': UserModel.create_time, + 'name': UserModel.name, + 'email': UserModel.email, + 'status': UserModel.status, + } + sort_field = SORT_COLUMNS.get(order_by, UserModel.account) + sort_clause = sort_field.desc() if desc else sort_field.asc() + + # SELECT 列必须包含 ORDER BY 列(PostgreSQL DISTINCT 约束) + select_columns = [UserModel.id, UserModel.account] + if order_by and order_by != 'account': + select_columns.append(sort_field) + origin_stmt = ( - select(UserModel.id, UserModel.account) + select(*select_columns) .join(UserWsModel, UserModel.id == UserWsModel.uid, isouter=True) .where(UserModel.id != 1) .distinct() - .order_by(UserModel.account) + .order_by(sort_clause) ) if oidlist: @@ -89,8 +106,7 @@ async def pager( user_page = await paginator.get_paginated_response( stmt=origin_stmt, - pagination=pagination, - **filters) + pagination=pagination) uid_list = [item.get('id') for item in user_page.items] if not uid_list: return user_page @@ -98,7 +114,7 @@ async def pager( select(UserModel, UserWsModel.oid.label('ws_oid')) .join(UserWsModel, UserModel.id == UserWsModel.uid, isouter=True) .where(UserModel.id.in_(uid_list)) - .order_by(UserModel.account, UserModel.create_time) + .order_by(sort_clause) ) user_workspaces = session.exec(stmt).all() merged = defaultdict(list) @@ -273,7 +289,7 @@ async def batch_del(session: SessionDep, id_list: list[int]): @clear_cache(namespace=CacheNamespace.AUTH_INFO, cacheName=CacheName.USER_INFO, keyExpression="current_user.id") async def langChange(session: SessionDep, current_user: CurrentUser, trans: Trans, language: UserLanguage): lang = language.language - if lang not in ["zh-CN", "en", "ko-KR"]: + if lang not in ["zh-CN", "zh-TW", "en", "ko-KR"]: raise Exception(trans('i18n_user.language_not_support', key = lang)) db_user: UserModel = get_db_user(session=session, user_id=current_user.id) db_user.language = lang diff --git a/backend/apps/system/api/variable_api.py b/backend/apps/system/api/variable_api.py index 751462391..ca5c952f5 100644 --- a/backend/apps/system/api/variable_api.py +++ b/backend/apps/system/api/variable_api.py @@ -8,27 +8,32 @@ from apps.system.models.system_variable_model import SystemVariable from common.core.config import settings from common.core.deps import SessionDep, CurrentUser, Trans +from apps.system.schemas.permission import SqlbotPermission, require_permissions router = APIRouter(tags=["System_variable"], prefix="/sys_variable") path = settings.EXCEL_PATH @router.post("/save", response_model=None, summary=f"{PLACEHOLDER_PREFIX}variable_save") +@require_permissions(permission=SqlbotPermission(role=['admin'])) async def save_variable(session: SessionDep, user: CurrentUser, trans: Trans, variable: SystemVariable): return save(session, user, trans, variable) @router.post("/delete",response_model=None, summary=f"{PLACEHOLDER_PREFIX}variable_delete") +@require_permissions(permission=SqlbotPermission(role=['admin'])) async def delete_variable(session: SessionDep, ids: List[int]): return delete(session, ids) @router.post("/listAll",response_model=None, summary=f"{PLACEHOLDER_PREFIX}variable_list") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) async def list_all_data(session: SessionDep, trans: Trans, variable: SystemVariable = None): return list_all(session, trans, variable) @router.post("/listPage/{pageNum}/{pageSize}",response_model=None, summary=f"{PLACEHOLDER_PREFIX}variable_page") +@require_permissions(permission=SqlbotPermission(role=['admin'])) async def pager(session: SessionDep, trans: Trans, pageNum: int, pageSize: int, variable: SystemVariable = None): return await list_page(session, trans, pageNum, pageSize, variable) diff --git a/backend/apps/system/crud/aimodel_manage.py b/backend/apps/system/crud/aimodel_manage.py index 2d4f5b752..b0fabdbd1 100644 --- a/backend/apps/system/crud/aimodel_manage.py +++ b/backend/apps/system/crud/aimodel_manage.py @@ -1,10 +1,11 @@ +from sqlmodel import Session, select, or_ -from apps.system.models.system_model import AiModelDetail +from apps.system.models.system_model import AiModelDetail, AiModelBrief, AiModelWorkspaceMapping from common.core.db import engine -from sqlmodel import Session, select from common.utils.crypto import sqlbot_encrypt from common.utils.utils import SQLBotLogUtil + async def async_model_info(): with Session(engine) as session: model_list = session.exec(select(AiModelDetail)).all() @@ -27,7 +28,40 @@ async def async_model_info(): session.add(model) if any_model_change: session.commit() - SQLBotLogUtil.info("✅ 异步加密已有模型的密钥和地址完成") - - - \ No newline at end of file + SQLBotLogUtil.info("✅ 异步加密已有模型的密钥和地址完成") + + +def get_ai_model_list_by_workspace(session: Session, workspace_id: int, with_default: bool = True): + sub_stmt = ( + select(AiModelWorkspaceMapping.ai_model_id) + .where(AiModelWorkspaceMapping.workspace_id == workspace_id) + .distinct() + ) + + # 查询:关联的模型 + default_model 为 True 的模型,默认模型排第一 + base_condition = AiModelDetail.id.in_(sub_stmt) + if with_default: + where_condition = or_(base_condition, AiModelDetail.default_model == True) + else: + where_condition = base_condition + stmt = ( + select( + AiModelDetail.id, + AiModelDetail.name, + AiModelDetail.default_model, + AiModelDetail.supplier, + ) + .where(where_condition) + .order_by(AiModelDetail.default_model.desc()) + ) + rows = session.exec(stmt).all() + + return [ + AiModelBrief( + id=row[0], + name=row[1], + default_model=row[2], + supplier=row[3], + ) + for row in rows + ] diff --git a/backend/apps/system/crud/assistant.py b/backend/apps/system/crud/assistant.py index 130ef9f74..0c9d60083 100644 --- a/backend/apps/system/crud/assistant.py +++ b/backend/apps/system/crud/assistant.py @@ -22,6 +22,21 @@ from common.core.response_middleware import ResponseMiddleware +def _update_cors_middleware_instance(app: FastAPI, updated_origins: list[str]): + """遍历 middleware 栈,找到 CORSMiddleware 实例并更新其 allow_origins。 + + 仅修改 middleware.kwargs 不会影响已构建的中间件实例, + 需要直接更新实例的 allow_origins 属性。 + """ + stack = getattr(app, 'middleware_stack', None) + while stack is not None and hasattr(stack, 'app'): + if isinstance(stack, CORSMiddleware): + stack.allow_origins = updated_origins + return + stack = stack.app + + + @cache(namespace=CacheNamespace.EMBEDDED_INFO, cacheName=CacheName.ASSISTANT_INFO, keyExpression="assistant_id") async def get_assistant_info(*, session: Session, assistant_id: int) -> AssistantModel | None: db_model = session.get(AssistantModel, assistant_id) @@ -94,10 +109,11 @@ def init_dynamic_cors(app: FastAPI): response_middleware = middleware if cors_middleware and response_middleware: break - + updated_origins = list(set(settings.all_cors_origins + unique_domains)) if cors_middleware: cors_middleware.kwargs['allow_origins'] = updated_origins + _update_cors_middleware_instance(app, updated_origins) if response_middleware: for instance in ResponseMiddleware.instances: instance.update_allow_origins(updated_origins) @@ -181,12 +197,13 @@ def get_simple_ds_list(self): raise Exception("Datasource list is not found.") def get_db_schema(self, ds_id: int, question: str = '', embedding: bool = True, - table_list: list[str] = None) -> str: + table_list: list[str] = None) -> tuple[str, list]: ds = self.get_ds(ds_id) schema_str = "" db_name = ds.db_schema if ds.db_schema is not None and ds.db_schema != "" else ds.dataBase schema_str += f"【DB_ID】 {db_name}\n【Schema】\n" tables = [] + table_name_list = [] i = 0 for table in ds.tables: # 如果传入了 table_list,则只处理在列表中的表 @@ -213,6 +230,7 @@ def get_db_schema(self, ds_id: int, question: str = '', embedding: bool = True, schema_table += '\n]\n' t_obj = {"id": i, "schema_table": schema_table} tables.append(t_obj) + table_name_list.append(table.name) # do table embedding # if embedding and tables and settings.TABLE_EMBEDDING_ENABLED: @@ -222,7 +240,7 @@ def get_db_schema(self, ds_id: int, question: str = '', embedding: bool = True, for s in tables: schema_str += s.get('schema_table') - return schema_str + return schema_str, table_name_list def get_ds(self, ds_id: int, trans: Trans = None): if self.ds_list: @@ -236,11 +254,11 @@ def get_ds(self, ds_id: int, trans: Trans = None): def convert2schema(self, ds_dict: dict, config: dict[any]) -> AssistantOutDsSchema: id_marker: str = '' - attr_list = ['name', 'type', 'host', 'port', 'user', 'dataBase', 'schema', 'mode'] + attr_list = ['name', 'type', 'host', 'port', 'user', 'dataBase', 'schema', 'mode', 'lowVersion'] if config.get('encrypt', False): key = config.get('aes_key', None) iv = config.get('aes_iv', None) - aes_attrs = ['host', 'user', 'password', 'dataBase', 'db_schema', 'schema', 'mode'] + aes_attrs = ['host', 'user', 'password', 'dataBase', 'db_schema', 'schema', 'mode', 'lowVersion'] for attr in aes_attrs: if attr in ds_dict and ds_dict[attr]: try: @@ -248,7 +266,7 @@ def convert2schema(self, ds_dict: dict, config: dict[any]) -> AssistantOutDsSche except Exception as e: raise Exception( f"Failed to encrypt {attr} for datasource {ds_dict.get('name')}, error: {str(e)}") - + id = ds_dict.get('id', None) if not id: for attr in attr_list: @@ -277,7 +295,8 @@ def get_out_ds_conf(ds: AssistantOutDsSchema, timeout: int = 30) -> str: "extraJdbc": ds.extraParams or '', "dbSchema": ds.db_schema or '', "timeout": timeout or 30, - "mode": ds.mode or '' + "mode": ds.mode or '', + "lowVersion": ds.lowVersion or False, } conf["extraJdbc"] = '' return aes_encrypt(json.dumps(conf)) diff --git a/backend/apps/system/crud/assistant_manage.py b/backend/apps/system/crud/assistant_manage.py index adec96793..f9dce3675 100644 --- a/backend/apps/system/crud/assistant_manage.py +++ b/backend/apps/system/crud/assistant_manage.py @@ -12,6 +12,17 @@ from common.core.response_middleware import ResponseMiddleware +def _update_cors_middleware_instance(app: FastAPI, updated_origins: list[str]): + """遍历 middleware 栈,找到 CORSMiddleware 实例并更新其 allow_origins。""" + stack = getattr(app, 'middleware_stack', None) + while stack is not None and hasattr(stack, 'app'): + if isinstance(stack, CORSMiddleware): + stack.allow_origins = updated_origins + return + stack = stack.app + + + def dynamic_upgrade_cors(request: Request, session: Session): list_result = session.exec(select(AssistantModel).order_by(AssistantModel.create_time)).all() seen = set() @@ -37,6 +48,7 @@ def dynamic_upgrade_cors(request: Request, session: Session): updated_origins = list(set(settings.all_cors_origins + unique_domains)) if cors_middleware: cors_middleware.kwargs['allow_origins'] = updated_origins + _update_cors_middleware_instance(app, updated_origins) if response_middleware: for instance in ResponseMiddleware.instances: instance.update_allow_origins(updated_origins) diff --git a/backend/apps/system/crud/parameter_manage.py b/backend/apps/system/crud/parameter_manage.py index f80edd29c..65aaf164a 100644 --- a/backend/apps/system/crud/parameter_manage.py +++ b/backend/apps/system/crud/parameter_manage.py @@ -15,7 +15,7 @@ async def get_groups(session: SessionDep, flag: str) -> list[SysArgModel]: async def save_parameter_args(session: SessionDep, request: Request): allow_file_mapping = { - """ "test_logo": { "types": [".jpg", ".jpeg", ".png", ".svg"], "size": 5 * 1024 * 1024 } """ + """ "test_logo": { "types": [".jpg", ".jpeg", ".png"], "size": 5 * 1024 * 1024 } """ } form_data = await request.form() files = form_data.getlist("files") diff --git a/backend/apps/system/crud/user.py b/backend/apps/system/crud/user.py index 1f5ccb59e..5f9928b1b 100644 --- a/backend/apps/system/crud/user.py +++ b/backend/apps/system/crud/user.py @@ -1,21 +1,23 @@ - from typing import Optional + from sqlmodel import Session, func, select, delete as sqlmodel_delete + from apps.system.models.system_model import UserWsModel, WorkspaceModel from apps.system.schemas.auth import CacheName, CacheNamespace from apps.system.schemas.system_schema import EMAIL_REGEX, PWD_REGEX, BaseUserDTO, UserInfoDTO, UserWs from common.core.deps import SessionDep +from common.core.security import verify_md5pwd from common.core.sqlbot_cache import cache, clear_cache -from common.utils.locale import I18n +from common.utils.locale import I18n, I18nHelper from common.utils.utils import SQLBotLogUtil from ..models.user import UserModel, UserPlatformModel -from common.core.security import verify_md5pwd -import re + def get_db_user(*, session: Session, user_id: int) -> UserModel: db_user = session.get(UserModel, user_id) return db_user + def get_user_by_account(*, session: Session, account: str) -> BaseUserDTO | None: statement = select(UserModel).where(UserModel.account == account) db_user = session.exec(statement).first() @@ -23,19 +25,22 @@ def get_user_by_account(*, session: Session, account: str) -> BaseUserDTO | None return None return BaseUserDTO.model_validate(db_user.model_dump()) + @cache(namespace=CacheNamespace.AUTH_INFO, cacheName=CacheName.USER_INFO, keyExpression="user_id") async def get_user_info(*, session: Session, user_id: int) -> UserInfoDTO | None: - db_user: UserModel = get_db_user(session = session, user_id = user_id) + db_user: UserModel = get_db_user(session=session, user_id=user_id) if not db_user: return None userInfo = UserInfoDTO.model_validate(db_user.model_dump()) userInfo.isAdmin = userInfo.id == 1 and userInfo.account == 'admin' if userInfo.isAdmin: return userInfo - ws_model: UserWsModel = session.exec(select(UserWsModel).where(UserWsModel.uid == userInfo.id, UserWsModel.oid == userInfo.oid)).first() + ws_model: UserWsModel = session.exec( + select(UserWsModel).where(UserWsModel.uid == userInfo.id, UserWsModel.oid == userInfo.oid)).first() userInfo.weight = ws_model.weight if ws_model else -1 return userInfo + def authenticate(*, session: Session, account: str, password: str) -> BaseUserDTO | None: db_user = get_user_by_account(session=session, account=account) if not db_user: @@ -44,7 +49,8 @@ def authenticate(*, session: Session, account: str, password: str) -> BaseUserDT return None return db_user -async def user_ws_options(session: Session, uid: int, trans: Optional[I18n] = None) -> list[UserWs]: + +def user_ws_list(session: Session, uid: int, trans: Optional[I18n | I18nHelper] = None) -> list[UserWs]: if uid == 1: stmt = select(WorkspaceModel.id, WorkspaceModel.name).order_by(WorkspaceModel.name, WorkspaceModel.create_time) else: @@ -57,16 +63,20 @@ async def user_ws_options(session: Session, uid: int, trans: Optional[I18n] = No if not trans: return result.all() list_result = [ - UserWs(id = id, name = trans(name) if name.startswith('i18n') else name) + UserWs(id=id, name=trans(name) if name.startswith('i18n') else name) for id, name in result.all() ] if list_result: list_result.sort(key=lambda x: x.name) return list_result - + +async def user_ws_options(session: Session, uid: int, trans: Optional[I18n | I18nHelper] = None) -> list[UserWs]: + return user_ws_list(session, uid, trans) + + @clear_cache(namespace=CacheNamespace.AUTH_INFO, cacheName=CacheName.USER_INFO, keyExpression="id") async def single_delete(session: SessionDep, id: int): - user_model: UserModel = get_db_user(session = session, user_id = id) + user_model: UserModel = get_db_user(session=session, user_id=id) del_stmt = sqlmodel_delete(UserWsModel).where(UserWsModel.uid == id) session.exec(del_stmt) if user_model and user_model.origin and user_model.origin != 0: @@ -75,20 +85,23 @@ async def single_delete(session: SessionDep, id: int): session.delete(user_model) session.commit() -@clear_cache(namespace=CacheNamespace.AUTH_INFO, cacheName=CacheName.USER_INFO, keyExpression="id") + +@clear_cache(namespace=CacheNamespace.AUTH_INFO, cacheName=CacheName.USER_INFO, keyExpression="id") async def clean_user_cache(id: int): SQLBotLogUtil.info(f"User cache for [{id}] has been cleaned") def check_account_exists(*, session: Session, account: str) -> bool: return session.exec(select(func.count()).select_from(UserModel).where(UserModel.account == account)).one() > 0 + + def check_email_exists(*, session: Session, email: str) -> bool: return session.exec(select(func.count()).select_from(UserModel).where(UserModel.email == email)).one() > 0 - def check_email_format(email: str) -> bool: return bool(EMAIL_REGEX.fullmatch(email)) + def check_pwd_format(pwd: str) -> bool: return bool(PWD_REGEX.fullmatch(pwd)) diff --git a/backend/apps/system/middleware/auth.py b/backend/apps/system/middleware/auth.py index 423f9ea78..feeb246aa 100644 --- a/backend/apps/system/middleware/auth.py +++ b/backend/apps/system/middleware/auth.py @@ -1,15 +1,17 @@ import base64 import json +import re from typing import Optional from fastapi import Request from fastapi.responses import JSONResponse +from starlette.responses import Response import jwt from sqlmodel import Session from starlette.middleware.base import BaseHTTPMiddleware from apps.system.crud.apikey_manage import get_api_key from apps.system.models.system_model import ApiKeyModel, AssistantModel -from common.core.db import engine +from common.core.db import engine from apps.system.crud.assistant import get_assistant_info, get_assistant_user from apps.system.crud.user import get_user_by_account, get_user_info from apps.system.schemas.system_schema import AssistantHeader, UserInfoDTO @@ -17,7 +19,7 @@ from common.core.config import settings from common.core.schemas import TokenPayload from common.utils.locale import I18n -from common.utils.utils import SQLBotLogUtil, get_origin_from_referer +from common.utils.utils import SQLBotLogUtil, get_origin_from_referer, origin_match_domain from common.utils.whitelist import whiteUtils from fastapi.security.utils import get_authorization_scheme_param from common.core.deps import get_i18n @@ -31,6 +33,26 @@ def __init__(self, app): async def dispatch(self, request, call_next): if self.is_options(request) or whiteUtils.is_whitelisted(request.url.path): + # 动态处理 /system/assistant/info/{id} 的 CORS 预检 + if request.method == "OPTIONS": + origin = request.headers.get("origin", "") + if origin: + match = re.search(r'/system/assistant/info/(\d+)', request.url.path) + if match: + assistant_id = int(match.group(1)) + with Session(engine) as session: + db_model = session.get(AssistantModel, assistant_id) + if db_model and origin_match_domain(origin, db_model.domain): + return Response( + status_code=200, + headers={ + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "*", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Max-Age": "600", + }, + ) return await call_next(request) assistantTokenKey = settings.ASSISTANT_TOKEN_KEY assistantToken = request.headers.get(assistantTokenKey) diff --git a/backend/apps/system/models/system_model.py b/backend/apps/system/models/system_model.py index 472f18681..6b3602329 100644 --- a/backend/apps/system/models/system_model.py +++ b/backend/apps/system/models/system_model.py @@ -1,6 +1,8 @@ - from typing import Optional + +from pydantic import field_serializer from sqlmodel import BigInteger, Field, Text, SQLModel + from common.core.models import SnowflakeBase from common.core.schemas import BaseCreatorDTO @@ -9,74 +11,98 @@ class AiModelBase: supplier: int = Field(nullable=False) name: str = Field(max_length=255, nullable=False) model_type: int = Field(nullable=False) - base_model: str = Field(max_length = 255, nullable=False) + base_model: str = Field(max_length=255, nullable=False) default_model: bool = Field(default=False, nullable=False) + class AiModelDetail(SnowflakeBase, AiModelBase, table=True): - __tablename__ = "ai_model" - api_key: str | None = Field(nullable=True) - api_domain: str = Field(nullable=False) - protocol: int = Field(nullable=False, default = 1) - config: str = Field(sa_type = Text()) - status: int = Field(nullable=False, default = 1) - create_time: int = Field(default=0, sa_type=BigInteger()) - + __tablename__ = "ai_model" + api_key: str | None = Field(default=None, nullable=True, sa_type=Text()) + api_domain: str = Field(nullable=False, sa_type=Text()) + protocol: int = Field(nullable=False, default=1) + config: str = Field(sa_type=Text()) + status: int = Field(nullable=False, default=1) + create_time: int = Field(default=0, sa_type=BigInteger()) + + +class AiModelWorkspaceMapping(SnowflakeBase, table=True): + __tablename__ = "ai_model_workspace_mapping" + ai_model_id: int = Field(default=None, nullable=True, sa_type=BigInteger()) + workspace_id: int = Field(default=None, nullable=True, sa_type=BigInteger()) +class AiModelBrief(SQLModel): + id: int + name: str + default_model: bool + supplier: int + + @field_serializer("id") + def id_to_str(self, v: int) -> str: + return str(v) + class WorkspaceBase(SQLModel): name: str = Field(max_length=255, nullable=False) + class WorkspaceEditor(WorkspaceBase, BaseCreatorDTO): pass - + + class WorkspaceModel(SnowflakeBase, WorkspaceBase, table=True): __tablename__ = "sys_workspace" create_time: int = Field(default=0, sa_type=BigInteger()) - + + class UserWsBaseModel(SQLModel): uid: int = Field(nullable=False, sa_type=BigInteger()) oid: int = Field(nullable=False, sa_type=BigInteger()) - weight: int = Field(default=0, nullable=False) - + weight: int = Field(default=0, nullable=False) + + class UserWsModel(SnowflakeBase, UserWsBaseModel, table=True): __tablename__ = "sys_user_ws" - + class AssistantBaseModel(SQLModel): name: str = Field(max_length=255, nullable=False) type: int = Field(nullable=False, default=0) domain: str = Field(max_length=255, nullable=False) - description: Optional[str] = Field(sa_type = Text(), nullable=True) - configuration: Optional[str] = Field(sa_type = Text(), nullable=True) + description: Optional[str] = Field(sa_type=Text(), nullable=True) + configuration: Optional[str] = Field(sa_type=Text(), nullable=True) create_time: int = Field(default=0, sa_type=BigInteger()) - app_id: Optional[str] = Field(default=None, max_length=255, nullable=True) + app_id: Optional[str] = Field(default=None, max_length=255, nullable=True) app_secret: Optional[str] = Field(default=None, max_length=255, nullable=True) oid: Optional[int] = Field(nullable=True, sa_type=BigInteger(), default=1) + enable_custom_model: Optional[bool] = Field(default=False, nullable=True) + custom_model: Optional[str] = Field(default=None, max_length=255, nullable=True) + class AssistantModel(SnowflakeBase, AssistantBaseModel, table=True): __tablename__ = "sys_assistant" - + class AuthenticationBaseModel(SQLModel): name: str = Field(max_length=255, nullable=False) type: int = Field(nullable=False, default=0) - config: Optional[str] = Field(sa_type = Text(), nullable=True) - - + config: Optional[str] = Field(sa_type=Text(), nullable=True) + + class AuthenticationModel(SnowflakeBase, AuthenticationBaseModel, table=True): __tablename__ = "sys_authentication" create_time: Optional[int] = Field(default=0, sa_type=BigInteger()) enable: bool = Field(default=False, nullable=False) valid: bool = Field(default=False, nullable=False) - + class ApiKeyBaseModel(SQLModel): access_key: str = Field(max_length=255, nullable=False) secret_key: str = Field(max_length=255, nullable=False) create_time: int = Field(default=0, sa_type=BigInteger()) - uid: int = Field(default=0,nullable=False, sa_type=BigInteger()) + uid: int = Field(default=0, nullable=False, sa_type=BigInteger()) status: bool = Field(default=True, nullable=False) - + + class ApiKeyModel(SnowflakeBase, ApiKeyBaseModel, table=True): - __tablename__ = "sys_apikey" \ No newline at end of file + __tablename__ = "sys_apikey" diff --git a/backend/apps/system/schemas/ai_model_schema.py b/backend/apps/system/schemas/ai_model_schema.py index 019aa358b..7f523eb93 100644 --- a/backend/apps/system/schemas/ai_model_schema.py +++ b/backend/apps/system/schemas/ai_model_schema.py @@ -14,7 +14,7 @@ class AiModelItem(BaseModel): default_model: bool = Field(default=False, description=f"{PLACEHOLDER_PREFIX}default_model") class AiModelGridItem(AiModelItem, BaseCreatorDTO): - pass + ws_mapping_count: int = Field(default=0, description="workspace mapping count") class AiModelConfigItem(BaseModel): key: str = Field(description=f"{PLACEHOLDER_PREFIX}arg_name") diff --git a/backend/apps/system/schemas/system_schema.py b/backend/apps/system/schemas/system_schema.py index 5be166688..db2f255b4 100644 --- a/backend/apps/system/schemas/system_schema.py +++ b/backend/apps/system/schemas/system_schema.py @@ -32,7 +32,7 @@ class BaseUser(BaseModel): class BaseUserDTO(BaseUser, BaseCreatorDTO): - language: str = Field(pattern=r"^(zh-CN|en|ko-KR)$", default="zh-CN", description="用户语言") + language: str = Field(pattern=r"^(zh-CN|zh-TW|en|ko-KR)$", default="zh-CN", description="用户语言") password: str status: int = 1 origin: int = 0 @@ -47,8 +47,8 @@ def to_dict(self): @field_validator("language") def validate_language(cls, lang: str) -> str: - if not re.fullmatch(r"^(zh-CN|en|ko-KR)$", lang): - raise ValueError("Language must be 'zh-CN', 'en', or 'ko-KR'") + if not re.fullmatch(r"^(zh-CN|zh-TW|en|ko-KR)$", lang): + raise ValueError("Language must be 'zh-CN', 'zh-TW', 'en', or 'ko-KR'") return lang @@ -111,6 +111,8 @@ class AssistantBase(BaseModel): configuration: Optional[str] = Field(default=None, description=f"{PLACEHOLDER_PREFIX}assistant_configuration") description: Optional[str] = Field(default=None, description=f"{PLACEHOLDER_PREFIX}assistant_description") oid: Optional[int] = Field(default=1, description=f"{PLACEHOLDER_PREFIX}oid") + enable_custom_model: Optional[bool] = Field(default=False, description=f"{PLACEHOLDER_PREFIX}enable_custom_model") + custom_model: Optional[str] = Field(description=f"{PLACEHOLDER_PREFIX}custom_model") class AssistantDTO(AssistantBase, BaseCreatorDTO): @@ -195,6 +197,7 @@ class AssistantOutDsSchema(AssistantOutDsBase): db_schema: Optional[str] = None extraParams: Optional[str] = None mode: Optional[str] = None + lowVersion: Optional[bool] = False tables: Optional[list[AssistantTableSchema]] = None diff --git a/backend/apps/terminology/api/terminology.py b/backend/apps/terminology/api/terminology.py index b74cb19bd..979cdf41f 100644 --- a/backend/apps/terminology/api/terminology.py +++ b/backend/apps/terminology/api/terminology.py @@ -82,6 +82,7 @@ def inner(): "description": obj.description, "all_data_sources": 'N' if obj.specific_ds else 'Y', "datasource": ', '.join(obj.datasource_names) if obj.datasource_names and obj.specific_ds else '', + "advanced_application_name": obj.advanced_application_name or '', } data_list.append(_data) @@ -91,6 +92,7 @@ def inner(): fields.append(AxisObj(name=trans('i18n_terminology.term_description'), value='description')) fields.append(AxisObj(name=trans('i18n_terminology.effective_data_sources'), value='datasource')) fields.append(AxisObj(name=trans('i18n_terminology.all_data_sources'), value='all_data_sources')) + fields.append(AxisObj(name=trans('i18n_data_training.advanced_application'), value='advanced_application_name')) md_data, _fields_list = DataFormat.convert_object_array_for_pandas(fields, data_list) @@ -119,6 +121,7 @@ def inner(): "description": trans('i18n_terminology.term_description_template_example_1'), "all_data_sources": 'N', "datasource": trans('i18n_terminology.effective_data_sources_template_example_1'), + "advanced_application_name": '', } data_list.append(_data1) _data2 = { @@ -127,6 +130,7 @@ def inner(): "description": trans('i18n_terminology.term_description_template_example_2'), "all_data_sources": 'Y', "datasource": '', + "advanced_application_name": '', } data_list.append(_data2) @@ -136,6 +140,7 @@ def inner(): fields.append(AxisObj(name=trans('i18n_terminology.term_description_template'), value='description')) fields.append(AxisObj(name=trans('i18n_terminology.effective_data_sources_template'), value='datasource')) fields.append(AxisObj(name=trans('i18n_terminology.all_data_sources_template'), value='all_data_sources')) + fields.append(AxisObj(name=trans('i18n_data_training.advanced_application'), value='advanced_application_name')) md_data, _fields_list = DataFormat.convert_object_array_for_pandas(fields, data_list) @@ -180,7 +185,7 @@ async def upload_excel(trans: Trans, current_user: CurrentUser, file: UploadFile oid = current_user.oid - use_cols = [0, 1, 2, 3, 4] + use_cols = [0, 1, 2, 3, 4, 5] def inner(): @@ -217,9 +222,11 @@ def inner(): 3].strip() else [] all_datasource = True if pd.notna(row[4]) and row[4].lower().strip() in ['y', 'yes', 'true'] else False specific_ds = False if all_datasource else True + advanced_application_name = row[5].strip() if pd.notna(row[5]) and row[5].strip() else None import_data.append(TerminologyInfo(word=word, description=description, other_words=other_words, - datasource_names=datasource_names, specific_ds=specific_ds)) + datasource_names=datasource_names, specific_ds=specific_ds, + advanced_application_name=advanced_application_name)) res = batch_create_terminology(session, import_data, oid, trans) @@ -237,6 +244,7 @@ def inner(): "all_data_sources": 'N' if obj['data'].specific_ds else 'Y', "datasource": ', '.join(obj['data'].datasource_names) if obj['data'].datasource_names and obj[ 'data'].specific_ds else '', + "advanced_application_name": obj['data'].advanced_application_name or '', "errors": obj['errors'] } data_list.append(_data) @@ -247,6 +255,7 @@ def inner(): fields.append(AxisObj(name=trans('i18n_terminology.term_description'), value='description')) fields.append(AxisObj(name=trans('i18n_terminology.effective_data_sources'), value='datasource')) fields.append(AxisObj(name=trans('i18n_terminology.all_data_sources'), value='all_data_sources')) + fields.append(AxisObj(name=trans('i18n_data_training.advanced_application'), value='advanced_application_name')) fields.append(AxisObj(name=trans('i18n_data_training.error_info'), value='errors')) md_data, _fields_list = DataFormat.convert_object_array_for_pandas(fields, data_list) diff --git a/backend/apps/terminology/curd/terminology.py b/backend/apps/terminology/curd/terminology.py index 296fd1eba..7ca6128ab 100644 --- a/backend/apps/terminology/curd/terminology.py +++ b/backend/apps/terminology/curd/terminology.py @@ -10,8 +10,9 @@ from apps.ai_model.embedding import EmbeddingModelCache from apps.datasource.models.datasource import CoreDatasource +from apps.system.models.system_model import AssistantModel from apps.template.generate_chart.generator import get_base_terminology_template -from apps.terminology.models.terminology_model import Terminology, TerminologyInfo +from apps.terminology.models.terminology_model import Terminology, TerminologyInfo, TerminologyInfoResult from common.core.config import settings from common.core.deps import SessionDep, Trans from common.utils.embedding_threads import run_save_terminology_embeddings @@ -141,7 +142,9 @@ def build_terminology_query(session: SessionDep, oid: int, name: Optional[str] = Terminology.datasource_ids, children_subquery.c.other_words, func.jsonb_agg(CoreDatasource.name).filter(CoreDatasource.id.isnot(None)).label('datasource_names'), - Terminology.enabled + Terminology.enabled, + Terminology.advanced_application, + AssistantModel.name.label('advanced_application_name'), ) .outerjoin( children_subquery, @@ -155,6 +158,8 @@ def build_terminology_query(session: SessionDep, oid: int, name: Optional[str] = CoreDatasource, CoreDatasource.id == datasource_names_subquery.c.ds_id ) + .outerjoin(AssistantModel, + and_(Terminology.advanced_application == AssistantModel.id, AssistantModel.type == 1)) .where(and_(Terminology.id.in_(paginated_parent_ids), Terminology.oid == oid)) .group_by( Terminology.id, @@ -163,6 +168,8 @@ def build_terminology_query(session: SessionDep, oid: int, name: Optional[str] = Terminology.description, Terminology.specific_ds, Terminology.datasource_ids, + Terminology.advanced_application, + AssistantModel.name, children_subquery.c.other_words, Terminology.enabled ) @@ -172,7 +179,7 @@ def build_terminology_query(session: SessionDep, oid: int, name: Optional[str] = return stmt, total_count, total_pages, current_page, page_size -def execute_terminology_query(session: SessionDep, stmt) -> List[TerminologyInfo]: +def execute_terminology_query(session: SessionDep, stmt) -> List[TerminologyInfoResult]: """ 执行查询并返回术语信息列表 """ @@ -180,7 +187,7 @@ def execute_terminology_query(session: SessionDep, stmt) -> List[TerminologyInfo result = session.execute(stmt) for row in result: - _list.append(TerminologyInfo( + _list.append(TerminologyInfoResult( id=row.id, word=row.word, create_time=row.create_time, @@ -190,6 +197,8 @@ def execute_terminology_query(session: SessionDep, stmt) -> List[TerminologyInfo datasource_ids=row.datasource_ids if row.datasource_ids is not None else [], datasource_names=row.datasource_names if row.datasource_names is not None else [], enabled=row.enabled if row.enabled is not None else False, + advanced_application=str(row.advanced_application) if row.advanced_application else None, + advanced_application_name=row.advanced_application_name, )) return _list @@ -240,8 +249,8 @@ def create_terminology(session: SessionDep, info: TerminologyInfo, oid: int, tra datasource_ids = info.datasource_ids if info.datasource_ids is not None else [] if specific_ds: - if not datasource_ids: - raise Exception(trans("i18n_terminology.datasource_cannot_be_none")) + if not datasource_ids and info.advanced_application is None: + raise Exception(trans("i18n_data_training.datasource_assistant_cannot_be_none")) parent = Terminology( word=info.word.strip(), @@ -250,7 +259,8 @@ def create_terminology(session: SessionDep, info: TerminologyInfo, oid: int, tra oid=oid, specific_ds=specific_ds, enabled=info.enabled, - datasource_ids=datasource_ids + datasource_ids=datasource_ids, + advanced_application=info.advanced_application, ) words = [info.word.strip()] @@ -267,30 +277,63 @@ def create_terminology(session: SessionDep, info: TerminologyInfo, oid: int, tra # 基础查询条件(word 和 oid 必须满足) base_query = and_( Terminology.word.in_(words), - Terminology.oid == oid + Terminology.oid == oid, ) # 构建查询 query = session.query(Terminology).filter(base_query) - if specific_ds: - # 仅当 specific_ds=False 时,检查数据源条件 - query = query.where( - or_( - or_(Terminology.specific_ds == False, Terminology.specific_ds.is_(None)), - and_( - Terminology.specific_ds == True, - Terminology.datasource_ids.isnot(None), - text(""" - EXISTS ( - SELECT 1 FROM jsonb_array_elements(datasource_ids) AS elem - WHERE elem::text::int = ANY(:datasource_ids) - ) - """) + # 作用域重复检查 + scope_conditions = [] + + if not specific_ds: + # 全部数据源:与有数据源的记录冲突,也与同为全部数据源的记录冲突 + scope_conditions.append( + and_( + Terminology.specific_ds == True, + Terminology.datasource_ids.isnot(None), + func.jsonb_array_length(Terminology.datasource_ids) > 0, + ) + ) + scope_conditions.append( + Terminology.specific_ds == False, + ) + elif specific_ds and datasource_ids: + # 指定数据源:与全部数据源冲突,也与有数据源重叠的记录冲突 + scope_conditions.append( + Terminology.specific_ds == False, + ) + ds_overlap_conditions = [ + Terminology.datasource_ids.contains([ds_id]) + for ds_id in datasource_ids + ] + scope_conditions.append( + and_( + Terminology.specific_ds == True, + Terminology.datasource_ids.isnot(None), + or_(*ds_overlap_conditions) + ) + ) + else: + # 不选数据源:仅与同为不选数据源的记录冲突 + scope_conditions.append( + and_( + Terminology.specific_ds == True, + or_( + Terminology.datasource_ids.is_(None), + func.jsonb_array_length(Terminology.datasource_ids) == 0, ) ) ) - query = query.params(datasource_ids=datasource_ids) + + # 高级应用重复检查:advanced_application 相同时同名即重复 + if info.advanced_application is not None: + scope_conditions.append( + Terminology.advanced_application == info.advanced_application + ) + + if scope_conditions: + query = query.where(or_(*scope_conditions)) # 转换为 EXISTS 查询并获取结果 exists = session.query(query.exists()).scalar() @@ -396,6 +439,14 @@ def batch_create_terminology(session: SessionDep, info_list: List[TerminologyInf for ds in datasource_result: datasource_name_to_id[ds.name.strip()] = ds.id + # 预加载高级应用名称到ID的映射 + assistant_name_to_id = {} + assistant_stmt = select(AssistantModel.id, AssistantModel.name).where( + and_(AssistantModel.oid == oid, AssistantModel.type == 1)) + assistant_result = session.execute(assistant_stmt).all() + for a in assistant_result: + assistant_name_to_id[a.name.strip()] = a.id + # 验证和转换数据源名称 valid_records = [] for info in deduplicated_list: @@ -411,6 +462,7 @@ def batch_create_terminology(session: SessionDep, info_list: List[TerminologyInf # 根据specific_ds决定是否验证数据源 specific_ds = info.specific_ds if info.specific_ds is not None else False datasource_ids = [] + advanced_application = info.advanced_application if specific_ds: # specific_ds为True时需要验证数据源 @@ -424,12 +476,21 @@ def batch_create_terminology(session: SessionDep, info_list: List[TerminologyInf else: error_messages.append(trans("i18n_terminology.datasource_not_found").format(ds_name)) - # 检查specific_ds为True时必须有数据源 - if not datasource_ids: - error_messages.append(trans("i18n_terminology.datasource_cannot_be_none")) + # 解析高级应用名称到ID + if advanced_application is None and info.advanced_application_name: + if info.advanced_application_name.strip() in assistant_name_to_id: + advanced_application = assistant_name_to_id[info.advanced_application_name.strip()] + else: + error_messages.append(trans("i18n_data_training.advanced_application_not_found").format( + info.advanced_application_name)) + + # 检查specific_ds为True时datasource_ids和advanced_application不能同时为空 + if specific_ds and not datasource_ids and advanced_application is None: + error_messages.append(trans("i18n_data_training.datasource_assistant_cannot_be_none")) else: # specific_ds为False时忽略数据源名称 datasource_ids = [] + advanced_application = None # 检查主词和其他词是否重复(过滤空字符串) words = [info.word.strip().lower()] @@ -456,11 +517,13 @@ def batch_create_terminology(session: SessionDep, info_list: List[TerminologyInf processed_info = TerminologyInfo( word=info.word.strip(), description=info.description.strip(), - other_words=[w for w in info.other_words if w and w.strip()], # 过滤空字符串 + other_words=[w for w in info.other_words if w and w.strip()], datasource_ids=datasource_ids, datasource_names=info.datasource_names, specific_ds=specific_ds, - enabled=info.enabled if info.enabled is not None else True + enabled=info.enabled if info.enabled is not None else True, + advanced_application=advanced_application, + advanced_application_name=info.advanced_application_name, ) valid_records.append(processed_info) @@ -512,8 +575,8 @@ def update_terminology(session: SessionDep, info: TerminologyInfo, oid: int, tra datasource_ids = info.datasource_ids if info.datasource_ids is not None else [] if specific_ds: - if not datasource_ids: - raise Exception(trans("i18n_terminology.datasource_cannot_be_none")) + if not datasource_ids and info.advanced_application is None: + raise Exception(trans("i18n_data_training.datasource_assistant_cannot_be_none")) words = [info.word.strip()] for child in info.other_words: @@ -536,24 +599,57 @@ def update_terminology(session: SessionDep, info: TerminologyInfo, oid: int, tra # 构建查询 query = session.query(Terminology).filter(base_query) - if specific_ds: - # 仅当 specific_ds=False 时,检查数据源条件 - query = query.where( - or_( - or_(Terminology.specific_ds == False, Terminology.specific_ds.is_(None)), - and_( - Terminology.specific_ds == True, - Terminology.datasource_ids.isnot(None), - text(""" - EXISTS ( - SELECT 1 FROM jsonb_array_elements(datasource_ids) AS elem - WHERE elem::text::int = ANY(:datasource_ids) - ) - """) # 检查是否包含任意目标值 + # 作用域重复检查 + scope_conditions = [] + + if not specific_ds: + # 全部数据源:与有数据源的记录冲突,也与同为全部数据源的记录冲突 + scope_conditions.append( + and_( + Terminology.specific_ds == True, + Terminology.datasource_ids.isnot(None), + func.jsonb_array_length(Terminology.datasource_ids) > 0, + ) + ) + scope_conditions.append( + Terminology.specific_ds == False, + ) + elif specific_ds and datasource_ids: + # 指定数据源:与全部数据源冲突,也与有数据源重叠的记录冲突 + scope_conditions.append( + Terminology.specific_ds == False, + ) + ds_overlap_conditions = [ + Terminology.datasource_ids.contains([ds_id]) + for ds_id in datasource_ids + ] + scope_conditions.append( + and_( + Terminology.specific_ds == True, + Terminology.datasource_ids.isnot(None), + or_(*ds_overlap_conditions) + ) + ) + else: + # 不选数据源:仅与同为不选数据源的记录冲突 + scope_conditions.append( + and_( + Terminology.specific_ds == True, + or_( + Terminology.datasource_ids.is_(None), + func.jsonb_array_length(Terminology.datasource_ids) == 0, ) ) ) - query = query.params(datasource_ids=datasource_ids) + + # 高级应用重复检查:advanced_application 相同时同名即重复 + if info.advanced_application is not None: + scope_conditions.append( + Terminology.advanced_application == info.advanced_application + ) + + if scope_conditions: + query = query.where(or_(*scope_conditions)) # 转换为 EXISTS 查询并获取结果 exists = session.query(query.exists()).scalar() @@ -567,6 +663,7 @@ def update_terminology(session: SessionDep, info: TerminologyInfo, oid: int, tra specific_ds=specific_ds, datasource_ids=datasource_ids, enabled=info.enabled, + advanced_application=info.advanced_application, ) session.execute(stmt) session.commit() @@ -590,7 +687,8 @@ def update_terminology(session: SessionDep, info: TerminologyInfo, oid: int, tra oid=oid, enabled=info.enabled, specific_ds=specific_ds, - datasource_ids=datasource_ids + datasource_ids=datasource_ids, + advanced_application=info.advanced_application, ) ) @@ -711,8 +809,22 @@ def save_embeddings(session_maker, ids: List[int]): LIMIT {settings.EMBEDDING_TERMINOLOGY_TOP_COUNT} """ +embedding_sql_with_advanced_application = f""" +SELECT id, pid, word, similarity +FROM +(SELECT id, pid, word, oid, specific_ds, advanced_application, enabled, +( 1 - (embedding <=> :embedding_array) ) AS similarity +FROM terminology AS child +) TEMP +WHERE similarity > {settings.EMBEDDING_TERMINOLOGY_SIMILARITY} AND oid = :oid AND enabled = true +AND advanced_application = :advanced_application_id +ORDER BY similarity DESC +LIMIT {settings.EMBEDDING_TERMINOLOGY_TOP_COUNT} +""" -def select_terminology_by_word(session: SessionDep, word: str, oid: int, datasource: int = None): + +def select_terminology_by_word(session: SessionDep, word: str, oid: int, datasource: int = None, + advanced_application_id: Optional[int] = None): if word.strip() == "": return [] @@ -729,7 +841,9 @@ def select_terminology_by_word(session: SessionDep, word: str, oid: int, datasou ) ) - if datasource is not None: + if advanced_application_id is not None: + stmt = stmt.where(Terminology.advanced_application == advanced_application_id) + elif datasource is not None: stmt = stmt.where( or_( or_(Terminology.specific_ds == False, Terminology.specific_ds.is_(None)), @@ -760,7 +874,11 @@ def select_terminology_by_word(session: SessionDep, word: str, oid: int, datasou embedding = model.embed_query(word) - if datasource is not None: + if advanced_application_id is not None: + results = session.execute(text(embedding_sql_with_advanced_application), + {'embedding_array': str(embedding), 'oid': oid, + 'advanced_application_id': advanced_application_id}).fetchall() + elif datasource is not None: results = session.execute(text(embedding_sql_with_datasource), {'embedding_array': str(embedding), 'oid': oid, 'datasource': datasource}).fetchall() @@ -846,10 +964,11 @@ def to_xml_string(_dict: list[dict] | dict, root: str = 'terminologies') -> str: def get_terminology_template(session: SessionDep, question: str, oid: Optional[int] = 1, - datasource: Optional[int] = None) -> tuple[str, list[dict]]: + datasource: Optional[int] = None, + advanced_application_id: Optional[int] = None) -> tuple[str, list[dict]]: if not oid: oid = 1 - _results = select_terminology_by_word(session, question, oid, datasource) + _results = select_terminology_by_word(session, question, oid, datasource, advanced_application_id) if _results and len(_results) > 0: terminology = to_xml_string(_results) template = get_base_terminology_template().format(terminologies=terminology) diff --git a/backend/apps/terminology/models/terminology_model.py b/backend/apps/terminology/models/terminology_model.py index 850aadabe..a0887eb77 100644 --- a/backend/apps/terminology/models/terminology_model.py +++ b/backend/apps/terminology/models/terminology_model.py @@ -20,6 +20,7 @@ class Terminology(SQLModel, table=True): specific_ds: Optional[bool] = Field(sa_column=Column(Boolean, default=False)) datasource_ids: Optional[list[int]] = Field(sa_column=Column(JSONB), default=[]) enabled: Optional[bool] = Field(sa_column=Column(Boolean, default=True)) + advanced_application: Optional[int] = Field(sa_column=Column(BigInteger, nullable=True)) class TerminologyInfo(BaseModel): @@ -32,3 +33,18 @@ class TerminologyInfo(BaseModel): datasource_ids: Optional[list[int]] = [] datasource_names: Optional[list[str]] = [] enabled: Optional[bool] = True + advanced_application: Optional[int] = None + advanced_application_name: Optional[str] = None + +class TerminologyInfoResult(BaseModel): + id: Optional[int] = None + create_time: Optional[datetime] = None + word: Optional[str] = None + description: Optional[str] = None + other_words: Optional[List[str]] = [] + specific_ds: Optional[bool] = False + datasource_ids: Optional[list[int]] = [] + datasource_names: Optional[list[str]] = [] + enabled: Optional[bool] = True + advanced_application: Optional[str] = None + advanced_application_name: Optional[str] = None diff --git a/backend/common/audit/schemas/logger_decorator.py b/backend/common/audit/schemas/logger_decorator.py index bd7a777cb..957ff5659 100644 --- a/backend/common/audit/schemas/logger_decorator.py +++ b/backend/common/audit/schemas/logger_decorator.py @@ -127,12 +127,34 @@ def get_client_info(request: Request) -> Dict[str, Optional[str]]: user_agent = None if request: - # Obtain IP address - if request.client: + # Prefer real client IP headers, then fallback to socket peer IP. + header_candidates = [ + "x-forwarded-for", + "x-real-ip", + "cf-connecting-ip", + "x-client-ip", + "x-forwarded", + "forwarded", + ] + for key in header_candidates: + value = request.headers.get(key) + if value: + if key == "x-forwarded-for": + ip_address = value.split(",")[0].strip() + elif key == "forwarded": + # RFC 7239 format, e.g. for=203.0.113.43;proto=https;by=203.0.113.1 + parts = [part.strip() for part in value.split(";")] + for part in parts: + if part.lower().startswith("for="): + ip_address = part.split("=", 1)[1].strip().strip('"') + break + else: + ip_address = value.strip() + if ip_address: + break + + if not ip_address and request.client: ip_address = request.client.host - # Attempt to obtain the real IP from X-Forwarded-For - if "x-forwarded-for" in request.headers: - ip_address = request.headers["x-forwarded-for"].split(",")[0].strip() # Get User Agent user_agent = request.headers.get("user-agent") diff --git a/backend/common/core/config.py b/backend/common/core/config.py index 1b3cc24ef..4b9baeaec 100644 --- a/backend/common/core/config.py +++ b/backend/common/core/config.py @@ -75,6 +75,8 @@ def API_V1_STR(self) -> str: SCRIPT_DIR: str = f"{BASE_DIR}/scripts" UPLOAD_DIR: str = "/opt/sqlbot/data/file" SQLBOT_KEY_EXPIRED: int = 100 # License key expiration timestamp, 0 means no expiration + + SQLBOT_DOC_ENABLED: bool = True @computed_field # type: ignore[prop-decorator] @property @@ -111,6 +113,10 @@ def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn | str: GENERATE_SQL_QUERY_LIMIT_ENABLED: bool = True GENERATE_SQL_QUERY_HISTORY_ROUND_COUNT: int = 3 + # 安全配置:是否允许元数据查询(SHOW/DESCRIBE/DESC/EXPLAIN) + # 默认关闭,防止通过元数据查询泄露数据库结构 + SQLBOT_ALLOW_METADATA_QUERIES: bool = False + PARSE_REASONING_BLOCK_ENABLED: bool = True DEFAULT_REASONING_CONTENT_START: str = '' DEFAULT_REASONING_CONTENT_END: str = '' diff --git a/backend/common/core/response_middleware.py b/backend/common/core/response_middleware.py index 922035fa6..91aa61783 100644 --- a/backend/common/core/response_middleware.py +++ b/backend/common/core/response_middleware.py @@ -30,9 +30,9 @@ async def dispatch(self, request, call_next): direct_paths = [ f"{settings.API_V1_STR}/mcp/mcp_question", f"{settings.API_V1_STR}/mcp/mcp_assistant", - "/openapi.json", - "/docs", - "/redoc" + f"{settings.CONTEXT_PATH}/openapi.json", + f"{settings.CONTEXT_PATH}/docs", + f"{settings.CONTEXT_PATH}/redoc" ] route = request.scope.get("route") @@ -40,7 +40,7 @@ async def dispatch(self, request, call_next): path_pattern = '' if not route else route.path_format if (isinstance(response, JSONResponse) - or request.url.path == f"{settings.API_V1_STR}/openapi.json" + or request.url.path == f"{settings.CONTEXT_PATH}/openapi.json" or path_pattern in direct_paths): return response if response.status_code != 200: diff --git a/backend/common/utils/data_format.py b/backend/common/utils/data_format.py index 1991fb3e4..56a83f866 100644 --- a/backend/common/utils/data_format.py +++ b/backend/common/utils/data_format.py @@ -1,3 +1,5 @@ +from decimal import Decimal + import pandas as pd from apps.chat.models.chat_model import AxisObj @@ -17,6 +19,34 @@ def safe_convert_to_string(df): return df_copy + @staticmethod + def normalize_qualified_sql_column_keys(row: dict) -> dict: + """Add unqualified keys for names like ``alias.column`` (Hive/MySQL return shape). + + Chart bindings use the bare column name (``table_name``) while drivers may return + ``_u2.table_name``. Only adds ``short`` when absent to avoid clobbering real duplicates. + """ + if not row: + return row + out = dict(row) + for k, v in row.items(): + ks = str(k) + if "." not in ks: + continue + short = ks.rsplit(".", 1)[-1] + if short not in out: + out[short] = v + return out + + @staticmethod + def normalize_qualified_sql_column_keys_in_object_array(obj_array: list) -> list: + if not obj_array: + return obj_array + return [ + DataFormat.normalize_qualified_sql_column_keys(obj) if isinstance(obj, dict) else obj + for obj in obj_array + ] + @staticmethod def convert_large_numbers_in_object_array(obj_array, int_threshold=1e15, float_threshold=1e10): """处理对象数组,将每个对象中的大数字转换为字符串""" @@ -25,7 +55,7 @@ def format_float_without_scientific(value): """格式化浮点数,避免科学记数法""" if value == 0: return "0" - formatted = f"{value:.15f}" + formatted = str(Decimal(str(value))) if '.' in formatted: formatted = formatted.rstrip('0').rstrip('.') return formatted diff --git a/backend/locales/en.json b/backend/locales/en.json index b9638452b..43db335b8 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -51,6 +51,7 @@ "miss_default": "Default large language model has not been configured" }, "i18n_ds_invalid": "Datasource connection is invalid", + "i18n_ds_upload_error": "Upload Failed", "i18n_embedded": { "invalid_origin": "Domain name validation failed【{origin}】" }, diff --git a/backend/locales/ko-KR.json b/backend/locales/ko-KR.json index 77140ab66..bccbfdbb9 100644 --- a/backend/locales/ko-KR.json +++ b/backend/locales/ko-KR.json @@ -51,6 +51,7 @@ "miss_default": "기본 대형 언어 모델이 구성되지 않았습니다" }, "i18n_ds_invalid": "데이터 소스 연결이 무효합니다", + "i18n_ds_upload_error": "파일 업로드 실패", "i18n_embedded": { "invalid_origin": "도메인 이름 검증 실패 【{origin}】" }, diff --git a/backend/locales/zh-CN.json b/backend/locales/zh-CN.json index 54bf9334c..788e911e7 100644 --- a/backend/locales/zh-CN.json +++ b/backend/locales/zh-CN.json @@ -51,6 +51,7 @@ "miss_default": "尚未配置默认大语言模型" }, "i18n_ds_invalid": "数据源连接无效", + "i18n_ds_upload_error": "上传文件失败", "i18n_embedded": { "invalid_origin": "域名校验失败【{origin}】" }, diff --git a/backend/locales/zh-TW.json b/backend/locales/zh-TW.json new file mode 100644 index 000000000..6b37aca9f --- /dev/null +++ b/backend/locales/zh-TW.json @@ -0,0 +1,198 @@ +{ + "i18n_default_workspace": "默認工作空間", + "i18n_ds_name_exist": "名稱已存在", + "i18n_concat_admin": "請聯繫管理員!", + "i18n_exist": "{msg}已存在!", + "i18n_name": "名稱", + "i18n_not_exist": "{msg}不存在!", + "i18n_error": "{key}錯誤!", + "i18n_miss_args": "缺失{key}參數!", + "i18n_format_invalid": "{key}格式不正確!", + "i18n_login": { + "account_pwd_error": "帳號或密碼錯誤!", + "no_associated_ws": "沒有關聯的工作空間,{msg}", + "user_disable": "帳號已禁用,{msg}", + "origin_error": "登入方式錯誤", + "prohibit_auto_create": "禁止自動建立用戶,請先同步用戶", + "no_platform_user": "帳號不存在,請先同步用戶" + }, + "i18n_user": { + "account": "帳號", + "email": "郵箱", + "password": "密碼", + "language_not_support": "系統不支援[{key}]語言!", + "ws_miss": "當前用戶不在工作空間[{ws}]中!", + "name": "姓名", + "status": "用戶狀態", + "origin": "用戶來源", + "workspace": "工作空間", + "role": "角色", + "platform_user_id": "外部用戶唯一標識", + "administrator": "管理員", + "ordinary_member": "普通成員", + "status_enabled": "已啟用", + "status_disabled": "已禁用", + "local_creation": "本地建立" + }, + "i18n_ws": { + "title": "工作空間" + }, + "i18n_permission": { + "only_admin": "僅支援管理員調用!", + "no_permission": "無權調用{url}{msg}", + "authenticate_invalid": "認證無效【{msg}】", + "token_expired": "Token 已過期", + "only_ws_admin": "僅支援工作空間管理員調用!", + "permission_resource_limit": "沒有操作權限或資源不存在!" + }, + "i18n_llm": { + "validate_error": "校驗失敗[{msg}]", + "delete_default_error": "無法刪除默認模型[{key}]!", + "miss_default": "尚未配置默認大語言模型" + }, + "i18n_ds_invalid": "數據源連接無效", + "i18n_ds_upload_error": "上傳文件失敗", + "i18n_embedded": { + "invalid_origin": "網域校驗失敗【{origin}】" + }, + "i18n_chat": { + "record_id_in_mcp": "響應ID: " + }, + "i18n_terminology": { + "terminology_not_exists": "該術語不存在", + "datasource_cannot_be_none": "數據源不能為空", + "cannot_be_repeated": "術語名稱,同義詞不能重複", + "exists_in_db": "術語名稱,同義詞已存在", + "term_name": "術語名稱", + "term_description": "術語描述", + "effective_data_sources": "生效數據源", + "all_data_sources": "所有數據源", + "synonyms": "同義詞", + "term_name_template": "術語名稱(必填)", + "term_description_template": "術語描述(必填)", + "effective_data_sources_template": "生效數據源(支援多個,用\",\"分割)", + "all_data_sources_template": "所有數據源(Y:應用到全部數據源,N:應用到指定數據源)", + "synonyms_template": "同義詞(支援多個,用\",\"分割)", + "term_name_template_example_1": "術語1", + "term_description_template_example_1": "術語1描述", + "effective_data_sources_template_example_1": "生效數據源1, 生效數據源2", + "synonyms_template_example_1": "同義詞1, 同義詞2", + "term_name_template_example_2": "術語2", + "term_description_template_example_2": "術語2描述", + "synonyms_template_example_2": "同義詞3", + "word_cannot_be_empty": "術語名稱不能為空", + "description_cannot_be_empty": "術語描述不能為空", + "datasource_not_found": "找不到數據源" + }, + "i18n_data_training": { + "datasource_list_is_not_found": "數據源列表未找到", + "datasource_id_not_found": "數據源ID: {key} 未找到", + "datasource_cannot_be_none": "數據源不能為空", + "datasource_assistant_cannot_be_none": "數據源或高級應用不能都為空", + "data_training_not_exists": "該範例不存在", + "exists_in_db": "該問題已存在", + "data_training": "SQL 範例庫", + "problem_description": "問題描述", + "sample_sql": "範例 SQL", + "effective_data_sources": "生效數據源", + "advanced_application": "高級應用", + "problem_description_template": "問題描述(必填)", + "sample_sql_template": "範例 SQL(必填)", + "effective_data_sources_template": "生效數據源", + "advanced_application_template": "高級應用", + "problem_description_template_example": "查詢TEST表內所有ID", + "effective_data_sources_template_example": "生效數據源1", + "advanced_application_template_example": "生效高級應用名稱", + "error_info": "錯誤訊息", + "question_cannot_be_empty": "問題不能為空", + "description_cannot_be_empty": "範例 SQL 不能為空", + "datasource_not_found": "找不到數據源", + "advanced_application_not_found": "找不到高級應用" + }, + "i18n_custom_prompt": { + "exists_in_db": "模版名稱已存在", + "not_exists": "該模版不存在", + "prompt_word_name": "提示詞名稱", + "prompt_word_content": "提示詞內容", + "effective_data_sources": "生效數據源", + "all_data_sources": "所有數據源", + "prompt_word_name_template": "提示詞名稱(必填)", + "prompt_word_content_template": "提示詞內容(必填)", + "effective_data_sources_template": "生效數據源(支援多個,用\",\"分割)", + "all_data_sources_template": "所有數據源(Y:應用到全部數據源,N:應用到指定數據源)", + "prompt_word_name_template_example1": "提示詞1", + "prompt_word_content_template_example1": "詳細描述你的提示詞", + "effective_data_sources_template_example1": "生效數據源1, 生效數據源2", + "prompt_word_name_template_example2": "提示詞2", + "prompt_word_content_template_example2": "詳細描述你的提示詞", + "name_cannot_be_empty": "名稱不能為空", + "prompt_cannot_be_empty": "提示詞內容不能為空", + "type_cannot_be_empty": "類型不能為空", + "datasource_not_found": "找不到數據源", + "datasource_cannot_be_none": "數據源不能為空" + }, + "i18n_excel_export": { + "data_is_empty": "表單數據為空,無法導出數據" + }, + "i18n_excel_import": { + "col_num_not_match": "EXCEL列數量不匹配" + }, + "i18n_authentication": { + "record_not_exist": "{msg} 記錄不存在,請先儲存!" + }, + "i18n_audit": { + "success": "成功", + "failed": "失敗", + "system_log": "操作日誌", + "operation_type_name": "操作類型", + "operation_detail_info": "操作詳情", + "user_name": "操作用戶", + "oid_name": "工作空間", + "operation_status_name": "操作狀態", + "error_message": "錯誤訊息", + "ip_address": "IP 地址", + "create_time": "操作時間", + "chat": "智能問數", + "datasource": "數據源", + "dashboard": "儀表板", + "member": "成員", + "permission": "權限", + "terminology": "術語", + "data_training": "SQL 範例庫", + "prompt_words": "提示詞", + "user": "用戶", + "workspace": "工作空間", + "ai_model": "AI 模型", + "application": "應用", + "theme": "外觀配置", + "create": "新建", + "delete": "刪除", + "update": "更新", + "edit": "編輯", + "login": "登入", + "export": "匯出", + "import": "匯入", + "add": "新增", + "create_or_update": "變更", + "api_key": "API Key", + "params_setting": "參數配置", + "rules": "權限規則", + "log_setting": "登入認證", + "setting": "設定", + "system_management": "系統管理", + "opt_log": "操作日誌", + "prediction": "數據預測", + "analysis": "數據分析", + "reset_pwd": "重置密碼", + "update_pwd": "更新密碼", + "update_status": "更新狀態", + "update_table_relation": "變更表關係" + }, + "i18n_table_not_exist": "當前表不存在", + "i18n_variable": { + "name_exist": "變數名稱已存在", + "name": "姓名", + "account": "帳號", + "email": "郵箱" + } +} \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index e473af875..a8bab9743 100644 --- a/backend/main.py +++ b/backend/main.py @@ -10,8 +10,10 @@ from fastapi.routing import APIRoute from fastapi.staticfiles import StaticFiles from fastapi_mcp import FastApiMCP +from starlette.datastructures import MutableHeaders from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.middleware.cors import CORSMiddleware +from starlette.middleware.base import BaseHTTPMiddleware from alembic import command from apps.api import api_router @@ -70,13 +72,27 @@ def custom_generate_unique_id(route: APIRoute) -> str: app = FastAPI( title=settings.PROJECT_NAME, - openapi_url=f"{settings.API_V1_STR}/openapi.json", + openapi_url=f"{settings.CONTEXT_PATH}/openapi.json" if settings.SQLBOT_DOC_ENABLED else None, generate_unique_id_function=custom_generate_unique_id, lifespan=lifespan, docs_url=None, redoc_url=None ) + +class McpClientIpForwardMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + client_host = request.client.host if request.client else None + if client_host: + headers = MutableHeaders(scope=request.scope) + if not headers.get("x-real-ip"): + headers["x-real-ip"] = client_host + if not headers.get("x-forwarded-for"): + headers["x-forwarded-for"] = client_host + if not headers.get("x-client-ip"): + headers["x-client-ip"] = client_host + return await call_next(request) + # cache docs for different text _openapi_cache: Dict[str, Dict[str, Any]] = {} @@ -152,27 +168,29 @@ def generate_openapi_for_lang(lang: str) -> Dict[str, Any]: # custom /openapi.json and /docs -@app.get("/openapi.json", include_in_schema=False) -async def custom_openapi(request: Request): - lang = get_language_from_request(request) - schema = generate_openapi_for_lang(lang) - return JSONResponse(schema) - - -@app.get("/docs", include_in_schema=False) -async def custom_swagger_ui(request: Request): - lang = get_language_from_request(request) - from fastapi.openapi.docs import get_swagger_ui_html - return get_swagger_ui_html( - openapi_url=f"/openapi.json?lang={lang}", - title="SQLBot API Docs", - swagger_favicon_url="https://fastapi.tiangolo.com/img/favicon.png", - swagger_js_url="/swagger-ui-bundle.js", - swagger_css_url="/swagger-ui.css", - ) +if settings.SQLBOT_DOC_ENABLED: + @app.get(f"{settings.CONTEXT_PATH}/openapi.json", include_in_schema=False) + async def custom_openapi(request: Request): + lang = get_language_from_request(request) + schema = generate_openapi_for_lang(lang) + return JSONResponse(schema) + + + @app.get(f"{settings.CONTEXT_PATH}/docs", include_in_schema=False) + async def custom_swagger_ui(request: Request): + lang = get_language_from_request(request) + from fastapi.openapi.docs import get_swagger_ui_html + return get_swagger_ui_html( + openapi_url=f"./openapi.json?lang={lang}", + title="SQLBot API Docs", + swagger_favicon_url="https://fastapi.tiangolo.com/img/favicon.png", + swagger_js_url="./swagger-ui-bundle.js", + swagger_css_url="./swagger-ui.css", + ) mcp_app = FastAPI() +mcp_app.add_middleware(McpClientIpForwardMiddleware) # mcp server, images path images_path = settings.MCP_IMAGE_PATH os.makedirs(images_path, exist_ok=True) @@ -184,7 +202,8 @@ async def custom_swagger_ui(request: Request): description="SQLBot MCP Server", describe_all_responses=True, describe_full_response_schema=True, - include_operations=["mcp_datasource_list", "get_model_list", "mcp_question", "mcp_start", "mcp_assistant"] + include_operations=["mcp_datasource_list", "get_model_list", "mcp_question", "mcp_start", "mcp_assistant", "mcp_ws_list", "access_token"], + headers=["Authorization", "X-Forwarded-For", "X-Real-IP", "CF-Connecting-IP", "X-Client-IP"] ) mcp.mount(mcp_app) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 06ae1a4f0..1c38a592d 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sqlbot" -version = "1.7.0" +version = "1.10.0" description = "" requires-python = "==3.11.*" dependencies = [ @@ -39,7 +39,7 @@ dependencies = [ "pyyaml (>=6.0.2,<7.0.0)", "fastapi-mcp (>=0.3.4,<0.4.0)", "tabulate>=0.9.0", - "sqlbot-xpack>=0.0.5.13,<0.0.6.0", + "sqlbot-xpack>=0.0.5.32,<0.0.6.0", "fastapi-cache2>=0.2.2", "sqlparse>=0.5.3", "redis>=6.2.0", @@ -53,7 +53,10 @@ dependencies = [ "elasticsearch[requests] (>=7.10,<8.0)", "ldap3>=2.9.1", "sqlglot>=28.6.0", - "numpy==2.3.5" + "numpy==2.3.5", + "pyhive[hive_pure_sasl]>=0.7.0", + "thrift-sasl", + "dbutils>=3.1.2", ] [project.optional-dependencies] diff --git a/backend/templates/sql_examples/AWS_Redshift.yaml b/backend/templates/sql_examples/AWS_Redshift.yaml index 40036b975..768bdca6b 100644 --- a/backend/templates/sql_examples/AWS_Redshift.yaml +++ b/backend/templates/sql_examples/AWS_Redshift.yaml @@ -51,6 +51,7 @@ template: COUNT("t1"."订单ID") AS "total_orders", CONCAT(ROUND("t1"."折扣率" * 100, 2), '%') AS "discount_percent" FROM "TEST"."SALES" "t1" + GROUP BY "t1"."订单ID", "t1"."金额", "t1"."折扣率" LIMIT 100 diff --git a/backend/templates/sql_examples/DM.yaml b/backend/templates/sql_examples/DM.yaml index 29e65d82c..df050f6e8 100644 --- a/backend/templates/sql_examples/DM.yaml +++ b/backend/templates/sql_examples/DM.yaml @@ -52,6 +52,7 @@ template: COUNT("t1"."订单ID") AS "total_orders", TO_CHAR("t1"."折扣率" * 100, '990.99') || '%' AS "discount_percent" FROM "TEST"."ORDERS" "t1" + GROUP BY "t1"."订单ID", "t1"."金额", "t1"."折扣率" LIMIT 100 diff --git a/backend/templates/sql_examples/Doris.yaml b/backend/templates/sql_examples/Doris.yaml index b973c529d..9e16b7593 100644 --- a/backend/templates/sql_examples/Doris.yaml +++ b/backend/templates/sql_examples/Doris.yaml @@ -53,6 +53,7 @@ template: COUNT(`t1`.`订单ID`) AS `total_orders`, CONCAT(ROUND(`t1`.`折扣率` * 100, 2), '%') AS `discount_percent` FROM `test`.`orders` `t1` + GROUP BY `t1`.`订单ID`, `t1`.`金额`, `t1`.`折扣率` LIMIT 100 diff --git a/backend/templates/sql_examples/Hive.yaml b/backend/templates/sql_examples/Hive.yaml new file mode 100644 index 000000000..f7998b930 --- /dev/null +++ b/backend/templates/sql_examples/Hive.yaml @@ -0,0 +1,88 @@ +template: + quot_rule: | + + 必须对数据库名、表名、字段名、别名外层加反引号(`)。 + + 1. 点号(.)不能包含在引号内,必须写成 `database`.`table` + 2. 即使标识符不含特殊字符或非关键字,也需强制加反引号 + 3. 在多表关联(JOIN)/ 多子查询的 SQL 中,只要多个表 / 子查询存在 同名字段,所有引用该字段的位置,必须显式指定 表别名 + + + + limit_rule: | + + 当需要限制行数时,必须使用标准的LIMIT语法 + + + other_rule: | + 必须为每个表生成别名(不加AS) + {multi_table_condition} + 禁止使用星号(*),必须明确字段名 + 中文/特殊字符字段需保留原名并添加英文别名 + 不能用 + 拼接字符串,字符串必须使用单引号 + 分组非常严格:SELECT 里的字段必须出现在 GROUP BY 里,或者是聚合函数 + 函数字段必须加别名 + 百分比字段保留两位小数并以%结尾 + WHERE 条件中不能使用 >、<、>=、<= 等比较运算符,必须使用 = + HIVE 中没有 NOT IN 操作符,必须使用 LEFT JOIN 或 EXISTS 替代 + 判空使用 NVL()函数 + 避免与数据库关键字冲突 + + basic_example: | + + + 📌 以下示例严格遵循中的 Hive 规范,展示符合要求的 SQL 写法与典型错误案例。 + ⚠️ 注意:示例中的表名、字段名均为演示虚构,实际使用时需替换为用户提供的真实标识符。 + 🔍 重点观察: + 1. 反引号包裹所有数据库对象的规范用法 + 2. 中英别名/百分比/函数等特殊字段的处理 + 3. 关键字冲突的规避方式 + + + 查询 ods.orders 表的前100条订单(含中文字段和百分比) + + SELECT * FROM ods.orders LIMIT 100 -- 错误:未加引号、使用星号 + SELECT `订单ID`, `金额` FROM `ods`.`orders` `t1` LIMIT 100 -- 错误:缺少英文别名 + SELECT COUNT(`订单ID`) FROM `ods`.`orders` `t1` -- 错误:函数未加别名 + + + SELECT + `t1`.`订单ID` AS `order_id`, + `t1`.`金额` AS `amount`, + COUNT(`t1`.`订单ID`) AS `total_orders`, + CONCAT(CAST(ROUND(`t1`.`折扣率` * 100, 2) AS STRING), '%') AS `discount_percent` + FROM `ods`.`orders` `t1` + GROUP BY `t1`.`订单ID`, `t1`.`金额`, `t1`.`折扣率` + LIMIT 100 + + + + + 统计 dim.users(含关键字字段user)的活跃占比 + + SELECT user, status FROM dim.users -- 错误:未处理关键字和引号 + SELECT `user`, ROUND(active_ratio) FROM `dim`.`users` -- 错误:百分比格式错误 + + + SELECT + `u`.`user` AS `username`, + CONCAT(CAST(ROUND(`u`.`active_ratio` * 100, 2) AS STRING), '%') AS `active_percent` + FROM `dim`.`users` `u` + WHERE `u`.`status` = 1 + + + + + example_engine: Apache Hive 2.X + example_answer_1: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `continent` AS `continent_name`, `year` AS `year`, `gdp` AS `gdp` FROM `Sample_Database`.`sample_country_gdp` ORDER BY `country`, `year`","tables":["sample_country_gdp"],"chart-type":"line"} + example_answer_1_with_limit: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `continent` AS `continent_name`, `year` AS `year`, `gdp` AS `gdp` FROM `Sample_Database`.`sample_country_gdp` ORDER BY `country`, `year` LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"line"} + example_answer_2: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `gdp` AS `gdp` FROM `Sample_Database`.`sample_country_gdp` WHERE `year` = '2024' ORDER BY `gdp` DESC","tables":["sample_country_gdp"],"chart-type":"pie"} + example_answer_2_with_limit: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `gdp` AS `gdp` FROM `Sample_Database`.`sample_country_gdp` WHERE `year` = '2024' ORDER BY `gdp` DESC LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"pie"} + example_answer_3: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `gdp` AS `gdp` FROM `Sample_Database`.`sample_country_gdp` WHERE `year` = '2025' AND `country` = '中国'","tables":["sample_country_gdp"],"chart-type":"table"} + example_answer_3_with_limit: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `gdp` AS `gdp` FROM `Sample_Database`.`sample_country_gdp` WHERE `year` = '2025' AND `country` = '中国' LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"table"} diff --git a/backend/templates/sql_examples/Microsoft_SQL_Server.yaml b/backend/templates/sql_examples/Microsoft_SQL_Server.yaml index 856ac2a6a..31bc77c5f 100644 --- a/backend/templates/sql_examples/Microsoft_SQL_Server.yaml +++ b/backend/templates/sql_examples/Microsoft_SQL_Server.yaml @@ -52,6 +52,7 @@ template: COUNT([o].[订单ID]) AS [total_orders], CONVERT(VARCHAR, ROUND([o].[折扣率] * 100, 2)) + '%' AS [discount_percent] FROM [Sales].[Orders] [o] + GROUP BY [o].[订单ID], [o].[金额], [o].[折扣率] diff --git a/backend/templates/sql_examples/MySQL.yaml b/backend/templates/sql_examples/MySQL.yaml index 1335ec9e4..36e27e818 100644 --- a/backend/templates/sql_examples/MySQL.yaml +++ b/backend/templates/sql_examples/MySQL.yaml @@ -51,6 +51,7 @@ template: COUNT(`t1`.`订单ID`) AS `total_orders`, CONCAT(ROUND(`t1`.`折扣率` * 100, 2), '%') AS `discount_percent` FROM `test`.`orders` `t1` + GROUP BY `t1`.`订单ID`, `t1`.`金额`, `t1`.`折扣率` LIMIT 100 diff --git a/backend/templates/sql_examples/Oracle.yaml b/backend/templates/sql_examples/Oracle.yaml index 60d36d477..e3c52288b 100644 --- a/backend/templates/sql_examples/Oracle.yaml +++ b/backend/templates/sql_examples/Oracle.yaml @@ -3,14 +3,16 @@ template: 1. 分析用户问题,确定查询需求 2. 根据表结构生成基础SQL - 3. 强制检查:验证SQL中使用的表名和字段名是否在中定义 + 3. 强制检查:逐字验证SQL中使用的表名和字段名是否在中定义,且字符必须完全匹配(包括繁简体、大小写、特殊字符),不得有任何转换。如發现任何不一致,必须重新生成SQL。 4. 强制检查:应用数据量限制规则(默认限制或用户指定数量) 5. 应用其他规则(引号、别名、格式化等) 6. 最终验证:GROUP BY查询的ROWNUM位置是否正确? 7. 强制检查:验证SQL语法是否符合规范 8. 确定图表类型(根据规则选择table/column/bar/line/pie) 9. 确定对话标题 - 10. 返回JSON结果 + 10. 生成JSON结果 + 11. 强制检查:JSON格式是否正确 + 12. 返回JSON结果 quot_rule: | @@ -114,6 +116,7 @@ template: COUNT("t1"."订单ID") AS "total_orders", TO_CHAR("t1"."折扣率" * 100, '990.99') || '%' AS "discount_percent" FROM "TEST"."ORDERS" "t1" + GROUP BY "t1"."订单ID", "t1"."金额", "t1"."折扣率" WHERE ROWNUM <= 100 diff --git a/backend/templates/sql_examples/PostgreSQL.yaml b/backend/templates/sql_examples/PostgreSQL.yaml index 42a9cfe41..268e8b7b5 100644 --- a/backend/templates/sql_examples/PostgreSQL.yaml +++ b/backend/templates/sql_examples/PostgreSQL.yaml @@ -46,6 +46,7 @@ template: COUNT("t1"."订单ID") AS "total_orders", ROUND("t1"."折扣率" * 100, 2) || '%' AS "discount_percent" FROM "TEST"."ORDERS" "t1" + GROUP BY "t1"."订单ID", "t1"."金额", "t1"."折扣率" LIMIT 100 diff --git a/backend/templates/template.yaml b/backend/templates/template.yaml index 0fdf9786b..8c2308575 100644 --- a/backend/templates/template.yaml +++ b/backend/templates/template.yaml @@ -13,13 +13,15 @@ template: 1. 分析用户问题,确定查询需求 2. 根据表结构生成基础SQL - 3. 强制检查:验证SQL中使用的表名和字段名是否在中定义 + 3. 强制检查:逐字验证SQL中使用的表名和字段名是否在中定义,且字符必须完全匹配(包括繁简体、大小写、特殊字符),不得有任何转换。如發现任何不一致,必须重新生成SQL。 4. 强制检查:应用数据量限制规则(默认限制或用户指定数量) 5. 应用其他规则(引号、别名、格式化等) 6. 强制检查:验证SQL语法是否符合规范 7. 确定图表类型(根据规则选择table/column/bar/line/pie) 8. 确定对话标题 - 9. 返回JSON结果 + 9. 生成JSON结果 + 10. 强制检查:JSON格式是否正确 + 11. 返回JSON结果 query_limit: | @@ -65,26 +67,27 @@ template: system: | - 你是"SQLBOT",智能问数小助手,可以根据用户提问,专业生成SQL,查询数据并进行图表展示。 + 你是智能问数小助手:"{sqlbot_name}"。你可以根据用户提问,专业生成SQL,查询数据并进行图表展示。 你当前的任务是根据给定的表结构和用户问题生成SQL语句、对话标题、可能适合展示的图表类型以及该SQL中所用到的表名。 我们会在块内提供给你信息,帮助你生成SQL: 内有等信息; 其中,:提供数据库引擎及版本信息; :以 M-Schema 格式提供数据库表结构信息; - :提供一组术语,块内每一个就是术语,其中同一个内的多个代表术语的多种叫法,也就是术语与它的同义词,即该术语对应的描述,其中也可能是能够用来参考的计算公式,或者是一些其他的查询条件; - :提供一组SQL示例,你可以参考这些示例来生成你的回答,其中内是提问,内是对于该提问的解释或者对应应该回答的SQL示例。 + :提供一组术语,块内每一个就是术语,其中同一个内的多个代表术语的多种叫法,也就是术语与它的同义词,即该术语对应的描述,其中也可能是能够用来参考的计算公式,或者是一些其他的查询条件; + :提供一组SQL示例,你可以参考这些示例来生成你的回答,其中内是提问,内是对于该提问的解释或者对应应该回答的SQL示例。 若有块,它会提供一组,可能会是额外添加的背景信息,或者是额外的生成SQL的要求,请结合额外信息或要求后生成你的回答。 - 用户的提问在内,内则会提供上次执行你提供的SQL时会出现的错误信息,内的会告诉你用户当前提问的时间 你必须遵守内规定的生成SQL规则 你必须遵守内规定的检查步骤生成你的回答 + 用户的提问在内,内则会提供上次执行你提供的SQL时会出现的错误信息,内的会告诉你用户当前提问的时间 + ⚠️ 重要警告:SQL语句中的数据库标识符(表名、字段名)必须严格保持原样,不得因回应语言而进行任何转换。即使整个回应使用繁体中文,SQL中的标识符也必须保持与完全一致(通常为简体中文)。这是确保SQL可执行的关键要求。 + {process_check} + + generate_rules: | + 以下是你必须遵守的规则和可以参考的基础示例: - - 请使用语言:{lang} 回答,若有深度思考过程,则思考过程也需要使用 {lang} 输出 - 即使示例中显示了中文消息,在实际生成时也必须翻译成英文 - - + 你只能生成查询用的SQL语句,不得生成增删改相关或操作数据库以及操作数据库数据的SQL @@ -93,13 +96,42 @@ template: 生成的SQL必须符合内提供数据库引擎的规范 + + 严格保持数据库标识符原样 + + 无论用户要求使用何种语言回应,SQL语句中的所有数据库标识符(表名、字段名)必须严格保持与中完全相同的字符。 + + 重要:当中提供的是简体中文标识符时: + 1. SQL中必须使用完全相同的简体中文字符 + 2. 绝对不允许进行繁简转换 + 3. 不允许进行任何字符替换、修改或标准化 + 4. 即使回应文本是繁体中文,SQL中的标识符也必须保持简体 + + 检查方法:生成SQL後,必须逐字对比SQL中的标识符与中的标识符,确保: + - 字符完全一致 + - 繁简体完全一致 + - 大小写完全一致 + - 特殊字符(如斜杠"/")完全一致 + + 如果发现不一致,必须重新生成SQL。 + + + + 标识符處理优先级 + + 1. 首先从中複製标识符到SQL中 + 2. 生成其他回应文本(如brief、message) + 3. 根据用户语言要求,只对非SQL部分的文本进行语言转换 + 4. 绝对不触及SQL中的标识符 + + 若用户提问中提供了参考SQL,你需要判断该SQL是否是查询语句 你只需要根据提供给你的信息生成的SQL,不需要你实际去数据库进行查询 - + 请使用JSON格式返回你的回答: 若能生成,则返回格式如:{{"success":true,"sql":"你生成的SQL语句","tables":["该SQL用到的表名1","该SQL用到的表名2",...],"chart-type":"table","brief":"如何需要生成对话标题,在这里填写你生成的对话标题,否则不需要这个字段"}} 若不能生成,则返回格式如:{{"success":false,"message":"说明无法生成SQL的原因"}} @@ -186,8 +218,6 @@ template: - {process_check} - {basic_sql_examples} @@ -237,7 +267,7 @@ template: 今天天气如何? - {{"success":false,"message":"我是智能问数小助手,我无法回答您的问题。"}} + {{"success":false,"message":"我是{sqlbot_name},我无法回答您的问题。"}} @@ -245,7 +275,7 @@ template: 请清空数据库 - {{"success":false,"message":"我是智能问数小助手,我只能查询数据,不能操作数据库来修改数据或者修改表结构。"}} + {{"success":false,"message":"我是{sqlbot_name},我只能查询数据,不能操作数据库来修改数据或者修改表结构。"}} @@ -298,23 +328,36 @@ template: - - 以下是正式的信息: + + generate_terminologies_info: | + 以下是你可以参考的术语: + {terminologies} + + generate_data_training_info: | + 以下是你可以参考的SQL示例: + {data_training} + + generate_custom_prompt_info: | + 以下是你可以参考的额外信息: + {custom_prompt} + + generate_basic_info: | + ## 以下是数据库与表结构信息,你生成的SQL使用到的表名与字段必须在提供的范围内 {engine} {schema} - - {terminologies} - {data_training} + + {sample_data} + - {custom_prompt} - - ### 响应, 请根据上述要求直接返回JSON结果: - ```json user: | + ## 请根据上述要求,使用语言:{lang} 进行回答,若有深度思考过程,则思考过程也需要使用 {lang} 输出 + ## 如果内的提问与上述要求冲突,你必须停止生成SQL并告知生成SQL失败的原因 + ## 回答中不需要输出你的分析,请直接输出符合要求的JSON + ## 必须注意:不论要求你用什么语言回答,生成SQL中使用到的表名与字段名是否完全和中提供的表名字与段名的字符保持一致! {current_time} @@ -331,20 +374,30 @@ template: chart: system: | - 你是"SQLBOT",智能问数小助手,可以根据用户提问,专业生成SQL,查询数据并进行图表展示。 + 你是智能问数小助手:"{sqlbot_name}"。你可以根据用户提问,专业生成SQL,查询数据并进行图表展示。 你当前的任务是根据给定SQL语句和用户问题,生成数据可视化图表的配置项。 用户会提供给你如下信息,帮助你生成配置项: :用户的提问 :需要参考的SQL :以 M-Schema 格式提供 SQL 内用到表的数据库表结构信息,你可以参考字段名与字段备注来生成图表使用到的字段名 :推荐你生成的图表类型 + 你必须遵守内规定的生成图表结构的规则 + 你必须遵守内规定的检查步骤生成你的回答 - 你必须遵守以下规则: + + 1. 分析提供的,结合确认图表需要的指标,维度和分类 + 2. 应用规则 + 3. 检查指标,维度和分类字段是否在SQL内存在 + 4. 结合确认指标,维度和分类展示用的名称 + 5. 生成JSON结果 + 6. 强制检查:JSON格式是否正确 + 7. 返回JSON结果 + + + generate_rules: | + 以下是你必须遵守的规则和可以参考的基础示例: - - 请使用语言:{lang} 回答,若有深度思考过程,则思考过程也需要使用 {lang} 输出 - 支持的图表类型为表格(table)、柱状图(column)、条形图(bar)、折线图(line)或饼图(pie), 提供给你的值则为 table/column/bar/line/pie 中的一个,若没有推荐类型,则由你自己选择一个合适的类型。 图表类型选择原则推荐:趋势 over time 用 line,分类对比用 column/bar,占比用 pie,原始数据查看用 table @@ -477,11 +530,11 @@ template: - - ### 响应, 请根据上述要求直接返回JSON结果: - ```json user: | + ## 请根据上述要求,使用语言:{lang} 进行回答,若有深度思考过程,则思考过程也需要使用 {lang} 输出 + ## 回答中不需要输出你的分析,请直接输出符合要求的JSON + ## 必须注意:不论要求你用什么语言回答,生成图表结构中的value字段内容必须与提供的SQL中提供的字段别名的字符保持一致! {question} @@ -532,7 +585,7 @@ template: analysis: system: | - 你是"SQLBOT",智能问数小助手,可以根据用户提问,专业生成SQL与可视化图表。 + 你是智能问数小助手:"{sqlbot_name}"。你可以根据用户提问,专业生成SQL与可视化图表。 你当前的任务是根据给定的数据分析数据,并给出你的分析结果。 我们会在块内提供给你信息,帮助你进行分析: 内有等信息; @@ -565,7 +618,7 @@ template: predict: system: | - 你是"SQLBOT",智能问数小助手,可以根据用户提问,专业生成SQL与可视化图表。 + 你是智能问数小助手:"{sqlbot_name}"。你可以根据用户提问,专业生成SQL与可视化图表。 你当前的任务是根据给定的数据进行数据预测,并给出你的预测结果。 若有块,它会提供一组,可能会是额外添加的背景信息,或者是额外的分析要求,请结合额外信息或要求后生成你的回答。 用户会在提问中提供给你信息: @@ -606,10 +659,9 @@ template: datasource: system: | - ### 请使用语言:{lang} 回答 - - ### 说明: - 你是一个数据分析师,你需要根据用户的提问,以及提供的数据源列表(格式为JSON数组:[{{"id": 数据源ID1,"name":"数据源名称1","description":"数据源描述1"}},{{"id": 数据源ID2,"name":"数据源名称2","description":"数据源描述2"}}]),根据名称和描述找出最符合用户提问的数据源,这个数据源后续将被用来进行数据的分析 + + 你是一个数据分析师,你需要根据用户的提问,以及提供的数据源列表(格式为JSON数组:[{{"id": 数据源ID1,"name":"数据源名称1","description":"数据源描述1"}},{{"id": 数据源ID2,"name":"数据源名称2","description":"数据源描述2"}}]),根据名称和描述找出最符合用户提问的数据源,这个数据源后续将被用来进行数据的分析 + ### 要求: - 以JSON格式返回你找到的符合提问的数据源ID,格式为:{{"id": 符合要求的数据源ID}} @@ -617,9 +669,9 @@ template: - 如果没有符合要求的数据源,则返回:{{"fail":"没有找到匹配的数据源"}} - 不需要思考过程,请直接返回JSON结果 - ### 响应, 请直接返回JSON结果: - ```json user: | + ## 请根据上述要求,使用语言:{lang} 进行回答符合要求的JSON结果 + ### 数据源列表: {data} @@ -628,7 +680,7 @@ template: permissions: system: | - 你是"SQLBOT",智能问数小助手,可以根据用户提问,专业生成SQL与可视化图表。 + 你是智能问数小助手:"{sqlbot_name}"。你可以根据用户提问,专业生成SQL与可视化图表。 你当前的任务是在给定的SQL基础上,根据提供的一组过滤条件,将过滤条件添加到该SQL内并生成一句新SQL 提供的SQL在内,提供的过滤条件在 diff --git a/docs/README.en.md b/docs/README.en.md index 0c6ea87e8..9848d3799 100644 --- a/docs/README.en.md +++ b/docs/README.en.md @@ -30,6 +30,23 @@ SQLBot is an intelligent data query system based on large language models and RA - **Easy Integration:** Supports multiple integration methods, providing capabilities such as web embedding, pop-up embedding, and MCP invocation. It can be quickly embedded into applications such as n8n, Dify, MaxKB, and DataEase, allowing various applications to quickly acquire intelligent data collection capabilities. - **Increasingly Accurate with Use:** Supports customizable prompts and terminology library configurations, maintainable SQL example calibration logic, and accurate matching of business scenarios. Efficient operation, based on continuous iteration and optimization using user interaction data, the data collection effect gradually improves with use, becoming more accurate with each use. +## Supported LLM Providers + +| Provider | API Compatibility | +|----------|-------------------| +| Alibaba Cloud Bailian | OpenAI Compatible | +| Qianfan Model | OpenAI Compatible | +| DeepSeek | OpenAI Compatible | +| Tencent Hunyuan | OpenAI Compatible | +| iFlytek Spark | OpenAI Compatible | +| Gemini | OpenAI Compatible | +| OpenAI | Native | +| Kimi | OpenAI Compatible | +| Tencent Cloud | OpenAI Compatible | +| Volcano Engine | OpenAI Compatible | +| MiniMax | OpenAI Compatible | +| Generic OpenAI Compatible | Custom | + ## Quick Start ### Installation and Deployment diff --git a/frontend/index.html b/frontend/index.html index 97c846b88..382730d02 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - SQLBot +
diff --git a/frontend/package.json b/frontend/package.json index 14d623762..280710df8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,7 @@ }, "scripts": { "dev": "vue-tsc -b && vite", - "build": "vue-tsc -b && vite build", + "build": "vue-tsc -b &&NODE_OPTIONS=--max_old_space_size=4096 vite build", "preview": "vite preview", "lint": "eslint . --ext .vue,.js,.ts,.jsx,.tsx --fix" }, @@ -24,6 +24,7 @@ "@npkg/tinymce-plugins": "^0.0.7", "@tinymce/tinymce-vue": "^5.1.0", "@vueuse/core": "^14.1.0", + "core-js": "^3.49.0", "dayjs": "^1.11.13", "element-plus": "^2.10.1", "element-plus-secondary": "^1.0.0", @@ -55,17 +56,23 @@ "@types/node": "^22.14.1", "@typescript-eslint/eslint-plugin": "^8.34.0", "@typescript-eslint/parser": "^8.34.0", + "@vitejs/plugin-legacy": "^6.1.1", "@vitejs/plugin-vue": "^5.2.2", "@vue/tsconfig": "^0.7.0", + "autoprefixer": "^10.5.2", "axios": "^1.8.4", "crypto-js": "^4.2.0", + "css-has-pseudo": "^8.0.0", "eslint": "^9.28.0", "eslint-config-prettier": "^10.1.5", "eslint-plugin-prettier": "^5.4.1", "eslint-plugin-vue": "^10.2.0", + "flex-gap-polyfill": "^5.0.0", "globals": "^16.2.0", "less": "4.4.2", "pinia": "^3.0.2", + "postcss": "^8.5.16", + "postcss-preset-env": "^11.3.2", "prettier": "^3.5.3", "typescript": "~5.7.2", "typescript-eslint": "^8.34.0", @@ -76,4 +83,4 @@ "vite-svg-loader": "^5.1.0", "vue-tsc": "^2.2.8" } -} \ No newline at end of file +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 000000000..bca102202 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,14 @@ +import autoprefixer from 'autoprefixer' +import postcssPresetEnv from 'postcss-preset-env' +import cssHasPseudo from 'css-has-pseudo' + +export default { + plugins: [ + cssHasPseudo({ preserve: true }), + autoprefixer(), + postcssPresetEnv({ + stage: 3, + browsers: 'Chrome 81', + }), + ], +} diff --git a/frontend/public/assistant.js b/frontend/public/assistant.js deleted file mode 100644 index a986eaa97..000000000 --- a/frontend/public/assistant.js +++ /dev/null @@ -1,865 +0,0 @@ -;(function () { - window.sqlbot_assistant_handler = window.sqlbot_assistant_handler || {} - const defaultData = { - id: '1', - show_guide: false, - float_icon: '', - domain_url: 'http://localhost:5173', - header_font_color: 'rgb(100, 106, 115)', - x_type: 'right', - y_type: 'bottom', - x_val: '30', - y_val: '30', - float_icon_drag: false, - } - const script_id_prefix = 'sqlbot-assistant-float-script-' - const guideHtml = ` -
-
-
-
-
- - - -
- -
🌟 遇见问题,不再有障碍!
-

你好,我是你的智能小助手。
- 点我,开启高效解答模式,让问题变成过去式。

-
- -
- -
-` - - const chatButtonHtml = (data) => ` -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
` - - const getChatContainerHtml = (data) => { - let srcUrl = `${data.domain_url}/#/assistant?id=${data.id}&online=${!!data.online}&name=${encodeURIComponent(data.name)}` - if (data.userFlag) { - srcUrl += `&userFlag=${data.userFlag || ''}` - } - if (data.history) { - srcUrl += `&history=${data.history}` - } - return ` -
- -
-
- - - -
-
- - - -
-
- - - -
-
-` - } - - function getHighestZIndexValue() { - try { - let maxZIndex = -Infinity - let foundAny = false - - const allElements = document.all || document.querySelectorAll('*') - - for (let i = 0; i < allElements.length; i++) { - const element = allElements[i] - - if (!element || element.nodeType !== 1) continue - - const styles = window.getComputedStyle(element) - - const position = styles.position - if (position === 'static') continue - - const zIndex = styles.zIndex - let zIndexValue - - if (zIndex === 'auto') { - zIndexValue = 0 - } else { - zIndexValue = parseInt(zIndex, 10) - if (isNaN(zIndexValue)) continue - } - - foundAny = true - - // 快速返回:如果找到很大的z-index,很可能就是最大值 - /* if (zIndexValue > 10000) { - return zIndexValue; - } */ - - if (zIndexValue > maxZIndex) { - maxZIndex = zIndexValue - } - } - return foundAny ? maxZIndex : 0 - } catch (error) { - console.warn('获取最高z-index时出错,返回默认值0:', error) - return 0 - } - } - - /** - * 初始化引导 - * @param {*} root - */ - const initGuide = (root) => { - root.insertAdjacentHTML('beforeend', guideHtml) - const button = root.querySelector('.sqlbot-assistant-button') - const close_icon = root.querySelector('.sqlbot-assistant-close') - const close_func = () => { - root.removeChild(root.querySelector('.sqlbot-assistant-tips')) - root.removeChild(root.querySelector('.sqlbot-assistant-mask')) - localStorage.setItem('sqlbot_assistant_mask_tip', true) - } - button.onclick = close_func - close_icon.onclick = close_func - } - const initChat = (root, data) => { - // 添加对话icon - root.insertAdjacentHTML('beforeend', chatButtonHtml(data)) - // 添加对话框 - root.insertAdjacentHTML('beforeend', getChatContainerHtml(data)) - // 按钮元素 - const chat_button = root.querySelector('.sqlbot-assistant-chat-button') - let chat_button_img = root.querySelector('.sqlbot-assistant-chat-button > svg') - if (data.float_icon) { - chat_button_img = root.querySelector('.sqlbot-assistant-chat-button > img') - } - chat_button_img.style.display = 'block' - // 对话框元素 - const chat_container = root.querySelector('#sqlbot-assistant-chat-container') - // 引导层 - const mask_content = root.querySelector('.sqlbot-assistant-mask > .sqlbot-assistant-content') - const mask_tips = root.querySelector('.sqlbot-assistant-tips') - chat_button_img.onload = (event) => { - if (mask_content) { - mask_content.style.width = chat_button_img.width + 'px' - mask_content.style.height = chat_button_img.height + 'px' - if (data.x_type == 'left') { - mask_tips.style.marginLeft = - (chat_button_img.naturalWidth > 500 ? 500 : chat_button_img.naturalWidth) - 64 + 'px' - } else { - mask_tips.style.marginRight = - (chat_button_img.naturalWidth > 500 ? 500 : chat_button_img.naturalWidth) - 64 + 'px' - } - } - } - - const viewport = root.querySelector('.sqlbot-assistant-openviewport') - const closeviewport = root.querySelector('.sqlbot-assistant-closeviewport') - const close_func = () => { - chat_container.style['display'] = - chat_container.style['display'] == 'block' ? 'none' : 'block' - chat_button.style['display'] = chat_container.style['display'] == 'block' ? 'none' : 'block' - } - close_icon = chat_container.querySelector('.sqlbot-assistant-chat-close') - chat_button.onclick = close_func - close_icon.onclick = close_func - const viewport_func = () => { - if (chat_container.classList.contains('sqlbot-assistant-enlarge')) { - chat_container.classList.remove('sqlbot-assistant-enlarge') - viewport.classList.remove('sqlbot-assistant-viewportnone') - closeviewport.classList.add('sqlbot-assistant-viewportnone') - } else { - chat_container.classList.add('sqlbot-assistant-enlarge') - viewport.classList.add('sqlbot-assistant-viewportnone') - closeviewport.classList.remove('sqlbot-assistant-viewportnone') - } - } - if (data.float_icon_drag) { - chat_button.setAttribute('draggable', 'true') - - let startX = 0 - let startY = 0 - const img = new Image() - img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=' - chat_button.addEventListener('dragstart', (e) => { - startX = e.clientX - chat_button.offsetLeft - startY = e.clientY - chat_button.offsetTop - e.dataTransfer.setDragImage(img, 0, 0) - }) - - chat_button.addEventListener('drag', (e) => { - if (e.clientX && e.clientY) { - const left = e.clientX - startX - const top = e.clientY - startY - - const maxX = window.innerWidth - chat_button.offsetWidth - const maxY = window.innerHeight - chat_button.offsetHeight - - chat_button.style.left = Math.min(Math.max(0, left), maxX) + 'px' - chat_button.style.top = Math.min(Math.max(0, top), maxY) + 'px' - } - }) - - let touchStartX = 0 - let touchStartY = 0 - - chat_button.addEventListener('touchstart', (e) => { - touchStartX = e.touches[0].clientX - chat_button.offsetLeft - touchStartY = e.touches[0].clientY - chat_button.offsetTop - e.preventDefault() - }) - - chat_button.addEventListener('touchmove', (e) => { - const left = e.touches[0].clientX - touchStartX - const top = e.touches[0].clientY - touchStartY - - const maxX = window.innerWidth - chat_button.offsetWidth - const maxY = window.innerHeight - chat_button.offsetHeight - - chat_button.style.left = Math.min(Math.max(0, left), maxX) + 'px' - chat_button.style.top = Math.min(Math.max(0, top), maxY) + 'px' - - e.preventDefault() - }) - } - /* const drag = (e) => { - if (['touchmove', 'touchstart'].includes(e.type)) { - chat_button.style.top = e.touches[0].clientY - chat_button_img.clientHeight / 2 + 'px' - chat_button.style.left = e.touches[0].clientX - chat_button_img.clientHeight / 2 + 'px' - } else { - chat_button.style.top = e.y - chat_button_img.clientHeight / 2 + 'px' - chat_button.style.left = e.x - chat_button_img.clientHeight / 2 + 'px' - } - chat_button.style.width = chat_button_img.clientHeight + 'px' - chat_button.style.height = chat_button_img.clientHeight + 'px' - } - if (data.float_icon_drag) { - chat_button.setAttribute('draggable', 'true') - chat_button.addEventListener('drag', drag) - chat_button.addEventListener('dragover', (e) => { - e.preventDefault() - }) - chat_button.addEventListener('dragend', drag) - chat_button.addEventListener('touchstart', drag) - chat_button.addEventListener('touchmove', drag) - } */ - viewport.onclick = viewport_func - closeviewport.onclick = viewport_func - } - /** - * 第一次进来的引导提示 - */ - function initsqlbot_assistant(data) { - const sqlbot_div = document.createElement('div') - const root = document.createElement('div') - const sqlbot_root_id = 'sqlbot-assistant-root-' + data.id - root.id = sqlbot_root_id - initsqlbot_assistantStyle(sqlbot_div, sqlbot_root_id, data) - sqlbot_div.appendChild(root) - document.body.appendChild(sqlbot_div) - const sqlbot_assistant_mask_tip = localStorage.getItem('sqlbot_assistant_mask_tip') - if (sqlbot_assistant_mask_tip == null && data.show_guide) { - initGuide(root) - } - initChat(root, data) - } - - // 初始化全局样式 - function initsqlbot_assistantStyle(root, sqlbot_assistantId, data) { - const maxZIndex = getHighestZIndexValue() - const zIndex = Math.max((maxZIndex || 0) + 1, 10000) - const maskZIndex = zIndex + 1 - style = document.createElement('style') - style.type = 'text/css' - style.innerText = ` - /* 放大 */ - #sqlbot-assistant .sqlbot-assistant-enlarge { - width: 50%!important; - height: 100%!important; - bottom: 0!important; - right: 0 !important; - } - @media only screen and (max-width: 768px){ - #sqlbot-assistant .sqlbot-assistant-enlarge { - width: 100%!important; - height: 100%!important; - right: 0 !important; - bottom: 0!important; - } - } - - /* 引导 */ - - #sqlbot-assistant .sqlbot-assistant-mask { - position: fixed; - z-index: ${maskZIndex}; - background-color: transparent; - height: 100%; - width: 100%; - top: 0; - left: 0; - } - #sqlbot-assistant .sqlbot-assistant-mask .sqlbot-assistant-content { - width: 64px; - height: 64px; - box-shadow: 1px 1px 1px 9999px rgba(0,0,0,.6); - position: absolute; - ${data.x_type}: ${data.x_val}px; - ${data.y_type}: ${data.y_val}px; - z-index: ${maskZIndex}; - } - #sqlbot-assistant .sqlbot-assistant-tips { - position: fixed; - ${data.x_type}:calc(${data.x_val}px + 75px); - ${data.y_type}: calc(${data.y_val}px + 0px); - padding: 22px 24px 24px; - border-radius: 6px; - color: #ffffff; - font-size: 14px; - background: #3370FF; - z-index: ${maskZIndex}; - } - #sqlbot-assistant .sqlbot-assistant-tips .sqlbot-assistant-arrow { - position: absolute; - background: #3370FF; - width: 10px; - height: 10px; - pointer-events: none; - transform: rotate(45deg); - box-sizing: border-box; - /* left */ - ${data.x_type}: -5px; - ${data.y_type}: 33px; - border-left-color: transparent; - border-bottom-color: transparent - } - #sqlbot-assistant .sqlbot-assistant-tips .sqlbot-assistant-title { - font-size: 20px; - font-weight: 500; - margin-bottom: 8px; - } - #sqlbot-assistant .sqlbot-assistant-tips .sqlbot-assistant-button { - text-align: right; - margin-top: 24px; - } - #sqlbot-assistant .sqlbot-assistant-tips .sqlbot-assistant-button button { - border-radius: 4px; - background: #FFF; - padding: 3px 12px; - color: #3370FF; - cursor: pointer; - outline: none; - border: none; - } - #sqlbot-assistant .sqlbot-assistant-tips .sqlbot-assistant-button button::after{ - border: none; - } - #sqlbot-assistant .sqlbot-assistant-tips .sqlbot-assistant-close { - position: absolute; - right: 20px; - top: 20px; - cursor: pointer; - - } - #sqlbot-assistant-chat-container { - width: 460px; - height: 640px; - display:none; - } - @media only screen and (max-width: 768px) { - #sqlbot-assistant-chat-container { - width: 100%; - height: 70%; - right: 0 !important; - } - } - - #sqlbot-assistant .sqlbot-assistant-chat-button{ - position: fixed; - ${data.x_type}: ${data.x_val}px; - ${data.y_type}: ${data.y_val}px; - cursor: pointer; - z-index: ${zIndex}; - } - #sqlbot-assistant #sqlbot-assistant-chat-container{ - z-index: ${zIndex}; - position: relative; - border-radius: 8px; - //border: 1px solid #ffffff; - background: linear-gradient(188deg, rgba(235, 241, 255, 0.20) 39.6%, rgba(231, 249, 255, 0.20) 94.3%), #EFF0F1; - box-shadow: 0px 4px 8px 0px rgba(31, 35, 41, 0.10); - position: fixed;bottom: 16px;right: 16px;overflow: hidden; - } - - .ed-overlay-dialog { - margin-top: 50px; - } - .ed-drawer { - margin-top: 50px; - } - - #sqlbot-assistant #sqlbot-assistant-chat-container .sqlbot-assistant-operate{ - top: 18px; - right: 15px; - position: absolute; - display: flex; - align-items: center; - line-height: 18px; - } - #sqlbot-assistant #sqlbot-assistant-chat-container .sqlbot-assistant-operate .sqlbot-assistant-chat-close{ - margin-left:15px; - cursor: pointer; - } - #sqlbot-assistant #sqlbot-assistant-chat-container .sqlbot-assistant-operate .sqlbot-assistant-openviewport{ - - cursor: pointer; - } - #sqlbot-assistant #sqlbot-assistant-chat-container .sqlbot-assistant-operate .sqlbot-assistant-closeviewport{ - - cursor: pointer; - } - #sqlbot-assistant #sqlbot-assistant-chat-container .sqlbot-assistant-viewportnone{ - display:none; - } - #sqlbot-assistant #sqlbot-assistant-chat-container #sqlbot-assistant-chat-iframe-${data.id} { - height:100%; - width:100%; - border: none; - } - #sqlbot-assistant #sqlbot-assistant-chat-container { - animation: appear .4s ease-in-out; - } - @keyframes appear { - from { - height: 0;; - } - - to { - height: 600px; - } - }`.replaceAll('#sqlbot-assistant ', `#${sqlbot_assistantId} `) - root.appendChild(style) - } - function getParam(src, key) { - const url = new URL(src) - return url.searchParams.get(key) - } - function parsrCertificate(config) { - const certificateList = config.certificate - if (!certificateList?.length) { - return null - } - const list = certificateList.map((item) => formatCertificate(item)).filter((item) => !!item) - return JSON.stringify(list) - } - function isEmpty(obj) { - return obj == null || typeof obj == 'undefined' - } - function formatCertificate(item) { - const { type, source, target, target_key, target_val } = item - let source_val = null - if (type.toLocaleLowerCase() == 'localstorage') { - source_val = localStorage.getItem(source) - } - if (type.toLocaleLowerCase() == 'sessionstorage') { - source_val = sessionStorage.getItem(source) - } - if (type.toLocaleLowerCase() == 'cookie') { - source_val = getCookie(source) - } - if (type.toLocaleLowerCase() == 'custom') { - source_val = source - } - if (isEmpty(source_val)) { - return null - } - return { - target, - key: target_key || source, - value: (target_val && eval(target_val)) || source_val, - } - } - function getCookie(key) { - if (!key || !document.cookie) { - return null - } - const cookies = document.cookie.split(';') - for (let i = 0; i < cookies.length; i++) { - const cookie = cookies[i].trim() - - if (cookie.startsWith(key + '=')) { - return decodeURIComponent(cookie.substring(key.length + 1)) - } - } - return null - } - function registerMessageEvent(id, data) { - const iframe = document.getElementById(`sqlbot-assistant-chat-iframe-${id}`) - const url = iframe.src - const eventName = 'sqlbot_assistant_event' - window.addEventListener('message', (event) => { - if (event.data?.eventName === eventName) { - if (event.data?.messageId !== id) { - return - } - if (event.data?.busi == 'ready' && event.data?.ready) { - params = { - eventName, - messageId: id, - hostOrigin: window.location.origin, - } - if (data.type === 1) { - const certificate = parsrCertificate(data) - params['busi'] = 'certificate' - params['certificate'] = certificate - } - const contentWindow = iframe.contentWindow - contentWindow.postMessage(params, url) - } - } - }) - } - function loadScript(src, id) { - const domain_url = getDomain(src) - const online = getParam(src, 'online') - const userFlag = getParam(src, 'userFlag') - const history = getParam(src, 'history') - let url = `${domain_url}/api/v1/system/assistant/info/${id}` - if (domain_url.includes('5173')) { - url = url.replace('5173', '8000') - } - fetch(url) - .then((response) => response.json()) - .then((res) => { - if (!res.data) { - throw new Error(res) - } - const data = res.data - const config_json = data.configuration - let tempData = Object.assign(defaultData, data) - if (tempData.configuration) { - delete tempData.configuration - } - if (config_json) { - const config = JSON.parse(config_json) - if (config) { - delete config.id - tempData = Object.assign(tempData, config) - } - } - tempData['id'] = id - tempData['domain_url'] = domain_url - - if (tempData['float_icon'] && !tempData['float_icon'].startsWith('http://')) { - tempData['float_icon'] = - `${domain_url}/api/v1/system/assistant/picture/${tempData['float_icon']}` - - if (domain_url.includes('5173')) { - tempData['float_icon'] = tempData['float_icon'].replace('5173', '8000') - } - } - - tempData['online'] = online && online.toString().toLowerCase() == 'true' - tempData['userFlag'] = userFlag - tempData['history'] = history - initsqlbot_assistant(tempData) - registerMessageEvent(id, tempData) - }) - .catch((e) => { - showMsg('嵌入失败', e.message) - }) - } - function getDomain(src) { - return src.substring(0, src.indexOf('/assistant.js')) - } - function init() { - const sqlbotScripts = document.querySelectorAll(`script[id^="${script_id_prefix}"]`) - const scriptsArray = Array.from(sqlbotScripts) - const src_list = scriptsArray.map((script) => script.src) - src_list.forEach((src) => { - const id = getParam(src, 'id') - window.sqlbot_assistant_handler[id] = window.sqlbot_assistant_handler[id] || {} - window.sqlbot_assistant_handler[id]['id'] = id - const propName = script_id_prefix + id + '-state' - if (window[propName]) { - return true - } - window[propName] = true - loadScript(src, id) - expposeGlobalMethods(id) - }) - } - - function showMsg(title, content) { - // 检查并创建容器(如果不存在) - let container = document.getElementById('messageContainer') - if (!container) { - container = document.createElement('div') - container.id = 'messageContainer' - container.style.position = 'fixed' - container.style.bottom = '20px' - container.style.right = '20px' - container.style.zIndex = '1000' - document.body.appendChild(container) - } else { - // 如果容器已存在,先移除旧弹窗 - const oldMessage = container.querySelector('div') - if (oldMessage) { - oldMessage.style.transform = 'translateX(120%)' - oldMessage.style.opacity = '0' - setTimeout(() => { - container.removeChild(oldMessage) - }, 300) - } - } - - // 创建弹窗元素 - const messageBox = document.createElement('div') - messageBox.style.width = '240px' - messageBox.style.minHeight = '100px' - messageBox.style.background = 'linear-gradient(135deg, #ff6b6b, #ff8e8e)' - messageBox.style.borderRadius = '8px' - messageBox.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.15)' - messageBox.style.padding = '15px' - messageBox.style.color = 'white' - messageBox.style.fontFamily = 'Arial, sans-serif' - messageBox.style.display = 'flex' - messageBox.style.flexDirection = 'column' - messageBox.style.transform = 'translateX(120%)' - messageBox.style.transition = 'transform 0.3s ease-out' - messageBox.style.opacity = '0' - messageBox.style.transition = 'opacity 0.3s ease, transform 0.3s ease' - messageBox.style.overflow = 'hidden' - - // 创建标题元素 - const titleElement = document.createElement('div') - titleElement.style.fontSize = '18px' - titleElement.style.fontWeight = 'bold' - titleElement.style.marginBottom = '10px' - titleElement.style.borderBottom = '1px solid rgba(255, 255, 255, 0.3)' - titleElement.style.paddingBottom = '8px' - titleElement.textContent = title - - // 创建内容元素 - const contentElement = document.createElement('div') - contentElement.style.fontSize = '14px' - contentElement.style.flexGrow = '1' - contentElement.style.overflow = 'auto' - contentElement.textContent = content - - // 组装元素 - messageBox.appendChild(titleElement) - messageBox.appendChild(contentElement) - - // 添加到容器 - container.appendChild(messageBox) - - // 触发显示动画 - setTimeout(() => { - messageBox.style.transform = 'translateX(0)' - messageBox.style.opacity = '1' - }, 10) - - // 3秒后自动隐藏 - setTimeout(() => { - messageBox.style.transform = 'translateX(120%)' - messageBox.style.opacity = '0' - setTimeout(() => { - container.removeChild(messageBox) - // 如果容器是空的,也移除容器 - if (container.children.length === 0) { - document.body.removeChild(container) - } - }, 300) - }, 5000) - } - - /* function hideMsg() { - const container = document.getElementById('messageContainer'); - if (container) { - const messageBox = container.querySelector('div'); - if (messageBox) { - messageBox.style.transform = 'translateX(120%)'; - messageBox.style.opacity = '0'; - setTimeout(() => { - container.removeChild(messageBox); - // 如果容器是空的,也移除容器 - if (container.children.length === 0) { - document.body.removeChild(container); - } - }, 300); - } - } - } */ - - function updateParam(target_url, key, newValue) { - try { - const url = new URL(target_url) - const [hashPath, hashQuery] = url.hash.split('?') - let searchParams - if (hashQuery) { - searchParams = new URLSearchParams(hashQuery) - } else { - searchParams = url.searchParams - } - searchParams.set(key, newValue) - if (hashQuery) { - url.hash = `${hashPath}?${searchParams.toString()}` - } else { - url.search = searchParams.toString() - } - return url.toString() - } catch (e) { - console.error('Invalid URL:', target_url) - return target_url - } - } - function expposeGlobalMethods(id) { - window.sqlbot_assistant_handler[id]['setOnline'] = (online) => { - if (online != null && typeof online != 'boolean') { - throw new Error('The parameter can only be of type boolean') - } - const iframe = document.getElementById(`sqlbot-assistant-chat-iframe-${id}`) - if (iframe) { - const url = iframe.src - const eventName = 'sqlbot_assistant_event' - const params = { - busi: 'setOnline', - online, - eventName, - messageId: id, - } - const contentWindow = iframe.contentWindow - contentWindow.postMessage(params, url) - } - } - window.sqlbot_assistant_handler[id]['refresh'] = (online, userFlag) => { - if (online != null && typeof online != 'boolean') { - throw new Error('The parameter can only be of type boolean') - } - const iframe = document.getElementById(`sqlbot-assistant-chat-iframe-${id}`) - if (iframe) { - const url = iframe.src - let new_url = updateParam(url, 't', Date.now()) - if (online != null) { - new_url = updateParam(new_url, 'online', online) - } - if (userFlag != null) { - new_url = updateParam(new_url, 'userFlag', userFlag) - } - iframe.src = 'about:blank' - setTimeout(() => { - iframe.src = new_url - }, 500) - } - } - window.sqlbot_assistant_handler[id]['destroy'] = () => { - const sqlbot_root_id = 'sqlbot-assistant-root-' + id - const container_div = document.getElementById(sqlbot_root_id) - if (container_div) { - const root_div = container_div.parentNode - if (root_div?.parentNode) { - root_div.parentNode.removeChild(root_div) - } - } - - const scriptDom = document.getElementById(`sqlbot-assistant-float-script-${id}`) - if (scriptDom) { - scriptDom.parentNode.removeChild(scriptDom) - } - const propName = script_id_prefix + id + '-state' - if (window[propName]) { - delete window[propName] - } - delete window.sqlbot_assistant_handler[id] - } - window.sqlbot_assistant_handler[id]['setHistory'] = (show) => { - if (show != null && typeof show != 'boolean') { - throw new Error('The parameter can only be of type boolean') - } - const iframe = document.getElementById(`sqlbot-assistant-chat-iframe-${id}`) - if (iframe) { - const url = iframe.src - const eventName = 'sqlbot_assistant_event' - const params = { - busi: 'setHistory', - show, - eventName, - messageId: id, - } - const contentWindow = iframe.contentWindow - contentWindow.postMessage(params, url) - } - } - window.sqlbot_assistant_handler[id]['createConversation'] = (param) => { - const iframe = document.getElementById(`sqlbot-assistant-chat-iframe-${id}`) - if (iframe) { - const url = iframe.src - const eventName = 'sqlbot_assistant_event' - const params = { - busi: 'createConversation', - param, - eventName, - messageId: id, - } - const contentWindow = iframe.contentWindow - contentWindow.postMessage(params, url) - } - } - } - // window.addEventListener('load', init) - const executeWhenReady = (fn) => { - if ( - document.readyState === 'complete' || - (document.readyState !== 'loading' && !document.documentElement.doScroll) - ) { - setTimeout(fn, 0) - } else { - const onReady = () => { - document.removeEventListener('DOMContentLoaded', onReady) - window.removeEventListener('load', onReady) - fn() - } - document.addEventListener('DOMContentLoaded', onReady) - window.addEventListener('load', onReady) - } - } - - executeWhenReady(init) -})() diff --git a/frontend/public/swagger-ui-bundle.js b/frontend/public/swagger-ui-bundle.js index cdbe29ace..dcfadae0b 100644 --- a/frontend/public/swagger-ui-bundle.js +++ b/frontend/public/swagger-ui-bundle.js @@ -41749,7 +41749,8 @@ const s = new Error(a.statusText || `response status is ${a.status}`) // if (a.status === 401 && a.statusText === 'Unauthorized') { if (a.status === 401) { - location.href = location.pathname.replace('/docs', '/') + '#/401?title=unauthorized&target=docs' + location.href = + location.pathname.replace('/docs', '/') + '#/401?title=unauthorized&target=docs' return a } throw ((s.status = a.status), (s.statusCode = a.status), (s.response = a), s) @@ -56482,8 +56483,15 @@ const a = o.getConfigs().withCredentials o.fn.fetch.withCredentials = a }) + function getTokenKey() { + var pathname = window.location.pathname.replace('docs', '') + var match = pathname.match(/^\/([^\/]+)/) + var prefix = match ? `${match[1]}_` : 'sqlbot_v1_' + return `${prefix}user.token` + } function get_token() { - var tokenCache = localStorage.getItem('user.token') + var token_key = getTokenKey() + var tokenCache = localStorage.getItem(token_key) if (!tokenCache) { return null } diff --git a/frontend/src/api/assistant.ts b/frontend/src/api/assistant.ts index bb955e09f..b883976f0 100644 --- a/frontend/src/api/assistant.ts +++ b/frontend/src/api/assistant.ts @@ -6,6 +6,6 @@ export const assistantApi = { add: (data: any) => request.post('/system/assistant', data), edit: (data: any) => request.put('/system/assistant', data), delete: (id: number) => request.delete(`/system/assistant/${id}`), - query: (id: number) => request.get(`/system/assistant/${id}`), + // query: (id: number) => request.get(`/system/assistant/${id}`), validate: (data: any) => request.get('/system/assistant/validator', { params: data }), } diff --git a/frontend/src/api/chat.ts b/frontend/src/api/chat.ts index 2a5870632..4a52187ee 100644 --- a/frontend/src/api/chat.ts +++ b/frontend/src/api/chat.ts @@ -474,7 +474,7 @@ export const chatApi = { return request.post('/chat/rename', { id: chat_id, brief: brief }) }, deleteChat: (id: number | undefined, brief: any): Promise => { - return request.delete(`/chat/${id}/${brief}`) + return request.delete(`/chat/${id}`, { data: { id: id, brief: brief } }) }, analysis: (record_id: number | undefined, controller?: AbortController) => { return request.fetchStream(`/chat/record/${record_id}/analysis`, {}, controller) diff --git a/frontend/src/api/datasource.ts b/frontend/src/api/datasource.ts index 7f2fb782b..fe5bf9a78 100644 --- a/frontend/src/api/datasource.ts +++ b/frontend/src/api/datasource.ts @@ -6,6 +6,7 @@ export const datasourceApi = { relationGet: (id: any) => request.post(`/table_relation/get/${id}`), relationSave: (dsId: any, data: any) => request.post(`/table_relation/save/${dsId}`, data), add: (data: any) => request.post('/datasource/add', data), + importToDb: (data: any) => request.post('/datasource/importToDb', data), list: () => request.get('/datasource/list'), update: (data: any) => request.post('/datasource/update', data), delete: (id: number, name: string) => request.post(`/datasource/delete/${id}/${name}`), diff --git a/frontend/src/api/embedded.ts b/frontend/src/api/embedded.ts index 0733088ae..2e51008ae 100644 --- a/frontend/src/api/embedded.ts +++ b/frontend/src/api/embedded.ts @@ -5,7 +5,7 @@ export const getAdvancedApplicationList = () => request.get('/system/assistant/advanced_application') export const updateAssistant = (data: any) => request.put('/system/assistant', data) export const saveAssistant = (data: any) => request.post('/system/assistant', data) -export const getOne = (id: any) => request.get(`/system/assistant/${id}`) +// export const getOne = (id: any) => request.get(`/system/assistant/${id}`) export const delOne = (id: any) => request.delete(`/system/assistant/${id}`) export const dsApi = (id: any) => request.get(`/datasource/ws/${id}`) diff --git a/frontend/src/api/system.ts b/frontend/src/api/system.ts index f9ed3ec10..4d56fecb1 100644 --- a/frontend/src/api/system.ts +++ b/frontend/src/api/system.ts @@ -27,6 +27,8 @@ export const modelApi = { query: (id: number) => request.get(`/system/aimodel/${id}`), setDefault: (id: number) => request.put(`/system/aimodel/default/${id}`), check: (data: any) => request.fetchStream('/system/aimodel/status', data), - platform: (id: number) => request.get(`/system/platform/org/${id}`), + platform: (id: number, lazy?: number, pid?: string) => + request.post(`/system/platform/org/${id}`, { lazy, pid }), userSync: (data: any) => request.post(`/system/platform/user/sync`, data), + list_by_ws: () => request.get(`/system/aimodel/list/by_ws`), } diff --git a/frontend/src/api/workspace.ts b/frontend/src/api/workspace.ts index 5298045b5..46637678c 100644 --- a/frontend/src/api/workspace.ts +++ b/frontend/src/api/workspace.ts @@ -15,3 +15,12 @@ export const workspaceDelete = (id: any) => request.delete(`/system/workspace/${ export const workspaceList = () => request.get('/system/workspace') export const workspaceDetail = (id: any) => request.get(`/system/workspace/${id}`) export const uwsOption = (params: any) => request.get('system/workspace/uws/option', { params }) + +export const workspaceModelMapping = (aiModelId: any) => + request.get(`/system/aimodel/${aiModelId}/ws_mapping`) +export const workspaceModelMappingUpdate = (aiModelId: any, data: any) => + request.put(`/system/aimodel/${aiModelId}/ws_mapping`, data) +export const workspaceModelMappingAdd = (aiModelId: any, data: any) => + request.post(`/system/aimodel/${aiModelId}/ws_mapping`, data) +export const workspaceModelMappingDelete = (aiModelId: any, data: any) => + request.delete(`/system/aimodel/${aiModelId}/ws_mapping`, { data }) diff --git a/frontend/src/assets/datasource/icon_hive.png b/frontend/src/assets/datasource/icon_hive.png new file mode 100644 index 000000000..51b996f5e Binary files /dev/null and b/frontend/src/assets/datasource/icon_hive.png differ diff --git a/frontend/src/assets/datasource/icon_sqlite.png b/frontend/src/assets/datasource/icon_sqlite.png new file mode 100644 index 000000000..1a198d0b4 Binary files /dev/null and b/frontend/src/assets/datasource/icon_sqlite.png differ diff --git a/frontend/src/assets/model/icon_minimax_colorful.png b/frontend/src/assets/model/icon_minimax_colorful.png new file mode 100644 index 000000000..f9dd9aaeb Binary files /dev/null and b/frontend/src/assets/model/icon_minimax_colorful.png differ diff --git a/frontend/src/assets/svg/chart/icon-thousand-separator.svg b/frontend/src/assets/svg/chart/icon-thousand-separator.svg new file mode 100644 index 000000000..670e86901 --- /dev/null +++ b/frontend/src/assets/svg/chart/icon-thousand-separator.svg @@ -0,0 +1,6 @@ + + + diff --git a/frontend/src/assets/svg/icon_block_outlined.svg b/frontend/src/assets/svg/icon_block_outlined.svg new file mode 100644 index 000000000..ffefda2c0 --- /dev/null +++ b/frontend/src/assets/svg/icon_block_outlined.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/src/assets/svg/icon_describe_outlined.svg b/frontend/src/assets/svg/icon_describe_outlined.svg new file mode 100644 index 000000000..7dc0951b4 --- /dev/null +++ b/frontend/src/assets/svg/icon_describe_outlined.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/svg/icon_expand-left_filled.svg b/frontend/src/assets/svg/icon_expand-left_filled.svg new file mode 100644 index 000000000..63b0ea82d --- /dev/null +++ b/frontend/src/assets/svg/icon_expand-left_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/svg/icon_expand-right_filled.svg b/frontend/src/assets/svg/icon_expand-right_filled.svg index e1e788fa5..58b34d86a 100644 --- a/frontend/src/assets/svg/icon_expand-right_filled.svg +++ b/frontend/src/assets/svg/icon_expand-right_filled.svg @@ -1,3 +1,3 @@ - - + + diff --git a/frontend/src/assets/svg/workspace-white.svg b/frontend/src/assets/svg/workspace-white.svg new file mode 100644 index 000000000..7e9cac0a5 --- /dev/null +++ b/frontend/src/assets/svg/workspace-white.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/components/Language-selector/index.vue b/frontend/src/components/Language-selector/index.vue index 8b21e4904..7ba9b37cf 100644 --- a/frontend/src/components/Language-selector/index.vue +++ b/frontend/src/components/Language-selector/index.vue @@ -34,6 +34,7 @@ const userStore = useUserStore() const languageOptions = computed(() => [ { value: 'en', label: t('common.english') }, { value: 'zh-CN', label: t('common.simplified_chinese') }, + { value: 'zh-TW', label: t('common.traditional_chinese') }, { value: 'ko-KR', label: t('common.korean') }, ]) diff --git a/frontend/src/components/drawer-filter/src/DrawerEnumFilter.vue b/frontend/src/components/drawer-filter/src/DrawerEnumFilter.vue index a7cae5c5d..262b5c710 100644 --- a/frontend/src/components/drawer-filter/src/DrawerEnumFilter.vue +++ b/frontend/src/components/drawer-filter/src/DrawerEnumFilter.vue @@ -84,7 +84,7 @@ useEmitt({ padding: 1px 6px; background: var(--deTextPrimary5, #f5f6f7); color: var(--deTextPrimary, #1f2329); - border-radius: 4px; + border-radius: 6px; cursor: pointer; display: inline-block; margin-bottom: 12px; diff --git a/frontend/src/components/filter-text/src/FilterText.vue b/frontend/src/components/filter-text/src/FilterText.vue index 4eedaa269..d423543d4 100644 --- a/frontend/src/components/filter-text/src/FilterText.vue +++ b/frontend/src/components/filter-text/src/FilterText.vue @@ -181,7 +181,7 @@ watch( .arrow-filter:hover { background: rgba(31, 35, 41, 0.1); - border-radius: 4px; + border-radius: 6px; } .ed-icon-arrow-right.arrow-filter { diff --git a/frontend/src/components/layout/Apikey.vue b/frontend/src/components/layout/Apikey.vue index d55e37a16..ee17bd56c 100644 --- a/frontend/src/components/layout/Apikey.vue +++ b/frontend/src/components/layout/Apikey.vue @@ -38,7 +38,7 @@ const handleAdd = () => { const pwd = ref('**********') const toApiDoc = () => { console.log('Add API Key') - const url = '/docs' + const url = './docs' window.open(url, '_blank') } diff --git a/frontend/src/components/layout/Person.vue b/frontend/src/components/layout/Person.vue index a683ab1dc..a58a02afb 100644 --- a/frontend/src/components/layout/Person.vue +++ b/frontend/src/components/layout/Person.vue @@ -58,6 +58,10 @@ const languageList = computed(() => [ name: '简体中文', value: 'zh-CN', }, + { + name: '繁體中文', + value: 'zh-TW', + }, { name: '한국인', value: 'ko-KR', @@ -341,7 +345,7 @@ const logout = async () => { position: relative; cursor: pointer; margin: 0 4px; - border-radius: 4px; + border-radius: 6px; &:hover { background-color: #1f23291a; } @@ -378,7 +382,7 @@ const logout = async () => { padding-right: 8px; margin-bottom: 2px; position: relative; - border-radius: 4px; + border-radius: 6px; cursor: pointer; &:not(.empty):hover { background: #1f23291a; diff --git a/frontend/src/components/layout/Workspace.vue b/frontend/src/components/layout/Workspace.vue index 1f76e92ad..456c083cd 100644 --- a/frontend/src/components/layout/Workspace.vue +++ b/frontend/src/components/layout/Workspace.vue @@ -198,7 +198,7 @@ onMounted(async () => { padding-right: 8px; margin-bottom: 2px; position: relative; - border-radius: 4px; + border-radius: 6px; cursor: pointer; &:not(.empty):hover { background: #1f23291a; diff --git a/frontend/src/components/layout/index.vue b/frontend/src/components/layout/index.vue index f83ef983a..4a0097e41 100644 --- a/frontend/src/components/layout/index.vue +++ b/frontend/src/components/layout/index.vue @@ -362,7 +362,7 @@ onMounted(() => { column-gap: 12px; align-items: center; padding: 8px 16px; - border-radius: 4px; + border-radius: 6px; cursor: pointer; border: none; font-weight: 500; @@ -493,7 +493,7 @@ onMounted(() => { column-gap: 12px; align-items: center; padding: 8px 16px; - border-radius: 4px; + border-radius: 6px; cursor: pointer; border: none; font-weight: 500; diff --git a/frontend/src/entity/supplier.ts b/frontend/src/entity/supplier.ts index 733cde9f4..bd4ddc971 100644 --- a/frontend/src/entity/supplier.ts +++ b/frontend/src/entity/supplier.ts @@ -10,6 +10,7 @@ import icon_txhy_colorful from '@/assets/model/icon_txhy_colorful.png' import icon_hsyq_colorful from '@/assets/model/icon_hsyq_colorful.png' // import icon_vllm_colorful from '@/assets/model/icon_vllm_colorful.png' import icon_common_openai from '@/assets/model/icon_common_openai.png' +import icon_minimax_colorful from '@/assets/model/icon_minimax_colorful.png' // import icon_azure_openAI_colorful from '@/assets/model/icon_Azure_OpenAI_colorful.png' type ModelArg = { key: string; val?: string | number; type: string; range?: string } @@ -45,6 +46,10 @@ export const supplierList: Array<{ { key: 'extra_body', val: '{"enable_thinking": false}', type: 'json' }, ], model_options: [ + { name: 'qwen3.6-plus' }, + { name: 'qwen3.5-plus' }, + { name: 'qwen3.5-flash' }, + { name: 'qwen3-coder-next' }, { name: 'qwen3-coder-plus' }, { name: 'qwen3-coder-flash' }, { name: 'qwen-plus' }, @@ -271,11 +276,24 @@ export const supplierList: Array<{ { name: 'doubao-1-5-pro-32k-character-250715' }, { name: 'kimi-k2-250711' }, { name: 'deepseek-v3-250324' }, - { name: 'deepseek-r1' }, + { name: 'deepseek-v4-pro-260425' }, ], }, }, }, + { + id: 13, + name: 'MiniMax', + i18nKey: 'supplier.minimax', + icon: icon_minimax_colorful, + model_config: { + 0: { + api_domain: 'https://api.minimax.io/v1', + common_args: [{ key: 'temperature', val: 0.7, type: 'number', range: '[0, 1]' }], + model_options: [{ name: 'MiniMax-M3' }, { name: 'MiniMax-M2.7' }], + }, + }, + }, /* { id: 11, name: 'vLLM', diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index c9db6e6ca..4cdef7da8 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -32,9 +32,24 @@ "enter_variable_name": "Please Enter Variable Name", "enter_variable_value": "Please Enter Variable Value" }, + "authorized_space": { + "authorized_space": "Authorized Space", + "authorized_space_list": "Authorized Space List", + "select_space": "Select Space", + "modify_authorized_space": "Modify Authorized Space", + "workspaces_authorized": "Authorized {num} Workspaces", + "number_of_members": "Number of Members", + "delete_selected_workspaces": "Are you sure you want to delete the selected {msg} workspaces?", + "delete_workspace": "Are you sure you want to delete the workspace: {msg}?", + "no_workspace": "No Workspaces" + }, "sync": { + "records": "Displaying {num} records out of {total}", + "confirm_upload": "Confirm Upload", + "field_details": "Field Details", "integration": "Platform integration needs to be enabled.", "the_existing_user": "If the user already exists, overwrite the existing user.", + "lazy_load": "Lazy Load", "sync_users": "Sync Users", "sync_wechat_users": "Sync WeChat Users", "sync_dingtalk_users": "Sync DingTalk Users", @@ -61,12 +76,16 @@ "context_record_count": "Context Record Count", "context_record_count_hint": "Number of user question rounds", "model_thinking_process": "Expand Model Thinking Process", + "hide_model_thinking_process": "Hide Model Thinking Process", "rows_of_data": "Limit 1000 Rows of Data", - "third_party_platform_settings": "Third-Party Platform Settings", - "by_third_party_platform": "Automatic User Creation by Third-Party Platform", - "platform_user_organization": "Third-Party Platform Workspace", + "third_party_platform_settings": "Authentication Settings", + "by_third_party_platform": "Automatic User Creation", + "platform_user_organization": "Default Workspace", "platform_user_roles": "Third-Party Platform User Roles", "excessive_data_volume": "Disabling the 1000-row data limit may cause system lag due to excessive data volume.", + "sqlbot_name": "Data Query Assistant Name", + "show_sql": "Allow Viewing SQL Statements", + "show_log": "Show Execution Log", "prompt": "Prompt", "disabling_successfully": "Disabling Successfully", "closed_by_default": "In the Question Count window, control whether the model thinking process is expanded or closed by default.", @@ -172,6 +191,7 @@ "system_manage": "System Management", "update_success": "Update", "save_success": "Save Successful", + "operation_success": "Operation successful", "next": "Next", "save": "Save", "logout": "Logout", @@ -391,9 +411,14 @@ }, "timeout": "Connection Timeout(second)", "address": "Address", - "low_version": "Compatible with lower versions" + "low_version": "Compatible with lower versions", + "ssl": "Enable SSL", + "file_path": "File Path", + "pool_size": "Connection Pool Size" }, - "sync_fields": "Sync Fields" + "sync_fields": "Sync Fields", + "sync_fields_success": "Sync fields successfully", + "sync_fields_failed": "Sync fields failed" }, "datasource": { "recommended_question": "Recommended Question", @@ -441,6 +466,9 @@ "get_schema": "Get Schema" }, "model": { + "int": "Integer", + "float": "Float", + "string": "String", "default_model": "Default model", "model_type": "Model type", "basic_model": "Basic model", @@ -558,6 +586,7 @@ "member_feng_yibudao": "Do you want to remove the member: {msg}?", "select_member": "Select member", "selected_2_people": "Selected: {msg} people", + "selected_number": "Selected: {msg} items", "clear": "Clear", "historical_dialogue": "No historical dialogue", "rename_a_workspace": "Rename a workspace", @@ -658,6 +687,10 @@ "application_name": "Application name", "application_description": "Application description", "cross_domain_settings": "Cross-domain settings", + "enableCustomModel": "Use specified model", + "useModel": "Use model", + "defaultModel": "Default model", + "customModel": "Specified model", "third_party_address": "Please enter the embedded third party address,multiple items separated by semicolons", "set_to_private": "Set as private", "set_to_public": "Set as public", @@ -668,7 +701,7 @@ "interface_url": "Interface URL", "format_is_incorrect": "format is incorrect{msg}", "domain_format_incorrect": ", start with http:// or https://, no trailing slash (/), multiple domains separated by semicolons", - "interface_url_incorrect": ",enter a relative path starting with /", + "interface_url_incorrect": ", enter a relative path starting with /, or an absolute path starting with http:// or https://", "aes_enable": "Enable AES encryption", "aes_enable_tips": "The fields (host, user, password, dataBase, schema) are all encrypted using the AES-CBC-PKCS5Padding encryption method", "bit": "bit", @@ -708,7 +741,7 @@ "display_settings": "Display Settings", "header_text_color": "Header Text Color", "app_logo": "App Logo", - "maximum_size_10mb": "Recommended size: 32 x 32, supports JPG, PNG, and SVG, maximum size: 10MB", + "maximum_size_10mb": "Recommended size: 32 x 32, supports JPG, PNG, maximum size: 10MB", "replace": "Replace", "default_icon_position": "Default Icon Position", "draggable_position": "Draggable Position", @@ -757,6 +790,8 @@ "no_data": "No Data", "loading_data": "Loading ...", "show_error_detail": "Show error info", + "thousands_separator_setting": "Thousands Separator Setting", + "thousands_separator_display": "Apply Thousands Separator Display", "log": { "GENERATE_SQL": "Generate SQL", "GENERATE_CHART": "Generate Chart Structure", @@ -821,11 +856,11 @@ "website_logo": "Website Logo", "tab": "Tab", "replace_image": "Replace Image", - "larger_than_200kb": "Logo displayed at the top of the website: Recommended size: 48 x 48 pixels, supports JPG, PNG, and SVG, and no larger than 200KB", + "larger_than_200kb": "Logo displayed at the top of the website: Recommended size: 48 x 48 pixels, supports JPG, PNG, and no larger than 200KB", "login_logo": "System Logo", - "larger_than_200kb_de": "Logo on the right side of the login page: Recommended size: 204 x 52 pixels, supports JPG, PNG, and SVG, and no larger than 200KB", + "larger_than_200kb_de": "Logo on the right side of the login page: Recommended size: 204 x 52 pixels, supports JPG, PNG, and no larger than 200KB", "login_background_image": "Login Background Image", - "larger_than_5mb": "Background image on the left: Recommended size: 576 x 900 for vector images, 1152 x 1800 for bitmap images, supports JPG, PNG, and SVG, and no larger than 5MB", + "larger_than_5mb": "Background image on the left: Recommended size: 576 x 900 for vector images, 1152 x 1800 for bitmap images, supports JPG, PNG, and no larger than 5MB", "website_name": "Website Name", "on_webpage_tabs": "Platform name displayed on webpage tabs", "welcome_message": "Display a welcome message", @@ -915,6 +950,7 @@ "kimi": "Kimi", "tencent_cloud": "Tencent Cloud", "volcano_engine": "Volcano Engine", + "minimax": "MiniMax", "generic_openai": "Generic OpenAI" }, "modelType": { diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts index df718e652..c06dfcd98 100644 --- a/frontend/src/i18n/index.ts +++ b/frontend/src/i18n/index.ts @@ -1,16 +1,42 @@ import { createI18n } from 'vue-i18n' import en from './en.json' import zhCN from './zh-CN.json' +import zhTw from './zh-TW.json' import koKR from './ko-KR.json' import elementEnLocale from 'element-plus-secondary/es/locale/lang/en' import elementZhLocale from 'element-plus-secondary/es/locale/lang/zh-cn' +import elementTwLocale from 'element-plus-secondary/es/locale/lang/zh-tw' import { useCache } from '@/utils/useCache' import { getBrowserLocale } from '@/utils/utils' const elementKoLocale = elementEnLocale const { wsCache } = useCache() +const isEmbeddedRoute = () => { + const hash = window.location.hash + if (!hash) return false + const hashPath = hash.substring(1).split('?')[0] + return ['/assistant', '/embeddedPage', '/embeddedCommon'].includes(hashPath) +} + +const getUrlLang = () => { + try { + const hash = window.location.hash + if (!hash) return null + const hashQuery = hash.substring(1).split('?')[1] + if (!hashQuery) return null + return new URLSearchParams(hashQuery).get('lang') + } catch { + return null + } +} + const getDefaultLocale = () => { + // 嵌入式页面的 URL lang 参数(第三种国际化渠道),优先级最高 + const urlLang = getUrlLang() + if (urlLang && isEmbeddedRoute()) { + return urlLang + } return wsCache.get('user.language') || getBrowserLocale() || 'zh-CN' } @@ -23,6 +49,10 @@ const messages = { ...zhCN, el: elementZhLocale, }, + 'zh-TW': { + ...zhTw, + el: elementTwLocale, + }, 'ko-KR': { ...koKR, el: elementKoLocale, @@ -40,6 +70,7 @@ export const i18n = createI18n({ const elementLocales = { en: elementEnLocale, 'zh-CN': elementZhLocale, + 'zh-TW': elementTwLocale, 'ko-KR': elementKoLocale, } as const diff --git a/frontend/src/i18n/ko-KR.json b/frontend/src/i18n/ko-KR.json index f1241465c..100efe262 100644 --- a/frontend/src/i18n/ko-KR.json +++ b/frontend/src/i18n/ko-KR.json @@ -7,22 +7,6 @@ "AI Model Configuration": "모델 구성", "Details": "세부" }, - "sync": { - "integration": "플랫폼 통합을 활성화해야 합니다.", - "the_existing_user": "해당 사용자가 이미 존재하는 경우 기존 사용자를 덮어씁니다.", - "sync_users": "사용자 동기화", - "sync_wechat_users": "위챗 사용자 동기화", - "sync_dingtalk_users": "딩톡 사용자 동기화", - "sync_lark_users": "라크 사용자 동기화", - "sync_complete": "동기화 완료", - "synced_10_users": "{num}명 동기화 성공", - "failed_3_users": "{failed}명 동기화 성공, {success}명 동기화 실패", - "download_failure_list": "실패 목록 다운로드", - "sync_failed": "동기화 실패", - "failed_10_users": "{failed}명 동기화 실패", - "continue_syncing": "동기화 계속", - "return_to_view": "화면으로 돌아가기" - }, "variables": { "please_select_date": "날짜를 선택하십시오", "number_variable_error": "올바른 숫자 범위를 입력하십시오.", @@ -48,6 +32,37 @@ "enter_variable_name": "변수 이름을 입력하세요", "enter_variable_value": "변수 값을 입력하세요" }, + "authorized_space": { + "authorized_space": "권한 있는 공간", + "authorized_space_list": "권한 있는 공간 목록", + "select_space": "공간 선택", + "modify_authorized_space": "권한 있는 공간 수정", + "workspaces_authorized": "{num}개의 권한 있는 작업 공간", + "number_of_members": "회원 수", + "delete_selected_workspaces": "선택한 {msg}개의 작업 공간을 삭제하시겠습니까?", + "delete_workspace": "작업 공간을 삭제하시겠습니까: {msg}?", + "no_workspace": "작업 공간 없음" + }, + "sync": { + "records": "{total}개 중 {num}개의 레코드 표시", + "confirm_upload": "업로드 확인", + "field_details": "필드 세부 정보", + "integration": "플랫폼 통합을 활성화해야 합니다.", + "the_existing_user": "해당 사용자가 이미 존재하는 경우 기존 사용자를 덮어씁니다.", + "lazy_load": "지연 로딩", + "sync_users": "사용자 동기화", + "sync_wechat_users": "위챗 사용자 동기화", + "sync_dingtalk_users": "딩톡 사용자 동기화", + "sync_lark_users": "라크 사용자 동기화", + "sync_complete": "동기화 완료", + "synced_10_users": "{num}명 동기화 성공", + "failed_3_users": "{failed}명 동기화 성공, {success}명 동기화 실패", + "download_failure_list": "실패 목록 다운로드", + "sync_failed": "동기화 실패", + "failed_10_users": "{failed}명 동기화 실패", + "continue_syncing": "동기화 계속", + "return_to_view": "화면으로 돌아가기" + }, "parameter": { "execution_details": "실행 세부 정보", "overview": "개요", @@ -61,12 +76,16 @@ "context_record_count": "컨텍스트 기록 수", "context_record_count_hint": "사용자 질문 라운드 수", "model_thinking_process": "모델 사고 프로세스 확장", + "hide_model_thinking_process": "모델 사고 과정 숨기기", "rows_of_data": "데이터 1,000행 제한", - "third_party_platform_settings": "타사 플랫폼 설정", - "by_third_party_platform": "타사 플랫폼에 의한 자동 사용자 생성", - "platform_user_organization": "제3자 플랫폼 작업 공간", + "third_party_platform_settings": "로그인 인증 설정", + "by_third_party_platform": "자동 사용자 생성", + "platform_user_organization": "기본 작업 공간", "platform_user_roles": "타사 플랫폼 사용자 역할", "excessive_data_volume": "1,000행 데이터 제한을 비활성화하면 과도한 데이터 양으로 인해 시스템 지연이 발생할 수 있습니다.", + "sqlbot_name": "데이터 질의 도우미 이름", + "show_sql": "SQL 문 보기 허용", + "show_log": "실행 로그 표시", "prompt": "프롬프트", "disabling_successfully": "비활성화 완료", "closed_by_default": "질문 수 창에서 모델 사고 프로세스를 기본적으로 확장할지 또는 닫을지 여부를 제어합니다.", @@ -172,6 +191,7 @@ "system_manage": "시스템 관리", "update_success": "업데이트 성공", "save_success": "저장 성공", + "operation_success": "작업 성공", "next": "다음 단계", "save": "저장", "logout": "로그아웃", @@ -391,9 +411,14 @@ }, "timeout": "연결 시간 초과(초)", "address": "주소", - "low_version": "낮은 버전 호환" + "low_version": "낮은 버전 호환", + "ssl": "SSL 활성화", + "file_path": "파일 경로", + "pool_size": "연결 풀 크기" }, - "sync_fields": "동기화된 테이블 구조" + "sync_fields": "동기화된 테이블 구조", + "sync_fields_success": "테이블 구조 동기화 성공", + "sync_fields_failed": "테이블 구조 동기화 실패" }, "datasource": { "recommended_question": "추천 질문", @@ -441,6 +466,9 @@ "get_schema": "스키마 가져오기" }, "model": { + "int": "정수", + "float": "소수", + "string": "문자열", "default_model": "기본 모델", "model_type": "모델 유형", "basic_model": "기본 모델", @@ -558,6 +586,7 @@ "member_feng_yibudao": "멤버를 제거하시겠습니까: {msg}?", "select_member": "멤버 선택", "selected_2_people": "선택됨: {msg}명", + "selected_number": "선택됨: {msg}개", "clear": "지우기", "historical_dialogue": "과거 대화가 없습니다", "rename_a_workspace": "작업 공간 이름 바꾸기", @@ -658,6 +687,10 @@ "application_name": "애플리케이션 이름", "application_description": "애플리케이션 설명", "cross_domain_settings": "교차 도메인 설정", + "enableCustomModel": "지정된 모델 사용", + "useModel": "사용 모델", + "defaultModel": "기본 모델", + "customModel": "지정 모델", "third_party_address": "임베디드할 제3자 주소를 입력하십시오, 여러 항목을 세미콜론으로 구분", "set_to_private": "비공개로 설정", "set_to_public": "공개로 설정", @@ -667,7 +700,7 @@ "interface_url": "인터페이스 URL", "format_is_incorrect": "형식이 올바르지 않습니다{msg}", "domain_format_incorrect": ", http:// 또는 https://로 시작해야 하며, 슬래시(/)로 끝날 수 없습니다. 여러 도메인은 세미콜론으로 구분합니다", - "interface_url_incorrect": ", 상대 경로를 입력해주세요. /로 시작합니다", + "interface_url_incorrect": ", 상대 경로는 /로 시작하거나, 절대 경로는 http:// 또는 https://로 시작하는 경로를 입력해 주세요.", "aes_enable": "AES 암호화 활성화", "aes_enable_tips": "암호화 필드 (host, user, password, dataBase, schema)는 모두 AES-CBC-PKCS5Padding 암호화 방식을 사용합니다", "bit": "비트", @@ -708,7 +741,7 @@ "display_settings": "표시 설정", "header_text_color": "헤더 텍스트 색상", "app_logo": "애플리케이션 로고", - "maximum_size_10mb": "권장 크기 32 x 32, JPG, PNG, SVG 지원, 크기 10MB 이하", + "maximum_size_10mb": "권장 크기 32 x 32, JPG, PNG 지원, 크기 10MB 이하", "replace": "교체", "default_icon_position": "아이콘 기본 위치", "draggable_position": "드래그 가능한 위치", @@ -757,6 +790,8 @@ "no_data": "데이터가 없습니다", "loading_data": "로딩 중 ...", "show_error_detail": "구체적인 정보 보기", + "thousands_separator_setting": "천 단위 구분 기호 설정", + "thousands_separator_display": "천 단위 구분 기호 표시 적용", "log": { "GENERATE_SQL": "SQL 생성", "GENERATE_CHART": "차트 구조 생성", @@ -821,11 +856,11 @@ "website_logo": "웹사이트 로고", "tab": "페이지 탭", "replace_image": "이미지 교체", - "larger_than_200kb": "상단 웹사이트에 표시되는 로고, 권장 크기 48 x 48, JPG, PNG, SVG 지원, 크기 200KB 이하", + "larger_than_200kb": "상단 웹사이트에 표시되는 로고, 권장 크기 48 x 48, JPG, PNG 지원, 크기 200KB 이하", "login_logo": "시스템 로고", - "larger_than_200kb_de": "로그인 페이지 오른쪽 로고, 권장 크기 204*52, JPG, PNG, SVG 지원, 크기 200KB 이하", + "larger_than_200kb_de": "로그인 페이지 오른쪽 로고, 권장 크기 204*52, JPG, PNG 지원, 크기 200KB 이하", "login_background_image": "로그인 배경 이미지", - "larger_than_5mb": "왼쪽 배경 이미지, 벡터 이미지 권장 크기 576*900, 비트맵 권장 크기 1152*1800; JPG, PNG, SVG 지원, 크기 5MB 이하", + "larger_than_5mb": "왼쪽 배경 이미지, 벡터 이미지 권장 크기 576*900, 비트맵 권장 크기 1152*1800; JPG, PNG 지원, 크기 5MB 이하", "website_name": "웹사이트 이름", "on_webpage_tabs": "웹페이지 탭에 표시되는 플랫폼 이름", "welcome_message": "환영 메시지 표시", @@ -915,6 +950,7 @@ "kimi": "Kimi", "tencent_cloud": "텐센트 클라우드", "volcano_engine": "볼케이노 엔진", + "minimax": "MiniMax", "generic_openai": "범용 OpenAI" }, "modelType": { @@ -946,4 +982,4 @@ "to_doc": "API 보기", "trigger_limit": "최대 {0}개의 API 키 생성 지원" } -} \ No newline at end of file +} diff --git a/frontend/src/i18n/zh-CN.json b/frontend/src/i18n/zh-CN.json index e2e222ae4..9f5cc9b24 100644 --- a/frontend/src/i18n/zh-CN.json +++ b/frontend/src/i18n/zh-CN.json @@ -7,22 +7,6 @@ "AI Model Configuration": "模型配置", "Details": "详情" }, - "sync": { - "integration": "需开启平台对接", - "the_existing_user": "若用户已存在,覆盖旧用户", - "sync_users": "同步用户", - "sync_wechat_users": "同步企业微信用户", - "sync_dingtalk_users": "同步钉钉用户", - "sync_lark_users": "同步飞书用户", - "sync_complete": "同步完成", - "synced_10_users": "成功同步 {num} 个用户", - "failed_3_users": "成功同步 {success} 个用户,同步失败 {failed} 个用户,", - "download_failure_list": "下载失败列表", - "sync_failed": "同步失败", - "failed_10_users": "同步失败 {num} 个用户,", - "continue_syncing": "继续同步", - "return_to_view": "返回查看" - }, "variables": { "please_select_date": "请选择日期", "number_variable_error": "请输入正确的数值范围", @@ -48,6 +32,37 @@ "enter_variable_name": "请输入变量名称", "enter_variable_value": "请输入变量值" }, + "authorized_space": { + "authorized_space": "授权空间", + "authorized_space_list": "授权空间列表", + "select_space": "选择空间", + "modify_authorized_space": "修改授权空间", + "workspaces_authorized": "已授权 {num} 个工作空间", + "number_of_members": "成员数量", + "delete_selected_workspaces": "是否移除选中的 {msg} 个工作空间?", + "delete_workspace": "是否移除工作空间:{msg}?", + "no_workspace": "暂无工作空间" + }, + "sync": { + "records": "显示 {num} 条数据,共 {total} 条", + "confirm_upload": "确定上传", + "field_details": "字段详情", + "integration": "需开启平台对接", + "the_existing_user": "若用户已存在,覆盖旧用户", + "lazy_load": "懒加载", + "sync_users": "同步用户", + "sync_wechat_users": "同步企业微信用户", + "sync_dingtalk_users": "同步钉钉用户", + "sync_lark_users": "同步飞书用户", + "sync_complete": "同步完成", + "synced_10_users": "成功同步 {num} 个用户", + "failed_3_users": "成功同步 {success} 个用户,同步失败 {failed} 个用户,", + "download_failure_list": "下载失败列表", + "sync_failed": "同步失败", + "failed_10_users": "同步失败 {num} 个用户,", + "continue_syncing": "继续同步", + "return_to_view": "返回查看" + }, "parameter": { "execution_details": "执行详情", "overview": "概览", @@ -61,12 +76,16 @@ "context_record_count": "上下文记录数", "context_record_count_hint": "用户提问轮数", "model_thinking_process": "展开模型思考过程", + "hide_model_thinking_process": "隐藏模型思考过程", "rows_of_data": "限制 1000 行数据", - "third_party_platform_settings": "第三方平台设置", - "by_third_party_platform": "第三方自动创建用户", - "platform_user_organization": "第三方平台工作空间", + "third_party_platform_settings": "登录认证设置", + "by_third_party_platform": "自动创建用户", + "platform_user_organization": "默认工作空间", "platform_user_roles": "第三方平台用户角色", "excessive_data_volume": "关闭1000行的数据限制后,数据量过大,可能会造成系统卡顿", + "sqlbot_name": "问数小助手名称", + "show_sql": "允许查看SQL语句", + "show_log": "显示执行日志", "prompt": "提示", "disabling_successfully": "关闭成功", "closed_by_default": "在问数窗口中,控制模型思考过程默认展开或者关闭", @@ -172,6 +191,7 @@ "system_manage": "系统管理", "update_success": "更新成功", "save_success": "保存成功", + "operation_success": "操作成功", "next": "下一步", "save": "保存", "logout": "退出登录", @@ -391,9 +411,14 @@ }, "timeout": "连接超时(秒)", "address": "地址", - "low_version": "兼容低版本" + "low_version": "兼容低版本", + "ssl": "启用 SSL", + "file_path": "文件路径", + "pool_size": "连接池大小" }, - "sync_fields": "同步表结构" + "sync_fields": "同步表结构", + "sync_fields_success": "同步表结构成功", + "sync_fields_failed": "同步表结构失败" }, "datasource": { "recommended_question": "推荐问题", @@ -441,6 +466,9 @@ "get_schema": "获取Schema" }, "model": { + "int": "整数", + "float": "小数", + "string": "字符串", "default_model": "默认模型", "model_type": "模型类型", "basic_model": "基础模型", @@ -558,6 +586,7 @@ "member_feng_yibudao": "是否移除成员:{msg}?", "select_member": "选择成员", "selected_2_people": "已选:{msg} 人", + "selected_number": "已选:{msg} 个", "clear": "清空", "historical_dialogue": "暂无历史对话", "rename_a_workspace": "重命名工作空间", @@ -658,6 +687,10 @@ "application_name": "应用名称", "application_description": "应用描述", "cross_domain_settings": "跨域设置", + "enableCustomModel": "使用指定大模型", + "useModel": "使用模型", + "defaultModel": "默认模型", + "customModel": "指定模型", "third_party_address": "请输入嵌入的第三方地址,多个以分号分割", "set_to_private": "设为私有", "set_to_public": "设为公共", @@ -667,7 +700,7 @@ "interface_url": "接口 URL", "format_is_incorrect": "格式不对{msg}", "domain_format_incorrect": ",http或https开头,不能以 / 结尾,多个域名以分号(半角)分隔", - "interface_url_incorrect": ",请填写相对路径,以/开头", + "interface_url_incorrect": ",请填写相对路径,以/开头,或者绝对路径以http或https开头", "aes_enable": "开启 AES 加密", "aes_enable_tips": "加密字段 (host, user, password, dataBase, schema) 均采用 AES-CBC-PKCS5Padding 加密方式", "bit": "位", @@ -708,7 +741,7 @@ "display_settings": "显示设置", "header_text_color": "头部文本颜色", "app_logo": "应用 Logo", - "maximum_size_10mb": "建议尺寸 32 x 32,支持 JPG、PNG、SVG,大小不超过 10MB", + "maximum_size_10mb": "建议尺寸 32 x 32,支持 JPG、PNG,大小不超过 10MB", "replace": "替换", "default_icon_position": "图标默认位置", "draggable_position": "可拖拽位置", @@ -757,6 +790,8 @@ "no_data": "暂无数据", "loading_data": "加载中...", "show_error_detail": "查看具体信息", + "thousands_separator_setting": "千分位符设置", + "thousands_separator_display": "应用千分位符展示", "log": { "GENERATE_SQL": "生成 SQL", "GENERATE_CHART": "生成图表结构", @@ -776,11 +811,11 @@ "log_question": "本次提问", "log_answer": "AI 回答", "log_history": "历史记录", - "find_term_title":"匹配到 {0} 个术语", - "find_sql_sample_title":"匹配到 {0} 个SQL示例", - "find_custom_prompt_title":"匹配到 {0} 个自定义提示词", - "query_count_title":"查询到 {0} 条数据", - "generate_picture_success":"已生成图片" + "find_term_title": "匹配到 {0} 个术语", + "find_sql_sample_title": "匹配到 {0} 个SQL示例", + "find_custom_prompt_title": "匹配到 {0} 个自定义提示词", + "query_count_title": "查询到 {0} 条数据", + "generate_picture_success": "已生成图片" }, "about": { "title": "关于", @@ -821,11 +856,11 @@ "website_logo": "网站 Logo", "tab": "页签", "replace_image": "替换图片", - "larger_than_200kb": "顶部网站显示的 Logo,建议尺寸 48 x 48,支持 JPG、PNG、SVG,大小不超过 200KB", + "larger_than_200kb": "顶部网站显示的 Logo,建议尺寸 48 x 48,支持 JPG、PNG,大小不超过 200KB", "login_logo": "系统 Logo", - "larger_than_200kb_de": "登录页面右侧 Logo,建议尺寸 204*52,支持 JPG、PNG、SVG,大小不超过 200KB", + "larger_than_200kb_de": "登录页面右侧 Logo,建议尺寸 204*52,支持 JPG、PNG,大小不超过 200KB", "login_background_image": "登录背景图", - "larger_than_5mb": "左侧背景图,矢量图建议尺寸 576*900,位图建议尺寸 1152*1800;支持 JPG、PNG、SVG,大小不超过 5M", + "larger_than_5mb": "左侧背景图,矢量图建议尺寸 576*900,位图建议尺寸 1152*1800;支持 JPG、PNG,大小不超过 5M", "website_name": "网站名称", "on_webpage_tabs": "显示在网页 Tab 的平台名称", "welcome_message": "显示欢迎语", @@ -915,6 +950,7 @@ "kimi": "Kimi", "tencent_cloud": "腾讯云", "volcano_engine": "火山引擎", + "minimax": "MiniMax", "generic_openai": "通用OpenAI" }, "modelType": { diff --git a/frontend/src/i18n/zh-TW.json b/frontend/src/i18n/zh-TW.json new file mode 100644 index 000000000..b64649f9a --- /dev/null +++ b/frontend/src/i18n/zh-TW.json @@ -0,0 +1,985 @@ +{ + "menu": { + "add_interface_credentials": "請新增介面憑證", + "Data Q&A": "智慧問數", + "Data Connections": "資料來源", + "Dashboard": "儀表板", + "AI Model Configuration": "模型配置", + "Details": "詳情" + }, + "variables": { + "please_select_date": "請選擇日期", + "number_variable_error": "請輸入正確的數值範圍", + "built_in": "已內建", + "normal_value": "一般值", + "​​cannot_be_empty": "變數值不能為空", + "1_to_100": "{name}數值範圍 {min}~{max}", + "1_to_100_de": "{name}日期範圍 {min}~{max}", + "system_variables": "系統變數", + "variables": "變數", + "system": "系統", + "search_variables": "搜尋變數", + "add_variable": "新增變數", + "variable_name": "變數名稱", + "variable_type": "變數類型", + "variable_value": "變數值", + "edit_variable": "編輯變數", + "no_variables_yet": "暫無變數", + "please_enter_value": "請輸入數值", + "date": "日期", + "start_date": "開始日期", + "end_date": "結束日期", + "enter_variable_name": "請輸入變數名稱", + "enter_variable_value": "請輸入變數值" + }, + "authorized_space": { + "authorized_space": "授權空間", + "authorized_space_list": "授權空間列表", + "select_space": "選擇空間", + "modify_authorized_space": "修改授權空間", + "workspaces_authorized": "已授權 {num} 個工作空間", + "number_of_members": "成員數量", + "delete_selected_workspaces": "是否移除選中的 {msg} 個工作空間?", + "delete_workspace": "是否移除工作空間:{msg}?", + "no_workspace": "暫無工作空間" + }, + "sync": { + "records": "顯示 {num} 筆資料,共 {total} 筆", + "confirm_upload": "確定上傳", + "field_details": "欄位詳情", + "integration": "需開啟平台對接", + "the_existing_user": "若使用者已存在,覆蓋舊使用者", + "lazy_load": "懶加載", + "sync_users": "同步使用者", + "sync_wechat_users": "同步企業微信使用者", + "sync_dingtalk_users": "同步釘釘使用者", + "sync_lark_users": "同步飛書使用者", + "sync_complete": "同步完成", + "synced_10_users": "成功同步 {num} 個使用者", + "failed_3_users": "成功同步 {success} 個使用者,同步失敗 {failed} 個使用者,", + "download_failure_list": "下載失敗列表", + "sync_failed": "同步失敗", + "failed_10_users": "同步失敗 {num} 個使用者,", + "continue_syncing": "繼續同步", + "return_to_view": "返回查看" + }, + "parameter": { + "execution_details": "執行詳情", + "overview": "概覽", + "tokens_required": "消耗 Tokens", + "time_execution": "耗時", + "export_notes": "匯出備註", + "import_notes": "匯入備註", + "memo": "備註更新規則:根據表名和欄位名稱匹配,如果匹配則更新表備註和欄位備註;如果匹配不上,備註保持不變。", + "parameter_configuration": "參數配置", + "question_count_settings": "問數設定", + "context_record_count": "上下文記錄數", + "context_record_count_hint": "使用者提問輪數", + "model_thinking_process": "展開模型思考過程", + "hide_model_thinking_process": "隱藏模型思考過程", + "rows_of_data": "限制 1000 列資料", + "third_party_platform_settings": "登入認證設定", + "by_third_party_platform": "自動建立使用者", + "platform_user_organization": "預設工作區", + "platform_user_roles": "第三方平台使用者角色", + "excessive_data_volume": "關閉1000列的資料限制後,資料量過大,可能會造成系統卡頓", + "sqlbot_name": "問數小助手名稱", + "show_sql": "允許查看SQL語句", + "show_log": "顯示執行日誌", + "prompt": "提示", + "disabling_successfully": "關閉成功", + "closed_by_default": "在問數視窗中,控制模型思考過程預設展開或者關閉", + "and_platform_integration": "登入認證相關", + "login_settings": "登入設定", + "default_login": "預設登入方式" + }, + "prompt": { + "default_password": "預設密碼:{msg}", + "no_sql_sample": "暫無 SQL 範例", + "add_sql_sample": "新增 SQL 範例", + "no_prompt_words": "暫無提示詞", + "customize_prompt_words": "自訂提示詞", + "ask_sql": "問數 SQL", + "data_analysis": "資料分析", + "data_prediction": "資料預測", + "add_prompt_word": "新增提示詞", + "prompt_word_name": "提示詞名稱", + "prompt_word_content": "提示詞內容", + "training_data_details": "訓練資料詳情", + "selected_prompt_words": "是否刪除選中的 {msg} 筆提示詞?", + "replaced_with": "請輸入提示詞,範例:回傳姓名時,只顯示姓氏,名字用*代替", + "loss_exercise_caution": "若輸入刪除資料庫、資料表或修改表結構等高風險的操作指令,可能導致資料永久遺失,請務必謹慎!", + "edit_prompt_word": "編輯提示詞", + "all_236_terms": "是否匯出全部 {msg} 筆{type}提示詞?", + "export_hint": "是否匯出全部{type}提示詞?", + "prompt_word_name_de": "是否刪除提示詞:{msg}?", + "disable_field": "停用欄位", + "to_disable_it": "該欄位已配置表關聯關係,若停用後,關聯關係將失效,確定停用?" + }, + "training": { + "effective_data_sources": "生效資料來源", + "all_data_sources": "所有資料來源", + "partial_data_sources": "部分資料來源", + "add_it_here": "拖曳左側表名,新增到這裡", + "table_relationship_management": "表關係管理", + "system_management": "系統管理", + "data_training": "SQL 範例庫", + "problem_description": "問題描述", + "sample_sql": "範例 SQL", + "search_problem": "搜尋問題", + "add_training_data": "新增範例 SQL", + "training_data_details": "範例 SQL 詳情", + "training_data_items": "是否刪除選中的 {msg} 筆範例 SQL?", + "sql_statement": "SQL 語句", + "edit_training_data": "編輯範例 SQL", + "all_236_terms": "是否匯出全部 {msg} 筆範例 SQL?", + "export_hint": "是否匯出全部範例 SQL?", + "sales_this_year": "是否刪除範例 SQL:{msg}?", + "upload_success": "匯入成功", + "upload_failed": "匯入成功 {success} 筆,失敗 {fail} 筆,失敗資訊詳見:{fail_info}" + }, + "professional": { + "cannot_be_repeated": "術語名稱,同義詞不能重複", + "code_for_debugging": "複製以下程式碼進行除錯", + "full_screen_mode": "全螢幕模式", + "editing_terminology": "編輯術語", + "professional_terminology": "術語配置", + "term_name": "術語名稱", + "term_description": "術語描述", + "search_term": "搜尋術語", + "no_term": "暫無術語", + "create_new_term": "新建術語", + "export_all": "全部匯出", + "professional_term_details": "專業術語詳情", + "business_term": "業務術語", + "synonyms": "同義詞", + "all_236_terms": "是否匯出全部 {msg} 筆術語?", + "export_hint": "是否匯出全部術語?", + "export": "匯出", + "selected_2_terms": "是否刪除選中的 {msg} 筆術語?", + "selected_2_terms_de": "是否匯出選中的 {msg} 筆術語?", + "the_term_gmv": "是否刪除術語:{msg}?" + }, + "common": { + "start_time": "開始時間", + "end_time": "結束時間", + "require": "必填", + "zoom_in": "放大", + "zoom_out": "縮小", + "the_default_model": "已是預設模型", + "as_default_model": "設為預設模型", + "no_model_yet": "暫無模型", + "intelligent_questioning_platform": "歡迎使用 SQLBot 智慧問數平台", + "login": "帳號登入", + "login_": "登入", + "confirm2": "確定", + "excessive_tables_selected": "選擇資料表過量", + "to_continue_saving": "選擇的資料表數量為:{msg},已超出30,可能會導致操作逾時或者無回應,是否繼續儲存?", + "proceed_with_caution": "刪除後無法復原,請謹慎操作。", + "sales_in_2024": "是否刪除對話:{msg}?", + "limited_to_30": "資料表數量限制30", + "enter_your_password": "請輸入密碼", + "your_account_email_address": "請輸入帳號/郵箱", + "the_correct_password": "請輸入正確的密碼", + "input_content": "請輸入內容", + "may_not_exist": "暫無結果,使用者可能不存在", + "empty": "", + "back": "返回", + "confirm": "確認", + "cancel": "取消", + "search": "查詢", + "system_manage": "系統管理", + "update_success": "更新成功", + "save_success": "儲存成功", + "operation_success": "操作成功", + "next": "下一步", + "save": "儲存", + "logout": "登出", + "please_input": "請輸入{msg}", + "switch_success": "切換成功", + "result_count": "個結果", + "clear_filter": "清空條件", + "reset": "重設", + "simplified_chinese": "簡體中文", + "korean": "한국어", + "traditional_chinese": "繁體中文", + "help": "說明", + "language": "語言", + "english": "English", + "re_upload": "重新上傳", + "not_exceed_50mb": "支援 XLS、XLSX、CSV 格式,檔案大小不超過 50MB", + "excel_file_type_limit": "僅支援 XLS、XLSX 格式", + "click_to_select_file": "點擊選擇檔案", + "upload_hint_first": "先", + "upload_hint_download_template": "下載範本", + "upload_hint_end": ",按要求填寫後上傳。", + "continue_to_upload": "繼續匯入", + "reset_password": "重設密碼", + "password_reset_successful": "重設密碼成功", + "or": "或者", + "refresh": "重新整理", + "input_limit": "長度在 {0} 到 {1} 個字元", + "file_size_limit_error": "檔案大小超出限制(最大{limit})" + }, + "dashboard": { + "open_dashboard": "開啟儀表板", + "add_dashboard_name_tips": "請輸入儀表板名稱", + "existing_dashboard": "存量儀表板", + "add_success": "新增成功", + "no_data": "沒有找到相關內容", + "new_tab": "新建Tab", + "length_limit64": "欄位長度需要在1-64之間", + "sort_column": "排序欄位", + "sort_type": "排序方式", + "time": "時間", + "sort_asc": "升序", + "sort_desc": "降序", + "name_repeat": "儀表板名稱已被使用", + "rich_text_tips": "雙擊輸入文字內容", + "exit_preview": "退出預覽", + "no_chat": "暫無對話", + "today": "今日", + "this_week": "本週", + "earlier": "更早", + "add_component_tips": "從頂部工具列中選擇元件,新增到這裡建立儀表板", + "add_view": "新增圖表", + "delete_dashboard_warn": "是否刪除儀表板:{0}", + "rename_dashboard": "重新命名儀表板", + "dashboard_name": "儀表板名稱", + "select_dashboard_tips": "請在左側選擇儀表板", + "no_dashboard": "暫無儀表板", + "no_dashboard_info": "暫無內容,點擊下列按鈕進行新增", + "search": "搜尋", + "time_asc": "按時間升序", + "time_desc": "按時間降序", + "name_asc": "按名稱升序", + "name_desc": "按名稱降序", + "select_dashboard": "請選擇儀表板", + "name": "名稱", + "view": "圖表", + "text": "富文字", + "preview": "預覽", + "creator": "建立人", + "dashboard_id": "儀表板ID", + "create_time": "建立時間", + "updater": "更新人", + "update_time": "更新時間", + "edit": "編輯", + "edit_title": "編輯標題", + "length_1_64_characters": "名稱欄位長度1-64個字元", + "rename": "重新命名", + "delete": "刪除", + "delete_tips": "刪除後,此目錄的所有資源都會被刪除,請謹慎操作。", + "undo": "復原", + "reduction": "復原", + "folder": "目錄", + "dashboard": "儀表板", + "delete_warning": "確定要刪除嗎?", + "delete_resource_warn": "確定刪除該{0}嗎?", + "delete_success": "刪除成功", + "new_folder": "新建目錄", + "new_dashboard": "新建儀表板", + "add_chart": "新增圖表", + "chart_selected": "已選{0}" + }, + "qa": { + "retrieve_error": "暫無內容", + "retrieve_again": "重新取得", + "recently": "最近", + "recommend": "推薦", + "quick_question": "快捷提問", + "new_chat": "新建對話", + "start_sqlbot": "開啟問數", + "title": "智慧問數", + "placeholder": "請輸入您的問題", + "ask": "提問", + "loading": "載入中...", + "no_data": "暫無資料", + "error": "發生錯誤,請稍後再試", + "question_placeholder": "按下 Enter 提交問題, 或使用 Ctrl + Enter 換行", + "greeting": "你好,我是SQLBot,很高興為你服務", + "hint_description": "我可以查詢資料、產生圖表、分析資料、預測資料等,請選擇一個資料來源,開啟智慧問數吧~", + "select_datasource": "選擇資料來源", + "view_more": "查看更多", + "selected_datasource": "已選擇資料來源", + "empty_datasource": "資料來源為空,請新建後再開啟智慧問數!", + "datasource_not_exist": "資料來源不存在", + "guess_u_ask": "猜你想問:", + "continue_to_ask": "繼續提問:", + "data_analysis": "資料分析", + "data_predict": "資料預測", + "data_regenerated": "重新產生", + "chat_search": "搜尋", + "thinking": "思考中", + "thinking_step": "思考過程", + "ask_again": "重新產生", + "today": "今天", + "week": "7天內", + "earlier": "更早以前", + "no_time": "沒有時間", + "rename_conversation_title": "重新命名對話標題", + "conversation_title": "對話標題", + "copied": "已複製", + "ask_failed": "問數失敗" + }, + "ds": { + "title": "資料來源", + "local_excelcsv": "本機 Excel/CSV", + "add": "新增資料來源", + "delete": "刪除資料來源", + "name": "資料來源名稱", + "type": "資料來源類型", + "status": "狀態", + "pieces_in_total": "顯示 {ms} 筆資料", + "actions": "操作", + "test_connection": "測試連線", + "check": "驗證", + "connection_success": "連線成功", + "connection_failed": "連線失敗,請檢查配置", + "Search Datasource": "搜尋資料來源", + "tables": "資料表", + "no_data_tip": "暫無資料,請從左側選擇資料表", + "comment": "註解", + "table_schema": "表結構", + "previous": "上一步", + "preview": "資料預覽", + "preview_tip": "預覽100筆資料", + "field": { + "name": "欄位名", + "type": "類型", + "comment": "註解", + "custom_comment": "自訂註解", + "status": "狀態" + }, + "edit": { + "table_comment": "編輯表註解", + "field_comment": "編輯欄位註解", + "table_comment_label": "表註解", + "field_comment_label": "欄位註解" + }, + "form": { + "title": { + "add": "新增資料來源", + "edit": "編輯資料來源", + "choose_tables": "選擇資料表" + }, + "base_info": "基本資訊", + "choose_tables": "選擇資料表", + "name": "名稱", + "description": "描述", + "host": "主機名/IP位址", + "port": "連接埠", + "username": "使用者名稱", + "password": "密碼", + "database": "資料庫", + "connect_mode": "連線方式", + "service_name": "服務名", + "extra_jdbc": "額外的資料庫連線配置", + "schema": "模式", + "get_schema": "取得模式", + "file": "檔案", + "upload_tip": "僅支援 .xls, .xlsx, .csv 格式,大小不超過50MB", + "version_tip": { + "sqlServer": "支援版本: 2012+", + "oracle": "支援版本: 12+", + "mysql": "支援版本: 5.6+", + "pg": "支援版本: 9.6+" + }, + "selected": "已選擇: {0}/{1}", + "validate": { + "name_required": "請輸入名稱", + "name_length": "長度應在1到50個字元之間", + "type_required": "請選擇資料庫類型", + "host_required": "請輸入主機位址", + "port_required": "請輸入連接埠號", + "database_required": "請輸入資料庫名", + "mode_required": "請選擇連線方式", + "schema_required": "請輸入模式名" + }, + "mode": { + "sid": "SID", + "service_name": "服務名" + }, + "support_version": "支援版本", + "upload": { + "button": "上傳", + "tip": "僅支援 .xls, .xlsx, .csv 格式,檔案小於 50MB." + }, + "connect": { + "success": "連線成功", + "failed": "連線失敗" + }, + "timeout": "連線逾時(秒)", + "address": "位址", + "low_version": "相容低版本", + "ssl": "啟用 SSL", + "file_path": "文件路徑", + "pool_size": "連線池大小" + }, + "sync_fields": "同步表結構", + "sync_fields_success": "同步表結構成功", + "sync_fields_failed": "同步表結構失敗" + }, + "datasource": { + "recommended_question": "推薦問題", + "recommended_repetitive_tips": "存在重複問題", + "recommended_problem_tips": "自訂配置至少一個問題,每個問題2-200個字元", + "recommended_problem_configuration": "推薦問題配置", + "problem_generation_method": "問題產生方式", + "ai_automatic_generation": "AI 自動產生", + "user_defined": "使用者自訂", + "question_tips": "請輸入推薦問題", + "add_question": "新增問題", + "data_source_yet": "暫無資料來源", + "search_by_name": "透過名稱搜尋", + "search": "搜尋", + "all_types": "全部類型", + "new_data_source": "新建資料來源", + "open_query": "開啟問數", + "edit": "編輯", + "source_connection_failed": "資料來源連線失敗", + "confirm": "確定", + "copy": "複製", + "the_original_one": "確認恢復為初始密碼嗎?", + "incorrect_email_format": "郵箱格式不對", + "enabled_status": "啟用狀態", + "custom_notes": "自訂備註", + "field_type": "欄位類型", + "field_name": "欄位名稱", + "relevant_content_found": "沒有找到相關內容", + "please_enter": "請輸入", + "Please_select": "請選擇", + "table_notes": "表備註", + "field_notes": "欄位備註", + "select_all": "全選", + "mysql_data_source": "修改 {msg} 資料來源", + "configuration_information": "配置資訊", + "data_source": "是否刪除資料來源:{msg}?", + "operate_with_caution": "被刪除後,將無法對該資料來源進行智慧問數,請謹慎操作。", + "data_source_de": "無法刪除資料來源:{msg}", + "cannot_be_deleted": "儀表板的圖表使用了該資料來源,不可刪除。", + "got_it": "知道了", + "field_original_notes": "欄位原始備註", + "field_notes_1": "欄位備註", + "no_table": "暫無資料表", + "go_add": "去新增", + "get_schema": "取得Schema" + }, + "model": { + "int": "整數", + "float": "小數", + "string": "字串", + "default_model": "預設模型", + "model_type": "模型類型", + "basic_model": "基礎模型", + "set_successfully": "設定成功", + "operate_with_caution": "系統預設模型被取代後,智慧問數的結果將會受到影響,請謹慎操作。", + "system_default_model": "是否設定 {msg} 為系統預設模型?", + "ai_model_configuration": "AI 模型配置", + "system_default_model_de": "系統預設模型", + "relevant_results_found": "沒有找到相關結果", + "add_model": "新增模型", + "select_supplier": "選擇供應商", + "the_basic_model": "請給基礎模型設定一個名稱", + "the_basic_model_de": "請選擇基礎模型", + "length_max_error": "{msg}長度不能超過{max}個字元", + "model_name": "模型名稱", + "custom_model_name": "自訂的模型名稱", + "enter_to_add": "清單中未列出的模型,直接輸入模型名稱,Enter 即可新增", + "api_domain_name": "API 網域", + "advanced_settings": "進階設定", + "model_parameters": "模型參數", + "add": "新增", + "parameters": "參數", + "display_name": "顯示名稱", + "parameter_value": "參數值", + "text": "文字", + "number": "數值", + "parameter_type": "參數類型", + "verification_successful": "驗證成功", + "del_default_tip": "無法刪除模型: {msg}", + "del_default_warn": "該模型為系統預設模型,請先設定其他模型為系統預設模型,再刪除此模型。", + "del_warn_tip": "是否刪除模型: {msg}?", + "check_failed": "模型無效【{msg}】", + "default_miss": "尚未設定預設大語言模型,因此無法開啟問數,請聯繫管理員設定。", + "default_miss_admin": "尚未設定預設大語言模型,因此無法開啟問數。", + "to_config": "去設定" + }, + "user": { + "workspace": "工作區", + "creation_time": "建立時間", + "user_source": "使用者來源", + "phone_number": "手機號", + "email": "郵箱", + "user_status": "使用者狀態", + "account": "帳號", + "name": "姓名", + "selected_2_items": "已選 {msg} 筆", + "name_account_email": "搜尋姓名、帳號、郵箱", + "user_management": "使用者管理", + "filter": "篩選", + "batch_import": "批次匯入", + "add_users": "新增使用者", + "selected_2_users": "是否刪除選中的 {msg} 個使用者?", + "filter_conditions": "篩選條件", + "enable": "啟用", + "disable": "停用", + "local_creation": "本機建立", + "lark": "飛書", + "larksuite": "國際飛書", + "dingtalk": "釘釘", + "wecom": "企業微信", + "1240_results": "{msg} 個結果 ", + "clear_conditions": "清空條件", + "disabled": "已停用", + "enabled": "已啟用", + "edit_user": "編輯使用者", + "del_user": "是否刪除使用者: {msg}?", + "del_key": "是否刪除key: {msg}?", + "please_first": "請先", + "download_the_template": "下載範本", + "required_and_upload": ",按要求填寫後上傳", + "file": "檔案", + "upload_file": "上傳檔案", + "xls_format_files": "僅支援xlsx、xls格式的檔案", + "import": "匯入", + "change_file": "更換檔案", + "data_import_completed": "資料匯入完成", + "notes_import_completed": "備註匯入完成", + "imported_100_data": "成功匯入資料 {msg} 筆", + "return_to_view": "返回查看", + "continue_importing": "繼續匯入", + "import_20_can": "成功匯入資料 {msg} 筆,匯入失敗 {loss} 筆,可 ", + "download_error_report": "下載錯誤報告", + "modify_and_re_import": ",修改後重新匯入", + "data_import_failed": "資料匯入失敗", + "failed_100_can": "匯入失敗 {msg} 筆,可", + "change_password": "修改密碼", + "new_password": "新密碼", + "confirm_password": "確認密碼", + "old_password": "舊密碼", + "title": "使用者管理", + "upgrade_pwd": { + "title": "修改密碼", + "old_pwd": "舊密碼", + "new_pwd": "新密碼", + "confirm_pwd": "確認密碼", + "two_pwd_not_match": "兩次輸入的密碼不一致", + "pwd_format_error": "8-20位且至少一位大寫字母、小寫字母、數字、特殊字元" + } + }, + "workspace": { + "relevant_content_found": "找不到相關內容", + "add_workspace": "新增工作區", + "administrator": "管理員", + "ordinary_member": "一般成員", + "people": "人", + "name_username_email": "搜尋姓名、使用者名稱、郵箱", + "add_member": "新增成員", + "member_type": "成員類型", + "workspace_name": "工作區名稱", + "no_user": "暫無使用者", + "remove": "移除", + "selected_2_members": "是否移除選中的 {msg} 個成員?", + "removed_successfully": "移除成功", + "workspace_de_workspace": "是否刪除工作區:{msg}?", + "member_feng_yibudao": "是否移除成員:{msg}?", + "select_member": "選擇成員", + "selected_2_people": "已選:{msg} 人", + "selected_number": "已選:{msg} 個", + "clear": "清空", + "historical_dialogue": "暫無歷史對話", + "rename_a_workspace": "重新命名工作區", + "return_to_workspace": "返回工作區", + "there_are": "有 ", + "2_dashboards": "{msg} 個儀表板", + "smart_data_centers": "{msg} 個智慧問數", + "confirm_to_delete": "資料來源刪除後,相關的問數不可繼續提問,儀表板中的圖表無法正常顯示,請謹慎操作!", + "id_account_to_add": "搜尋使用者名稱/帳號新增", + "find_user": "尋找使用者", + "add_successfully": "新增成功", + "member_management": "成員管理", + "permission_configuration": "權限配置", + "set": "設定", + "operate_with_caution": "刪除後,該工作區下的使用者將被移除,所有資源也將被刪除,請謹慎操作。" + }, + "permission": { + "search_field": "搜尋欄位", + "search_rule_group": "搜尋規則組", + "add_rule_group": "新增規則組", + "permission_rule": "權限規則", + "restricted_user": "受限使用者", + "set_rule": "設定規則", + "set_user": "設定使用者", + "2": "{msg} 個", + "238_people": "{msg} 人", + "no_permission_rule": "暫無權限規則", + "set_permission_rule": "設定權限規則", + "basic_information": "基本資訊", + "rule_group_name": "規則組名稱", + "rule_name": "規則名稱", + "type": "類型", + "data_source": "資料來源", + "data_table": "資料表", + "select_restricted_user": "選擇受限使用者", + "rule_group_1": "是否刪除規則組:{msg}?", + "no_rule": "暫無規則", + "row_permission": "列權限", + "column_permission": "欄位權限", + "rule_rule_1": "是否刪除規則:{msg}?", + "no_content": "暫無內容", + "edit_column_permission": "編輯欄位權限", + "edit_row_permission": "編輯列權限", + "add_column_permission": "新增欄位權限", + "add_row_permission": "新增列權限", + "no_fields_yet": "暫無欄位", + "filter_eq": "等於", + "filter_not_eq": "不等於", + "filter_lt": "小於", + "filter_le": "小於等於", + "filter_gt": "大於", + "filter_ge": "大於等於", + "filter_null": "為空", + "filter_not_null": "不為空", + "filter_empty": "空字串", + "filter_not_empty": "非空字串", + "filter_include": "包含", + "filter_not_include": "不包含", + "conditional_filtering": "條件篩選", + "add_conditions": "新增條件", + "add_relationships": "新增關係", + "cannot_be_empty_": "過濾欄位不能為空", + "cannot_be_empty_de_ruler": "規則條件不能為空", + "filter_value_can_null": "過濾值不能為空", + "filter_like": "包含", + "filter_not_like": "不包含", + "filter_in": "屬於", + "filter_not_in": "不屬於", + "enter_a_question": "請輸入問題", + "new_conversation": "新對話", + "send": "傳送", + "stop_replying": "停止回覆", + "i_am_sqlbot": "你好,我是SQLBot", + "predict_data_etc": "我可以查詢資料、產生圖表、分析資料、預測資料等。", + "intelligent_data_query": "趕快開啟智慧問數吧~" + }, + "embedded": { + "delete_10_apps": "是否刪除 {msg} 個應用?", + "click_to_show": "點擊顯示", + "click_to_hide": "點擊隱藏", + "your_app_secret": "確定更新 APP Secret 嗎?", + "proceed_with_caution": "重設後現有的 App Secret 將會失效,請謹慎操作。", + "password_length": "密碼長度", + "edit_app": "編輯應用", + "embedded_management": "嵌入式管理", + "embedded_assistant": "小助手嵌入", + "embedded_page": "頁面嵌入", + "no_application": "暫無應用", + "create_application": "建立應用", + "basic_application": "基礎應用", + "advanced_application": "進階應用", + "embed_third_party": "嵌入第三方", + "support_is_required": "適用於無需資料驗證的場景,只需將嵌入式程式碼內嵌到第三方的程式碼中,無需做額外的支援", + "data_permissions_etc": "適用於需要資料驗證的場景,需要第三方系統提供資料來源介面、使用者介面資料權限等", + "create_basic_application": "建立基礎應用", + "set_data_source": "設定資料來源", + "basic_information": "基本資訊", + "application_name": "應用名稱", + "application_description": "應用描述", + "cross_domain_settings": "跨網域設定", + "enableCustomModel": "使用指定模型", + "useModel": "使用模型", + "defaultModel": "預設模型", + "customModel": "指定模型", + "third_party_address": "請輸入嵌入的第三方位址,多個以分號分割", + "set_to_private": "設為私有", + "set_to_public": "設為公共", + "public": "公共", + "private": "私有", + "configure_interface": "配置介面", + "interface_url": "介面 URL", + "format_is_incorrect": "格式不對{msg}", + "domain_format_incorrect": ",http或https開頭,不能以 / 結尾,多個網域名稱以分號(半形)分隔", + "interface_url_incorrect": ",請填寫相對路徑,以/開頭,或者絕對路徑以http或https開頭", + "aes_enable": "開啟 AES 加密", + "aes_enable_tips": "加密欄位 (host, user, password, dataBase, schema) 均採用 AES-CBC-PKCS5Padding 加密方式", + "bit": "位", + "creating_advanced_applications": "建立進階應用", + "credential_acquisition_method": "憑證取得方式", + "table_notes": "表備註", + "system_credential_type": "來源系統憑證類型", + "credential_name": "憑證名稱", + "target_credential_location": "目標憑證位置", + "target_credential_name": "目標憑證名稱", + "target_credential": "目標憑證", + "edit_advanced_applications": "編輯進階應用", + "edit_basic_applications": "編輯基礎應用", + "delete": "是否刪除: {msg}?", + "code_to_embed": "複製以下程式碼進行嵌入", + "floating_window_mode": "浮窗模式", + "copy_successful": "複製成功", + "copy_failed": "複製失敗", + "open_the_query": "公共的資料來源,允許所有使用者開啟問數;私有資料來源,登入狀態的使用者可以開啟問數", + "add_interface_credentials": "新增介面憑證", + "edit_interface_credentials": "編輯介面憑證", + "duplicate_name": "重複名稱", + "duplicate_name_": "重複姓名", + "duplicate_account": "重複帳號", + "duplicate_email": "重複郵箱", + "repeating_parameters": "重複參數", + "interface_credentials": "介面憑證", + "no_credentials_yet": "暫無憑證", + "intelligent_customer_service": "SQLBot 智慧客服", + "enter_a_question": "請輸入問題", + "new_conversation": "新建對話", + "send": "傳送", + "stop_replying": "停止回答", + "i_am_sqlbot": "你好,我是 SQLBot", + "predict_data_etc": "我可以查詢資料、產生圖表、檢測資料異常、預測資料等", + "intelligent_data_query": "趕快開啟智慧問數吧~", + "origin_format_error": "格式錯誤,http或https開頭,不能以 / 結尾", + "display_settings": "顯示設定", + "header_text_color": "頭部文字顏色", + "app_logo": "應用 Logo", + "maximum_size_10mb": "建議尺寸 32 x 32,支援 JPG、PNG,大小不超過 10MB", + "replace": "取代", + "default_icon_position": "圖示預設位置", + "draggable_position": "可拖曳位置", + "up": "上", + "down": "下", + "left": "左", + "right": "右", + "welcome_description": "歡迎語描述", + "data_analysis_now": "我可以查詢資料、產生圖表、檢測資料異常、預測資料等趕快開啟智慧問數吧~", + "window_entrance_icon": "浮窗入口圖示", + "preview_error": "目前頁面禁止使用 Iframe 嵌入!", + "assistant_app": "小助手應用", + "auto_select_ds": "自動選擇資料來源" + }, + "chat": { + "type": "圖表類型", + "chart_type": { + "table": "明細表", + "bar": "橫條圖", + "column": "柱狀圖", + "line": "折線圖", + "pie": "圓餅圖" + }, + "hide_label": "隱藏標籤", + "show_label": "顯示標籤", + "sort_asc": "升序", + "sort_desc": "降序", + "sort_none": "不排序", + "show_sql": "檢視SQL", + "export_to": "匯出為", + "excel": "Excel", + "picture": "圖片", + "add_to_dashboard": "新增到儀表板", + "full_screen": "全螢幕", + "exit_full_screen": "退出全螢幕", + "sql_generation": "產生SQL", + "chart_generation": "產生圖表", + "inference_process": "思考過程", + "thinking": "思考中", + "data_analysis": "資料分析", + "data_predict": "資料預測", + "data_over_limit": "資料量過大,僅展示前{0}筆資料", + "ds_is_invalid": "資料來源無效", + "error": "錯誤", + "exec-sql-err": "執行SQL失敗", + "no_data": "暫無資料", + "loading_data": "載入中...", + "show_error_detail": "檢視具體資訊", + "thousands_separator_setting": "千分位符設定", + "thousands_separator_display": "套用千分位符顯示", + "log": { + "GENERATE_SQL": "產生 SQL", + "GENERATE_CHART": "產生圖表結構", + "ANALYSIS": "分析", + "PREDICT_DATA": "預測", + "GENERATE_SQL_WITH_PERMISSIONS": "拼接 SQL 列權限", + "CHOOSE_DATASOURCE": "匹配資料來源", + "GENERATE_DYNAMIC_SQL": "產生動態 SQL", + "CHOOSE_TABLE": "匹配資料表 (Schema)", + "FILTER_TERMS": "匹配術語", + "FILTER_SQL_EXAMPLE": "匹配 SQL 範例", + "FILTER_CUSTOM_PROMPT": "匹配自訂提示詞", + "EXECUTE_SQL": "執行 SQL", + "GENERATE_PICTURE": "產生圖片" + }, + "log_system": "對話範本", + "log_question": "本次提問", + "log_answer": "AI 回答", + "log_history": "歷史記錄", + "find_term_title": "匹配到 {0} 個術語", + "find_sql_sample_title": "匹配到 {0} 個SQL範例", + "find_custom_prompt_title": "匹配到 {0} 個自訂提示詞", + "query_count_title": "查詢到 {0} 筆資料", + "generate_picture_success": "已產生圖片" + }, + "about": { + "title": "關於", + "auth_to": "授權給", + "invalid_license": "License 無效", + "update_license": "更新 License", + "expiration_time": "過期時間", + "expirationed": "(已過期)", + "auth_num": "授權數量", + "version": "版本", + "version_num": "版本號", + "standard": "社區版", + "enterprise": "企業版", + "professional": "專業版", + "embedded": "嵌入式版", + "support": "取得技術支援", + "update_success": "更新成功,請重新登入", + "serial_no": "序號", + "remark": "備註", + "back_community": "還原至社區版", + "confirm_tips": "確定還原至社區版?" + }, + "license": { + "error_tips": "是否重新整理頁面?", + "offline_tips": "服務已下線,請聯繫管理員重啟服務!" + }, + "system": { + "system_settings": "系統設定", + "appearance_settings": "外觀設定", + "authentication_settings": "登入認證", + "platform_display_theme": "平台顯示主題", + "default_turquoise": "預設 (松石綠)", + "tech_blue": "科技藍", + "custom": "自訂", + "platform_login_settings": "平台登入設定", + "page_preview": "頁面預覽", + "restore_default": "恢復預設", + "website_logo": "網站 Logo", + "tab": "頁籤", + "replace_image": "取代圖片", + "larger_than_200kb": "頂部網站顯示的 Logo,建議尺寸 48 x 48,支援 JPG、PNG,大小不超過 200KB", + "login_logo": "系統 Logo", + "larger_than_200kb_de": "登入頁面右側 Logo,建議尺寸 204*52,支援 JPG、PNG,大小不超過 200KB", + "login_background_image": "登入背景圖", + "larger_than_5mb": "左側背景圖,向量圖建議尺寸 576*900,點陣圖建議尺寸 1152*1800;支援 JPG、PNG,大小不超過 5M", + "website_name": "網站名稱", + "on_webpage_tabs": "顯示在網頁 Tab 的平台名稱", + "welcome_message": "顯示歡迎語", + "the_product_logo": "產品 Logo 下的 歡迎語", + "screen_customization_supported": "預設為 SQLBot 登入介面,支援自訂設定", + "screen_customization_settings": "預設為 SQLBot 平台介面,支援自訂設定", + "platform_settings": "平台設定", + "help_documentation": "說明文件", + "show_about": "顯示關於", + "abort_update": "放棄更新", + "save_and_apply": "儲存並套用", + "setting_successfully": "設定成功", + "customize_theme_color": "自訂主題色" + }, + "platform": { + "title": "平台對接", + "status_open": "已開啟", + "status_close": "已關閉", + "access_in": "接入", + "can_enable_it": "測試連線有效後,可開啟" + }, + "authentication": { + "invalid": "無效", + "valid": "有效", + "cas_settings": "CAS 設定", + "callback_domain_name": "回調位址", + "callback_domain_name_error": "回調位址必須是目前的存取網域名稱", + "field_mapping": "使用者屬性對應", + "field_mapping_placeholder": "例如:{'{'}\"account\": \"saml2Account\", \"name\": \"saml2Name\", \"email\": \"email\"{'}'}", + "incorrect_please_re_enter": "格式錯誤,請重新填寫", + "in_json_format": "請輸入json格式", + "authorize_url": "授權位址", + "client_id": "用戶端 ID", + "client_secret": "用戶端密鑰", + "redirect_url": "回調位址", + "logout_redirect_url": "登出回調位址", + "logout_redirect_url_placeholder": "登出後預設跳轉至 SQLBot 登入頁面,可自訂設定登出後跳轉位址", + "oauth2_settings": "OAuth2 設定", + "scope": "授權範圍", + "userinfo_url": "使用者資訊位址", + "token_url": "權杖位址", + "revoke_url": "撤銷位址", + "oauth2_field_mapping_placeholder": "例如:{'{'}\"account\": \"oauth2Account\", \"name\": \"oauth2Name\", \"email\": \"email\"{'}'}", + "token_auth_method": "Token 認證方式", + "userinfo_auth_method": "使用者資訊認證方式", + "oidc_settings": "OIDC 設定", + "metadata_url": "中繼資料位址", + "realm": "領域", + "oidc_field_mapping_placeholder": "例如:{'{'}\"account\": \"oidcAccount\", \"name\": \"oidcName\", \"email\": \"email\"{'}'}", + "ldap_settings": "LDAP 設定", + "server_address": "伺服器位址", + "server_address_placeholder": "例如:ldap://ldap.example.com:389", + "bind_dn": "繫結 DN", + "bind_dn_placeholder": "例如:cn=admin,dc=example,dc=com", + "bind_pwd": "繫結密碼", + "ou": "使用者 OU", + "ou_placeholder": "例如:ou=users,dc=example,dc=com", + "user_filter": "使用者過濾器", + "user_filter_placeholder": "例如:uid 或 uid {0} email, 多屬性以 {1} 分割", + "ldap_field_mapping_placeholder": "例如:{'{'}\"account\": \"ldapAccount\", \"name\": \"ldapName\", \"email\": \"mail\"{'}'}", + "be_turned_on": "測試連線有效後,可開啟" + }, + "login": { + "default_login": "預設", + "ldap_login": "LDAP 登入", + "account_login": "帳號登入", + "other_login": "其他登入方式", + "pwd_invalid_error": "密碼已過期請聯繫管理員修改或重設", + "pwd_exp_tips": "密碼在 {0} 天後過期,請盡快修改密碼", + "qr_code": "QR Code", + "platform_disable": "{0}設定未開啟!", + "input_account": "請輸入帳號", + "redirect_2_auth": "正在跳轉至 {0} 認證,{1} 秒...", + "redirect_immediately": "立即跳轉", + "permission_invalid": "認證無效【目前帳號權限不夠】", + "scan_qr_login": "掃碼登入", + "no_auth_error": "未認證,即將跳轉登入頁面,{0} 秒..." + }, + "supplier": { + "alibaba_cloud_bailian": "阿里雲百煉", + "qianfan_model": "千帆大模型", + "deepseek": "DeepSeek", + "tencent_hunyuan": "騰訊混元", + "iflytek_spark": "訊飛星火", + "gemini": "Gemini", + "openai": "OpenAI", + "kimi": "Kimi", + "tencent_cloud": "騰訊雲", + "volcano_engine": "火山引擎", + "minimax": "MiniMax", + "generic_openai": "通用OpenAI" + }, + "modelType": { + "llm": "大語言模型" + }, + "audit": { + "system_log": "操作日誌", + "search_log": "搜尋日誌", + "operation_type": "操作類型", + "operation_details": "操作詳情", + "operation_user_name": "操作使用者", + "operation_status": "操作狀態", + "user_name": "操作日誌", + "oid_name": "工作區", + "ip_address": "IP 位址", + "create_time": "建立時間", + "no_log": "暫無日誌", + "all_236_terms": "是否匯出全部 {msg} 筆日誌?", + "export_hint": "是否匯出全部日誌?", + "export": "匯出", + "success": "成功", + "failed": "失敗", + "failed_info": "操作失敗詳情", + "opt_time": "操作時間" + }, + "api_key": { + "info_tips": "API Key 是您存取 SQLBot API 的密鑰,具有帳戶的完全權限,請您務必妥善保管!不要以任何方式公開 API Key 到外部渠道,避免被他人利用造成安全威脅。", + "create": "建立", + "to_doc": "檢視 API", + "trigger_limit": "最多支援建立 {0} 個 API Key" + } +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index f81e9fcbe..08c414c1a 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,3 +1,7 @@ +import 'core-js/features/object/has-own' +// @ts-ignore: css-has-pseudo/browser lacks official type definitions +import cssHasPseudo from 'css-has-pseudo/browser' + import { createApp } from 'vue' import { createPinia } from 'pinia' import './style.less' @@ -7,6 +11,26 @@ import { i18n } from './i18n' import VueDOMPurifyHTML from 'vue-dompurify-html' // import 'element-plus/dist/index.css' +cssHasPseudo(document) + +function supportsFlexGap() { + const flex = document.createElement('div') + flex.style.display = 'flex' + flex.style.flexDirection = 'column' + flex.style.rowGap = '1px' + + flex.appendChild(document.createElement('div')) + flex.appendChild(document.createElement('div')) + + document.body.appendChild(flex) + const isSupported = flex.scrollHeight === 1 + document.body.removeChild(flex) + + return isSupported +} + +document.documentElement.setAttribute('data-no-flex-gap', String(!supportsFlexGap())) + const app = createApp(App) const pinia = createPinia() diff --git a/frontend/src/stores/appearance.ts b/frontend/src/stores/appearance.ts index 2093f300a..ed368a7dc 100644 --- a/frontend/src/stores/appearance.ts +++ b/frontend/src/stores/appearance.ts @@ -315,7 +315,7 @@ const setLinkIcon = (linkWeb?: string) => { if (linkWeb) { link['href'] = baseUrl + linkWeb } else { - link['href'] = '/LOGO-fold.svg' + link['href'] = `${location.pathname}LOGO-fold.svg` } } } diff --git a/frontend/src/stores/assistant.ts b/frontend/src/stores/assistant.ts index 754d00a11..45474fcbd 100644 --- a/frontend/src/stores/assistant.ts +++ b/frontend/src/stores/assistant.ts @@ -1,10 +1,6 @@ import { defineStore } from 'pinia' import { store } from './index' import { chatApi, ChatInfo } from '@/api/chat' -import { useCache } from '@/utils/useCache' - -const { wsCache } = useCache() -const flagKey = 'sqlbit-assistant-flag' type Resolver = (value: T | PromiseLike) => void type Rejecter = (reason?: any) => void interface PendingRequest { @@ -16,7 +12,6 @@ interface AssistantState { id: string token: string assistant: boolean - flag: number type: number certificate: string online: boolean @@ -35,7 +30,6 @@ export const AssistantStore = defineStore('assistant', { id: '', token: '', assistant: false, - flag: 0, type: 0, certificate: '', online: false, @@ -61,9 +55,6 @@ export const AssistantStore = defineStore('assistant', { getAssistant(): boolean { return this.assistant }, - getFlag(): number { - return this.flag - }, getType(): number { return this.type }, @@ -176,14 +167,6 @@ export const AssistantStore = defineStore('assistant', { setAssistant(assistant: boolean) { this.assistant = assistant }, - setFlag(flag: number) { - if (wsCache.get(flagKey)) { - this.flag = wsCache.get(flagKey) - } else { - this.flag = flag - wsCache.set(flagKey, flag) - } - }, setPageEmbedded(embedded?: boolean) { this.pageEmbedded = !!embedded }, @@ -208,7 +191,6 @@ export const AssistantStore = defineStore('assistant', { return chat }, clear() { - wsCache.delete(flagKey) this.$reset() }, }, diff --git a/frontend/src/stores/chatConfig.ts b/frontend/src/stores/chatConfig.ts index a4ad4def9..478547826 100644 --- a/frontend/src/stores/chatConfig.ts +++ b/frontend/src/stores/chatConfig.ts @@ -4,21 +4,41 @@ import { request } from '@/utils/request.ts' import { formatArg } from '@/utils/utils.ts' interface ChatConfig { + sqlbot_name: string expand_thinking_block: boolean + hide_thinking_block: boolean limit_rows: boolean + show_sql: boolean + show_log: boolean } export const chatConfigStore = defineStore('chatConfigStore', { state: (): ChatConfig => { return { + sqlbot_name: 'SQLBot', expand_thinking_block: false, + hide_thinking_block: false, limit_rows: true, + show_sql: true, + show_log: true, } }, getters: { + getSQLBotName(): string { + return this.sqlbot_name + }, getExpandThinkingBlock(): boolean { return this.expand_thinking_block }, + getHideThinkingBlock(): boolean { + return this.hide_thinking_block + }, + getShowSQL(): boolean { + return this.show_sql + }, + getShowLog(): boolean { + return this.show_log + }, getLimitRows(): boolean { return this.limit_rows }, @@ -28,12 +48,26 @@ export const chatConfigStore = defineStore('chatConfigStore', { request.get('/system/parameter/chat').then((res: any) => { if (res) { res.forEach((item: any) => { + if (item.pkey === 'chat.hide_thinking_block') { + this.hide_thinking_block = formatArg(item.pval) + } if (item.pkey === 'chat.expand_thinking_block') { this.expand_thinking_block = formatArg(item.pval) } + if (item.pkey === 'chat.show_sql') { + this.show_sql = formatArg(item.pval) + } + if (item.pkey === 'chat.show_log') { + this.show_log = formatArg(item.pval) + } if (item.pkey === 'chat.limit_rows') { this.limit_rows = formatArg(item.pval) } + if (item.pkey === 'chat.sqlbot_name') { + if (item.pval && item.pval.trim().length > 0) { + this.sqlbot_name = item.pval + } + } }) } }) diff --git a/frontend/src/stores/user.ts b/frontend/src/stores/user.ts index 356a8619e..b72e061d7 100644 --- a/frontend/src/stores/user.ts +++ b/frontend/src/stores/user.ts @@ -177,6 +177,8 @@ export const UserStore = defineStore('user', { language = 'zh-CN' } else if (language === 'zh_CN') { language = 'zh-CN' + } else if (language === 'zh_TW') { + language = 'zh-TW' } else if (language === 'ko_KR') { language = 'ko-KR' } diff --git a/frontend/src/style.less b/frontend/src/style.less index 3798d8be9..0d1d9263b 100644 --- a/frontend/src/style.less +++ b/frontend/src/style.less @@ -1,3 +1,23 @@ +/* ========================================== + 老旧浏览器 Flex Gap 完美物理隔离补丁 + ========================================== */ + +/* 1. 当浏览器【不支持 flex gap】时,激活此特定命名空间 */ +:root[data-no-flex-gap='true'] .flex-gap-fallback { + display: flex; +} + +/* 2. 仅对标记了该 class 的容器下的【相邻子元素】水平方向加间距 */ +:root[data-no-flex-gap='true'] .flex-gap-fallback > * + * { + margin-left: var(--gap-size, 16px); /* 默认 16px,可通过变量动态修改 */ +} + +/* 3. 如果需要处理垂直排列(flex-direction: column) */ +:root[data-no-flex-gap='true'] .flex-gap-fallback.flex-col > * + * { + margin-left: 0; + margin-top: var(--gap-size, 16px); +} + :root { font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; line-height: 1.5; @@ -25,12 +45,15 @@ --shadow: 0 1px 3px rgba(0, 0, 0, 0.1); --ed-border-radius-base: 6px !important; --ed-border-color: #d9dcdf !important; +} + +:root:root { --ed-color-primary-light-7: #d2f1e9 !important; --ed-border-color: #d9dcdf !important; - --ed-border-radius-base: 6px !important; --ed-disabled-border-color: #d9dcdf !important; --ed-border-color-light: #dee0e3 !important; --ed-border-color-lighter: #dee0e3 !important; + --ed-border-radius-base: 6px !important; } a { @@ -85,7 +108,7 @@ body { .list-item_primary { height: 40px; - border-radius: 4px; + border-radius: 6px; padding: 8px 12px; cursor: pointer; display: flex; @@ -227,7 +250,11 @@ strong { .ed-select__popper { padding: 0 4px !important; .ed-select-dropdown__item { - border-radius: 4px; + border-radius: 6px; + } + + .ed-select-dropdown__list { + padding: 4px 0 !important; } } @@ -380,16 +407,6 @@ strong { line-height: 22px; } -.ed-pager li.number.number.number { - &:hover { - background-color: var(--ed-color-primary-60); - } - - &:active { - background-color: var(--ed-color-primary-80); - } -} - .ed-table.ed-table.ed-table { --ed-table-border-color: #1f232926; } @@ -420,12 +437,44 @@ strong { } } -.text-variables.text-variables { +.text-variables.text-variables, +.string-variables.string-variables { color: #3370ff; } -.number-variables.number-variables { +.number-variables.number-variables, +.int-variables.int-variables, +.float-variables.float-variables { color: #04b49c; } .datetime-variables.datetime-variables { color: #3370ff; } + +.ed-tree-node__content { + border-radius: 6px; +} + +.login-content { + /* 针对 Webkit 浏览器的自动填充样式重置 */ + input:-webkit-autofill, + input:-webkit-autofill:hover, + input:-webkit-autofill:focus, + input:-webkit-autofill:active { + /* 1. 使用足够大的内阴影来覆盖背景色,把 #ffffff 替换成你输入框原本的背景色 */ + -webkit-box-shadow: 0 0 0px 1000px #ffffff inset !important; + + /* 2. 由于常规的 color 属性也会失效,需要用这个属性修改文字颜色 */ + -webkit-text-fill-color: #333333 !important; + + /* 3. 保留光标的正常颜色 */ + caret-color: #333333; + } +} + +.ed-popper.is-pure:has(.ed-select-dropdown) { + padding: 0 !important; + + .ed-select-dropdown { + padding: 0 4px !important; + } +} diff --git a/frontend/src/utils/useCache.ts b/frontend/src/utils/useCache.ts index c7cda2afa..256da2142 100644 --- a/frontend/src/utils/useCache.ts +++ b/frontend/src/utils/useCache.ts @@ -1,13 +1,50 @@ import WebStorageCache from 'web-storage-cache' -type CacheType = 'sessionStorage' | 'localStorage' +type CacheType = 'localStorage' | 'sessionStorage' + +const getPathPrefix = () => { + const pathname = window.location.pathname + // eslint-disable-next-line no-useless-escape + const match = pathname.match(/^\/([^\/]+)/) + return match ? `${match[1]}_` : 'sqlbot_v1_' +} export const useCache = (type: CacheType = 'localStorage') => { - const wsCache: WebStorageCache = new WebStorageCache({ - storage: type, + const originalCache = new WebStorageCache({ storage: type }) + const prefix = getPathPrefix() + + const methodsNeedKeyPrefix = new Set(['get', 'delete', 'touch', 'add', 'replace']) + + const wrappedCache = new Proxy(originalCache, { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + get(target, prop, _receiver) { + const originalMethod = target[prop as keyof typeof target] + + if (typeof originalMethod !== 'function') { + return originalMethod + } + + if (methodsNeedKeyPrefix.has(prop as string)) { + return function (this: any, key: string, ...args: any[]) { + // 自动加上前缀 + const scopedKey = `${prefix}${key}` + return (originalMethod as (...args: any[]) => any).apply(target, [scopedKey, ...args]) + } + } + + if (prop === 'set') { + return function (this: any, key: string, value: any, ...args: any[]) { + const scopedKey = `${prefix}${key}` + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + return (originalMethod as Function).apply(target, [scopedKey, value, ...args]) + } + } + + return originalMethod.bind(target) + }, }) return { - wsCache, + wsCache: wrappedCache, } } diff --git a/frontend/src/utils/utils.ts b/frontend/src/utils/utils.ts index 65aa4fed3..01eb57390 100644 --- a/frontend/src/utils/utils.ts +++ b/frontend/src/utils/utils.ts @@ -50,7 +50,7 @@ export const getBrowserLocale = () => { } if (language.toLowerCase().startsWith('zh')) { const temp = language.toLowerCase().replace('_', '-') - return temp === 'zh' ? 'zh-CN' : temp === 'zh-cn' ? 'zh-CN' : 'tw' + return temp === 'zh' ? 'zh-CN' : temp === 'zh-cn' ? 'zh-CN' : 'zh-TW' } return language } diff --git a/frontend/src/views/chat/ChatCreator.vue b/frontend/src/views/chat/ChatCreator.vue index 831ab3950..aab5ae298 100644 --- a/frontend/src/views/chat/ChatCreator.vue +++ b/frontend/src/views/chat/ChatCreator.vue @@ -75,6 +75,11 @@ function selectDsInDialog(ds: any) { innerDs.value = ds.id } +function selectDsDirectlyInDialog(ds: any) { + innerDs.value = ds.id + confirmSelectDs() +} + function confirmSelectDs() { if (innerDs.value) { if (assistantStore.getType == 1) { @@ -207,6 +212,7 @@ defineExpose({ :is-selected="ele.id === innerDs" :description="ele.description" @select-ds="selectDsInDialog(ele)" + @select-ds-directly="selectDsDirectlyInDialog(ele)" > diff --git a/frontend/src/views/chat/ChatList.vue b/frontend/src/views/chat/ChatList.vue index c2a98aa3b..33799afc5 100644 --- a/frontend/src/views/chat/ChatList.vue +++ b/frontend/src/views/chat/ChatList.vue @@ -200,7 +200,7 @@ const handleConfirmPassword = () => {
+
{ display: flex; flex-direction: column; + --gap-size: 16px; gap: 16px; .group { @@ -399,7 +400,7 @@ const handleConfirmPassword = () => { diff --git a/frontend/src/views/dashboard/canvas/CanvasCore.vue b/frontend/src/views/dashboard/canvas/CanvasCore.vue index 7fe3a84a7..6a58d220f 100644 --- a/frontend/src/views/dashboard/canvas/CanvasCore.vue +++ b/frontend/src/views/dashboard/canvas/CanvasCore.vue @@ -727,7 +727,11 @@ function startResize(e: MouseEvent, point: string, item: CanvasItem, index: numb infoBox.value.point = point } -function containerClick() { +function containerClick(e?: MouseEvent) { + // Ignore clicks that originate inside a component so interacting with component + // content (e.g. selecting/copying cells in an S2 table) does not clear the + // current selection. Only blank-canvas clicks deselect. + if ((e?.target as HTMLElement)?.closest?.('.item')) return // remove current component info dashboardStore.setCurComponent(null) } diff --git a/frontend/src/views/dashboard/canvas/CanvasShape.vue b/frontend/src/views/dashboard/canvas/CanvasShape.vue index f49de87fb..a498b3590 100644 --- a/frontend/src/views/dashboard/canvas/CanvasShape.vue +++ b/frontend/src/views/dashboard/canvas/CanvasShape.vue @@ -52,7 +52,11 @@ const props = defineProps({ const { draggable } = toRefs(props) const shapeClick = (e: MouseEvent) => { - e.stopPropagation() + // Do not stopPropagation here: the click must reach window so components that + // rely on a window-level click listener (e.g. the S2 table copy interaction, + // which only enables Cmd/Ctrl+C when the last click landed on its canvas) work + // inside the editor. Deselection is handled by containerClick ignoring clicks + // that originate inside a component. e.preventDefault() } diff --git a/frontend/src/views/dashboard/canvas/ComponentBar.vue b/frontend/src/views/dashboard/canvas/ComponentBar.vue index bd6ac78b2..b2480804c 100644 --- a/frontend/src/views/dashboard/canvas/ComponentBar.vue +++ b/frontend/src/views/dashboard/canvas/ComponentBar.vue @@ -115,7 +115,7 @@ const doDeleteComponent = (e: MouseEvent) => { diff --git a/frontend/src/views/ds/RecommendedProblemConfigDialog.vue b/frontend/src/views/ds/RecommendedProblemConfigDialog.vue index 0f232266b..1e5f2abd1 100644 --- a/frontend/src/views/ds/RecommendedProblemConfigDialog.vue +++ b/frontend/src/views/ds/RecommendedProblemConfigDialog.vue @@ -164,6 +164,9 @@ defineExpose({ diff --git a/frontend/src/views/ds/TableRelationship.vue b/frontend/src/views/ds/TableRelationship.vue index 908a477a4..941fef0bf 100644 --- a/frontend/src/views/ds/TableRelationship.vue +++ b/frontend/src/views/ds/TableRelationship.vue @@ -467,7 +467,7 @@ const save = () => { font-synthesis: none; text-rendering: optimizeLegibility; outline: none; - border-radius: 4px; + border-radius: 6px; padding: 5px 11px; font-size: 12px; line-height: 20px; @@ -509,8 +509,6 @@ const save = () => { touch-action: none; box-sizing: border-box; position: relative; - min-width: 400px; - min-height: 600px; width: 100%; height: 100%; background-color: #f5f6f7; diff --git a/frontend/src/views/ds/index.vue b/frontend/src/views/ds/index.vue index 7acacfeeb..1a16c9e8c 100644 --- a/frontend/src/views/ds/index.vue +++ b/frontend/src/views/ds/index.vue @@ -27,7 +27,7 @@ >
-
+
@@ -77,6 +94,27 @@ defineExpose({ showErrorMask }) {{ $t('model.basic_model') }} {{ baseModel }}
+
+ {{ $t('authorized_space.authorized_space') }} + + {{ $t('permission.2', { msg: num }) }} + + + + + + +
{{ $t('dashboard.edit') }} - - - - - {{ $t('dashboard.delete') }} - + + + + + +
+
+ + + + {{ $t('authorized_space.authorized_space') }} +
+
+ + + + {{ $t('dashboard.delete') }} +
+
+
@@ -117,7 +184,7 @@ defineExpose({ showErrorMask }) + + diff --git a/frontend/src/views/system/model/Model.vue b/frontend/src/views/system/model/Model.vue index 76f514058..47da0cd49 100644 --- a/frontend/src/views/system/model/Model.vue +++ b/frontend/src/views/system/model/Model.vue @@ -16,8 +16,11 @@ import { getModelTypeName } from '@/entity/CommonEntity.ts' import { useI18n } from 'vue-i18n' import { get_supplier } from '@/entity/supplier' import { highlightKeyword } from '@/utils/xss' +import AuthorizedWorkspaceDialogForModel from '@/views/system/workspace/AuthorizedWorkspaceDialogForModel.vue' +import AuthorizedWorkspaceDraw from '@/views/system/workspace/AuthorizedWorkspaceDraw.vue' interface Model { + ws_mapping_count: number | undefined name: string model_type: string base_model: string @@ -249,6 +252,16 @@ const deleteHandler = (item: any) => { }) } +const AuthorizedWorkspaceDialogForModelRef = ref() +const AuthorizedWorkspaceDrawRef = ref() + +const handleAuthorizedSpace = (item: any) => { + AuthorizedWorkspaceDialogForModelRef.value.open(item.id) +} +const handleEditWorkspaceList = (item: any) => { + AuthorizedWorkspaceDrawRef.value.open(item.id) +} + const clickModel = (ele: any) => { activeStep.value = 1 supplierChang(ele) @@ -387,6 +400,7 @@ const submit = (item: any) => { :ref="(el: any) => setCardRef(el, index)" :key="ele.id" :name="ele.name" + :num="ele.ws_mapping_count" :supplier="ele.supplier" :model-type="getModelTypeName(ele['model_type'])" :base-model="ele['base_model']" @@ -394,6 +408,8 @@ const submit = (item: any) => { @edit="handleEditModel(ele)" @del="deleteHandler" @default="handleDefault(ele)" + @authorized-space="handleAuthorizedSpace(ele)" + @edit-workspace-list="handleEditWorkspaceList(ele)" > @@ -468,6 +484,8 @@ const submit = (item: any) => {
+ + diff --git a/frontend/src/views/system/workspace/AuthorizedWorkspaceDialogForModelAdd.vue b/frontend/src/views/system/workspace/AuthorizedWorkspaceDialogForModelAdd.vue new file mode 100644 index 000000000..4f1fc4ba6 --- /dev/null +++ b/frontend/src/views/system/workspace/AuthorizedWorkspaceDialogForModelAdd.vue @@ -0,0 +1,349 @@ + + + + diff --git a/frontend/src/views/system/workspace/AuthorizedWorkspaceDraw.vue b/frontend/src/views/system/workspace/AuthorizedWorkspaceDraw.vue new file mode 100644 index 000000000..808eb16ac --- /dev/null +++ b/frontend/src/views/system/workspace/AuthorizedWorkspaceDraw.vue @@ -0,0 +1,521 @@ + + + + + diff --git a/frontend/src/views/system/workspace/index.vue b/frontend/src/views/system/workspace/index.vue index da3519cd4..303c888e6 100644 --- a/frontend/src/views/system/workspace/index.vue +++ b/frontend/src/views/system/workspace/index.vue @@ -701,7 +701,7 @@ const handleCurrentChange = (val: number) => { display: flex; align-items: center; padding-left: 8px; - border-radius: 4px; + border-radius: 6px; cursor: pointer; padding-right: 8px; margin-bottom: 2px; @@ -876,7 +876,7 @@ const handleCurrentChange = (val: number) => {