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
|
_channel_mgr = None
|
||||||
|
|
||||||
# Desktop mode: a lighter runtime for the packaged Electron client. The plugin
|
# Desktop mode: a lighter runtime for the packaged Electron client. Plugins are
|
||||||
# framework is still bundled (it's tiny and on the web channel's import path),
|
# loaded in a background thread (so command plugins like cow_cli/godcmd work
|
||||||
# but we skip loading actual plugins and MCP tools to keep startup fast.
|
# without slowing startup), while MCP warmup is still skipped to keep it fast.
|
||||||
DESKTOP_MODE = os.environ.get("COW_DESKTOP") == "1"
|
DESKTOP_MODE = os.environ.get("COW_DESKTOP") == "1"
|
||||||
|
|
||||||
|
|
||||||
@@ -80,8 +80,16 @@ class ChannelManager:
|
|||||||
if self._primary_channel is None and channels:
|
if self._primary_channel is None and channels:
|
||||||
self._primary_channel = channels[0][1]
|
self._primary_channel = channels[0][1]
|
||||||
|
|
||||||
if first_start and not DESKTOP_MODE:
|
if first_start:
|
||||||
PluginManager().load_plugins()
|
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
|
# Cloud client is optional. It is only started when
|
||||||
# use_linkai=True AND cloud_deployment_id is set.
|
# use_linkai=True AND cloud_deployment_id is set.
|
||||||
|
|||||||
@@ -52,15 +52,19 @@ hiddenimports += collect_submodules('models')
|
|||||||
hiddenimports += collect_submodules('voice')
|
hiddenimports += collect_submodules('voice')
|
||||||
hiddenimports += collect_submodules('bridge')
|
hiddenimports += collect_submodules('bridge')
|
||||||
|
|
||||||
# Plugin framework: WebChannel -> ChatChannel imports `from plugins import *`,
|
# Plugin framework + plugins. WebChannel -> ChatChannel imports
|
||||||
# so the framework package must be present even though desktop mode never loads
|
# `from plugins import *`, and desktop mode loads plugins (in a background
|
||||||
# actual plugins (it's only ~tens of KB of code).
|
# 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 += [
|
hiddenimports += [
|
||||||
'plugins',
|
'plugins',
|
||||||
'plugins.event',
|
'plugins.event',
|
||||||
'plugins.plugin',
|
'plugins.plugin',
|
||||||
'plugins.plugin_manager',
|
'plugins.plugin_manager',
|
||||||
]
|
]
|
||||||
|
hiddenimports += collect_submodules('plugins')
|
||||||
|
hiddenimports += collect_submodules('cli')
|
||||||
|
|
||||||
# Third-party SDKs that use lazy/conditional imports internally.
|
# Third-party SDKs that use lazy/conditional imports internally.
|
||||||
hiddenimports += collect_submodules('dashscope')
|
hiddenimports += collect_submodules('dashscope')
|
||||||
@@ -75,6 +79,9 @@ hiddenimports += [
|
|||||||
datas = [
|
datas = [
|
||||||
(rp('config-template.json'), '.'),
|
(rp('config-template.json'), '.'),
|
||||||
(rp('skills'), 'skills'),
|
(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
|
# 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
|
# assets (~1.9MB) so the browser-based console works as a debug/fallback
|
||||||
# entry alongside the Electron UI.
|
# entry alongside the Electron UI.
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ export type ChatInputHandle = (text: string, attachments: Attachment[]) => void
|
|||||||
interface SlashCommand {
|
interface SlashCommand {
|
||||||
cmd: string
|
cmd: string
|
||||||
desc: 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 {
|
interface ChatInputProps {
|
||||||
@@ -35,9 +37,25 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
|
|||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(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[] = [
|
const slashCommands: SlashCommand[] = [
|
||||||
{ cmd: '/new', desc: t('session_new'), action: 'new' },
|
{ cmd: '/new', desc: t('slash_new'), action: 'new' },
|
||||||
{ cmd: '/clear', desc: t('chat_clear_context'), action: 'clear' },
|
{ 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()))
|
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) => {
|
const runSlash = (c: SlashCommand) => {
|
||||||
setText('')
|
|
||||||
setSlashOpen(false)
|
setSlashOpen(false)
|
||||||
resetHeight()
|
if (c.action === 'new') {
|
||||||
if (c.action === 'new') onNewChat()
|
setText('')
|
||||||
else if (c.action === 'clear') onClearContext()
|
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(() => {
|
const handleSubmit = useCallback(() => {
|
||||||
@@ -205,18 +242,27 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
|
|||||||
|
|
||||||
{/* Slash command menu */}
|
{/* Slash command menu */}
|
||||||
{slashOpen && filtered.length > 0 && (
|
{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) => (
|
{filtered.map((c, i) => (
|
||||||
<button
|
<button
|
||||||
key={c.cmd}
|
key={c.cmd}
|
||||||
onMouseEnter={() => setSlashIndex(i)}
|
onMouseEnter={() => setSlashIndex(i)}
|
||||||
onClick={() => runSlash(c)}
|
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'
|
i === slashIndex ? 'bg-accent-soft' : 'hover:bg-surface-2'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-sm font-medium text-accent">{c.cmd}</span>
|
<span
|
||||||
<span className="text-xs text-content-tertiary">{c.desc}</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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -331,6 +331,23 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
status_error: '初始化失败',
|
status_error: '初始化失败',
|
||||||
status_error_desc: '客户端初始化失败,请重试',
|
status_error_desc: '客户端初始化失败,请重试',
|
||||||
status_retry: '重试',
|
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: {
|
en: {
|
||||||
console: 'Console',
|
console: 'Console',
|
||||||
@@ -664,6 +681,23 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
status_error: 'Initialization Failed',
|
status_error: 'Initialization Failed',
|
||||||
status_error_desc: 'Failed to initialize the client, please retry',
|
status_error_desc: 'Failed to initialize the client, please retry',
|
||||||
status_retry: '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])
|
}, [activeId, ensureSession, loadHistory])
|
||||||
|
|
||||||
const scrollToBottom = useCallback((smooth = true) => {
|
const scrollToBottom = useCallback((smooth = true) => {
|
||||||
const el = scrollRef.current
|
// Defer to the next frame so we read the height *after* the new content has
|
||||||
if (!el) return
|
// been laid out (markdown/streaming renders a frame later than the effect).
|
||||||
// Jump straight to the bottom; instant for session switches, smooth for streaming.
|
requestAnimationFrame(() => {
|
||||||
if (smooth) {
|
const el = scrollRef.current
|
||||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
if (!el) return
|
||||||
} else {
|
// Smooth animations get interrupted by high-frequency streaming updates
|
||||||
el.scrollTop = el.scrollHeight
|
// 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).
|
// 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 lastSessionRef = useRef('')
|
||||||
const lastLenRef = useRef(0)
|
const lastLenRef = useRef(0)
|
||||||
const pendingSnapRef = useRef(false)
|
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(() => {
|
useEffect(() => {
|
||||||
const el = scrollRef.current
|
const el = scrollRef.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
@@ -74,12 +86,13 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
|||||||
lastSessionRef.current = activeId
|
lastSessionRef.current = activeId
|
||||||
lastLenRef.current = messages.length
|
lastLenRef.current = messages.length
|
||||||
pendingSnapRef.current = true
|
pendingSnapRef.current = true
|
||||||
|
followBottomRef.current = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pendingSnapRef.current) {
|
if (pendingSnapRef.current) {
|
||||||
// Instant snap on switch and on the first content that lands afterwards.
|
// Instant snap on switch and on the first content that lands afterwards.
|
||||||
lastLenRef.current = messages.length
|
lastLenRef.current = messages.length
|
||||||
requestAnimationFrame(() => scrollToBottom(false))
|
scrollToBottom(false)
|
||||||
if (messages.length > 0) pendingSnapRef.current = false
|
if (messages.length > 0) pendingSnapRef.current = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -87,8 +100,24 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
|||||||
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 160
|
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 160
|
||||||
const grew = messages.length !== lastLenRef.current
|
const grew = messages.length !== lastLenRef.current
|
||||||
lastLenRef.current = messages.length
|
lastLenRef.current = messages.length
|
||||||
if (nearBottom || grew) scrollToBottom(true)
|
// Follow the bottom when: a new message arrived, the user is already near
|
||||||
}, [messages, activeId, scrollToBottom])
|
// 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(
|
const handleSend = useCallback(
|
||||||
async (text: string, attachments: Attachment[]) => {
|
async (text: string, attachments: Attachment[]) => {
|
||||||
@@ -146,6 +175,9 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
|||||||
const handleScroll = useCallback(
|
const handleScroll = useCallback(
|
||||||
async (e: React.UIEvent<HTMLDivElement>) => {
|
async (e: React.UIEvent<HTMLDivElement>) => {
|
||||||
const el = e.currentTarget
|
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]
|
const s = useChatStore.getState().sessions[activeId]
|
||||||
if (el.scrollTop < 40 && s?.historyHasMore && !loadingMore && !isStreaming) {
|
if (el.scrollTop < 40 && s?.historyHasMore && !loadingMore && !isStreaming) {
|
||||||
setLoadingMore(true)
|
setLoadingMore(true)
|
||||||
|
|||||||
@@ -9,11 +9,30 @@ import sys
|
|||||||
from common.log import logger
|
from common.log import logger
|
||||||
from common.singleton import singleton
|
from common.singleton import singleton
|
||||||
from common.sorted_dict import SortedDict
|
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 *
|
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
|
@singleton
|
||||||
class PluginManager:
|
class PluginManager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -45,15 +64,21 @@ class PluginManager:
|
|||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
def save_config(self):
|
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)
|
json.dump(self.pconf, f, indent=4, ensure_ascii=False)
|
||||||
|
|
||||||
def load_config(self):
|
def load_config(self):
|
||||||
logger.debug("Loading plugins config...")
|
logger.debug("Loading plugins config...")
|
||||||
|
|
||||||
modified = False
|
modified = False
|
||||||
if os.path.exists("./plugins/plugins.json"):
|
# Prefer the writable copy (data dir); fall back to the one shipped in
|
||||||
with open("./plugins/plugins.json", "r", encoding="utf-8") as f:
|
# 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 = json.load(f)
|
||||||
pconf["plugins"] = SortedDict(lambda k, v: v["priority"], pconf["plugins"], reverse=True)
|
pconf["plugins"] = SortedDict(lambda k, v: v["priority"], pconf["plugins"], reverse=True)
|
||||||
else:
|
else:
|
||||||
@@ -73,7 +98,7 @@ class PluginManager:
|
|||||||
从 plugins/config.json 中加载所有插件的配置并写入 config.py 的全局配置中,供插件中使用
|
从 plugins/config.json 中加载所有插件的配置并写入 config.py 的全局配置中,供插件中使用
|
||||||
插件实例中通过 config.pconf(plugin_name) 即可获取该插件的配置
|
插件实例中通过 config.pconf(plugin_name) 即可获取该插件的配置
|
||||||
"""
|
"""
|
||||||
all_config_path = "./plugins/config.json"
|
all_config_path = os.path.join(_plugins_resource_dir(), "config.json")
|
||||||
try:
|
try:
|
||||||
if os.path.exists(all_config_path):
|
if os.path.exists(all_config_path):
|
||||||
# read from all plugins config
|
# read from all plugins config
|
||||||
@@ -88,7 +113,7 @@ class PluginManager:
|
|||||||
|
|
||||||
def scan_plugins(self):
|
def scan_plugins(self):
|
||||||
logger.debug("Scanning plugins ...")
|
logger.debug("Scanning plugins ...")
|
||||||
plugins_dir = "./plugins"
|
plugins_dir = _plugins_resource_dir()
|
||||||
raws = [self.plugins[name] for name in self.plugins]
|
raws = [self.plugins[name] for name in self.plugins]
|
||||||
for plugin_name in os.listdir(plugins_dir):
|
for plugin_name in os.listdir(plugins_dir):
|
||||||
plugin_path = os.path.join(plugins_dir, plugin_name)
|
plugin_path = os.path.join(plugins_dir, plugin_name)
|
||||||
|
|||||||
Reference in New Issue
Block a user