diff --git a/backend/alembic/versions/019_upgrade_model.py b/backend/alembic/versions/019_upgrade_model.py new file mode 100644 index 00000000..3e55c1ef --- /dev/null +++ b/backend/alembic/versions/019_upgrade_model.py @@ -0,0 +1,61 @@ +"""019_upgrade_model + +Revision ID: dcaecd481715 +Revises: 863105882eba +Create Date: 2025-07-04 14:45:56.927188 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'dcaecd481715' +down_revision = '863105882eba' +branch_labels = None +depends_on = None + + +def upgrade(): + op.drop_index(op.f('ix_ai_model_id'), table_name='ai_model') + op.drop_table('ai_model') + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('ai_model', + sa.Column('id', sa.BigInteger(), nullable=False), + sa.Column('api_key', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('api_domain', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('protocol', sa.Integer(), nullable=False), + sa.Column('supplier', sa.Integer(), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('model_type', sa.Integer(), nullable=False), + sa.Column('base_model', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('default_model', sa.Boolean(), nullable=False), + sa.Column('config', sa.Text(), nullable=False), + sa.Column('status', sa.Integer(), nullable=False), + sa.Column('create_time', sa.BigInteger(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_ai_model_id'), 'ai_model', ['id'], unique=False) + # ### end Alembic commands ### + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_ai_model_id'), table_name='ai_model') + op.drop_table('ai_model') + + op.create_table('ai_model', + sa.Column('id', sa.BigInteger(), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('type', sa.Integer(), nullable=False), + sa.Column('api_key', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('endpoint', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('max_context_window', sa.Integer(), nullable=False), + sa.Column('temperature', sa.Float(), nullable=False), + sa.Column('status', sa.Boolean(), nullable=False), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('create_time', sa.BigInteger(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_ai_model_id'), 'ai_model', ['id'], unique=False) + # ### end Alembic commands ### diff --git a/backend/apps/ai_model/model_factory.py b/backend/apps/ai_model/model_factory.py index 2aa5ff39..605c1d03 100644 --- a/backend/apps/ai_model/model_factory.py +++ b/backend/apps/ai_model/model_factory.py @@ -1,9 +1,12 @@ +import json from pydantic import BaseModel from typing import Optional, Dict, Any, Type from abc import ABC, abstractmethod from langchain_core.language_models import BaseLLM as LangchainBaseLLM from langchain_openai import ChatOpenAI +from sqlmodel import Session, select +from common.core.db import engine from apps.system.models.system_model import AiModelDetail @@ -11,6 +14,7 @@ from apps.system.models.system_model import AiModelDetail class LLMConfig(BaseModel): """Base configuration class for large language models""" + model_id: Optional[int] = None model_type: str # Model type: openai/tongyi/vllm etc. model_name: str # Specific model name api_key: Optional[str] = None @@ -93,7 +97,7 @@ class LLMFactory: # todo -def get_llm_config(aimodel: AiModelDetail) -> LLMConfig: +""" def get_llm_config(aimodel: AiModelDetail) -> LLMConfig: config = LLMConfig( model_type="openai", model_name=aimodel.name, @@ -101,4 +105,31 @@ def get_llm_config(aimodel: AiModelDetail) -> LLMConfig: api_base_url=aimodel.endpoint, additional_params={"temperature": aimodel.temperature} ) - return config \ No newline at end of file + return config """ + +def get_default_config() -> LLMConfig: + with Session(engine) as session: + db_model = session.exec( + select(AiModelDetail).where(AiModelDetail.default_model == True) + ).first() + if not db_model: + raise ValueError("The system default model has not been set") + + additional_params = {} + if db_model.config: + try: + config_raw = json.loads(db_model.config) + additional_params = {item["key"]: item["val"] for item in config_raw if "key" in item and "val" in item} + except Exception: + pass + + # 构造 LLMConfig + return LLMConfig( + model_id=db_model.id, + model_type="openai", + model_name=db_model.base_model, + api_key=db_model.api_key, + api_base_url=db_model.api_domain, + additional_params=additional_params + ) + \ No newline at end of file diff --git a/backend/apps/chat/task/llm.py b/backend/apps/chat/task/llm.py index 5418beb6..46f6a505 100644 --- a/backend/apps/chat/task/llm.py +++ b/backend/apps/chat/task/llm.py @@ -11,7 +11,7 @@ from langchain_core.messages import BaseMessage, SystemMessage, HumanMessage, AI from sqlalchemy import select from sqlalchemy.orm import load_only -from apps.ai_model.model_factory import LLMConfig, LLMFactory, get_llm_config +from apps.ai_model.model_factory import LLMConfig, LLMFactory, get_default_config from apps.chat.curd.chat import save_question, save_full_sql_message, save_full_sql_message_and_answer, save_sql, \ save_error_message, save_sql_exec_data, save_full_chart_message, save_full_chart_message_and_answer, save_chart, \ finish_record, save_full_analysis_message_and_answer, save_full_predict_message_and_answer, save_predict_data, \ @@ -21,7 +21,6 @@ from apps.datasource.crud.datasource import get_table_schema from apps.datasource.models.datasource import CoreDatasource from apps.db.db import exec_sql from apps.system.crud.user import get_user_info -from apps.system.models.system_model import AiModelDetail from common.core.deps import SessionDep, CurrentUser warnings.filterwarnings("ignore") @@ -58,13 +57,6 @@ class LLMService: chat_question.engine = ds.type_name if ds.type != 'excel' else 'PostgreSQL' - # Get available AI model - aimodel = self.session.exec(select(AiModelDetail).where( - AiModelDetail.status == True, - AiModelDetail.api_key.is_not(None) - )).first() - if not aimodel and aimodel[0]: - raise Exception("No available AI model configuration found") history_records: List[ChatRecord] = list( map(lambda x: ChatRecord(**x.model_dump()), filter(lambda r: True if r.first_chat != True else False, @@ -79,8 +71,9 @@ class LLMService: self.ds = CoreDatasource(**ds.model_dump()) if ds else None self.chat_question = chat_question - self.chat_question.ai_modal_id = aimodel[0].id - self.config = get_llm_config(aimodel[0]) + self.config = get_default_config() + self.chat_question.ai_modal_id = self.config.model_id + # Create LLM instance through factory llm_instance = LLMFactory.create_llm(self.config) diff --git a/backend/apps/system/api/aimodel.py b/backend/apps/system/api/aimodel.py index eabc1fd6..52074a57 100644 --- a/backend/apps/system/api/aimodel.py +++ b/backend/apps/system/api/aimodel.py @@ -1,90 +1,90 @@ -from fastapi import APIRouter -from sqlmodel import select +import json +from typing import List, Union +from apps.system.schemas.ai_model_schema import AiModelConfigItem, AiModelCreator, AiModelEditor, AiModelGridItem +from fastapi import APIRouter, Query +from sqlmodel import func, select from apps.system.models.system_model import AiModelDetail -from apps.system.schemas.system_schema import model_status from common.core.deps import SessionDep -from common.core.pagination import Paginator -from common.core.schemas import PaginatedResponse, PaginationParams from common.utils.time import get_timestamp router = APIRouter(tags=["system/aimodel"], prefix="/system/aimodel") - -@router.get("/pager/{pageNum}/{pageSize}", response_model=PaginatedResponse[AiModelDetail]) -async def pager( +@router.get("", response_model=list[AiModelGridItem]) +async def query( session: SessionDep, - pageNum: int, - pageSize: int + keyword: Union[str, None] = Query(default=None, max_length=255) ): - pagination = PaginationParams(page=pageNum, size=pageSize) - paginator = Paginator(session) - filters = {} - return await paginator.get_paginated_response( - model=AiModelDetail, - pagination=pagination, - **filters) + statement = select(AiModelDetail.id, + AiModelDetail.name, + AiModelDetail.model_type, + AiModelDetail.base_model, + AiModelDetail.supplier, + AiModelDetail.default_model) + if keyword is not None: + statement = statement.where(AiModelDetail.name.like(f"%{keyword}%")) + + items = session.exec(statement).all() + return items - -@router.get("/{id}", response_model=AiModelDetail) +@router.get("/{id}", response_model=AiModelEditor) async def get_model_by_id( session: SessionDep, id: int ): - term = session.get(AiModelDetail, id) - return term + db_model = session.get(AiModelDetail, id) + if not db_model: + raise ValueError(f"AiModelDetail with id {id} not found") + config_list: List[AiModelConfigItem] = [] + if db_model.config: + try: + raw = json.loads(db_model.config) + config_list = [AiModelConfigItem(**item) for item in raw] + except Exception: + pass + data = AiModelDetail.model_validate(db_model).model_dump(exclude_unset=True) + data.pop("config", None) + data["config_list"] = config_list + return AiModelEditor(**data) -@router.post("", response_model=AiModelDetail) +@router.post("") async def add_model( session: SessionDep, - creator: AiModelDetail + creator: AiModelCreator ): - data = AiModelDetail.model_validate(creator) - data.create_time = get_timestamp() - session.add(data) + data = creator.model_dump(exclude_unset=True) + data["config"] = json.dumps([item.model_dump(exclude_unset=True) for item in creator.config_list]) + data.pop("config_list", None) + detail = AiModelDetail.model_validate(data) + detail.create_time = get_timestamp() + count = session.exec(select(func.count(AiModelDetail.id))).one() + if count == 0: + detail.default_model = True + session.add(detail) session.commit() - return creator - -@router.put("", response_model=AiModelDetail) -async def update_terminology( +@router.put("") +async def update_model( session: SessionDep, - model: AiModelDetail + editor: AiModelEditor ): - model.id = int(model.id) - term = session.exec(select(AiModelDetail).where(AiModelDetail.id == model.id)).first() - update_data = model.model_dump(exclude_unset=True) - for field, value in update_data.items(): - setattr(term, field, value) - session.add(term) + id = int(editor.id) + data = editor.model_dump(exclude_unset=True) + 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) + db_model.sqlmodel_update(update_data) + session.add(db_model) session.commit() - return model - -@router.delete("/{id}", response_model=AiModelDetail) -async def delete_terminology( +@router.delete("/{id}") +async def delete_model( session: SessionDep, id: int ): - term = session.exec(select(AiModelDetail).where(AiModelDetail.id == id)).first() - session.delete(term) + item = session.get(AiModelDetail, id) + session.delete(item) session.commit() - return { - "message": f"AiModel with ID {id} deleted successfully." - } - - -@router.patch("/status", response_model=dict) -async def status(session: SessionDep, dto: model_status): - ids = dto.ids - status = dto.status - if not ids: - return {"message": "ids is empty"} - statement = select(AiModelDetail).where(AiModelDetail.id.in_(ids)) - terms = session.exec(statement).all() - for term in terms: - term.status = status - session.add(term) - session.commit() - return {"message": f"AiModel with IDs {ids} updated successfully."} + \ No newline at end of file diff --git a/backend/apps/system/models/system_model.py b/backend/apps/system/models/system_model.py index cc35045e..89d7d997 100644 --- a/backend/apps/system/models/system_model.py +++ b/backend/apps/system/models/system_model.py @@ -1,18 +1,20 @@ - -from sqlmodel import BigInteger, Field, SQLModel + +from sqlmodel import BigInteger, Field, Text, SQLModel from common.core.models import SnowflakeBase class AiModelBase: + supplier: int = Field(nullable=False) name: str = Field(max_length=255, nullable=False) - type: int = Field(nullable=False) + model_type: int = Field(nullable=False) + base_model: str = Field(max_length = 255, nullable=False) + default_model: bool = Field(default=False, nullable=False) -class AiModelDetail(AiModelBase, SnowflakeBase, table=True): +class AiModelDetail(SnowflakeBase, AiModelBase, table=True): __tablename__ = "ai_model" api_key: str | None = Field(max_length=255, nullable=True) - endpoint: str = Field(max_length=255, nullable=False) - max_context_window: int = Field(default=0) - temperature: float = Field(default=0.0) - status: bool = Field(default=True) - description: str | None = Field(max_length=255, nullable=True) + api_domain: str = Field(max_length=255, 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()) \ No newline at end of file diff --git a/backend/apps/system/schemas/ai_model_schema.py b/backend/apps/system/schemas/ai_model_schema.py new file mode 100644 index 00000000..5bfd206b --- /dev/null +++ b/backend/apps/system/schemas/ai_model_schema.py @@ -0,0 +1,28 @@ + +from typing import List +from pydantic import BaseModel + +from common.core.schemas import BaseCreatorDTO + +class AiModelItem(BaseModel): + name: str + model_type: int + base_model: str + supplier: int + default_model: bool = False + +class AiModelGridItem(AiModelItem, BaseCreatorDTO): + pass + +class AiModelConfigItem(BaseModel): + key: str + val: object + name: str + +class AiModelCreator(AiModelItem): + api_domain: str + api_key: str + config_list: List[AiModelConfigItem] + +class AiModelEditor(AiModelCreator, BaseCreatorDTO): + pass \ No newline at end of file diff --git a/backend/common/core/response_middleware.py b/backend/common/core/response_middleware.py index f0979411..114bdf99 100644 --- a/backend/common/core/response_middleware.py +++ b/backend/common/core/response_middleware.py @@ -4,7 +4,7 @@ from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import JSONResponse from starlette.exceptions import HTTPException from starlette.requests import Request - +from common.core.config import settings class ResponseMiddleware(BaseHTTPMiddleware): def __init__(self, app): super().__init__(app) @@ -12,7 +12,7 @@ class ResponseMiddleware(BaseHTTPMiddleware): async def dispatch(self, request, call_next): response = await call_next(request) - if isinstance(response, JSONResponse): + if isinstance(response, JSONResponse) or request.url.path == f"{settings.API_V1_STR}/openapi.json": return response if response.headers.get("content-type") == "application/json": diff --git a/backend/common/core/schemas.py b/backend/common/core/schemas.py index a4637ff8..30746692 100644 --- a/backend/common/core/schemas.py +++ b/backend/common/core/schemas.py @@ -46,4 +46,12 @@ class PaginatedResponse(BaseModel, Generic[T]): total: int page: int size: int - total_pages: int \ No newline at end of file + total_pages: int + + +class BaseCreatorDTO(BaseModel): + id: int + class Config: + json_encoders = { + int: lambda v: str(v) if isinstance(v, int) and v > (2**53 - 1) else v + } \ No newline at end of file diff --git a/frontend/src/api/system.ts b/frontend/src/api/system.ts index 63dceff5..0153122c 100644 --- a/frontend/src/api/system.ts +++ b/frontend/src/api/system.ts @@ -1,8 +1,8 @@ import { request } from '@/utils/request' export const modelApi = { - pager: (pageNumber: number, pageSize: number) => - request.get(`/system/aimodel/pager/${pageNumber}/${pageSize}`), + queryAll: (keyword?: string) => + request.get('/system/aimodel', { params: keyword ? { keyword } : {} }), add: (data: any) => request.post('/system/aimodel', data), edit: (data: any) => request.put('/system/aimodel', data), delete: (id: number) => request.delete(`/system/aimodel/${id}`), diff --git a/frontend/src/entity/CommonEntity.ts b/frontend/src/entity/CommonEntity.ts index 543a4ca5..a515e943 100644 --- a/frontend/src/entity/CommonEntity.ts +++ b/frontend/src/entity/CommonEntity.ts @@ -4,7 +4,7 @@ export interface SelectOption { } export const modelTypeOptions: SelectOption[] = [ - { label: 'OpenAI', value: 0 }, + { label: '大语言模型', value: 0 }, /* { label: 'Anthropic', value: 1 }, { label: 'Baidu', value: 2 }, { label: 'iFLYTEK', value: 3 }, diff --git a/frontend/src/entity/supplier.ts b/frontend/src/entity/supplier.ts new file mode 100644 index 00000000..62a5c819 --- /dev/null +++ b/frontend/src/entity/supplier.ts @@ -0,0 +1,54 @@ +import icon_alybl_colorful from '@/assets/model/icon_alybl_colorful.png' +import icon_bdyzn_colorful from '@/assets/model/icon_bdyzn_colorful.png' +import icon_deepseek_colorful from '@/assets/model/icon_deepseek_colorful.png' +type ModelConfig = Record + +export const supplierList: Array<{ + id: number + name: string + icon: any + model_config: ModelConfig +}> = [ + { + id: 1, + name: '阿里云百炼', + icon: icon_alybl_colorful, + model_config: { + 0: { + api_domain: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + model_options: ['qwen-plus'], + }, + }, + }, + { + id: 2, + name: '千帆大模型I', + icon: icon_bdyzn_colorful, + model_config: { + 0: { + api_domain: 'https://qianfan.baidubce.com/v2/', + model_options: ['ernie-x1-turbo-32k'], + }, + }, + }, + { + id: 3, + name: 'DeepSeek', + icon: icon_deepseek_colorful, + model_config: { + 0: { + api_domain: 'https://api.deepseek.com', + model_options: ['deepseek-chat'], + }, + }, + }, +] + +export const base_model_options = (supplier_id: number, model_type?: number) => { + const supplier = get_supplier(supplier_id) + return supplier?.model_config[model_type || 0].model_options +} + +export const get_supplier = (supplier_id: number) => { + return supplierList.find((item: any) => item.id === supplier_id) +} diff --git a/frontend/src/views/system/model/Card.vue b/frontend/src/views/system/model/Card.vue index 7bb38832..516fbef2 100644 --- a/frontend/src/views/system/model/Card.vue +++ b/frontend/src/views/system/model/Card.vue @@ -1,8 +1,8 @@ - - + + - +
{{ t('model.advanced_settings') }}
-
+
{{ t('model.model_parameters') }} @@ -220,11 +229,11 @@ defineExpose({
-
+
- + - + -import { ref, shallowRef, computed } from 'vue' +import { ref, computed } from 'vue' import icon_searchOutline_outlined from '@/assets/svg/icon_search-outline_outlined.svg' import EmptyBackground from '@/views/dashboard/common/EmptyBackground.vue' -import icon_Azure_OpenAI_colorful from '@/assets/model/icon_Azure_OpenAI_colorful.png' -interface Model { - name: string - modleType: string - baseModle: string - img?: string -} +import { supplierList } from '@/entity/supplier' + const keywords = ref('') -const modelList = shallowRef([ - { - img: icon_Azure_OpenAI_colorful, - name: '千帆大模型-chinese', - }, - { - img: icon_Azure_OpenAI_colorful, - name: '千帆大模-chinese', - }, -] as Model[]) + const modelListWithSearch = computed(() => { - if (!keywords.value) return modelList.value - return modelList.value.filter((ele) => - ele.name.toLowerCase().includes(keywords.value.toLowerCase()) - ) + if (!keywords.value) return supplierList + return supplierList.filter((ele) => ele.name.toLowerCase().includes(keywords.value.toLowerCase())) }) const emits = defineEmits(['clickModel']) const handleModelClick = (item: any) => { @@ -54,7 +38,7 @@ const handleModelClick = (item: any) => { class="model" @click="handleModelClick(ele)" > - + {{ ele.name }}
{ display: flex; justify-content: space-between; height: calc(100% - 40px); - + gap: 16px; .model:nth-child(odd) { margin-left: 0; } diff --git a/frontend/src/views/system/model/ModelListSide.vue b/frontend/src/views/system/model/ModelListSide.vue index 78552e96..d1e7756f 100644 --- a/frontend/src/views/system/model/ModelListSide.vue +++ b/frontend/src/views/system/model/ModelListSide.vue @@ -1,14 +1,9 @@