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:
wsb1224 2026-07-25 13:45:32 +08:00
parent 8e897276e7
commit dbe2f9202d
5 changed files with 162 additions and 79 deletions

View File

@ -244,3 +244,14 @@ def get_settings():
def update_settings():
updated_by = getattr(request, "user_id", "")
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", ""),
))

View File

@ -566,3 +566,58 @@ class PptAdminService:
db.session.add(SystemSetting(key=key, value=str(value), updated_by=updated_by))
db.session.commit()
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", [])]

View File

@ -35,7 +35,7 @@ PROVIDERS = {
"deepseek": LLMProviderConfig(
name="deepseek",
base_url="https://api.deepseek.com/v1",
model="deepseek-chat",
model="deepseek-v4-pro",
max_retries=2,
rate_limit=0,
),
@ -149,7 +149,16 @@ async def _call_provider(
"generationConfig": {"temperature": 0.3, "maxOutputTokens": 4096},
}
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:
resp = await client.post(url, json=body, headers=headers)
@ -159,20 +168,7 @@ async def _call_provider(
latency_ms = (time.monotonic() - start) * 1000
# 解析响应
if config.name in ("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),
}
else:
# Gemini
if config.name == "gemini":
content = ""
candidates = data.get("candidates", [])
if candidates:
@ -186,6 +182,19 @@ async def _call_provider(
"input": usage_meta.get("promptTokenCount", 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(
content=content,

View File

@ -20,34 +20,28 @@
<el-form-item label="模型名称">
<div style="display: flex; gap: 8px; width: 100%">
<el-input v-model="form.ppt_llm_model" placeholder="如 deepseek-chat" />
<el-button v-if="!difyModels.length" @click="fetchDifyModels" :loading="fetchingModels"> Dify 获取</el-button>
<el-dropdown v-else trigger="click" @command="(cmd: string) => applyDifyModel('ppt', cmd)">
<el-button> Dify 获取 <el-icon><ArrowDown /></el-icon></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="m in difyModels" :key="m.provider + '/' + m.model"
:command="m.provider + '|' + m.model">
{{ m.label }} ({{ m.provider }}/{{ m.model }})
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-select
v-if="pptAvailableModels.length"
v-model="form.ppt_llm_model"
filterable allow-create
placeholder="选择或输入模型名"
style="flex: 1"
>
<el-option v-for="m in pptAvailableModels" :key="m" :label="m" :value="m" />
</el-select>
<el-input v-else v-model="form.ppt_llm_model" placeholder="如 deepseek-v4-pro" style="flex: 1" />
<el-button @click="handlePptSyncModels" :loading="syncingPptModels">同步模型</el-button>
</div>
</el-form-item>
<el-form-item v-if="form.ppt_llm_provider !== 'dify'" label="API Key">
<el-input v-model="form.ppt_llm_api_key" type="password" show-password placeholder="留空则使用环境变量配置" />
<el-form-item label="API Key">
<el-input v-model="form.ppt_llm_api_key" type="password" show-password placeholder="该供应商的 API Key" />
</el-form-item>
<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-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>
@ -63,34 +57,30 @@
<el-form-item label="模型名称">
<div style="display: flex; gap: 8px; width: 100%">
<el-input v-model="form.poster_llm_model" placeholder="如 deepseek-chat" />
<el-button v-if="!difyModels.length" @click="fetchDifyModels" :loading="fetchingModels"> Dify 获取</el-button>
<el-dropdown v-else trigger="click" @command="(cmd: string) => applyDifyModel('poster', cmd)">
<el-button> Dify 获取 <el-icon><ArrowDown /></el-icon></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="m in difyModels" :key="m.provider + '/' + m.model"
:command="m.provider + '|' + m.model">
{{ m.label }} ({{ m.provider }}/{{ m.model }})
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-select
v-if="availableModels.length"
v-model="form.poster_llm_model"
filterable allow-create
placeholder="选择或输入模型名"
style="flex: 1"
>
<el-option v-for="m in availableModels" :key="m" :label="m" :value="m" />
</el-select>
<el-input v-else v-model="form.poster_llm_model" placeholder="如 deepseek-v4-pro" style="flex: 1" />
<el-button @click="handleSyncModels" :loading="syncingModels">
同步模型
</el-button>
</div>
</el-form-item>
<el-form-item v-if="form.poster_llm_provider !== 'dify'" label="API Key">
<el-input v-model="form.poster_llm_api_key" type="password" show-password placeholder="留空则使用环境变量配置" />
<el-form-item label="API Key">
<el-input v-model="form.poster_llm_api_key" type="password" show-password placeholder="该供应商的 API Key" />
</el-form-item>
<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-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>
@ -133,13 +123,14 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { ArrowDown } from '@element-plus/icons-vue'
import { pptAdminApi } from '@/utils/ppt-admin-api'
const loading = ref(false)
const saving = ref(false)
const fetchingModels = ref(false)
const difyModels = ref<Array<{ provider: string; model: string; label: string }>>([])
const syncingModels = ref(false)
const availableModels = ref<string[]>([])
const syncingPptModels = ref(false)
const pptAvailableModels = ref<string[]>([])
const form = reactive({
ppt_llm_provider: 'deepseek',
@ -158,7 +149,7 @@ const form = reactive({
})
const DEFAULT_MODELS: Record<string, string> = {
deepseek: 'deepseek-chat',
deepseek: 'deepseek-v4-pro',
minimax: 'MiniMax-2.7-Flash',
gemini: 'gemini-2.5-flash',
openai: 'gpt-4o',
@ -213,33 +204,47 @@ function inferBaseUrl(model: string): string {
return ''
}
function applyDifyModel(target: 'ppt' | 'poster', command: string) {
const [, model] = command.split('|')
if (target === 'ppt') {
form.ppt_llm_provider = 'dify'
form.ppt_llm_model = model
form.ppt_llm_api_key = ''
form.ppt_llm_base_url = ''
async function handleSyncModels() {
syncingModels.value = true
try {
const res: any = await pptAdminApi.syncModels({
provider: form.poster_llm_provider,
api_key: form.poster_llm_api_key,
base_url: form.poster_llm_base_url,
})
const models = res?.data?.models || []
availableModels.value = models
if (!models.length) {
ElMessage.warning('未获取到模型,请检查 API Key 和供应商配置')
} else {
form.poster_llm_provider = 'dify'
form.poster_llm_model = model
form.poster_llm_api_key = ''
form.poster_llm_base_url = ''
ElMessage.success(`同步成功,共 ${models.length} 个模型`)
}
} catch (e: any) {
ElMessage.error(e?.message || '同步失败')
} finally {
syncingModels.value = false
}
}
async function fetchDifyModels() {
fetchingModels.value = true
async function handlePptSyncModels() {
syncingPptModels.value = true
try {
const res: any = await pptAdminApi.getAvailableModels()
difyModels.value = res?.data?.models || []
if (!difyModels.value.length) {
ElMessage.warning('未获取到可用模型,请检查 Dify Workspace API Key 配置')
const res: any = await pptAdminApi.syncModels({
provider: form.ppt_llm_provider,
api_key: form.ppt_llm_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 {
ElMessage.error('获取模型列表失败')
} catch (e: any) {
ElMessage.error(e?.message || '同步失败')
} finally {
fetchingModels.value = false
syncingPptModels.value = false
}
}

View File

@ -105,4 +105,7 @@ export const pptAdminApi = {
getAvailableModels() {
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)
},
}