新增: 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;
|
||||
|
||||
Reference in New Issue
Block a user