mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat: add model directive management
This commit is contained in:
@@ -381,6 +381,7 @@ import {
|
||||
Zap,
|
||||
FileUp,
|
||||
Server,
|
||||
SlidersHorizontal,
|
||||
type LucideIcon,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
@@ -559,6 +560,7 @@ const navigation = computed(() => {
|
||||
Shield,
|
||||
Puzzle,
|
||||
Server,
|
||||
SlidersHorizontal,
|
||||
}
|
||||
|
||||
// 添加模块菜单项(按 admin_menu_order 排序,只显示已激活的)
|
||||
|
||||
@@ -220,6 +220,12 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'ModuleManagement',
|
||||
component: () => importWithRetry(() => import('@/views/admin/ModuleManagement.vue'))
|
||||
},
|
||||
{
|
||||
path: 'model-directives',
|
||||
name: 'ModelDirectivesManagement',
|
||||
component: () => importWithRetry(() => import('@/views/admin/ModelDirectivesManagement.vue')),
|
||||
meta: { module: 'model_directives' }
|
||||
},
|
||||
{
|
||||
path: 'email',
|
||||
name: 'EmailSettings',
|
||||
|
||||
97
frontend/src/views/admin/ModelDirectivesManagement.vue
Normal file
97
frontend/src/views/admin/ModelDirectivesManagement.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="模型后缀参数"
|
||||
description="允许通过模型名后缀覆盖推理参数"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="loading"
|
||||
@click="loadConfig"
|
||||
>
|
||||
<RefreshCw
|
||||
class="w-4 h-4 mr-2"
|
||||
:class="{ 'animate-spin': loading }"
|
||||
/>
|
||||
刷新
|
||||
</Button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="mt-6 space-y-5">
|
||||
<Card
|
||||
variant="default"
|
||||
class="p-6"
|
||||
>
|
||||
<ModelDirectivesPanel
|
||||
:config="modelDirectivesConfig"
|
||||
:loading="loading || saving"
|
||||
@save="saveConfig"
|
||||
@update:config="modelDirectivesConfig = $event"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { RefreshCw } from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import { PageContainer, PageHeader } from '@/components/layout'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { log } from '@/utils/logger'
|
||||
import { getErrorMessage } from '@/types/api-error'
|
||||
import ModelDirectivesPanel from './module-management/ModelDirectivesPanel.vue'
|
||||
import {
|
||||
createDefaultModelDirectivesConfig,
|
||||
normalizeModelDirectivesConfig,
|
||||
type ModelDirectivesConfig,
|
||||
} from './module-management/modelDirectivesConfig'
|
||||
|
||||
const { success, error } = useToast()
|
||||
|
||||
const modelDirectivesConfig = ref<ModelDirectivesConfig>(createDefaultModelDirectivesConfig())
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
async function loadConfig() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await adminApi.getSystemConfig('model_directives')
|
||||
const normalized = normalizeModelDirectivesConfig(response.value)
|
||||
modelDirectivesConfig.value = normalized
|
||||
} catch (err) {
|
||||
error('获取模型后缀参数配置失败')
|
||||
log.error('获取模型后缀参数配置失败:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
saving.value = true
|
||||
try {
|
||||
const normalized = normalizeModelDirectivesConfig(modelDirectivesConfig.value)
|
||||
modelDirectivesConfig.value = normalized
|
||||
await adminApi.updateSystemConfig(
|
||||
'model_directives',
|
||||
normalized,
|
||||
'模型后缀参数配置'
|
||||
)
|
||||
success('模型后缀参数配置已保存')
|
||||
} catch (err) {
|
||||
error(getErrorMessage(err, '保存模型后缀参数配置失败'))
|
||||
log.error('保存模型后缀参数配置失败:', err)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadConfig()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,213 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold">
|
||||
推理参数
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
各端点可以分别启用推理参数,并配置推理程度到实际请求参数和值的映射。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<Switch
|
||||
:model-value="config.reasoning_effort.enabled"
|
||||
:disabled="loading"
|
||||
@update:model-value="onReasoningEnabledChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-lg border">
|
||||
<div class="grid grid-cols-1 gap-2 border-b bg-muted/40 px-4 py-3 text-xs font-medium text-muted-foreground lg:grid-cols-[minmax(0,1fr)_minmax(0,0.7fr)_minmax(0,1.8fr)_auto]">
|
||||
<div>API 端点</div>
|
||||
<div>推理程度</div>
|
||||
<div>映射参数</div>
|
||||
<div class="md:text-right">状态</div>
|
||||
</div>
|
||||
<div class="divide-y">
|
||||
<div
|
||||
v-for="format in MODEL_DIRECTIVE_API_FORMATS"
|
||||
:key="format.key"
|
||||
class="grid grid-cols-1 items-center gap-3 px-4 py-3 lg:grid-cols-[minmax(0,1fr)_minmax(0,0.7fr)_minmax(0,1.8fr)_auto]"
|
||||
>
|
||||
<div>
|
||||
<div class="text-sm font-medium">
|
||||
{{ format.label }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Select
|
||||
:model-value="selectedEfforts[format.key] ?? 'low'"
|
||||
:disabled="loading || !config.reasoning_effort.enabled || !formatConfig(format.key).enabled"
|
||||
@update:model-value="value => selectEffort(format.key, value)"
|
||||
>
|
||||
<SelectTrigger class="h-9 w-28 rounded-lg">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="effort in DEFAULT_REASONING_SUFFIXES"
|
||||
:key="effort"
|
||||
:value="effort"
|
||||
>
|
||||
{{ effort }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
v-model="localMappingParams[mappingKey(format.key)]"
|
||||
class="h-9 font-mono text-xs"
|
||||
:disabled="loading || !config.reasoning_effort.enabled || !formatConfig(format.key).enabled"
|
||||
placeholder="{"reasoning_effort":"low"}"
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
class="h-7 w-7 shrink-0 text-muted-foreground"
|
||||
:disabled="loading || !config.reasoning_effort.enabled || !formatConfig(format.key).enabled || !hasMappingParamChanges(format.key)"
|
||||
@click="saveMappingParam(format.key)"
|
||||
>
|
||||
<Save class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex items-center md:justify-end">
|
||||
<Switch
|
||||
:model-value="formatConfig(format.key).enabled"
|
||||
:disabled="loading || !config.reasoning_effort.enabled"
|
||||
@update:model-value="value => onApiFormatEnabledChange(format.key, value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch } from 'vue'
|
||||
import { Save } from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui'
|
||||
import Switch from '@/components/ui/switch.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import {
|
||||
DEFAULT_REASONING_SUFFIXES,
|
||||
MODEL_DIRECTIVE_API_FORMATS,
|
||||
type ReasoningApiFormatConfig,
|
||||
type ModelDirectivesConfig,
|
||||
} from './modelDirectivesConfig'
|
||||
|
||||
const props = defineProps<{
|
||||
config: ModelDirectivesConfig
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: []
|
||||
'update:config': [value: ModelDirectivesConfig]
|
||||
}>()
|
||||
|
||||
const selectedEfforts = reactive<Record<string, string>>({})
|
||||
const localMappingParams = reactive<Record<string, string>>({})
|
||||
|
||||
watch(() => props.config.reasoning_effort.api_formats, (newFormats) => {
|
||||
for (const format of MODEL_DIRECTIVE_API_FORMATS) {
|
||||
const fc = newFormats[format.key]
|
||||
const selectedEffort = selectedEfforts[format.key] ?? firstMappingEffort(fc?.mappings) ?? 'low'
|
||||
selectedEfforts[format.key] = selectedEffort
|
||||
const key = mappingKey(format.key, selectedEffort)
|
||||
if (localMappingParams[key] === undefined || !hasMappingParamChanges(format.key)) {
|
||||
localMappingParams[key] = JSON.stringify(fc?.mappings?.[selectedEffort] ?? {}, null, 2)
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
function formatConfig(apiFormat: string): ReasoningApiFormatConfig {
|
||||
return props.config.reasoning_effort.api_formats[apiFormat] ?? {
|
||||
enabled: true,
|
||||
mappings: {},
|
||||
}
|
||||
}
|
||||
|
||||
function firstMappingEffort(mappings: Record<string, unknown> | undefined): string | undefined {
|
||||
return DEFAULT_REASONING_SUFFIXES.find((effort) => mappings?.[effort] !== undefined)
|
||||
}
|
||||
|
||||
function mappingKey(apiFormat: string, effort = selectedEfforts[apiFormat] ?? 'low'): string {
|
||||
return `${apiFormat}:${effort}`
|
||||
}
|
||||
|
||||
function selectEffort(apiFormat: string, effort: string) {
|
||||
selectedEfforts[apiFormat] = effort
|
||||
const key = mappingKey(apiFormat, effort)
|
||||
localMappingParams[key] = JSON.stringify(formatConfig(apiFormat).mappings[effort] ?? {}, null, 2)
|
||||
}
|
||||
|
||||
function hasMappingParamChanges(apiFormat: string): boolean {
|
||||
const effort = selectedEfforts[apiFormat] ?? 'low'
|
||||
return (localMappingParams[mappingKey(apiFormat, effort)] ?? '') !== JSON.stringify(formatConfig(apiFormat).mappings[effort] ?? {}, null, 2)
|
||||
}
|
||||
|
||||
function onReasoningEnabledChange(value: boolean) {
|
||||
emit('update:config', {
|
||||
...props.config,
|
||||
reasoning_effort: { ...props.config.reasoning_effort, enabled: Boolean(value) },
|
||||
})
|
||||
emit('save')
|
||||
}
|
||||
|
||||
function onApiFormatEnabledChange(apiFormat: string, value: boolean) {
|
||||
const current = formatConfig(apiFormat)
|
||||
emit('update:config', {
|
||||
...props.config,
|
||||
reasoning_effort: {
|
||||
...props.config.reasoning_effort,
|
||||
api_formats: {
|
||||
...props.config.reasoning_effort.api_formats,
|
||||
[apiFormat]: { ...current, enabled: Boolean(value) },
|
||||
},
|
||||
},
|
||||
})
|
||||
emit('save')
|
||||
}
|
||||
|
||||
function saveMappingParam(apiFormat: string) {
|
||||
const current = formatConfig(apiFormat)
|
||||
const effort = selectedEfforts[apiFormat] ?? 'low'
|
||||
let mapping: unknown
|
||||
try {
|
||||
const parsed = JSON.parse(localMappingParams[mappingKey(apiFormat, effort)] || '{}')
|
||||
mapping = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
||||
} catch {
|
||||
localMappingParams[mappingKey(apiFormat, effort)] = JSON.stringify(current.mappings[effort] ?? {}, null, 2)
|
||||
return
|
||||
}
|
||||
localMappingParams[mappingKey(apiFormat, effort)] = JSON.stringify(mapping, null, 2)
|
||||
emit('update:config', {
|
||||
...props.config,
|
||||
reasoning_effort: {
|
||||
...props.config.reasoning_effort,
|
||||
api_formats: {
|
||||
...props.config.reasoning_effort.api_formats,
|
||||
[apiFormat]: {
|
||||
...current,
|
||||
mappings: {
|
||||
...current.mappings,
|
||||
[effort]: mapping,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
emit('save')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,156 @@
|
||||
export interface ReasoningApiFormatConfig {
|
||||
enabled: boolean
|
||||
mappings: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ModelDirectivesConfig {
|
||||
reasoning_effort: {
|
||||
enabled: boolean
|
||||
api_formats: Record<string, ReasoningApiFormatConfig>
|
||||
}
|
||||
}
|
||||
|
||||
export const MODEL_DIRECTIVES_MODULE_NAME = 'model_directives'
|
||||
|
||||
export const MODEL_DIRECTIVE_API_FORMATS = [
|
||||
{
|
||||
key: 'openai:chat',
|
||||
label: 'OpenAI Chat',
|
||||
parameter: 'reasoning_effort',
|
||||
},
|
||||
{
|
||||
key: 'openai:responses',
|
||||
label: 'OpenAI Responses',
|
||||
parameter: 'reasoning.effort',
|
||||
},
|
||||
{
|
||||
key: 'openai:responses:compact',
|
||||
label: 'OpenAI Responses Compact',
|
||||
parameter: 'reasoning.effort',
|
||||
},
|
||||
{
|
||||
key: 'claude:messages',
|
||||
label: 'Claude Messages',
|
||||
parameter: 'output_config.effort + thinking',
|
||||
},
|
||||
{
|
||||
key: 'gemini:generate_content',
|
||||
label: 'Gemini GenerateContent',
|
||||
parameter: 'generationConfig.thinkingConfig',
|
||||
},
|
||||
] as const
|
||||
|
||||
export const DEFAULT_REASONING_SUFFIXES = ['low', 'medium', 'high', 'xhigh', 'max'] as const
|
||||
|
||||
function defaultMappingsForApiFormat(apiFormat: string): Record<string, unknown> {
|
||||
switch (apiFormat) {
|
||||
case 'openai:chat':
|
||||
return {
|
||||
low: { reasoning_effort: 'low' },
|
||||
medium: { reasoning_effort: 'medium' },
|
||||
high: { reasoning_effort: 'high' },
|
||||
xhigh: { reasoning_effort: 'xhigh' },
|
||||
max: { reasoning_effort: 'xhigh' },
|
||||
}
|
||||
case 'openai:responses':
|
||||
case 'openai:responses:compact':
|
||||
return {
|
||||
low: { reasoning: { effort: 'low' } },
|
||||
medium: { reasoning: { effort: 'medium' } },
|
||||
high: { reasoning: { effort: 'high' } },
|
||||
xhigh: { reasoning: { effort: 'xhigh' } },
|
||||
max: { reasoning: { effort: 'xhigh' } },
|
||||
}
|
||||
case 'claude:messages':
|
||||
return {
|
||||
low: { thinking: { type: 'enabled', budget_tokens: 1024 } },
|
||||
medium: { thinking: { type: 'enabled', budget_tokens: 4096 } },
|
||||
high: { thinking: { type: 'enabled', budget_tokens: 8192 } },
|
||||
xhigh: { thinking: { type: 'enabled', budget_tokens: 16384 } },
|
||||
max: { thinking: { type: 'enabled', budget_tokens: 32768 } },
|
||||
}
|
||||
case 'gemini:generate_content':
|
||||
return {
|
||||
low: { generationConfig: { thinkingConfig: { thinkingBudget: 1024 } } },
|
||||
medium: { generationConfig: { thinkingConfig: { thinkingBudget: 4096 } } },
|
||||
high: { generationConfig: { thinkingConfig: { thinkingBudget: 8192 } } },
|
||||
xhigh: { generationConfig: { thinkingConfig: { thinkingBudget: 16384 } } },
|
||||
max: { generationConfig: { thinkingConfig: { thinkingBudget: -1 } } },
|
||||
}
|
||||
default:
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function createDefaultModelDirectivesConfig(): ModelDirectivesConfig {
|
||||
return {
|
||||
reasoning_effort: {
|
||||
enabled: true,
|
||||
api_formats: Object.fromEntries(
|
||||
MODEL_DIRECTIVE_API_FORMATS.map((format) => [
|
||||
format.key,
|
||||
{
|
||||
enabled: true,
|
||||
mappings: defaultMappingsForApiFormat(format.key),
|
||||
},
|
||||
])
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function mappingsFromLegacySuffixes(apiFormat: string, value: unknown): Record<string, unknown> {
|
||||
if (!Array.isArray(value)) return defaultMappingsForApiFormat(apiFormat)
|
||||
const supported = new Set<string>(DEFAULT_REASONING_SUFFIXES)
|
||||
const defaults = defaultMappingsForApiFormat(apiFormat)
|
||||
return Object.fromEntries(value
|
||||
.map((item) => String(item).trim().toLowerCase())
|
||||
.filter((item, index, array) => supported.has(item) && array.indexOf(item) === index)
|
||||
.map((suffix) => [suffix, defaults[suffix]])
|
||||
.filter(([, mapping]) => mapping !== undefined))
|
||||
}
|
||||
|
||||
function normalizeMappings(apiFormat: string, value: unknown, legacySuffixes: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return mappingsFromLegacySuffixes(apiFormat, legacySuffixes)
|
||||
}
|
||||
return { ...(value as Record<string, unknown>) }
|
||||
}
|
||||
|
||||
export function normalizeModelDirectivesConfig(value: unknown): ModelDirectivesConfig {
|
||||
const defaults = createDefaultModelDirectivesConfig()
|
||||
if (!value || typeof value !== 'object') return defaults
|
||||
|
||||
const source = value as Partial<ModelDirectivesConfig>
|
||||
const reasoning = source.reasoning_effort
|
||||
const apiFormats: Record<string, ReasoningApiFormatConfig> = {
|
||||
...defaults.reasoning_effort.api_formats,
|
||||
}
|
||||
const sourceApiFormats = reasoning?.api_formats
|
||||
if (sourceApiFormats && typeof sourceApiFormats === 'object') {
|
||||
for (const [apiFormat, rawConfig] of Object.entries(sourceApiFormats)) {
|
||||
if (typeof rawConfig === 'boolean') {
|
||||
apiFormats[apiFormat] = {
|
||||
enabled: rawConfig,
|
||||
mappings: defaultMappingsForApiFormat(apiFormat),
|
||||
}
|
||||
} else if (rawConfig && typeof rawConfig === 'object') {
|
||||
const value = rawConfig as Partial<ReasoningApiFormatConfig> & { suffixes?: unknown }
|
||||
apiFormats[apiFormat] = {
|
||||
enabled: typeof value.enabled === 'boolean' ? value.enabled : true,
|
||||
mappings: normalizeMappings(apiFormat, value.mappings, value.suffixes),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
reasoning_effort: {
|
||||
enabled:
|
||||
typeof reasoning?.enabled === 'boolean'
|
||||
? reasoning.enabled
|
||||
: defaults.reasoning_effort.enabled,
|
||||
api_formats: apiFormats,
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user