feat: update openclaw and polish desktop flows

This commit is contained in:
inman
2026-05-13 21:52:17 +08:00
parent 7c8781a6e3
commit 86795078f7
22 changed files with 1145 additions and 186 deletions

View File

@@ -50,11 +50,17 @@ const OPENCLAW_AGENTS_SKILLS_PATCH_REPLACE = `\tconst openClawAgentsSkillsDisabl
\t\tdir: path.resolve(workspaceDir, ".agents", "skills"),
\t\tsource: "agents-skills-project"
\t});`;
const OPENCLAW_MAIN_SESSION_RECOVERY_MARK_SEARCH = `\t\t\tif (!interruptedSessionIds.has(entry.sessionId)) continue;
const OPENCLAW_MAIN_SESSION_RECOVERY_MARK_SEARCH = `\t\t\tif (!resolveEntryTranscriptLockPaths({
\t\t\t\tentry,
\t\t\t\tsessionsDir
\t\t\t}).some((lockPath) => interruptedLockPaths.has(lockPath))) continue;
\t\t\tentry.abortedLastRun = true;
\t\t\tstore[sessionKey] = entry;
\t\t\tresult.marked++;`;
const OPENCLAW_MAIN_SESSION_RECOVERY_MARK_REPLACE = `\t\t\tif (!interruptedSessionIds.has(entry.sessionId)) continue;
const OPENCLAW_MAIN_SESSION_RECOVERY_MARK_REPLACE = `\t\t\tif (!resolveEntryTranscriptLockPaths({
\t\t\t\tentry,
\t\t\t\tsessionsDir
\t\t\t}).some((lockPath) => interruptedLockPaths.has(lockPath))) continue;
\t\t\tentry.abortedLastRun = true;
\t\t\tif (process.env.OPENCLAW_DISABLE_MAIN_SESSION_RESTART_RECOVERY === "1") {
\t\t\t\tentry.status = "failed";
@@ -73,13 +79,13 @@ const OPENCLAW_MAIN_SESSION_RECOVERY_SCHEDULE_REPLACE = `function scheduleRestar
\t\treturn;
\t}
\tconst initialDelay = params.delayMs ?? DEFAULT_RECOVERY_DELAY_MS;`;
const OPENCLAW_STUCK_ACTIVE_ABORT_SEARCH = `\t\t\t\t(opts?.recoverStuckSession ?? recoverStuckSession)({
const OPENCLAW_STUCK_ACTIVE_ABORT_SEARCH = `\t\t\t\tif (classification?.recoveryEligible) (opts?.recoverStuckSession ?? recoverStuckSession)({
\t\t\t\t\tsessionId: state.sessionId,
\t\t\t\t\tsessionKey: state.sessionKey,
\t\t\t\t\tageMs,
\t\t\t\t\tqueueDepth: state.queueDepth
\t\t\t\t});`;
const OPENCLAW_STUCK_ACTIVE_ABORT_REPLACE = `\t\t\t\t(opts?.recoverStuckSession ?? recoverStuckSession)({
const OPENCLAW_STUCK_ACTIVE_ABORT_REPLACE = `\t\t\t\tif (classification?.recoveryEligible) (opts?.recoverStuckSession ?? recoverStuckSession)({
\t\t\t\t\tsessionId: state.sessionId,
\t\t\t\t\tsessionKey: state.sessionKey,
\t\t\t\t\tageMs,
@@ -194,6 +200,7 @@ const OPENCLAW_BUNDLED_RUNTIME_DEPENDENCY_NAMES = [
'semver',
'sharp',
'silk-wasm',
'socks-proxy-agent',
'sqlite-vec',
'tar',
'tokenjuice',

View File

@@ -5,6 +5,12 @@ import { EventEmitter } from 'events';
import { existsSync, mkdirSync, rmSync, readdirSync } from 'fs';
import { deflateSync } from 'zlib';
import { getOpenClawDir, getOpenClawResolvedDir } from './paths';
import { getAllSettings } from './store';
import {
isSocksProxyUrl,
redactProxyUrlForLog,
resolveWhatsAppProxyUrl,
} from './whatsapp-proxy';
const require = createRequire(import.meta.url);
@@ -76,6 +82,32 @@ type ConnectionState = {
const QRCode = QRCodeModule;
const QRErrorCorrectLevel = QRErrorCorrectLevelModule;
function loadProxyAgentConstructor(proxyUrl: string) {
const moduleName = isSocksProxyUrl(proxyUrl) ? 'socks-proxy-agent' : 'https-proxy-agent';
const agentModule = require(resolveOpenClawModule(moduleName));
return agentModule.SocksProxyAgent
|| agentModule.HttpsProxyAgent
|| agentModule.default
|| agentModule;
}
async function createWhatsAppSocketAgent(): Promise<unknown | undefined> {
let settings: Awaited<ReturnType<typeof getAllSettings>>;
try {
settings = await getAllSettings();
} catch (error) {
console.warn('[WhatsAppLogin] Failed to read proxy settings; connecting directly', error);
return undefined;
}
const proxyUrl = resolveWhatsAppProxyUrl(settings);
if (!proxyUrl) return undefined;
const AgentCtor = loadProxyAgentConstructor(proxyUrl);
console.log(`[WhatsAppLogin] Using proxy for WhatsApp WebSocket: ${redactProxyUrlForLog(proxyUrl)}`);
return new AgentCtor(proxyUrl);
}
function createQrMatrix(input: string) {
const qr = new QRCode(-1, QRErrorCorrectLevel.L);
qr.addData(input);
@@ -201,6 +233,7 @@ export class WhatsAppLoginManager extends EventEmitter {
private loginSucceeded: boolean = false;
private retryCount: number = 0;
private maxRetries: number = 5;
private lastConnectionError: string | null = null;
constructor() {
super();
@@ -242,10 +275,16 @@ export class WhatsAppLoginManager extends EventEmitter {
this.loginSucceeded = false;
this.qr = null;
this.retryCount = 0;
this.lastConnectionError = null;
await this.connectToWhatsApp(accountId);
}
private buildConnectionFailureMessage(fallback: string): string {
const details = this.lastConnectionError ? ` Last error: ${this.lastConnectionError}` : '';
return `${fallback}. Check network connectivity or enable a proxy in Advanced Settings.${details}`;
}
private async connectToWhatsApp(accountId: string): Promise<void> {
if (!this.active) return;
@@ -292,6 +331,7 @@ export class WhatsAppLoginManager extends EventEmitter {
const { version } = await fetchLatestBaileysVersion();
console.log(`[WhatsAppLogin] Starting login for ${accountId}, version: ${version}`);
const socketAgent = await createWhatsAppSocketAgent();
this.socket = makeWASocket({
version,
@@ -299,6 +339,7 @@ export class WhatsAppLoginManager extends EventEmitter {
printQRInTerminal: false,
logger: pino({ level: 'silent' }), // Silent logger
connectTimeoutMs: 60000,
...(socketAgent ? { agent: socketAgent } : {}),
// mobile: false,
// browser: ['ClawX', 'Chrome', '1.0.0'],
});
@@ -333,6 +374,8 @@ export class WhatsAppLoginManager extends EventEmitter {
if (connection === 'close') {
const error = lastDisconnect?.error as BaileysError | undefined;
const statusCode = error?.output?.statusCode;
this.lastConnectionError = error?.message
|| (statusCode ? `statusCode=${statusCode}` : this.lastConnectionError);
const isLoggedOut = statusCode === DisconnectReason.loggedOut;
// Treat 401 as transient if we haven't exhausted retries (max 2 attempts)
// This handles the case where WhatsApp's session hasn't fully released
@@ -351,7 +394,7 @@ export class WhatsAppLoginManager extends EventEmitter {
} else {
console.log('[WhatsAppLogin] Max retries reached, stopping.');
this.active = false;
this.emit('error', 'Connection failed after multiple retries');
this.emit('error', this.buildConnectionFailureMessage('Connection failed after multiple retries'));
}
} else {
// Logged out or explicitly stopped
@@ -384,6 +427,12 @@ export class WhatsAppLoginManager extends EventEmitter {
}
} catch (innerErr) {
console.error('[WhatsAppLogin] Error in connection update:', innerErr);
const msg = innerErr instanceof Error ? innerErr.message : String(innerErr);
this.lastConnectionError = msg;
if (this.active) {
this.active = false;
this.emit('error', this.buildConnectionFailureMessage('Failed to generate WhatsApp QR code'));
}
}
});
@@ -395,7 +444,8 @@ export class WhatsAppLoginManager extends EventEmitter {
} else {
this.active = false;
const msg = error instanceof Error ? error.message : String(error);
this.emit('error', msg);
this.lastConnectionError = msg;
this.emit('error', this.buildConnectionFailureMessage('Failed to start WhatsApp login'));
}
}
}
@@ -408,6 +458,7 @@ export class WhatsAppLoginManager extends EventEmitter {
const cleanupAccountId = this.accountId;
this.active = false;
this.qr = null;
this.lastConnectionError = null;
if (this.socket) {
try {
// Remove listeners to prevent handling closure as error

View File

@@ -0,0 +1,59 @@
import type { ProxySettings } from './proxy';
import { resolveProxySettings } from './proxy';
const WHATSAPP_WEB_HOST = 'web.whatsapp.com';
function splitBypassRules(rules: string): string[] {
return rules
.split(/[,\n;]/)
.map((rule) => rule.trim())
.filter(Boolean);
}
function matchesBypassRule(host: string, rule: string): boolean {
const normalizedHost = host.toLowerCase();
const normalizedRule = rule.toLowerCase();
if (normalizedRule === '*') return true;
if (normalizedRule === '<local>') {
return normalizedHost === 'localhost'
|| normalizedHost === '127.0.0.1'
|| normalizedHost === '::1'
|| !normalizedHost.includes('.');
}
if (normalizedRule.startsWith('*.')) {
const suffix = normalizedRule.slice(1);
return normalizedHost.endsWith(suffix);
}
if (normalizedRule.startsWith('.')) {
return normalizedHost.endsWith(normalizedRule);
}
return normalizedHost === normalizedRule;
}
export function shouldBypassWhatsAppProxy(bypassRules: string): boolean {
return splitBypassRules(bypassRules).some((rule) => matchesBypassRule(WHATSAPP_WEB_HOST, rule));
}
export function resolveWhatsAppProxyUrl(settings: ProxySettings): string {
if (!settings.proxyEnabled) return '';
const resolved = resolveProxySettings(settings);
if (shouldBypassWhatsAppProxy(resolved.bypassRules)) return '';
return resolved.allProxy || resolved.httpsProxy || resolved.httpProxy;
}
export function isSocksProxyUrl(proxyUrl: string): boolean {
return /^socks[45]h?:\/\//i.test(proxyUrl.trim());
}
export function redactProxyUrlForLog(proxyUrl: string): string {
try {
const parsed = new URL(proxyUrl);
if (parsed.username) parsed.username = 'redacted';
if (parsed.password) parsed.password = 'redacted';
return parsed.toString();
} catch {
return proxyUrl.replace(/\/\/([^/@:]+):([^/@]+)@/, '//redacted:redacted@');
}
}

View File

@@ -216,7 +216,7 @@
"node-edge-tts": "^1.2.10",
"nostr-tools": "^2.23.3",
"openai": "^6.34.0",
"openclaw": "2026.4.29",
"openclaw": "2026.5.7",
"openshell": "0.1.0",
"opusscript": "^0.1.1",
"pdfjs-dist": "^5.7.284",

678
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -306,6 +306,7 @@ const OPENCLAW_RUNTIME_DEPS_PACKAGES = [
'semver',
'sharp',
'silk-wasm',
'socks-proxy-agent',
'sqlite-vec',
'tar',
'tokenjuice',
@@ -904,6 +905,17 @@ function findFilesByName(rootDir, matcher) {
return matches;
}
function findFirstFileByNameContaining(rootDir, matcher, needle) {
for (const filePath of findFilesByName(rootDir, matcher)) {
try {
if (fs.readFileSync(filePath, 'utf8').includes(needle)) return filePath;
} catch {
continue;
}
}
return null;
}
function patchBundledRuntime(outputDir) {
const replacePatches = [
{
@@ -957,11 +969,17 @@ function patchBundledRuntime(outputDir) {
{
label: 'main session restart recovery mark boundary',
target: () => findFirstFileByName(path.join(outputDir, 'dist'), /^main-session-restart-recovery-.*\.js$/),
search: `\t\t\tif (!interruptedSessionIds.has(entry.sessionId)) continue;
search: `\t\t\tif (!resolveEntryTranscriptLockPaths({
\t\t\t\tentry,
\t\t\t\tsessionsDir
\t\t\t}).some((lockPath) => interruptedLockPaths.has(lockPath))) continue;
\t\t\tentry.abortedLastRun = true;
\t\t\tstore[sessionKey] = entry;
\t\t\tresult.marked++;`,
replace: `\t\t\tif (!interruptedSessionIds.has(entry.sessionId)) continue;
replace: `\t\t\tif (!resolveEntryTranscriptLockPaths({
\t\t\t\tentry,
\t\t\t\tsessionsDir
\t\t\t}).some((lockPath) => interruptedLockPaths.has(lockPath))) continue;
\t\t\tentry.abortedLastRun = true;
\t\t\tif (process.env.OPENCLAW_DISABLE_MAIN_SESSION_RESTART_RECOVERY === "1") {
\t\t\t\tentry.status = "failed";
@@ -987,14 +1005,18 @@ function patchBundledRuntime(outputDir) {
},
{
label: 'stuck session active-run recovery boundary',
target: () => findFirstFileByName(path.join(outputDir, 'dist'), /^diagnostic-.*\.js$/),
search: `\t\t\t\t(opts?.recoverStuckSession ?? recoverStuckSession)({
target: () => findFirstFileByNameContaining(
path.join(outputDir, 'dist'),
/^diagnostic-.*\.js$/,
'classification?.recoveryEligible',
),
search: `\t\t\t\tif (classification?.recoveryEligible) (opts?.recoverStuckSession ?? recoverStuckSession)({
\t\t\t\t\tsessionId: state.sessionId,
\t\t\t\t\tsessionKey: state.sessionKey,
\t\t\t\t\tageMs,
\t\t\t\t\tqueueDepth: state.queueDepth
\t\t\t\t});`,
replace: `\t\t\t\t(opts?.recoverStuckSession ?? recoverStuckSession)({
replace: `\t\t\t\tif (classification?.recoveryEligible) (opts?.recoverStuckSession ?? recoverStuckSession)({
\t\t\t\t\tsessionId: state.sessionId,
\t\t\t\t\tsessionKey: state.sessionKey,
\t\t\t\t\tageMs,
@@ -1019,6 +1041,9 @@ function patchBundledRuntime(outputDir) {
}
const current = fs.readFileSync(target, 'utf8');
if (current.includes(patch.replace)) {
continue;
}
if (!current.includes(patch.search)) {
echo` ⚠️ Skipped patch for ${patch.label}: expected source snippet not found`;
continue;
@@ -1075,6 +1100,10 @@ function patchBundledRuntime(outputDir) {
let matchedAny = false;
for (const target of ptyTargets) {
const current = fs.readFileSync(target, 'utf8');
if (current.includes(patch.replace)) {
matchedAny = true;
continue;
}
if (!current.includes(patch.search)) continue;
matchedAny = true;
const next = current.replaceAll(patch.search, patch.replace);
@@ -1173,6 +1202,10 @@ function patchBundledRuntime(outputDir) {
if (!file.endsWith('.js')) continue;
const filePath = path.join(distDir, file);
const content = fs.readFileSync(filePath, 'utf8');
if (content.includes(patch.replace)) {
matchedAny = true;
continue;
}
if (!content.includes(patch.search)) continue;
matchedAny = true;
const next = content.replace(patch.search, patch.replace);

View File

@@ -273,7 +273,10 @@ export function ChannelConfigModal({
const err = typeof args[0] === 'string'
? args[0]
: String((args[0] as { message?: string } | undefined)?.message || args[0]);
toast.error(translateRef.current('toast.qrFailed', { name: CHANNEL_NAMES[channelType], error: err }));
const errorText = channelType === 'whatsapp' && /websocket|network|proxy/i.test(err)
? translateRef.current('toast.whatsappNetworkHint', { error: err })
: translateRef.current('toast.qrFailed', { name: CHANNEL_NAMES[channelType], error: err });
toast.error(errorText);
setQrCode(null);
setConnecting(false);
};
@@ -370,10 +373,13 @@ export function ChannelConfigModal({
}
if (meta.connectionType === 'qr') {
await hostApiFetch(`/api/channels/${encodeURIComponent(selectedType)}/start`, {
const startResult = await hostApiFetch<{ success?: boolean; error?: string }>(`/api/channels/${encodeURIComponent(selectedType)}/start`, {
method: 'POST',
body: JSON.stringify(resolvedAccountId ? { accountId: resolvedAccountId } : {}),
});
if (!startResult?.success) {
throw new Error(startResult?.error || 'Failed to start QR login');
}
return;
}

View File

@@ -39,6 +39,7 @@ import type { CronJob } from '@/types/cron';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { hostApiFetch } from '@/lib/host-api';
import { useTranslation } from 'react-i18next';
import logoSvg from '@/assets/logo.svg';
@@ -123,8 +124,22 @@ const sidebarItemActive = [
'dark:border-white/10 dark:bg-white/10 dark:text-blue-100 dark:ring-white/10',
].join(' ');
function NavItem({ to, icon, label, badge, collapsed, onClick, testId, inset }: NavItemProps) {
function SidebarTooltip({ label, children, enabled = true }: { label: string; children: React.ReactElement; enabled?: boolean }) {
if (!enabled) return children;
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="block w-full">{children}</span>
</TooltipTrigger>
<TooltipContent side="right" align="center" sideOffset={10} className="whitespace-nowrap text-xs font-medium">
{label}
</TooltipContent>
</Tooltip>
);
}
function NavItem({ to, icon, label, badge, collapsed, onClick, testId, inset }: NavItemProps) {
const link = (
<NavLink
to={to}
onClick={onClick}
@@ -158,6 +173,12 @@ function NavItem({ to, icon, label, badge, collapsed, onClick, testId, inset }:
)}
</NavLink>
);
return (
<SidebarTooltip label={label} enabled={collapsed}>
{link}
</SidebarTooltip>
);
}
function getSessionBucket(activityMs: number, nowMs: number): SessionBucketKey {
@@ -634,6 +655,7 @@ export function Sidebar() {
};
return (
<TooltipProvider delayDuration={250}>
<>
<aside
data-testid="sidebar"
@@ -706,42 +728,48 @@ export function Sidebar() {
{sidebarCollapsed && (
<div className="px-2 pb-2 pt-2">
<div className="space-y-1">
<button
data-testid="sidebar-new-chat"
title={newChatLabel}
onClick={() => {
const { messages, currentSessionKey } = useChatStore.getState();
if (messages.length > 0 || !currentSessionKey.startsWith('agent:main:')) {
newSession();
}
navigate('/chat');
}}
className="flex w-full justify-center rounded-lg border border-[#D5E8F3]/70 bg-white/50 px-0 py-2 text-[#075985] shadow-[inset_0_1px_0_rgba(255,255,255,0.75)] backdrop-blur-md transition-colors hover:border-[#BFDCEC] hover:bg-white/70 dark:border-white/10 dark:bg-white/10 dark:text-blue-100 dark:hover:bg-white/15"
>
<Plus className="h-[18px] w-[18px]" strokeWidth={2} />
</button>
<Button
data-testid="sidebar-collapse-toggle"
title={expandLabel}
variant="ghost"
size="icon"
className="h-9 w-full rounded-lg text-muted-foreground hover:bg-white/70 hover:text-foreground dark:hover:bg-white/10"
onClick={() => setSidebarCollapsed(false)}
>
<PanelLeft className="h-[18px] w-[18px]" />
</Button>
<button
data-testid="sidebar-chat-history"
title={historyLabel}
onClick={() => setHistoryOpen((open) => !open)}
aria-expanded={historyOpen}
className={cn(
'flex w-full justify-center rounded-lg border border-transparent px-0 py-2 transition-colors hover:bg-white/70 dark:hover:bg-white/5',
isHistoryActive ? sidebarItemActive : 'text-muted-foreground',
)}
>
<MessageCircle className="h-[18px] w-[18px]" strokeWidth={2} />
</button>
<SidebarTooltip label={newChatLabel}>
<button
data-testid="sidebar-new-chat"
title={newChatLabel}
onClick={() => {
const { messages, currentSessionKey } = useChatStore.getState();
if (messages.length > 0 || !currentSessionKey.startsWith('agent:main:')) {
newSession();
}
navigate('/chat');
}}
className="flex w-full justify-center rounded-lg border border-[#D5E8F3]/70 bg-white/50 px-0 py-2 text-[#075985] shadow-[inset_0_1px_0_rgba(255,255,255,0.75)] backdrop-blur-md transition-colors hover:border-[#BFDCEC] hover:bg-white/70 dark:border-white/10 dark:bg-white/10 dark:text-blue-100 dark:hover:bg-white/15"
>
<Plus className="h-[18px] w-[18px]" strokeWidth={2} />
</button>
</SidebarTooltip>
<SidebarTooltip label={expandLabel}>
<Button
data-testid="sidebar-collapse-toggle"
title={expandLabel}
variant="ghost"
size="icon"
className="h-9 w-full rounded-lg text-muted-foreground hover:bg-white/70 hover:text-foreground dark:hover:bg-white/10"
onClick={() => setSidebarCollapsed(false)}
>
<PanelLeft className="h-[18px] w-[18px]" />
</Button>
</SidebarTooltip>
<SidebarTooltip label={historyLabel}>
<button
data-testid="sidebar-chat-history"
title={historyLabel}
onClick={() => setHistoryOpen((open) => !open)}
aria-expanded={historyOpen}
className={cn(
'flex w-full justify-center rounded-lg border border-transparent px-0 py-2 transition-colors hover:bg-white/70 dark:hover:bg-white/5',
isHistoryActive ? sidebarItemActive : 'text-muted-foreground',
)}
>
<MessageCircle className="h-[18px] w-[18px]" strokeWidth={2} />
</button>
</SidebarTooltip>
</div>
</div>
)}
@@ -853,27 +881,29 @@ export function Sidebar() {
</div>
)}
{isE2EMode && devModeUnlocked && (
<Button
data-testid="sidebar-open-dev-console"
title={t('common:sidebar.openClawPage')}
variant="ghost"
className={cn(
'flex items-center gap-2.5 rounded-lg px-2.5 py-2 h-auto text-[14px] font-medium transition-colors w-full mt-1',
'hover:bg-black/5 dark:hover:bg-white/5 text-foreground/80',
sidebarCollapsed ? 'justify-center px-0' : 'justify-start'
)}
onClick={openDevConsole}
>
<div className="flex shrink-0 items-center justify-center text-muted-foreground">
<Terminal className="h-[18px] w-[18px]" strokeWidth={2} />
</div>
{!sidebarCollapsed && (
<>
<span className="flex-1 text-left overflow-hidden text-ellipsis whitespace-nowrap">{t('common:sidebar.openClawPage')}</span>
<ExternalLink className="h-3 w-3 shrink-0 ml-auto opacity-50 text-muted-foreground" />
</>
)}
</Button>
<SidebarTooltip label={t('common:sidebar.openClawPage')} enabled={sidebarCollapsed}>
<Button
data-testid="sidebar-open-dev-console"
title={t('common:sidebar.openClawPage')}
variant="ghost"
className={cn(
'flex items-center gap-2.5 rounded-lg px-2.5 py-2 h-auto text-[14px] font-medium transition-colors w-full mt-1',
'hover:bg-black/5 dark:hover:bg-white/5 text-foreground/80',
sidebarCollapsed ? 'justify-center px-0' : 'justify-start'
)}
onClick={openDevConsole}
>
<div className="flex shrink-0 items-center justify-center text-muted-foreground">
<Terminal className="h-[18px] w-[18px]" strokeWidth={2} />
</div>
{!sidebarCollapsed && (
<>
<span className="flex-1 text-left overflow-hidden text-ellipsis whitespace-nowrap">{t('common:sidebar.openClawPage')}</span>
<ExternalLink className="h-3 w-3 shrink-0 ml-auto opacity-50 text-muted-foreground" />
</>
)}
</Button>
</SidebarTooltip>
)}
<div
data-testid="sidebar-account-summary"
@@ -891,20 +921,22 @@ export function Sidebar() {
>
<img src={logoSvg} alt={t('common:appName')} className="h-7 w-7 rounded-lg" />
</div>
<NavLink
to="/settings"
data-testid="sidebar-nav-settings"
title={t('sidebar.settings')}
className={({ isActive }) =>
cn(
'flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-white/70 hover:text-foreground dark:hover:bg-white/10',
isActive && 'bg-white/70 text-[#075985] dark:bg-white/10 dark:text-blue-100',
)
}
>
<SettingsIcon className="h-[18px] w-[18px]" strokeWidth={2} />
<span className="sr-only">{t('sidebar.settings')}</span>
</NavLink>
<SidebarTooltip label={t('sidebar.settings')}>
<NavLink
to="/settings"
data-testid="sidebar-nav-settings"
title={t('sidebar.settings')}
className={({ isActive }) =>
cn(
'flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-white/70 hover:text-foreground dark:hover:bg-white/10',
isActive && 'bg-white/70 text-[#075985] dark:bg-white/10 dark:text-blue-100',
)
}
>
<SettingsIcon className="h-[18px] w-[18px]" strokeWidth={2} />
<span className="sr-only">{t('sidebar.settings')}</span>
</NavLink>
</SidebarTooltip>
</div>
) : (
<div className="flex min-w-0 items-center gap-1.5">
@@ -1112,5 +1144,6 @@ export function Sidebar() {
</aside>
)}
</>
</TooltipProvider>
);
}

View File

@@ -24,6 +24,7 @@
"whatsappFailed": "WhatsApp connection failed: {{error}}",
"qrConnected": "{{name}} connected successfully",
"qrFailed": "{{name}} connection failed: {{error}}",
"whatsappNetworkHint": "Could not connect to WhatsApp WebSocket. Check network connectivity or enable a proxy in Advanced Settings. {{error}}",
"channelSaved": "Channel {{name}} saved",
"channelConnecting": "Connecting to {{name}}...",
"savedButRefreshFailed": "Configuration was saved, but refreshing page data failed. Please refresh manually.",

View File

@@ -24,6 +24,7 @@
"whatsappFailed": "WhatsApp 接続に失敗しました: {{error}}",
"qrConnected": "{{name}} が正常に接続されました",
"qrFailed": "{{name}} の接続に失敗しました: {{error}}",
"whatsappNetworkHint": "WhatsApp WebSocket に接続できません。ネットワークを確認するか、詳細設定でプロキシを有効にしてください。{{error}}",
"channelSaved": "チャンネル {{name}} が保存されました",
"channelConnecting": "{{name}} に接続中...",
"savedButRefreshFailed": "設定は保存されましたが、画面データの更新に失敗しました。手動で再読み込みしてください。",

View File

@@ -24,6 +24,7 @@
"whatsappFailed": "Не удалось подключить WhatsApp: {{error}}",
"qrConnected": "{{name}} успешно подключён",
"qrFailed": "Не удалось подключить {{name}}: {{error}}",
"whatsappNetworkHint": "Не удалось подключиться к WhatsApp WebSocket. Проверьте сеть или включите прокси в расширенных настройках. {{error}}",
"channelSaved": "Канал {{name}} сохранён",
"channelConnecting": "Подключение к {{name}}...",
"savedButRefreshFailed": "Конфигурация сохранена, но обновление данных страницы не удалось. Пожалуйста, обновите вручную.",

View File

@@ -24,6 +24,7 @@
"whatsappFailed": "WhatsApp 连接失败: {{error}}",
"qrConnected": "{{name}} 连接成功",
"qrFailed": "{{name}} 连接失败: {{error}}",
"whatsappNetworkHint": "无法连接 WhatsApp WebSocket。请检查网络或在高级设置中开启代理。{{error}}",
"channelSaved": "频道 {{name}} 已保存",
"channelConnecting": "正在连接 {{name}}...",
"savedButRefreshFailed": "配置已保存,但刷新页面数据失败,请手动刷新查看最新状态",

View File

@@ -89,6 +89,14 @@ function parseUnifiedProxyResponse<T>(
}
const data: HostApiProxyData = response.data ?? {};
if (data.ok === false) {
const message = data.text
|| (typeof data.json === 'object' && data.json != null && 'error' in (data.json as Record<string, unknown>)
? String((data.json as Record<string, unknown>).error)
: `HTTP ${data.status ?? 'unknown'}`);
throw new Error(message);
}
trackUiEvent('hostapi.fetch', {
path,
method,

View File

@@ -32,9 +32,7 @@ export function AppCenter() {
const init = useAppCenterStore((state) => state.init);
const items = useAppCenterStore((state) => state.items);
const selectedTagKey = useAppCenterStore((state) => state.selectedTagKey);
const selectedItemId = useAppCenterStore((state) => state.selectedItemId);
const selectTag = useAppCenterStore((state) => state.selectTag);
const selectItem = useAppCenterStore((state) => state.selectItem);
useEffect(() => {
init();
@@ -127,13 +125,12 @@ export function AppCenter() {
<div className="grid grid-cols-[repeat(auto-fill,minmax(220px,260px))] justify-center gap-4 pb-2 sm:justify-start">
{filteredItems.map((item) => {
const Icon = getAppIcon(item.icon);
const isSelected = selectedItemId === item.id;
return (
<button
key={item.id}
type="button"
onClick={() => {
selectItem(item.id);
onClick={(event) => {
event.currentTarget.blur();
openItem(item);
}}
data-testid={`app-center-item-${item.id}`}
@@ -142,9 +139,7 @@ export function AppCenter() {
'hover:border-[#7DBADB] hover:bg-white hover:shadow-[0_18px_42px_rgba(15,23,42,0.09)]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1E3A8A]/30',
'dark:border-white/10 dark:bg-slate-950/75 dark:hover:shadow-[0_10px_24px_rgba(0,0,0,0.24)]',
isSelected
? 'border-[#0369A1] bg-[#F6FBFE] shadow-[0_14px_30px_rgba(3,105,161,0.10)]'
: 'border-slate-200/80',
'border-slate-200/80',
)}
>
<div className="pointer-events-none absolute inset-x-0 top-0 h-20 bg-[linear-gradient(180deg,rgba(229,244,250,0.85),rgba(255,255,255,0))] dark:bg-[linear-gradient(180deg,rgba(30,58,138,0.18),rgba(15,23,42,0))]" />

View File

@@ -32,6 +32,7 @@ import { useSettingsStore } from '@/stores/settings';
import { useGatewayStore } from '@/stores/gateway';
import { useUpdateStore } from '@/stores/update';
import { UpdateSettings } from '@/components/settings/UpdateSettings';
import { ProvidersSettings } from '@/components/settings/ProvidersSettings';
import {
getGatewayWsDiagnosticEnabled,
invokeIpc,
@@ -1109,6 +1110,12 @@ export function Settings() {
</div>
)}
{devModeUnlocked && (
<div className="yinian-panel p-4" data-testid="settings-model-config-section">
<ProvidersSettings />
</div>
)}
{devModeUnlocked && (
<div className="yinian-panel p-4" data-testid="settings-model-diagnostics">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">

View File

@@ -11,7 +11,6 @@ import {
Plus,
Play,
RefreshCw,
ShieldCheck,
Trash2,
} from 'lucide-react';
import { toast } from 'sonner';
@@ -27,12 +26,10 @@ import {
YinianEmptyState,
YinianHeaderActions,
YinianInfoRow,
YinianMetricCard,
YinianNotice,
YinianPageHeader,
YinianPageShell,
YinianPanel,
yinianAccent,
yinianPrimaryButton,
} from '@/components/yinian/ui';
import type { YinianLocalSkill, YinianSkillEntitlement } from '../../../shared/yinian';
@@ -147,12 +144,6 @@ export function YinianSkills() {
const visibleOpenClawSkills = openClawSkills
.filter((skill) => !isOpenClawBuiltInSkill(skill))
.sort((a, b) => a.name.localeCompare(b.name, i18n.language === 'zh' ? 'zh-CN' : 'en-US'));
const states = config.entitlements.map((skill) => getManagerState(skill, localById.get(skill.skillId)));
const runnableCount = states.filter(canRun).length;
const notSyncedCount = states.filter((state) => state === 'not-synced').length;
const updateCount = states.filter((state) => state === 'update-available').length;
const failedCount = states.filter((state) => state === 'failed').length;
const disabledCount = states.filter((state) => state === 'disabled').length;
const selectedServerSkill = config.entitlements.find((skill) => skill.skillId === selectedServerSkillId);
const selectedServerLocal = selectedServerSkill ? localById.get(selectedServerSkill.skillId) : undefined;
const selectedServerState = selectedServerSkill ? getManagerState(selectedServerSkill, selectedServerLocal) : null;
@@ -255,18 +246,6 @@ export function YinianSkills() {
</YinianNotice>
)}
<section className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
{[
{ label: t('business.metrics.enabled'), value: String(config.entitlements.length), icon: ShieldCheck, tone: yinianAccent },
{ label: t('business.metrics.runnable'), value: String(runnableCount), icon: CheckCircle2, tone: 'text-emerald-600' },
{ label: t('business.metrics.notSynced'), value: String(notSyncedCount), icon: CloudDownload, tone: 'text-slate-600 dark:text-slate-300' },
{ label: t('business.metrics.updates'), value: String(updateCount), icon: RefreshCw, tone: 'text-amber-600' },
{ label: t('business.metrics.issues'), value: String(failedCount + disabledCount), icon: AlertCircle, tone: failedCount ? 'text-red-600' : 'text-slate-500' },
].map((item) => (
<YinianMetricCard key={item.label} {...item} />
))}
</section>
<div className="flex flex-wrap gap-2 rounded-lg border border-slate-200/70 bg-white/60 p-1 dark:border-white/10 dark:bg-white/5">
{[
{ key: 'quickTasks' as const, label: t('business.quickTasks.title'), count: quickTasks.length },

View File

@@ -49,7 +49,7 @@ export const useAppCenterStore = create<AppCenterState>((set, get) => ({
init: () => {
if (get().initialized) return;
set({ items: BUILT_IN_APPS, selectedItemId: BUILT_IN_APPS[0]?.id ?? null, initialized: true });
set({ items: BUILT_IN_APPS, selectedItemId: null, initialized: true });
},
selectItem: (itemId) => set({ selectedItemId: itemId }),

View File

@@ -37,11 +37,13 @@ describe('App Center', () => {
expect(screen.getByTestId('app-center-item-product-center')).toHaveTextContent('旅游资源订购');
expect(screen.getByTestId('app-center-item-product-center')).toHaveTextContent('旅游资源底价订购');
expect(useAppCenterStore.getState().selectedItemId).toBeNull();
fireEvent.click(screen.getByTestId('app-center-item-product-center'));
expect(screen.getByTestId('product-center-route')).toBeVisible();
expect(window.electron.openExternal).not.toHaveBeenCalled();
expect(useAppCenterStore.getState().selectedItemId).toBeNull();
});
it('covers built-in apps in English without exposing translation keys', async () => {

View File

@@ -81,6 +81,21 @@ describe('host-api', () => {
await expect(hostApiFetch('/api/test')).rejects.toThrow('Invalid Authentication');
});
it('throws message from unified non-ok Host API responses', async () => {
invokeIpcMock.mockResolvedValueOnce({
ok: true,
data: {
status: 500,
ok: false,
json: { success: false, error: 'WhatsApp startup failed' },
},
});
const { hostApiFetch } = await import('@/lib/host-api');
await expect(hostApiFetch('/api/channels/whatsapp/start', { method: 'POST' }))
.rejects.toThrow('WhatsApp startup failed');
});
it('falls back to browser fetch only when IPC channel is unavailable', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,

View File

@@ -0,0 +1,148 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { Settings } from '@/pages/Settings';
import { useGatewayStore } from '@/stores/gateway';
import { useSettingsStore } from '@/stores/settings';
import { useYinianStore } from '@/stores/yinian';
import i18n from '@/i18n';
const hostApiFetchMock = vi.fn();
vi.mock('@/components/settings/ProvidersSettings', () => ({
ProvidersSettings: () => <div data-testid="settings-advanced-model-config-panel"></div>,
}));
vi.mock('@/components/settings/UpdateSettings', () => ({
UpdateSettings: () => null,
}));
vi.mock('@/pages/Channels', () => ({
Channels: () => null,
}));
vi.mock('@/pages/YinianSkills', () => ({
YinianSkills: () => null,
}));
vi.mock('@/lib/host-api', () => ({
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
}));
vi.mock('@/lib/api-client', () => ({
getGatewayWsDiagnosticEnabled: () => false,
invokeIpc: vi.fn().mockResolvedValue({ success: true, command: 'agent' }),
setGatewayWsDiagnosticEnabled: vi.fn(),
toUserMessage: (error: unknown) => String(error),
}));
vi.mock('@/lib/telemetry', () => ({
clearUiTelemetry: vi.fn(),
getUiTelemetrySnapshot: vi.fn(() => []),
subscribeUiTelemetry: vi.fn(() => () => undefined),
trackUiEvent: vi.fn(),
}));
const hotel = {
id: 'workspace-1',
name: '智念空间',
city: '杭州',
role: 'manager' as const,
permissions: [],
ota: [],
};
function renderRuntimeSettings() {
return render(
<MemoryRouter initialEntries={['/settings/runtime']}>
<Routes>
<Route path="/settings/*" element={<Settings />} />
</Routes>
</MemoryRouter>,
);
}
describe('Settings advanced model config', () => {
beforeEach(async () => {
vi.clearAllMocks();
await i18n.changeLanguage('zh');
hostApiFetchMock.mockImplementation((path: string) => {
if (path.startsWith('/api/diagnostics/model-config')) {
return Promise.resolve({
capturedAt: 1,
ok: true,
model: { primary: null, fallbacks: [], providerKey: null, modelId: null },
runtime: { heartbeatEvery: null, heartbeatDisabled: false },
providers: [],
authProfiles: { path: '', exists: false, providers: [] },
checks: [],
paths: { openclawConfig: '', authProfiles: '' },
});
}
if (path.startsWith('/api/diagnostics/office-runtime')) {
return Promise.resolve({
capturedAt: 1,
ok: true,
repairAttempted: false,
python: { executable: null, packages: [] },
node: { modules: [] },
dotnet: { available: false, version: null },
checks: [],
});
}
return Promise.resolve({});
});
useSettingsStore.setState({
devModeUnlocked: false,
gatewayAutoStart: false,
telemetryEnabled: true,
proxyEnabled: false,
proxyServer: '',
proxyHttpServer: '',
proxyHttpsServer: '',
proxyAllServer: '',
proxyBypassRules: '<local>',
});
useGatewayStore.setState({
status: { state: 'stopped', port: 18789, gatewayReady: false },
});
useYinianStore.setState({
status: 'authenticated',
session: {
authenticated: true,
user: { id: 'user-1', name: '王管理员' },
hotels: [hotel],
currentHotelId: hotel.id,
accessTokenExpiresAt: 100,
},
config: {
serverTime: 1,
user: { id: 'user-1', name: '王管理员' },
hotel,
hotels: [hotel],
entitlements: [],
notificationChannels: [],
featureFlags: {},
uiPolicy: { defaultPage: 'today', showAdvancedSettings: false },
},
error: null,
});
});
it('keeps model configuration settings inside advanced mode', async () => {
useSettingsStore.setState({ devModeUnlocked: true });
renderRuntimeSettings();
expect(await screen.findByTestId('settings-model-config-section')).toBeVisible();
expect(screen.getByTestId('settings-advanced-model-config-panel')).toHaveTextContent('模型配置设置');
expect(screen.getByTestId('settings-model-diagnostics')).toBeVisible();
});
it('keeps model configuration settings hidden until advanced mode is opened', () => {
renderRuntimeSettings();
expect(screen.queryByTestId('settings-model-config-section')).not.toBeInTheDocument();
expect(screen.queryByTestId('settings-advanced-model-config-panel')).not.toBeInTheDocument();
});
});

View File

@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { Sidebar } from '@/components/layout/Sidebar';
import { useChatStore } from '@/stores/chat';
@@ -144,7 +144,7 @@ describe('Sidebar layout', () => {
expect(screen.queryByText('快速使用')).not.toBeInTheDocument();
});
it('hides pinned quick tasks when collapsed and keeps hover titles on icon buttons', () => {
it('hides pinned quick tasks when collapsed and shows hover labels on icon buttons', async () => {
useSettingsStore.setState({
sidebarCollapsed: true,
devModeUnlocked: false,
@@ -159,6 +159,13 @@ describe('Sidebar layout', () => {
expect(screen.getByTestId('sidebar-chat-history')).toHaveAttribute('title', '历史会话');
expect(screen.getByTestId('sidebar-nav-tasks')).toHaveAttribute('title', '任务中心');
expect(screen.getByTestId('sidebar-nav-settings')).toHaveAttribute('title', '设置');
fireEvent.pointerMove(screen.getByTestId('sidebar-nav-tasks'), { pointerType: 'mouse' });
fireEvent.pointerEnter(screen.getByTestId('sidebar-nav-tasks'), { pointerType: 'mouse' });
await waitFor(() => {
expect(screen.getAllByText('任务中心').length).toBeGreaterThan(0);
});
});
it('does not mark history active just because a new chat is open', () => {

View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import {
isSocksProxyUrl,
redactProxyUrlForLog,
resolveWhatsAppProxyUrl,
shouldBypassWhatsAppProxy,
} from '@electron/utils/whatsapp-proxy';
import type { ProxySettings } from '@electron/utils/proxy';
function settings(overrides: Partial<ProxySettings>): ProxySettings {
return {
proxyEnabled: false,
proxyServer: '',
proxyHttpServer: '',
proxyHttpsServer: '',
proxyAllServer: '',
proxyBypassRules: '<local>;localhost;127.0.0.1;::1',
...overrides,
};
}
describe('WhatsApp proxy helpers', () => {
it('does not use a proxy when app proxy is disabled', () => {
expect(resolveWhatsAppProxyUrl(settings({
proxyEnabled: false,
proxyServer: 'http://127.0.0.1:7890',
}))).toBe('');
});
it('uses the all-proxy value first for WhatsApp WebSocket traffic', () => {
expect(resolveWhatsAppProxyUrl(settings({
proxyEnabled: true,
proxyServer: 'http://127.0.0.1:7890',
proxyHttpsServer: 'http://127.0.0.1:7892',
proxyAllServer: 'socks5://127.0.0.1:7891',
}))).toBe('socks5://127.0.0.1:7891');
});
it('falls back to HTTPS/base proxy when no all-proxy is configured', () => {
expect(resolveWhatsAppProxyUrl(settings({
proxyEnabled: true,
proxyServer: '127.0.0.1:7890',
proxyHttpsServer: '',
proxyAllServer: '',
}))).toBe('http://127.0.0.1:7890');
});
it('honors bypass rules for web.whatsapp.com', () => {
expect(shouldBypassWhatsAppProxy('*.whatsapp.com')).toBe(true);
expect(resolveWhatsAppProxyUrl(settings({
proxyEnabled: true,
proxyServer: 'http://127.0.0.1:7890',
proxyBypassRules: '*.whatsapp.com',
}))).toBe('');
});
it('detects SOCKS proxy URLs and redacts credentials for logs', () => {
expect(isSocksProxyUrl('socks5://127.0.0.1:7891')).toBe(true);
expect(isSocksProxyUrl('http://127.0.0.1:7890')).toBe(false);
expect(redactProxyUrlForLog('http://user:secret@127.0.0.1:7890'))
.toBe('http://redacted:redacted@127.0.0.1:7890/');
});
});