feat(chat): add in-chat /skill slash menu with searchable picker

This commit is contained in:
matevip 2026-05-29 08:10:22 +08:00
parent 82538ca3aa
commit f3e6e1152a
5 changed files with 474 additions and 3 deletions

View File

@ -133,6 +133,13 @@
</div>
<div class="input-area">
<SkillSlashMenu
v-if="slashActive"
ref="slashMenuRef"
:query="slashQuery"
@select="handleSkillSelect"
@close="handleSlashClose"
/>
<textarea
ref="textareaRef"
v-model="inputValue"
@ -141,11 +148,12 @@
:disabled="disabled"
:maxlength="maxLength"
rows="1"
@keydown="handleSlashKeydown"
@keydown.enter.exact.prevent="handleEnter"
@compositionstart="isComposing = true"
@compositionend="isComposing = false"
@focus="isFocused = true"
@blur="isFocused = false"
@focus="onTextareaFocus"
@blur="onTextareaBlur"
@input="autoResize"
@paste="handlePaste"
></textarea>
@ -232,7 +240,8 @@ import { ref, computed, nextTick, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ArrowDown, CloseBold, MagicStick, Microphone, Paperclip, Promotion, Select, Timer, WarningFilled } from '@element-plus/icons-vue'
import { useToolLabel } from '@/composables/useToolLabel'
import type { ChatAttachment, PendingApprovalMeta, StreamPhase, QueuedMessage } from '@/types'
import SkillSlashMenu from '@/components/chat/SkillSlashMenu.vue'
import type { ChatAttachment, PendingApprovalMeta, StreamPhase, QueuedMessage, Skill } from '@/types'
interface Props {
/** 输入值 */
@ -272,6 +281,12 @@ interface Props {
* 不响应点击tooltip 提示当前模型不支持深度思考默认 true 以保持向后兼容
*/
thinkingSupported?: boolean
/**
* Whether the current agent can use skills. When false the skill slash menu
* is suppressed a skills-disabled agent has no `load_skill` tool, so naming
* a skill would be a dead end.
*/
skillsEnabled?: boolean
}
const props = withDefaults(defineProps<Props>(), {
@ -291,6 +306,7 @@ const props = withDefaults(defineProps<Props>(), {
enableTalkMode: false,
thinkingEnabled: false,
thinkingSupported: true,
skillsEnabled: true,
})
const emit = defineEmits<{
@ -324,6 +340,102 @@ const fileInputRef = ref<HTMLInputElement | null>(null)
const isFocused = ref(false)
const isComposing = ref(false)
// ---- Skill slash-command menu ----
// The menu opens when the whole input is a single "/<query>" token (no spaces
// yet). Picking a skill rewrites the input to a directive that names the skill,
// which the agent recognises and loads via `load_skill`.
const slashMenuRef = ref<InstanceType<typeof SkillSlashMenu> | null>(null)
const slashDismissed = ref(false)
const slashMatch = computed(() => {
const m = /^\/([^\s/]*)$/.exec(props.modelValue)
return m ? m[1] : null
})
const slashQuery = computed(() => slashMatch.value ?? '')
const slashActive = computed(
() =>
slashMatch.value !== null &&
!slashDismissed.value &&
!props.disabled &&
!props.pendingApproval &&
props.skillsEnabled,
)
// Leaving slash mode (cleared the "/" token) re-arms the menu for next time.
watch(slashMatch, (val) => {
if (val === null) slashDismissed.value = false
})
function handleSlashKeydown(e: KeyboardEvent) {
if (!slashActive.value) return
const menu = slashMenuRef.value
if (!menu) return
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
menu.next()
break
case 'ArrowUp':
e.preventDefault()
menu.prev()
break
case 'Enter':
if (!isComposing.value && menu.count() > 0) {
e.preventDefault()
menu.confirm()
}
break
case 'Tab':
if (menu.count() > 0) {
e.preventDefault()
menu.confirm()
}
break
case 'Escape':
e.preventDefault()
slashDismissed.value = true
break
}
}
function handleSkillSelect(skill: Skill) {
inputValue.value = t('chat.useSkillDirective', { name: skill.name })
slashDismissed.value = false
nextTick(() => {
const el = textareaRef.value
if (el) {
el.focus()
const end = el.value.length
el.setSelectionRange(end, end)
}
autoResize()
})
}
// On open, the menu autofocuses its search box, which blurs the textarea. That
// is expected focus movement keep the menu open. Only dismiss when focus
// actually leaves the input+menu (clicked elsewhere). The relatedTarget check
// covers direct focus moves; the deferred activeElement check covers browsers
// that report a null relatedTarget for programmatic focus.
function onTextareaBlur(e: FocusEvent) {
isFocused.value = false
const next = e.relatedTarget as HTMLElement | null
if (next && next.closest && next.closest('.skill-slash-menu')) return
setTimeout(() => {
const ae = document.activeElement as HTMLElement | null
if (ae && ae.closest && ae.closest('.skill-slash-menu')) return
slashDismissed.value = true
}, 0)
}
function onTextareaFocus() {
isFocused.value = true
}
// The menu asked to close (Escape, or focus left the menu). Suppress it until
// the "/" token is cleared and retyped.
function handleSlashClose() {
slashDismissed.value = true
}
// Always-approve dropdown collapsed by default; opens on the chevron click,
// closes on outside click or after the user picks a scope.
const alwaysApproveOpen = ref(false)
@ -394,6 +506,9 @@ const sendBtnClass = computed(() => ({
//
const handleEnter = () => {
if (isComposing.value) return
// When the slash menu is showing matches, Enter confirms the highlighted
// skill (handled in handleSlashKeydown) instead of submitting the message.
if (slashActive.value && (slashMenuRef.value?.count() ?? 0) > 0) return
handleSubmit()
}
@ -570,6 +685,7 @@ defineExpose({
/* 输入区域 */
.input-area {
position: relative;
display: flex;
gap: 10px;
align-items: flex-end;

View File

@ -0,0 +1,342 @@
<template>
<div class="skill-slash-menu" role="listbox" :aria-label="t('chat.slashMenuTitle')">
<div class="slash-menu__header">
<span class="slash-menu__title">{{ t('chat.slashMenuTitle') }}</span>
<span class="slash-menu__hint">{{ t('chat.slashMenuHint') }}</span>
</div>
<div class="slash-menu__search">
<el-icon class="slash-menu__search-icon"><Search /></el-icon>
<input
ref="searchRef"
v-model="keyword"
class="slash-menu__search-input"
type="text"
autocomplete="off"
spellcheck="false"
:placeholder="t('chat.slashMenuSearchPlaceholder')"
@keydown="onSearchKeydown"
@blur="onSearchBlur"
/>
</div>
<div v-if="loading" class="slash-menu__state">{{ t('chat.slashMenuLoading') }}</div>
<div v-else-if="filtered.length === 0" class="slash-menu__state">{{ t('chat.slashMenuEmpty') }}</div>
<ul v-else class="slash-menu__list">
<li
v-for="(skill, idx) in filtered"
:key="String(skill.id)"
class="slash-menu__item"
:class="{ active: idx === activeIndex }"
role="option"
:aria-selected="idx === activeIndex"
@mouseenter="activeIndex = idx"
@mousedown.prevent="choose(skill)"
>
<span class="slash-menu__icon"><SkillIcon :value="skill.icon" :size="18" :fallback="'🧩'" /></span>
<span class="slash-menu__body">
<span class="slash-menu__name">
{{ displayName(skill) }}
<code class="slash-menu__slug">{{ skill.name }}</code>
</span>
<span v-if="skill.description" class="slash-menu__desc">{{ skill.description }}</span>
</span>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { Search } from '@element-plus/icons-vue'
import { skillApi } from '@/api/index'
import SkillIcon from '@/components/common/SkillIcon.vue'
import type { Skill } from '@/types/index'
// Short-lived, workspace-keyed cache. The menu remounts every time the user
// re-types "/", so without this each keystroke that re-opens it would re-hit
// the endpoint. Keyed by workspace because the listing is workspace-scoped.
const CACHE_TTL_MS = 30_000
let enabledCache: { key: string; ts: number; data: Skill[] } | null = null
async function loadEnabledSkills(): Promise<Skill[]> {
const key = localStorage.getItem('mc-workspace-id') || 'default'
const now = Date.now()
if (enabledCache && enabledCache.key === key && now - enabledCache.ts < CACHE_TTL_MS) {
return enabledCache.data
}
const res: any = await skillApi.listEnabled()
const data = (res?.data ?? []) as Skill[]
enabledCache = { key, ts: now, data }
return data
}
const props = defineProps<{
/** Initial query, seeded from the text typed after the leading "/". */
query: string
}>()
const emit = defineEmits<{
/** A skill was picked (click or Enter). */
select: [skill: Skill]
/** The menu requested to close (Escape, or focus left the menu). */
close: []
}>()
const { t, locale } = useI18n()
const MAX_RESULTS = 8
const allSkills = ref<Skill[]>([])
const loading = ref(true)
const activeIndex = ref(0)
// The in-menu search box owns the filter. Seeded once from the slash query so
// "/da" carries the "da" into the box; afterwards it is edited independently
// (it tolerates spaces, which the slash trigger does not).
const searchRef = ref<HTMLInputElement | null>(null)
const keyword = ref(props.query)
const q = computed(() => keyword.value.trim().toLowerCase())
const filtered = computed<Skill[]>(() => {
const query = q.value
const list = allSkills.value
const matched = query
? list.filter((s) =>
[s.name, s.nameZh, s.nameEn, s.description].some(
(f) => f && f.toLowerCase().includes(query),
),
)
: list
return matched.slice(0, MAX_RESULTS)
})
// Keep the highlighted row in range as the query narrows the result set.
watch(filtered, () => {
if (activeIndex.value >= filtered.value.length) activeIndex.value = 0
})
function displayName(s: Skill): string {
const zh = locale.value.startsWith('zh')
return (zh ? s.nameZh : s.nameEn) || s.name
}
function choose(skill: Skill) {
emit('select', skill)
}
// ---- Keyboard API consumed by the parent textarea handler ----
function next() {
if (filtered.value.length) activeIndex.value = (activeIndex.value + 1) % filtered.value.length
}
function prev() {
if (filtered.value.length)
activeIndex.value = (activeIndex.value - 1 + filtered.value.length) % filtered.value.length
}
function confirm() {
const skill = filtered.value[activeIndex.value]
if (skill) emit('select', skill)
}
function count() {
return filtered.value.length
}
defineExpose({ next, prev, confirm, count })
// ---- Search box: owns focus and keyboard while the menu is open ----
function onSearchKeydown(e: KeyboardEvent) {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
next()
break
case 'ArrowUp':
e.preventDefault()
prev()
break
case 'Enter':
if (filtered.value.length) {
e.preventDefault()
confirm()
}
break
case 'Tab':
if (filtered.value.length) {
e.preventDefault()
confirm()
}
break
case 'Escape':
e.preventDefault()
emit('close')
break
}
}
// Close when focus leaves the menu entirely. Item clicks use
// `@mousedown.prevent`, so picking a skill never blurs the search box.
function onSearchBlur(e: FocusEvent) {
const next = e.relatedTarget as HTMLElement | null
if (next && next.closest && next.closest('.skill-slash-menu')) return
emit('close')
}
onMounted(async () => {
try {
allSkills.value = await loadEnabledSkills()
} catch {
allSkills.value = []
} finally {
loading.value = false
}
// Move focus into the search box so typing filters immediately. The parent
// textarea's blur handler detects the focus landing inside the menu and
// keeps the menu open.
await nextTick()
const el = searchRef.value
if (el) {
el.focus()
const end = el.value.length
el.setSelectionRange(end, end)
}
})
</script>
<style scoped>
.skill-slash-menu {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
right: 0;
z-index: 30;
background: var(--mc-input-bg, #ffffff);
border-radius: 14px;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.14), 0 0 0 1px rgba(0, 0, 0, 0.06);
overflow: hidden;
max-height: 320px;
display: flex;
flex-direction: column;
}
.slash-menu__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
font-size: 12px;
color: var(--el-text-color-secondary, #909399);
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
.slash-menu__title {
font-weight: 600;
}
.slash-menu__hint {
font-size: 11px;
opacity: 0.8;
}
.slash-menu__search {
display: flex;
align-items: center;
gap: 8px;
margin: 8px;
padding: 6px 10px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.04);
border: 1px solid transparent;
}
.slash-menu__search:focus-within {
border-color: rgba(217, 119, 87, 0.5);
background: var(--mc-input-bg, #ffffff);
}
.slash-menu__search-icon {
flex-shrink: 0;
font-size: 14px;
color: var(--el-text-color-secondary, #909399);
}
.slash-menu__search-input {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
font-size: 13px;
color: var(--el-text-color-primary, #303133);
}
.slash-menu__search-input::placeholder {
color: var(--el-text-color-secondary, #909399);
}
.slash-menu__state {
padding: 16px 12px;
font-size: 13px;
color: var(--el-text-color-secondary, #909399);
text-align: center;
}
.slash-menu__list {
list-style: none;
margin: 0;
padding: 4px;
overflow-y: auto;
}
.slash-menu__item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 10px;
border-radius: 10px;
cursor: pointer;
}
.slash-menu__item.active {
background: rgba(217, 119, 87, 0.12);
}
.slash-menu__icon {
flex-shrink: 0;
margin-top: 1px;
}
.slash-menu__body {
display: flex;
flex-direction: column;
min-width: 0;
}
.slash-menu__name {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary, #303133);
}
.slash-menu__slug {
font-size: 11px;
font-weight: 400;
color: var(--el-text-color-secondary, #909399);
background: rgba(0, 0, 0, 0.05);
padding: 1px 5px;
border-radius: 5px;
}
.slash-menu__desc {
font-size: 12px;
color: var(--el-text-color-secondary, #909399);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
</style>

View File

@ -303,6 +303,12 @@ export default {
thinkingOn: 'Deep thinking enabled',
thinkingOff: 'Click to enable deep thinking',
thinkingUnsupported: 'Current model does not support deep thinking',
slashMenuTitle: 'Skills',
slashMenuHint: '↑↓ navigate · Enter select · Esc dismiss',
slashMenuLoading: 'Loading skills…',
slashMenuEmpty: 'No matching skills',
slashMenuSearchPlaceholder: 'Search skills…',
useSkillDirective: 'Use the "{name}" skill: ',
subtitle: 'Memory · Knowledge base · Skills · Automation — your personal AI operating surface',
// Queue related
queuedSending: 'Sending queued message...',

View File

@ -303,6 +303,12 @@ export default {
thinkingOn: '深度思考已开启',
thinkingOff: '点击开启深度思考',
thinkingUnsupported: '当前模型不支持深度思考',
slashMenuTitle: '技能',
slashMenuHint: '↑↓ 选择 · Enter 确认 · Esc 关闭',
slashMenuLoading: '加载技能中…',
slashMenuEmpty: '没有匹配的技能',
slashMenuSearchPlaceholder: '搜索技能…',
useSkillDirective: '使用「{name}」技能:',
subtitle: '记忆 · 知识库 · 技能 · 自动化 —— 你的个人 AI 工作面',
// 排队相关
queuedSending: '正在发送排队消息...',

View File

@ -217,6 +217,7 @@
v-model="inputText"
:loading="isGenerating && !hasPendingApproval"
:disabled="blockingPrompt || !currentAgent"
:skills-enabled="!!currentAgent && !currentAgent.skillsDisabled"
:placeholder="$t('chat.messagePlaceholder')"
:hint="currentRuntimeModel"
:attachments="pendingAttachments"