feat: support direct LLM provider config with model sync
- Fix _call_provider to support custom providers (OpenAI-compatible fallback) instead of raising ValueError for unknown providers - Add POST /admin/ppt/sync-models endpoint that fetches available models from the configured provider's API - Redesign PptSettingsAdmin UI: replace Dify model dropdown with per-provider 'sync models' button + searchable model selector - Remove dead Dify-specific code (fetchDifyModels, applyDifyModel) - Update deepseek default model from deepseek-chat to deepseek-v4-pro - API Key field now always shown (not conditional on non-dify)
This commit is contained in:
parent
8e897276e7
commit
dbe2f9202d
@ -244,3 +244,14 @@ def get_settings():
|
|||||||
def update_settings():
|
def update_settings():
|
||||||
updated_by = getattr(request, "user_id", "")
|
updated_by = getattr(request, "user_id", "")
|
||||||
return jsonify(ppt_admin_service.update_settings(request.get_json(force=True), updated_by))
|
return jsonify(ppt_admin_service.update_settings(request.get_json(force=True), updated_by))
|
||||||
|
|
||||||
|
|
||||||
|
@ppt_admin_bp.route("/sync-models", methods=["POST"])
|
||||||
|
@permission_required("config_manage")
|
||||||
|
def sync_models():
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
return jsonify(ppt_admin_service.sync_models(
|
||||||
|
provider=data.get("provider", ""),
|
||||||
|
api_key=data.get("api_key", ""),
|
||||||
|
base_url=data.get("base_url", ""),
|
||||||
|
))
|
||||||
|
|||||||
@ -566,3 +566,58 @@ class PptAdminService:
|
|||||||
db.session.add(SystemSetting(key=key, value=str(value), updated_by=updated_by))
|
db.session.add(SystemSetting(key=key, value=str(value), updated_by=updated_by))
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return {"code": 0, "data": None}
|
return {"code": 0, "data": None}
|
||||||
|
|
||||||
|
def sync_models(self, provider: str, api_key: str, base_url: str = "") -> dict:
|
||||||
|
"""从供应商 API 拉取可用模型列表。"""
|
||||||
|
if not api_key:
|
||||||
|
return {"code": 1001, "message": "请填写 API Key", "data": None}
|
||||||
|
|
||||||
|
try:
|
||||||
|
models = self._fetch_provider_models(provider, api_key, base_url)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"同步模型失败: {e}")
|
||||||
|
return {"code": 5001, "message": f"同步失败: {e}", "data": None}
|
||||||
|
|
||||||
|
if not models:
|
||||||
|
return {"code": 0, "data": {"models": [], "message": "未获取到模型"}}
|
||||||
|
|
||||||
|
return {"code": 0, "data": {"models": models}}
|
||||||
|
|
||||||
|
def _fetch_provider_models(self, provider: str, api_key: str, base_url: str) -> list:
|
||||||
|
"""根据供应商类型拉取模型列表。"""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
if provider == "deepseek":
|
||||||
|
url = "https://api.deepseek.com/v1/models"
|
||||||
|
headers = {"Authorization": f"Bearer {api_key}"}
|
||||||
|
resp = httpx.get(url, headers=headers, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return [m["id"] for m in resp.json().get("data", [])]
|
||||||
|
|
||||||
|
elif provider == "gemini":
|
||||||
|
url = f"https://generativelanguage.googleapis.com/v1/models?key={api_key}"
|
||||||
|
resp = httpx.get(url, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return [m["name"].split("/")[-1] for m in resp.json().get("models", [])]
|
||||||
|
|
||||||
|
elif provider == "minimax":
|
||||||
|
url = "https://api.minimax.chat/v1/models"
|
||||||
|
headers = {"Authorization": f"Bearer {api_key}"}
|
||||||
|
resp = httpx.get(url, headers=headers, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
models = data.get("models") or data.get("data", [])
|
||||||
|
return [m.get("id") or m.get("model", "") for m in models if m]
|
||||||
|
|
||||||
|
elif provider == "dify":
|
||||||
|
return [m["model"] for m in (self._fetch_dify_models() or [])]
|
||||||
|
|
||||||
|
else:
|
||||||
|
# 自定义供应商:OpenAI 兼容 /models 端点
|
||||||
|
if not base_url:
|
||||||
|
return []
|
||||||
|
url = f"{base_url.rstrip('/')}/models"
|
||||||
|
headers = {"Authorization": f"Bearer {api_key}"}
|
||||||
|
resp = httpx.get(url, headers=headers, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return [m["id"] for m in resp.json().get("data", [])]
|
||||||
|
|||||||
@ -35,7 +35,7 @@ PROVIDERS = {
|
|||||||
"deepseek": LLMProviderConfig(
|
"deepseek": LLMProviderConfig(
|
||||||
name="deepseek",
|
name="deepseek",
|
||||||
base_url="https://api.deepseek.com/v1",
|
base_url="https://api.deepseek.com/v1",
|
||||||
model="deepseek-chat",
|
model="deepseek-v4-pro",
|
||||||
max_retries=2,
|
max_retries=2,
|
||||||
rate_limit=0,
|
rate_limit=0,
|
||||||
),
|
),
|
||||||
@ -149,7 +149,16 @@ async def _call_provider(
|
|||||||
"generationConfig": {"temperature": 0.3, "maxOutputTokens": 4096},
|
"generationConfig": {"temperature": 0.3, "maxOutputTokens": 4096},
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unknown provider: {config.name}")
|
# 自定义供应商:OpenAI 兼容格式
|
||||||
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
|
base = config.base_url.rstrip("/")
|
||||||
|
url = f"{base}/chat/completions"
|
||||||
|
body = {
|
||||||
|
"model": config.model,
|
||||||
|
"messages": messages,
|
||||||
|
"temperature": 0.3,
|
||||||
|
"max_tokens": 4096,
|
||||||
|
}
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=timeout_s) as client:
|
async with httpx.AsyncClient(timeout=timeout_s) as client:
|
||||||
resp = await client.post(url, json=body, headers=headers)
|
resp = await client.post(url, json=body, headers=headers)
|
||||||
@ -159,20 +168,7 @@ async def _call_provider(
|
|||||||
latency_ms = (time.monotonic() - start) * 1000
|
latency_ms = (time.monotonic() - start) * 1000
|
||||||
|
|
||||||
# 解析响应
|
# 解析响应
|
||||||
if config.name in ("deepseek", "minimax"):
|
if config.name == "gemini":
|
||||||
content = ""
|
|
||||||
choices = data.get("choices", [])
|
|
||||||
if choices:
|
|
||||||
content = choices[0].get("message", {}).get("content", "")
|
|
||||||
usage = data.get("usage")
|
|
||||||
tokens = None
|
|
||||||
if usage:
|
|
||||||
tokens = {
|
|
||||||
"input": usage.get("prompt_tokens", 0),
|
|
||||||
"output": usage.get("completion_tokens", 0),
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
# Gemini
|
|
||||||
content = ""
|
content = ""
|
||||||
candidates = data.get("candidates", [])
|
candidates = data.get("candidates", [])
|
||||||
if candidates:
|
if candidates:
|
||||||
@ -186,6 +182,19 @@ async def _call_provider(
|
|||||||
"input": usage_meta.get("promptTokenCount", 0),
|
"input": usage_meta.get("promptTokenCount", 0),
|
||||||
"output": usage_meta.get("candidatesTokenCount", 0),
|
"output": usage_meta.get("candidatesTokenCount", 0),
|
||||||
}
|
}
|
||||||
|
else:
|
||||||
|
# OpenAI 兼容格式(deepseek / minimax / 自定义供应商)
|
||||||
|
content = ""
|
||||||
|
choices = data.get("choices", [])
|
||||||
|
if choices:
|
||||||
|
content = choices[0].get("message", {}).get("content", "")
|
||||||
|
usage = data.get("usage")
|
||||||
|
tokens = None
|
||||||
|
if usage:
|
||||||
|
tokens = {
|
||||||
|
"input": usage.get("prompt_tokens", 0),
|
||||||
|
"output": usage.get("completion_tokens", 0),
|
||||||
|
}
|
||||||
|
|
||||||
return LLMResponse(
|
return LLMResponse(
|
||||||
content=content,
|
content=content,
|
||||||
|
|||||||
@ -20,34 +20,28 @@
|
|||||||
|
|
||||||
<el-form-item label="模型名称">
|
<el-form-item label="模型名称">
|
||||||
<div style="display: flex; gap: 8px; width: 100%">
|
<div style="display: flex; gap: 8px; width: 100%">
|
||||||
<el-input v-model="form.ppt_llm_model" placeholder="如 deepseek-chat" />
|
<el-select
|
||||||
<el-button v-if="!difyModels.length" @click="fetchDifyModels" :loading="fetchingModels">从 Dify 获取</el-button>
|
v-if="pptAvailableModels.length"
|
||||||
<el-dropdown v-else trigger="click" @command="(cmd: string) => applyDifyModel('ppt', cmd)">
|
v-model="form.ppt_llm_model"
|
||||||
<el-button>从 Dify 获取 <el-icon><ArrowDown /></el-icon></el-button>
|
filterable allow-create
|
||||||
<template #dropdown>
|
placeholder="选择或输入模型名"
|
||||||
<el-dropdown-menu>
|
style="flex: 1"
|
||||||
<el-dropdown-item v-for="m in difyModels" :key="m.provider + '/' + m.model"
|
>
|
||||||
:command="m.provider + '|' + m.model">
|
<el-option v-for="m in pptAvailableModels" :key="m" :label="m" :value="m" />
|
||||||
{{ m.label }} ({{ m.provider }}/{{ m.model }})
|
</el-select>
|
||||||
</el-dropdown-item>
|
<el-input v-else v-model="form.ppt_llm_model" placeholder="如 deepseek-v4-pro" style="flex: 1" />
|
||||||
</el-dropdown-menu>
|
<el-button @click="handlePptSyncModels" :loading="syncingPptModels">同步模型</el-button>
|
||||||
</template>
|
|
||||||
</el-dropdown>
|
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item v-if="form.ppt_llm_provider !== 'dify'" label="API Key">
|
<el-form-item label="API Key">
|
||||||
<el-input v-model="form.ppt_llm_api_key" type="password" show-password placeholder="留空则使用环境变量配置" />
|
<el-input v-model="form.ppt_llm_api_key" type="password" show-password placeholder="该供应商的 API Key" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item v-if="form.ppt_llm_provider === 'custom'" label="Base URL">
|
<el-form-item v-if="form.ppt_llm_provider === 'custom'" label="Base URL">
|
||||||
<el-input v-model="form.ppt_llm_base_url" placeholder="如 https://api.openai.com/v1" />
|
<el-input v-model="form.ppt_llm_base_url" placeholder="如 https://api.openai.com/v1" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item v-if="form.ppt_llm_provider === 'dify'" label="">
|
|
||||||
<el-tag type="success">通过 Dify 调用,无需配置 API Key</el-tag>
|
|
||||||
</el-form-item>
|
|
||||||
|
|
||||||
<!-- 海报文案模型 -->
|
<!-- 海报文案模型 -->
|
||||||
<el-divider content-position="left">海报文案模型</el-divider>
|
<el-divider content-position="left">海报文案模型</el-divider>
|
||||||
|
|
||||||
@ -63,34 +57,30 @@
|
|||||||
|
|
||||||
<el-form-item label="模型名称">
|
<el-form-item label="模型名称">
|
||||||
<div style="display: flex; gap: 8px; width: 100%">
|
<div style="display: flex; gap: 8px; width: 100%">
|
||||||
<el-input v-model="form.poster_llm_model" placeholder="如 deepseek-chat" />
|
<el-select
|
||||||
<el-button v-if="!difyModels.length" @click="fetchDifyModels" :loading="fetchingModels">从 Dify 获取</el-button>
|
v-if="availableModels.length"
|
||||||
<el-dropdown v-else trigger="click" @command="(cmd: string) => applyDifyModel('poster', cmd)">
|
v-model="form.poster_llm_model"
|
||||||
<el-button>从 Dify 获取 <el-icon><ArrowDown /></el-icon></el-button>
|
filterable allow-create
|
||||||
<template #dropdown>
|
placeholder="选择或输入模型名"
|
||||||
<el-dropdown-menu>
|
style="flex: 1"
|
||||||
<el-dropdown-item v-for="m in difyModels" :key="m.provider + '/' + m.model"
|
>
|
||||||
:command="m.provider + '|' + m.model">
|
<el-option v-for="m in availableModels" :key="m" :label="m" :value="m" />
|
||||||
{{ m.label }} ({{ m.provider }}/{{ m.model }})
|
</el-select>
|
||||||
</el-dropdown-item>
|
<el-input v-else v-model="form.poster_llm_model" placeholder="如 deepseek-v4-pro" style="flex: 1" />
|
||||||
</el-dropdown-menu>
|
<el-button @click="handleSyncModels" :loading="syncingModels">
|
||||||
</template>
|
同步模型
|
||||||
</el-dropdown>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item v-if="form.poster_llm_provider !== 'dify'" label="API Key">
|
<el-form-item label="API Key">
|
||||||
<el-input v-model="form.poster_llm_api_key" type="password" show-password placeholder="留空则使用环境变量配置" />
|
<el-input v-model="form.poster_llm_api_key" type="password" show-password placeholder="该供应商的 API Key" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item v-if="form.poster_llm_provider === 'custom'" label="Base URL">
|
<el-form-item v-if="form.poster_llm_provider === 'custom'" label="Base URL">
|
||||||
<el-input v-model="form.poster_llm_base_url" placeholder="如 https://api.openai.com/v1" />
|
<el-input v-model="form.poster_llm_base_url" placeholder="如 https://api.openai.com/v1" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item v-if="form.poster_llm_provider === 'dify'" label="">
|
|
||||||
<el-tag type="success">通过 Dify 调用,无需配置 API Key</el-tag>
|
|
||||||
</el-form-item>
|
|
||||||
|
|
||||||
<!-- 海报图片模型 -->
|
<!-- 海报图片模型 -->
|
||||||
<el-divider content-position="left">海报图片模型</el-divider>
|
<el-divider content-position="left">海报图片模型</el-divider>
|
||||||
|
|
||||||
@ -133,13 +123,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { ArrowDown } from '@element-plus/icons-vue'
|
|
||||||
import { pptAdminApi } from '@/utils/ppt-admin-api'
|
import { pptAdminApi } from '@/utils/ppt-admin-api'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const fetchingModels = ref(false)
|
const syncingModels = ref(false)
|
||||||
const difyModels = ref<Array<{ provider: string; model: string; label: string }>>([])
|
const availableModels = ref<string[]>([])
|
||||||
|
const syncingPptModels = ref(false)
|
||||||
|
const pptAvailableModels = ref<string[]>([])
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
ppt_llm_provider: 'deepseek',
|
ppt_llm_provider: 'deepseek',
|
||||||
@ -158,7 +149,7 @@ const form = reactive({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const DEFAULT_MODELS: Record<string, string> = {
|
const DEFAULT_MODELS: Record<string, string> = {
|
||||||
deepseek: 'deepseek-chat',
|
deepseek: 'deepseek-v4-pro',
|
||||||
minimax: 'MiniMax-2.7-Flash',
|
minimax: 'MiniMax-2.7-Flash',
|
||||||
gemini: 'gemini-2.5-flash',
|
gemini: 'gemini-2.5-flash',
|
||||||
openai: 'gpt-4o',
|
openai: 'gpt-4o',
|
||||||
@ -213,33 +204,47 @@ function inferBaseUrl(model: string): string {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyDifyModel(target: 'ppt' | 'poster', command: string) {
|
async function handleSyncModels() {
|
||||||
const [, model] = command.split('|')
|
syncingModels.value = true
|
||||||
if (target === 'ppt') {
|
try {
|
||||||
form.ppt_llm_provider = 'dify'
|
const res: any = await pptAdminApi.syncModels({
|
||||||
form.ppt_llm_model = model
|
provider: form.poster_llm_provider,
|
||||||
form.ppt_llm_api_key = ''
|
api_key: form.poster_llm_api_key,
|
||||||
form.ppt_llm_base_url = ''
|
base_url: form.poster_llm_base_url,
|
||||||
} else {
|
})
|
||||||
form.poster_llm_provider = 'dify'
|
const models = res?.data?.models || []
|
||||||
form.poster_llm_model = model
|
availableModels.value = models
|
||||||
form.poster_llm_api_key = ''
|
if (!models.length) {
|
||||||
form.poster_llm_base_url = ''
|
ElMessage.warning('未获取到模型,请检查 API Key 和供应商配置')
|
||||||
|
} else {
|
||||||
|
ElMessage.success(`同步成功,共 ${models.length} 个模型`)
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message || '同步失败')
|
||||||
|
} finally {
|
||||||
|
syncingModels.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchDifyModels() {
|
async function handlePptSyncModels() {
|
||||||
fetchingModels.value = true
|
syncingPptModels.value = true
|
||||||
try {
|
try {
|
||||||
const res: any = await pptAdminApi.getAvailableModels()
|
const res: any = await pptAdminApi.syncModels({
|
||||||
difyModels.value = res?.data?.models || []
|
provider: form.ppt_llm_provider,
|
||||||
if (!difyModels.value.length) {
|
api_key: form.ppt_llm_api_key,
|
||||||
ElMessage.warning('未获取到可用模型,请检查 Dify Workspace API Key 配置')
|
base_url: form.ppt_llm_base_url,
|
||||||
|
})
|
||||||
|
const models = res?.data?.models || []
|
||||||
|
pptAvailableModels.value = models
|
||||||
|
if (!models.length) {
|
||||||
|
ElMessage.warning('未获取到模型,请检查 API Key 和供应商配置')
|
||||||
|
} else {
|
||||||
|
ElMessage.success(`同步成功,共 ${models.length} 个模型`)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e: any) {
|
||||||
ElMessage.error('获取模型列表失败')
|
ElMessage.error(e?.message || '同步失败')
|
||||||
} finally {
|
} finally {
|
||||||
fetchingModels.value = false
|
syncingPptModels.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -105,4 +105,7 @@ export const pptAdminApi = {
|
|||||||
getAvailableModels() {
|
getAvailableModels() {
|
||||||
return api.get('/admin/ppt/available-models')
|
return api.get('/admin/ppt/available-models')
|
||||||
},
|
},
|
||||||
|
syncModels(data: { provider: string; api_key: string; base_url?: string }) {
|
||||||
|
return api.post('/admin/ppt/sync-models', data)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user