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. 353
      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 ?? "", config: props.tool?.config ?? "",
inputSchema: "", inputSchema: "",
enabled: props.tool ? props.tool.enabled === 1 : true, enabled: props.tool ? props.tool.enabled === 1 : true,
needsApproval: props.tool ? props.tool.needsApproval === 1 : false,
sortOrder: props.tool?.sortOrder ?? 0, sortOrder: props.tool?.sortOrder ?? 0,
}) })
@ -90,6 +91,7 @@ async function save() {
type: form.type, type: form.type,
config: parsedConfig, config: parsedConfig,
enabled: form.enabled ? 1 : 0, enabled: form.enabled ? 1 : 0,
needsApproval: form.needsApproval ? 1 : 0,
sortOrder: Number(form.sortOrder) || 0, sortOrder: Number(form.sortOrder) || 0,
} }
@ -183,6 +185,12 @@ function onOverlayClick(e: MouseEvent) {
<span>启用</span> <span>启用</span>
</label> </label>
</div> </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>
</div> </div>

353
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 { export interface MessagePart {
id: string id: string
@ -10,7 +10,11 @@ export interface MessagePart {
toolCallId?: string toolCallId?: string
args?: unknown args?: unknown
result?: unknown result?: unknown
state?: 'call' | 'result' state?: 'call' | 'result' | 'approval-requested' | 'approval-responded'
approvalId?: string
approved?: boolean
approvalReason?: string
isAutomaticApproval?: boolean
reasoningLoading?: boolean reasoningLoading?: boolean
reasoningDuration?: number reasoningDuration?: number
} }
@ -73,123 +77,239 @@ export function useLlmChat(options: UseLlmChatOptions) {
} }
} }
async function sendMessage(text: string) { function toUIMessageParts(msg: LlmChatMessage): any[] {
const trimmed = text.trim() if (!msg.parts) return msg.content ? [{ type: 'text', text: msg.content }] : []
const mid = modelId() const parts: any[] = []
if (!trimmed || mid === null || isLoading.value) return for (const p of msg.parts) {
if (p.type === 'text' && p.text) {
errorMessage.value = '' 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
}
const userMsg: LlmChatMessage = { id: generateId(), role: 'user', content: trimmed } function buildRequestBody(extraMessages?: LlmChatMessage[]) {
messages.value.push(userMsg) const allMessages = extraMessages ? [...messages.value, ...extraMessages] : messages.value
const uiMessages: any[] = []
const assistantMsg: LlmChatMessage = { if (systemPrompt?.()) {
id: generateId(), uiMessages.push({ role: 'system', parts: [{ type: 'text', text: systemPrompt() }] })
role: 'assistant',
content: '',
parts: [],
} }
messages.value.push(assistantMsg)
const assistantIdx = messages.value.length - 1
isLoading.value = true for (const m of allMessages) {
abortController = new AbortController() if (!m.content && (!m.parts || m.parts.length === 0)) continue
uiMessages.push({
let reasoningStartTime: number | null = null role: m.role,
parts: toUIMessageParts(m),
})
}
try { return {
const res = await fetch(apiEndpoint, { modelId: modelId(),
method: 'POST', messages: uiMessages,
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, enableThinking: enableThinking?.() ?? false,
enableTools: enableTools?.() ?? false, enableTools: enableTools?.() ?? false,
}),
signal: abortController.signal,
})
if (!res.ok) {
const errText = await res.text()
throw new Error(errText || `请求失败 (${res.status})`)
} }
if (!res.body) {
throw new Error('响应体为空')
} }
await processDataStream({ async function processStream(res: Response, assistantIdx: number) {
if (!res.body) throw new Error('响应体为空')
let reasoningStartTime: number | null = null
const chunkStream = parseJsonEventStream({
stream: res.body, stream: res.body,
onReasoningPart: (text) => { 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] const msg = messages.value[assistantIdx]
if (!msg) return if (!msg) break
let part = getOrCreateLastPart(msg, 'reasoning')
if (!part) {
if (reasoningStartTime === null) reasoningStartTime = Date.now() if (reasoningStartTime === null) reasoningStartTime = Date.now()
part = { id: generateId(), type: 'reasoning', text: '', reasoningLoading: true } appendPart(msg, { id: generateId(), type: 'reasoning', text: '', reasoningLoading: true })
appendPart(msg, part) break
} }
part.text = (part.text ?? '') + text case 'reasoning-delta': {
}, const msg = messages.value[assistantIdx]
onTextPart: (text) => { 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] const msg = messages.value[assistantIdx]
if (!msg) return if (msg) updateLastReasoningDuration(msg)
break
}
case 'text-delta': {
const msg = messages.value[assistantIdx]
if (!msg) break
updateLastReasoningDuration(msg) updateLastReasoningDuration(msg)
let part = getOrCreateLastPart(msg, 'text') let part = getOrCreateLastPart(msg, 'text')
if (!part) { if (!part) {
part = { id: generateId(), type: 'text', text: '' } part = { id: generateId(), type: 'text', text: '' }
appendPart(msg, part) appendPart(msg, part)
} }
part.text = (part.text ?? '') + text part.text = (part.text ?? '') + chunk.delta
msg.content += text msg.content += chunk.delta
}, break
onErrorPart: (error) => { }
errorMessage.value = error || '流式响应出错' case 'error': {
}, errorMessage.value = chunk.errorText || '流式响应出错'
onToolCallPart: (part) => { break
}
case 'tool-input-available': {
const msg = messages.value[assistantIdx] const msg = messages.value[assistantIdx]
if (!msg) return if (!msg) break
updateLastReasoningDuration(msg) updateLastReasoningDuration(msg)
appendPart(msg, { appendPart(msg, {
id: generateId(), id: generateId(),
type: 'tool-call', type: 'tool-call',
toolName: part.toolName, toolName: chunk.toolName,
toolCallId: part.toolCallId, toolCallId: chunk.toolCallId,
args: part.args, args: chunk.input,
state: 'call', state: 'call',
}) })
}, break
onToolResultPart: (part) => { }
case 'tool-output-available': {
const msg = messages.value[assistantIdx] const msg = messages.value[assistantIdx]
if (!msg || !msg.parts) return if (!msg || !msg.parts) break
const callPart = msg.parts.find(p => p.type === 'tool-call' && p.toolCallId === part.toolCallId) const callPart = msg.parts.find(p => p.type === 'tool-call' && p.toolCallId === chunk.toolCallId)
if (callPart) { if (callPart) {
callPart.result = part.result callPart.result = chunk.output
callPart.state = 'result' 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] const msg = messages.value[assistantIdx]
if (msg) updateLastReasoningDuration(msg) if (msg) updateLastReasoningDuration(msg)
}
// 检查是否有实际内容产出 function validateAssistantContent(assistantIdx: number) {
const finalMsg = messages.value[assistantIdx] const finalMsg = messages.value[assistantIdx]
if (finalMsg && !errorMessage.value) { if (finalMsg && !errorMessage.value) {
const hasText = finalMsg.parts?.some(p => p.type === 'text' && p.text) const hasText = finalMsg.parts?.some(p => p.type === 'text' && p.text)
const hasToolCall = finalMsg.parts?.some(p => p.type === 'tool-call') 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) { if (!hasText && !hasToolCall) {
// 完全无内容,移除空消息
errorMessage.value = '模型未返回任何内容(可能已达到工具调用次数上限或模型无响应)' errorMessage.value = '模型未返回任何内容(可能已达到工具调用次数上限或模型无响应)'
messages.value.splice(assistantIdx, 1) messages.value.splice(assistantIdx, 1)
} else if (!hasText && hasToolCall) { } else if (!hasText && hasToolCall) {
// 有工具调用但无最终文本回答(maxSteps 用完),保留工具记录,追加提示
finalMsg.parts?.push({ finalMsg.parts?.push({
id: generateId(), id: generateId(),
type: 'text', type: 'text',
@ -197,6 +317,45 @@ export function useLlmChat(options: UseLlmChatOptions) {
}) })
} }
} }
}
async function sendMessage(text: string) {
const trimmed = text.trim()
const mid = modelId()
if (!trimmed || mid === null || isLoading.value) return
errorMessage.value = ''
const userMsg: LlmChatMessage = { id: generateId(), role: 'user', content: trimmed }
messages.value.push(userMsg)
const assistantMsg: LlmChatMessage = {
id: generateId(),
role: 'assistant',
content: '',
parts: [],
}
messages.value.push(assistantMsg)
const assistantIdx = messages.value.length - 1
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, assistantIdx)
validateAssistantContent(assistantIdx)
} catch (err: any) { } catch (err: any) {
if (err.name === 'AbortError') { if (err.name === 'AbortError') {
// user stopped // 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() { function stopGeneration() {
if (abortController) { if (abortController) {
abortController.abort() abortController.abort()
@ -232,5 +434,6 @@ export function useLlmChat(options: UseLlmChatOptions) {
sendMessage, sendMessage,
stopGeneration, stopGeneration,
clearChat, clearChat,
respondToApproval,
} }
} }

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

@ -7,6 +7,7 @@ interface AgentToolRow {
type: string type: string
config: string config: string
enabled: number enabled: number
needsApproval: number
sortOrder: number sortOrder: number
createdAt: string createdAt: string
updatedAt: 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) { function openEdit(tool: AgentToolRow) {
editingTool.value = tool editingTool.value = tool
showFormModal.value = true showFormModal.value = true
@ -119,13 +133,14 @@ function onExecuteClose() {
<th class="text-left">Slug</th> <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-left">审批</th>
<th class="text-left">排序</th> <th class="text-left">排序</th>
<th class="text-right">操作</th> <th class="text-right">操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-if="toolList.length === 0"> <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>
<tr v-for="t in toolList" :key="t.id" class="table-row"> <tr v-for="t in toolList" :key="t.id" class="table-row">
<td class="name-cell"> <td class="name-cell">
@ -141,6 +156,11 @@ function onExecuteClose() {
{{ statusBadge(t.enabled).label }} {{ statusBadge(t.enabled).label }}
</span> </span>
</td> </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="sort-cell">{{ t.sortOrder }}</td>
<td class="actions-cell"> <td class="actions-cell">
<div class="action-buttons"> <div class="action-buttons">
@ -148,6 +168,9 @@ function onExecuteClose() {
<button class="action-btn" @click="handleToggle(t)"> <button class="action-btn" @click="handleToggle(t)">
{{ t.enabled ? '禁用' : '启用' }} {{ t.enabled ? '禁用' : '启用' }}
</button> </button>
<button class="action-btn" @click="handleToggleApproval(t)">
{{ t.needsApproval ? '关闭审批' : '开启审批' }}
</button>
<button class="action-btn" @click="openEdit(t)">编辑</button> <button class="action-btn" @click="openEdit(t)">编辑</button>
<button class="action-btn action-btn-danger" @click="handleDelete(t.id)">删除</button> <button class="action-btn action-btn-danger" @click="handleDelete(t.id)">删除</button>
</div> </div>
@ -357,6 +380,16 @@ function onExecuteClose() {
color: #b06000; color: #b06000;
} }
.badge-approval {
background: #fef0f0;
color: #e53e3e;
}
.badge-no-approval {
background: #f0f0f0;
color: #666;
}
.sort-cell { .sort-cell {
color: var(--color-muted); color: var(--color-muted);
font-size: 13px; 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 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, modelId: () => selectedModelId.value,
systemPrompt: () => systemPrompt.value, systemPrompt: () => systemPrompt.value,
enableThinking: () => enableThinking.value, enableThinking: () => enableThinking.value,
@ -297,13 +297,47 @@ function modelTypeClass(type: string) {
<Icon name="lucide:wrench" class="tool-icon" /> <Icon name="lucide:wrench" class="tool-icon" />
<span class="tool-name">{{ part.toolName }}</span> <span class="tool-name">{{ part.toolName }}</span>
<span :class="['tool-state', part.state]"> <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> </span>
</div> </div>
<div v-if="part.args" class="tool-args"> <div v-if="part.args" class="tool-args">
<span class="tool-label">参数:</span> <span class="tool-label">参数:</span>
<code>{{ JSON.stringify(part.args) }}</code> <code>{{ JSON.stringify(part.args) }}</code>
</div> </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"> <div v-if="part.result !== undefined" class="tool-result">
<span class="tool-label">结果:</span> <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> <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); 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-args,
.tool-result { .tool-result {
display: flex; 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(), type: text("type", { length: 30 }).notNull(),
config: text("config").notNull(), config: text("config").notNull(),
enabled: integer("enabled").default(1).notNull(), enabled: integer("enabled").default(1).notNull(),
needsApproval: integer("needs_approval").default(0).notNull(),
sortOrder: integer("sort_order").default(0).notNull(), sortOrder: integer("sort_order").default(0).notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" }) createdAt: integer("created_at", { mode: "timestamp_ms" })
.defaultNow() .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, "when": 1785894874773,
"tag": "0014_breezy_maestro", "tag": "0014_breezy_maestro",
"breakpoints": true "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 body = await readBody(event);
const { name, slug, description, type, config, enabled, sortOrder } = body; const { name, slug, description, type, config, enabled, needsApproval, sortOrder } = body;
try { try {
const result = await updateAgentTool(id, { const result = await updateAgentTool(id, {
@ -20,6 +20,7 @@ export default defineWrappedResponseHandler(async (event) => {
type, type,
config, config,
enabled, enabled,
needsApproval,
sortOrder, sortOrder,
}); });
if (!result) { if (!result) {

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

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

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

@ -1,7 +1,7 @@
import { requireUser } from "#server/utils/context"; import { requireUser } from "#server/utils/context";
import { getProviderById, getModelById } from "#server/service/llm"; import { getProviderById, getModelById } from "#server/service/llm";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; 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 { getEnabledToolsForLlm } from "#server/service/agent-tool";
import log4js from "logger"; import log4js from "logger";
@ -15,7 +15,7 @@ function resolveModel(
parseMode: string; parseMode: string;
}, },
modelId: string, modelId: string,
): LanguageModelV1 { ): LanguageModel {
const baseUrl = provider.baseUrl?.replace(/\/+$/, "") || undefined; const baseUrl = provider.baseUrl?.replace(/\/+$/, "") || undefined;
if (provider.parseMode === "anthropic") { if (provider.parseMode === "anthropic") {
@ -31,7 +31,7 @@ function resolveModel(
baseURL: baseUrl || "https://api.openai.com/v1", baseURL: baseUrl || "https://api.openai.com/v1",
}); });
return openaiCompatible(modelId) as LanguageModelV1; return openaiCompatible(modelId) as LanguageModel;
} }
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
@ -43,7 +43,7 @@ export default defineEventHandler(async (event) => {
const body = await readBody(event); const body = await readBody(event);
const { modelId: llmModelId, messages, enableThinking, enableTools } = body as { const { modelId: llmModelId, messages, enableThinking, enableTools } = body as {
modelId: number; modelId: number;
messages: { role: "user" | "assistant" | "system"; content: string }[]; messages: any[];
enableThinking?: boolean; enableThinking?: boolean;
enableTools?: boolean; enableTools?: boolean;
}; };
@ -84,14 +84,16 @@ export default defineEventHandler(async (event) => {
const languageModel = resolveModel(provider, model.modelId); 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({ const result = streamText({
model: languageModel, model: languageModel,
messages, messages: modelMessages,
maxTokens: model.maxTokens || undefined, maxOutputTokens: model.maxTokens || undefined,
...(tools && Object.keys(tools).length > 0 ...(tools && Object.keys(tools).length > 0
? { tools, maxSteps: 8 } ? { tools, stopWhen: stepCountIs(8), toolApproval: approvalConfig }
: {}), : {}),
...(enableThinking ...(enableThinking
? { ? {
@ -112,11 +114,11 @@ export default defineEventHandler(async (event) => {
event.context.requestId ?? "-", event.context.requestId ?? "-",
finishReason, finishReason,
steps.length, steps.length,
usage?.promptTokens ?? 0, usage?.inputTokens ?? 0,
usage?.completionTokens ?? 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; type: string;
config: Record<string, unknown>; config: Record<string, unknown>;
enabled?: boolean; enabled?: boolean;
needsApproval?: boolean;
sortOrder?: number; sortOrder?: number;
} }
@ -99,6 +100,7 @@ export interface UpdateAgentToolInput {
type?: string; type?: string;
config?: Record<string, unknown>; config?: Record<string, unknown>;
enabled?: boolean; enabled?: boolean;
needsApproval?: boolean;
sortOrder?: number; sortOrder?: number;
} }
@ -175,6 +177,7 @@ export async function createAgentTool(input: CreateAgentToolInput): Promise<Agen
type: input.type, type: input.type,
config: JSON.stringify(validatedConfig), config: JSON.stringify(validatedConfig),
enabled: input.enabled === false ? 0 : 1, enabled: input.enabled === false ? 0 : 1,
needsApproval: input.needsApproval ? 1 : 0,
sortOrder: input.sortOrder ?? 0, sortOrder: input.sortOrder ?? 0,
}); });
const row = await getAgentToolById(id); 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)); updates.config = JSON.stringify(validateConfig(typeToValidate, input.config));
} }
if (input.enabled !== undefined) updates.enabled = input.enabled ? 1 : 0; 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 (input.sortOrder !== undefined) updates.sortOrder = input.sortOrder;
if (Object.keys(updates).length === 0) return existing; if (Object.keys(updates).length === 0) return existing;
@ -311,7 +315,10 @@ export async function getEnabledToolInfos(): Promise<EnabledToolInfo[]> {
return result; 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 const tools = await dbGlobal
.select() .select()
.from(agentTools) .from(agentTools)
@ -319,6 +326,7 @@ export async function getEnabledToolsForLlm(): Promise<Record<string, ReturnType
.orderBy(asc(agentTools.sortOrder)); .orderBy(asc(agentTools.sortOrder));
const result: Record<string, any> = {}; const result: Record<string, any> = {};
const approvalConfig: Record<string, "user-approval" | "approved"> = {};
for (const agentTool of tools) { for (const agentTool of tools) {
const executor = getExecutor(agentTool.type); const executor = getExecutor(agentTool.type);
if (!executor) continue; if (!executor) continue;
@ -334,7 +342,7 @@ export async function getEnabledToolsForLlm(): Promise<Record<string, ReturnType
result[agentTool.slug] = tool({ result[agentTool.slug] = tool({
description: executor.buildDescription(config), description: executor.buildDescription(config),
parameters: jsonSchema(jsonSch, { inputSchema: jsonSchema(jsonSch, {
validate: (v: unknown) => { validate: (v: unknown) => {
const r = zodSchema.safeParse(v); const r = zodSchema.safeParse(v);
return r.success return r.success
@ -354,8 +362,9 @@ export async function getEnabledToolsForLlm(): Promise<Record<string, ReturnType
return `[${agentTool.type} 结果 ${statusInfo} ${sizeInfo}]\n${dataStr}`; return `[${agentTool.type} 结果 ${statusInfo} ${sizeInfo}]\n${dataStr}`;
}, },
}); });
approvalConfig[agentTool.slug] = agentTool.needsApproval ? "user-approval" : "approved";
} }
return result; return { tools: result, approvalConfig };
} }
// 导出各工具默认配置和类型 // 导出各工具默认配置和类型

Loading…
Cancel
Save