diff --git a/app.py b/app.py index cee532bd..7e7bc3bb 100644 --- a/app.py +++ b/app.py @@ -15,6 +15,11 @@ 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 = os.environ.get("COW_DESKTOP") == "1" + def get_channel_manager(): return _channel_mgr @@ -75,7 +80,7 @@ class ChannelManager: if self._primary_channel is None and channels: self._primary_channel = channels[0][1] - if first_start: + if first_start and not DESKTOP_MODE: PluginManager().load_plugins() # Cloud client is optional. It is only started when @@ -364,10 +369,18 @@ def run(): _sync_builtin_skills() # Kick off MCP server loading in the background so first-message - # latency isn't dominated by npx package downloads. - _warmup_mcp_tools() + # latency isn't dominated by npx package downloads. Skipped in desktop + # mode (MCP relies on external npx/uvx runtimes that aren't bundled). + if not DESKTOP_MODE: + _warmup_mcp_tools() - _warmup_scheduler() + if DESKTOP_MODE: + # Defer the (heavy) AgentBridge/scheduler warmup to a background + # thread so the web API becomes available within a couple seconds. + # The scheduler still starts; it just doesn't block UI readiness. + threading.Thread(target=_warmup_scheduler, daemon=True).start() + else: + _warmup_scheduler() logger.info(f"[App] Starting channels: {channel_names}") diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index c4c17169..7353cea7 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -24,7 +24,7 @@ from common import const from common import i18n from common.log import logger from common.singleton import singleton -from config import conf +from config import conf, get_data_root, get_weixin_credentials_path IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"} VIDEO_EXTENSIONS = {".mp4", ".webm", ".avi", ".mov", ".mkv"} @@ -1114,18 +1114,25 @@ class WebChannel(ChatChannel): else: logger.info(f"[WebChannel] 🔒 Listening on {host} only (local access). For public access, set web_host to 0.0.0.0 and configure web_password") - try: - import webbrowser - webbrowser.open(f"http://localhost:{port}") - logger.debug(f"[WebChannel] Opened browser at http://localhost:{port}") - except Exception as e: - logger.debug(f"[WebChannel] Could not open browser: {e}") + # In desktop mode the Electron shell renders the UI, so don't pop a + # browser window (also avoids issues when running detached/headless). + if os.environ.get("COW_DESKTOP") != "1": + try: + import webbrowser + webbrowser.open(f"http://localhost:{port}") + logger.debug(f"[WebChannel] Opened browser at http://localhost:{port}") + except Exception as e: + logger.debug(f"[WebChannel] Could not open browser: {e}") - # 确保静态文件目录存在 + # Ensure the static dir exists. In a packaged build it ships read-only + # inside the bundle, so swallow errors instead of failing startup. static_dir = os.path.join(os.path.dirname(__file__), 'static') if not os.path.exists(static_dir): - os.makedirs(static_dir) - logger.debug(f"[WebChannel] Created static directory: {static_dir}") + try: + os.makedirs(static_dir) + logger.debug(f"[WebChannel] Created static directory: {static_dir}") + except OSError as e: + logger.debug(f"[WebChannel] Skipped creating static dir (read-only bundle?): {e}") urls = ( '/', 'RootHandler', @@ -1730,8 +1737,7 @@ class ConfigHandler: if not applied: return json.dumps({"status": "error", "message": "no valid keys to update"}) - config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname( - os.path.abspath(__file__)))), "config.json") + config_path = os.path.join(get_data_root(), "config.json") if os.path.exists(config_path): with open(config_path, "r", encoding="utf-8") as f: file_cfg = json.load(f) @@ -2164,10 +2170,7 @@ class ModelsHandler: @staticmethod def _config_path() -> str: - return os.path.join( - os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), - "config.json", - ) + return os.path.join(get_data_root(), "config.json") @classmethod def _read_file_config(cls) -> dict: @@ -3550,8 +3553,12 @@ class ChannelsHandler: try: local_config = conf() active_channels = self._active_channel_set() + # Desktop build ships without lark-oapi, so hide Feishu from the list. + desktop_mode = os.environ.get("COW_DESKTOP") == "1" channels = [] for ch_name, ch_def in self.CHANNEL_DEFS.items(): + if desktop_mode and ch_name == "feishu": + continue fields_out = [] for f in ch_def["fields"]: raw_val = local_config.get(f["key"], f.get("default", "")) @@ -3633,8 +3640,7 @@ class ChannelsHandler: if not applied: return json.dumps({"status": "error", "message": "no valid fields to update"}) - config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname( - os.path.abspath(__file__)))), "config.json") + config_path = os.path.join(get_data_root(), "config.json") if os.path.exists(config_path): with open(config_path, "r", encoding="utf-8") as f: file_cfg = json.load(f) @@ -3704,8 +3710,7 @@ class ChannelsHandler: new_channel_type = ",".join(existing) local_config["channel_type"] = new_channel_type - config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname( - os.path.abspath(__file__)))), "config.json") + config_path = os.path.join(get_data_root(), "config.json") if os.path.exists(config_path): with open(config_path, "r", encoding="utf-8") as f: file_cfg = json.load(f) @@ -3760,8 +3765,7 @@ class ChannelsHandler: local_config = conf() local_config["channel_type"] = new_channel_type - config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname( - os.path.abspath(__file__)))), "config.json") + config_path = os.path.join(get_data_root(), "config.json") if os.path.exists(config_path): with open(config_path, "r", encoding="utf-8") as f: file_cfg = json.load(f) @@ -3911,9 +3915,7 @@ class WeixinQrHandler: if not bot_token or not bot_id: return json.dumps({"status": "error", "message": "Login confirmed but missing token"}) - cred_path = os.path.expanduser( - conf().get("weixin_credentials_path", "~/.weixin_cow_credentials.json") - ) + cred_path = get_weixin_credentials_path() from channel.weixin.weixin_channel import _save_credentials _save_credentials(cred_path, { "token": bot_token, @@ -4577,8 +4579,7 @@ class LogsHandler: web.header('Cache-Control', 'no-cache') web.header('X-Accel-Buffering', 'no') - from config import get_root - log_path = os.path.join(get_root(), "run.log") + log_path = os.path.join(get_data_root(), "run.log") def generate(): if not os.path.isfile(log_path): diff --git a/channel/weixin/weixin_channel.py b/channel/weixin/weixin_channel.py index 309aecab..516b041a 100644 --- a/channel/weixin/weixin_channel.py +++ b/channel/weixin/weixin_channel.py @@ -24,7 +24,7 @@ from channel.weixin.weixin_message import WeixinMessage from common.expired_dict import ExpiredDict from common.log import logger from common.singleton import singleton -from config import conf +from config import conf, get_weixin_credentials_path MAX_CONSECUTIVE_FAILURES = 3 BACKOFF_DELAY = 30 @@ -96,9 +96,7 @@ class WeixinChannel(ChatChannel): cdn_base_url = conf().get("weixin_cdn_base_url", CDN_BASE_URL) token = conf().get("weixin_token", "") - self._credentials_path = os.path.expanduser( - conf().get("weixin_credentials_path", "~/.weixin_cow_credentials.json") - ) + self._credentials_path = get_weixin_credentials_path() # Always load credentials so we can restore context_tokens even when # the bot token itself comes from config. diff --git a/common/cloud_client.py b/common/cloud_client.py index 79d9b248..30d7f7d5 100644 --- a/common/cloud_client.py +++ b/common/cloud_client.py @@ -21,7 +21,7 @@ from bridge.context import Context, ContextType from bridge.reply import Reply, ReplyType from common.log import logger from linkai import LinkAIClient, PushMsg -from config import conf, pconf, plugin_config, available_setting, write_plugin_config, get_root +from config import conf, pconf, plugin_config, available_setting, write_plugin_config, get_root, get_weixin_credentials_path from plugins import PluginManager import threading import time @@ -336,9 +336,7 @@ class CloudClient(LinkAIClient): @staticmethod def _remove_weixin_credentials(): """Remove the weixin token credentials file so next connect triggers QR login.""" - cred_path = os.path.expanduser( - conf().get("weixin_credentials_path", "~/.weixin_cow_credentials.json") - ) + cred_path = get_weixin_credentials_path() try: if os.path.exists(cred_path): os.remove(cred_path) diff --git a/common/log.py b/common/log.py index e0bc577c..c310703c 100644 --- a/common/log.py +++ b/common/log.py @@ -1,8 +1,21 @@ import logging +import os import sys import io +def _log_path(): + # Mirror config.get_data_root() without importing config (avoids a circular + # import, since config imports this module). The desktop build sets + # COW_DATA_DIR (e.g. ~/.cow); source deployments fall back to CWD. + data_dir = os.environ.get("COW_DATA_DIR") + if data_dir: + data_dir = os.path.expanduser(data_dir) + os.makedirs(data_dir, exist_ok=True) + return os.path.join(data_dir, "run.log") + return "run.log" + + def _reset_logger(log): for handler in log.handlers: handler.close() @@ -20,7 +33,7 @@ def _reset_logger(log): datefmt="%Y-%m-%d %H:%M:%S", ) ) - file_handle = logging.FileHandler("run.log", encoding="utf-8") + file_handle = logging.FileHandler(_log_path(), encoding="utf-8") file_handle.setFormatter( logging.Formatter( "[%(levelname)s][%(asctime)s][%(filename)s:%(lineno)d] - %(message)s", diff --git a/common/tmp_dir.py b/common/tmp_dir.py index b01880bd..b0d89bc8 100644 --- a/common/tmp_dir.py +++ b/common/tmp_dir.py @@ -1,18 +1,21 @@ import os -import pathlib +from common.utils import expand_path from config import conf class TmpDir(object): - """A temporary directory that is deleted when the object is destroyed.""" + """Temporary directory for transient artifacts (e.g. synthesized voice). - tmpFilePath = pathlib.Path("./tmp/") + Resolves to ``/tmp`` (default ``~/cow/tmp``) so temp files + land inside the agent workspace instead of a CWD-relative ``./tmp``, which + is unreliable for the packaged desktop app where CWD is undefined. + """ def __init__(self): - pathExists = os.path.exists(self.tmpFilePath) - if not pathExists: - os.makedirs(self.tmpFilePath) + ws_root = expand_path(conf().get("agent_workspace", "~/cow")) + self.tmpFilePath = os.path.join(ws_root, "tmp") + os.makedirs(self.tmpFilePath, exist_ok=True) def path(self): return str(self.tmpFilePath) + "/" diff --git a/config.py b/config.py index baf8e0b2..03bf9abe 100644 --- a/config.py +++ b/config.py @@ -5,6 +5,7 @@ import json import logging import os import pickle +import sys from common.log import logger from common import i18n @@ -377,10 +378,16 @@ def load_config(): logger.info(" \\____\\___/ \\_/\\_//_/ \\_\\__, |\\___|_| |_|\\__|") logger.info(" |___/ ") logger.info("") - config_path = "./config.json" + # User config lives in the data root: source deployments use CWD (./), while + # the desktop build points COW_DATA_DIR at ~/.cow so config survives updates. + config_path = os.path.join(get_data_root(), "config.json") if not os.path.exists(config_path): logger.info("config file not found, falling back to config-template.json") - config_path = "./config-template.json" + # Resolve the template via get_resource_root() so it works both from + # source and from a frozen (PyInstaller) bundle, where the template + # ships inside the bundle (sys._MEIPASS) and CWD may differ. + template_path = os.path.join(get_resource_root(), "config-template.json") + config_path = template_path if os.path.exists(template_path) else "./config-template.json" config_str = read_file(config_path) logger.debug("[INIT] config str: {}".format(drag_sensitive(config_str))) @@ -620,6 +627,34 @@ def get_root(): return os.path.dirname(os.path.abspath(__file__)) +def get_resource_root(): + """Directory holding bundled read-only resources (e.g. config-template.json). + + Under PyInstaller, data files live in sys._MEIPASS (the onedir _internal + folder), which differs from get_root() — the latter is used for writable + user data and should stay next to the executable, not inside the bundle. + """ + if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): + return sys._MEIPASS + return os.path.dirname(os.path.abspath(__file__)) + + +def get_data_root(): + """Directory for writable user data (config.json, user_datas.pkl, run.log). + + The desktop build sets COW_DATA_DIR (e.g. ~/.cow) so data lives in the + user's home rather than inside the read-only app bundle and survives app + updates. When unset (source deployment), it falls back to get_root(), so + existing behavior is unchanged. + """ + data_dir = os.environ.get("COW_DATA_DIR") + if data_dir: + data_dir = os.path.expanduser(data_dir) + os.makedirs(data_dir, exist_ok=True) + return data_dir + return get_root() + + def read_file(path): with open(path, mode="r", encoding="utf-8-sig") as f: return f.read() @@ -630,13 +665,29 @@ def conf(): def get_appdata_dir(): - data_path = os.path.join(get_root(), conf().get("appdata_dir", "")) + data_path = os.path.join(get_data_root(), conf().get("appdata_dir", "")) if not os.path.exists(data_path): logger.info("[INIT] data path not exists, create it: {}".format(data_path)) os.makedirs(data_path) return data_path +def get_weixin_credentials_path(): + """Resolve the Weixin credentials (token) file path. + + Honors an explicit ``weixin_credentials_path`` from config. Otherwise the + packaged desktop build (COW_DATA_DIR set) keeps it under the data dir + (~/.cow) so all user data stays together, while source deployments retain + the legacy ~/.weixin_cow_credentials.json default unchanged. + """ + configured = conf().get("weixin_credentials_path") + if configured: + return os.path.expanduser(configured) + if os.environ.get("COW_DATA_DIR"): + return os.path.join(get_data_root(), "weixin_credentials.json") + return os.path.expanduser("~/.weixin_cow_credentials.json") + + def subscribe_msg(): trigger_prefix = conf().get("single_chat_prefix", [""])[0] msg = conf().get("subscribe_msg", "") diff --git a/desktop/package.json b/desktop/package.json index 052c3602..472c00cd 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -52,18 +52,8 @@ ], "extraResources": [ { - "from": "../", - "to": "backend", - "filter": [ - "**/*", - "!desktop/**", - "!.git/**", - "!__pycache__/**", - "!**/__pycache__/**", - "!*.pyc", - "!run.log", - "!workspace/**" - ] + "from": "build/dist/cowagent-backend", + "to": "backend/cowagent-backend" } ], "mac": { @@ -73,7 +63,7 @@ { "target": "dmg", "arch": [ - "universal" + "arm64" ] } ] diff --git a/desktop/src/main/python-manager.ts b/desktop/src/main/python-manager.ts index 16801ab5..0cfe1884 100644 --- a/desktop/src/main/python-manager.ts +++ b/desktop/src/main/python-manager.ts @@ -1,9 +1,15 @@ import { ChildProcess, spawn } from 'child_process' import { EventEmitter } from 'events' import path from 'path' +import os from 'os' import fs from 'fs' import http from 'http' +// Writable data dir for the packaged app (config.json, run.log, user data). +// Lives in the user's home so it survives app updates and avoids writing into +// the read-only app bundle. Source/dev runs keep using the repo CWD instead. +const COW_DATA_DIR = path.join(os.homedir(), '.cow') + export class PythonBackend extends EventEmitter { private process: ChildProcess | null = null private backendPath: string @@ -23,6 +29,25 @@ export class PythonBackend extends EventEmitter { return this.status } + /** + * Locate the packaged onedir backend executable shipped with the app. + * Returns null when not present (e.g. during local development), so we can + * fall back to running app.py with a system/venv Python. + */ + private findBundledBackend(): string | null { + const exeName = process.platform === 'win32' ? 'cowagent-backend.exe' : 'cowagent-backend' + const candidates = [ + path.join(this.backendPath, 'cowagent-backend', exeName), + path.join(this.backendPath, exeName), + ] + for (const p of candidates) { + if (fs.existsSync(p)) { + return p + } + } + return null + } + private findPython(): string { const venvPaths = [ path.join(this.backendPath, '.venv', 'bin', 'python'), @@ -40,9 +65,14 @@ export class PythonBackend extends EventEmitter { return process.platform === 'win32' ? 'python' : 'python3' } - private readPort(): number { + /** + * Resolve config.json from the given data dir to read the web port. The + * packaged build keeps config in COW_DATA_DIR (~/.cow); dev reads it from the + * repo path. Returns the default port when no config (or web_port) is found. + */ + private readPort(dataDir: string): number { try { - const configPath = path.join(this.backendPath, 'config.json') + const configPath = path.join(dataDir, 'config.json') if (fs.existsSync(configPath)) { const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) if (config.web_port) { @@ -61,7 +91,13 @@ export class PythonBackend extends EventEmitter { } this.status = 'starting' - this.port = this.readPort() + + // Prefer the packaged self-contained backend (production); fall back to + // running app.py with a Python interpreter (local development). + const bundled = this.findBundledBackend() + // Packaged app stores writable data in ~/.cow; dev keeps it in the repo. + const dataDir = bundled ? COW_DATA_DIR : this.backendPath + this.port = this.readPort(dataDir) const alreadyRunning = await this.probeHealth() if (alreadyRunning) { @@ -71,20 +107,41 @@ export class PythonBackend extends EventEmitter { return } - const pythonPath = this.findPython() - const appPath = path.join(this.backendPath, 'app.py') + let command: string + let args: string[] + let cwd: string - if (!fs.existsSync(appPath)) { - this.status = 'error' - this.emit('error', `app.py not found at ${appPath}`) - return + if (bundled) { + command = bundled + args = [] + // The onedir bundle reads data files relative to the executable's dir. + cwd = path.dirname(bundled) + this.emit('log', `Starting bundled backend: ${bundled}`) + } else { + const pythonPath = this.findPython() + const appPath = path.join(this.backendPath, 'app.py') + if (!fs.existsSync(appPath)) { + this.status = 'error' + this.emit('error', `app.py not found at ${appPath}`) + return + } + command = pythonPath + args = [appPath] + cwd = this.backendPath + this.emit('log', `Starting Python backend: ${pythonPath} ${appPath}`) } - this.emit('log', `Starting Python backend: ${pythonPath} ${appPath}`) - - this.process = spawn(pythonPath, [appPath], { - cwd: this.backendPath, - env: { ...process.env, PYTHONUNBUFFERED: '1' }, + this.process = spawn(command, args, { + cwd, + // COW_DESKTOP enables the lighter desktop runtime (no plugins, no MCP). + // COW_DATA_DIR (packaged only) redirects writable data to ~/.cow so the + // app bundle stays read-only; dev runs omit it and keep using the repo. + env: { + ...process.env, + PYTHONUNBUFFERED: '1', + COW_DESKTOP: '1', + ...(bundled ? { COW_DATA_DIR } : {}), + }, stdio: ['pipe', 'pipe', 'pipe'], }) diff --git a/desktop/src/renderer/src/pages/settings/CapabilityCard.tsx b/desktop/src/renderer/src/pages/settings/CapabilityCard.tsx index a5e0b506..2da063df 100644 --- a/desktop/src/renderer/src/pages/settings/CapabilityCard.tsx +++ b/desktop/src/renderer/src/pages/settings/CapabilityCard.tsx @@ -46,12 +46,27 @@ const CapabilityCard: React.FC = ({ const [customModel, setCustomModel] = useState('') const [showCustom, setShowCustom] = useState(false) + // A provider is configured when it has credentials (custom providers always + // carry their own). Unconfigured ones stay selectable but are flagged so the + // user is guided to set up the API key. + const isConfigured = (id: string): boolean => { + const p = data?.providers?.find((x) => x.id === id) + if (!p) return true + return p.configured || (p.is_custom && !!p.custom_name) + } + const providerOptions: DropdownOption[] = useMemo(() => { - const opts = (state.providers || []).map((id) => ({ value: id, label: providerLabel(data, id) })) + const opts = (state.providers || []).map((id) => ({ + value: id, + label: providerLabel(data, id), + hint: isConfigured(id) ? undefined : t('config_provider_unconfigured'), + })) if (allowAuto) return [{ value: '', label: autoLabel || t('models_auto') }, ...opts] return opts }, [state.providers, data, allowAuto, autoLabel]) + const currentUnconfigured = !!provider && !isConfigured(provider) + const modelOptions: DropdownOption[] = useMemo(() => { const list = resolveModels(data, provider, state.provider_models).map((o) => ({ value: o.value, @@ -98,6 +113,11 @@ const CapabilityCard: React.FC = ({ placeholder={t('models_select_provider')} onChange={handleProvider} /> + {/* The provider's API key is configured in the vendor cards above on + this same tab, so warn instead of linking elsewhere. */} + {currentUnconfigured && ( +

{t('config_provider_unconfigured_hint')}

+ )} {!isAuto && ( diff --git a/voice/dashscope/dashscope_voice.py b/voice/dashscope/dashscope_voice.py index 746bb59a..e1f0770c 100644 --- a/voice/dashscope/dashscope_voice.py +++ b/voice/dashscope/dashscope_voice.py @@ -12,6 +12,7 @@ from dashscope import MultiModalConversation from bridge.reply import Reply, ReplyType from common.log import logger +from common.tmp_dir import TmpDir from config import conf from voice import audio_convert from voice.voice import Voice @@ -121,8 +122,7 @@ class DashScopeVoice(Voice): @staticmethod def _download_audio(url: str) -> Optional[str]: try: - tmp_dir = os.path.join(os.getcwd(), "tmp") - os.makedirs(tmp_dir, exist_ok=True) + tmp_dir = TmpDir().path() ts = datetime.datetime.now().strftime("%Y%m%d%H%M%S") ext = os.path.splitext(url.split("?", 1)[0])[1].lower() or ".wav" if ext not in (".mp3", ".wav", ".m4a", ".aac", ".opus"):