feat(chat): add experts menu to message input toolbar
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
69
src/components/chat/experts-command-menu.tsx
Normal file
69
src/components/chat/experts-command-menu.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Bot,
|
||||||
|
Bug,
|
||||||
|
CheckCheck,
|
||||||
|
FileCode2,
|
||||||
|
FlaskConical,
|
||||||
|
GitBranch,
|
||||||
|
GitFork,
|
||||||
|
GitMerge,
|
||||||
|
Lightbulb,
|
||||||
|
ListTodo,
|
||||||
|
MessageSquareQuote,
|
||||||
|
MessageSquareReply,
|
||||||
|
PlayCircle,
|
||||||
|
Sparkles,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
const ICON_MAP: Record<string, LucideIcon> = {
|
||||||
|
Lightbulb,
|
||||||
|
ListTodo,
|
||||||
|
PlayCircle,
|
||||||
|
Bot,
|
||||||
|
GitFork,
|
||||||
|
GitBranch,
|
||||||
|
FlaskConical,
|
||||||
|
CheckCheck,
|
||||||
|
Bug,
|
||||||
|
MessageSquareQuote,
|
||||||
|
MessageSquareReply,
|
||||||
|
GitMerge,
|
||||||
|
Sparkles,
|
||||||
|
FileCode2,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the lucide-react component referenced by an expert's `icon`
|
||||||
|
* metadata field. Falls back to `Sparkles` when the name is missing or
|
||||||
|
* does not match a known icon.
|
||||||
|
*/
|
||||||
|
export function getExpertIcon(name: string | null | undefined): LucideIcon {
|
||||||
|
return (name && ICON_MAP[name]) || Sparkles
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a localized string from an expert metadata dictionary.
|
||||||
|
*
|
||||||
|
* next-intl locales look like `zh_cn`, while the bundled expert metadata
|
||||||
|
* uses BCP-47 style keys such as `zh-CN`. Normalize both sides, then fall
|
||||||
|
* back to `en`, then to any available entry when the exact locale is
|
||||||
|
* missing.
|
||||||
|
*/
|
||||||
|
export function pickExpertLocalized(
|
||||||
|
dict: Record<string, string> | undefined,
|
||||||
|
locale: string
|
||||||
|
): string {
|
||||||
|
if (!dict) return ""
|
||||||
|
if (dict[locale]) return dict[locale]
|
||||||
|
const normalized = locale.replace("_", "-")
|
||||||
|
if (dict[normalized]) return dict[normalized]
|
||||||
|
const [lang] = normalized.split("-")
|
||||||
|
const match = Object.keys(dict).find(
|
||||||
|
(key) => key.toLowerCase().split("-")[0] === lang.toLowerCase()
|
||||||
|
)
|
||||||
|
if (match) return dict[match]
|
||||||
|
return dict.en ?? Object.values(dict)[0] ?? ""
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { isDesktop } from "@/lib/platform"
|
import { isDesktop } from "@/lib/platform"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import { useTranslations } from "next-intl"
|
import { useLocale, useTranslations } from "next-intl"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
Send,
|
Send,
|
||||||
Command,
|
Command,
|
||||||
|
Sparkles,
|
||||||
Square,
|
Square,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
@@ -28,6 +29,8 @@ import {
|
|||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { ImagePreviewDialog } from "@/components/ui/image-preview-dialog"
|
import { ImagePreviewDialog } from "@/components/ui/image-preview-dialog"
|
||||||
@@ -39,6 +42,7 @@ import { openFileDialog } from "@/lib/platform"
|
|||||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
import { disposeTauriListener } from "@/lib/tauri-listener"
|
||||||
import type {
|
import type {
|
||||||
AvailableCommandInfo,
|
AvailableCommandInfo,
|
||||||
|
ExpertListItem,
|
||||||
PromptCapabilitiesInfo,
|
PromptCapabilitiesInfo,
|
||||||
PromptDraft,
|
PromptDraft,
|
||||||
PromptInputBlock,
|
PromptInputBlock,
|
||||||
@@ -53,10 +57,14 @@ import {
|
|||||||
} from "@/lib/session-attachment-events"
|
} from "@/lib/session-attachment-events"
|
||||||
import { ModeSelector } from "@/components/chat/mode-selector"
|
import { ModeSelector } from "@/components/chat/mode-selector"
|
||||||
import { SessionConfigSelector } from "@/components/chat/session-config-selector"
|
import { SessionConfigSelector } from "@/components/chat/session-config-selector"
|
||||||
import { SlashCommandMenu } from "@/components/chat/slash-command-menu"
|
import {
|
||||||
|
getExpertIcon,
|
||||||
|
pickExpertLocalized,
|
||||||
|
} from "@/components/chat/experts-command-menu"
|
||||||
import { FileMentionMenu } from "@/components/chat/file-mention-menu"
|
import { FileMentionMenu } from "@/components/chat/file-mention-menu"
|
||||||
import { DropdownRadioItemContent } from "@/components/chat/dropdown-radio-item-content"
|
import { DropdownRadioItemContent } from "@/components/chat/dropdown-radio-item-content"
|
||||||
import { useFileTree } from "@/hooks/use-file-tree"
|
import { useFileTree } from "@/hooks/use-file-tree"
|
||||||
|
import { useBuiltInExperts } from "@/hooks/use-built-in-experts"
|
||||||
import { joinFsPath } from "@/lib/path-utils"
|
import { joinFsPath } from "@/lib/path-utils"
|
||||||
import {
|
import {
|
||||||
clearMessageInputDraft,
|
clearMessageInputDraft,
|
||||||
@@ -300,6 +308,81 @@ export function MessageInput({
|
|||||||
}: MessageInputProps) {
|
}: MessageInputProps) {
|
||||||
const t = useTranslations("Folder.chat.messageInput")
|
const t = useTranslations("Folder.chat.messageInput")
|
||||||
const tQueue = useTranslations("Folder.chat.messageQueue")
|
const tQueue = useTranslations("Folder.chat.messageQueue")
|
||||||
|
const tExperts = useTranslations("ExpertsSettings")
|
||||||
|
const locale = useLocale()
|
||||||
|
const builtInExperts = useBuiltInExperts()
|
||||||
|
const expertIdSet = useMemo(
|
||||||
|
() => new Set(builtInExperts.map((item) => item.metadata.id)),
|
||||||
|
[builtInExperts]
|
||||||
|
)
|
||||||
|
// Derive the list of experts this specific agent session actually knows
|
||||||
|
// about. The backend advertises every enabled expert via its skill
|
||||||
|
// directory, so any expert whose id appears in `availableCommands` is
|
||||||
|
// guaranteed to be linked for the current agent. Using this intersection
|
||||||
|
// keeps the experts button in lockstep with what the agent will accept
|
||||||
|
// — an expert disabled in settings simply never reaches this dropdown.
|
||||||
|
const availableExperts = useMemo(() => {
|
||||||
|
if (!availableCommands || availableCommands.length === 0) return []
|
||||||
|
const agentCommandNames = new Set(availableCommands.map((cmd) => cmd.name))
|
||||||
|
return builtInExperts.filter((item) =>
|
||||||
|
agentCommandNames.has(item.metadata.id)
|
||||||
|
)
|
||||||
|
}, [availableCommands, builtInExperts])
|
||||||
|
// Stable presentation order for expert categories in the button
|
||||||
|
// dropdown. Keep this in sync with experts-settings.tsx so both surfaces
|
||||||
|
// group experts the same way.
|
||||||
|
const groupedExperts = useMemo(() => {
|
||||||
|
const CATEGORY_SORT: Record<string, number> = {
|
||||||
|
discovery: 1,
|
||||||
|
planning: 2,
|
||||||
|
execution: 3,
|
||||||
|
quality: 4,
|
||||||
|
debugging: 5,
|
||||||
|
review: 6,
|
||||||
|
meta: 7,
|
||||||
|
}
|
||||||
|
const groups = new Map<string, typeof availableExperts>()
|
||||||
|
const sorted = [...availableExperts].sort((a, b) => {
|
||||||
|
const ca = CATEGORY_SORT[a.metadata.category] ?? 99
|
||||||
|
const cb = CATEGORY_SORT[b.metadata.category] ?? 99
|
||||||
|
if (ca !== cb) return ca - cb
|
||||||
|
const sa = a.metadata.sort_order ?? 0
|
||||||
|
const sb = b.metadata.sort_order ?? 0
|
||||||
|
if (sa !== sb) return sa - sb
|
||||||
|
return a.metadata.id.localeCompare(b.metadata.id)
|
||||||
|
})
|
||||||
|
for (const item of sorted) {
|
||||||
|
const list = groups.get(item.metadata.category) ?? []
|
||||||
|
list.push(item)
|
||||||
|
groups.set(item.metadata.category, list)
|
||||||
|
}
|
||||||
|
return Array.from(groups.entries()).sort(
|
||||||
|
(a, b) => (CATEGORY_SORT[a[0]] ?? 99) - (CATEGORY_SORT[b[0]] ?? 99)
|
||||||
|
)
|
||||||
|
}, [availableExperts])
|
||||||
|
const translateExpertCategory = useCallback(
|
||||||
|
(category: string): string => {
|
||||||
|
switch (category) {
|
||||||
|
case "discovery":
|
||||||
|
return tExperts("categories.discovery")
|
||||||
|
case "planning":
|
||||||
|
return tExperts("categories.planning")
|
||||||
|
case "execution":
|
||||||
|
return tExperts("categories.execution")
|
||||||
|
case "quality":
|
||||||
|
return tExperts("categories.quality")
|
||||||
|
case "debugging":
|
||||||
|
return tExperts("categories.debugging")
|
||||||
|
case "review":
|
||||||
|
return tExperts("categories.review")
|
||||||
|
case "meta":
|
||||||
|
return tExperts("categories.meta")
|
||||||
|
default:
|
||||||
|
return category
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[tExperts]
|
||||||
|
)
|
||||||
const { shortcuts } = useShortcutSettings()
|
const { shortcuts } = useShortcutSettings()
|
||||||
const effectiveDraftStorageKey = draftStorageKey ?? attachmentTabId ?? null
|
const effectiveDraftStorageKey = draftStorageKey ?? attachmentTabId ?? null
|
||||||
const resolvedPlaceholder = placeholder ?? t("askAnything")
|
const resolvedPlaceholder = placeholder ?? t("askAnything")
|
||||||
@@ -423,11 +506,18 @@ export function MessageInput({
|
|||||||
const hasSendableContent = text.trim().length > 0 || hasAttachments
|
const hasSendableContent = text.trim().length > 0 || hasAttachments
|
||||||
|
|
||||||
// ── Slash command autocomplete ──
|
// ── Slash command autocomplete ──
|
||||||
|
//
|
||||||
|
// Built-in experts are always surfaced via a dedicated button, so any
|
||||||
|
// agent-advertised command whose name matches an expert id is hidden
|
||||||
|
// from the slash list to avoid showing the same item twice. Autocomplete
|
||||||
|
// for `/` merges the filtered agent commands and the built-in experts
|
||||||
|
// into a single flat list — agent commands first, then experts — so
|
||||||
|
// typing `/brain` still completes `brainstorming`.
|
||||||
const [slashMenuOpen, setSlashMenuOpen] = useState(false)
|
const [slashMenuOpen, setSlashMenuOpen] = useState(false)
|
||||||
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0)
|
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0)
|
||||||
const slashCommands = useMemo(
|
const slashCommands = useMemo(
|
||||||
() => availableCommands ?? [],
|
() => (availableCommands ?? []).filter((cmd) => !expertIdSet.has(cmd.name)),
|
||||||
[availableCommands]
|
[availableCommands, expertIdSet]
|
||||||
)
|
)
|
||||||
const filteredSlashCommands = useMemo(() => {
|
const filteredSlashCommands = useMemo(() => {
|
||||||
if (!slashMenuOpen || slashCommands.length === 0) return []
|
if (!slashMenuOpen || slashCommands.length === 0) return []
|
||||||
@@ -438,6 +528,17 @@ export function MessageInput({
|
|||||||
cmd.name.toLowerCase().startsWith(filter)
|
cmd.name.toLowerCase().startsWith(filter)
|
||||||
)
|
)
|
||||||
}, [slashMenuOpen, slashCommands, text])
|
}, [slashMenuOpen, slashCommands, text])
|
||||||
|
const filteredSlashExperts = useMemo(() => {
|
||||||
|
if (!slashMenuOpen || availableExperts.length === 0) return []
|
||||||
|
const match = text.match(/^\/(\S*)$/)
|
||||||
|
if (!match) return []
|
||||||
|
const filter = match[1].toLowerCase()
|
||||||
|
return availableExperts.filter((item) =>
|
||||||
|
item.metadata.id.toLowerCase().startsWith(filter)
|
||||||
|
)
|
||||||
|
}, [slashMenuOpen, availableExperts, text])
|
||||||
|
const slashAutocompleteCount =
|
||||||
|
filteredSlashCommands.length + filteredSlashExperts.length
|
||||||
|
|
||||||
// ── @ file mention autocomplete ──
|
// ── @ file mention autocomplete ──
|
||||||
const [atMenuOpen, setAtMenuOpen] = useState(false)
|
const [atMenuOpen, setAtMenuOpen] = useState(false)
|
||||||
@@ -799,6 +900,35 @@ export function MessageInput({
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const handleExpertAutocompleteSelect = useCallback(
|
||||||
|
(expert: ExpertListItem) => {
|
||||||
|
setText(`/${expert.metadata.id} `)
|
||||||
|
setSlashMenuOpen(false)
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Experts always inject `/expert-id ` at the very front of the input,
|
||||||
|
// never at the cursor. The expert skill is a whole-turn directive that
|
||||||
|
// the agent inspects first, so prepending keeps semantics unambiguous
|
||||||
|
// regardless of what the user has already typed.
|
||||||
|
const handleExpertPopoverSelect = useCallback((expert: ExpertListItem) => {
|
||||||
|
const current = textRef.current
|
||||||
|
const insertion = `/${expert.metadata.id} `
|
||||||
|
const newText = current.length === 0 ? insertion : insertion + current
|
||||||
|
setText(newText)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const ta = textareaRef.current
|
||||||
|
if (ta) {
|
||||||
|
ta.focus()
|
||||||
|
// Place the caret just after the inserted prefix so the user can
|
||||||
|
// start (or continue) typing context for the expert.
|
||||||
|
const pos = insertion.length
|
||||||
|
ta.setSelectionRange(pos, pos)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const atTriggerPosRef = useRef(atTriggerPos)
|
const atTriggerPosRef = useRef(atTriggerPos)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
atTriggerPosRef.current = atTriggerPos
|
atTriggerPosRef.current = atTriggerPos
|
||||||
@@ -834,8 +964,12 @@ export function MessageInput({
|
|||||||
const value = e.target.value
|
const value = e.target.value
|
||||||
setText(value)
|
setText(value)
|
||||||
|
|
||||||
// Slash command detection (only at start of input)
|
// Slash command detection (only at start of input). Either an agent
|
||||||
if (slashCommands.length > 0 && /^\/(\S*)$/.test(value)) {
|
// command or an agent-enabled expert can satisfy the prompt, so open
|
||||||
|
// the menu whenever at least one of them is available.
|
||||||
|
const hasSlashSource =
|
||||||
|
slashCommands.length > 0 || availableExperts.length > 0
|
||||||
|
if (hasSlashSource && /^\/(\S*)$/.test(value)) {
|
||||||
setSlashSelectedIndex(0)
|
setSlashSelectedIndex(0)
|
||||||
setSlashMenuOpen(true)
|
setSlashMenuOpen(true)
|
||||||
setAtMenuOpen(false)
|
setAtMenuOpen(false)
|
||||||
@@ -861,7 +995,7 @@ export function MessageInput({
|
|||||||
}
|
}
|
||||||
setAtMenuOpen(false)
|
setAtMenuOpen(false)
|
||||||
},
|
},
|
||||||
[slashCommands.length, defaultPath]
|
[slashCommands.length, availableExperts.length, defaultPath]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handlePickFiles = useCallback(async () => {
|
const handlePickFiles = useCallback(async () => {
|
||||||
@@ -1143,24 +1277,33 @@ export function MessageInput({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (slashMenuOpen && filteredSlashCommands.length > 0) {
|
if (slashMenuOpen && slashAutocompleteCount > 0) {
|
||||||
if (e.key === "ArrowDown") {
|
if (e.key === "ArrowDown") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setSlashSelectedIndex((i) =>
|
setSlashSelectedIndex((i) =>
|
||||||
i < filteredSlashCommands.length - 1 ? i + 1 : 0
|
i < slashAutocompleteCount - 1 ? i + 1 : 0
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (e.key === "ArrowUp") {
|
if (e.key === "ArrowUp") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setSlashSelectedIndex((i) =>
|
setSlashSelectedIndex((i) =>
|
||||||
i > 0 ? i - 1 : filteredSlashCommands.length - 1
|
i > 0 ? i - 1 : slashAutocompleteCount - 1
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (e.key === "Enter" || e.key === "Tab") {
|
if (e.key === "Enter" || e.key === "Tab") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleSlashSelect(filteredSlashCommands[slashSelectedIndex])
|
if (slashSelectedIndex < filteredSlashCommands.length) {
|
||||||
|
handleSlashSelect(filteredSlashCommands[slashSelectedIndex])
|
||||||
|
} else {
|
||||||
|
const expertIndex =
|
||||||
|
slashSelectedIndex - filteredSlashCommands.length
|
||||||
|
const expert = filteredSlashExperts[expertIndex]
|
||||||
|
if (expert) {
|
||||||
|
handleExpertAutocompleteSelect(expert)
|
||||||
|
}
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
@@ -1227,9 +1370,12 @@ export function MessageInput({
|
|||||||
handleSend,
|
handleSend,
|
||||||
shortcuts,
|
shortcuts,
|
||||||
slashMenuOpen,
|
slashMenuOpen,
|
||||||
|
slashAutocompleteCount,
|
||||||
filteredSlashCommands,
|
filteredSlashCommands,
|
||||||
|
filteredSlashExperts,
|
||||||
slashSelectedIndex,
|
slashSelectedIndex,
|
||||||
handleSlashSelect,
|
handleSlashSelect,
|
||||||
|
handleExpertAutocompleteSelect,
|
||||||
atMenuOpen,
|
atMenuOpen,
|
||||||
filteredAtFiles,
|
filteredAtFiles,
|
||||||
atSelectedIndex,
|
atSelectedIndex,
|
||||||
@@ -1400,12 +1546,74 @@ export function MessageInput({
|
|||||||
onDragLeave={handleContainerDragLeave}
|
onDragLeave={handleContainerDragLeave}
|
||||||
onDrop={handleContainerDrop}
|
onDrop={handleContainerDrop}
|
||||||
>
|
>
|
||||||
{slashMenuOpen && filteredSlashCommands.length > 0 && (
|
{slashMenuOpen && slashAutocompleteCount > 0 && (
|
||||||
<SlashCommandMenu
|
<div className="absolute bottom-full left-0 right-0 mb-1 z-50 max-h-64 overflow-y-auto rounded-xl border border-border bg-popover p-1 shadow-lg">
|
||||||
commands={filteredSlashCommands}
|
{filteredSlashCommands.map((cmd, i) => (
|
||||||
selectedIndex={slashSelectedIndex}
|
<button
|
||||||
onSelect={handleSlashSelect}
|
key={`cmd-${cmd.name}`}
|
||||||
/>
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-start gap-2 rounded-lg px-3 py-2 text-left text-sm",
|
||||||
|
i === slashSelectedIndex
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "hover:bg-muted"
|
||||||
|
)}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSlashSelect(cmd)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="shrink-0 font-mono text-primary">
|
||||||
|
/{cmd.name}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{cmd.description}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{filteredSlashExperts.map((expert, i) => {
|
||||||
|
const absoluteIndex = filteredSlashCommands.length + i
|
||||||
|
const Icon = getExpertIcon(expert.metadata.icon)
|
||||||
|
const name =
|
||||||
|
pickExpertLocalized(expert.metadata.display_name, locale) ||
|
||||||
|
expert.metadata.id
|
||||||
|
const description = pickExpertLocalized(
|
||||||
|
expert.metadata.description,
|
||||||
|
locale
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`expert-${expert.metadata.id}`}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-start gap-2 rounded-lg px-3 py-2 text-left text-sm",
|
||||||
|
absoluteIndex === slashSelectedIndex
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "hover:bg-muted"
|
||||||
|
)}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
handleExpertAutocompleteSelect(expert)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon className="mt-0.5 size-4 shrink-0 text-primary/80" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="truncate font-medium">{name}</span>
|
||||||
|
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">
|
||||||
|
/{expert.metadata.id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{description && (
|
||||||
|
<div className="truncate text-xs text-muted-foreground">
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{atMenuOpen && filteredAtFiles.length > 0 && (
|
{atMenuOpen && filteredAtFiles.length > 0 && (
|
||||||
<FileMentionMenu
|
<FileMentionMenu
|
||||||
@@ -1508,6 +1716,72 @@ export function MessageInput({
|
|||||||
>
|
>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
disabled={disabled}
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 shrink-0 bg-transparent"
|
||||||
|
title={t("expertSkills")}
|
||||||
|
>
|
||||||
|
<Sparkles className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
side="top"
|
||||||
|
align="start"
|
||||||
|
className="min-w-80 overflow-y-auto"
|
||||||
|
style={{
|
||||||
|
maxHeight:
|
||||||
|
"min(32rem, var(--radix-dropdown-menu-content-available-height))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{availableExperts.length === 0 ? (
|
||||||
|
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||||
|
{t("expertsEmptyForAgent")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
groupedExperts.map(([category, items], groupIndex) => (
|
||||||
|
<div key={category}>
|
||||||
|
{groupIndex > 0 && <DropdownMenuSeparator />}
|
||||||
|
<DropdownMenuLabel className="text-[11px] font-semibold uppercase tracking-wide">
|
||||||
|
{translateExpertCategory(category)}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
{items.map((expert) => {
|
||||||
|
const Icon = getExpertIcon(expert.metadata.icon)
|
||||||
|
const name =
|
||||||
|
pickExpertLocalized(
|
||||||
|
expert.metadata.display_name,
|
||||||
|
locale
|
||||||
|
) || expert.metadata.id
|
||||||
|
const description = pickExpertLocalized(
|
||||||
|
expert.metadata.description,
|
||||||
|
locale
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={expert.metadata.id}
|
||||||
|
onClick={() => handleExpertPopoverSelect(expert)}
|
||||||
|
className="items-start gap-2"
|
||||||
|
>
|
||||||
|
<Icon className="mt-0.5 size-4 shrink-0 text-primary/80" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate font-medium">{name}</div>
|
||||||
|
{description && (
|
||||||
|
<div className="line-clamp-2 text-xs text-muted-foreground">
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -1527,7 +1801,11 @@ export function MessageInput({
|
|||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
side="top"
|
side="top"
|
||||||
align="start"
|
align="start"
|
||||||
className="min-w-72"
|
className="min-w-72 overflow-y-auto"
|
||||||
|
style={{
|
||||||
|
maxHeight:
|
||||||
|
"min(32rem, var(--radix-dropdown-menu-content-available-height))",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{slashCommands.map((cmd) => (
|
{slashCommands.map((cmd) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
|||||||
84
src/hooks/use-built-in-experts.ts
Normal file
84
src/hooks/use-built-in-experts.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
|
import { expertsList } from "@/lib/api"
|
||||||
|
import type { ExpertListItem } from "@/lib/types"
|
||||||
|
|
||||||
|
// Module-level cache so every MessageInput/ChatInput instance shares a single
|
||||||
|
// fetch. Experts are bundled into the binary and change only when codeg is
|
||||||
|
// upgraded, so refetching per mount is wasted work.
|
||||||
|
let cachedExperts: ExpertListItem[] | null = null
|
||||||
|
let inflight: Promise<ExpertListItem[]> | null = null
|
||||||
|
const subscribers = new Set<(experts: ExpertListItem[]) => void>()
|
||||||
|
|
||||||
|
async function loadExperts(): Promise<ExpertListItem[]> {
|
||||||
|
if (cachedExperts) return cachedExperts
|
||||||
|
if (inflight) return inflight
|
||||||
|
inflight = expertsList()
|
||||||
|
.then((list) => {
|
||||||
|
cachedExperts = list
|
||||||
|
inflight = null
|
||||||
|
for (const subscriber of subscribers) {
|
||||||
|
subscriber(list)
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
inflight = null
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
return inflight
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the list of built-in expert skills bundled into codeg.
|
||||||
|
*
|
||||||
|
* The first call triggers a single backend request; subsequent hook
|
||||||
|
* instances read from an in-memory cache. Safe to call from many components
|
||||||
|
* without causing duplicate fetches.
|
||||||
|
*/
|
||||||
|
export function useBuiltInExperts(): ExpertListItem[] {
|
||||||
|
const [experts, setExperts] = useState<ExpertListItem[]>(
|
||||||
|
() => cachedExperts ?? []
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// If the cache is already populated the useState initializer above
|
||||||
|
// already handed us the right value — no follow-up setState needed.
|
||||||
|
// Only kick off a fetch when the cache is empty, and always register
|
||||||
|
// the subscriber so concurrent consumers pick up the fresh list the
|
||||||
|
// moment the first load resolves.
|
||||||
|
let cancelled = false
|
||||||
|
if (!cachedExperts) {
|
||||||
|
loadExperts()
|
||||||
|
.then((list) => {
|
||||||
|
if (!cancelled) setExperts(list)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn("[useBuiltInExperts] failed to load experts:", err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onUpdate = (next: ExpertListItem[]) => {
|
||||||
|
if (!cancelled) setExperts(next)
|
||||||
|
}
|
||||||
|
subscribers.add(onUpdate)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
subscribers.delete(onUpdate)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return experts
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate the cached experts list. Call this after installing/uninstalling
|
||||||
|
* experts so subsequent consumers see the fresh list.
|
||||||
|
*/
|
||||||
|
export function invalidateBuiltInExperts(): void {
|
||||||
|
cachedExperts = null
|
||||||
|
inflight = null
|
||||||
|
}
|
||||||
@@ -1439,7 +1439,9 @@
|
|||||||
"cancel": "إلغاء",
|
"cancel": "إلغاء",
|
||||||
"send": "إرسال",
|
"send": "إرسال",
|
||||||
"forkAndSend": "تفريع وإرسال",
|
"forkAndSend": "تفريع وإرسال",
|
||||||
"slashCommands": "أوامر الشرطة المائلة"
|
"slashCommands": "أوامر الشرطة المائلة",
|
||||||
|
"expertSkills": "مهارات الخبراء",
|
||||||
|
"expertsEmptyForAgent": "لا يحتوي هذا العميل على خبراء مفعّلين. فعّلهم من الإعدادات > الخبراء."
|
||||||
},
|
},
|
||||||
"messageQueue": {
|
"messageQueue": {
|
||||||
"addToQueue": "إضافة للقائمة",
|
"addToQueue": "إضافة للقائمة",
|
||||||
|
|||||||
@@ -1439,7 +1439,9 @@
|
|||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
"send": "Senden",
|
"send": "Senden",
|
||||||
"forkAndSend": "Fork & Senden",
|
"forkAndSend": "Fork & Senden",
|
||||||
"slashCommands": "Slash-Befehle"
|
"slashCommands": "Slash-Befehle",
|
||||||
|
"expertSkills": "Expertenfähigkeiten",
|
||||||
|
"expertsEmptyForAgent": "Dieser Agent hat keine aktivierten Experten. Aktivieren Sie sie unter Einstellungen > Experten."
|
||||||
},
|
},
|
||||||
"messageQueue": {
|
"messageQueue": {
|
||||||
"addToQueue": "Zur Warteschlange",
|
"addToQueue": "Zur Warteschlange",
|
||||||
|
|||||||
@@ -1439,7 +1439,9 @@
|
|||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"send": "Send",
|
"send": "Send",
|
||||||
"forkAndSend": "Fork & Send",
|
"forkAndSend": "Fork & Send",
|
||||||
"slashCommands": "Slash commands"
|
"slashCommands": "Slash commands",
|
||||||
|
"expertSkills": "Expert skills",
|
||||||
|
"expertsEmptyForAgent": "This agent has no enabled experts. Enable them in Settings > Experts."
|
||||||
},
|
},
|
||||||
"messageQueue": {
|
"messageQueue": {
|
||||||
"addToQueue": "Queue message",
|
"addToQueue": "Queue message",
|
||||||
|
|||||||
@@ -1439,7 +1439,9 @@
|
|||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
"send": "Enviar",
|
"send": "Enviar",
|
||||||
"forkAndSend": "Fork y Enviar",
|
"forkAndSend": "Fork y Enviar",
|
||||||
"slashCommands": "Comandos de barra"
|
"slashCommands": "Comandos de barra",
|
||||||
|
"expertSkills": "Habilidades de expertos",
|
||||||
|
"expertsEmptyForAgent": "Este agente no tiene expertos habilitados. Actívalos en Configuración > Expertos."
|
||||||
},
|
},
|
||||||
"messageQueue": {
|
"messageQueue": {
|
||||||
"addToQueue": "Agregar a la cola",
|
"addToQueue": "Agregar a la cola",
|
||||||
|
|||||||
@@ -1439,7 +1439,9 @@
|
|||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
"send": "Envoyer",
|
"send": "Envoyer",
|
||||||
"forkAndSend": "Fork & Envoyer",
|
"forkAndSend": "Fork & Envoyer",
|
||||||
"slashCommands": "Commandes slash"
|
"slashCommands": "Commandes slash",
|
||||||
|
"expertSkills": "Compétences d'expert",
|
||||||
|
"expertsEmptyForAgent": "Cet agent n'a aucun expert activé. Activez-les dans Paramètres > Experts."
|
||||||
},
|
},
|
||||||
"messageQueue": {
|
"messageQueue": {
|
||||||
"addToQueue": "Mettre en file",
|
"addToQueue": "Mettre en file",
|
||||||
|
|||||||
@@ -1439,7 +1439,9 @@
|
|||||||
"cancel": "キャンセル",
|
"cancel": "キャンセル",
|
||||||
"send": "送信",
|
"send": "送信",
|
||||||
"forkAndSend": "フォークして送信",
|
"forkAndSend": "フォークして送信",
|
||||||
"slashCommands": "スラッシュコマンド"
|
"slashCommands": "スラッシュコマンド",
|
||||||
|
"expertSkills": "エキスパートスキル",
|
||||||
|
"expertsEmptyForAgent": "このエージェントで有効なエキスパートはありません。「設定 > エキスパート」から有効にしてください。"
|
||||||
},
|
},
|
||||||
"messageQueue": {
|
"messageQueue": {
|
||||||
"addToQueue": "キューに追加",
|
"addToQueue": "キューに追加",
|
||||||
|
|||||||
@@ -1439,7 +1439,9 @@
|
|||||||
"cancel": "취소",
|
"cancel": "취소",
|
||||||
"send": "보내기",
|
"send": "보내기",
|
||||||
"forkAndSend": "포크 & 전송",
|
"forkAndSend": "포크 & 전송",
|
||||||
"slashCommands": "슬래시 명령"
|
"slashCommands": "슬래시 명령",
|
||||||
|
"expertSkills": "전문가 스킬",
|
||||||
|
"expertsEmptyForAgent": "이 에이전트에 활성화된 전문가가 없습니다. 설정 > 전문가에서 활성화하세요."
|
||||||
},
|
},
|
||||||
"messageQueue": {
|
"messageQueue": {
|
||||||
"addToQueue": "대기열에 추가",
|
"addToQueue": "대기열에 추가",
|
||||||
|
|||||||
@@ -1439,7 +1439,9 @@
|
|||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
"send": "Enviar",
|
"send": "Enviar",
|
||||||
"forkAndSend": "Fork & Enviar",
|
"forkAndSend": "Fork & Enviar",
|
||||||
"slashCommands": "Comandos de barra"
|
"slashCommands": "Comandos de barra",
|
||||||
|
"expertSkills": "Habilidades de especialistas",
|
||||||
|
"expertsEmptyForAgent": "Este agente não tem especialistas ativados. Ative-os em Configurações > Especialistas."
|
||||||
},
|
},
|
||||||
"messageQueue": {
|
"messageQueue": {
|
||||||
"addToQueue": "Adicionar à fila",
|
"addToQueue": "Adicionar à fila",
|
||||||
|
|||||||
@@ -1439,7 +1439,9 @@
|
|||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"send": "发送",
|
"send": "发送",
|
||||||
"forkAndSend": "分叉发送",
|
"forkAndSend": "分叉发送",
|
||||||
"slashCommands": "斜杠命令"
|
"slashCommands": "斜杠命令",
|
||||||
|
"expertSkills": "专家技能",
|
||||||
|
"expertsEmptyForAgent": "该智能体没有启用任何专家。请在「设置 > 专家」中启用。"
|
||||||
},
|
},
|
||||||
"messageQueue": {
|
"messageQueue": {
|
||||||
"addToQueue": "加入队列",
|
"addToQueue": "加入队列",
|
||||||
|
|||||||
@@ -1439,7 +1439,9 @@
|
|||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"send": "傳送",
|
"send": "傳送",
|
||||||
"forkAndSend": "分叉發送",
|
"forkAndSend": "分叉發送",
|
||||||
"slashCommands": "斜線命令"
|
"slashCommands": "斜線命令",
|
||||||
|
"expertSkills": "專家技能",
|
||||||
|
"expertsEmptyForAgent": "該智慧代理沒有啟用任何專家。請在「設定 > 專家」中啟用。"
|
||||||
},
|
},
|
||||||
"messageQueue": {
|
"messageQueue": {
|
||||||
"addToQueue": "加入佇列",
|
"addToQueue": "加入佇列",
|
||||||
|
|||||||
Reference in New Issue
Block a user