新增: 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:
@@ -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;
|
||||
|
||||
@@ -79,6 +79,12 @@ export interface ToolResult {
|
||||
old_size?: number | null
|
||||
encoding?: string
|
||||
cross_volume?: boolean
|
||||
// http_request result fields
|
||||
method?: string
|
||||
url?: string
|
||||
body?: string
|
||||
body_bytes?: number
|
||||
elapsed_ms?: number
|
||||
}
|
||||
|
||||
/** 内联 SVG 图标字符串(经 v-html 注入,常量安全;图标库改造见决策记录) */
|
||||
@@ -414,11 +420,47 @@ export function formatToolResult(tc: AiToolCallInfo): string {
|
||||
if (!execId) return formatJson(r)
|
||||
return t('aiTool.workflowTriggered', { id: r.task_id || '', status: r.target_status || '', exec: execId })
|
||||
}
|
||||
case 'http_request':
|
||||
return formatHttpResult(r)
|
||||
default:
|
||||
return formatJson(r)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* http_request 结果摘要:状态码标题(method + 最终 URL) + 截断的 body(前 N 行) + 耗时/字节数。
|
||||
* 后端已截断 body 到 50KB + parse 格式化(json pretty/auto),此处仅做行数限制(前 20 行)
|
||||
* 防大 JSON 撑爆折叠态卡片。展开态由 ToolCard.vue http_request 分支全量展示。
|
||||
*/
|
||||
export function formatHttpResult(r: ToolResult): string {
|
||||
const status = typeof r.status === 'number' ? r.status : 0
|
||||
const method = typeof r.method === 'string' ? r.method : 'HTTP'
|
||||
const url = typeof r.url === 'string' ? r.url : ''
|
||||
const ms = typeof r.elapsed_ms === 'number' ? r.elapsed_ms : 0
|
||||
const ok = status >= 200 && status < 300
|
||||
const head = ok
|
||||
? t('aiTool.httpOk', { method, status, ms })
|
||||
: t('aiTool.httpFailed', { method, status })
|
||||
const parts: string[] = [head, url]
|
||||
const body = typeof r.body === 'string' ? r.body : ''
|
||||
if (body) {
|
||||
const MAX_LINES = 20
|
||||
const lines = body.split('\n')
|
||||
if (lines.length > MAX_LINES) {
|
||||
parts.push(lines.slice(0, MAX_LINES).join('\n'))
|
||||
parts.push(t('aiTool.commandOutputLines', { n: lines.length }))
|
||||
} else {
|
||||
parts.push(body)
|
||||
}
|
||||
}
|
||||
const trunc = r.truncated === true
|
||||
const bytes = typeof r.body_bytes === 'number' ? r.body_bytes : 0
|
||||
if (trunc) {
|
||||
parts.push(t('aiTool.httpTruncated', { bytes: formatBytes(bytes) }))
|
||||
}
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* completed 工具结果的一句话摘要(折叠态 header 也能看到,避免光秃秃)。
|
||||
* UX-260616-04:覆盖范围与 formatToolResult 对齐(header+body 一致);
|
||||
@@ -493,6 +535,15 @@ export function toolResultSummary(tc: AiToolCallInfo): string {
|
||||
: (typeof r.id === 'string' ? r.id : '')
|
||||
return execId ? t('aiTool.workflowTriggeredShort', { exec: execId }) : ''
|
||||
}
|
||||
case 'http_request': {
|
||||
const status = typeof r.status === 'number' ? r.status : 0
|
||||
const method = typeof r.method === 'string' ? r.method : 'HTTP'
|
||||
const ms = typeof r.elapsed_ms === 'number' ? r.elapsed_ms : 0
|
||||
const ok = status >= 200 && status < 300
|
||||
return ok
|
||||
? t('aiTool.httpOk', { method, status, ms })
|
||||
: t('aiTool.httpFailed', { method, status })
|
||||
}
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,11 @@ export default {
|
||||
renamedCrossVolume: 'Moved {from} → {to} (cross-volume, {bytes})',
|
||||
writeOverwrite: 'overwrote {old} → {neww}',
|
||||
encodingLabel: 'encoding: {enc}',
|
||||
// http_request result summary (method + status + elapsed; body in expanded view)
|
||||
httpOk: '{method} {status} OK ({ms} ms)',
|
||||
httpFailed: '{method} {status} failed',
|
||||
httpTruncated: 'Response truncated ({bytes}), see details',
|
||||
httpRequestFallback: 'HTTP Request',
|
||||
foundN: 'Found {n}',
|
||||
boundDir: 'Bound directory "{path}" stack: {stack}',
|
||||
deleteFilePermanent: 'Permanently deleted {path}',
|
||||
@@ -125,6 +130,7 @@ export default {
|
||||
purgeProject: 'Purge Project',
|
||||
updateProject: 'Update Project',
|
||||
runWorkflow: 'Run Workflow',
|
||||
httpRequest: 'HTTP Request',
|
||||
},
|
||||
|
||||
// UX-260617-14: approval loading fallback timeout notice (no backend ack, card still pending_approval → reset + prompt)
|
||||
@@ -133,6 +139,9 @@ export default {
|
||||
// UX-260617-18: tool failure detection copy (regex narrowed to line-anchored err_msg prefix)
|
||||
// (reuse executionFailed/executionFailedHint, no new key)
|
||||
|
||||
// http_request High-risk confirm: external network call, data may leak via URL/body
|
||||
confirmHighHttp: 'Send this HTTP request to an external server?',
|
||||
|
||||
// Approval argument key labels
|
||||
argLabel: {
|
||||
id: 'ID',
|
||||
@@ -146,6 +155,12 @@ export default {
|
||||
priority: 'Priority',
|
||||
tags: 'Tags',
|
||||
source: 'Source',
|
||||
method: 'Method',
|
||||
url: 'URL',
|
||||
body: 'Body',
|
||||
headers: 'Headers',
|
||||
timeout_secs: 'Timeout (s)',
|
||||
parse: 'Parse',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -83,6 +83,11 @@ export default {
|
||||
renamedCrossVolume: '已移动 {from} → {to}(跨卷,{bytes})',
|
||||
writeOverwrite: '覆盖 {old} → {neww}',
|
||||
encodingLabel: '编码:{enc}',
|
||||
// http_request 结果摘要(method + 状态码 + 耗时;body 在展开态显示)
|
||||
httpOk: '{method} {status} 成功({ms} 毫秒)',
|
||||
httpFailed: '{method} {status} 失败',
|
||||
httpTruncated: '响应已截断({bytes}),详见下方',
|
||||
httpRequestFallback: 'HTTP 请求',
|
||||
foundN: '找到 {n} 项',
|
||||
boundDir: '已绑定目录「{path}」技术栈:{stack}',
|
||||
deleteFilePermanent: '已硬删除 {path}',
|
||||
@@ -107,6 +112,8 @@ export default {
|
||||
confirmHighDelete: '确定永久删除?此操作不可恢复',
|
||||
confirmHighExec: '确定执行此命令?',
|
||||
confirmHighGeneric: '确定执行此高危操作?',
|
||||
// http_request 高危二次确认:外发网络请求,数据可经 URL/body 泄露
|
||||
confirmHighHttp: '确定向外部服务器发起此 HTTP 请求?',
|
||||
|
||||
// 工具结果详情格式(非 JSON 纯文本 + 审批占位时补参数摘要)
|
||||
resultDetail: '{raw}({detail})',
|
||||
@@ -127,6 +134,7 @@ export default {
|
||||
purgeProject: '彻底删除项目',
|
||||
updateProject: '更新项目',
|
||||
runWorkflow: '运行工作流',
|
||||
httpRequest: 'HTTP 请求',
|
||||
},
|
||||
|
||||
// UX-260617-14:审批 loading 超时兜底提示(后端无回执,卡片仍 pending_approval 时复位 + 提示用户)
|
||||
@@ -148,6 +156,12 @@ export default {
|
||||
priority: '优先级',
|
||||
tags: '标签',
|
||||
source: '来源',
|
||||
method: '方法',
|
||||
url: 'URL',
|
||||
body: '请求体',
|
||||
headers: '请求头',
|
||||
timeout_secs: '超时(秒)',
|
||||
parse: '解析',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user