mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 12:27:53 +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>
|
||||||
|
|
||||||
<div class="input-area">
|
<div class="input-area">
|
||||||
|
<SkillSlashMenu
|
||||||
|
v-if="slashActive"
|
||||||
|
ref="slashMenuRef"
|
||||||
|
:query="slashQuery"
|
||||||
|
@select="handleSkillSelect"
|
||||||
|
@close="handleSlashClose"
|
||||||
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
ref="textareaRef"
|
ref="textareaRef"
|
||||||
v-model="inputValue"
|
v-model="inputValue"
|
||||||
@ -141,11 +148,12 @@
|
|||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:maxlength="maxLength"
|
:maxlength="maxLength"
|
||||||
rows="1"
|
rows="1"
|
||||||
|
@keydown="handleSlashKeydown"
|
||||||
@keydown.enter.exact.prevent="handleEnter"
|
@keydown.enter.exact.prevent="handleEnter"
|
||||||
@compositionstart="isComposing = true"
|
@compositionstart="isComposing = true"
|
||||||
@compositionend="isComposing = false"
|
@compositionend="isComposing = false"
|
||||||
@focus="isFocused = true"
|
@focus="onTextareaFocus"
|
||||||
@blur="isFocused = false"
|
@blur="onTextareaBlur"
|
||||||
@input="autoResize"
|
@input="autoResize"
|
||||||
@paste="handlePaste"
|
@paste="handlePaste"
|
||||||
></textarea>
|
></textarea>
|
||||||
@ -232,7 +240,8 @@ import { ref, computed, nextTick, watch } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { ArrowDown, CloseBold, MagicStick, Microphone, Paperclip, Promotion, Select, Timer, WarningFilled } from '@element-plus/icons-vue'
|
import { ArrowDown, CloseBold, MagicStick, Microphone, Paperclip, Promotion, Select, Timer, WarningFilled } from '@element-plus/icons-vue'
|
||||||
import { useToolLabel } from '@/composables/useToolLabel'
|
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 {
|
interface Props {
|
||||||
/** 输入值 */
|
/** 输入值 */
|
||||||
@ -272,6 +281,12 @@ interface Props {
|
|||||||
* 不响应点击,tooltip 提示当前模型不支持深度思考。默认 true 以保持向后兼容。
|
* 不响应点击,tooltip 提示当前模型不支持深度思考。默认 true 以保持向后兼容。
|
||||||
*/
|
*/
|
||||||
thinkingSupported?: boolean
|
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>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@ -291,6 +306,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
enableTalkMode: false,
|
enableTalkMode: false,
|
||||||
thinkingEnabled: false,
|
thinkingEnabled: false,
|
||||||
thinkingSupported: true,
|
thinkingSupported: true,
|
||||||
|
skillsEnabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@ -324,6 +340,102 @@ const fileInputRef = ref<HTMLInputElement | null>(null)
|
|||||||
const isFocused = ref(false)
|
const isFocused = ref(false)
|
||||||
const isComposing = 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,
|
// Always-approve dropdown — collapsed by default; opens on the chevron click,
|
||||||
// closes on outside click or after the user picks a scope.
|
// closes on outside click or after the user picks a scope.
|
||||||
const alwaysApproveOpen = ref(false)
|
const alwaysApproveOpen = ref(false)
|
||||||
@ -394,6 +506,9 @@ const sendBtnClass = computed(() => ({
|
|||||||
// 处理回车键
|
// 处理回车键
|
||||||
const handleEnter = () => {
|
const handleEnter = () => {
|
||||||
if (isComposing.value) return
|
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()
|
handleSubmit()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -570,6 +685,7 @@ defineExpose({
|
|||||||
|
|
||||||
/* 输入区域 */
|
/* 输入区域 */
|
||||||
.input-area {
|
.input-area {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: flex-end;
|
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',
|
thinkingOn: 'Deep thinking enabled',
|
||||||
thinkingOff: 'Click to enable deep thinking',
|
thinkingOff: 'Click to enable deep thinking',
|
||||||
thinkingUnsupported: 'Current model does not support 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',
|
subtitle: 'Memory · Knowledge base · Skills · Automation — your personal AI operating surface',
|
||||||
// Queue related
|
// Queue related
|
||||||
queuedSending: 'Sending queued message...',
|
queuedSending: 'Sending queued message...',
|
||||||
|
|||||||
@ -303,6 +303,12 @@ export default {
|
|||||||
thinkingOn: '深度思考已开启',
|
thinkingOn: '深度思考已开启',
|
||||||
thinkingOff: '点击开启深度思考',
|
thinkingOff: '点击开启深度思考',
|
||||||
thinkingUnsupported: '当前模型不支持深度思考',
|
thinkingUnsupported: '当前模型不支持深度思考',
|
||||||
|
slashMenuTitle: '技能',
|
||||||
|
slashMenuHint: '↑↓ 选择 · Enter 确认 · Esc 关闭',
|
||||||
|
slashMenuLoading: '加载技能中…',
|
||||||
|
slashMenuEmpty: '没有匹配的技能',
|
||||||
|
slashMenuSearchPlaceholder: '搜索技能…',
|
||||||
|
useSkillDirective: '使用「{name}」技能:',
|
||||||
subtitle: '记忆 · 知识库 · 技能 · 自动化 —— 你的个人 AI 工作面',
|
subtitle: '记忆 · 知识库 · 技能 · 自动化 —— 你的个人 AI 工作面',
|
||||||
// 排队相关
|
// 排队相关
|
||||||
queuedSending: '正在发送排队消息...',
|
queuedSending: '正在发送排队消息...',
|
||||||
|
|||||||
@ -217,6 +217,7 @@
|
|||||||
v-model="inputText"
|
v-model="inputText"
|
||||||
:loading="isGenerating && !hasPendingApproval"
|
:loading="isGenerating && !hasPendingApproval"
|
||||||
:disabled="blockingPrompt || !currentAgent"
|
:disabled="blockingPrompt || !currentAgent"
|
||||||
|
:skills-enabled="!!currentAgent && !currentAgent.skillsDisabled"
|
||||||
:placeholder="$t('chat.messagePlaceholder')"
|
:placeholder="$t('chat.messagePlaceholder')"
|
||||||
:hint="currentRuntimeModel"
|
:hint="currentRuntimeModel"
|
||||||
:attachments="pendingAttachments"
|
:attachments="pendingAttachments"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user