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:
eezd committed 2026-10-14 15:12:27 +08:00
1 parent 5db3ead883
commit b549f3f441
9 files changed
+190 -51

No files matched your search

@@ -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">
+26 -7
View File
@@ -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
+12 -5
View File
@@ -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" />