mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(chat): add in-chat /skill slash menu with searchable picker
This commit is contained in:
parent
82538ca3aa
commit
f3e6e1152a
@ -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;
|
||||
|
||||
342
mateclaw-ui/src/components/chat/SkillSlashMenu.vue
Normal file
342
mateclaw-ui/src/components/chat/SkillSlashMenu.vue
Normal 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>
|
||||
@ -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...',
|
||||
|
||||
@ -303,6 +303,12 @@ export default {
|
||||
thinkingOn: '深度思考已开启',
|
||||
thinkingOff: '点击开启深度思考',
|
||||
thinkingUnsupported: '当前模型不支持深度思考',
|
||||
slashMenuTitle: '技能',
|
||||
slashMenuHint: '↑↓ 选择 · Enter 确认 · Esc 关闭',
|
||||
slashMenuLoading: '加载技能中…',
|
||||
slashMenuEmpty: '没有匹配的技能',
|
||||
slashMenuSearchPlaceholder: '搜索技能…',
|
||||
useSkillDirective: '使用「{name}」技能:',
|
||||
subtitle: '记忆 · 知识库 · 技能 · 自动化 —— 你的个人 AI 工作面',
|
||||
// 排队相关
|
||||
queuedSending: '正在发送排队消息...',
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user