修复 tool_result 压缩零效果(BUG-260628-01):单行/少行/JSON 大字符 串字段逃逸压缩的问题,基于实测数据(53/94 次迭代零节省)调参, 增加字符级保底截断,压缩有效率从 8% 提升至 ~85%+
374 lines
19 KiB
Vue
374 lines
19 KiB
Vue
<template>
|
|
<div
|
|
class="ai-tool-card"
|
|
:class="[
|
|
'ai-tool-card--' + tc.status,
|
|
{ 'ai-tool-card--collapsed': !isExpanded && !shouldKeepOpen(tc), 'ai-tool-card--failed': isFailed },
|
|
]"
|
|
>
|
|
<!-- 卡片头部:图标 + 名称 + 状态 -->
|
|
<div class="ai-tool-header" @click="emit('toggle', tc.id)">
|
|
<span class="ai-tool-icon-wrap" :class="'ai-tool-icon-wrap--' + toolCategory(tc.name)">
|
|
<span v-html="toolIcon(tc.name)"></span>
|
|
</span>
|
|
<div class="ai-tool-header-text">
|
|
<span class="ai-tool-name">{{ toolDisplayName(tc) }}</span>
|
|
<span v-if="tc.status === 'running'" class="ai-tool-sub">{{ $t('aiTool.running') }}</span>
|
|
<span v-else-if="isFailed" class="ai-tool-sub ai-tool-sub--failed">{{ $t('aiTool.executionFailed') }}</span>
|
|
<span v-else-if="toolResultSummary(tc)" class="ai-tool-sub">{{ toolResultSummary(tc) }}</span>
|
|
</div>
|
|
<span class="ai-tool-status-dot" :class="'ai-tool-status-dot--' + (isFailed ? 'failed' : tc.status)"></span>
|
|
<span v-if="!shouldKeepOpen(tc)" class="ai-tool-collapse-hint" :class="{ 'is-open': isExpanded }">▸</span>
|
|
</div>
|
|
|
|
<!-- Body: 收起态隐藏(running/pending 强制展开) -->
|
|
<div v-show="isExpanded || shouldKeepOpen(tc)" class="ai-tool-body">
|
|
|
|
<!-- Running 骨架屏 -->
|
|
<div v-if="tc.status === 'running'" class="ai-tool-skeleton">
|
|
<div class="ai-tool-skeleton-line" style="width:60%"></div>
|
|
<div class="ai-tool-skeleton-line" style="width:85%"></div>
|
|
<div class="ai-tool-skeleton-line" style="width:40%"></div>
|
|
</div>
|
|
|
|
<!-- 审批:参数内容 + 风险提示 + 按钮 -->
|
|
<div v-if="tc.status === 'pending_approval'" class="ai-tool-approval">
|
|
<div v-if="argsEntries().length" class="ai-tool-approval-args">
|
|
<div v-for="arg in argsEntries()" :key="arg.key" class="ai-tool-arg">
|
|
<span class="ai-tool-arg-key">{{ arg.label }}</span>
|
|
<span class="ai-tool-arg-val">{{ displayArgValue(arg) }}</span>
|
|
</div>
|
|
</div>
|
|
<div v-if="tc.reason" class="ai-tool-approval-reason">⚠ {{ tc.reason }}</div>
|
|
<!-- BUG-260624-02: 审批挂起时长提示(>2分钟橙色,>5分钟红色) -->
|
|
<div v-if="waitSecs > 0" class="ai-tool-wait-time" :class="'is-' + waitLevel">
|
|
⏱ 已等待 {{ formatWaitTime(waitSecs) }}
|
|
</div>
|
|
<!-- AE-2025-03: write_file 审批 diff 预览(红删绿增,行级)。旧文件不存在→tc.diff 为空,回退显上方 args content -->
|
|
<div v-if="tc.diff" class="ai-tool-approval-diff">
|
|
<pre class="ai-tool-diff-pre"><code><span v-for="(ln, idx) in diffLines" :key="idx" class="ai-tool-diff-line" :class="'ai-tool-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
|
|
</div>
|
|
<!-- path_auth 审批链阶段3b:按 tc.kind 分两组按钮。
|
|
- kind='path'(路径授权挂起):once/always/deny 三选项,调 authorizeDir。
|
|
- 其余(risk/缺省,普通 RiskLevel 审批):approve/reject 两选项,调 approve。 -->
|
|
<div v-if="tc.kind === 'path'" class="ai-tool-actions ai-tool-actions--path">
|
|
<button class="ai-tool-btn ai-tool-btn--approve"
|
|
:disabled="approving"
|
|
@click="onAuthorizeOnce">
|
|
<span v-if="approving" class="ai-tool-btn-spinner" />
|
|
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
|
{{ $t('aiChat.dirAuthOnce') }}
|
|
</button>
|
|
<button class="ai-tool-btn ai-tool-btn--always"
|
|
:disabled="approving"
|
|
@click="onAuthorizeAlways">
|
|
<span v-if="approving" class="ai-tool-btn-spinner" />
|
|
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
|
{{ $t('aiChat.dirAuthAlways') }}
|
|
</button>
|
|
<button class="ai-tool-btn ai-tool-btn--reject"
|
|
:disabled="approving"
|
|
@click="onDeny">
|
|
<span v-if="approving" class="ai-tool-btn-spinner" />
|
|
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
{{ $t('aiChat.dirAuthDeny') }}
|
|
</button>
|
|
</div>
|
|
<div v-else class="ai-tool-actions">
|
|
<button class="ai-tool-btn ai-tool-btn--approve"
|
|
:disabled="approving"
|
|
@click="onApprove">
|
|
<span v-if="approving" class="ai-tool-btn-spinner" />
|
|
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
|
{{ $t('aiTool.approve') }}
|
|
</button>
|
|
<button class="ai-tool-btn ai-tool-btn--reject"
|
|
:disabled="approving"
|
|
@click="onReject">
|
|
<span v-if="approving" class="ai-tool-btn-spinner" />
|
|
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
{{ $t('aiTool.reject') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 已拒绝提示 -->
|
|
<div v-if="tc.status === 'rejected'" class="ai-tool-rejected">
|
|
<span class="ai-tool-rejected-icon">✕</span>
|
|
{{ tc.result || $t('aiTool.rejectedHint') }}
|
|
</div>
|
|
|
|
<!-- 各工具结果展示(read_file/list_directory/search_files/write_file/patch_file/run_command/http_request/通用)
|
|
委托 ToolResultBody 子组件,透传 tc + 内容展开态 + expand-content 冒泡 -->
|
|
<ToolResultBody
|
|
v-if="tc.status === 'completed'"
|
|
:tc="tc"
|
|
:isContentExpanded="isContentExpanded"
|
|
@expand-content="(id) => emit('expand-content', id)"
|
|
/>
|
|
</div><!-- /.ai-tool-body -->
|
|
|
|
<!-- AE-2025-05:High 风险审批二次确认(本卡自治 useConfirm,不与 AiChat 父级 confirmState 串台) -->
|
|
<ConfirmDialog
|
|
:visible="confirmState.visible"
|
|
:msg="confirmState.msg"
|
|
:danger-label="t('common.confirm')"
|
|
@result="answerConfirm"
|
|
/>
|
|
|
|
<!-- UX-260617-14:审批 loading 超时兜底 toast(零依赖 reactive,复用 AiChat 同款样式) -->
|
|
<Transition name="ai-tool-toast">
|
|
<div v-if="approveToast.visible" class="ai-tool-toast ai-tool-toast--warning">{{ approveToast.msg }}</div>
|
|
</Transition>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, toRef } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import {
|
|
shouldKeepOpen,
|
|
parseResult,
|
|
toolCategory,
|
|
toolIcon,
|
|
toolResultSummary,
|
|
} from '@/composables/ai/useToolCard'
|
|
import { parseDiffLines } from '@/composables/ai/useToolCardRender'
|
|
import { useToolCardHeader } from '@/composables/ai/useToolCardHeader'
|
|
import { useToolApproval } from '@/composables/ai/useToolApproval'
|
|
import type { AiToolCallInfo } from '@/api/types'
|
|
import ConfirmDialog from './ConfirmDialog.vue'
|
|
import ToolResultBody from './ToolResultBody.vue'
|
|
|
|
const { t } = useI18n()
|
|
|
|
const props = defineProps<{
|
|
/** 单条工具调用数据 */
|
|
tc: AiToolCallInfo
|
|
/** 卡片级展开(来自父级 expandedCards) */
|
|
isExpanded: boolean
|
|
/** 内容级展开(read_file 的"展开全部",来自父级 expandedTools) */
|
|
isContentExpanded: boolean
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
/** header 点击 → 卡片折叠/展开 */
|
|
toggle: [id: string]
|
|
/** read_file "展开全部/收起" → 内容级展开 */
|
|
'expand-content': [id: string]
|
|
/**
|
|
* 审批按钮 → 父级转发 store.approveToolCall。
|
|
* path_auth 审批链阶段3b:risk 类带 approved boolean;path 类带 decision('once'|'always'|'deny')。
|
|
* 调用方(store.approveToolCall)按是否有 decision 分派 approve / authorizeDir。
|
|
*/
|
|
approve: [{ id: string; approved: boolean; decision?: 'once' | 'always' | 'deny' }]
|
|
}>()
|
|
|
|
// 头部显示逻辑(工具名/审批参数语义化回显)抽离至 composable,传入 tc getter 保持响应式。
|
|
const { toolDisplayName, displayArgValue, argsEntries } = useToolCardHeader(() => props.tc)
|
|
|
|
// 审批状态机(按钮 loading/挂起时长/toast/approve+reject+path_auth 三选项)抽离至 composable。
|
|
const {
|
|
approving,
|
|
approveToast,
|
|
waitSecs,
|
|
waitLevel,
|
|
formatWaitTime,
|
|
onApprove,
|
|
onReject,
|
|
onAuthorizeOnce,
|
|
onAuthorizeAlways,
|
|
onDeny,
|
|
confirmState,
|
|
answerConfirm,
|
|
} = useToolApproval(toRef(props, 'tc'), emit)
|
|
|
|
/** 一次解析结果供失败判定复用(头部 isFailed 用于卡片 border/status-dot 着色) */
|
|
const parsed = computed(() => parseResult(props.tc.result))
|
|
|
|
/**
|
|
* 是否工具失败(completed 语义)。头部多处复用(.ai-tool-card--failed / 状态点 / sub 文案)。
|
|
* 逻辑与模块级 isToolFailure 同源;结果展示区的 isFailed 已迁入 ToolResultBody(各自 computed)。
|
|
*/
|
|
const isFailed = computed(() => {
|
|
const tc = props.tc
|
|
if (tc.status !== 'completed') return false
|
|
const r = parsed.value
|
|
if (tc.name === 'run_command') {
|
|
if (r && typeof r.exit_code === 'number') return r.exit_code !== 0
|
|
if (r) return true
|
|
}
|
|
if (!r) {
|
|
const raw = typeof tc.result === 'string' ? tc.result : ''
|
|
if (!raw) return false
|
|
return /^(执行失败|Error:|Failed:)/m.test(raw)
|
|
}
|
|
return false
|
|
})
|
|
|
|
/** AE-2025-03: write_file 审批 diff 行解析(复用 parseDiffLines 纯函数,数据源 props.tc.diff) */
|
|
const diffLines = computed(() => parseDiffLines(props.tc.diff))
|
|
</script>
|
|
|
|
<style scoped>
|
|
.ai-tool-card {
|
|
background: var(--df-bg-card);
|
|
border: 0.5px solid var(--df-border);
|
|
border-radius: var(--df-radius);
|
|
padding: 0;
|
|
overflow: hidden;
|
|
max-width: var(--df-msg-max-width);
|
|
animation: fadeInUp 0.2s var(--df-ease);
|
|
/* UX-260617-14:相对定位锚定本卡 toast(absolute 子元素) */
|
|
position: relative;
|
|
/* UX-260616-03: border/shadow/opacity 过渡让折叠/失败态切换不突兀。
|
|
不做 body 高度动画(v-show display 切换 + flex gap 会留双倍间距致布局跳变,见 ToolCardList 注释)。 */
|
|
transition: border-color 0.2s var(--df-ease), box-shadow 0.2s var(--df-ease), opacity 0.2s var(--df-ease);
|
|
}
|
|
.ai-tool-card--pending_approval { border-color: rgba(240,199,94,0.3); box-shadow: 0 0 0 1px rgba(240,199,94,0.06); }
|
|
.ai-tool-card--completed { border-color: rgba(61,219,160,0.2); }
|
|
.ai-tool-card--rejected { border-color: rgba(240,101,101,0.2); opacity: 0.55; }
|
|
/* UX-260616-01: 工具失败态——AR-6 下 status 仍为 completed,故单独覆盖边框与 completed 区分 */
|
|
.ai-tool-card--failed { border-color: rgba(240,101,101,0.35); box-shadow: 0 0 0 1px rgba(240,101,101,0.06); }
|
|
.ai-tool-status-dot--failed { background: var(--df-danger); }
|
|
.ai-tool-sub--failed { color: var(--df-danger); }
|
|
.ai-tool-rejected {
|
|
display: flex; align-items: center; gap: 6px;
|
|
padding: 4px 10px 8px 42px; font-size: 11px; color: var(--df-danger);
|
|
}
|
|
.ai-tool-rejected-icon { font-weight: 700; }
|
|
|
|
/* -- 卡片折叠态(running/pending 始终展开,completed/rejected 默认折叠为单行 header) -- */
|
|
.ai-tool-card--collapsed .ai-tool-header { cursor: pointer; }
|
|
.ai-tool-card--collapsed .ai-tool-header:hover { background: rgba(255,255,255,0.03); }
|
|
.ai-tool-body { overflow: hidden; }
|
|
.ai-tool-collapse-hint {
|
|
font-size: 10px; color: var(--df-text-dim); margin-left: auto; flex-shrink: 0;
|
|
transition: transform 0.15s var(--df-ease);
|
|
}
|
|
.ai-tool-collapse-hint.is-open { transform: rotate(90deg); }
|
|
|
|
/* -- Header -- */
|
|
.ai-tool-header { display: flex; align-items: center; gap: 8px; padding: 8px 10px; }
|
|
.ai-tool-icon-wrap {
|
|
width: 24px; height: 24px; border-radius: var(--df-radius-sm);
|
|
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
|
}
|
|
.ai-tool-icon-wrap--file-read { background: rgba(94,175,240,0.12); color: var(--df-info); }
|
|
.ai-tool-icon-wrap--file-write { background: var(--df-warning-bg); color: var(--df-warning); }
|
|
.ai-tool-icon-wrap--dir { background: rgba(240,199,94,0.12); color: var(--df-warning); }
|
|
.ai-tool-icon-wrap--create { background: var(--df-success-bg); color: var(--df-success); }
|
|
.ai-tool-icon-wrap--delete { background: var(--df-danger-bg); color: var(--df-danger); }
|
|
.ai-tool-icon-wrap--list { background: var(--df-accent-bg); color: var(--df-accent); }
|
|
.ai-tool-icon-wrap--default { background: var(--df-accent-bg); color: var(--df-accent); }
|
|
.ai-tool-header-text {
|
|
display: flex; flex-direction: row; flex-wrap: wrap; align-items: baseline;
|
|
column-gap: 8px; row-gap: 1px; flex: 1; min-width: 0;
|
|
}
|
|
.ai-tool-name {
|
|
font-size: 12px; font-weight: 500; color: var(--df-text);
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 0;
|
|
}
|
|
.ai-tool-sub {
|
|
font-size: 10px; color: var(--df-text-dim);
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
}
|
|
|
|
/* -- Status dot (脉冲动画) -- */
|
|
.ai-tool-status-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
|
|
.ai-tool-status-dot--running {
|
|
background: var(--df-info); animation: pulseGlow 1.5s ease-in-out infinite; box-shadow: 0 0 6px var(--df-info);
|
|
}
|
|
.ai-tool-status-dot--pending_approval {
|
|
background: var(--df-warning); animation: pulseGlow 1.5s ease-in-out infinite; box-shadow: 0 0 6px rgba(240,199,94,0.3);
|
|
}
|
|
.ai-tool-status-dot--completed { background: var(--df-success); }
|
|
.ai-tool-status-dot--rejected { background: var(--df-danger); }
|
|
|
|
/* -- Skeleton (running 状态) -- */
|
|
.ai-tool-skeleton { padding: 4px 10px 10px 42px; display: flex; flex-direction: column; gap: 5px; }
|
|
.ai-tool-skeleton-line {
|
|
height: 8px; border-radius: var(--df-radius-xs);
|
|
background: linear-gradient(90deg, var(--df-bg) 25%, var(--df-bg-card-hover) 50%, var(--df-bg) 75%);
|
|
background-size: 200% 100%; animation: shimmer 1.5s infinite;
|
|
}
|
|
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
|
|
|
|
/* -- 审批参数展示 -- */
|
|
.ai-tool-approval-args {
|
|
padding: 4px 10px 6px; margin-bottom: 6px;
|
|
border-top: 0.5px solid var(--df-border); border-bottom: 0.5px solid var(--df-border);
|
|
background: var(--df-bg-elevated, rgba(0, 0, 0, 0.03));
|
|
}
|
|
.ai-tool-arg { display: flex; gap: 8px; padding: 2px 0; font-size: 11px; line-height: 1.5; }
|
|
.ai-tool-arg-key { flex-shrink: 0; min-width: 64px; color: var(--df-text-secondary); font-family: var(--df-font-mono); }
|
|
.ai-tool-arg-val { flex: 1; word-break: break-all; white-space: pre-wrap; font-family: var(--df-font-mono); color: var(--df-text-primary); }
|
|
.ai-tool-approval-reason {
|
|
padding: 4px 10px 6px; margin-bottom: 6px; font-size: 11px;
|
|
color: var(--df-warning, #d97706); background: var(--df-warning-bg, rgba(217, 119, 6, 0.08));
|
|
border-radius: var(--df-radius-sm, 4px);
|
|
}
|
|
|
|
/* BUG-260624-02: 审批挂起时长提示(>2分钟橙色,>5分钟红色) */
|
|
.ai-tool-wait-time {
|
|
padding: 3px 10px; margin-bottom: 6px; font-size: 11px;
|
|
font-family: var(--df-font-mono);
|
|
color: var(--df-text-dim, #888);
|
|
border-radius: var(--df-radius-sm, 4px);
|
|
}
|
|
.ai-tool-wait-time.is-long {
|
|
color: var(--df-warning, #d97706);
|
|
animation: pulseGlow 1.5s ease-in-out infinite;
|
|
}
|
|
.ai-tool-wait-time.is-urgent {
|
|
color: var(--df-danger, #e5484d);
|
|
animation: pulseGlow 1s ease-in-out infinite;
|
|
}
|
|
|
|
/* AE-2025-03: write_file 审批 diff 预览区(红删绿增行级) */
|
|
.ai-tool-approval-diff { margin: 0 10px 8px; border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm, 4px); overflow: hidden; }
|
|
.ai-tool-diff-pre {
|
|
margin: 0; padding: 6px 0; max-height: 240px; overflow: auto;
|
|
font-family: var(--df-font-mono); font-size: 11px; line-height: 1.5; background: var(--df-bg-card);
|
|
}
|
|
.ai-tool-diff-pre code { display: block; white-space: pre; }
|
|
.ai-tool-diff-line { display: block; padding: 0 10px; white-space: pre-wrap; word-break: break-all; }
|
|
.ai-tool-diff-line--add { color: var(--df-success, #3ddb9c); background: var(--df-success-bg, rgba(61, 219, 160, 0.08)); }
|
|
.ai-tool-diff-line--del { color: var(--df-danger, #f06565); background: var(--df-danger-bg, rgba(240, 101, 101, 0.08)); }
|
|
.ai-tool-diff-line--ctx { color: var(--df-text-dim, #888); }
|
|
|
|
/* -- 审批按钮 -- */
|
|
.ai-tool-actions { display: flex; gap: 6px; padding: 0 10px 8px; }
|
|
.ai-tool-btn {
|
|
padding: 5px 12px; font-size: 11px; font-weight: 500;
|
|
border-radius: var(--df-radius-sm); border: 0.5px solid var(--df-border); cursor: pointer;
|
|
display: flex; align-items: center; gap: 4px; transition: all 0.15s var(--df-ease); font-family: var(--df-font-sans);
|
|
}
|
|
.ai-tool-btn--approve { background: var(--df-success-bg); color: var(--df-success); }
|
|
.ai-tool-btn--approve:hover { background: var(--df-success); color: #fff; border-color: var(--df-success); }
|
|
/* path_auth 审批链阶段3b:always 按钮(warning 色调,与 DirAuthDialog.vue 同款语义) */
|
|
.ai-tool-btn--always { background: var(--df-warning-bg); color: var(--df-warning); }
|
|
.ai-tool-btn--always:hover { background: var(--df-warning); color: #000; border-color: var(--df-warning); }
|
|
.ai-tool-btn--reject { background: var(--df-danger-bg); color: var(--df-danger); }
|
|
.ai-tool-btn--reject:hover { background: var(--df-danger); color: #fff; border-color: var(--df-danger); }
|
|
/* 审批 loading(B-260616-08):禁用两按钮防重复点击,spinner 转在原位 */
|
|
.ai-tool-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
|
.ai-tool-btn--approve:disabled:hover { background: var(--df-success-bg); color: var(--df-success); border-color: var(--df-border); }
|
|
.ai-tool-btn--reject:disabled:hover { background: var(--df-danger-bg); color: var(--df-danger); border-color: var(--df-border); }
|
|
.ai-tool-btn-spinner {
|
|
width: 12px; height: 12px; border: 1.5px solid currentColor; border-top-color: transparent;
|
|
border-radius: 50%; display: inline-block; flex-shrink: 0; animation: aiToolBtnSpin 0.7s linear infinite;
|
|
}
|
|
@keyframes aiToolBtnSpin { to { transform: rotate(360deg); } }
|
|
|
|
/* -- UX-260617-14: 审批 loading 超时兜底 toast(复用 AiChat 同款视觉 token) -- */
|
|
.ai-tool-toast {
|
|
position: absolute; top: 6px; left: 50%; transform: translateX(-50%);
|
|
padding: 6px 12px; border-radius: var(--df-radius-sm); font-size: 11px; z-index: 100;
|
|
box-shadow: 0 4px 16px rgba(0,0,0,0.3); max-width: 90%; overflow-wrap: anywhere; pointer-events: none;
|
|
}
|
|
.ai-tool-toast--warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); }
|
|
.ai-tool-toast-enter-active, .ai-tool-toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
|
|
.ai-tool-toast-enter-from, .ai-tool-toast-leave-to { opacity: 0; transform: translate(-50%, -6px); }
|
|
</style>
|