feat: support multi-catalog for LLMs

- Create llm_catalog_rel model for one-to-many relationship
- Remove llmcatelogid from llm model
- Update SQL queries in utils.py and dspy files to use join
- Add maintenance UI (llm_catalog_rel_manage.ui) and API endpoints
- Filter options by user's orgid
This commit is contained in:
yumoqing 2026-05-16 21:31:19 +08:00
parent 6a33c5e9aa
commit 6cc3986a2d
14 changed files with 310 additions and 36 deletions

View File

@ -141,29 +141,37 @@ async def get_llmcatelogs():
return []
async def get_llms_by_catelog():
async def get_llms_by_catelog(catelogid=None):
env = ServerEnv()
async with get_sor_context(env, 'llmage') as sor:
today = curDateString()
sql = """select a.*, b.name as catelogname from llm a, llmcatelog b
where a.llmcatelogid = b.id
and enabled_date <= ${today}$
and expired_date > ${today}$
order by a.llmcatelogid, a.id
"""
recs = await sor.sqlExe(sql, {'today': today})
# Join with llm_catalog_rel to support multiple catalogs per LLM
sql = """select a.*, b.name as catelogname, rel.llmcatelogid as catelog_id
from llm a
join llm_catalog_rel rel on a.id = rel.llmid
join llmcatelog b on rel.llmcatelogid = b.id
where a.enabled_date <= ${today}$
and a.expired_date > ${today}$"""
params = {'today': today}
if catelogid:
sql += " and rel.llmcatelogid = ${catelogid}$"
params['catelogid'] = catelogid
sql += " order by rel.llmcatelogid, a.id"
recs = await sor.sqlExe(sql, params)
d = []
cid = ''
x = None
for r in recs:
if cid != r.llmcatelogid:
if cid != r.catelog_id:
x = {
'catelogid': r.llmcatelogid,
'catelogid': r.catelog_id,
'catelogname': r.catelogname,
'llms': [r]
}
d.append(x)
cid = r.llmcatelogid
cid = r.catelog_id
else:
x['llms'].append(r)
return d

Binary file not shown.

View File

@ -0,0 +1,11 @@
fields:
name|title|type|length:int|dec:int|nullable|default|comments
id|id|str|32|None|None|None|None
llmid|模型id|str|32|None|None|None|None
llmcatelogid|模型类型id|str|32|None|None|None|None
indexes:
name|fields|unique
idx_llm|llmid|None
idx_catelog|llmcatelogid|None
uq_llm_catelog|llmid,llmcatelogid|1

View File

@ -0,0 +1,32 @@
#!/usr/bin/env python3
import json
result = {'widgettype': 'Message', 'options': {'title': 'Error', 'message': 'Invalid', 'type': 'error'}}
try:
dbname = get_module_dbname('llmage')
llmid = params_kw.get('llmid', '')
catelogid = params_kw.get('llmcatelogid', '')
if not llmid or not catelogid:
result['options'] = {'title': 'Error', 'message': '请选择模型和目录', 'type': 'error'}
else:
from appPublic.uniqueID import getID
new_id = getID()
async with DBPools().sqlorContext(dbname) as sor:
# 检查是否已存在
check_sql = "select id from llm_catalog_rel where llmid = ${llmid}$ and llmcatelogid = ${catelogid}$"
exists = await sor.sqlExe(check_sql, {'llmid': llmid, 'catelogid': catelogid})
if exists:
result['options'] = {'title': '提示', 'message': '该关联已存在', 'type': 'warning'}
else:
data = {'id': new_id, 'llmid': llmid, 'llmcatelogid': catelogid}
await sor.C('llm_catalog_rel', data)
result['options'] = {'title': 'Success', 'message': '添加成功', 'type': 'success'}
except Exception as e:
result['options'] = {'title': 'Error', 'message': f'添加失败: {str(e)}', 'type': 'error'}
return json.dumps(result, ensure_ascii=False)

View File

@ -0,0 +1,20 @@
#!/usr/bin/env python3
import json
result = {'widgettype': 'Message', 'options': {'title': 'Error', 'message': 'Invalid', 'type': 'error'}}
try:
dbname = get_module_dbname('llmage')
rel_id = params_kw.get('id', '')
if not rel_id:
result['options'] = {'title': 'Error', 'message': '缺少ID参数', 'type': 'error'}
else:
async with DBPools().sqlorContext(dbname) as sor:
await sor.sqlExe("delete from llm_catalog_rel where id = ${id}$", {'id': rel_id})
result['options'] = {'title': 'Success', 'message': '删除成功', 'type': 'success'}
except Exception as e:
result['options'] = {'title': 'Error', 'message': f'删除失败: {str(e)}', 'type': 'error'}
return json.dumps(result, ensure_ascii=False)

View File

@ -0,0 +1,25 @@
#!/usr/bin/env python3
import json
result = {'success': False, 'rows': [], 'total': 0}
try:
dbname = get_module_dbname('llmage')
sql = """
select r.id, r.llmid, l.name as llm_name, r.llmcatelogid, c.name as catelog_name
from llm_catalog_rel r
join llm l on r.llmid = l.id
join llmcatelog c on r.llmcatelogid = c.id
order by l.name, c.name
"""
async with DBPools().sqlorContext(dbname) as sor:
rows = await sor.sqlExe(sql, {})
result['rows'] = [dict(r) for r in (rows or [])]
result['total'] = len(result['rows'])
result['success'] = True
except Exception as e:
result['error'] = str(e)
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -0,0 +1,30 @@
#!/usr/bin/env python3
import json
result = {'success': False, 'data': {'llms': [], 'catelogs': []}}
try:
dbname = get_module_dbname('llmage')
user_orgid = await get_userorgid()
async with DBPools().sqlorContext(dbname) as sor:
# Get all active LLMs belonging to the user's organization
today = await get_business_date(sor)
llms_sql = """select id, name from llm
where enabled_date <= ${today}$
and expired_date > ${today}$
and ownerid = ${ownerid}$
order by name"""
llms = await sor.sqlExe(llms_sql, {'today': today, 'ownerid': user_orgid})
result['data']['llms'] = [{'id': r['id'], 'text': r['name']} for r in (llms or [])]
# Get all catalogs (assuming catalogs are global or filtered similarly if needed)
catelogs = await sor.sqlExe("select id, name from llmcatelog order by name", {})
result['data']['catelogs'] = [{'id': r['id'], 'text': r['name']} for r in (catelogs or [])]
result['success'] = True
except Exception as e:
result['error'] = str(e)
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -3,16 +3,16 @@ lt = '文生视频'
if params_kw.type in ['文生视频', '参考生视频', '图生视频']:
lt = params_kw.type
async with get_sor_context(request._run_ns, 'llmage') as sor:
sql = '''select a.*, e.input_fields from llm a, llmcatelog b, upapp c, uapi d, uapiio e
where a.llmcatelogid = b.id
and a.upappid = c.id
and c.apisetid = d.apisetid
and a.apiname = d.name
and d.ioid = e.id
sql = '''select a.*, e.input_fields from llm a
join llm_catalog_rel rel on a.id = rel.llmid
join llmcatelog b on rel.llmcatelogid = b.id
join upapp c on a.upappid = c.id
join uapi d on c.apisetid = d.apisetid and a.apiname = d.name
join uapiio e on d.ioid = e.id
where b.name=${lt}$
and a.enabled_date <= ${biz_date}$
and ${biz_date}$ < a.expired_date
and ppid is not NULL
and b.name=${lt}$'''
and ppid is not NULL'''
biz_date = await get_business_date(sor)
recs = await sor.sqlExe(sql, {
'biz_date': biz_date,

View File

@ -1,7 +1,9 @@
dbname = get_module_dbname('llmage')
db = DBPools()
async with db.sqlorContext(dbname) as sor:
sql = """select * from llm where llmcatelogid = ${llmcatelogid}$ and id != ${llmid}$"""
sql = """select * from llm a
join llm_catalog_rel rel on a.id = rel.llmid
where rel.llmcatelogid = ${llmcatelogid}$ and a.id != ${llmid}$"""
ns = params_kw.copy()
recs = await sor.sqlExe(sql, ns)
for r in recs.get('rows', []):

View File

@ -13,15 +13,15 @@ y.user_message,
y.assisant_message
from (
select a.*, b.hfid, e.ioid, e.stream
from llm a, llmcatelog b,upapp c, uapiset d, uapi e
where a.llmcatelogid = b.id
and a.upappid = c.id
and c.apisetid = d.id
and e.apisetid = d.id
and a.apiname = e.name
from llm a
join llm_catalog_rel rel on a.id = rel.llmid
join llmcatelog b on rel.llmcatelogid = b.id
join upapp c on a.upappid = c.id
join uapiset d on c.apisetid = d.id
join uapi e on e.apisetid = d.id and a.apiname = e.name
) x left join historyformat y on x.hfid = y.id
left join uapiio z on x.ioid = z.id
where llmcatelogid = ${llmcatelogid}$
where rel.llmcatelogid = ${llmcatelogid}$
and x.id != ${llmid}$
"""
ns = params_kw.copy()

View File

@ -0,0 +1,145 @@
{
"widgettype": "VBox",
"options": {
"width": "100%",
"height": "100%",
"spacing": 16
},
"subwidgets": [
{
"widgettype": "Title2",
"options": {
"text": "LLM 目录关联管理",
"halign": "left"
}
},
{
"widgettype": "VBox",
"options": {
"width": "calc(100% - 40px)",
"margin": "0 20px",
"padding": "16px",
"bgcolor": "#f5f5f5",
"spacing": 12
},
"subwidgets": [
{
"widgettype": "Text",
"options": {
"text": "添加新关联",
"fontSize": "16px",
"fontWeight": "bold"
}
},
{
"widgettype": "Form",
"id": "add_form",
"options": {
"layout": "horizontal",
"cols": 3,
"fields": [
{
"name": "llmid",
"label": "选择模型",
"uitype": "select",
"dataurl": "{{entire_url('./api/llm_catelog_options.dspy')}}",
"data_field": "llms",
"placeholder": "请选择模型"
},
{
"name": "llmcatelogid",
"label": "选择目录",
"uitype": "select",
"dataurl": "{{entire_url('./api/llm_catelog_options.dspy')}}",
"data_field": "catelogs",
"placeholder": "请选择目录"
}
],
"buttons": [
{
"name": "add_btn",
"label": "添加关联",
"variant": "primary"
}
]
},
"binds": [
{
"wid": "add_btn",
"event": "click",
"actiontype": "urlwidget",
"target": "PopupWindow",
"popup_options": {"archor": "cc", "width": "30%", "height": "20%"},
"options": {
"url": "{{entire_url('./api/llm_catalog_rel_create.dspy')}}",
"params": {
"llmid": "$[add_form.llmid]$",
"llmcatelogid": "$[add_form.llmcatelogid]$"
}
}
}
]
}
]
},
{
"widgettype": "VBox",
"options": {
"width": "calc(100% - 40px)",
"margin": "0 20px",
"spacing": 12
},
"subwidgets": [
{
"widgettype": "Text",
"options": {
"text": "当前关联列表",
"fontSize": "16px",
"fontWeight": "bold"
}
},
{
"widgettype": "Tabular",
"id": "rel_table",
"options": {
"width": "100%",
"height": "400px",
"data_url": "{{entire_url('./api/llm_catalog_rel_list.dspy')}}",
"data_method": "GET",
"page_rows": 20,
"row_options": {
"fields": [
{"name": "llm_name", "title": "模型名称", "width": 200},
{"name": "catelog_name", "title": "目录名称", "width": 150},
{
"name": "actions",
"title": "操作",
"width": 100,
"uitype": "button",
"data": [
{"text": "删除", "event": "delete_rel"}
]
}
]
}
},
"binds": [
{
"wid": "self",
"event": "delete_rel",
"actiontype": "urlwidget",
"target": "PopupWindow",
"popup_options": {"archor": "cc", "width": "30%", "height": "20%"},
"options": {
"url": "{{entire_url('./api/llm_catalog_rel_delete.dspy')}}",
"params": {
"id": "$[event.params.id]$"
}
}
}
]
}
]
}
]
}

View File

@ -22,7 +22,6 @@
"models":[
{
"llmid":"{{llm.id}}",
"llmcatelogid":"{{llm.llmcatelogid}}",
"response_mode": "{{llm.stream}}",
"icon":"{{entire_url('/appbase/show_icon.dspy')}}?id={{llm.iconid}}",
"url":"{{entire_url('/llmage/llminference.dspy')}}",

View File

@ -17,10 +17,11 @@ if not params_kw.prompt:
return json_response(d, status=400)
env = request._run_ns
async with get_sor_context(env, 'llmage') as sor:
sql = """select a.* from llm a, llmcatelog b
where a.llmcatelogid=b.id
and a.model=${model}$
and b.name = ${lctype}$"""
sql = """select a.* from llm a
join llm_catalog_rel rel on a.id = rel.llmid
join llmcatelog b on rel.llmcatelogid = b.id
where b.name = ${lctype}$
and a.model=${model}$"""
recs = await sor.sqlExe(sql, {
'lctype': lctype,
'model': params_kw.model or 'qwen3-max'

View File

@ -30,10 +30,11 @@ if not params_kw.prompt and not params_kw.messages:
return json_response(d, status=400)
env = request._run_ns
async with get_sor_context(env, 'llmage') as sor:
sql = """select a.* from llm a, llmcatelog b
where a.llmcatelogid=b.id
and a.model=${model}$
and b.name = ${lctype}$"""
sql = """select a.* from llm a
join llm_catalog_rel rel on a.id = rel.llmid
join llmcatelog b on rel.llmcatelogid = b.id
where b.name = ${lctype}$
and a.model=${model}$"""
recs = await sor.sqlExe(sql, {
'lctype': lctype,
'model': params_kw.model or 'qwen3-max'