Files
EasyFlow/easyflow-ui-admin/app/src/views/ai/skill/SkillToolBindingDialog.vue
陈子默 4e8640dcaf feat: 完善 Agent 标准交互与安全运行时
- 接入 AG-UI 运行投影、Turn 时间线和审批隔离

- 增加 Agent Skill 冻结绑定与运行时消费闭环

- 增加受控工作区、内置工具和私有 Artifact 生命周期
2026-08-19 22:13:41 +08:00

1067 lines
28 KiB
Vue

<script setup lang="ts">
import type {
SkillMcpToolManifest,
SkillToolBinding,
SkillToolOption,
SkillToolType,
} from './types';
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue';
import { EasyFlowPanelModal } from '@easyflow/common-ui';
import {
ArrowDown,
ArrowRight,
Connection,
Link,
Refresh,
Search,
Share,
} from '@element-plus/icons-vue';
import {
ElButton,
ElCheckbox,
ElEmpty,
ElInput,
ElMessage,
ElSwitch,
ElTag,
} from 'element-plus';
import {
getSkillMcpTools,
getSkillToolOptions,
updateSkillToolBindings,
} from './api';
import { resolveSkillApiErrorMessage } from './skill-api-error';
interface McpState {
data?: SkillMcpToolManifest;
error?: string;
expanded?: boolean;
loading?: boolean;
}
const props = withDefaults(
defineProps<{
bindings?: SkillToolBinding[];
hasToolUpdate?: boolean;
modelValue: boolean;
skillId: number | string;
}>(),
{ bindings: () => [], hasToolUpdate: false },
);
const emit = defineEmits<{
saved: [bindings: SkillToolBinding[]];
'update:modelValue': [value: boolean];
}>();
const typeOptions: Array<{
icon: typeof Share;
label: string;
value: SkillToolType;
}> = [
{ icon: Share, label: '工作流', value: 'WORKFLOW' },
{ icon: Connection, label: '插件工具', value: 'PLUGIN' },
{ icon: Link, label: 'MCP', value: 'MCP' },
];
const activeType = ref<SkillToolType>('WORKFLOW');
const keyword = ref('');
const loading = ref(false);
const saving = ref(false);
const loadError = ref('');
const actionError = ref('');
const options = ref<SkillToolOption[]>([]);
const selected = ref<SkillToolBinding[]>([]);
const mcpStates = reactive<Record<string, McpState>>({});
const confirmedMcpManifests = reactive<Record<string, string>>({});
let optionRequest = 0;
let searchTimer: ReturnType<typeof setTimeout> | undefined;
const dialogVisible = computed({
get: () => props.modelValue,
set: (value) => {
if (!value && saving.value) return;
emit('update:modelValue', value);
},
});
const selectedCount = computed(() => selected.value.length);
const totalToolCount = computed(() =>
selected.value.reduce(
(total, binding) => total + bindingToolCount(binding),
0,
),
);
const hitlCount = computed(
() => selected.value.filter((binding) => effectiveHitl(binding)).length,
);
const invalidBindings = computed(() =>
selected.value.filter(
(binding) => binding.resourceSummary?.available === false,
),
);
const unresolvedMcpBindings = computed(() =>
selected.value.filter((binding) => {
if (binding.toolType !== 'MCP') return false;
const state = mcpStates[String(binding.targetId)];
if (state?.loading || state?.error || !state?.data) return true;
return !(
state.data.manifestHash === binding.mcpToolManifestHash ||
confirmedMcpManifests[String(binding.targetId)] ===
state.data.manifestHash
);
}),
);
const saveDisabled = computed(
() =>
saving.value ||
totalToolCount.value > 20 ||
invalidBindings.value.length > 0 ||
unresolvedMcpBindings.value.length > 0,
);
const currentRows = computed(() => {
const rows = [...options.value];
for (const binding of selected.value) {
if (binding.toolType !== activeType.value) continue;
if (rows.some((item) => sameTarget(item, binding))) continue;
rows.push(optionFromBinding(binding));
}
return rows;
});
function bindingKey(binding: Pick<SkillToolBinding, 'targetId' | 'toolType'>) {
return `${binding.toolType}:${binding.targetId}`;
}
function sameTarget(
left: Pick<SkillToolBinding, 'targetId' | 'toolType'>,
right: Pick<SkillToolBinding, 'targetId' | 'toolType'>,
) {
return bindingKey(left) === bindingKey(right);
}
function cloneBinding(binding: SkillToolBinding): SkillToolBinding {
return {
hitlEnabled: Boolean(binding.hitlEnabled),
id: binding.id,
mcpToolCount: binding.mcpToolCount,
mcpToolManifestHash: binding.mcpToolManifestHash,
resourceSummary: binding.resourceSummary
? { ...binding.resourceSummary }
: undefined,
sortNo: binding.sortNo,
targetId: binding.targetId,
toolType: binding.toolType,
};
}
function optionFromBinding(binding: SkillToolBinding): SkillToolOption {
return {
approvalRequired: binding.resourceSummary?.approvalRequired,
available: binding.resourceSummary?.available !== false,
description: binding.resourceSummary?.description,
knownToolCount:
binding.mcpToolCount ?? binding.resourceSummary?.toolCount ?? 1,
targetId: binding.targetId,
title: binding.resourceSummary?.title || '当前资源不可用',
toolType: binding.toolType,
};
}
function selectedBinding(item: SkillToolOption) {
return selected.value.find((binding) => sameTarget(binding, item));
}
function bindingTitle(binding: SkillToolBinding) {
const option = options.value.find((item) => sameTarget(item, binding));
return (
option?.title ||
binding.resourceSummary?.title ||
typeLabel(binding.toolType)
);
}
function typeLabel(type: SkillToolType) {
return typeOptions.find((item) => item.value === type)?.label || type;
}
function effectiveHitl(binding: SkillToolBinding) {
return Boolean(
binding.hitlEnabled || binding.resourceSummary?.approvalRequired,
);
}
function bindingToolCount(binding: SkillToolBinding) {
if (binding.toolType !== 'MCP') return 1;
return Math.max(
0,
mcpStates[String(binding.targetId)]?.data?.toolCount ??
binding.mcpToolCount ??
binding.resourceSummary?.toolCount ??
0,
);
}
function mcpState(targetId: number | string) {
const key = String(targetId);
return (mcpStates[key] ||= {});
}
function mcpManifestChanged(binding: SkillToolBinding) {
const data = mcpState(binding.targetId).data;
return Boolean(
data?.manifestHash && data.manifestHash !== binding.mcpToolManifestHash,
);
}
function resetDialog() {
activeType.value = 'WORKFLOW';
keyword.value = '';
actionError.value = '';
selected.value = props.bindings.map((binding) => cloneBinding(binding));
for (const key of Object.keys(mcpStates)) delete mcpStates[key];
for (const key of Object.keys(confirmedMcpManifests))
delete confirmedMcpManifests[key];
void loadOptions();
for (const binding of selected.value) {
if (binding.toolType === 'MCP') void loadMcp(binding.targetId);
}
}
async function loadOptions() {
const request = ++optionRequest;
loading.value = true;
loadError.value = '';
try {
const response = await getSkillToolOptions({
keyword: keyword.value.trim() || undefined,
pageNum: 1,
pageSize: 100,
toolType: activeType.value,
});
if (request !== optionRequest) return;
if (response.errorCode !== 0) throw response;
options.value = response.data?.records || [];
} catch (error) {
if (request !== optionRequest) return;
options.value = [];
loadError.value = resolveSkillApiErrorMessage(
error,
'候选加载失败,请重试',
);
} finally {
if (request === optionRequest) loading.value = false;
}
}
async function loadMcp(targetId: number | string, force = false) {
const state = mcpState(targetId);
if (state.loading || (!force && state.data)) return state.data;
state.loading = true;
state.error = '';
try {
const response = await getSkillMcpTools(targetId);
if (response.errorCode !== 0) throw response;
state.data = response.data;
return state.data;
} catch (error) {
state.error = resolveSkillApiErrorMessage(
error,
'工具清单加载失败,请重试',
);
} finally {
state.loading = false;
}
}
async function toggleCandidate(item: SkillToolOption) {
if (!item.available || saving.value) return;
const existing = selectedBinding(item);
if (existing) {
selected.value = selected.value.filter(
(binding) => !sameTarget(binding, item),
);
return;
}
let manifest: SkillMcpToolManifest | undefined;
if (item.toolType === 'MCP') {
manifest = await loadMcp(item.targetId);
if (!manifest) return;
confirmedMcpManifests[String(item.targetId)] = manifest.manifestHash;
}
selected.value.push({
hitlEnabled: Boolean(item.approvalRequired),
mcpToolCount: manifest?.toolCount,
mcpToolManifestHash: manifest?.manifestHash,
resourceSummary: {
approvalRequired: item.approvalRequired,
available: item.available,
description: item.description,
title: item.title,
toolCount: manifest?.toolCount ?? item.knownToolCount ?? 1,
},
sortNo: selected.value.length,
targetId: item.targetId,
toolType: item.toolType,
});
}
function removeBinding(binding: SkillToolBinding) {
selected.value = selected.value.filter((item) => !sameTarget(item, binding));
}
async function toggleMcpExpanded(targetId: number | string) {
const state = mcpState(targetId);
state.expanded = !state.expanded;
if (state.expanded) await loadMcp(targetId);
}
function acceptLatestManifest(binding: SkillToolBinding) {
const data = mcpState(binding.targetId).data;
if (!data) return;
confirmedMcpManifests[String(binding.targetId)] = data.manifestHash;
selected.value = selected.value.map((item) =>
sameTarget(item, binding)
? {
...item,
mcpToolCount: data.toolCount,
mcpToolManifestHash: data.manifestHash,
resourceSummary: item.resourceSummary
? { ...item.resourceSummary, toolCount: data.toolCount }
: item.resourceSummary,
}
: item,
);
}
async function save() {
if (saveDisabled.value) return;
saving.value = true;
actionError.value = '';
try {
const response = await updateSkillToolBindings(
props.skillId,
selected.value,
);
if (response.errorCode !== 0) throw response;
ElMessage.success('工具绑定已保存');
emit('saved', response.data || []);
emit('update:modelValue', false);
} catch (error) {
actionError.value = resolveSkillApiErrorMessage(error, '保存失败,请重试');
} finally {
saving.value = false;
}
}
watch(
() => props.modelValue,
(visible) => {
if (visible) resetDialog();
},
{ immediate: true },
);
watch(activeType, () => {
if (props.modelValue) void loadOptions();
});
watch(keyword, () => {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
if (props.modelValue) void loadOptions();
}, 250);
});
onBeforeUnmount(() => {
if (searchTimer) clearTimeout(searchTimer);
});
</script>
<template>
<EasyFlowPanelModal
v-model:open="dialogVisible"
centered
:closable="!saving"
:close-on-click-modal="false"
:show-footer="false"
title="工具绑定"
width="xl"
>
<div class="skill-tools" :aria-busy="loading || saving">
<div class="skill-tools__summary">
<div class="skill-tools__metrics" aria-live="polite">
<strong>{{ selectedCount }}</strong> 项资源
<span aria-hidden="true">·</span>
<strong :class="{ 'is-overflow': totalToolCount > 20 }">
{{ totalToolCount }}
</strong>
/ 20 个工具
<span aria-hidden="true">·</span>
{{ hitlCount }} 项调用前确认
</div>
<ElTag v-if="hasToolUpdate" type="warning" effect="plain" size="small">
有未发布变更
</ElTag>
</div>
<div v-if="actionError" class="skill-tools__error" role="alert">
<span>{{ actionError }}</span>
<ElButton link type="primary" @click="actionError = ''">关闭</ElButton>
</div>
<div class="skill-tools__body">
<section class="skill-tools__catalog" aria-label="可绑定工具">
<div class="skill-tools__toolbar">
<div
class="skill-tools__types"
role="tablist"
aria-label="工具类型"
>
<button
v-for="type in typeOptions"
:key="type.value"
class="skill-tools__type"
:class="{ 'is-active': activeType === type.value }"
role="tab"
type="button"
:aria-selected="activeType === type.value"
@click="activeType = type.value"
>
<component :is="type.icon" />
{{ type.label }}
</button>
</div>
<ElInput
v-model="keyword"
clearable
:prefix-icon="Search"
placeholder="搜索名称或描述"
aria-label="搜索工具"
/>
</div>
<div v-loading="loading" class="skill-tools__list">
<div v-if="loadError" class="skill-tools__state" role="alert">
<span>{{ loadError }}</span>
<ElButton :icon="Refresh" @click="loadOptions">重新加载</ElButton>
</div>
<ElEmpty
v-else-if="!loading && currentRows.length === 0"
:description="keyword ? '没有匹配的工具' : '暂无可绑定工具'"
:image-size="72"
/>
<article
v-for="item in currentRows"
v-else
:key="bindingKey(item)"
class="skill-tools__candidate"
:class="{
'is-disabled': !item.available,
'is-selected': Boolean(selectedBinding(item)),
}"
>
<div class="skill-tools__candidate-main">
<ElCheckbox
:aria-label="`${selectedBinding(item) ? '取消绑定' : '绑定'}${item.title}`"
:disabled="!item.available || mcpState(item.targetId).loading"
:model-value="Boolean(selectedBinding(item))"
@change="toggleCandidate(item)"
/>
<button
class="skill-tools__candidate-content"
type="button"
:disabled="!item.available"
@click="toggleCandidate(item)"
>
<span class="skill-tools__candidate-title">{{
item.title
}}</span>
<span class="skill-tools__candidate-description">
{{ item.description || '暂无描述' }}
</span>
</button>
<div class="skill-tools__candidate-meta">
<ElTag
v-if="!item.available"
type="danger"
size="small"
effect="plain"
>
已失效
</ElTag>
<ElTag
v-if="item.approvalRequired"
type="warning"
size="small"
effect="plain"
>
需确认
</ElTag>
<span v-if="item.toolType !== 'MCP'">1 个工具</span>
<span v-else-if="mcpState(item.targetId).data">
{{ mcpState(item.targetId).data?.toolCount }} 个工具
</span>
<ElButton
v-if="item.toolType === 'MCP'"
link
type="primary"
:loading="mcpState(item.targetId).loading"
:aria-expanded="Boolean(mcpState(item.targetId).expanded)"
@click="toggleMcpExpanded(item.targetId)"
>
<component
:is="
mcpState(item.targetId).expanded
? ArrowDown
: ArrowRight
"
/>
查看工具
</ElButton>
</div>
</div>
<div
v-if="
item.toolType === 'MCP' && mcpState(item.targetId).expanded
"
class="skill-tools__mcp-tools"
>
<div
v-if="mcpState(item.targetId).error"
class="skill-tools__mcp-error"
>
<span>{{ mcpState(item.targetId).error }}</span>
<ElButton
link
type="primary"
@click="loadMcp(item.targetId, true)"
>
重试
</ElButton>
</div>
<div
v-for="tool in mcpState(item.targetId).data?.tools || []"
:key="tool.name"
class="skill-tools__mcp-tool"
>
<span>{{ tool.name }}</span>
<small>{{ tool.description || '暂无描述' }}</small>
</div>
</div>
</article>
</div>
</section>
<aside class="skill-tools__selected" aria-label="已绑定工具">
<div class="skill-tools__selected-heading">
<div>
<strong>已绑定</strong>
<span>{{ selectedCount }} 项</span>
</div>
<span>调用前确认可逐项设置</span>
</div>
<ElEmpty
v-if="selected.length === 0"
description="从左侧选择工具"
:image-size="64"
/>
<div v-else class="skill-tools__selected-list">
<article
v-for="binding in selected"
:key="bindingKey(binding)"
class="skill-tools__selected-item"
>
<div class="skill-tools__selected-title">
<div>
<strong>{{ bindingTitle(binding) }}</strong>
<span>
{{ typeLabel(binding.toolType) }} ·
{{ bindingToolCount(binding) }} 个工具
</span>
</div>
<ElButton link type="danger" @click="removeBinding(binding)">
移除
</ElButton>
</div>
<div class="skill-tools__selected-state">
<ElTag
v-if="binding.resourceSummary?.available === false"
type="danger"
size="small"
effect="plain"
>
已失效
</ElTag>
<ElTag
v-if="
binding.toolType === 'MCP' && mcpManifestChanged(binding)
"
type="warning"
size="small"
effect="plain"
>
清单变化
</ElTag>
<ElButton
v-if="
binding.toolType === 'MCP' && mcpManifestChanged(binding)
"
link
type="primary"
@click="acceptLatestManifest(binding)"
>
确认最新清单
</ElButton>
<span v-if="mcpState(binding.targetId).error" class="is-error">
清单加载失败
</span>
</div>
<label class="skill-tools__hitl">
<span>
调用前确认
<small v-if="binding.resourceSummary?.approvalRequired">
MCP 要求
</small>
</span>
<ElSwitch
v-model="binding.hitlEnabled"
:disabled="Boolean(binding.resourceSummary?.approvalRequired)"
:aria-label="`${bindingTitle(binding)}调用前确认`"
/>
</label>
</article>
</div>
</aside>
</div>
<footer class="skill-tools__footer">
<div class="skill-tools__footer-message" aria-live="polite">
<span v-if="totalToolCount > 20" class="is-error">
每个 Skill 最多绑定 20 个工具
</span>
<span v-else-if="invalidBindings.length > 0" class="is-error">
请移除已失效的绑定
</span>
<span v-else-if="unresolvedMcpBindings.length > 0">
请等待 MCP 清单加载,并确认发生变化的清单
</span>
<span v-else>保存后随下一次发布生效</span>
</div>
<div class="skill-tools__footer-actions">
<ElButton :disabled="saving" @click="dialogVisible = false">
取消
</ElButton>
<ElButton
type="primary"
:disabled="saveDisabled"
:loading="saving"
@click="save"
>
保存绑定
</ElButton>
</div>
</footer>
</div>
</EasyFlowPanelModal>
</template>
<style scoped>
.skill-tools {
display: flex;
flex-direction: column;
max-height: min(720px, calc(100vh - 96px));
min-height: 560px;
color: hsl(var(--text-strong));
}
.skill-tools__summary,
.skill-tools__toolbar,
.skill-tools__selected-heading,
.skill-tools__footer,
.skill-tools__candidate-main,
.skill-tools__selected-title,
.skill-tools__selected-state,
.skill-tools__hitl {
display: flex;
align-items: center;
}
.skill-tools__summary {
justify-content: space-between;
padding: var(--space-3) var(--space-5);
color: hsl(var(--text-muted));
background: hsl(var(--surface-subtle));
border-bottom: 1px solid hsl(var(--line-subtle));
}
.skill-tools__metrics {
display: flex;
gap: var(--space-2);
align-items: center;
font-size: 13px;
}
.skill-tools__metrics strong {
color: hsl(var(--text-strong));
}
.skill-tools__body {
display: grid;
flex: 1;
min-height: 0;
grid-template-columns: minmax(0, 1fr) 320px;
}
.skill-tools__catalog,
.skill-tools__selected {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.skill-tools__selected {
background: hsl(var(--surface-subtle));
border-left: 1px solid hsl(var(--line-subtle));
}
.skill-tools__toolbar {
gap: var(--space-3);
padding: var(--space-4);
border-bottom: 1px solid hsl(var(--line-subtle));
}
.skill-tools__toolbar :deep(.el-input) {
width: min(260px, 42%);
margin-left: auto;
}
.skill-tools__types {
display: inline-flex;
gap: var(--space-1);
padding: var(--space-1);
background: hsl(var(--surface-contrast-soft));
border-radius: var(--radius-toolbar);
}
.skill-tools__type {
display: inline-flex;
gap: var(--space-2);
align-items: center;
height: 32px;
padding: 0 var(--space-3);
font-weight: 600;
color: hsl(var(--text-muted));
cursor: pointer;
background: transparent;
border: 0;
border-radius: var(--radius-control);
transition:
color var(--motion-duration-base) var(--motion-ease-standard),
background var(--motion-duration-base) var(--motion-ease-standard);
}
.skill-tools__type svg {
width: 15px;
height: 15px;
}
.skill-tools__type:hover {
color: hsl(var(--text-strong));
background: hsl(var(--table-row-hover));
}
.skill-tools__type.is-active {
color: hsl(var(--nav-item-active-foreground));
background: hsl(var(--nav-item-active));
}
.skill-tools__type:focus-visible,
.skill-tools__candidate-content:focus-visible {
outline: 2px solid hsl(var(--primary));
outline-offset: 2px;
}
.skill-tools__list,
.skill-tools__selected-list {
flex: 1;
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
}
.skill-tools__list {
padding: var(--space-2) var(--space-4) var(--space-4);
}
.skill-tools__candidate {
border-bottom: 1px solid hsl(var(--line-subtle));
transition: background var(--motion-duration-fast) var(--motion-ease-standard);
}
.skill-tools__candidate:not(.is-disabled):hover {
background: hsl(var(--table-row-hover));
}
.skill-tools__candidate.is-selected {
background: hsl(var(--nav-item-active) / 0.55);
}
.skill-tools__candidate.is-selected:hover {
background: hsl(var(--nav-item-active) / 0.78);
}
.skill-tools__candidate.is-disabled {
background: hsl(var(--surface-subtle));
}
.skill-tools__candidate-main {
gap: var(--space-3);
min-height: 72px;
padding: var(--space-3);
}
.skill-tools__candidate-content {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--space-1);
min-width: 0;
padding: 0;
text-align: left;
cursor: pointer;
background: transparent;
border: 0;
}
.skill-tools__candidate-content:disabled {
cursor: not-allowed;
}
.skill-tools__candidate-title {
overflow: hidden;
font-weight: 650;
color: hsl(var(--text-strong));
text-overflow: ellipsis;
white-space: nowrap;
}
.skill-tools__candidate-description {
overflow: hidden;
font-size: 13px;
color: hsl(var(--text-muted));
text-overflow: ellipsis;
white-space: nowrap;
}
.skill-tools__candidate-meta {
display: flex;
flex-shrink: 0;
gap: var(--space-2);
align-items: center;
font-size: 12px;
color: hsl(var(--text-muted));
}
.skill-tools__candidate-meta :deep(.el-button) svg {
width: 14px;
height: 14px;
}
.skill-tools__mcp-tools {
display: grid;
gap: var(--space-2);
padding: 0 var(--space-3) var(--space-3) 48px;
}
.skill-tools__mcp-tool {
display: grid;
grid-template-columns: minmax(128px, 0.45fr) minmax(0, 1fr);
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
background: hsl(var(--surface-subtle));
border-radius: var(--radius-control);
}
.skill-tools__mcp-tool span {
overflow: hidden;
font-family: ui-monospace, sfmono-regular, menlo, monospace;
font-size: 12px;
font-weight: 600;
color: hsl(var(--text-strong));
text-overflow: ellipsis;
white-space: nowrap;
}
.skill-tools__mcp-tool small {
color: hsl(var(--text-muted));
}
.skill-tools__mcp-error,
.skill-tools__error,
.skill-tools__state {
display: flex;
gap: var(--space-3);
align-items: center;
justify-content: space-between;
}
.skill-tools__error {
padding: var(--space-2) var(--space-5);
font-size: 13px;
color: hsl(var(--destructive));
background: hsl(var(--destructive) / 0.08);
border-bottom: 1px solid hsl(var(--destructive) / 0.16);
}
.skill-tools__state {
flex-direction: column;
justify-content: center;
min-height: 240px;
color: hsl(var(--text-muted));
}
.skill-tools__selected-heading {
justify-content: space-between;
min-height: 65px;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid hsl(var(--line-subtle));
}
.skill-tools__selected-heading > div {
display: flex;
gap: var(--space-2);
align-items: baseline;
}
.skill-tools__selected-heading > span,
.skill-tools__selected-heading div span {
font-size: 12px;
color: hsl(var(--text-muted));
}
.skill-tools__selected-list {
padding: var(--space-2);
}
.skill-tools__selected-item {
padding: var(--space-3);
background: hsl(var(--surface-panel));
border-bottom: 1px solid hsl(var(--line-subtle));
}
.skill-tools__selected-item:first-child {
border-radius: var(--radius-control) var(--radius-control) 0 0;
}
.skill-tools__selected-item:last-child {
border-bottom: 0;
border-radius: 0 0 var(--radius-control) var(--radius-control);
}
.skill-tools__selected-title {
gap: var(--space-2);
justify-content: space-between;
}
.skill-tools__selected-title > div {
display: flex;
flex-direction: column;
min-width: 0;
}
.skill-tools__selected-title strong {
overflow: hidden;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.skill-tools__selected-title span,
.skill-tools__hitl small {
font-size: 11px;
color: hsl(var(--text-muted));
}
.skill-tools__selected-state {
flex-wrap: wrap;
gap: var(--space-2);
min-height: var(--space-3);
margin-top: var(--space-2);
font-size: 12px;
}
.skill-tools__hitl {
justify-content: space-between;
margin-top: var(--space-3);
font-size: 12px;
}
.skill-tools__hitl > span {
display: flex;
flex-direction: column;
}
.skill-tools__footer {
justify-content: space-between;
min-height: 64px;
padding: var(--space-3) var(--space-5);
background: hsl(var(--modal-footer-surface));
border-top: 1px solid hsl(var(--modal-divider));
}
.skill-tools__footer-message {
font-size: 12px;
color: hsl(var(--text-muted));
}
.skill-tools__footer-actions {
display: flex;
gap: var(--space-3);
}
.is-error,
.is-overflow {
color: hsl(var(--destructive)) !important;
}
@media (max-width: 820px) {
.skill-tools {
min-height: min(680px, calc(100vh - 48px));
}
.skill-tools__body {
grid-template-columns: minmax(0, 1fr);
overflow: auto;
}
.skill-tools__catalog,
.skill-tools__selected {
min-height: 360px;
}
.skill-tools__selected {
border-top: 1px solid hsl(var(--line-subtle));
border-left: 0;
}
.skill-tools__toolbar {
align-items: stretch;
flex-direction: column;
}
.skill-tools__toolbar :deep(.el-input) {
width: 100%;
}
.skill-tools__types {
justify-content: space-between;
}
.skill-tools__type {
flex: 1;
justify-content: center;
}
}
</style>