Replace temperature with reasoning effort and retry in-stream errors
- Providers get a default reasoning effort (low..max, empty = not sent); chat can override it per message. OpenAI sends reasoning_effort, Claude sends output_config.effort with adaptive thinking, and thinking blocks are replayed during the tool loop. - Show a thinking indicator while waiting for model output. - Surface in-stream upstream errors (e.g. overloaded) instead of breaking the SSE stream, and retry them up to 3 times when nothing has been emitted yet.
This commit is contained in:
1 parent
5db3ead883
commit
b549f3f441
9 files changed
+190
-51
No files matched your search
@@ -2,6 +2,11 @@ import { http } from "@/http"
|
||||
|
||||
export type Protocol = "openai" | "anthropic"
|
||||
|
||||
/** 思考强度,与后端 llm.Effort 一致 */
|
||||
export const EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const
|
||||
|
||||
export type Effort = typeof EFFORTS[number]
|
||||
|
||||
export interface Provider {
|
||||
id: number
|
||||
name: string
|
||||
@@ -11,7 +16,8 @@ export interface Provider {
|
||||
model: string
|
||||
max_tokens: number
|
||||
context_window: number
|
||||
temperature: number | null
|
||||
/** 为空表示不传,由模型决定 */
|
||||
reasoning_effort: Effort | null
|
||||
is_default: boolean
|
||||
updated_at: string
|
||||
}
|
||||
@@ -25,7 +31,8 @@ export interface ProviderForm {
|
||||
model: string
|
||||
max_tokens: number
|
||||
context_window: number
|
||||
temperature: number | null
|
||||
/** 为空表示不传,由模型决定 */
|
||||
reasoning_effort: Effort | null
|
||||
is_default: boolean
|
||||
}
|
||||
|
||||
@@ -53,6 +60,6 @@ export function deleteProvider(id: number): Promise<void> {
|
||||
}
|
||||
|
||||
export function testProvider(id: number): Promise<TestResult> {
|
||||
// 模型接口超时 120s 且 SDK 会重试,前端放宽等待时间
|
||||
return http.post(`/settings/providers/${id}/test`, null, { timeout: 400000 })
|
||||
// 模型接口读超时 600s 且 SDK 会重试 2 次,前端放宽等待时间
|
||||
return http.post(`/settings/providers/${id}/test`, null, { timeout: 1900000 })
|
||||
}
|
||||
@@ -18,6 +18,8 @@ export interface DisplayItem {
|
||||
error?: string
|
||||
/** 生成中:这次模型调用已结束,后续文字另起一条 */
|
||||
closed?: boolean
|
||||
/** 生成中:模型正在思考,还没有输出文字 */
|
||||
thinking?: boolean
|
||||
}
|
||||
|
||||
defineProps<{ items: DisplayItem[] }>()
|
||||
@@ -53,6 +55,9 @@ function prettyResult(result: string) {
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<!-- markdown-it 已关闭 html,模型输出中的标签按文本显示 -->
|
||||
<div v-if="item.thinking" class="text-xs text-[var(--el-text-color-secondary)]">
|
||||
思考中…
|
||||
</div>
|
||||
<div v-if="item.text" class="markdown-body" v-html="renderMarkdown(item.text)" />
|
||||
<el-collapse v-if="item.toolCalls.length" class="tool-cards">
|
||||
<el-collapse-item v-for="call in item.toolCalls" :key="call.id" :name="call.id">
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ChatMessage } from "@/apis/chat"
|
||||
import type { Provider } from "@/apis/settings"
|
||||
import type { Effort, Provider } from "@/apis/settings"
|
||||
import type { SkillNode } from "@/apis/skills"
|
||||
import type { DisplayItem } from "@/pages/chat/components/MessageList.vue"
|
||||
import { ElNotification } from "element-plus"
|
||||
import { getChatSession, resetChat } from "@/apis/chat"
|
||||
import { listProviders } from "@/apis/settings"
|
||||
import { EFFORTS, listProviders } from "@/apis/settings"
|
||||
import { listSkillTree } from "@/apis/skills"
|
||||
import SkillTree from "@/components/SkillTree.vue"
|
||||
import SkillVersionDrawer from "@/components/SkillVersionDrawer.vue"
|
||||
@@ -22,6 +22,8 @@ const contextTab = ref<InstanceType<typeof ContextTab>>()
|
||||
const providers = ref<Provider[]>([])
|
||||
const providerId = usePersisted<number | null>("chat.providerId", null)
|
||||
const provider = computed(() => providers.value.find(p => p.id === providerId.value))
|
||||
// 为空表示跟随模型配置里的默认值
|
||||
const effort = usePersisted<Effort | null>("chat.effort", null)
|
||||
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
const estimatedTokens = ref(0)
|
||||
@@ -94,11 +96,19 @@ function currentAssistant(): DisplayItem {
|
||||
function onEvent(name: string, data: any) {
|
||||
if (name === "prompt") {
|
||||
estimatedTokens.value = data.estimated_tokens
|
||||
// OpenAI 格式推理期间没有任何推送,先显示等待状态直到出字
|
||||
currentAssistant().thinking = true
|
||||
} else if (name === "thinking") {
|
||||
currentAssistant().thinking = true
|
||||
} else if (name === "delta") {
|
||||
currentAssistant().text += data.text
|
||||
const item = currentAssistant()
|
||||
item.thinking = false
|
||||
item.text += data.text
|
||||
} else if (name === "usage") {
|
||||
// 一次模型调用结束;之后的工具调用挂在这条回复上,新的文字另起一条
|
||||
currentAssistant().closed = true
|
||||
const item = currentAssistant()
|
||||
item.closed = true
|
||||
item.thinking = false
|
||||
} else if (name === "tool_call") {
|
||||
const last = [...liveItems.value].reverse().find(i => i.kind === "assistant") ?? currentAssistant()
|
||||
last.toolCalls.push({ id: data.id, name: data.name, arguments: data.arguments, result: null })
|
||||
@@ -108,6 +118,10 @@ function onEvent(name: string, data: any) {
|
||||
if (call)
|
||||
call.result = data.preview
|
||||
}
|
||||
// 工具都返回后进入下一次模型调用
|
||||
const last = liveItems.value.at(-1)
|
||||
if (last?.closed && last.toolCalls.every(c => c.result !== null))
|
||||
currentAssistant().thinking = true
|
||||
} else if (name === "skill_draft") {
|
||||
highlightIds.value = [...new Set([...highlightIds.value, data.skill_id])]
|
||||
loadSkills()
|
||||
@@ -115,7 +129,9 @@ function onEvent(name: string, data: any) {
|
||||
} else if (name === "context_updated") {
|
||||
contextTab.value?.refreshFromChat()
|
||||
} else if (name === "error") {
|
||||
currentAssistant().error = data.message
|
||||
const item = currentAssistant()
|
||||
item.thinking = false
|
||||
item.error = data.message
|
||||
}
|
||||
scrollToBottom()
|
||||
}
|
||||
@@ -131,11 +147,11 @@ async function submit() {
|
||||
input.value = ""
|
||||
liveItems.value = [{ key: `live-user${Date.now()}`, kind: "user", text, toolCalls: [] }]
|
||||
scrollToBottom()
|
||||
await send({ text, provider_id: provider.value.id }, onEvent)
|
||||
await send({ text, provider_id: provider.value.id, reasoning_effort: effort.value }, onEvent)
|
||||
// 无论完成、出错还是停止,都以服务端保存的会话为准;保留本地的错误提示
|
||||
const errors = liveItems.value.filter(i => i.error)
|
||||
await loadSession()
|
||||
liveItems.value = errors.map(e => ({ ...e, text: "", toolCalls: [] }))
|
||||
liveItems.value = errors.map(e => ({ ...e, text: "", toolCalls: [], thinking: false }))
|
||||
}
|
||||
|
||||
function onKeydown(e: Event | KeyboardEvent) {
|
||||
@@ -222,6 +238,9 @@ loadSkills()
|
||||
<el-select v-model="providerId" placeholder="选择模型配置" class="w-60" :disabled="streaming">
|
||||
<el-option v-for="p in providers" :key="p.id" :value="p.id" :label="`${p.name}(${p.model})`" />
|
||||
</el-select>
|
||||
<el-select v-model="effort" class="w-44" :disabled="streaming" clearable :value-on-clear="null" :placeholder="`思考强度:跟随配置(${provider?.reasoning_effort ?? '不设置'})`">
|
||||
<el-option v-for="e in EFFORTS" :key="e" :value="e" :label="`思考强度:${e}`" />
|
||||
</el-select>
|
||||
<el-tooltip content="下一轮发给模型的 system prompt(含已发布 skills、CONTEXT.md)与历史消息的估算大小" placement="bottom">
|
||||
<span class="text-xs" :class="tokenRatio > 0.8 ? 'text-red-500 font-bold' : 'text-[var(--el-text-color-secondary)]'">
|
||||
上下文约 {{ estimatedTokens.toLocaleString() }}{{ provider ? ` / ${provider.context_window.toLocaleString()}` : "" }} token
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Provider, ProviderForm } from "@/apis/settings"
|
||||
import { createProvider, deleteProvider, listProviders, patchProvider, testProvider } from "@/apis/settings"
|
||||
import { createProvider, deleteProvider, EFFORTS, listProviders, patchProvider, testProvider } from "@/apis/settings"
|
||||
import { formatTime } from "@/utils/text"
|
||||
|
||||
const PROTOCOL_LABELS = { openai: "OpenAI 格式(/v1/chat/completions)", anthropic: "Claude 格式(/v1/messages)" } as const
|
||||
@@ -12,7 +12,7 @@ const saving = ref(false)
|
||||
const testing = ref<number | null>(null)
|
||||
|
||||
function emptyForm(): ProviderForm {
|
||||
return { name: "", protocol: "anthropic", base_url: "", api_key: "", model: "", max_tokens: 8192, context_window: 200000, temperature: null, is_default: false }
|
||||
return { name: "", protocol: "anthropic", base_url: "", api_key: "", model: "", max_tokens: 8192, context_window: 200000, reasoning_effort: null, is_default: false }
|
||||
}
|
||||
const form = ref<ProviderForm>(emptyForm())
|
||||
|
||||
@@ -106,6 +106,11 @@ load()
|
||||
</el-table-column>
|
||||
<el-table-column prop="base_url" label="Base URL" min-width="200" />
|
||||
<el-table-column prop="model" label="模型" min-width="150" />
|
||||
<el-table-column label="思考强度" width="90">
|
||||
<template #default="{ row }">
|
||||
{{ (row as Provider).reasoning_effort ?? "-" }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="API Key" width="100">
|
||||
<template #default="{ row }">
|
||||
****{{ row.api_key_hint }}
|
||||
@@ -169,9 +174,11 @@ load()
|
||||
<el-input-number v-model="form.context_window" :min="4096" :max="2000000" :step="10000" controls-position="right" />
|
||||
<span class="ml-2 text-xs">token,超过时拒绝发送</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="Temperature">
|
||||
<el-input-number v-model="form.temperature" :min="0" :max="2" :step="0.1" :value-on-clear="null" controls-position="right" placeholder="默认" />
|
||||
<span class="ml-2 text-xs">留空使用模型默认值</span>
|
||||
<el-form-item label="思考强度">
|
||||
<el-select v-model="form.reasoning_effort" clearable placeholder="不设置" class="w-40" :value-on-clear="null">
|
||||
<el-option v-for="e in EFFORTS" :key="e" :value="e" :label="e" />
|
||||
</el-select>
|
||||
<span class="ml-2 text-xs">Chat 默认值,可在 Chat 页按轮调整;留空则不传,由模型决定。越高越慢、越贵</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="设为默认">
|
||||
<el-switch v-model="form.is_default" />
|
||||
|
||||
Reference in new issue
Block a user