Browse Source

feat: agent-tools 工具审批机制

基于 ai-sdk v7 的 toolApproval API 实现工具调用审批:

数据库:
- agent_tools 表新增 needs_approval 字段(默认 0)
- 迁移 0015_high_red_shift.sql

Service 层:
- getEnabledToolsForLlm 返回 { tools, approvalConfig }
- approvalConfig 按工具 needsApproval 映射为 user-approval / approved
- createAgentTool / updateAgentTool 支持 needsApproval 读写
- tool() 定义 parameters → inputSchema(v7 适配)

服务端 chat 端点:
- 使用 convertToModelMessages 转换 UIMessage 格式
- streamText 添加 toolApproval 配置
- maxTokens → maxOutputTokens, maxSteps → stopWhen(v7 适配)
- toDataStreamResponse → toUIMessageStreamResponse(v7 适配)

前端 useLlmChat:
- 流处理从 processDataStream 改为 parseJsonEventStream + 手动 chunk 处理(v7 适配)
- 新增 toUIMessageParts() / buildRequestBody() / processStream()
- 处理 tool-approval-request chunk(记录 isAutomatic 标志)
- 处理 tool-approval-response chunk(更新审批状态)
- 新增 respondToApproval() 函数
- validateAssistantContent 排除 isAutomaticApproval 的 pending part

Admin UI:
- 工具管理页面新增审批列和切换按钮
- 工具表单弹窗新增 needsApproval 复选框

聊天审批 UI:
- tool-call 显示审批状态(等待审批/自动批准中/已批准/已拒绝)
- 仅对非自动审批显示批准/拒绝按钮
- 拒绝后显示拒绝原因

Bug 修复:
- 无需审批的工具(approved 状态)不再显示审批按钮
- 原因:v7 中 approved 工具也会发送 tool-approval-request chunk(isAutomatic: true),
  前端之前未处理 tool-approval-response chunk 且未区分 isAutomatic

Co-authored-by: CodeFree <codefree@chinatelcom.cn>
feat/ai-sdk-v6-upgrade
npmrun 2 days ago
parent
commit
d847f5531f
  1. 8
      app/components/AgentToolFormModal.vue
  2. 389
      app/composables/useLlmChat.ts
  3. 35
      app/pages/admin/agent-tools/index.vue
  4. 104
      app/pages/settings/llm-test/index.vue
  5. BIN
      packages/drizzle-pkg/db.sqlite
  6. 1
      packages/drizzle-pkg/lib/schema/agent-tool.ts
  7. 1
      packages/drizzle-pkg/migrations/0015_high_red_shift.sql
  8. 1877
      packages/drizzle-pkg/migrations/meta/0015_snapshot.json
  9. 7
      packages/drizzle-pkg/migrations/meta/_journal.json
  10. 3
      server/api/agent-tools/[id].put.ts
  11. 3
      server/api/agent-tools/index.post.ts
  12. 24
      server/api/llm/chat/index.post.ts
  13. 15
      server/service/agent-tool/index.ts

8
app/components/AgentToolFormModal.vue

@ -33,6 +33,7 @@ const form = reactive({
config: props.tool?.config ?? "",
inputSchema: "",
enabled: props.tool ? props.tool.enabled === 1 : true,
needsApproval: props.tool ? props.tool.needsApproval === 1 : false,
sortOrder: props.tool?.sortOrder ?? 0,
})
@ -90,6 +91,7 @@ async function save() {
type: form.type,
config: parsedConfig,
enabled: form.enabled ? 1 : 0,
needsApproval: form.needsApproval ? 1 : 0,
sortOrder: Number(form.sortOrder) || 0,
}
@ -183,6 +185,12 @@ function onOverlayClick(e: MouseEvent) {
<span>启用</span>
</label>
</div>
<div class="form-group form-group-grow">
<label class="checkbox-label">
<input v-model="form.needsApproval" type="checkbox" class="form-checkbox" />
<span>需要审批</span>
</label>
</div>
</div>
</div>

389
app/composables/useLlmChat.ts

@ -1,6 +1,6 @@
import { processDataStream } from 'ai'
import { parseJsonEventStream, uiMessageChunkSchema } from 'ai'
export type MessagePartType = 'text' | 'reasoning' | 'tool-call' | 'tool-result'
export type MessagePartType = 'text' | 'reasoning' | 'tool-call' | 'tool-result' | 'tool-approval'
export interface MessagePart {
id: string
@ -10,7 +10,11 @@ export interface MessagePart {
toolCallId?: string
args?: unknown
result?: unknown
state?: 'call' | 'result'
state?: 'call' | 'result' | 'approval-requested' | 'approval-responded'
approvalId?: string
approved?: boolean
approvalReason?: string
isAutomaticApproval?: boolean
reasoningLoading?: boolean
reasoningDuration?: number
}
@ -73,6 +77,248 @@ export function useLlmChat(options: UseLlmChatOptions) {
}
}
function toUIMessageParts(msg: LlmChatMessage): any[] {
if (!msg.parts) return msg.content ? [{ type: 'text', text: msg.content }] : []
const parts: any[] = []
for (const p of msg.parts) {
if (p.type === 'text' && p.text) {
parts.push({ type: 'text', text: p.text })
} else if (p.type === 'reasoning' && p.text) {
parts.push({ type: 'reasoning', text: p.text })
} else if (p.type === 'tool-call') {
if (p.state === 'approval-requested') {
parts.push({
type: `tool-${p.toolName}`,
toolCallId: p.toolCallId,
state: 'approval-requested',
input: p.args,
approval: { id: p.approvalId ?? '' },
})
} else if (p.state === 'approval-responded') {
parts.push({
type: `tool-${p.toolName}`,
toolCallId: p.toolCallId,
state: 'approval-responded',
input: p.args,
approval: {
id: p.approvalId ?? '',
approved: p.approved ?? false,
reason: p.approvalReason,
},
})
} else if (p.state === 'result') {
parts.push({
type: `tool-${p.toolName}`,
toolCallId: p.toolCallId,
state: 'output-available',
input: p.args,
output: p.result,
})
} else {
parts.push({
type: `tool-${p.toolName}`,
toolCallId: p.toolCallId,
state: 'input-available',
input: p.args,
})
}
} else if (p.type === 'tool-approval') {
if (p.state === 'approval-responded') {
parts.push({
type: `tool-${p.toolName}`,
toolCallId: p.toolCallId,
state: 'approval-responded',
input: p.args,
approval: {
id: p.approvalId ?? '',
approved: p.approved ?? false,
reason: p.approvalReason,
},
})
}
}
}
return parts
}
function buildRequestBody(extraMessages?: LlmChatMessage[]) {
const allMessages = extraMessages ? [...messages.value, ...extraMessages] : messages.value
const uiMessages: any[] = []
if (systemPrompt?.()) {
uiMessages.push({ role: 'system', parts: [{ type: 'text', text: systemPrompt() }] })
}
for (const m of allMessages) {
if (!m.content && (!m.parts || m.parts.length === 0)) continue
uiMessages.push({
role: m.role,
parts: toUIMessageParts(m),
})
}
return {
modelId: modelId(),
messages: uiMessages,
enableThinking: enableThinking?.() ?? false,
enableTools: enableTools?.() ?? false,
}
}
async function processStream(res: Response, assistantIdx: number) {
if (!res.body) throw new Error('响应体为空')
let reasoningStartTime: number | null = null
const chunkStream = parseJsonEventStream({
stream: res.body,
schema: uiMessageChunkSchema,
})
const reader = chunkStream.getReader()
for (;;) {
const { done, value: parsed } = await reader.read()
if (done) break
if (!parsed.success) continue
const chunk = parsed.value
switch (chunk.type) {
case 'reasoning-start': {
const msg = messages.value[assistantIdx]
if (!msg) break
if (reasoningStartTime === null) reasoningStartTime = Date.now()
appendPart(msg, { id: generateId(), type: 'reasoning', text: '', reasoningLoading: true })
break
}
case 'reasoning-delta': {
const msg = messages.value[assistantIdx]
if (!msg) break
const part = getOrCreateLastPart(msg, 'reasoning')
if (part) {
part.text = (part.text ?? '') + chunk.delta
}
break
}
case 'reasoning-end': {
const msg = messages.value[assistantIdx]
if (msg) updateLastReasoningDuration(msg)
break
}
case 'text-delta': {
const msg = messages.value[assistantIdx]
if (!msg) break
updateLastReasoningDuration(msg)
let part = getOrCreateLastPart(msg, 'text')
if (!part) {
part = { id: generateId(), type: 'text', text: '' }
appendPart(msg, part)
}
part.text = (part.text ?? '') + chunk.delta
msg.content += chunk.delta
break
}
case 'error': {
errorMessage.value = chunk.errorText || '流式响应出错'
break
}
case 'tool-input-available': {
const msg = messages.value[assistantIdx]
if (!msg) break
updateLastReasoningDuration(msg)
appendPart(msg, {
id: generateId(),
type: 'tool-call',
toolName: chunk.toolName,
toolCallId: chunk.toolCallId,
args: chunk.input,
state: 'call',
})
break
}
case 'tool-output-available': {
const msg = messages.value[assistantIdx]
if (!msg || !msg.parts) break
const callPart = msg.parts.find(p => p.type === 'tool-call' && p.toolCallId === chunk.toolCallId)
if (callPart) {
callPart.result = chunk.output
callPart.state = 'result'
}
break
}
case 'tool-output-denied': {
const msg = messages.value[assistantIdx]
if (!msg || !msg.parts) break
const callPart = msg.parts.find(p => p.type === 'tool-call' && p.toolCallId === chunk.toolCallId)
if (callPart) {
callPart.state = 'result'
callPart.result = '工具执行被拒绝'
}
break
}
case 'tool-approval-request': {
const msg = messages.value[assistantIdx]
if (!msg) break
updateLastReasoningDuration(msg)
const isAutomatic = !!(chunk as any).isAutomatic
const existingPart = msg.parts?.find(p => p.type === 'tool-call' && p.toolCallId === chunk.toolCallId)
if (existingPart) {
existingPart.state = 'approval-requested'
existingPart.approvalId = chunk.approvalId
existingPart.isAutomaticApproval = isAutomatic
} else {
appendPart(msg, {
id: generateId(),
type: 'tool-call',
toolName: (chunk as any).toolName,
toolCallId: chunk.toolCallId,
args: (chunk as any).input,
state: 'approval-requested',
approvalId: chunk.approvalId,
isAutomaticApproval: isAutomatic,
})
}
break
}
case 'tool-approval-response': {
const msg = messages.value[assistantIdx]
if (!msg || !msg.parts) break
const part = msg.parts.find(p => p.type === 'tool-call' && p.approvalId === chunk.approvalId)
if (part) {
part.state = 'approval-responded'
part.approved = chunk.approved
if (chunk.reason) part.approvalReason = chunk.reason
}
break
}
default:
break
}
}
const msg = messages.value[assistantIdx]
if (msg) updateLastReasoningDuration(msg)
}
function validateAssistantContent(assistantIdx: number) {
const finalMsg = messages.value[assistantIdx]
if (finalMsg && !errorMessage.value) {
const hasText = finalMsg.parts?.some(p => p.type === 'text' && p.text)
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) {
errorMessage.value = '模型未返回任何内容(可能已达到工具调用次数上限或模型无响应)'
messages.value.splice(assistantIdx, 1)
} else if (!hasText && hasToolCall) {
finalMsg.parts?.push({
id: generateId(),
type: 'text',
text: '(已达到工具调用次数上限,模型未能生成最终回答。以上是工具调用的尝试记录。)',
})
}
}
}
async function sendMessage(text: string) {
const trimmed = text.trim()
const mid = modelId()
@ -95,23 +341,11 @@ export function useLlmChat(options: UseLlmChatOptions) {
isLoading.value = true
abortController = new AbortController()
let reasoningStartTime: number | null = null
try {
const res = await fetch(apiEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
modelId: mid,
messages: [
...(systemPrompt?.() ? [{ role: 'system' as const, content: systemPrompt() }] : []),
...messages.value
.filter(m => m.content)
.map(m => ({ role: m.role, content: m.content })),
],
enableThinking: enableThinking?.() ?? false,
enableTools: enableTools?.() ?? false,
}),
body: JSON.stringify(buildRequestBody()),
signal: abortController.signal,
})
@ -120,83 +354,8 @@ export function useLlmChat(options: UseLlmChatOptions) {
throw new Error(errText || `请求失败 (${res.status})`)
}
if (!res.body) {
throw new Error('响应体为空')
}
await processDataStream({
stream: res.body,
onReasoningPart: (text) => {
const msg = messages.value[assistantIdx]
if (!msg) return
let part = getOrCreateLastPart(msg, 'reasoning')
if (!part) {
if (reasoningStartTime === null) reasoningStartTime = Date.now()
part = { id: generateId(), type: 'reasoning', text: '', reasoningLoading: true }
appendPart(msg, part)
}
part.text = (part.text ?? '') + text
},
onTextPart: (text) => {
const msg = messages.value[assistantIdx]
if (!msg) return
updateLastReasoningDuration(msg)
let part = getOrCreateLastPart(msg, 'text')
if (!part) {
part = { id: generateId(), type: 'text', text: '' }
appendPart(msg, part)
}
part.text = (part.text ?? '') + text
msg.content += text
},
onErrorPart: (error) => {
errorMessage.value = error || '流式响应出错'
},
onToolCallPart: (part) => {
const msg = messages.value[assistantIdx]
if (!msg) return
updateLastReasoningDuration(msg)
appendPart(msg, {
id: generateId(),
type: 'tool-call',
toolName: part.toolName,
toolCallId: part.toolCallId,
args: part.args,
state: 'call',
})
},
onToolResultPart: (part) => {
const msg = messages.value[assistantIdx]
if (!msg || !msg.parts) return
const callPart = msg.parts.find(p => p.type === 'tool-call' && p.toolCallId === part.toolCallId)
if (callPart) {
callPart.result = part.result
callPart.state = 'result'
}
},
})
const msg = messages.value[assistantIdx]
if (msg) updateLastReasoningDuration(msg)
// 检查是否有实际内容产出
const finalMsg = messages.value[assistantIdx]
if (finalMsg && !errorMessage.value) {
const hasText = finalMsg.parts?.some(p => p.type === 'text' && p.text)
const hasToolCall = finalMsg.parts?.some(p => p.type === 'tool-call')
if (!hasText && !hasToolCall) {
// 完全无内容,移除空消息
errorMessage.value = '模型未返回任何内容(可能已达到工具调用次数上限或模型无响应)'
messages.value.splice(assistantIdx, 1)
} else if (!hasText && hasToolCall) {
// 有工具调用但无最终文本回答(maxSteps 用完),保留工具记录,追加提示
finalMsg.parts?.push({
id: generateId(),
type: 'text',
text: '(已达到工具调用次数上限,模型未能生成最终回答。以上是工具调用的尝试记录。)',
})
}
}
await processStream(res, assistantIdx)
validateAssistantContent(assistantIdx)
} catch (err: any) {
if (err.name === 'AbortError') {
// user stopped
@ -213,6 +372,49 @@ export function useLlmChat(options: UseLlmChatOptions) {
}
}
async function respondToApproval(toolCallId: string, approved: boolean, reason?: string) {
if (isLoading.value) return
const assistantMsg = messages.value.find(m =>
m.parts?.some(p => p.toolCallId === toolCallId && p.state === 'approval-requested'),
)
if (!assistantMsg) return
const approvalPart = assistantMsg.parts?.find(p => p.toolCallId === toolCallId && p.state === 'approval-requested')
if (!approvalPart) return
approvalPart.state = 'approval-responded'
approvalPart.approved = approved
approvalPart.approvalReason = reason
isLoading.value = true
abortController = new AbortController()
try {
const res = await fetch(apiEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildRequestBody()),
signal: abortController.signal,
})
if (!res.ok) {
const errText = await res.text()
throw new Error(errText || `请求失败 (${res.status})`)
}
await processStream(res, messages.value.indexOf(assistantMsg))
validateAssistantContent(messages.value.indexOf(assistantMsg))
} catch (err: any) {
if (err.name !== 'AbortError') {
errorMessage.value = err.message || '请求失败'
}
} finally {
isLoading.value = false
abortController = null
}
}
function stopGeneration() {
if (abortController) {
abortController.abort()
@ -232,5 +434,6 @@ export function useLlmChat(options: UseLlmChatOptions) {
sendMessage,
stopGeneration,
clearChat,
respondToApproval,
}
}

35
app/pages/admin/agent-tools/index.vue

@ -7,6 +7,7 @@ interface AgentToolRow {
type: string
config: string
enabled: number
needsApproval: number
sortOrder: number
createdAt: string
updatedAt: string
@ -68,6 +69,19 @@ async function handleToggle(tool: AgentToolRow) {
}
}
async function handleToggleApproval(tool: AgentToolRow) {
try {
await $fetch(`/api/agent-tools/${tool.id}`, {
method: "PUT",
body: { needsApproval: !tool.needsApproval },
})
$toast.success(tool.needsApproval ? "已关闭审批" : "已开启审批")
refresh()
} catch (e: any) {
$toast.error(e?.data?.message ?? "操作失败")
}
}
function openEdit(tool: AgentToolRow) {
editingTool.value = tool
showFormModal.value = true
@ -119,13 +133,14 @@ function onExecuteClose() {
<th class="text-left">Slug</th>
<th class="text-left">类型</th>
<th class="text-left">状态</th>
<th class="text-left">审批</th>
<th class="text-left">排序</th>
<th class="text-right">操作</th>
</tr>
</thead>
<tbody>
<tr v-if="toolList.length === 0">
<td colspan="6" class="text-center empty-cell">暂无工具</td>
<td colspan="7" class="text-center empty-cell">暂无工具</td>
</tr>
<tr v-for="t in toolList" :key="t.id" class="table-row">
<td class="name-cell">
@ -141,6 +156,11 @@ function onExecuteClose() {
{{ statusBadge(t.enabled).label }}
</span>
</td>
<td class="approval-cell">
<span :class="['status-badge', t.needsApproval ? 'badge-approval' : 'badge-no-approval']">
{{ t.needsApproval ? '需审批' : '直接执行' }}
</span>
</td>
<td class="sort-cell">{{ t.sortOrder }}</td>
<td class="actions-cell">
<div class="action-buttons">
@ -148,6 +168,9 @@ function onExecuteClose() {
<button class="action-btn" @click="handleToggle(t)">
{{ t.enabled ? '禁用' : '启用' }}
</button>
<button class="action-btn" @click="handleToggleApproval(t)">
{{ t.needsApproval ? '关闭审批' : '开启审批' }}
</button>
<button class="action-btn" @click="openEdit(t)">编辑</button>
<button class="action-btn action-btn-danger" @click="handleDelete(t.id)">删除</button>
</div>
@ -357,6 +380,16 @@ function onExecuteClose() {
color: #b06000;
}
.badge-approval {
background: #fef0f0;
color: #e53e3e;
}
.badge-no-approval {
background: #f0f0f0;
color: #666;
}
.sort-cell {
color: var(--color-muted);
font-size: 13px;

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

@ -64,7 +64,7 @@ const selectedModel = computed(() => {
return allModels.value.find(m => m.id === selectedModelId.value) ?? null
})
const { messages, isLoading, errorMessage, sendMessage, stopGeneration, clearChat } = useLlmChat({
const { messages, isLoading, errorMessage, sendMessage, stopGeneration, clearChat, respondToApproval } = useLlmChat({
modelId: () => selectedModelId.value,
systemPrompt: () => systemPrompt.value,
enableThinking: () => enableThinking.value,
@ -297,13 +297,47 @@ function modelTypeClass(type: string) {
<Icon name="lucide:wrench" class="tool-icon" />
<span class="tool-name">{{ part.toolName }}</span>
<span :class="['tool-state', part.state]">
{{ part.state === 'call' ? '调用中...' : '完成' }}
<template v-if="part.state === 'call'">调用中...</template>
<template v-else-if="part.state === 'approval-requested'">
{{ part.isAutomaticApproval ? '自动批准中...' : '等待审批' }}
</template>
<template v-else-if="part.state === 'approval-responded'">
{{ part.approved ? '已批准' : '已拒绝' }}
</template>
<template v-else>完成</template>
</span>
</div>
<div v-if="part.args" class="tool-args">
<span class="tool-label">参数:</span>
<code>{{ JSON.stringify(part.args) }}</code>
</div>
<!-- Approval request UI (only for manual approvals) -->
<div v-if="part.state === 'approval-requested' && !part.isAutomaticApproval" class="tool-approval-actions">
<button
class="btn-approve"
:disabled="isLoading"
@click="respondToApproval(part.toolCallId, true)"
>
<Icon name="lucide:check" />
批准执行
</button>
<button
class="btn-deny"
:disabled="isLoading"
@click="respondToApproval(part.toolCallId, false)"
>
<Icon name="lucide:x" />
拒绝
</button>
</div>
<!-- Approval responded info -->
<div v-if="part.state === 'approval-responded' && part.approvalReason" class="tool-approval-reason">
<span class="tool-label">拒绝原因:</span>
<span>{{ part.approvalReason }}</span>
</div>
<div v-if="part.result !== undefined" class="tool-result">
<span class="tool-label">结果:</span>
<pre>{{ typeof part.result === 'string' ? part.result.slice(0, 500) : JSON.stringify(part.result, null, 2)?.slice(0, 500) }}</pre>
@ -1034,6 +1068,72 @@ function modelTypeClass(type: string) {
color: var(--color-accent-teal);
}
.tool-state.approval-requested {
background: rgba(232, 165, 90, 0.15);
color: #b06000;
}
.tool-state.approval-responded {
background: rgba(93, 184, 166, 0.15);
color: var(--color-accent-teal);
}
.tool-approval-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.btn-approve,
.btn-deny {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 14px;
font-size: 12px;
font-weight: 500;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-approve {
background: rgba(93, 184, 166, 0.15);
color: var(--color-accent-teal);
}
.btn-approve:hover:not(:disabled) {
background: rgba(93, 184, 166, 0.25);
}
.btn-deny {
background: rgba(198, 69, 69, 0.1);
color: var(--color-error);
}
.btn-deny:hover:not(:disabled) {
background: rgba(198, 69, 69, 0.2);
}
.btn-approve:disabled,
.btn-deny:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-approve :deep(svg),
.btn-deny :deep(svg) {
width: 14px;
height: 14px;
}
.tool-approval-reason {
margin-top: 6px;
font-size: 11px;
color: var(--color-error);
}
.tool-args,
.tool-result {
display: flex;

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.

1
packages/drizzle-pkg/lib/schema/agent-tool.ts

@ -27,6 +27,7 @@ export const agentTools = sqliteTable(
type: text("type", { length: 30 }).notNull(),
config: text("config").notNull(),
enabled: integer("enabled").default(1).notNull(),
needsApproval: integer("needs_approval").default(0).notNull(),
sortOrder: integer("sort_order").default(0).notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.defaultNow()

1
packages/drizzle-pkg/migrations/0015_high_red_shift.sql

@ -0,0 +1 @@
ALTER TABLE `agent_tools` ADD `needs_approval` integer DEFAULT 0 NOT NULL;

1877
packages/drizzle-pkg/migrations/meta/0015_snapshot.json

File diff suppressed because it is too large

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

@ -106,6 +106,13 @@
"when": 1785894874773,
"tag": "0014_breezy_maestro",
"breakpoints": true
},
{
"idx": 15,
"version": "6",
"when": 1785942019319,
"tag": "0015_high_red_shift",
"breakpoints": true
}
]
}

3
server/api/agent-tools/[id].put.ts

@ -10,7 +10,7 @@ export default defineWrappedResponseHandler(async (event) => {
}
const body = await readBody(event);
const { name, slug, description, type, config, enabled, sortOrder } = body;
const { name, slug, description, type, config, enabled, needsApproval, sortOrder } = body;
try {
const result = await updateAgentTool(id, {
@ -20,6 +20,7 @@ export default defineWrappedResponseHandler(async (event) => {
type,
config,
enabled,
needsApproval,
sortOrder,
});
if (!result) {

3
server/api/agent-tools/index.post.ts

@ -5,7 +5,7 @@ export default defineWrappedResponseHandler(async (event) => {
await requireAdmin(event);
const body = await readBody(event);
const { name, slug, description, type, config, enabled, sortOrder } = body;
const { name, slug, description, type, config, enabled, needsApproval, sortOrder } = body;
if (!name || !slug || !type) {
return R.throwError(422, "name、slug、type 不能为空", null);
@ -19,6 +19,7 @@ export default defineWrappedResponseHandler(async (event) => {
type,
config: config ?? {},
enabled,
needsApproval,
sortOrder,
});
return R.success(result);

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

@ -1,7 +1,7 @@
import { requireUser } from "#server/utils/context";
import { getProviderById, getModelById } from "#server/service/llm";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { streamText, type LanguageModelV1 } from "ai";
import { type LanguageModel, streamText, stepCountIs, convertToModelMessages } from 'ai';
import { getEnabledToolsForLlm } from "#server/service/agent-tool";
import log4js from "logger";
@ -15,7 +15,7 @@ function resolveModel(
parseMode: string;
},
modelId: string,
): LanguageModelV1 {
): LanguageModel {
const baseUrl = provider.baseUrl?.replace(/\/+$/, "") || undefined;
if (provider.parseMode === "anthropic") {
@ -31,7 +31,7 @@ function resolveModel(
baseURL: baseUrl || "https://api.openai.com/v1",
});
return openaiCompatible(modelId) as LanguageModelV1;
return openaiCompatible(modelId) as LanguageModel;
}
export default defineEventHandler(async (event) => {
@ -43,7 +43,7 @@ export default defineEventHandler(async (event) => {
const body = await readBody(event);
const { modelId: llmModelId, messages, enableThinking, enableTools } = body as {
modelId: number;
messages: { role: "user" | "assistant" | "system"; content: string }[];
messages: any[];
enableThinking?: boolean;
enableTools?: boolean;
};
@ -84,14 +84,16 @@ export default defineEventHandler(async (event) => {
const languageModel = resolveModel(provider, model.modelId);
const tools = enableTools ? await getEnabledToolsForLlm() : undefined;
const { tools, approvalConfig } = enableTools ? await getEnabledToolsForLlm() : { tools: undefined, approvalConfig: {} };
const modelMessages = await convertToModelMessages(messages);
const result = streamText({
model: languageModel,
messages,
maxTokens: model.maxTokens || undefined,
messages: modelMessages,
maxOutputTokens: model.maxTokens || undefined,
...(tools && Object.keys(tools).length > 0
? { tools, maxSteps: 8 }
? { tools, stopWhen: stepCountIs(8), toolApproval: approvalConfig }
: {}),
...(enableThinking
? {
@ -112,11 +114,11 @@ export default defineEventHandler(async (event) => {
event.context.requestId ?? "-",
finishReason,
steps.length,
usage?.promptTokens ?? 0,
usage?.completionTokens ?? 0,
usage?.inputTokens ?? 0,
usage?.outputTokens ?? 0,
);
},
});
return result.toDataStreamResponse({ sendReasoning: true });
return result.toUIMessageStreamResponse({ sendReasoning: true });
});

15
server/service/agent-tool/index.ts

@ -89,6 +89,7 @@ export interface CreateAgentToolInput {
type: string;
config: Record<string, unknown>;
enabled?: boolean;
needsApproval?: boolean;
sortOrder?: number;
}
@ -99,6 +100,7 @@ export interface UpdateAgentToolInput {
type?: string;
config?: Record<string, unknown>;
enabled?: boolean;
needsApproval?: boolean;
sortOrder?: number;
}
@ -175,6 +177,7 @@ export async function createAgentTool(input: CreateAgentToolInput): Promise<Agen
type: input.type,
config: JSON.stringify(validatedConfig),
enabled: input.enabled === false ? 0 : 1,
needsApproval: input.needsApproval ? 1 : 0,
sortOrder: input.sortOrder ?? 0,
});
const row = await getAgentToolById(id);
@ -203,6 +206,7 @@ export async function updateAgentTool(id: string, input: UpdateAgentToolInput):
updates.config = JSON.stringify(validateConfig(typeToValidate, input.config));
}
if (input.enabled !== undefined) updates.enabled = input.enabled ? 1 : 0;
if (input.needsApproval !== undefined) updates.needsApproval = input.needsApproval ? 1 : 0;
if (input.sortOrder !== undefined) updates.sortOrder = input.sortOrder;
if (Object.keys(updates).length === 0) return existing;
@ -311,7 +315,10 @@ export async function getEnabledToolInfos(): Promise<EnabledToolInfo[]> {
return result;
}
export async function getEnabledToolsForLlm(): Promise<Record<string, ReturnType<typeof tool>>> {
export async function getEnabledToolsForLlm(): Promise<{
tools: Record<string, ReturnType<typeof tool>>;
approvalConfig: Record<string, "user-approval" | "approved">;
}> {
const tools = await dbGlobal
.select()
.from(agentTools)
@ -319,6 +326,7 @@ export async function getEnabledToolsForLlm(): Promise<Record<string, ReturnType
.orderBy(asc(agentTools.sortOrder));
const result: Record<string, any> = {};
const approvalConfig: Record<string, "user-approval" | "approved"> = {};
for (const agentTool of tools) {
const executor = getExecutor(agentTool.type);
if (!executor) continue;
@ -334,7 +342,7 @@ export async function getEnabledToolsForLlm(): Promise<Record<string, ReturnType
result[agentTool.slug] = tool({
description: executor.buildDescription(config),
parameters: jsonSchema(jsonSch, {
inputSchema: jsonSchema(jsonSch, {
validate: (v: unknown) => {
const r = zodSchema.safeParse(v);
return r.success
@ -354,8 +362,9 @@ export async function getEnabledToolsForLlm(): Promise<Record<string, ReturnType
return `[${agentTool.type} 结果 ${statusInfo} ${sizeInfo}]\n${dataStr}`;
},
});
approvalConfig[agentTool.slug] = agentTool.needsApproval ? "user-approval" : "approved";
}
return result;
return { tools: result, approvalConfig };
}
// 导出各工具默认配置和类型

Loading…
Cancel
Save