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:
zhayujie
2026-06-23 17:22:53 +08:00
parent 215ed24401
commit ec4c36f450
11 changed files with 223 additions and 79 deletions

21
app.py
View File

@@ -15,6 +15,11 @@ import threading
_channel_mgr = None _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(): def get_channel_manager():
return _channel_mgr return _channel_mgr
@@ -75,7 +80,7 @@ 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: if first_start and not DESKTOP_MODE:
PluginManager().load_plugins() PluginManager().load_plugins()
# Cloud client is optional. It is only started when # Cloud client is optional. It is only started when
@@ -364,10 +369,18 @@ def run():
_sync_builtin_skills() _sync_builtin_skills()
# Kick off MCP server loading in the background so first-message # Kick off MCP server loading in the background so first-message
# latency isn't dominated by npx package downloads. # latency isn't dominated by npx package downloads. Skipped in desktop
_warmup_mcp_tools() # 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}") logger.info(f"[App] Starting channels: {channel_names}")

View File

@@ -24,7 +24,7 @@ from common import const
from common import i18n from common import i18n
from common.log import logger from common.log import logger
from common.singleton import singleton 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"} IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"}
VIDEO_EXTENSIONS = {".mp4", ".webm", ".avi", ".mov", ".mkv"} VIDEO_EXTENSIONS = {".mp4", ".webm", ".avi", ".mov", ".mkv"}
@@ -1114,18 +1114,25 @@ class WebChannel(ChatChannel):
else: 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") 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: # In desktop mode the Electron shell renders the UI, so don't pop a
import webbrowser # browser window (also avoids issues when running detached/headless).
webbrowser.open(f"http://localhost:{port}") if os.environ.get("COW_DESKTOP") != "1":
logger.debug(f"[WebChannel] Opened browser at http://localhost:{port}") try:
except Exception as e: import webbrowser
logger.debug(f"[WebChannel] Could not open browser: {e}") 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') static_dir = os.path.join(os.path.dirname(__file__), 'static')
if not os.path.exists(static_dir): if not os.path.exists(static_dir):
os.makedirs(static_dir) try:
logger.debug(f"[WebChannel] Created static directory: {static_dir}") 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 = ( urls = (
'/', 'RootHandler', '/', 'RootHandler',
@@ -1730,8 +1737,7 @@ class ConfigHandler:
if not applied: if not applied:
return json.dumps({"status": "error", "message": "no valid keys to update"}) 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( config_path = os.path.join(get_data_root(), "config.json")
os.path.abspath(__file__)))), "config.json")
if os.path.exists(config_path): if os.path.exists(config_path):
with open(config_path, "r", encoding="utf-8") as f: with open(config_path, "r", encoding="utf-8") as f:
file_cfg = json.load(f) file_cfg = json.load(f)
@@ -2164,10 +2170,7 @@ class ModelsHandler:
@staticmethod @staticmethod
def _config_path() -> str: def _config_path() -> str:
return os.path.join( return os.path.join(get_data_root(), "config.json")
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"config.json",
)
@classmethod @classmethod
def _read_file_config(cls) -> dict: def _read_file_config(cls) -> dict:
@@ -3550,8 +3553,12 @@ class ChannelsHandler:
try: try:
local_config = conf() local_config = conf()
active_channels = self._active_channel_set() 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 = [] channels = []
for ch_name, ch_def in self.CHANNEL_DEFS.items(): for ch_name, ch_def in self.CHANNEL_DEFS.items():
if desktop_mode and ch_name == "feishu":
continue
fields_out = [] fields_out = []
for f in ch_def["fields"]: for f in ch_def["fields"]:
raw_val = local_config.get(f["key"], f.get("default", "")) raw_val = local_config.get(f["key"], f.get("default", ""))
@@ -3633,8 +3640,7 @@ class ChannelsHandler:
if not applied: if not applied:
return json.dumps({"status": "error", "message": "no valid fields to update"}) 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( config_path = os.path.join(get_data_root(), "config.json")
os.path.abspath(__file__)))), "config.json")
if os.path.exists(config_path): if os.path.exists(config_path):
with open(config_path, "r", encoding="utf-8") as f: with open(config_path, "r", encoding="utf-8") as f:
file_cfg = json.load(f) file_cfg = json.load(f)
@@ -3704,8 +3710,7 @@ class ChannelsHandler:
new_channel_type = ",".join(existing) new_channel_type = ",".join(existing)
local_config["channel_type"] = new_channel_type local_config["channel_type"] = new_channel_type
config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname( config_path = os.path.join(get_data_root(), "config.json")
os.path.abspath(__file__)))), "config.json")
if os.path.exists(config_path): if os.path.exists(config_path):
with open(config_path, "r", encoding="utf-8") as f: with open(config_path, "r", encoding="utf-8") as f:
file_cfg = json.load(f) file_cfg = json.load(f)
@@ -3760,8 +3765,7 @@ class ChannelsHandler:
local_config = conf() local_config = conf()
local_config["channel_type"] = new_channel_type local_config["channel_type"] = new_channel_type
config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname( config_path = os.path.join(get_data_root(), "config.json")
os.path.abspath(__file__)))), "config.json")
if os.path.exists(config_path): if os.path.exists(config_path):
with open(config_path, "r", encoding="utf-8") as f: with open(config_path, "r", encoding="utf-8") as f:
file_cfg = json.load(f) file_cfg = json.load(f)
@@ -3911,9 +3915,7 @@ class WeixinQrHandler:
if not bot_token or not bot_id: if not bot_token or not bot_id:
return json.dumps({"status": "error", "message": "Login confirmed but missing token"}) return json.dumps({"status": "error", "message": "Login confirmed but missing token"})
cred_path = os.path.expanduser( cred_path = get_weixin_credentials_path()
conf().get("weixin_credentials_path", "~/.weixin_cow_credentials.json")
)
from channel.weixin.weixin_channel import _save_credentials from channel.weixin.weixin_channel import _save_credentials
_save_credentials(cred_path, { _save_credentials(cred_path, {
"token": bot_token, "token": bot_token,
@@ -4577,8 +4579,7 @@ class LogsHandler:
web.header('Cache-Control', 'no-cache') web.header('Cache-Control', 'no-cache')
web.header('X-Accel-Buffering', 'no') web.header('X-Accel-Buffering', 'no')
from config import get_root log_path = os.path.join(get_data_root(), "run.log")
log_path = os.path.join(get_root(), "run.log")
def generate(): def generate():
if not os.path.isfile(log_path): if not os.path.isfile(log_path):

View File

@@ -24,7 +24,7 @@ from channel.weixin.weixin_message import WeixinMessage
from common.expired_dict import ExpiredDict from common.expired_dict import ExpiredDict
from common.log import logger from common.log import logger
from common.singleton import singleton from common.singleton import singleton
from config import conf from config import conf, get_weixin_credentials_path
MAX_CONSECUTIVE_FAILURES = 3 MAX_CONSECUTIVE_FAILURES = 3
BACKOFF_DELAY = 30 BACKOFF_DELAY = 30
@@ -96,9 +96,7 @@ class WeixinChannel(ChatChannel):
cdn_base_url = conf().get("weixin_cdn_base_url", CDN_BASE_URL) cdn_base_url = conf().get("weixin_cdn_base_url", CDN_BASE_URL)
token = conf().get("weixin_token", "") token = conf().get("weixin_token", "")
self._credentials_path = os.path.expanduser( self._credentials_path = get_weixin_credentials_path()
conf().get("weixin_credentials_path", "~/.weixin_cow_credentials.json")
)
# Always load credentials so we can restore context_tokens even when # Always load credentials so we can restore context_tokens even when
# the bot token itself comes from config. # the bot token itself comes from config.

View File

@@ -21,7 +21,7 @@ from bridge.context import Context, ContextType
from bridge.reply import Reply, ReplyType from bridge.reply import Reply, ReplyType
from common.log import logger from common.log import logger
from linkai import LinkAIClient, PushMsg 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 from plugins import PluginManager
import threading import threading
import time import time
@@ -336,9 +336,7 @@ class CloudClient(LinkAIClient):
@staticmethod @staticmethod
def _remove_weixin_credentials(): def _remove_weixin_credentials():
"""Remove the weixin token credentials file so next connect triggers QR login.""" """Remove the weixin token credentials file so next connect triggers QR login."""
cred_path = os.path.expanduser( cred_path = get_weixin_credentials_path()
conf().get("weixin_credentials_path", "~/.weixin_cow_credentials.json")
)
try: try:
if os.path.exists(cred_path): if os.path.exists(cred_path):
os.remove(cred_path) os.remove(cred_path)

View File

@@ -1,8 +1,21 @@
import logging import logging
import os
import sys import sys
import io 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): def _reset_logger(log):
for handler in log.handlers: for handler in log.handlers:
handler.close() handler.close()
@@ -20,7 +33,7 @@ def _reset_logger(log):
datefmt="%Y-%m-%d %H:%M:%S", 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( file_handle.setFormatter(
logging.Formatter( logging.Formatter(
"[%(levelname)s][%(asctime)s][%(filename)s:%(lineno)d] - %(message)s", "[%(levelname)s][%(asctime)s][%(filename)s:%(lineno)d] - %(message)s",

View File

@@ -1,18 +1,21 @@
import os import os
import pathlib
from common.utils import expand_path
from config import conf from config import conf
class TmpDir(object): 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): def __init__(self):
pathExists = os.path.exists(self.tmpFilePath) ws_root = expand_path(conf().get("agent_workspace", "~/cow"))
if not pathExists: self.tmpFilePath = os.path.join(ws_root, "tmp")
os.makedirs(self.tmpFilePath) os.makedirs(self.tmpFilePath, exist_ok=True)
def path(self): def path(self):
return str(self.tmpFilePath) + "/" return str(self.tmpFilePath) + "/"

View File

@@ -5,6 +5,7 @@ import json
import logging import logging
import os import os
import pickle import pickle
import sys
from common.log import logger from common.log import logger
from common import i18n from common import i18n
@@ -377,10 +378,16 @@ def load_config():
logger.info(" \\____\\___/ \\_/\\_//_/ \\_\\__, |\\___|_| |_|\\__|") logger.info(" \\____\\___/ \\_/\\_//_/ \\_\\__, |\\___|_| |_|\\__|")
logger.info(" |___/ ") 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): if not os.path.exists(config_path):
logger.info("config file not found, falling back to config-template.json") 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) config_str = read_file(config_path)
logger.debug("[INIT] config str: {}".format(drag_sensitive(config_str))) 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__)) 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): def read_file(path):
with open(path, mode="r", encoding="utf-8-sig") as f: with open(path, mode="r", encoding="utf-8-sig") as f:
return f.read() return f.read()
@@ -630,13 +665,29 @@ def conf():
def get_appdata_dir(): 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): if not os.path.exists(data_path):
logger.info("[INIT] data path not exists, create it: {}".format(data_path)) logger.info("[INIT] data path not exists, create it: {}".format(data_path))
os.makedirs(data_path) os.makedirs(data_path)
return 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(): def subscribe_msg():
trigger_prefix = conf().get("single_chat_prefix", [""])[0] trigger_prefix = conf().get("single_chat_prefix", [""])[0]
msg = conf().get("subscribe_msg", "") msg = conf().get("subscribe_msg", "")

View File

@@ -52,18 +52,8 @@
], ],
"extraResources": [ "extraResources": [
{ {
"from": "../", "from": "build/dist/cowagent-backend",
"to": "backend", "to": "backend/cowagent-backend"
"filter": [
"**/*",
"!desktop/**",
"!.git/**",
"!__pycache__/**",
"!**/__pycache__/**",
"!*.pyc",
"!run.log",
"!workspace/**"
]
} }
], ],
"mac": { "mac": {
@@ -73,7 +63,7 @@
{ {
"target": "dmg", "target": "dmg",
"arch": [ "arch": [
"universal" "arm64"
] ]
} }
] ]

View File

@@ -1,9 +1,15 @@
import { ChildProcess, spawn } from 'child_process' import { ChildProcess, spawn } from 'child_process'
import { EventEmitter } from 'events' import { EventEmitter } from 'events'
import path from 'path' import path from 'path'
import os from 'os'
import fs from 'fs' import fs from 'fs'
import http from 'http' 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 { export class PythonBackend extends EventEmitter {
private process: ChildProcess | null = null private process: ChildProcess | null = null
private backendPath: string private backendPath: string
@@ -23,6 +29,25 @@ export class PythonBackend extends EventEmitter {
return this.status 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 { private findPython(): string {
const venvPaths = [ const venvPaths = [
path.join(this.backendPath, '.venv', 'bin', 'python'), path.join(this.backendPath, '.venv', 'bin', 'python'),
@@ -40,9 +65,14 @@ export class PythonBackend extends EventEmitter {
return process.platform === 'win32' ? 'python' : 'python3' 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 { try {
const configPath = path.join(this.backendPath, 'config.json') const configPath = path.join(dataDir, 'config.json')
if (fs.existsSync(configPath)) { if (fs.existsSync(configPath)) {
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
if (config.web_port) { if (config.web_port) {
@@ -61,7 +91,13 @@ export class PythonBackend extends EventEmitter {
} }
this.status = 'starting' 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() const alreadyRunning = await this.probeHealth()
if (alreadyRunning) { if (alreadyRunning) {
@@ -71,20 +107,41 @@ export class PythonBackend extends EventEmitter {
return return
} }
const pythonPath = this.findPython() let command: string
const appPath = path.join(this.backendPath, 'app.py') let args: string[]
let cwd: string
if (!fs.existsSync(appPath)) { if (bundled) {
this.status = 'error' command = bundled
this.emit('error', `app.py not found at ${appPath}`) args = []
return // 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(command, args, {
cwd,
this.process = spawn(pythonPath, [appPath], { // COW_DESKTOP enables the lighter desktop runtime (no plugins, no MCP).
cwd: this.backendPath, // COW_DATA_DIR (packaged only) redirects writable data to ~/.cow so the
env: { ...process.env, PYTHONUNBUFFERED: '1' }, // 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'], stdio: ['pipe', 'pipe', 'pipe'],
}) })

View File

@@ -46,12 +46,27 @@ const CapabilityCard: React.FC<CapabilityCardProps> = ({
const [customModel, setCustomModel] = useState('') const [customModel, setCustomModel] = useState('')
const [showCustom, setShowCustom] = useState(false) 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 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] if (allowAuto) return [{ value: '', label: autoLabel || t('models_auto') }, ...opts]
return opts return opts
}, [state.providers, data, allowAuto, autoLabel]) }, [state.providers, data, allowAuto, autoLabel])
const currentUnconfigured = !!provider && !isConfigured(provider)
const modelOptions: DropdownOption[] = useMemo(() => { const modelOptions: DropdownOption[] = useMemo(() => {
const list = resolveModels(data, provider, state.provider_models).map((o) => ({ const list = resolveModels(data, provider, state.provider_models).map((o) => ({
value: o.value, value: o.value,
@@ -98,6 +113,11 @@ const CapabilityCard: React.FC<CapabilityCardProps> = ({
placeholder={t('models_select_provider')} placeholder={t('models_select_provider')}
onChange={handleProvider} 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> </Field>
{!isAuto && ( {!isAuto && (
<Field label={t('models_model')}> <Field label={t('models_model')}>

View File

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