Browse Source

feat: add supportsTools field to LLM models and update related APIs

- Added supportsTools field to LLM models in various API endpoints.
- Updated model creation and update functions to handle supportsTools.
- Modified chat and agent APIs to utilize supportsTools for tool management.
- Enhanced error handling and validation for new field in relevant endpoints.
- Updated migration journal to include new model version for supportsTools.
feat/ai-sdk-v6-upgrade
npmrun 1 day ago
parent
commit
c4475a5e3b
  1. 9
      app/components/agent/AgentChatArea.vue
  2. 79
      app/components/agent/AgentInput.vue
  3. 9
      app/composables/useAgentChat.ts
  4. 9
      app/composables/useLlmChat.ts
  5. 16
      app/pages/index.vue
  6. 24
      app/pages/settings/llm-config/index.vue
  7. 20
      app/pages/settings/llm-test/index.vue
  8. 119
      docs/qwen3-vl-tools-fix.md
  9. BIN
      packages/drizzle-pkg/db.sqlite
  10. 3
      packages/drizzle-pkg/lib/schema/llm.ts
  11. 1
      packages/drizzle-pkg/migrations/0018_colossal_barracuda.sql
  12. 2225
      packages/drizzle-pkg/migrations/meta/0018_snapshot.json
  13. 7
      packages/drizzle-pkg/migrations/meta/_journal.json
  14. 4
      server/api/admin/llm/models/index.post.ts
  15. 6
      server/api/admin/llm/providers/index.post.ts
  16. 36
      server/api/agent/chat/index.post.ts
  17. 2
      server/api/agent/models/index.get.ts
  18. 13
      server/api/llm/chat/index.post.ts
  19. 1
      server/api/llm/chat/models.get.ts
  20. 3
      server/api/llm/models/[providerId].post.ts
  21. 3
      server/api/llm/models/detail/[id].put.ts
  22. 8
      server/service/agent/title.ts
  23. 11
      server/service/llm/index.ts

9
app/components/agent/AgentChatArea.vue

@ -16,6 +16,7 @@ const props = defineProps<{
modelId: number | null;
models: ModelOption[];
enableTools: boolean;
toolsDisabled?: boolean;
rateLimitInfo?: {
sessionRemaining: number | null;
ipRemaining: number | null;
@ -51,6 +52,11 @@ function handleEdit(messageId: string, content: string) {
inputRef.value?.setText(content);
}
function handleCancelEdit() {
editingMessageId.value = null;
inputRef.value?.setText("");
}
function handleSend(content: string) {
if (editingMessageId.value) {
emit("edit", editingMessageId.value, content);
@ -108,9 +114,12 @@ watch(
:disabled="rateLimitInfo?.sessionRemaining === 0 || hasPendingApproval"
:disabled-reason="rateLimitInfo?.sessionRemaining === 0 ? 'rate-limit' : hasPendingApproval ? 'approval' : null"
:enable-tools="enableTools"
:tools-disabled="toolsDisabled"
:logged-in="loggedIn"
:editing="!!editingMessageId"
@send="handleSend"
@stop="emit('stop')"
@cancel-edit="handleCancelEdit"
@update:enable-tools="emit('update:enableTools', $event)"
/>
</div>

79
app/components/agent/AgentInput.vue

@ -12,13 +12,16 @@ const props = defineProps<{
disabledReason?: "approval" | "rate-limit" | null;
placeholder?: string;
enableTools: boolean;
toolsDisabled?: boolean;
loggedIn: boolean;
editing?: boolean;
}>();
const emit = defineEmits<{
send: [content: string];
stop: [];
"update:enableTools": [val: boolean];
cancelEdit: [];
}>();
const inputText = ref("");
@ -77,6 +80,11 @@ function handleSend() {
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape" && props.editing) {
e.preventDefault();
emit("cancelEdit");
return;
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
@ -99,13 +107,26 @@ defineExpose({
<template>
<div class="agent-input">
<!-- Editing banner -->
<div v-if="editing" class="edit-banner">
<div class="edit-banner-info">
<Icon name="lucide:pencil-line" class="edit-banner-icon" />
<span>正在编辑消息</span>
</div>
<button class="edit-banner-cancel" @click="emit('cancelEdit')">
<Icon name="lucide:x" />
取消
</button>
</div>
<!-- Tools bar above input -->
<div v-if="loggedIn" class="tools-bar">
<button
class="toggle-btn"
:class="{ active: enableTools }"
title="工具调用"
@click="emit('update:enableTools', !enableTools)"
:class="{ active: enableTools, disabled: toolsDisabled }"
:disabled="toolsDisabled"
:title="toolsDisabled ? '当前模型不支持工具调用' : '工具调用'"
@click="!toolsDisabled && emit('update:enableTools', !enableTools)"
>
<Icon name="lucide:wrench" />
<span>工具</span>
@ -162,6 +183,49 @@ defineExpose({
width: 100%;
}
.edit-banner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 14px;
margin-bottom: 8px;
background: var(--color-surface-soft);
border: 1px solid var(--color-hairline);
border-radius: 12px;
font-size: 13px;
color: var(--color-muted);
}
.edit-banner-info {
display: flex;
align-items: center;
gap: 6px;
}
.edit-banner-icon {
font-size: 14px;
color: var(--color-primary);
}
.edit-banner-cancel {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 10px;
background: none;
border: 1px solid var(--color-hairline);
border-radius: 8px;
font-size: 12px;
color: var(--color-muted);
cursor: pointer;
transition: all 0.15s;
}
.edit-banner-cancel:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.tools-bar {
display: flex;
align-items: center;
@ -194,6 +258,15 @@ defineExpose({
color: var(--color-on-primary);
}
.toggle-btn.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.toggle-btn.disabled:hover {
border-color: var(--color-hairline);
}
.tools-list {
display: flex;
align-items: center;

9
app/composables/useAgentChat.ts

@ -286,9 +286,16 @@ export function useAgentChat(options: UseAgentChatOptions) {
p.result = "(此工具调用已被跳过——AI 已给出最终回复)";
});
}
if (!hasText && !hasToolCall) {
const hasReasoning = finalMsg.parts?.some((p) => p.type === "reasoning" && p.text);
if (!hasText && !hasToolCall && !hasReasoning) {
errorMessage.value = "模型未返回任何内容(可能已达到工具调用次数上限或模型无响应)";
messages.value.splice(assistantIdx, 1);
} else if (!hasText && !hasToolCall && hasReasoning) {
finalMsg.parts?.push({
id: generateId(),
type: "text",
text: "(模型仅返回了思考过程,未生成正式回复。请尝试更换模型或调整问题后重试。)",
});
}
}
}

9
app/composables/useLlmChat.ts

@ -290,9 +290,16 @@ export function useLlmChat(options: UseLlmChatOptions) {
const hasToolCall = finalMsg.parts?.some(p => p.type === 'tool-call')
const hasPendingApproval = finalMsg.parts?.some(p => p.state === 'approval-requested' && !p.isAutomaticApproval)
if (hasPendingApproval) return
if (!hasText && !hasToolCall) {
const hasReasoning = finalMsg.parts?.some(p => p.type === 'reasoning' && p.text)
if (!hasText && !hasToolCall && !hasReasoning) {
errorMessage.value = '模型未返回任何内容(可能已达到工具调用次数上限或模型无响应)'
messages.value.splice(assistantIdx, 1)
} else if (!hasText && !hasToolCall && hasReasoning) {
finalMsg.parts?.push({
id: generateId(),
type: 'text',
text: '(模型仅返回了思考过程,未生成正式回复。请尝试更换模型或调整问题后重试。)',
})
} else if (!hasText && hasToolCall) {
finalMsg.parts?.push({
id: generateId(),

16
app/pages/index.vue

@ -20,6 +20,7 @@ interface ModelOption {
name: string;
modelId: string;
providerName: string;
supportsTools: number;
}
const models = ref<ModelOption[]>([]);
@ -57,9 +58,20 @@ watch(
},
);
const currentModelSupportsTools = computed(() => {
const mid = currentModelId.value;
if (mid === null) return true;
const model = models.value.find((m) => m.id === mid);
return model ? model.supportsTools === 1 : true;
});
const enableTools = computed({
get: () => !!(sessions.currentSession.value?.enableTools),
get: () => {
if (!currentModelSupportsTools.value) return false;
return !!(sessions.currentSession.value?.enableTools);
},
set: (val: boolean) => {
if (!currentModelSupportsTools.value) return;
if (sessions.currentSessionId.value) {
sessions.updateSessionConfig(sessions.currentSessionId.value, { enableTools: val });
}
@ -97,6 +109,7 @@ async function loadModels() {
name: m.name,
modelId: m.modelId,
providerName: m.providerName ?? "—",
supportsTools: m.supportsTools ?? 1,
}));
if (isGuest) {
guestDefaultModelId.value = res.data?.defaultModelId ?? null;
@ -218,6 +231,7 @@ onMounted(async () => {
:model-id="currentModelId"
:models="models"
:enable-tools="enableTools"
:tools-disabled="!currentModelSupportsTools"
:rate-limit-info="rateLimitInfo"
:sidebar-collapsed="sidebarCollapsed"
:error-message="chat.errorMessage.value"

24
app/pages/settings/llm-config/index.vue

@ -21,6 +21,7 @@ interface LlmModel {
enabled: number
description: string | null
maxTokens: number | null
supportsTools: number
createdAt: string
updatedAt: string
}
@ -48,7 +49,7 @@ const providerForm = ref({
name: '',
slug: '',
baseUrl: '',
parseMode: 'openai' as string,
parseMode: 'openai-compatible' as string,
apiKey: '',
status: 'active' as string,
description: '',
@ -66,6 +67,7 @@ const modelForm = ref({
enabled: 1,
description: '',
maxTokens: null as number | null,
supportsTools: 1,
})
const modelLoading = ref(false)
const modelError = ref('')
@ -99,7 +101,7 @@ function showToast(message: string, type: 'success' | 'error' = 'success') {
function openCreateProvider() {
editingProvider.value = null
providerForm.value = { name: '', slug: '', baseUrl: '', parseMode: 'openai', apiKey: '', status: 'active', description: '' }
providerForm.value = { name: '', slug: '', baseUrl: '', parseMode: 'openai-compatible', apiKey: '', status: 'active', description: '' }
providerError.value = ''
showProviderDrawer.value = true
}
@ -210,7 +212,7 @@ async function fetchAvailableModels(providerId: number) {
function openCreateModel(providerId: number) {
currentProviderId.value = providerId
editingModel.value = null
modelForm.value = { name: '', modelId: '', type: 'text', enabled: 1, description: '', maxTokens: null }
modelForm.value = { name: '', modelId: '', type: 'text', enabled: 1, description: '', maxTokens: null, supportsTools: 1 }
modelError.value = ''
showModelDrawer.value = true
fetchAvailableModels(providerId)
@ -226,6 +228,7 @@ function openEditModel(m: LlmModel) {
enabled: m.enabled,
description: m.description || '',
maxTokens: m.maxTokens,
supportsTools: m.supportsTools ?? 1,
}
modelError.value = ''
showModelDrawer.value = true
@ -330,7 +333,7 @@ function formatDate(date: string) {
}
function parseModeLabel(mode: string) {
const map: Record<string, string> = { openai: 'OpenAI', anthropic: 'Anthropic' }
const map: Record<string, string> = { 'openai': 'OpenAI 原生', 'openai-compatible': 'OpenAI 兼容' }
return map[mode] ?? mode
}
@ -527,8 +530,8 @@ const currentModels = computed(() => {
<div class="form-group">
<label class="form-label">解析方式</label>
<select v-model="providerForm.parseMode" class="form-select">
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="openai-compatible">OpenAI 兼容</option>
<option value="openai">OpenAI 原生</option>
</select>
</div>
@ -614,6 +617,15 @@ const currentModels = computed(() => {
</div>
<div class="form-group">
<label class="form-label">支持工具调用</label>
<select v-model.number="modelForm.supportsTools" class="form-select">
<option :value="1">支持</option>
<option :value="0">不支持</option>
</select>
<small class="form-hint">不支持工具调用的模型 VL 多模态模型将不传 tools 参数</small>
</div>
<div class="form-group">
<label class="form-label">描述</label>
<textarea v-model="modelForm.description" class="form-textarea" rows="3" placeholder="模型描述(选填)"></textarea>
</div>

20
app/pages/settings/llm-test/index.vue

@ -7,6 +7,7 @@ interface ModelOption {
modelId: string
type: string
maxTokens: number | null
supportsTools: number
}
interface ProviderWithModels {
@ -64,11 +65,16 @@ const selectedModel = computed(() => {
return allModels.value.find(m => m.id === selectedModelId.value) ?? null
})
const selectedModelSupportsTools = computed(() => {
if (!selectedModel.value) return true
return selectedModel.value.supportsTools === 1
})
const { messages, isLoading, errorMessage, sendMessage, stopGeneration, clearChat, respondToApproval } = useLlmChat({
modelId: () => selectedModelId.value,
systemPrompt: () => systemPrompt.value,
enableThinking: () => enableThinking.value,
enableTools: () => enableTools.value,
enableTools: () => enableTools.value && selectedModelSupportsTools.value,
})
const inputMessage = ref('')
@ -184,13 +190,14 @@ function modelTypeClass(type: string) {
<label class="panel-label thinking-toggle">
<span>工具调用</span>
<button
:class="['toggle-switch', { active: enableTools }]"
@click="enableTools = !enableTools"
:class="['toggle-switch', { active: enableTools && selectedModelSupportsTools }]"
:disabled="!selectedModelSupportsTools"
@click="selectedModelSupportsTools && (enableTools = !enableTools)"
>
<span class="toggle-knob"></span>
</button>
</label>
<p class="panel-hint">启用后模型可调用已注册的 Agent 工具 fetch 网页抓取来辅助回答</p>
<p class="panel-hint">{{ selectedModelSupportsTools ? '启用后,模型可调用已注册的 Agent 工具(如 fetch 网页抓取)来辅助回答' : '当前模型不支持工具调用' }}</p>
<div v-if="enableTools && enabledTools.length > 0" class="enabled-tools-list">
<div v-for="t in enabledTools" :key="t.slug" class="enabled-tool-item">
<span :class="['tool-type-badge', `type-${t.type}`]">{{ t.type }}</span>
@ -515,6 +522,11 @@ function modelTypeClass(type: string) {
background: var(--color-primary);
}
.toggle-switch:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.toggle-knob {
position: absolute;
top: 2px;

119
docs/qwen3-vl-tools-fix.md

@ -0,0 +1,119 @@
# Qwen3-VL 工具调用兼容性修复
## 问题描述
选择 `Qwen3-VL-8B-Instruct` 模型发送消息后,模型回复被误判为"仅返回思考过程"而被删除,用户看不到任何回复内容。
## 根本原因
Qwen3-VL 不支持 function calling/tools。当请求携带 `tools` 参数时,SiliconFlow 平台会将模型输出放到 `reasoning_content` 字段而非 `content` 字段:
```json
// 带 tools 时
{"delta":{"content":null,"reasoning_content":"Hello"}}
// 不带 tools 时
{"delta":{"content":"Hello"}}
```
同时前端 `validateAssistantContent` 只检查 `content` 是否为空,未考虑 `reasoning` 类型的 part,导致只有思考过程的消息被误删。
## 解决方案
### 1. parseMode 重构
`parseMode``openai`/`anthropic` 改为 `openai`/`openai-compatible`:
- `openai`:使用 `@ai-sdk/openai``createOpenAI`(原生 OpenAI API)
- `openai-compatible`:使用 `@ai-sdk/openai-compatible``createOpenAICompatible`(兼容 API,如 SiliconFlow、DeepSeek)
### 2. 模型表增加 supportsTools 字段
`llmModels` 表新增 `supportsTools` 字段(`integer("supports_tools").default(1).notNull()`):
- `1`(true):模型支持工具调用,请求时正常传 tools 参数
- `0`(false):模型不支持工具调用,请求时不传 tools 参数
### 3. 前端工具按钮禁用
不支持工具的模型,工具开关按钮自动禁用,tooltip 显示"当前模型不支持工具调用"。
### 4. 前端 validateAssistantContent 修复
增加 `reasoning` 类型检查,避免只有思考过程的消息被误删。
## 改动文件清单
### 数据库
| 文件 | 改动 |
|------|------|
| `packages/drizzle-pkg/lib/schema/llm.ts` | `LlmParseModes` 改为 `["openai", "openai-compatible"]`;`llmModels` 增加 `supportsTools` 字段 |
| `packages/drizzle-pkg/migrations/0018_colossal_barracuda.sql` | 迁移文件:`ALTER TABLE llm_models ADD supports_tools` |
### 服务端 - resolveModel 实现
| 文件 | 改动 |
|------|------|
| `server/api/agent/chat/index.post.ts` | `resolveModel`:`openai` 用 `createOpenAI`,`openai-compatible` 用 `createOpenAICompatible`;`streamText` 中 `supportsTools=false` 时不传 tools |
| `server/api/llm/chat/index.post.ts` | 同步 `resolveModel` 和 tools 逻辑 |
| `server/service/agent/title.ts` | 同步 `resolveModel` |
### 服务端 - Service 层
| 文件 | 改动 |
|------|------|
| `server/service/llm/index.ts` | `LlmModelRow` 接口加 `supportsTools`;`listSystemModels`、`listAllEnabledModelsForUser` 查询加 `supportsTools`;`createModel`、`createSystemModel`、`updateModel` 加 `supportsTools` 字段 |
### 服务端 - API 接口
| 文件 | 改动 |
|------|------|
| `server/api/admin/llm/providers/index.post.ts` | `parseMode` 校验更新为 `["openai", "openai-compatible"]` |
| `server/api/admin/llm/models/index.post.ts` | 加 `supportsTools` 参数 |
| `server/api/llm/models/[providerId].post.ts` | 加 `supportsTools` 参数 |
| `server/api/llm/models/detail/[id].put.ts` | 加 `supportsTools` 参数 |
| `server/api/llm/models/index.get.ts` | 返回值包含 `supportsTools`(通过 service 层) |
| `server/api/llm/chat/models.get.ts` | 查询加 `supportsTools` 字段 |
| `server/api/agent/models/index.get.ts` | 返回值加 `supportsTools` |
### 前端
| 文件 | 改动 |
|------|------|
| `app/pages/index.vue` | `ModelOption``supportsTools`;新增 `currentModelSupportsTools` computed;`enableTools` getter 不支持时返回 false;传 `tools-disabled` prop |
| `app/components/agent/AgentChatArea.vue` | 加 `toolsDisabled` prop,传给 `AgentInput` |
| `app/components/agent/AgentInput.vue` | 加 `toolsDisabled` prop;工具按钮禁用逻辑和样式 |
| `app/composables/useAgentChat.ts` | `validateAssistantContent` 增加 `hasReasoning` 检查 |
| `app/composables/useLlmChat.ts` | `validateAssistantContent` 增加 `hasReasoning` 检查 |
| `app/pages/settings/llm-config/index.vue` | provider 编辑页面 `parseMode` 选项更新;模型编辑表单加 `supportsTools` 下拉选项 |
| `app/pages/settings/llm-test/index.vue` | `ModelOption``supportsTools`;工具开关不支持时禁用 |
## 数据库现状
- 数据库路径:`packages/drizzle-pkg/db.sqlite`
- provider 配置:
- deepseek(id=2, parseMode=`openai-compatible`)
- siliconflow(id=3, parseMode=`openai-compatible`)
- 模型配置:
- deepseek-v4-flash(id=2, supportsTools=1)
- Qwen3-VL-8B-Instruct(id=4, supportsTools=0)
## 关键技术决策
1. **`createOpenAI` 返回类型与 `LanguageModel` 不完全兼容**:需用 `as unknown as LanguageModel` 转换
2. **`enableThinking`/`enableTools` 改为请求体参数优先于 session 配置**:更灵活的控制方式
3. **`openai-compatible` 作为默认 parseMode**:大多数第三方 API 都是 OpenAI 兼容协议
## 测试验证
- [x] Qwen3-VL-8B-Instruct 发送消息正常返回内容(不再只有思考过程)
- [x] Qwen3-VL-8B-Instruct 工具按钮禁用,tooltip 显示"当前模型不支持工具调用"
- [x] deepseek-v4-flash 工具按钮正常启用,可开关工具
- [x] deepseek-v4-flash 工具列表正常显示(fetch_html、calculator、uuid 等)
## 后续注意事项
- `server/api/llm/providers/[id].models.get.ts` 中仍有 `anthropic` 分支代码,但不会命中(parseMode 不再有 "anthropic"),走 else 分支
- `app/pages/index.vue` 第 72 行硬编码 `enableThinking: () => false`
- 新增模型时默认 `supportsTools=1`,需在管理后台手动改为 0 如果模型不支持工具

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.

3
packages/drizzle-pkg/lib/schema/llm.ts

@ -4,7 +4,7 @@ import { users } from "./auth";
export const LlmProviderStatuses = ["active", "disabled"] as const;
export type LlmProviderStatus = (typeof LlmProviderStatuses)[number];
export const LlmParseModes = ["openai", "anthropic"] as const;
export const LlmParseModes = ["openai", "openai-compatible"] as const;
export type LlmParseMode = (typeof LlmParseModes)[number];
export const LlmModelTypes = ["text", "vision", "multimodal"] as const;
@ -47,6 +47,7 @@ export const llmModels = sqliteTable(
modelId: text("model_id", { length: 200 }).notNull(),
type: text("type", { enum: LlmModelTypes }).notNull().default("text"),
enabled: integer("enabled").default(1).notNull(),
supportsTools: integer("supports_tools").default(1).notNull(),
description: text("description"),
maxTokens: integer("max_tokens"),
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),

1
packages/drizzle-pkg/migrations/0018_colossal_barracuda.sql

@ -0,0 +1 @@
ALTER TABLE `llm_models` ADD `supports_tools` integer DEFAULT 1 NOT NULL;

2225
packages/drizzle-pkg/migrations/meta/0018_snapshot.json

File diff suppressed because it is too large

7
packages/drizzle-pkg/migrations/meta/_journal.json

@ -127,6 +127,13 @@
"when": 1786025064939,
"tag": "0017_freezing_puppet_master",
"breakpoints": true
},
{
"idx": 18,
"version": "6",
"when": 1786116553581,
"tag": "0018_colossal_barracuda",
"breakpoints": true
}
]
}

4
server/api/admin/llm/models/index.post.ts

@ -7,13 +7,14 @@ export default defineWrappedResponseHandler(async (event) => {
await requireAdmin(event);
const body = await readBody(event);
const { providerId, name, modelId, type, maxTokens, enabled } = body as {
const { providerId, name, modelId, type, maxTokens, enabled, supportsTools } = body as {
providerId: number;
name: string;
modelId: string;
type?: string;
maxTokens?: number;
enabled?: number;
supportsTools?: number;
};
if (!providerId || !name || !modelId) {
@ -36,6 +37,7 @@ export default defineWrappedResponseHandler(async (event) => {
type: type as "chat" | "embedding" | "image" | "audio" | undefined,
maxTokens,
enabled,
supportsTools,
});
return R.success({ id: result.id });

6
server/api/admin/llm/providers/index.post.ts

@ -21,15 +21,15 @@ export default defineWrappedResponseHandler(async (event) => {
return R.error("参数无效:name、slug、parseMode 必填", null);
}
if (!["openai", "anthropic"].includes(parseMode)) {
return R.error("parseMode 仅支持 openai 或 anthropic", null);
if (!["openai", "openai-compatible"].includes(parseMode)) {
return R.error("parseMode 仅支持 openai 或 openai-compatible", null);
}
const result = await createSystemProvider({
name,
slug,
baseUrl,
parseMode: parseMode as "openai" | "anthropic",
parseMode: parseMode as "openai" | "openai-compatible",
apiKey,
status: status as "active" | "inactive" | undefined,
description,

36
server/api/agent/chat/index.post.ts

@ -1,6 +1,7 @@
import { defineEventHandler, getQuery, getRouterParam, readBody, setResponseHeaders } from "h3";
import { R } from "#server/utils/response";
import { getCurrentUser, getConfigGlobal, getConfigUser } from "#server/utils/context";
import { createOpenAI } from "@ai-sdk/openai";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { type LanguageModel, type ModelMessage, streamText, stepCountIs } from "ai";
import { getSessionByIdAndUser, getMessagesBySession, getMaxSortOrder, saveMessage, truncateMessagesAfter, deleteMessage, getMessageById, updateMessageContent, updateMessageParts, touchSession, countAssistantMessages } from "#server/service/agent/session";
@ -24,11 +25,12 @@ function resolveModel(
modelId: string,
): LanguageModel {
const baseUrl = provider.baseUrl?.replace(/\/+$/, "") || undefined;
if (provider.parseMode === "anthropic") {
throw createError({
statusCode: 400,
statusMessage: "Anthropic 解析模式暂不支持流式对话,请使用 OpenAI 兼容模式",
if (provider.parseMode === "openai") {
const openai = createOpenAI({
apiKey: provider.apiKey || undefined,
baseURL: baseUrl,
});
return openai(modelId) as unknown as LanguageModel;
}
const openaiCompatible = createOpenAICompatible({
name: provider.name,
@ -184,7 +186,7 @@ export default defineEventHandler(async (event) => {
}
const body = await readBody(event);
const { sessionId, content, editMessageId, regenerate, continueAfterApproval, approvalToolCallId, approved, approvalReason } = body as {
const { sessionId, content, editMessageId, regenerate, continueAfterApproval, approvalToolCallId, approved, approvalReason, enableThinking: bodyEnableThinking, enableTools: bodyEnableTools } = body as {
sessionId: string;
content: string;
editMessageId?: string;
@ -193,6 +195,8 @@ export default defineEventHandler(async (event) => {
approvalToolCallId?: string;
approved?: boolean;
approvalReason?: string;
enableThinking?: boolean;
enableTools?: boolean;
};
const isApprovalContinue = continueAfterApproval === true && approvalToolCallId;
@ -265,10 +269,12 @@ export default defineEventHandler(async (event) => {
}
}
const toolsEnabled = bodyEnableTools !== undefined ? bodyEnableTools : session.enableTools === 1;
const { tools } = await getAgentToolsForChat({
userId: user?.id ?? null,
userRole: user?.role ?? null,
enableTools: session.enableTools === 1,
enableTools: toolsEnabled,
publicToolSlugs,
});
@ -383,14 +389,20 @@ export default defineEventHandler(async (event) => {
const languageModel = resolveModel(provider, model.modelId);
const thinkingEnabled = bodyEnableThinking !== undefined ? bodyEnableThinking : session.enableThinking === 1;
const modelSupportsTools = (model.supportsTools ?? 1) === 1;
const effectiveTools = modelSupportsTools ? tools : {};
logger.info(
"[%s] [AGENT-CHAT] userId=%s sessionId=%s modelId=%d thinking=%s tools=%s edit=%s regen=%s approval=%s",
"[%s] [AGENT-CHAT] userId=%s sessionId=%s modelId=%d thinking=%s tools=%s supportsTools=%s edit=%s regen=%s approval=%s",
event.context.requestId ?? "-",
user?.id ?? "temp",
sessionId,
model.id,
session.enableThinking === 1 ? "on" : "off",
Object.keys(tools).length > 0 ? "on" : "off",
thinkingEnabled ? "on" : "off",
Object.keys(effectiveTools).length > 0 ? "on" : "off",
modelSupportsTools ? "yes" : "no",
editMessageId ? "yes" : "no",
regenerate ? "yes" : "no",
isApprovalContinue ? "yes" : "no",
@ -401,10 +413,10 @@ export default defineEventHandler(async (event) => {
system: systemPrompt || undefined,
messages: modelMessages,
maxOutputTokens: model.maxTokens || undefined,
...(Object.keys(tools).length > 0
? { tools, stopWhen: stepCountIs(8) }
...(Object.keys(effectiveTools).length > 0
? { tools: effectiveTools, stopWhen: stepCountIs(8) }
: {}),
...(session.enableThinking === 1
...(thinkingEnabled
? {
providerOptions: {
openaiCompatible: { reasoningEffort: "high" },

2
server/api/agent/models/index.get.ts

@ -12,6 +12,7 @@ export default defineWrappedResponseHandler(async () => {
name: m.name,
modelId: m.modelId,
providerName: "—",
supportsTools: m.supportsTools,
}));
if (defaultModelId) {
@ -24,6 +25,7 @@ export default defineWrappedResponseHandler(async () => {
name: row.model.name,
modelId: row.model.modelId,
providerName: row.provider.name,
supportsTools: row.model.supportsTools,
});
}
}

13
server/api/llm/chat/index.post.ts

@ -1,5 +1,6 @@
import { requireUser } from "#server/utils/context";
import { getProviderById, getModelById } from "#server/service/llm";
import { createOpenAI } from "@ai-sdk/openai";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { type LanguageModel, streamText, stepCountIs, convertToModelMessages } from 'ai';
import { getEnabledToolsForLlm } from "#server/service/agent-tool";
@ -18,11 +19,12 @@ function resolveModel(
): LanguageModel {
const baseUrl = provider.baseUrl?.replace(/\/+$/, "") || undefined;
if (provider.parseMode === "anthropic") {
throw createError({
statusCode: 400,
statusMessage: "Anthropic 解析模式暂不支持流式对话,请使用 OpenAI 兼容模式",
if (provider.parseMode === "openai") {
const openai = createOpenAI({
apiKey: provider.apiKey || undefined,
baseURL: baseUrl,
});
return openai(modelId) as unknown as LanguageModel;
}
const openaiCompatible = createOpenAICompatible({
@ -84,7 +86,8 @@ export default defineEventHandler(async (event) => {
const languageModel = resolveModel(provider, model.modelId);
const { tools } = enableTools ? await getEnabledToolsForLlm(user.id, user.role) : { tools: undefined };
const modelSupportsTools = (model.supportsTools ?? 1) === 1;
const { tools } = enableTools && modelSupportsTools ? await getEnabledToolsForLlm(user.id, user.role) : { tools: undefined };
const modelMessages = await convertToModelMessages(messages);

1
server/api/llm/chat/models.get.ts

@ -27,6 +27,7 @@ export default defineWrappedResponseHandler(async (event) => {
modelId: llmModels.modelId,
type: llmModels.type,
maxTokens: llmModels.maxTokens,
supportsTools: llmModels.supportsTools,
})
.from(llmModels)
.where(and(eq(llmModels.providerId, provider.id), eq(llmModels.enabled, 1)));

3
server/api/llm/models/[providerId].post.ts

@ -10,7 +10,7 @@ export default defineWrappedResponseHandler(async (event) => {
}
const body = await readBody(event);
const { name, modelId, type, enabled, description, maxTokens } = body;
const { name, modelId, type, enabled, description, maxTokens, supportsTools } = body;
if (!name || !modelId) {
return R.throwError(422, "模型名称和模型ID不能为空", null);
@ -24,6 +24,7 @@ export default defineWrappedResponseHandler(async (event) => {
enabled: enabled ?? 1,
description,
maxTokens,
supportsTools,
});
if (!result) {
return R.throwError(403, "无权操作该供应商", null);

3
server/api/llm/models/detail/[id].put.ts

@ -10,7 +10,7 @@ export default defineWrappedResponseHandler(async (event) => {
}
const body = await readBody(event);
const { name, modelId, type, enabled, description, maxTokens } = body;
const { name, modelId, type, enabled, description, maxTokens, supportsTools } = body;
await updateModel(id, user.id, {
name,
@ -19,6 +19,7 @@ export default defineWrappedResponseHandler(async (event) => {
enabled,
description,
maxTokens,
supportsTools,
});
return R.success(null);
});

8
server/service/agent/title.ts

@ -1,4 +1,5 @@
import { generateText } from "ai";
import { createOpenAI } from "@ai-sdk/openai";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { type LanguageModel } from "ai";
import { getSystemModelWithProviderById, getModelWithProviderById } from "#server/service/llm";
@ -17,6 +18,13 @@ function resolveModel(
modelId: string,
): LanguageModel {
const baseUrl = provider.baseUrl?.replace(/\/+$/, "") || undefined;
if (provider.parseMode === "openai") {
const openai = createOpenAI({
apiKey: provider.apiKey || undefined,
baseURL: baseUrl,
});
return openai(modelId) as unknown as LanguageModel;
}
const openaiCompatible = createOpenAICompatible({
name: provider.name,
apiKey: provider.apiKey || undefined,

11
server/service/llm/index.ts

@ -26,6 +26,7 @@ export interface LlmModelRow {
enabled: number;
description: string | null;
maxTokens: number | null;
supportsTools: number;
createdAt: Date;
updatedAt: Date;
}
@ -187,6 +188,7 @@ export async function createModel(userId: number, data: {
enabled?: number;
description?: string;
maxTokens?: number;
supportsTools?: number;
}) {
const provider = await getProviderById(data.providerId, userId);
if (!provider) return null;
@ -201,6 +203,7 @@ export async function createModel(userId: number, data: {
enabled: data.enabled ?? 1,
description: data.description || null,
maxTokens: data.maxTokens || null,
supportsTools: data.supportsTools ?? 1,
})
.returning({ id: llmModels.id });
return inserted!;
@ -226,6 +229,7 @@ export async function updateModel(
enabled?: number;
description?: string;
maxTokens?: number;
supportsTools?: number;
},
) {
const model = await getModelById(id, userId);
@ -238,6 +242,7 @@ export async function updateModel(
if (data.enabled !== undefined) updates.enabled = data.enabled;
if (data.description !== undefined) updates.description = data.description;
if (data.maxTokens !== undefined) updates.maxTokens = data.maxTokens;
if (data.supportsTools !== undefined) updates.supportsTools = data.supportsTools;
if (Object.keys(updates).length === 0) return;
@ -284,6 +289,7 @@ export async function listSystemModels(): Promise<LlmModelRow[]> {
enabled: llmModels.enabled,
description: llmModels.description,
maxTokens: llmModels.maxTokens,
supportsTools: llmModels.supportsTools,
createdAt: llmModels.createdAt,
updatedAt: llmModels.updatedAt,
})
@ -354,6 +360,7 @@ export async function listAllEnabledModelsForUser(userId: number | null): Promis
type: LlmModelType;
providerName: string;
providerId: number;
supportsTools: number;
}[]
> {
if (userId) {
@ -365,6 +372,7 @@ export async function listAllEnabledModelsForUser(userId: number | null): Promis
type: llmModels.type,
providerName: llmProviders.name,
providerId: llmProviders.id,
supportsTools: llmModels.supportsTools,
})
.from(llmModels)
.innerJoin(llmProviders, eq(llmModels.providerId, llmProviders.id))
@ -384,6 +392,7 @@ export async function listAllEnabledModelsForUser(userId: number | null): Promis
type: llmModels.type,
providerName: llmProviders.name,
providerId: llmProviders.id,
supportsTools: llmModels.supportsTools,
})
.from(llmModels)
.innerJoin(llmProviders, eq(llmModels.providerId, llmProviders.id))
@ -423,6 +432,7 @@ export async function createSystemModel(data: {
type?: LlmModelType;
maxTokens?: number;
enabled?: number;
supportsTools?: number;
}) {
const [inserted] = await dbGlobal
.insert(llmModels)
@ -433,6 +443,7 @@ export async function createSystemModel(data: {
type: data.type || "chat",
maxTokens: data.maxTokens || null,
enabled: data.enabled ?? 1,
supportsTools: data.supportsTools ?? 1,
})
.returning({ id: llmModels.id });
return inserted!;

Loading…
Cancel
Save