mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
feat(desktop): support plugin commands
This commit is contained in:
@@ -9,7 +9,9 @@ export type ChatInputHandle = (text: string, attachments: Attachment[]) => void
|
||||
interface SlashCommand {
|
||||
cmd: string
|
||||
desc: string
|
||||
action: 'new' | 'clear'
|
||||
// 'new'/'clear' run a local action; 'send' (default) is a completion that
|
||||
// gets sent to the backend as a normal message (handled by command plugins).
|
||||
action?: 'new' | 'clear'
|
||||
}
|
||||
|
||||
interface ChatInputProps {
|
||||
@@ -35,9 +37,25 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Local actions ('new'/'clear') plus completion commands handled by backend
|
||||
// command plugins (cow_cli/godcmd). Commands ending with a space expect an
|
||||
// argument, so selecting them keeps focus in the input instead of sending.
|
||||
const slashCommands: SlashCommand[] = [
|
||||
{ cmd: '/new', desc: t('session_new'), action: 'new' },
|
||||
{ cmd: '/clear', desc: t('chat_clear_context'), action: 'clear' },
|
||||
{ cmd: '/new', desc: t('slash_new'), action: 'new' },
|
||||
{ cmd: '/clear', desc: t('slash_clear'), action: 'clear' },
|
||||
{ cmd: '/help', desc: t('slash_help') },
|
||||
{ cmd: '/status', desc: t('slash_status') },
|
||||
{ cmd: '/context', desc: t('slash_context') },
|
||||
{ cmd: '/skill list', desc: t('slash_skill_list') },
|
||||
{ cmd: '/skill search ', desc: t('slash_skill_search') },
|
||||
{ cmd: '/skill install ', desc: t('slash_skill_install') },
|
||||
{ cmd: '/memory dream ', desc: t('slash_memory_dream') },
|
||||
{ cmd: '/knowledge', desc: t('slash_knowledge') },
|
||||
{ cmd: '/knowledge list', desc: t('slash_knowledge_list') },
|
||||
{ cmd: '/config', desc: t('slash_config') },
|
||||
{ cmd: '/cancel', desc: t('slash_cancel') },
|
||||
{ cmd: '/logs', desc: t('slash_logs') },
|
||||
{ cmd: '/version', desc: t('slash_version') },
|
||||
]
|
||||
const filtered = slashCommands.filter((c) => c.cmd.startsWith(text.trim().toLowerCase()))
|
||||
|
||||
@@ -60,11 +78,30 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
|
||||
})
|
||||
|
||||
const runSlash = (c: SlashCommand) => {
|
||||
setText('')
|
||||
setSlashOpen(false)
|
||||
resetHeight()
|
||||
if (c.action === 'new') onNewChat()
|
||||
else if (c.action === 'clear') onClearContext()
|
||||
if (c.action === 'new') {
|
||||
setText('')
|
||||
resetHeight()
|
||||
onNewChat()
|
||||
return
|
||||
}
|
||||
if (c.action === 'clear') {
|
||||
setText('')
|
||||
resetHeight()
|
||||
onClearContext()
|
||||
return
|
||||
}
|
||||
// Completion command. If it expects an argument (trailing space), keep it
|
||||
// in the input so the user can type the argument; otherwise send it now.
|
||||
const needsArg = c.cmd.endsWith(' ')
|
||||
if (needsArg) {
|
||||
setText(c.cmd)
|
||||
requestAnimationFrame(() => textareaRef.current?.focus())
|
||||
} else {
|
||||
onSend(c.cmd.trim(), [])
|
||||
setText('')
|
||||
resetHeight()
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
@@ -205,18 +242,27 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
|
||||
|
||||
{/* Slash command menu */}
|
||||
{slashOpen && filtered.length > 0 && (
|
||||
<div className="absolute bottom-full left-0 mb-2 w-64 rounded-xl border border-default bg-elevated shadow-lg overflow-hidden z-30">
|
||||
<div className="absolute bottom-full left-0 right-0 mb-1.5 max-h-80 overflow-y-auto rounded-xl border border-default bg-elevated shadow-xl z-30 p-1.5">
|
||||
<div className="px-2.5 pt-1 pb-1.5 text-[11px] font-semibold uppercase tracking-wider text-content-tertiary">
|
||||
{t('slash_menu_title')}
|
||||
</div>
|
||||
{filtered.map((c, i) => (
|
||||
<button
|
||||
key={c.cmd}
|
||||
onMouseEnter={() => setSlashIndex(i)}
|
||||
onClick={() => runSlash(c)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 text-left cursor-pointer transition-colors ${
|
||||
className={`w-full flex items-center justify-between gap-3 px-2.5 py-2 rounded-lg text-left cursor-pointer transition-colors ${
|
||||
i === slashIndex ? 'bg-accent-soft' : 'hover:bg-surface-2'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm font-medium text-accent">{c.cmd}</span>
|
||||
<span className="text-xs text-content-tertiary">{c.desc}</span>
|
||||
<span
|
||||
className={`text-[13px] font-medium font-mono whitespace-nowrap ${
|
||||
i === slashIndex ? 'text-accent' : 'text-content-secondary'
|
||||
}`}
|
||||
>
|
||||
{c.cmd}
|
||||
</span>
|
||||
<span className="text-xs text-content-tertiary whitespace-nowrap truncate">{c.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -331,6 +331,23 @@ const translations: Record<string, Record<string, string>> = {
|
||||
status_error: '初始化失败',
|
||||
status_error_desc: '客户端初始化失败,请重试',
|
||||
status_retry: '重试',
|
||||
// slash command descriptions
|
||||
slash_menu_title: '命令',
|
||||
slash_new: '新建对话',
|
||||
slash_clear: '清除对话上下文',
|
||||
slash_help: '显示命令帮助',
|
||||
slash_status: '查看运行状态',
|
||||
slash_context: '查看对话上下文',
|
||||
slash_skill_list: '查看已安装技能',
|
||||
slash_skill_search: '搜索技能',
|
||||
slash_skill_install: '安装技能 (名称或 GitHub URL)',
|
||||
slash_memory_dream: '手动触发记忆蒸馏 (可指定天数, 默认3)',
|
||||
slash_knowledge: '查看知识库统计',
|
||||
slash_knowledge_list: '查看知识库文件树',
|
||||
slash_config: '查看当前配置',
|
||||
slash_cancel: '中止当前正在运行的 Agent 任务',
|
||||
slash_logs: '查看最近日志',
|
||||
slash_version: '查看版本',
|
||||
},
|
||||
en: {
|
||||
console: 'Console',
|
||||
@@ -664,6 +681,23 @@ const translations: Record<string, Record<string, string>> = {
|
||||
status_error: 'Initialization Failed',
|
||||
status_error_desc: 'Failed to initialize the client, please retry',
|
||||
status_retry: 'Retry',
|
||||
// slash command descriptions
|
||||
slash_menu_title: 'Commands',
|
||||
slash_new: 'New chat',
|
||||
slash_clear: 'Clear conversation context',
|
||||
slash_help: 'Show command help',
|
||||
slash_status: 'Show running status',
|
||||
slash_context: 'Show conversation context',
|
||||
slash_skill_list: 'List installed skills',
|
||||
slash_skill_search: 'Search skills',
|
||||
slash_skill_install: 'Install a skill (name or GitHub URL)',
|
||||
slash_memory_dream: 'Trigger memory distillation (optional days, default 3)',
|
||||
slash_knowledge: 'Show knowledge base stats',
|
||||
slash_knowledge_list: 'Show knowledge base file tree',
|
||||
slash_config: 'Show current config',
|
||||
slash_cancel: 'Abort the running agent task',
|
||||
slash_logs: 'Show recent logs',
|
||||
slash_version: 'Show version',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -51,14 +51,19 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
||||
}, [activeId, ensureSession, loadHistory])
|
||||
|
||||
const scrollToBottom = useCallback((smooth = true) => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
// Jump straight to the bottom; instant for session switches, smooth for streaming.
|
||||
if (smooth) {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
} else {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
// Defer to the next frame so we read the height *after* the new content has
|
||||
// been laid out (markdown/streaming renders a frame later than the effect).
|
||||
requestAnimationFrame(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
// Smooth animations get interrupted by high-frequency streaming updates
|
||||
// and never catch up, so jump instantly while following the stream.
|
||||
if (smooth) {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
} else {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Snap to the bottom instantly when switching sessions (no top-to-bottom animation).
|
||||
@@ -66,6 +71,13 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
||||
const lastSessionRef = useRef('')
|
||||
const lastLenRef = useRef(0)
|
||||
const pendingSnapRef = useRef(false)
|
||||
// True while we should keep the view pinned to the bottom (e.g. during
|
||||
// streaming). Cleared when the user scrolls up to read earlier messages.
|
||||
const followBottomRef = useRef(true)
|
||||
// Tracks the previous streaming state so we can do one final snap to the
|
||||
// bottom right when streaming ends (the last chunk of a long command output
|
||||
// often lands together with isStreaming flipping to false).
|
||||
const wasStreamingRef = useRef(false)
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
@@ -74,12 +86,13 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
||||
lastSessionRef.current = activeId
|
||||
lastLenRef.current = messages.length
|
||||
pendingSnapRef.current = true
|
||||
followBottomRef.current = true
|
||||
}
|
||||
|
||||
if (pendingSnapRef.current) {
|
||||
// Instant snap on switch and on the first content that lands afterwards.
|
||||
lastLenRef.current = messages.length
|
||||
requestAnimationFrame(() => scrollToBottom(false))
|
||||
scrollToBottom(false)
|
||||
if (messages.length > 0) pendingSnapRef.current = false
|
||||
return
|
||||
}
|
||||
@@ -87,8 +100,24 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
||||
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 160
|
||||
const grew = messages.length !== lastLenRef.current
|
||||
lastLenRef.current = messages.length
|
||||
if (nearBottom || grew) scrollToBottom(true)
|
||||
}, [messages, activeId, scrollToBottom])
|
||||
// Follow the bottom when: a new message arrived, the user is already near
|
||||
// the bottom, or we're streaming and the user hasn't scrolled up. This
|
||||
// keeps long command/streaming output (where length is unchanged but the
|
||||
// content keeps growing) glued to the latest line.
|
||||
// One final snap right when streaming ends, so the tail of a long command
|
||||
// output isn't left scrolled off-screen.
|
||||
const justFinished = wasStreamingRef.current && !isStreaming
|
||||
wasStreamingRef.current = isStreaming
|
||||
|
||||
const following = isStreaming && followBottomRef.current
|
||||
if (grew || nearBottom || following || (justFinished && followBottomRef.current)) {
|
||||
// Instant jump while streaming/new content (smooth animations get
|
||||
// interrupted by rapid updates and never reach the bottom); smooth only
|
||||
// for a lone increment when the user is already sitting near the bottom.
|
||||
const smooth = nearBottom && !following && !grew && !justFinished
|
||||
scrollToBottom(smooth)
|
||||
}
|
||||
}, [messages, activeId, isStreaming, scrollToBottom])
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (text: string, attachments: Attachment[]) => {
|
||||
@@ -146,6 +175,9 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
||||
const handleScroll = useCallback(
|
||||
async (e: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget
|
||||
// Track whether the user wants to stay pinned to the bottom: scrolling up
|
||||
// pauses auto-follow; returning near the bottom resumes it.
|
||||
followBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 160
|
||||
const s = useChatStore.getState().sessions[activeId]
|
||||
if (el.scrollTop < 40 && s?.historyHasMore && !loadingMore && !isStreaming) {
|
||||
setLoadingMore(true)
|
||||
|
||||
Reference in New Issue
Block a user