mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 03:03:19 +08:00
feat(desktop): redirect writable data to ~/.cow for packaged app
Introduce get_data_root() driven by the COW_DATA_DIR env var so the packaged desktop build stores config.json, run.log, user data and WeChat credentials under ~/.cow — surviving app updates and keeping the app bundle read-only. Source deployments leave COW_DATA_DIR unset and fall back to the repo root, so existing behavior is unchanged.
This commit is contained in:
21
app.py
21
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}")
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ``<agent_workspace>/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) + "/"
|
||||
|
||||
57
config.py
57
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", "")
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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'],
|
||||
})
|
||||
|
||||
|
||||
@@ -46,12 +46,27 @@ const CapabilityCard: React.FC<CapabilityCardProps> = ({
|
||||
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<CapabilityCardProps> = ({
|
||||
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 && (
|
||||
<p className="text-xs text-danger mt-1.5">{t('config_provider_unconfigured_hint')}</p>
|
||||
)}
|
||||
</Field>
|
||||
{!isAuto && (
|
||||
<Field label={t('models_model')}>
|
||||
|
||||
@@ -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"):
|
||||
|
||||
Reference in New Issue
Block a user