新增: http_request AI工具(结构化HTTP·SSRF防御·High审批)

- 后端: Cargo.toml reqwest0.12(json/gzip/brotli native-tls对齐df-ai) + http.rs(handler+SSRF三层防御 validate_url/resolve_and_check/重定向≤3跳每跳校验) + tool_registry register_http_tools + 基线28→29
- 前端: useToolCard formatHttpResult + ToolCard渲染(method/url/status/body折叠/截断) + HIGH_RISK_TOOLS + confirmHighHttp + i18n
- SSRF: localhost/私有IP(127/10/172.16/192.168/169.254云元数据/100.64CGNAT/≥224/IPv6保留)拒绝 + DNS resolve校验防重绑定 + 重定向每跳重校验
- 审批: High(统一保守 GET也审批 防漏写操作)
- 截断: 响应50KB(T-05)+ 请求1MB + 超时1-60s默认30
This commit is contained in:
2026-06-19 12:48:28 +08:00
parent b42cc66e7a
commit f1a06732fd
8 changed files with 904 additions and 5 deletions

View File

@@ -165,6 +165,28 @@
<pre v-if="cmdOutputExpanded && cmdOutput" class="ai-tool-cmd-output"><code>{{ cmdOutput }}</code></pre>
</div>
<!-- http_request 结果:method + url + 状态码徽标 + 可折叠响应 body(对齐 run_command 模式 UX-260618-08) -->
<div
v-else-if="tc.name === 'http_request' && tc.result && tc.status === 'completed' && parsed"
class="ai-tool-http-result"
>
<div class="ai-tool-http-line" :class="{ 'ai-tool-http-line--failed': isFailed }">
<span class="ai-tool-http-method">{{ parsed?.method || 'GET' }}</span>
<code class="ai-tool-http-url">{{ parsed?.url }}</code>
<span class="ai-tool-http-status" :class="{ 'ai-tool-http-status--ok': !isFailed }">{{ parsed?.status }}</span>
<button
v-if="parsed?.body"
class="ai-tool-cmd-toggle"
:title="httpBodyExpanded ? $t('aiTool.collapse') : $t('aiTool.expand')"
@click="httpBodyExpanded = !httpBodyExpanded"
>{{ httpBodyExpanded ? $t('aiTool.collapse') : $t('aiTool.expand') }}</button>
</div>
<div v-if="parsed?.truncated && !httpBodyExpanded" class="ai-tool-http-trunc-warn">
{{ $t('aiTool.httpTruncated', { bytes: formatBytes(parsed?.body_bytes) }) }}
</div>
<pre v-if="httpBodyExpanded && parsed?.body" class="ai-tool-cmd-output"><code>{{ parsed.body }}</code></pre>
</div>
<!-- 通用 CRUD 结果 -->
<div
v-else-if="tc.result && tc.status === 'completed'"
@@ -248,6 +270,8 @@ const emit = defineEmits<{
// run_command 输出折叠态:失败命令默认展开(便于看 stderr),成功默认折叠
// 注:此处仅状态翻转时调一次,不进模板高频路径,沿用模块级 isToolFailure(props.tc) 无性能问题。
const cmdOutputExpanded = ref(false)
// http_request 响应 body 折叠态:失败(非 2xx)默认展开便于看错误,成功默认折叠
const httpBodyExpanded = ref(false)
// SW-260618-06: cmdOutputExpanded 的 status watch 合并到下方 approving 复位 watch
// (approving/approvingTimer 在下方定义,此处 immediate 会 TDZ,合并 watch 放三 ref 都已定义后)。
@@ -286,11 +310,13 @@ function showApproveTimeoutToast(): void {
*/
const HIGH_RISK_TOOLS = new Set<string>([
'delete_task', 'delete_project', 'restore_project', 'purge_project', 'delete_file', 'run_command',
'http_request',
])
/** High 风险工具的二次确认文案(按操作类型) */
function highRiskConfirmMsg(name: string): string {
if (name === 'run_command') return t('aiTool.confirmHighExec')
if (name === 'http_request') return t('aiTool.confirmHighHttp')
if (name === 'delete_task' || name === 'delete_project' || name === 'purge_project' || name === 'delete_file' || name === 'restore_project') {
return t('aiTool.confirmHighDelete')
}
@@ -320,7 +346,10 @@ async function onApprove(approved: boolean) {
// immediate 时 s=初始 status:cmdOutputExpanded 仅 completed 初始化(初始非 completed 无副作用);
// approving 初始 false + approvingTimer 初始 null(if null 跳过 clearTimeout),immediate 安全。
watch(() => props.tc.status, (s) => {
if (s === 'completed') cmdOutputExpanded.value = isToolFailure(props.tc)
if (s === 'completed') {
cmdOutputExpanded.value = isToolFailure(props.tc)
httpBodyExpanded.value = isToolFailure(props.tc)
}
if (s !== 'pending_approval') {
approving.value = false
if (approvingTimer) { clearTimeout(approvingTimer); approvingTimer = null }
@@ -499,9 +528,33 @@ function toolDisplayName(tc: AiToolCallInfo): string {
const cmd = argString(tc.args, 'command')
return cmd ? `$ ${shortCmd(cmd)}` : formatToolName(tc.name)
}
case 'http_request': {
// 折叠态头部显 method + host(从 url 提取),无 url 回退通用名
const method = argString(tc.args, 'method') || 'GET'
const url = argString(tc.args, 'url')
const host = httpHost(url)
return host ? `${method} ${host}` : t('aiTool.httpRequestFallback')
}
default: return formatToolName(tc.name)
}
}
/**
* http_request url → host 显示用(折叠态头部精简,不全显 url)。
* 取 scheme://host[:port],剥 path/query;非 http(s) url 返回空串回退通用名。
* 注:用 URL 构造器容错,非法 url 抛错时回退原 url 截断。
*/
function httpHost(raw: string): string {
if (!raw) return ''
try {
const u = new URL(raw)
if (u.protocol !== 'http:' && u.protocol !== 'https:') return ''
const portPart = u.port ? `:${u.port}` : ''
return `${u.hostname}${portPart}`
} catch {
return raw.length > 40 ? raw.slice(0, 40) + '…' : raw
}
}
</script>
<style scoped>
@@ -1041,6 +1094,54 @@ function toolDisplayName(tc: AiToolCallInfo): string {
border-top: 0.5px solid var(--df-border);
}
/* -- http_request 结果:method 徽标 + url + 状态码(复用 cmd-line 视觉 token) -- */
.ai-tool-http-result {
margin: 0;
border-top: 0.5px solid var(--df-border);
font-family: var(--df-font-mono);
font-size: 11px;
}
.ai-tool-http-line {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: rgba(61,219,160,0.04); /* 成功默认(对齐 cmd-line);失败由 --failed 覆盖 */
}
.ai-tool-http-line--failed {
background: var(--df-danger-bg);
}
.ai-tool-http-method {
flex-shrink: 0;
font-weight: 600;
padding: 1px 5px;
border-radius: var(--df-radius-sm);
background: rgba(255,255,255,0.08);
color: var(--df-text);
font-size: 10px;
}
.ai-tool-http-url {
flex: 1;
min-width: 0;
white-space: pre-wrap;
word-break: break-all;
color: var(--df-text-dim);
}
.ai-tool-http-status {
flex-shrink: 0;
font-weight: 600;
color: var(--df-danger);
}
.ai-tool-http-status--ok {
color: var(--df-success);
}
.ai-tool-http-trunc-warn {
padding: 4px 10px;
font-size: 10.5px;
color: var(--df-warning, #f0c75e);
border-top: 0.5px solid var(--df-border);
}
/* -- UX-260617-14: 审批 loading 超时兜底 toast(复用 AiChat 同款视觉 token) -- */
.ai-tool-toast {
position: absolute;