mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 03:03:19 +08:00
feat(desktop): support plugin commands
This commit is contained in:
18
app.py
18
app.py
@@ -15,9 +15,9 @@ import threading
|
||||
|
||||
_channel_mgr = None
|
||||
|
||||
# Desktop mode: a lighter runtime for the packaged Electron client. The plugin
|
||||
# framework is still bundled (it's tiny and on the web channel's import path),
|
||||
# but we skip loading actual plugins and MCP tools to keep startup fast.
|
||||
# Desktop mode: a lighter runtime for the packaged Electron client. Plugins are
|
||||
# loaded in a background thread (so command plugins like cow_cli/godcmd work
|
||||
# without slowing startup), while MCP warmup is still skipped to keep it fast.
|
||||
DESKTOP_MODE = os.environ.get("COW_DESKTOP") == "1"
|
||||
|
||||
|
||||
@@ -80,8 +80,16 @@ class ChannelManager:
|
||||
if self._primary_channel is None and channels:
|
||||
self._primary_channel = channels[0][1]
|
||||
|
||||
if first_start and not DESKTOP_MODE:
|
||||
PluginManager().load_plugins()
|
||||
if first_start:
|
||||
if DESKTOP_MODE:
|
||||
# Load plugins in the background so command plugins
|
||||
# (cow_cli / godcmd, e.g. /status, #help) work in the
|
||||
# desktop client, without blocking web-service readiness.
|
||||
threading.Thread(
|
||||
target=PluginManager().load_plugins, daemon=True
|
||||
).start()
|
||||
else:
|
||||
PluginManager().load_plugins()
|
||||
|
||||
# Cloud client is optional. It is only started when
|
||||
# use_linkai=True AND cloud_deployment_id is set.
|
||||
|
||||
@@ -52,15 +52,19 @@ hiddenimports += collect_submodules('models')
|
||||
hiddenimports += collect_submodules('voice')
|
||||
hiddenimports += collect_submodules('bridge')
|
||||
|
||||
# Plugin framework: WebChannel -> ChatChannel imports `from plugins import *`,
|
||||
# so the framework package must be present even though desktop mode never loads
|
||||
# actual plugins (it's only ~tens of KB of code).
|
||||
# Plugin framework + plugins. WebChannel -> ChatChannel imports
|
||||
# `from plugins import *`, and desktop mode loads plugins (in a background
|
||||
# thread) so command plugins like cow_cli/godcmd (/status, #help) work. Plugin
|
||||
# modules are imported dynamically by name in scan_plugins(), so list them
|
||||
# explicitly. The `cli` package is a cow_cli dependency (`from cli import ...`).
|
||||
hiddenimports += [
|
||||
'plugins',
|
||||
'plugins.event',
|
||||
'plugins.plugin',
|
||||
'plugins.plugin_manager',
|
||||
]
|
||||
hiddenimports += collect_submodules('plugins')
|
||||
hiddenimports += collect_submodules('cli')
|
||||
|
||||
# Third-party SDKs that use lazy/conditional imports internally.
|
||||
hiddenimports += collect_submodules('dashscope')
|
||||
@@ -75,6 +79,9 @@ hiddenimports += [
|
||||
datas = [
|
||||
(rp('config-template.json'), '.'),
|
||||
(rp('skills'), 'skills'),
|
||||
# PluginManager.scan_plugins() walks the on-disk ./plugins dir at runtime
|
||||
# (it doesn't rely solely on imports), so ship the package directory too.
|
||||
(rp('plugins'), 'plugins'),
|
||||
# Web console served on the backend port: ship chat.html plus its static
|
||||
# assets (~1.9MB) so the browser-based console works as a debug/fallback
|
||||
# entry alongside the Electron UI.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -9,11 +9,30 @@ import sys
|
||||
from common.log import logger
|
||||
from common.singleton import singleton
|
||||
from common.sorted_dict import SortedDict
|
||||
from config import conf, remove_plugin_config, write_plugin_config
|
||||
from config import conf, remove_plugin_config, write_plugin_config, get_data_root, get_resource_root
|
||||
|
||||
from .event import *
|
||||
|
||||
|
||||
def _plugins_resource_dir():
|
||||
"""Read-only plugins source dir. In a frozen bundle it lives under the
|
||||
resource root (sys._MEIPASS); from source it's the CWD-relative ./plugins."""
|
||||
if getattr(sys, "frozen", False):
|
||||
return os.path.join(get_resource_root(), "plugins")
|
||||
return "./plugins"
|
||||
|
||||
|
||||
def _plugins_data_dir():
|
||||
"""Writable dir for plugin runtime config (plugins.json). In a frozen
|
||||
bundle the resource dir is read-only, so redirect writes to the data root
|
||||
(~/.cow); from source it stays the CWD-relative ./plugins."""
|
||||
if getattr(sys, "frozen", False):
|
||||
d = os.path.join(get_data_root(), "plugins")
|
||||
os.makedirs(d, exist_ok=True)
|
||||
return d
|
||||
return "./plugins"
|
||||
|
||||
|
||||
@singleton
|
||||
class PluginManager:
|
||||
def __init__(self):
|
||||
@@ -45,15 +64,21 @@ class PluginManager:
|
||||
return wrapper
|
||||
|
||||
def save_config(self):
|
||||
with open("./plugins/plugins.json", "w", encoding="utf-8") as f:
|
||||
cfg_path = os.path.join(_plugins_data_dir(), "plugins.json")
|
||||
with open(cfg_path, "w", encoding="utf-8") as f:
|
||||
json.dump(self.pconf, f, indent=4, ensure_ascii=False)
|
||||
|
||||
def load_config(self):
|
||||
logger.debug("Loading plugins config...")
|
||||
|
||||
modified = False
|
||||
if os.path.exists("./plugins/plugins.json"):
|
||||
with open("./plugins/plugins.json", "r", encoding="utf-8") as f:
|
||||
# Prefer the writable copy (data dir); fall back to the one shipped in
|
||||
# the resource dir (first run in a frozen bundle, before any save).
|
||||
data_cfg = os.path.join(_plugins_data_dir(), "plugins.json")
|
||||
res_cfg = os.path.join(_plugins_resource_dir(), "plugins.json")
|
||||
cfg_path = data_cfg if os.path.exists(data_cfg) else res_cfg
|
||||
if os.path.exists(cfg_path):
|
||||
with open(cfg_path, "r", encoding="utf-8") as f:
|
||||
pconf = json.load(f)
|
||||
pconf["plugins"] = SortedDict(lambda k, v: v["priority"], pconf["plugins"], reverse=True)
|
||||
else:
|
||||
@@ -73,7 +98,7 @@ class PluginManager:
|
||||
从 plugins/config.json 中加载所有插件的配置并写入 config.py 的全局配置中,供插件中使用
|
||||
插件实例中通过 config.pconf(plugin_name) 即可获取该插件的配置
|
||||
"""
|
||||
all_config_path = "./plugins/config.json"
|
||||
all_config_path = os.path.join(_plugins_resource_dir(), "config.json")
|
||||
try:
|
||||
if os.path.exists(all_config_path):
|
||||
# read from all plugins config
|
||||
@@ -88,7 +113,7 @@ class PluginManager:
|
||||
|
||||
def scan_plugins(self):
|
||||
logger.debug("Scanning plugins ...")
|
||||
plugins_dir = "./plugins"
|
||||
plugins_dir = _plugins_resource_dir()
|
||||
raws = [self.plugins[name] for name in self.plugins]
|
||||
for plugin_name in os.listdir(plugins_dir):
|
||||
plugin_path = os.path.join(plugins_dir, plugin_name)
|
||||
|
||||
Reference in New Issue
Block a user